diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index be398df..7f18239 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -21,3 +21,6 @@ /go/cmd/vserver/ @GreenNodeHub/vserver /go/internal/vserverclient/ @GreenNodeHub/vserver /docs/commands/vserver/ @GreenNodeHub/vserver +/go/cmd/vdb/ @GreenNodeHub/vdb +/go/internal/vdbclient/ @GreenNodeHub/vdb +/docs/commands/vdb/ @GreenNodeHub/vdb diff --git a/.gitignore b/.gitignore index 803e60e..e23aee8 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,5 @@ build/ # Internal design docs (local only, not for public repo) docs/superpowers/ +docs/api-docs-local/ diff --git a/CLAUDE.md b/CLAUDE.md index d35c8fb..e361616 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,6 +29,38 @@ go/ │ │ ├── helpers.go # mustLoadConfig / newAuthProvider │ │ ├── tools.go # tools group: built-in browsers (+ sessions, streams, recording; delete has confirm/--force/--dry-run) │ │ └── tools_code_interpreter.go # tools code-interpreter + sessions + session invoke (+ async task-wait) +│ ├── vdb/ # grn vdb (behind !vks_only tag, in development) +│ │ ├── vdb.go # VdbCmd parent + --user-type persistent flag +│ │ ├── CLAUDE.md # vDB API quirks (differ from BOTH vks and vserver) +│ │ ├── relational/ # /vdb-relational — one subpackage per engine family +│ │ │ ├── instance/ # 18 commands: read + lifecycle (create/resize/start/ +│ │ │ │ # stop/reboot/delete) + replicas + settings/secrules +│ │ │ ├── catalog/ # read-only lookups: engines/datastores/flavors/families/ +│ │ │ │ # codes/zones/networks/subnets/volume-types/config-groups +│ │ │ ├── backup/ # list/get/create/delete/restore/get-free-storage +│ │ │ ├── configuration/ # config groups: list/get/create/update/delete/list-params +│ │ │ └── backupstorage/ # quota: list/list-packages/create/resize/delete +│ │ ├── memorystore/ # MemoryStore (Redis): 42 commands, same 5 nouns as relational +│ │ │ │ # but almost every path differs; no volume/resize-storage +│ │ │ ├── instance/ # 16 commands (master password instead of a db user) +│ │ │ ├── catalog/ # 9 lookups under /database/* (no zones/subnets) +│ │ │ ├── backup/ configuration/ backupstorage/ +│ │ ├── postgresql/ # PostgreSQL Cluster: /vdb-postgresql + 7 relational endpoints +│ │ │ ├── cluster/ # list/get/histories/secrules/create/resize/settings/ +│ │ │ │ # config-group/reboot/delete (+ pg- ID guard) +│ │ │ ├── catalog/ # versions/flavors/volume-types/config-groups/backup opts +│ │ │ └── backup/ # list/get/list-restore-points/create (keyed by CLUSTER id) +│ │ └── kafka/ # Kafka: 36 commands / 35 endpoints, /vdb-kafka (NO /v1) +│ │ │ # envelope on 9/36 only, no pagination, no backups +│ │ ├── cluster/ # 14: read + create/delete + resize-brokers/resize-storage/ +│ │ │ # update-volume-type + authentication/config-group/ +│ │ │ # public-access + secrule create/delete (list-secrules +│ │ │ # reads cluster get — the API has no endpoint) +│ │ ├── topic/ # list/get/create/update/delete (under a cluster) +│ │ ├── user/ # + get-creds/generate-creds; permissions by topic NAME +│ │ ├── configuration/ # VERSIONED config groups: create-version instead of update +│ │ └── catalog/ # flavors/families/codes/volume-types (versions come from +│ │ # /database/configs, which has no command of its own) │ ├── configure/ │ │ ├── configure.go # Interactive setup │ │ ├── list.go # grn configure list @@ -77,6 +109,10 @@ go/ │ ├── resources/ │ │ └── vserver/ │ │ └── vserver.go # vserver resource completers (vpc/subnet/ssh-key/security-group/disk-type) +│ ├── vdbclient/ +│ │ ├── client.go # BuildClient(cmd) + user-type header +│ │ ├── query.go # BuildListQuery — 1-based pageNumber, flat filters +│ │ └── output.go # Unwrap({code,message,data}) + Output/OutputWithColumns │ ├── agentbase/ # self-contained agentbase stack (own auth/config/client) │ │ ├── auth/ # OAuth2 v2 clientcredentials │ │ ├── client/ # bearer-token HTTP client (401 refresh + retry-once, mirrors internal/client) @@ -110,8 +146,24 @@ scripts/ ## GreenNode API quirks - **IAM API uses camelCase**: `grantType`, `accessToken`, `expiresIn` -- **VKS API pagination is 0-based**: page 0 = first page - **`--version` conflict**: Use `--k8s-version` for Kubernetes version +- **Pagination differs per service** — check before writing a list command: + + | Service | Params | First page | + |---------|--------|------------| + | vks | `page`, `pageSize` | `0` | + | vserver | `page`, `size` | `1` | + | vdb | `pageNumber`, `pageSize` | `1` | + | vdb Kafka | — | **no pagination at all** | + +- **vDB wraps every response** in `{code, message, data}`, with a second `data` + nested inside for paginated lists — except Kafka, which has no consistent + envelope. Use `vdbclient.Unwrap`; see `go/cmd/vdb/CLAUDE.md`. +- **`client.GreennodeClient` has `Request`/`RequestRaw`** for method+params+body in + one call, and for responses that must not be JSON-parsed. Both were added for vDB + Kafka, whose mutating endpoints take their arguments in the query string of a + body-less PUT and answer with an empty body. The verb helpers are still the right + choice everywhere else. ## Adding a new command @@ -132,7 +184,25 @@ Static command/flag completion is automatic (cobra: `grn completion `). F - API-backed: `cli.FlagFromAPI(func(ctx, cmd) ([]string,error))` — bounded timeout, fails silently - Cross-service resource: consumer uses `cli.ResourceCompletion(":")`; the owning service registers a provider via `cli.RegisterResourceCompleter(":", ...)` (e.g. `internal/resources/vserver/`, blank-imported in `cmd/register.go`) -VKS wires its flags centrally in `cmd/vks/completion.go` `registerCompletions()`. +VKS wires its flags centrally in `cmd/vks/completion.go` `registerCompletions()`, +called from `vks.go`'s `init()`. That works because every direct subcommand of +`VksCmd` lives in a file sorted before `vks.go`, so its flags already exist — +`init()` functions run in filename order within a package. (`wait.go` sorts after, +but its flags are on *sub*subcommands, which `registerCompletions` does not walk; +`grn vks wait … --cluster-id` therefore has no value completion.) + +**Prefer binding a flag to its completer in the `init()` of the file that defines +the flag** — that is what `cmd/vdb/**` does. A central binding that +runs too early fails inside `RegisterFlagCompletionFunc`, and the error is +discarded by the usual `//nolint:errcheck`, leaving completion that looks wired +but does nothing. Assert bindings with `cmd.GetFlagCompletionFunc` in a test. + +Better still, assert the whole tree: `cmd/vdb/completion_test.go` walks every command +under `VdbCmd` and fails for any id/version/zone/package flag with no completer, with an +explicit exception list. It caught 11 missing bindings that per-file tests had not covered. +Note that a bound completer can still return nothing — ids that arrive as JSON numbers need +`vdbclient.ExtractIDValues`, since `cli.ExtractIDs` collects only strings — so confirm new +completers with `grn __complete …`. ## Adding a new service @@ -236,6 +306,8 @@ Code without docs is not done. | `cmd/root.go` | Root command, global flags, --version | | `cmd/vks/helpers.go` | Client creation, output formatting, label/taint parsing | | `internal/config/config.go` | Config loading from ~/.greennode/, REGIONS map | +| `internal/vdbclient/` | vDB client + error enrichment, envelope unwrap, 1-based list query, ID guard, action/resize bodies, security-rule parsing, dry-run preview | +| `go/cmd/vdb/CLAUDE.md` | vDB API quirks — read before touching `grn vdb` | | `internal/config/writer.go` | INI file writer with 0600 perms | | `internal/auth/token.go` | TokenManager — OAuth2 with IAM (camelCase) | | `internal/client/client.go` | HTTP client with retry (3x backoff) + 401 refresh | diff --git a/docs/commands/vdb/index.md b/docs/commands/vdb/index.md new file mode 100644 index 0000000..defb52b --- /dev/null +++ b/docs/commands/vdb/index.md @@ -0,0 +1,134 @@ +# vDB commands + +`grn vdb` — manage GreenNode Database (vDB) resources. + +```bash +grn vdb [options] +``` + +vDB covers four database products that share a gateway but not an API, so the +command tree keeps them apart: + +| Group | Product | Status | +|-------|---------|--------| +| [relational](relational-instance.md) | Relational Database (MySQL, PostgreSQL, MariaDB) | Available | +| [postgresql](postgresql-cluster.md) | PostgreSQL Cluster (2–10 nodes) | Available | +| [memorystore](memorystore-instance.md) | MemoryStore (Redis) | Available | +| [kafka](kafka-cluster.md) | Kafka | Available | + +## Commands + +| Command | Description | +|---------|-------------| +| [relational instance](relational-instance.md) | Manage Relational Database instances | +| [relational catalog](relational-catalog.md) | Look up engines, flavors, zones, networks and volume types | +| [relational backup](relational-backup.md) | Manage backups and restore them into new instances | +| [relational configuration](relational-configuration.md) | Manage database config groups | +| [relational backup-storage](relational-backup-storage.md) | Buy and manage backup storage quota | +| [postgresql cluster](postgresql-cluster.md) | Manage PostgreSQL Clusters | +| [postgresql catalog](postgresql-catalog.md) | Look up cluster versions, flavors, volume types and backup options | +| [postgresql backup](postgresql-backup.md) | Manage PostgreSQL Cluster backups | +| [memorystore instance](memorystore-instance.md) | Manage MemoryStore (Redis) instances | +| [memorystore catalog](memorystore-catalog.md) | Look up Redis versions, flavors, networks and config groups | +| [memorystore backup](memorystore-backup.md) | Manage MemoryStore backups and restore them into new instances | +| [memorystore configuration](memorystore-configuration.md) | Manage Redis config groups | +| [memorystore backup-storage](memorystore-backup-storage.md) | Buy and manage backup storage quota | +| [kafka cluster](kafka-cluster.md) | Manage Kafka clusters | +| [kafka topic](kafka-topic.md) | Manage the topics of a cluster | +| [kafka user](kafka-user.md) | Manage users and their credentials | +| [kafka configuration](kafka-configuration.md) | Manage versioned Kafka config groups | +| [kafka catalog](kafka-catalog.md) | Look up broker flavors and volume types | + +## Choosing between `relational` and `postgresql` + +Both run PostgreSQL, but they are separate products with separate APIs: + +- **`grn vdb relational`** — single instances (also MySQL and MariaDB). IDs start + with `db-`. +- **`grn vdb postgresql`** — clusters of 2–10 nodes, with their own flavors, volume + types and backup service. IDs start with `pg-`. + +`grn vdb relational instance list` returns **both**, because the cluster product +has no listing of its own; `grn vdb postgresql cluster list` shows only the +clusters. Values are not interchangeable between the two: a cluster flavor +(`pgp-...`) is rejected by a relational create and vice versa. + +## Region availability + +vDB is currently available in **HCM-3 only**. Running a `grn vdb` command with +`--region HAN` fails with an endpoint-not-found error naming the service and +region. + +## Global options + +`--user-type` (string) +: Billing flow used by chargeable operations (create, resize, restore). +Possible values: `ROOT_USER` (Checkout flow), `IAM_USER` (Auto Payment flow). +When omitted, the API applies its own default of `ROOT_USER`. + +All [global options](../../usage/global-options.md) apply as well, including +`--output`, `--query`, `--profile` and `--region`. + +## MemoryStore vs Relational Database + +MemoryStore has the same five nouns as Relational Database, and two product differences +that change what the commands offer: + +- **No volume.** A MemoryStore instance's capacity is its flavor's RAM, so there are no + storage flags and no `resize-storage` — growing means a larger flavor. +- **A master password, not a database user.** Access is guarded by + `redisPasswordEnabled` + `redisPassword` on the instance, and public access requires + it. See [Redis authentication](memorystore-instance.md#redis-authentication). + +Zones and subnets come from the **relational** catalog: MemoryStore has no endpoint for +either, and they are project-wide. + +Instance IDs use the `db-` prefix in both products, so an ID alone does not tell you +which one it belongs to — the listings do, and they are separate. + +### Paths that differ + +Useful when porting a script or reading the API directly. Each row is the same +operation spelled two ways: + +| Operation | relational | memorystore | +|---|---|---| +| get instance | `/database-instances/id/{id}` | `/database-instances/{id}` | +| update settings | `/{id}/update/setting` | `/{id}/update-setting` | +| update config group | `/{id}/update/config-group` | `/{id}/update-config-group` | +| catalog root | `/database-instances/*` | `/database/*` | +| flavor codes | `/database-instances/flavor_zones/codes` | `/database/codes` | +| volume types | `/database-instances/volume/types` | `/database/volume-types` | +| backup detail | `/backups/detail/{id}` | `/backups/{id}/detail` | +| backup delete | `DELETE /backups/{id}/delete` | `POST /backups/delete` (no ID in the path) | +| backups of an instance | `/backups/insId/{id}` | `/database-instances/{id}/backups` | +| config group detail | `GET /configurations/id?id=` | `GET /configurations/{id}/detail` | +| config group delete | `DELETE /configurations/delete` | `POST /configurations/delete` | +| **storage you own** | `/backup-storages/information` | **`/backup-storages`** | +| **storage you can buy** | `/backup-storages` | **`/backup-storages/packages`** | +| release storage | `/actions/deletions` | `/actions/delete` | + +The last three invert: `GET /backup-storages` means "what I can buy" in one product and +"what I own" in the other. + +## Kafka is not shaped like the other three + +`grn vdb kafka` shares the gateway and almost nothing else. Do not carry habits from +the other groups over: + +- **No backups, no replicas.** Kafka is the one vDB product with no backup service, so + deleting a cluster or a topic loses the data outright. +- **No pagination and no server-side filters** on any listing. `cluster list --name` and + `--status` are applied by the CLI to a complete response. +- **The nouns are different**: topics, users with per-topic permissions, and config + groups that are **versioned** — a cluster attaches to a config group *version*, and a + group is changed by adding a version, never edited in place. +- **Security rules are per rule**, added with `create-secrule` and removed with + `delete-secrule`, where relational and memorystore replace the whole set at once. There + is no endpoint that lists them; `cluster list-secrules` reads them out of the cluster. +- **Identifiers are opaque strings**: `clus-`, `topic-`, `user-`, `cgroup-`, + `cgroupver-`, and — for `--flavor-id` and `--volume-type` — `flav-` and `vtype-`. + Relational Database's numeric flavor id and named volume type have no equivalent here. +- **Broker count, volume size and volume type are three separate chargeable + operations** (`resize-brokers`, `resize-storage`, `update-volume-type`), not one + resize. diff --git a/docs/commands/vdb/kafka-catalog.md b/docs/commands/vdb/kafka-catalog.md new file mode 100644 index 0000000..2669286 --- /dev/null +++ b/docs/commands/vdb/kafka-catalog.md @@ -0,0 +1,113 @@ +# kafka catalog + +Read-only lookups for what a Kafka cluster can be built from. + +```bash +grn vdb kafka catalog [options] +``` + +## Commands + +| Command | Description | +|---------|-------------| +| [list-flavors](#list-flavors) | Broker flavors | +| [list-families](#list-families) | Instance families | +| [list-flavor-codes](#list-flavor-codes) | CPU platform codes | +| [list-volume-types](#list-volume-types) | Volume types | + +Kafka's catalog is smaller than the other products'. It has **no** engine, datastore, +zone, network, subnet or config-group lookup: + +- **Versions** come from shell completion on `--kafka-version` — see + [Kafka versions](#kafka-versions) below. +- **Networks and subnets** come from + [`grn vdb relational catalog list-networks`](relational-catalog.md) and + `list-subnets`; they are project-wide and Kafka has no endpoint for them. +- **Config groups** have their own noun, + [`kafka configuration`](kafka-configuration.md), because they are versioned. + +None of these endpoints takes a `--zone-id`, unlike their relational and memorystore +counterparts. + +--- + +## list-flavors + +``` +grn vdb kafka catalog list-flavors [--datastore-type kafka] [--datastore-version ] +``` + +Columns: `flavorId`, `id`, `name`, `vcpus`, `ram`, `priceKey`, `description`, +`locateZoneId`. + +!!! important "`cluster create --flavor-id` takes `flavorId`, not `id`" + The value is the opaque `flav-…` string. The numeric `id` is the identifier + Relational Database's `--package-id` uses; passing it here is wrong. + +`--datastore-type` filters, and `kafka` is the only value that matches anything +(`?type=bogus` returns none; `kafka` and `Kafka` both return everything — the case +inconsistency vDB has elsewhere too). + +`--datastore-version` is accepted by the API and currently has **no effect**: the same +flavors come back for a real version and for a made-up one, and the rows carry no +version field to filter on client-side (verified live 2026-08-17). Completion for the +flag still offers the real versions. + +--- + +## list-families + +``` +grn vdb kafka catalog list-families +``` + +Columns: `group`, `key`, `value`, `name`, `description`. In practice these are zone/ +family records with `key` and `value` null. + +--- + +## list-flavor-codes + +``` +grn vdb kafka catalog list-flavor-codes +``` + +The CPU platform codes flavors are grouped by (`code-a`, `code-s`, `code-s2`). +Columns: `key`, `value`, `familyType`, `description`. + +--- + +## list-volume-types + +``` +grn vdb kafka catalog list-volume-types +``` + +Columns: `kafkaUuid`, `type`, `displayName`, `minVolumeSize`, `maxVolumeSize`, `iops`, +`zoneId`. + +!!! important "`cluster create --volume-type` takes `kafkaUuid`" + The value is the opaque `vtype-…` string, **not** the display `type` + (`kafka.Gen2-NVMe2-IOPS3000`) and not the numeric `id`. This is the opposite of + Relational Database, where the volume type is passed by name. + +This is the one Kafka catalog endpoint whose payload is not a bare array: the envelope's +`data` is `{projectId, data: [...]}`. The CLI strips that second layer, so the output is +the volume types themselves. + +--- + +## Kafka versions + +There is no command that lists Kafka versions, because there is no user-facing endpoint +that carries them. The only source is `GET /vdb-kafka/database/configs`, which returns +the service's own settings — regexes, per-user quotas, forced broker properties — and is +deliberately not exposed as a command. + +The CLI reads that endpoint for one value, `kafkaVersions`, and uses it for shell +completion of `--kafka-version` and `--datastore-version`: + +```bash +grn vdb kafka cluster create --kafka-version +3.6.0 3.6.1 3.7.0 +``` diff --git a/docs/commands/vdb/kafka-cluster.md b/docs/commands/vdb/kafka-cluster.md new file mode 100644 index 0000000..11688bf --- /dev/null +++ b/docs/commands/vdb/kafka-cluster.md @@ -0,0 +1,283 @@ +# kafka cluster + +Manage vDB Kafka clusters. + +```bash +grn vdb kafka cluster [options] +``` + +A cluster is 3–10 brokers sharing one flavor, one volume type and one volume size per +broker. Cluster IDs start with `clus-`. + +## Commands + +| Command | Description | +|---------|-------------| +| [list](#list) | List clusters | +| [get](#get) | Show one cluster | +| [list-histories](#list-histories) | Action history | +| [list-secrules](#list-secrules) | Security rules of a cluster | +| [create](#create) | Create a cluster (**costs money**) | +| [delete](#delete) | Delete a cluster | +| [resize-brokers](#resize-brokers) | Change the broker count (**costs money**) | +| [resize-storage](#resize-storage) | Change the volume size (**costs money**) | +| [update-volume-type](#update-volume-type) | Change the volume type (**costs money**) | +| [update-authentication](#update-authentication) | Turn mTLS / SASL on or off | +| [update-config-group](#update-config-group) | Apply a config group version | +| [update-public-access](#update-public-access) | Attach or detach public addresses | +| [create-secrule](#create-secrule) | Allow a remote IP on a port | +| [delete-secrule](#delete-secrule) | Remove one rule | + +--- + +## list + +``` +grn vdb kafka cluster list [--name ] [--status ] +``` + +The endpoint takes **no pagination and no filters** — it returns every cluster at once, +so there is no `--page`, and `--name` (substring) and `--status` (comma-separated) are +applied by the CLI after the response. That also means the result is the complete match +set, never one page of it. + +Columns: `id`, `name`, `status`, `kafkaVersion`, `kafkaBrokerCount`, `kafkaStorageType`, +`kafkaStorageSize`, `vcpus`, `ram`, `createdAt`. + +!!! note "`securityGroupRules` is not in the listing" + The listing omits it even though the schema declares it. Use + [list-secrules](#list-secrules), which reads the cluster detail. + +--- + +## get + +``` +grn vdb kafka cluster get --cluster-id +``` + +Printed as key/value: a cluster nests four arrays (`fixedIps`, `floatingIps`, +`securityGroupRules`, `kafkaStorageUsage`). + +This is the only place the security rules and the attached `configGroupVersionId` are +visible — Kafka has no separate endpoint for either. + +`createdAt` here is ISO-8601 UTC (`2026-08-17T03:05:03.000+00:00`), while the **listing** +returns a localized display string (`Aug 17, 2026, 10:05:03 AM`, GMT+7). Both are +passed through unchanged; never compare a value from one against a value from the other. + +--- + +## list-histories + +``` +grn vdb kafka cluster list-histories --cluster-id +``` + +Kafka records history in its own shape: `startedAt` / `finishedAt` rather than the +`createdTime` / `updatedTime` the other vDB products use, plus `clusterId`. No +pagination. + +Columns: `id`, `action`, `description`, `status`, `startedAt`, `finishedAt`, +`errorMessage`. `description` earns its width — it carries the parameters of the change +(`Name: nhontt-test, Version: 3.7.0, Flavor: …, Storage: … 20 GB, Config group: null`). + +--- + +## list-secrules + +``` +grn vdb kafka cluster list-secrules --cluster-id +``` + +**Kafka has no endpoint that lists security rules.** This command reads the cluster and +prints the `securityGroupRules` nested in it. Columns: `id`, `remoteIp`, `port`, +`status`, `createdAt`. + +The IDs here are what [delete-secrule](#delete-secrule) expects. + +--- + +## create + +``` +grn vdb kafka cluster create + --name + --kafka-version + --flavor-id + --volume-type + --volume-size + --network-id + --subnet-id + [--broker-count 3] + [--vserver-project-id ] + [--mtls-authen] [--sasl-authen] + [--config-group-version-id ] + [--encryption-volume] + [--dry-run] [--force] +``` + +!!! danger "This costs money" + An order/payment flow, completed asynchronously. Run `--dry-run` first, then poll + [get](#get) until the status is `ACTIVE`. + +**The identifiers are opaque strings, not the numeric ones Relational Database uses:** + +| Flag | Value | Where from | +|---|---|---| +| `--flavor-id` | `flav-…` | the `flavorId` column of [`catalog list-flavors`](kafka-catalog.md#list-flavors) — **not** `id` | +| `--volume-type` | `vtype-…` | the `kafkaUuid` column of [`catalog list-volume-types`](kafka-catalog.md#list-volume-types) — **not** `type` | +| `--kafka-version` | `3.7.0` | shell completion; see [catalog](kafka-catalog.md#kafka-versions) | +| `--config-group-version-id` | `cgroupver-…` | a config group **version**, from [`configuration get`](kafka-configuration.md#get) | + +`--broker-count` is 3–10, checked before the order is placed. + +`--vserver-project-id` defaults to the profile's `project_id`, so it rarely needs +passing; if neither is set the command errors before placing the order — set +`project_id` in `~/.greennode/config` (or `GRN_DEFAULT_PROJECT_ID`) or pass the flag. +`--network-id` and `--subnet-id` complete from the **relational** catalog — +Kafka has no networks endpoint and both are project-wide. + +**At least one of `--mtls-authen` / `--sasl-authen` is required.** The API rejects a +cluster with neither (`Need to enable at least one authentication mode`), and the CLI +enforces it before the order is placed. + +--- + +## delete + +``` +grn vdb kafka cluster delete --cluster-id [--dry-run] [--force] +``` + +Prints what it is about to delete, then confirms. **Kafka is the one vDB product with no +backup service**, so the topics and their data go with the cluster and there is nothing +to restore from. + +--- + +## resize-brokers + +``` +grn vdb kafka cluster resize-brokers --cluster-id --broker-count <3-10> + [--rebalance] [--dry-run] [--force] +``` + +!!! danger "This costs money" + Order/payment flow, asynchronous. + +`--rebalance` moves existing partitions onto the new broker set. Without it the brokers +change but the partitions stay where they are, so a newly added broker carries no +traffic until a topic is created or rebalanced. + +A resize to the count the cluster already has is refused client-side: the API accepts +such an order and then fails it in the background with an empty change description. + +The arguments travel in the **query string** (`?count=&rebalance=`), so `--dry-run` +prints the query rather than a JSON body. + +--- + +## resize-storage + +``` +grn vdb kafka cluster resize-storage --cluster-id --volume-size + [--dry-run] [--force] +``` + +!!! danger "This costs money" + Order/payment flow, asynchronous. + +The size is **per broker**. Unlike Relational Database, where one resize carries both +size and type, Kafka splits them — use [update-volume-type](#update-volume-type) for the +type. A no-op size is refused for the same reason as above. + +--- + +## update-volume-type + +``` +grn vdb kafka cluster update-volume-type --cluster-id --volume-type + [--dry-run] [--force] +``` + +!!! danger "This costs money" + Despite the `update-` name this is an order/payment flow like the two resizes, and + it completes asynchronously. + +Takes the `kafkaUuid` (`vtype-…`) from +[`catalog list-volume-types`](kafka-catalog.md#list-volume-types). + +--- + +## update-authentication + +``` +grn vdb kafka cluster update-authentication --cluster-id + [--mtls-authen] [--sasl-authen] [--dry-run] [--force] +``` + +!!! warning "This can cut off every client" + Turning a mechanism off invalidates the credentials that use it. Turning both off + leaves the cluster accepting unauthenticated clients, and the confirmation prompt + says so explicitly. + +The endpoint takes **both** switches on every call, so a flag you leave out keeps the +cluster's current value — the command reads the cluster first to fill it in. Without +that, changing one would silently turn the other off. + +--- + +## update-config-group + +``` +grn vdb kafka cluster update-config-group --cluster-id + --config-group-version-id [--dry-run] [--force] +``` + +Takes a config group **VERSION** (`cgroupver-…`), not a group. Kafka config groups are +versioned and a cluster attaches to one specific version — see +[kafka configuration](kafka-configuration.md). + +--- + +## update-public-access + +``` +grn vdb kafka cluster update-public-access --cluster-id [--enable] + [--dry-run] [--force] +``` + +`--enable` attaches the cluster's public addresses; omitting it (or `--enable=false`) +detaches them. Turning it on exposes the brokers to the internet, filtered only by the +security rules — check [list-secrules](#list-secrules) first. + +--- + +## create-secrule + +``` +grn vdb kafka cluster create-secrule --cluster-id + --remote-ip --port [--dry-run] [--force] +``` + +Rules are added and removed **one at a time** here, one endpoint each — unlike +Relational Database and MemoryStore, where a single call replaces the whole rule set. +Existing rules are untouched. + +`--port` must be one of `9092`, `9094`, `9096`, `9194`, `9196`; the CLI checks it so a +typo names the flag rather than arriving as a 400. + +Returns the created rule, including the ID [delete-secrule](#delete-secrule) needs. + +--- + +## delete-secrule + +``` +grn vdb kafka cluster delete-secrule --cluster-id --secrule-id + [--dry-run] [--force] +``` + +Run [list-secrules](#list-secrules) for the IDs. Clients covered only by this rule lose +access as soon as it is gone. diff --git a/docs/commands/vdb/kafka-configuration.md b/docs/commands/vdb/kafka-configuration.md new file mode 100644 index 0000000..2f3fc0b --- /dev/null +++ b/docs/commands/vdb/kafka-configuration.md @@ -0,0 +1,128 @@ +# kafka configuration + +Config groups hold Kafka broker properties. + +```bash +grn vdb kafka configuration [options] +``` + +!!! info "Kafka config groups are versioned and immutable" + A **group** (`cgroup-…`) is a named container. A **version** (`cgroupver-…`) holds + the properties. A cluster is attached to a specific *version*, not to the group. + + So there is **no `update` command**: changing a property means + [create-version](#create-version) followed by + [`cluster update-config-group`](kafka-cluster.md#update-config-group). This is the + opposite of Relational Database and MemoryStore, where a group is edited in place. + +Two other differences from the other products: properties are a **list of key/value +pairs**, not a map; and Kafka has no endpoint listing the valid property names, so there +is no `list-params` and no completion for them. + +## Commands + +| Command | Description | +|---------|-------------| +| [list](#list) | List config groups | +| [get](#get) | Show one group and all its versions | +| [get-version](#get-version) | Show one version | +| [create](#create) | Create a group with its first version | +| [create-version](#create-version) | Add a version to a group | +| [delete](#delete) | Delete a group and every version | + +--- + +## list + +``` +grn vdb kafka configuration list +``` + +No pagination. Columns: `id`, `name`, `description`, `status`, `createdAt`. Each group's +versions come back **nested in its row**, so use `--output json` to see them — or +[get](#get) for one group. + +--- + +## get + +``` +grn vdb kafka configuration get --config-group-id +``` + +Printed as key/value, since the versions are nested. + +This is where the version IDs for +[`cluster update-config-group`](kafka-cluster.md#update-config-group) come from. + +--- + +## get-version + +``` +grn vdb kafka configuration get-version --config-group-id + --config-group-version-id +``` + +Shows the version's `properties` plus `associatedClusterIds` / +`associatedClusterNames` — which answer "what breaks if I change this?". Check them +before applying a different version to a cluster. + +--- + +## create + +``` +grn vdb kafka configuration create --name + --property [--property ...] + [--description ] +``` + +Creating a group is free and touches no cluster, so there is no confirmation prompt. + +```bash +grn vdb kafka configuration create --name broker-tuning \ + --property num.partitions=3 \ + --property log.retention.hours=168 +``` + +Kafka has no endpoint listing the valid property names, so they are neither validated +nor completed here — a wrong key is rejected by the API. Note the service forces some +properties regardless of what a group says (`auto.create.topics.enable`, +`allow.everyone.if.no.acl.found`). + +--- + +## create-version + +``` +grn vdb kafka configuration create-version --config-group-id + [--property ...] [--from-version ] + [--dry-run] [--force] +``` + +This is how a config group is "edited". Creating a version changes nothing on any +cluster by itself — apply it with +[`cluster update-config-group`](kafka-cluster.md#update-config-group). + +!!! warning "A version is the whole property set" + The properties given here do **not** merge with the previous version. Pass + `--from-version` to start from an existing version's properties and override only + what `--property` sets. + +```bash +# Copy version N and change one property +grn vdb kafka configuration create-version --config-group-id cgroup-... \ + --from-version cgroupver-... --property num.partitions=6 +``` + +--- + +## delete + +``` +grn vdb kafka configuration delete --config-group-id [--dry-run] [--force] +``` + +Deletes the group and every version of it. Check [get](#get) first: a version in use by +a cluster is listed in its `associatedClusterIds`. diff --git a/docs/commands/vdb/kafka-topic.md b/docs/commands/vdb/kafka-topic.md new file mode 100644 index 0000000..4adf72d --- /dev/null +++ b/docs/commands/vdb/kafka-topic.md @@ -0,0 +1,105 @@ +# kafka topic + +Manage the topics of a vDB Kafka cluster. + +```bash +grn vdb kafka topic --cluster-id [options] +``` + +Topics live under a cluster, so **every command takes `--cluster-id`**. Topic IDs start +with `topic-`. + +## Commands + +| Command | Description | +|---------|-------------| +| [list](#list) | List the topics of a cluster | +| [get](#get) | Show one topic | +| [create](#create) | Create a topic | +| [update](#update) | Change partitions, replicas or retention | +| [delete](#delete) | Delete a topic | + +--- + +## list + +``` +grn vdb kafka topic list --cluster-id +``` + +No pagination and no filters — this is the complete list. Columns: `id`, `name`, +`partitions`, `replicas`, `retentionSeconds`, `retentionBytes`, `status`, `createdAt`. + +--- + +## get + +``` +grn vdb kafka topic get --cluster-id --topic-id +``` + +`createdAt` is ISO-8601 UTC here and a localized display string in the listing, as +everywhere in the Kafka API. + +--- + +## create + +``` +grn vdb kafka topic create --cluster-id + --name --partitions <1-2048> --replicas + [--retention-seconds <3600-7776000>] + [--retention-bytes ] +``` + +`--replicas` cannot exceed the cluster's broker count. + +Retention is **optional and omitted when not given**, so Kafka applies its own defaults; +the topic then reads back with `retentionSeconds` and `retentionBytes` as `null`. +`--retention-bytes` accepts `-1` for unlimited. + +Names must match the service's own pattern — at least 6 characters, alphanumeric plus +`- _ .`, starting and ending alphanumeric. + +Returns the created topic, whose status starts at `WAITING_CREATING` and reaches +`ACTIVE` after roughly half a minute. **The cluster must be `ACTIVE`**: a create against +a busy cluster is refused with `Cluster … is not active`. + +--- + +## update + +``` +grn vdb kafka topic update --cluster-id --topic-id + [--partitions ] [--replicas ] + [--retention-seconds ] [--retention-bytes ] + [--dry-run] [--force] +``` + +!!! warning "The request carries the whole set" + Flags you leave out keep their current values — the command reads the topic first + and repeats them. Without that, omitting a field would reset it. A retention field + that is `null` on the topic and not passed here is left **out** of the request + rather than sent as 0, which the API would reject. + +Partition and replica counts normally only go **up** in Kafka, and increasing partitions +changes which partition a key lands on, so consumers relying on key ordering are +affected. + +The response carries no body; the command reports the outcome and points at +[get](#get). The topic passes through `WAITING_UPDATING` / `UPDATING` before the change +shows. + +--- + +## delete + +``` +grn vdb kafka topic delete --cluster-id --topic-id + [--dry-run] [--force] +``` + +Irreversible — Kafka has no backup service, so every record in the topic is lost. + +**The topic must be `ACTIVE`.** Deleting one that is still `UPDATING` is refused with +`Topic … does not have appropriate status`; poll [get](#get) first. diff --git a/docs/commands/vdb/kafka-user.md b/docs/commands/vdb/kafka-user.md new file mode 100644 index 0000000..f83cd58 --- /dev/null +++ b/docs/commands/vdb/kafka-user.md @@ -0,0 +1,155 @@ +# kafka user + +Manage the users of a vDB Kafka cluster and their credentials. + +```bash +grn vdb kafka user --cluster-id [options] +``` + +Users are a **Kafka-only concept in vDB**. MemoryStore authenticates with a single +master password set through its instance settings, and Relational Database creates +database users as part of the instance order — neither has anything like this. User IDs +start with `user-`. + +## Commands + +| Command | Description | +|---------|-------------| +| [list](#list) | List the users of a cluster | +| [get](#get) | Show one user and its permissions | +| [get-creds](#get-creds) | Read a user's credentials — written to a ZIP file (**secret**) | +| [create](#create) | Create a user | +| [update](#update) | Replace a user's permissions | +| [delete](#delete) | Delete a user | +| [generate-creds](#generate-creds) | Reissue a user's credentials | + +## Permissions + +A user holds four kinds of permission, each as a pair: a list of topic **names** and an +"all topics" switch. + +| Flags | Request fields | +|---|---| +| `--produce-topics`, `--produce-all` | `produceTopicNames`, `produceAll` | +| `--consume-topics`, `--consume-all` | `consumeTopicNames`, `consumeAll` | +| `--produce-consume-topics`, `--produce-consume-all` | `produceConsumeTopicNames`, `produceConsumeAll` | +| `--admin-topics`, `--admin-all` | `adminTopicNames`, `adminAll` | + +Topics are referenced by **NAME**, not by ID — the one place in the Kafka API that does +so — and shell completion offers names accordingly. Each `--*-all` switch **overrides** +its list: the API ignores the topic names when the matching switch is true, so passing +both is not an error, just a list that does nothing. + +A user can only use a mechanism the cluster itself accepts. `--sasl-authen` on a user +does nothing until [`cluster update-authentication`](kafka-cluster.md#update-authentication) +enables SASL on the cluster. + +--- + +## list + +``` +grn vdb kafka user list --cluster-id +``` + +Columns: `id`, `name`, `status`, `produceAll`, `consumeAll`, `produceConsumeAll`, +`adminAll`, `mtlsAuthen`, `saslAuthen`, `createdAt`. The four topic-name lists are +arrays and do not fit a table — use `--output json`, or [get](#get) for one user. + +--- + +## get + +``` +grn vdb kafka user get --cluster-id --user-id +``` + +Printed as key/value: a user nests four arrays. + +--- + +## get-creds + +``` +grn vdb kafka user get-creds --cluster-id --user-id + [--output-file ] +``` + +The answer for an mTLS user is a **ZIP archive** of the credential material +(`ca.crt`, `ca.p12`, `user.crt`, `user.key`, `user.p12`, `user.password`, +`ca.password`, `config.properties` under `/mtls/`), so it cannot be +printed: the CLI writes it to `--output-file`, defaulting to +`kafka-creds-.zip` in the current directory, with `0600` permissions. + +!!! danger "The output is secret" + The archive carries private keys and passwords. Do not paste it into a ticket + or a shared log, and mind where the default file lands. + +--- + +## create + +``` +grn vdb kafka user create --cluster-id --name + [--produce-topics a,b] [--produce-all] + [--consume-topics a,b] [--consume-all] + [--produce-consume-topics a,b] [--produce-consume-all] + [--admin-topics a,b] [--admin-all] + [--mtls-authen] [--sasl-authen] +``` + +Creating a user is free and touches no data, so there is no confirmation prompt. + +If neither `--mtls-authen` nor `--sasl-authen` is given the command warns: the user is +created but has no way to authenticate. + +Returns the created user. Read its credentials afterwards with [get-creds](#get-creds). + +--- + +## update + +``` +grn vdb kafka user update --cluster-id --user-id + [permission flags] [--mtls-authen] [--sasl-authen] + [--dry-run] [--force] +``` + +!!! warning "The request replaces the whole permission set" + Flags you leave out keep their current values — the command reads the user first and + repeats them. Without that, changing one permission would revoke the other three. + A list you **do** pass replaces the previous one; it is not merged, so run + [get](#get) first to see what is there. + +The response carries no body; the command reports the outcome and points at +[get](#get). + +--- + +## delete + +``` +grn vdb kafka user delete --cluster-id --user-id + [--dry-run] [--force] +``` + +Anything connecting as that user stops working immediately. + +--- + +## generate-creds + +``` +grn vdb kafka user generate-creds --cluster-id --user-id + [--dry-run] [--force] +``` + +!!! warning "This invalidates the current credentials" + Every client still using them is cut off until it is given the new ones. + +The API returns no usable body, so the command reports the outcome and leaves fetching +the new values to [get-creds](#get-creds) — that way the secret is written out only when +it is asked for. + +(The API operation is `regenerateUserAuthenCredential`; the CLI spells it +`generate-creds`, since `regenerate` is not one of the canonical verbs.) diff --git a/docs/commands/vdb/memorystore-backup-storage.md b/docs/commands/vdb/memorystore-backup-storage.md new file mode 100644 index 0000000..6ad50e2 --- /dev/null +++ b/docs/commands/vdb/memorystore-backup-storage.md @@ -0,0 +1,97 @@ +# memorystore backup-storage + +Backup storage is a purchased quota, not a per-instance setting. + +```bash +grn vdb memorystore backup-storage [options] +``` + +Backups consume the free allowance that comes with an instance's flavor first +([`backup get-free-storage`](memorystore-backup.md#get-free-storage)); beyond that they +need storage bought here. + +!!! danger "Two paths mean the opposite of their relational namesakes" + `GET /backup-storages` lists the quota you **own** here, and the packages you can + **buy** in the Relational Database API. The catalogue here is + [list-packages](#list-packages) (`/backup-storages/packages`), and releasing storage + is `/actions/delete`, singular, where relational uses `/actions/deletions`. A script + ported between the two products without checking will read the wrong thing. + +## Commands + +| Command | Description | +|---------|-------------| +| [list](#list) | Storage you own, with usage | +| [list-packages](#list-packages) | Packages available to buy | +| [create](#create) | Buy a package (**paid order**) | +| [resize](#resize) | Move to another package (**paid order**) | +| [delete](#delete) | Release storage | + +--- + +## list + +``` +grn vdb memorystore backup-storage list +``` + +What you own: `id`, `name`, `usage`, `quota`, `status`, `backupPackageName`, +`engineGroup`. No pagination or filters. An empty result means you own none and are +living within the free allowance. + +`backupPackageName` comes back null even for storage bought minutes ago (observed on the +relational side); the `quota` column is the reliable indicator of which package it is. + +--- + +## list-packages + +``` +grn vdb memorystore backup-storage list-packages +``` + +Columns: `engineGroup`, `packageId`, `packageName`, `packageQuota`, `price`, `sku`, +`description`. The `packageId` is what `--package-id` expects below, and shell completion +for that flag reads this listing. + +The API nests packages under engine groups; the table flattens that to one row per +package, keeping `engineGroup` on each. `--output json` shows the original shape. + +--- + +## create + +``` +grn vdb memorystore backup-storage create --package-id [--dry-run] [--force] +``` + +Buys a package. Order/payment flow. If you already own storage, growing it is +[resize](#resize), not a second purchase. + +--- + +## resize + +``` +grn vdb memorystore backup-storage resize + --storage-id --package-id [--poc] [--dry-run] [--force] +``` + +Moves existing storage to another package. Shrinking below what your backups already +occupy is rejected by the API — check the `usage` column in [list](#list) first. + +--- + +## delete + +``` +grn vdb memorystore backup-storage delete --storage-id [--dry-run] [--force] +``` + +Releases the quota, printing the storage and its current usage first. Any backup that +relies on it is at risk: release it only when your backups fit in the free allowance, or +after deleting them. + +!!! note "Storage IDs are not instance IDs" + They look like `db-bk-storage-…`, which starts the same way as an instance ID — the + commands check the full prefix so an instance ID cannot slip through. diff --git a/docs/commands/vdb/memorystore-backup.md b/docs/commands/vdb/memorystore-backup.md new file mode 100644 index 0000000..783b1db --- /dev/null +++ b/docs/commands/vdb/memorystore-backup.md @@ -0,0 +1,147 @@ +# memorystore backup + +Backups of vDB MemoryStore instances. + +```bash +grn vdb memorystore backup [options] +``` + +Backups consume the free allowance that comes with an instance's flavor first; past +that they need purchased quota — see [get-free-storage](#get-free-storage) and +[`backup-storage`](memorystore-backup-storage.md). + +## Commands + +| Command | Description | +|---------|-------------| +| [list](#list) | List backups, all or per instance | +| [get](#get) | Show one backup record | +| [create](#create) | Take a backup now | +| [delete](#delete) | Delete a backup | +| [restore](#restore) | Create a **new instance** from a backup (**paid order**) | +| [get-free-storage](#get-free-storage) | Free allowance and current usage | + +--- + +## list + +``` +grn vdb memorystore backup list + [--instance-id ] [--page ] [--page-size ] +``` + +Without `--instance-id` the listing is paginated (items under `content`). With it, a +**different endpoint** — `/database-instances/{id}/backups` — returns every backup of +that instance as a plain array, and the paging flags do not apply. + +The per-instance view drops the `dbInstanceId` and `instanceName` columns: that endpoint +leaves them empty, since you asked by instance. + +--- + +## get + +``` +grn vdb memorystore backup get --backup-id +``` + +The whole record as key/value. It carries the spec of the instance the backup came from +— flavor, config group, subnets — which is what [restore](#restore) reads. + +!!! warning "A missing backup comes back as HTTP 200" + This API reports "not found" as `{"code":200,"message":"success","data":null}`, not + a 404 — a backup whose creation failed reads exactly that way. The command turns it + into a clear error; check + [`instance list-histories`](memorystore-instance.md#list-histories) to see why a + backup never materialised. + +Path note: `/backups/{id}/detail` here, `/backups/detail/{id}` in relational. + +--- + +## create + +``` +grn vdb memorystore backup create + --instance-id + --name + --description + [--backup-type FULL|INCREMENTAL] [--parent-id ] + [--dry-run] [--force] +``` + +!!! danger "--description is required even though the API calls it optional" + On the relational endpoint — which shares this request schema — a backup without a + non-empty description is accepted (HTTP 200, `success: true`, a backup ID handed + back) and then fails in the background with *"An error occurred when communicating + with system"*. Measured 4 failures out of 4 there. The CLI requires it here rather + than risk the same silent failure. + +`--backup-type INCREMENTAL` builds on `--parent-id`, which is then required. + +The backup counts towards billable backup storage, so the command confirms first. + +--- + +## delete + +``` +grn vdb memorystore backup delete --backup-id [--dry-run] [--force] +``` + +Prints the backup it is about to delete, then confirms. Irreversible, and deleting a +`FULL` backup that `INCREMENTAL` ones build on may leave those unrestorable. + +Request shape: a **POST** to `/backups/delete` with a JSON **array** body and no ID in +the path — relational uses `DELETE` with the ID in the path instead. + +--- + +## restore + +Create a **new** instance from a backup. + +``` +grn vdb memorystore backup restore + --backup-id + --name + --zone-id + --subnet-ids + [--package-id ] [--config-id ] + [--redis-password ] + [--public-access] + [--backup-auto --backup-duration --backup-time ] + [--poc] [--dry-run] [--force] +``` + +!!! danger "This does not restore in place" + The API builds a **new** instance and leaves the original untouched. A restore is + therefore a create: order/payment flow, real cost, asynchronous, and it needs a + name. + +`--zone-id` and `--subnet-ids` are **required**: a restored instance need not sit where +the original did, so the CLI does not inherit them. Everything else defaults to the +backup — engine, version, flavor, config group. + +!!! warning "Restoring into a different zone needs a zone-matched flavor" + Flavor ids are zone-specific. When the target zone differs from the original's, pass + `--package-id` from that zone's [catalog](memorystore-catalog.md) as well. + On the relational endpoint, omitting the zone produced an error blaming the flavor + instead of the zone — sending it explicitly is what avoids that class of confusion. + +`--public-access` requires a master password, as it does on create. + +--- + +## get-free-storage + +``` +grn vdb memorystore backup get-free-storage +``` + +Shows `freeBackupStorage` and `backupUsage`. + +!!! note "The allowance is not a fixed project quota" + It is the **sum of your instances' own allowances**, so it grows and shrinks as + instances come and go (verified live on the relational side). Do not treat a + comfortable margin as permanent if you are about to delete instances. diff --git a/docs/commands/vdb/memorystore-catalog.md b/docs/commands/vdb/memorystore-catalog.md new file mode 100644 index 0000000..d5d40b1 --- /dev/null +++ b/docs/commands/vdb/memorystore-catalog.md @@ -0,0 +1,164 @@ +# memorystore catalog + +Read-only lookups of what a vDB MemoryStore instance can be built from. + +```bash +grn vdb memorystore catalog [options] +``` + +These live under `/vdb-memory/v1/database/*`, not `/database-instances/*` as in the +relational API, and they back shell completion for `--package-id` (once +`--datastore-version` is set), `--datastore-version`, +`--package-id` and `--config-id` on +[`instance create`](memorystore-instance.md#create). + +!!! note "Zones and subnets are not here" + MemoryStore has no endpoint for either. Use + [`grn vdb relational catalog list-zones`](relational-catalog.md#list-zones) and + [`list-subnets`](relational-catalog.md#list-subnets) — zones and subnets are + project-wide, and the product team confirmed that is where they come from. + +## Commands + +| Command | Description | +|---------|-------------| +| [list-engines](#list-engines) | Engines on offer | +| [list-datastores](#list-datastores) | Deployable versions | +| [list-flavors](#list-flavors) | Flavors for one version | +| [list-families](#list-families) | Instance families | +| [list-flavor-codes](#list-flavor-codes) | CPU platform codes | +| [list-networks](#list-networks) | Networks available to instances | +| [list-subnets](#list-subnets) | Networks with their subnets | +| [list-volume-types](#list-volume-types) | Volume types (informational) | +| [list-config-groups](#list-config-groups) | Config groups an instance can use | + +--- + +## list-engines + +``` +grn vdb memorystore catalog list-engines +``` + +Columns: `name`, `description`. In practice this is Redis. + +--- + +## list-datastores + +``` +grn vdb memorystore catalog list-datastores +``` + +Columns: `type`, `version`, `name`, `versionName`, `licenseName`. The `version` value is +what `--datastore-version` expects. + +Note `type` is lowercase here (`redis`) while an instance reports `datastoreType` in +display form (`Redis`). + +--- + +## list-flavors + +``` +grn vdb memorystore catalog list-flavors + --datastore-type + --datastore-version + [--zone-id ] +``` + +Both engine flags are required by the API; run [list-datastores](#list-datastores) for +the valid pairs. The flag is `--datastore-version`, not `--version`, which is reserved +globally. + +Columns: `id`, `name`, `vcpus`, `ram`, `bandwidth`, `familyType`, `platformType`, +`locateZoneId`. The `id` is what `--package-id` expects, and **`ram` is the instance's +capacity** — a MemoryStore instance has no volume. + +`volumeSize` and `monthlyCost` are omitted: the API returns 0 for both here. + +```bash +grn vdb memorystore catalog list-flavors \ + --datastore-type Redis --datastore-version 7.2 --zone-id HCM03-1A --output table +``` + +--- + +## list-families + +``` +grn vdb memorystore catalog list-families +``` + +Columns: `group`, `key`, `value`, `name`, `description`. As in the relational catalog, +the response mixes real families (`group: family`) with custom zones +(`group: family_custom`). + +--- + +## list-flavor-codes + +``` +grn vdb memorystore catalog list-flavor-codes +``` + +Columns: `key`, `value`, `familyType`, `description`. Note the path is +`/database/codes`, where relational uses `/database-instances/flavor_zones/codes`. + +--- + +## list-networks + +``` +grn vdb memorystore catalog list-networks +``` + +Columns: `id`, `displayName`, `cidr`, `status`, `createdAt`. + +--- + +## list-subnets + +``` +grn vdb memorystore catalog list-subnets [--zone-id ] +``` + +Each row is a **network**, not a subnet: columns are `uuid`, `displayName`, `status`, +`networkId`, `zoneId`, and the subnets themselves are a nested array — use +`--output json` or a `--query` to see them. + +```bash +grn vdb memorystore catalog list-subnets \ + --query '[].{network:displayName,subnets:subnets[].{id:uuid,cidr:cidr,zone:zoneId}}' +``` + +--- + +## list-volume-types + +``` +grn vdb memorystore catalog list-volume-types [--zone-id ] +``` + +**Informational only.** The API reports volume types for MemoryStore, but a MemoryStore +instance takes no volume type or size — its capacity is the flavor's RAM — so nothing on +`instance create` consumes these. + +--- + +## list-config-groups + +``` +grn vdb memorystore catalog list-config-groups +``` + +Columns: `id`, `name`, `datastoreName`, `datastoreVersionName`, `created`. The +`instanceCount` the API returns is not shown: it is always 0, even for a group with an +instance attached, so a column of zeros would only mislead. `configuration get` lists the +real attachments under `instances`. The `id` is what `--config-id` expects on +[`instance create`](memorystore-instance.md#create) and +[`instance update-config-group`](memorystore-instance.md#update-config-group). + +A group only fits an instance with the same engine and version. +[`memorystore configuration`](memorystore-configuration.md) is where groups are +created and changed; this is the lookup used when attaching one. diff --git a/docs/commands/vdb/memorystore-configuration.md b/docs/commands/vdb/memorystore-configuration.md new file mode 100644 index 0000000..a9fc7a4 --- /dev/null +++ b/docs/commands/vdb/memorystore-configuration.md @@ -0,0 +1,139 @@ +# memorystore configuration + +Config groups hold Redis parameters for vDB MemoryStore instances. + +```bash +grn vdb memorystore configuration [options] +``` + +A group is tied to one engine version and only attaches to matching instances, with +[`instance update-config-group`](memorystore-instance.md#update-config-group). + +## Commands + +| Command | Description | +|---------|-------------| +| [list](#list) | List config groups | +| [get](#get) | Show one group and its parameters | +| [create](#create) | Create an empty group | +| [update](#update) | Set parameters (**replaces the set**) | +| [delete](#delete) | Delete a group | +| [list-params](#list-params) | Parameters Redis accepts | + +--- + +## list + +``` +grn vdb memorystore configuration list [--page ] [--page-size ] +``` + +Paginated, items under `content`. Columns: `id`, `name`, `datastoreName`, +`datastoreVersionName`, `description`, `created` — no `deployType`, which MemoryStore +groups do not carry, and no `instanceCount`: the API answers 0 for every group, verified +live against one that had an instance attached, so [get](#get) is the only way to see +what a group is used by. + +--- + +## get + +``` +grn vdb memorystore configuration get --config-id +``` + +Printed as key/value: the parameters live in a nested `values` map. + +Path note: `/configurations/{id}/detail` here, where relational takes the ID as a +**query parameter** (`/configurations/id?id=…`). + +--- + +## create + +``` +grn vdb memorystore configuration create + --name + --datastore-version + [--datastore-type Redis] + [--description ] + [--dry-run] +``` + +Creating a group is free and touches no instance — it holds parameters until you attach +it — so there is no confirmation prompt. Set the parameters afterwards with +[update](#update). + +There is no `--deploy-type`: MemoryStore has one deployment shape, unlike the relational +equivalent which offers `single_node` and `cluster`. + +--- + +## update + +``` +grn vdb memorystore configuration update + --config-id + --set [--set ...] + [--merge] + [--dry-run] [--force] +``` + +!!! danger "The API replaces the whole parameter set" + Whatever the group held and you do not repeat is **dropped** — verified on the + relational endpoint, which shares this request. Pass `--merge` to keep the existing + parameters and only add or override the ones you name. Either way the command prints + the parameters before and after, so the effect is visible before you confirm. + +Values are sent **typed**: `1` as a number, `2.5` as a float, `true` as a boolean, +anything else as a string — the API's parameter definitions are typed. Run +[list-params](#list-params) for the accepted names and ranges. + +The change is asynchronous: a read straight afterwards may still show the old +parameters, so the command points you at `get` instead of printing a stale result. + +```bash +# Add one parameter, keep the rest +grn vdb memorystore configuration update --config-id cfg-... --merge --set timeout=300 + +# Make these the only parameters +grn vdb memorystore configuration update --config-id cfg-... --set maxmemory-policy=allkeys-lru +``` + +--- + +## delete + +``` +grn vdb memorystore configuration delete --config-id [--dry-run] [--force] +``` + +A group still attached to instances cannot be deleted — detach it first with +`instance update-config-group --detach`, and read [get](#get)'s `instances` to see what +is attached (`instanceCount` is always 0 and cannot be used for this). Attempting it +anyway is refused clearly: `Some configuration groups are being attached to some +database(s)`. + +Deletion is not instantly visible: for a few seconds afterwards [get](#get) still +returns the old record, then starts reporting the group as not found. The listing drops +it immediately. + +The request is a **POST** to `/configurations/delete` with a JSON array body and no ID +in the path; relational uses `DELETE` for the same operation. + +--- + +## list-params + +``` +grn vdb memorystore configuration list-params + --datastore-version [--datastore-type Redis] +``` + +The Redis parameters one version accepts, with `type`, `min`, `max`, `modifiable` and +`restartRequired`. This is the reference for `update --set`: a parameter that is not +listed, or has `modifiable: false`, is rejected. + +```bash +grn vdb memorystore configuration list-params --datastore-version 7.2 --output table +``` diff --git a/docs/commands/vdb/memorystore-instance.md b/docs/commands/vdb/memorystore-instance.md new file mode 100644 index 0000000..187bcd3 --- /dev/null +++ b/docs/commands/vdb/memorystore-instance.md @@ -0,0 +1,403 @@ +# memorystore instance + +Manage vDB MemoryStore (Redis) instances. + +```bash +grn vdb memorystore instance [options] +``` + +Two product facts shape every command here: + +- **No volume.** An instance's capacity is its flavor's RAM, so there are no storage + flags and **no `resize-storage`** — growing means a larger flavor. +- **A master password, not a database user.** Access is guarded by a password on the + instance itself. See [Redis authentication](#redis-authentication). + +Instance IDs start with `db-`, the same prefix Relational Database uses. The two +products keep separate listings, so find IDs with `instance list` here — a relational +ID passed to these commands is rejected by the API, not by the CLI. + +!!! note "Zones and subnets come from the relational catalog" + MemoryStore has no zones or subnets endpoint, so use + [`grn vdb relational catalog list-zones`](relational-catalog.md#list-zones) and + [`list-subnets`](relational-catalog.md#list-subnets) — they are project-wide. + +## Commands + +| Command | Description | +|---------|-------------| +| [list](#list) | List instances | +| [get](#get) | Show one instance | +| [list-histories](#list-histories) | Action history | +| [list-replicas](#list-replicas) | Read replicas | +| [list-secrules](#list-secrules) | Security rules | +| [create](#create) | Create an instance (**paid order**) | +| [resize-instance](#resize-instance) | Change the flavor (**paid order**) | +| [start, stop, reboot](#start-stop-reboot) | Lifecycle | +| [delete](#delete) | Delete an instance | +| [create-replica](#create-replica) | Create a read replica (**paid order**) | +| [detach-replica](#detach-replica) | Promote a replica to standalone | +| [update-settings](#update-settings) | Password, public access, backup schedule | +| [update-config-group](#update-config-group) | Attach or detach a config group | +| [update-secrule](#update-secrule) | Replace the security rules | + +--- + +## list + +``` +grn vdb memorystore instance list + [--page ] [--page-size ] + [--name ] [--status ] +``` + +Paginated, `--page` is 1-based. `--name` and `--status` filter **server-side** — and +unlike the relational listing, this one returns only MemoryStore instances, with +nothing from another product mixed in. + +Table columns: `id`, `name`, `status`, `datastoreType`, `datastoreVersion`, `vcpus`, +`ram`, `ip`, `created`. There is no volume column — `ram` is the capacity. + +`--status` completion suggests the statuses present in your project, read from this +listing. (MemoryStore does have a status-enumeration endpoint, but it is outdated and +deliberately unused.) + +--- + +## get + +``` +grn vdb memorystore instance get --instance-id +``` + +Key/value view of the whole object. Porting note: the path is +`/database-instances/{id}` — no `/id/` segment, which is the relational spelling. + +--- + +## list-histories + +``` +grn vdb memorystore instance list-histories + --instance-id [--page ] [--page-size ] +``` + +Recorded actions with status and, for failures, the error message. Columns: `id`, +`action`, `description`, `status`, `createdTime`, `errorMessage`. + +`description` is the field that identifies the action: `Update` covers every settings +change, resize, and config-group attach or detach, and only the description separates +them (`… Change redis password`, `… Attach config my-group`, `… Update daily backup with +time and duration from 00:00 and 2 retention to 03:30 and 4 retention`). `Create` and +`Create replica` rows carry the whole order cart there and run long. + +`updatedTime` is JSON-only — it repeated `createdTime` on every record observed. Items +come back under `content`. + +--- + +## list-replicas + +``` +grn vdb memorystore instance list-replicas --instance-id +``` + +The API answers with a **reduced** view of each replica — no `ip`, no `port` — so run +`instance get` on a replica ID for its full details. + +--- + +## list-secrules + +``` +grn vdb memorystore instance list-secrules --instance-id +``` + +Run this before [update-secrule](#update-secrule), which replaces the whole set. + +--- + +## create + +Create an instance. + +!!! danger "This places a paid order" + The API answers with an order (`orderId`, `orderUrl`) and builds the instance + asynchronously — a successful response means the order was placed, not that Redis + is ready. Poll `instance get`. Use `--dry-run` first; `--user-type IAM_USER` + switches from Checkout to Auto Payment. + +### Synopsis + +``` +grn vdb memorystore instance create + --name + --datastore-version + --package-id + --zone-id + --subnet-ids + [--datastore-type Redis] + [--redis-password ] [--redis-password-enabled] + [--config-id ] + [--public-access] + [--backup-auto --backup-duration --backup-time ] + [--poc] + [--dry-run] [--force] +``` + +### Options + +`--name`, `--datastore-version`, `--package-id`, `--zone-id`, `--subnet-ids` — **required** + +`--package-id` (string) +: The flavor, as the **numeric id** from +[`catalog list-flavors`](memorystore-catalog.md#list-flavors). Shell completion offers the valid ids once +`--datastore-version` is set — and note that one flavor name can appear under two ids +in the same zone, so pick by `platformType` rather than by name. + +`--datastore-type` (string) +: Defaults to `Redis`, which is what MemoryStore offers. + +`--redis-password`, `--redis-password-enabled` +: See [Redis authentication](#redis-authentication). The password falls back to +`$GRN_VDB_MASTER_PASSWORD` and is masked in `--dry-run` output. + +`--backup-auto`, `--backup-duration`, `--backup-time` +: Daily automatic backup. When `--backup-auto` is given the API requires both a +retention of **2–14 days** and a time of day; the command rejects the combination +early rather than letting the API refuse it. + +**No storage flags**: this API takes no volume type or size. + +```bash +export GRN_VDB_MASTER_PASSWORD='...' + +grn vdb memorystore instance create --dry-run \ + --name my-redis --datastore-version 7.2 --package-id 284 \ + --zone-id HCM03-1B --subnet-ids sub-7cc39ad2-a00f-4edd-8e8b-5f5aaa3eeffe \ + --public-access --backup-auto --backup-duration 3 --backup-time 02:00 +``` + +--- + +## resize-instance + +``` +grn vdb memorystore instance resize-instance + --instance-id --package-id [--poc] [--dry-run] [--force] +``` + +!!! danger "This places a paid order" + Asynchronous, and Redis restarts on the new flavor. + +**This is the only resize MemoryStore has.** An instance holds its data in the +flavor's RAM and has no volume, so growing capacity means a larger flavor. + +--- + +## start, stop, reboot + +``` +grn vdb memorystore instance start --instance-id [--dry-run] [--force] +grn vdb memorystore instance stop --instance-id [--dry-run] [--force] +grn vdb memorystore instance reboot --instance-id [--dry-run] [--force] +``` + +`stop` and `reboot` drop every client connection and confirm first; `start` does not +prompt. + +!!! warning "Redis is in-memory" + Stopping or rebooting loses anything the instance's own snapshot or AOF settings + have not persisted. The confirmation says so. + +The API calls the stop endpoint `/shutdown`; the CLI verb is `stop`. + +--- + +## delete + +``` +grn vdb memorystore instance delete + --instance-id + [--create-final-backup] [--delete-all-backup] + [--dry-run] [--force] +``` + +Prints the instance it is about to destroy — including a replica count, since replicas +may have to go first — then confirms. `--delete-all-backup` is the only way to lose the +ability to restore. + +--- + +## create-replica + +``` +grn vdb memorystore instance create-replica + --instance-id --name + [--package-id ] [--zone-id ] [--subnet-ids ] + [--config-id ] [--redis-password ] + [--public-access] + [--backup-auto --backup-duration --backup-time ] + [--poc] [--dry-run] [--force] +``` + +!!! danger "This places a paid order" + A replica is a full instance and costs accordingly. + +**Everything defaults to the source instance**: engine, version, flavor, zone, subnet, +config group, public access and backup schedule are read from `--instance-id`, so in +practice only that and `--name` are needed. Engine and version are deliberately not +overridable — a replica must match its source. + +If the source requires a master password, the replica needs one supplied: the source's +cannot be read back. + +--- + +## detach-replica + +``` +grn vdb memorystore instance detach-replica --instance-id [--dry-run] [--force] +``` + +Pass the **replica's** ID, not the source's — [list-replicas](#list-replicas) on the +source finds it. Replication stops and cannot be re-established. + +--- + +## update-settings + +``` +grn vdb memorystore instance update-settings + --instance-id + [--redis-password ] [--redis-password-enabled] + [--public-access] + [--backup-auto --backup-duration --backup-time ] + [--dry-run] [--force] +``` + +Only the settings you pass change, with one exception: the **backup schedule is always +sent**. A request without `backupAuto` is answered with HTTP 500, so the command reads +the instance first and repeats its current schedule — which is why `--backup-time` and +`--backup-duration` can also be changed on their own, as long as automatic backup is on. +Repeating the schedule is not reported as a change. + +!!! note "Asynchronous, and the response says nothing" + The API accepts the request and applies it in the background, answering with a + payload whose fields are not meaningful. The command reports acceptance and points + you at `instance get`. It also warns when the instance is not `ACTIVE`: in that + state these endpoints are known to answer "accepted" and silently drop the change. + +!!! warning "Accepted is not applied — wait for ACTIVE between changes" + Fire a second change while the first is still being applied and the API accepts it + too, then drops it. The instance briefly goes `BUILDING`, and the discarded change + appears as a `Failed` row in + [`list-histories`](#list-histories) with `Cannot perform action EDIT, database […] + status is BUILDING`. Both were observed live. Poll `instance get` for `ACTIVE` + before the next call, and check `list-histories` when a change seems to have had no + effect. + +```bash +GRN_VDB_MASTER_PASSWORD='...' grn vdb memorystore instance update-settings --instance-id db-... +grn vdb memorystore instance update-settings --instance-id db-... --public-access=false + +# The schedule alone, without naming --backup-auto +grn vdb memorystore instance update-settings --instance-id db-... --backup-time 03:30 +``` + +--- + +## update-config-group + +``` +grn vdb memorystore instance update-config-group + --instance-id + (--config-id | --detach) + [--dry-run] [--force] +``` + +The group must match the instance's engine and version — see +[`catalog list-config-groups`](memorystore-catalog.md#list-config-groups). The two +flags are mutually exclusive; `--detach` is required rather than accepting an empty +`--config-id`, because an unset shell variable should not silently detach your +configuration. + +Detaching sends `configId: null`. The relational endpoint rejects the empty string the +spec describes, and this request shares that schema. + +Applying parameters can restart Redis, so the command confirms first. Asynchronous, +like `update-settings`. + +!!! note "Where the attachment shows up" + `instance get` reports it under `configuration` (`{id, name}`) — the sibling + `configId` field stays **null** even while a group is attached, so a script reading + `configId` will conclude there is none. A detach leaves the instance + `RESTART_REQUIRED`; clear it with [`reboot`](#reboot). + +--- + +## update-secrule + +``` +grn vdb memorystore instance update-secrule + --instance-id + --rule [--rule ...] + [--add] + [--dry-run] [--force] +``` + +!!! warning "This replaces the entire rule set by default" + Any network currently allowed and not repeated is revoked. Pass `--add` to keep the + existing rules and append instead. The command prints the current set and the + resulting set before doing anything. + +Each `--rule` is a comma-separated list of `key=value` pairs: `cidr` (**required**), +`port` (defaults to the instance's own port, 6379), `port-min`/`port-max`, and `id` to +keep a rule you are re-sending. An unknown key is an error, not an ignored field. + +```bash +grn vdb memorystore instance update-secrule --instance-id db-... --add \ + --rule cidr=198.51.100.0/24 +``` + +--- + +## Redis authentication + +Access is a master password on the instance, not a database user. The CLI enforces two +API rules so a mistake surfaces before the request goes out: + +- **Public access requires the password.** `--public-access` without one is refused. +- **Giving a password means requiring it.** Passing `--redis-password` also sets + `redisPasswordEnabled`, so the body cannot both set a password and disable it. + Override with an explicit `--redis-password-enabled=false`. + +On `update-settings` the API additionally needs an `editRedisPassword` marker alongside +any password change. That is not a setting, so the CLI sets it for you rather than +exposing a flag. + +### What the password may contain + +Checked before the request goes out, so a rejection names your flag instead of arriving +as a 400 with the password in it: + +| Rule | MemoryStore | Relational Database | +|---|---|---| +| Length | 16–128 | 8–32 | +| Characters | letters, digits, `$ ^ _ < >` | letters, digits, `$ ^ _ < >` | +| Both `<` and `>` in one password | rejected | not enforced | + +The two length ranges overlap only between **16 and 32** — worth knowing when one +`$GRN_VDB_MASTER_PASSWORD` is reused across products. The `<`/`>` pair is a live +finding: either bracket alone is accepted, but a password containing both is refused +however far apart they sit. It is enforced for MemoryStore only, the API where it was +observed. + +PostgreSQL Cluster is checked by neither rule: it is a separate API surface, already +known to validate differently, and its password limits were never confirmed. + +The password is read from `$GRN_VDB_MASTER_PASSWORD` when the flag is omitted, which +keeps it out of your shell history, and is masked as `***` in `--dry-run` output. When +the API rejects a password it echoes it back in the error message; the CLI masks any +secret the request carried before printing an error, so it does not reach your terminal +or your CI log. diff --git a/docs/commands/vdb/postgresql-backup.md b/docs/commands/vdb/postgresql-backup.md new file mode 100644 index 0000000..314daf8 --- /dev/null +++ b/docs/commands/vdb/postgresql-backup.md @@ -0,0 +1,103 @@ +# postgresql backup + +Backups of vDB PostgreSQL Clusters. + +```bash +grn vdb postgresql backup [options] +``` + +A cluster has **one backup record** — its policy, location and total size — +holding **many restore points**, the individual snapshots. Scheduled backups come +from the policy chosen at creation; `backup create` takes one immediately. + +!!! warning "Every ID here is a cluster ID" + These endpoints key backups by the cluster they belong to: + `--cluster-id`, never a backup ID. The relational equivalent + (`/backups/detail/{backupId}`) means the opposite, so do not carry that habit + over. These commands also reject `db-` IDs — backups of Relational Database + instances are a different API. + +## Commands + +| Command | Description | +|---------|-------------| +| [list](#list) | Backup record of every cluster | +| [get](#get) | Backup record of one cluster | +| [list-restore-points](#list-restore-points) | Snapshots of one cluster | +| [create](#create) | Take a backup now | + +--- + +## list + +``` +grn vdb postgresql backup list +``` + +The backup record of each cluster in the project. This endpoint takes no +pagination and no filters — it returns the whole list. + +Table columns: `id`, `name`, `databaseId` (the cluster), `status`, +`backupEnabled`, `totalBackupSize`, `latestRecord`, `createdAt`. + +`backupPolicyName` and `backupDestinationName` are not shown: the API leaves both +null and puts the real values in the nested `policy` and `backupDestination` +objects, which table output cannot reach into. + +```bash +grn vdb postgresql backup list --output table +grn vdb postgresql backup list --query '[].{cluster:databaseId,policy:policy.name,size:totalBackupSize}' +``` + +--- + +## get + +``` +grn vdb postgresql backup get --cluster-id +``` + +One cluster's backup record: whether backup is enabled, which policy and location +it uses, the latest record and the total size. Printed as key/value, because the +record nests the full `policy` and `backupDestination` objects. + +```bash +grn vdb postgresql backup get --cluster-id pg-... \ + --query '{enabled:backupEnabled,latest:latestRecord,vault:backupDestination.name}' +``` + +--- + +## list-restore-points + +``` +grn vdb postgresql backup list-restore-points --cluster-id +``` + +The individual snapshots taken for a cluster. Table columns: `id`, `backupName`, +`status`, `time`, `engineVersion`, `compressedSize`, `uncompressedSize`, +`createdAt`. + +A restore point ID (`bk-db-pt-...`) is what `--backup-point-id` expects on +[`cluster create`](postgresql-cluster.md#create) when building a cluster from a +backup. + +--- + +## create + +``` +grn vdb postgresql backup create --cluster-id [--dry-run] [--force] +``` + +Take a backup now, in addition to whatever the policy schedules. The endpoint +takes no parameters — location, policy and retention come from the cluster's +backup record. + +The backup counts towards the cluster's backup storage, which is chargeable beyond +the free allowance that comes with its flavor (`backupSize` in +[`catalog list-flavors`](postgresql-catalog.md#list-flavors)), so the command +confirms first unless `--force` is given. + +The API answers with a plain string inside the response envelope rather than an +object. diff --git a/docs/commands/vdb/postgresql-catalog.md b/docs/commands/vdb/postgresql-catalog.md new file mode 100644 index 0000000..fa7da24 --- /dev/null +++ b/docs/commands/vdb/postgresql-catalog.md @@ -0,0 +1,132 @@ +# postgresql catalog + +Read-only lookups of what a vDB PostgreSQL Cluster can be built from. + +```bash +grn vdb postgresql catalog [options] +``` + +These are also the endpoints behind shell completion for `--datastore-version`, +`--package-id`, `--volume-type-id`, `--config-id`, `--backup-location-id` and +`--backup-policy-id` on +[`postgresql cluster create`](postgresql-cluster.md#create). + +!!! warning "Cluster values are not relational values" + Flavor IDs here start with `pgp-` and volume type IDs with `pgst-`; the spec is + explicit that each is only compatible with a PostgreSQL Cluster. The + similar-looking lists under + [`grn vdb relational catalog`](relational-catalog.md) belong to the + single-instance product and are rejected by cluster create. + +## Commands + +| Command | Description | +|---------|-------------| +| [list-datastores](#list-datastores) | PostgreSQL versions a cluster can run | +| [list-flavors](#list-flavors) | vCPU/RAM packages | +| [list-volume-types](#list-volume-types) | Storage types and size limits | +| [list-config-groups](#list-config-groups) | Config groups a cluster can use | +| [list-backup-locations](#list-backup-locations) | Where backups are stored | +| [list-backup-policies](#list-backup-policies) | Backup schedules and retention | + +--- + +## list-datastores + +``` +grn vdb postgresql catalog list-datastores +``` + +Table columns: `type`, `version`, `name`, `versionName`, `licenseName`. The +`version` value is what `--datastore-version` expects. + +--- + +## list-flavors + +``` +grn vdb postgresql catalog list-flavors [--zone-id ] [--multi-zone] +``` + +Table columns: `id`, `name`, `vcpus`, `ram`, `platformType`, `backupSize`, +`status`, `locateZoneId`. The `id` (`pgp-...`) is what `--package-id` expects on +create, and `backupSize` is the free backup allowance that comes with the flavor. + +`--multi-zone` restricts the list to the flavors a Multi-AZ cluster can use — a +create that passes several `--subnet-ids`, one per zone (see +[Multi-AZ](postgresql-cluster.md#multi-az)). + +```bash +grn vdb postgresql catalog list-flavors --zone-id HCM03-1A --output table +grn vdb postgresql catalog list-flavors --multi-zone --query '[].id' +``` + +--- + +## list-volume-types + +``` +grn vdb postgresql catalog list-volume-types [--zone-id ] [--multi-zone] +``` + +Table columns: `id`, `type`, `name`, `minVolumeSize`, `maxVolumeSize`, `iops`, +`status`, `zoneId`. The `id` (`pgst-...`) is what `--volume-type-id` expects; +`minVolumeSize`/`maxVolumeSize` bound `--volume-size`. + +`--multi-zone` restricts the list to the volume types a Multi-AZ cluster can use. + +Note that cluster storage limits differ from the single-instance ones — the +relational types allow far larger volumes. + +--- + +## list-config-groups + +``` +grn vdb postgresql catalog list-config-groups + [--page ] + [--page-size ] + [--all] +``` + +Config groups a cluster can be attached to. The cluster product has no +config-group endpoint of its own, so this reads the Relational Database config +groups and keeps only those with `deployType: cluster` — the only ones +`--config-id` accepts. Pass `--all` to see every config group, including the +single-instance ones. + +Table columns: `id`, `name`, `datastoreName`, `datastoreVersionName`, +`deployType`, `created` — `instanceCount` is omitted because the API always answers 0 +for it. Items are keyed under `content`, so a query goes through that key: +`--query 'content[].id'`. + +--- + +## list-backup-locations + +``` +grn vdb postgresql catalog list-backup-locations +``` + +The vBackup locations a cluster's backups can be stored in. Table columns: `id`, +`name`, `type`, `product`, `status`, `isDefault`, `numberOfBackupInstances`. The +`id` (`bk-des-...`) is what `--backup-location-id` expects on create. + +--- + +## list-backup-policies + +``` +grn vdb postgresql catalog list-backup-policies +``` + +The vBackup policies a cluster's backups can follow. Table columns: `id`, `name`, +`product`, `isDefault`, `backupInstanceCount`, `createdAt`. The `id` +(`bk-pol-...`) is what `--backup-policy-id` expects on create. + +The schedule and retention live in the nested `config` object, so use +`--output json` or a query to read them: + +```bash +grn vdb postgresql catalog list-backup-policies --query '[].{name:name,config:config}' +``` diff --git a/docs/commands/vdb/postgresql-cluster.md b/docs/commands/vdb/postgresql-cluster.md new file mode 100644 index 0000000..b20d079 --- /dev/null +++ b/docs/commands/vdb/postgresql-cluster.md @@ -0,0 +1,499 @@ +# postgresql cluster + +Manage vDB PostgreSQL Clusters — the multi-node PostgreSQL product, distinct from +the single instances under [`grn vdb relational`](relational-instance.md). + +```bash +grn vdb postgresql cluster [options] +``` + +Cluster IDs start with `pg-`. A cluster has 2–10 nodes, its own flavors and volume +types, and its own backup service. + +!!! note "Two APIs behind one command group" + The cluster product has no read, reboot, delete or security-rule endpoints of + its own — those are served by the Relational Database API, which accepts `pg-` + IDs. Each command below says which API it calls. Because those relational + endpoints also serve Relational Database instances, every command here + **rejects a `db-` ID** rather than acting on the wrong product. + +## Commands + +| Command | Description | +|---------|-------------| +| [list](#list) | List clusters | +| [get](#get) | Show one cluster in full | +| [list-histories](#list-histories) | Action history of a cluster | +| [get-volume-used](#get-volume-used) | Storage in use | +| [list-secrules](#list-secrules) | Security rules | +| [create](#create) | Create a cluster (**paid order**) | +| [resize](#resize) | Change storage or node count (**paid order**) | +| [update-settings](#update-settings) | Master password / public access | +| [update-config-group](#update-config-group) | Attach or detach a config group | +| [update-secrule](#update-secrule) | Replace the security rules | +| [reboot](#reboot) | Restart the database | +| [delete](#delete) | Delete the cluster | + +--- + +## list + +List the clusters in your project. + +### Synopsis + +``` +grn vdb postgresql cluster list + [--page ] + [--page-size ] + [--name ] + [--status ] +``` + +### Options + +`--page` (integer) +: Page number, 1-based. Default: `1`. + +`--page-size` (integer) +: Number of items per page. Default: `50`. + +`--name` (string) +: Filter by cluster name, substring match, case-insensitive. + +`--status` (string) +: Filter by status; comma-separated for several, e.g. `--status ACTIVE,BUILDING`. + +!!! warning "Filtering happens on the client here" + This calls the Relational Database listing — the only endpoint that enumerates + clusters — and keeps the `pg-` rows. That endpoint's server-side `name` and + `status` filters **do not apply to cluster rows**, so the CLI applies them + itself. Consequences: `--page`/`--page-size` page the underlying mixed + listing, so a page can return fewer clusters than `--page-size`, and + `pageObject` in JSON output describes that mixed listing, not the filtered + result. + +### Output + +`--output table` shows `id`, `name`, `status`, `datastoreVersion`, +`numberOfNodes`, `vcpus`, `ram`, `volumeSize`, `privateRwIp`, `created`. + +```bash +grn vdb postgresql cluster list --output table +grn vdb postgresql cluster list --status ACTIVE --query 'data[].{id:id,name:name}' +``` + +--- + +## get + +Show one cluster in full. Calls the Relational Database get-by-id endpoint. + +``` +grn vdb postgresql cluster get --cluster-id +``` + +Output is a key/value view of the whole object, never a fixed column set — a +cluster carries several nested arrays. Cluster-specific fields worth knowing: +`deployType` (`cluster`), `numberOfNodes`, and `privateRwIp` / `publicRwIp` / +`privateRoIp` / `publicRoIp`, which replace a single instance's `ip` list — `Rw` +is the read-write endpoint, `Ro` the read-only one. A [Multi-AZ](#multi-az) +cluster additionally carries `multiZoneInfos[]`, one entry per zone with that +zone's subnet and endpoints. + +```bash +grn vdb postgresql cluster get --cluster-id pg-2e6f2253-9032-466f-975f-d6d6b6ec8330 \ + --query '{rw:privateRwIp,ro:privateRoIp,port:port,nodes:numberOfNodes}' +``` + +--- + +## list-histories + +List the recorded actions of a cluster — create, resize, restart, backup — with +status and, for failures, the error message. Calls the Relational Database +histories endpoint. + +``` +grn vdb postgresql cluster list-histories + --cluster-id + [--page ] + [--page-size ] +``` + +Table columns: `id`, `action`, `description`, `status`, `createdTime`, `errorMessage`. +`description` is what distinguishes the rows — a resize, a settings change and a +config-group attach are all recorded as `Update` — so it sits right after `action`. +`updatedTime` is JSON-only: it repeats `createdTime` on every record observed. Items are +keyed under `content`, not `data`. + +```bash +grn vdb postgresql cluster list-histories --cluster-id pg-... --output table +``` + +--- + +## get-volume-used + +Show how much of the provisioned storage is in use. One of the cluster's own +endpoints. + +``` +grn vdb postgresql cluster get-volume-used --cluster-id +``` + +The API answers with an array of human-readable sizes, one per node (e.g. +`["194M"]`), so the output is printed as-is. + +--- + +## list-secrules + +List the security group rules controlling access to a cluster. Calls the +Relational Database secrules endpoint. + +``` +grn vdb postgresql cluster list-secrules --cluster-id +``` + +Table output shows `id`, `direction`, `protocol`, `portRangeMin`, `portRangeMax`, +`remoteIpPrefix`, `createdAt`. Run this before +[update-secrule](#update-secrule) — that command replaces the whole set. + +--- + +## create + +Create a cluster. One of the cluster's own endpoints. + +!!! danger "This places a paid order" + Creation goes through the order/payment flow: the API answers with an order + (`orderId`, `orderUrl`) and builds the cluster asynchronously. A successful + response means the order was placed, not that the cluster is ready — poll + `cluster get`. Use `--dry-run` first; the command also confirms before + ordering unless `--force` is given. `--user-type IAM_USER` switches from + Checkout to Auto Payment. + +### Synopsis + +``` +grn vdb postgresql cluster create + --name + --datastore-version + --package-id + --volume-type-id + --volume-size + --zone-id + --subnet-ids + --username + --database-name + [--password ] + [--number-of-nodes ] + [--config-id ] + [--public-access] + [--backup-location-id ] + [--backup-policy-id ] + [--backup-point-id ] + [--poc] + [--dry-run] [--force] +``` + +### Options + +`--name` (string) — **required** +: Cluster name. + +`--datastore-version` (string) — **required** +: PostgreSQL version, e.g. `17`. See +[`catalog list-datastores`](postgresql-catalog.md#list-datastores). + +`--package-id` (string) — **required** +: Flavor ID, `pgp-...`. See +[`catalog list-flavors`](postgresql-catalog.md#list-flavors). + +`--volume-type-id` (string) — **required** +: Volume type ID, `pgst-...`. See +[`catalog list-volume-types`](postgresql-catalog.md#list-volume-types). + +`--volume-size` (integer) — **required** +: Volume size in GB, within the type's `minVolumeSize`/`maxVolumeSize`. + +`--zone-id` (string) — **required** +: Availability zone, e.g. `HCM03-1A`. + +`--subnet-ids` (string) — **required** +: Subnet ID(s), comma-separated. One subnet places every node in that subnet's +zone. Several — one per zone — spread the nodes across those zones +(**Multi-AZ**); the subnets come from +[`grn vdb relational catalog list-subnets`](relational-catalog.md#list-subnets). + +`--username` (string) — **required** +: Master username. + +`--database-name` (string) — **required** +: Initial database. The API accepts exactly one at creation. + +`--password` (string) +: Master password. Read from `$GRN_VDB_MASTER_PASSWORD` when omitted, which keeps +it out of your shell history; it is masked as `***` in `--dry-run` output. + +`--number-of-nodes` (integer) +: 2–10. Default: `3`. + +`--config-id` (string) +: Config group with deploy type `cluster`. See +[`catalog list-config-groups`](postgresql-catalog.md#list-config-groups). + +`--public-access` +: Allow public access. + +`--backup-location-id`, `--backup-policy-id` (string) +: Where and how backups are kept. See +[`catalog list-backup-locations`](postgresql-catalog.md#list-backup-locations) +and [`list-backup-policies`](postgresql-catalog.md#list-backup-policies). + +`--backup-point-id` (string) +: Restore point to build the cluster from, `bk-db-pt-...`. See +[`backup list-restore-points`](postgresql-backup.md#list-restore-points). + +`--poc` +: Pay with PoC credit (Auto Payment only). + +!!! note "Cluster IDs are not relational IDs" + A `pgp-`/`pgst-` value comes only from the PostgreSQL catalog. Flavors and + volume types listed by `grn vdb relational catalog` belong to the + single-instance product and are rejected here. + +### Multi-AZ + +Pass one subnet per zone in `--subnet-ids` and the nodes are spread across those +zones. Two rules are enforced before the order is placed: + +- Each subnet appears once — one subnet per zone, so a repeat names a zone twice. +- At most one subnet per node — every zone you name has to receive at least one + node. + +A Multi-AZ cluster can only use the flavors and volume types that +[`catalog list-flavors --multi-zone`](postgresql-catalog.md#list-flavors) and +[`catalog list-volume-types --multi-zone`](postgresql-catalog.md#list-volume-types) +list — check those before ordering. Once built, the per-zone placement of a +Multi-AZ cluster is visible in `cluster get` as `multiZoneInfos[]`: one entry per +zone with that zone's `subnetId`, read-write / read-only endpoints and `status`. + +### Examples + +Single zone — every node in one subnet: + +```bash +export GRN_VDB_MASTER_PASSWORD='...' + +grn vdb postgresql cluster create --dry-run \ + --name analytics \ + --datastore-version 17 \ + --package-id pgp-cd1958e4-a6a6-445b-b96f-07d26b811293 \ + --volume-type-id pgst-63e28e83-165c-4a6d-8cd4-36ed37f1b65d \ + --volume-size 40 --number-of-nodes 3 \ + --zone-id HCM03-1A \ + --subnet-ids sub-66a5327f-1970-427d-b1a3-8eb146e94bab \ + --username pgadmin --database-name appdb +``` + +Multi-AZ — one subnet per zone, nodes spread across both: + +```bash +grn vdb postgresql cluster create --dry-run \ + --name ha-analytics \ + --datastore-version 17 \ + --package-id pgp-... \ + --volume-type-id pgst-... \ + --volume-size 40 --number-of-nodes 3 \ + --zone-id HCM03-1A \ + --subnet-ids sub-...-HCM03-1A,sub-...-HCM03-1B \ + --username pgadmin --database-name appdb +``` + +--- + +## resize + +Change storage size, storage type or node count. One of the cluster's own +endpoints. + +!!! danger "This places a paid order" + Same order/payment flow as create, and asynchronous. Use `--dry-run` first. + +### Synopsis + +``` +grn vdb postgresql cluster resize + --cluster-id + --type VOLUME-SIZE|VOLUME-TYPE|NUMBER-OF-NODES + [--volume-size ] + [--volume-type-id ] + [--number-of-nodes ] + [--poc] + [--dry-run] [--force] +``` + +### Options + +`--type` (string) — **required** +: What to change. One resize does one thing, and only that dimension's flag is +read: + +| `--type` | reads | +|---|---| +| `VOLUME-SIZE` | `--volume-size` | +| `VOLUME-TYPE` | `--volume-type-id` | +| `NUMBER-OF-NODES` | `--number-of-nodes` (2–10) | + +The command refuses a `--type` whose flag is missing, rather than sending an +order that changes nothing. Storage can normally only grow. + +```bash +grn vdb postgresql cluster resize --cluster-id pg-... --type VOLUME-SIZE --volume-size 80 --dry-run +grn vdb postgresql cluster resize --cluster-id pg-... --type NUMBER-OF-NODES --number-of-nodes 5 +``` + +--- + +## update-settings + +Change the master password, the public-access setting, or both. One of the +cluster's own endpoints. + +``` +grn vdb postgresql cluster update-settings + --cluster-id + [--password ] + [--public-access] + [--dry-run] [--force] +``` + +Only the settings you pass are sent. This matters for `--public-access`: its zero +value is a real setting, so a body that always carried `publicAccess: false` would +cut off public connectivity every time someone rotated a password. Pass the flag +only when you mean to change exposure. + +`--password` falls back to `$GRN_VDB_MASTER_PASSWORD` and is masked in `--dry-run` +output. Both changes affect live connections, so the command confirms first. + +```bash +grn vdb postgresql cluster update-settings --cluster-id pg-... --public-access=false +GRN_VDB_MASTER_PASSWORD='...' grn vdb postgresql cluster update-settings --cluster-id pg-... +``` + +--- + +## update-config-group + +Attach a config group to the cluster, or detach the current one. One of the +cluster's own endpoints. + +``` +grn vdb postgresql cluster update-config-group + --cluster-id + (--config-id | --detach) + [--dry-run] [--force] +``` + +Only config groups with deploy type `cluster` are accepted. The API detaches when +it receives an empty `configGroupId`; the CLI requires the explicit `--detach` +flag instead, because an unset shell variable should not silently detach your +configuration. `--config-id` and `--detach` are mutually exclusive. + +Applying configuration can restart the database, so the command confirms first. + +--- + +## update-secrule + +Set which networks may reach the cluster. Calls the Relational Database secrules +endpoint, whose body is a JSON array. + +### Synopsis + +``` +grn vdb postgresql cluster update-secrule + --cluster-id + --rule [--rule ...] + [--add] + [--dry-run] [--force] +``` + +!!! warning "This replaces the entire rule set by default" + The API takes the full list, so any network currently allowed and not repeated + is revoked. Pass `--add` to keep the existing rules and append instead. The + command prints the current set and the resulting set before doing anything; + run it with `--dry-run` first. + +### Rule syntax + +Each `--rule` is a comma-separated list of `key=value` pairs, and the flag repeats: + +| Key | Meaning | +|-----|---------| +| `cidr` | The allowed network, e.g. `cidr=203.0.113.0/24` (**required**) | +| `port` | Single port; defaults to `5432` | +| `port-min`, `port-max` | Port range, given together | +| `id` | Existing rule ID, to keep a rule you are re-sending | + +An unknown key is an error, not an ignored field: a typo'd `cird=` would otherwise +send a rule with no network and revoke everything. + +### Examples + +Restrict access to one network (replacing everything else): + +```bash +grn vdb postgresql cluster update-secrule --cluster-id pg-... \ + --rule cidr=203.0.113.0/24 --dry-run +``` + +Add an office network without touching the existing rules: + +```bash +grn vdb postgresql cluster update-secrule --cluster-id pg-... --add \ + --rule 'cidr=198.51.100.0/24,port=5432' +``` + +--- + +## reboot + +Restart the database on a cluster. Calls the Relational Database reboot endpoint. + +``` +grn vdb postgresql cluster reboot --cluster-id [--dry-run] [--force] +``` + +Open connections are dropped, so the command confirms first. + +--- + +## delete + +Delete a cluster. Calls the Relational Database delete endpoint. + +``` +grn vdb postgresql cluster delete + --cluster-id + [--create-final-backup] + [--dry-run] [--force] +``` + +### Options + +`--create-final-backup` +: Take one last backup before deleting. + +That is the only option a cluster delete takes. The shared request schema also +carries `deleteAllBackup`, but it applies to Relational Database instances, not to +clusters — existing backups survive the deletion and remain restorable. + +Before doing anything the command prints the cluster it is about to destroy (ID, +name, status, version, nodes, storage). Deleting the cluster is irreversible. + +```bash +grn vdb postgresql cluster delete --cluster-id pg-... --create-final-backup --dry-run +``` diff --git a/docs/commands/vdb/relational-backup-storage.md b/docs/commands/vdb/relational-backup-storage.md new file mode 100644 index 0000000..0d2ee68 --- /dev/null +++ b/docs/commands/vdb/relational-backup-storage.md @@ -0,0 +1,104 @@ +# relational backup-storage + +Backup storage is a purchased quota, not a per-instance setting. + +```bash +grn vdb relational backup-storage [options] +``` + +Backups consume the free allowance that comes with an instance's flavor first +([`backup get-free-storage`](relational-backup.md#get-free-storage)); beyond that +they need storage bought here. + +!!! note "The free allowance is not a fixed project quota" + It is the **sum of the `freeBackupSize` of every instance you run** — 100 GB per + 4 vCPU/8 GB instance, for example. It therefore grows and shrinks as instances + come and go: creating one raised the reported allowance from 300 to 400 GB and + deleting it brought it back (verified live). Do not treat a comfortable margin + as permanent if you are about to delete instances. + +## Commands + +| Command | Description | +|---------|-------------| +| [list](#list) | Storage you own, with usage | +| [list-packages](#list-packages) | Packages available to buy | +| [create](#create) | Buy a package (**paid order**) | +| [resize](#resize) | Move to another package (**paid order**) | +| [delete](#delete) | Release storage | + +--- + +## list + +``` +grn vdb relational backup-storage list +``` + +What you own: `id`, `name`, `usage`, `quota`, `status`, `backupPackageName`, +`engineGroup`. No pagination or filters. An empty result means you own none and are +living within the free allowance. + +`backupPackageName` comes back null even for storage bought minutes ago; the quota +column is the reliable indicator of which package it is. + +!!! note "Two similar paths" + This reads `/backup-storages/information` — what you *have*. `/backup-storages` + is the catalogue of what you can *buy*, which is [list-packages](#list-packages). + +--- + +## list-packages + +``` +grn vdb relational backup-storage list-packages +``` + +Table columns: `engineGroup`, `packageId`, `packageName`, `packageQuota`, `price`, +`sku`, `description`. The `packageId` is what `--package-id` expects below. + +The API nests packages under engine groups; the table flattens that to one row per +package, keeping `engineGroup` on each. `--output json` shows the original shape. + +--- + +## create + +``` +grn vdb relational backup-storage create --package-id [--dry-run] [--force] +``` + +Buys a package. Order/payment flow, asynchronous. If you already own storage, +growing it is [resize](#resize), not a second purchase. + +--- + +## resize + +``` +grn vdb relational backup-storage resize + --storage-id + --package-id + [--poc] + [--dry-run] [--force] +``` + +Moves existing storage to another package. Shrinking below what your backups already +occupy is rejected by the API — check the `usage` column in [list](#list) first. + +--- + +## delete + +``` +grn vdb relational backup-storage delete --storage-id [--dry-run] [--force] +``` + +Releases the quota. Prints the storage and its current usage first. + +Any backup that relies on this storage is at risk: release it only when your backups +fit in the free allowance, or after deleting them. + +!!! note "Storage IDs are not instance IDs" + They look like `db-bk-storage-…`, which starts the same way as an instance ID — + the commands check the full prefix so an instance ID cannot slip through. diff --git a/docs/commands/vdb/relational-backup.md b/docs/commands/vdb/relational-backup.md new file mode 100644 index 0000000..3d9bcaa --- /dev/null +++ b/docs/commands/vdb/relational-backup.md @@ -0,0 +1,175 @@ +# relational backup + +Backups of vDB Relational Database instances. + +```bash +grn vdb relational backup [options] +``` + +Backups consume the free allowance that comes with an instance's flavor first; past +that they need purchased quota — see [get-free-storage](#get-free-storage) and +[`backup-storage`](relational-backup-storage.md). + +## Commands + +| Command | Description | +|---------|-------------| +| [list](#list) | List backups, all or per instance | +| [get](#get) | Show one backup record | +| [create](#create) | Take a backup now | +| [delete](#delete) | Delete a backup | +| [restore](#restore) | Create a **new instance** from a backup (**paid order**) | +| [get-free-storage](#get-free-storage) | Free allowance and current usage | + +--- + +## list + +``` +grn vdb relational backup list [--instance-id ] [--page ] [--page-size ] +``` + +Without `--instance-id` the listing is paginated (items under `content`). With it, +a **different endpoint** returns every backup of that instance as a plain array and +the paging flags do not apply. + +The per-instance view also drops two columns: that endpoint leaves `dbInstanceId` +and `instanceName` empty on every row, since you asked by instance. + +```bash +grn vdb relational backup list --output table +grn vdb relational backup list --instance-id db-... --output table +``` + +--- + +## get + +``` +grn vdb relational backup get --backup-id +``` + +Prints the whole record as key/value. Beyond the obvious fields it carries the spec +of the instance the backup came from — flavor, storage type and size, config group, +username — which is what [restore](#restore) reads. + +!!! warning "A missing backup comes back as HTTP 200" + This API reports "not found" as `{"code":200,"message":"success","data":null}`, + not a 404 — a backup whose creation failed reads exactly that way. The command + turns it into a clear error; check + [`instance list-histories`](relational-instance.md#list-histories) to see why a + backup never materialised. + +--- + +## create + +``` +grn vdb relational backup create + --instance-id + --name + --description + [--backup-type FULL|INCREMENTAL] [--parent-id ] + [--dry-run] [--force] +``` + +### Options + +`--instance-id`, `--name` — **required** + +`--description` (string) — **required** +: Free-text description. + +!!! danger "--description is required even though the API calls it optional" + Without a **non-empty** description the request is accepted — HTTP 200, + `success: true`, a backup ID handed back — and the backup then fails in the + background with *"An error occurred when communicating with system"*. Measured + 4 failures out of 4 without one (including an explicit empty string), against + 2 successes out of 2 with one. The CLI requires it so the failure surfaces + before anything is sent. + +`--backup-type` (string) +: `FULL` (default) or `INCREMENTAL`. Incremental builds on `--parent-id`, which is +then required. + +The backup counts towards billable backup storage, so the command confirms first. + +```bash +grn vdb relational backup create --instance-id db-... \ + --name nightly-manual --description "before schema change" +``` + +--- + +## delete + +``` +grn vdb relational backup delete --backup-id [--dry-run] [--force] +``` + +Prints the backup it is about to delete, then confirms. Irreversible. + +Deleting a `FULL` backup that `INCREMENTAL` backups build on may leave those +unrestorable — check `backup list --instance-id ` for children first. + +The request is one of vdb's six JSON **array** bodies, and the backup ID travels in +the path *and* in the array. + +--- + +## restore + +Create a **new** instance from a backup. + +``` +grn vdb relational backup restore + --backup-id + --name + --zone-id + --subnet-ids + [--package-id ] [--volume-type ] [--volume-size ] + [--config-id ] [--password ] + [--public-access] + [--backup-auto --backup-duration --backup-time ] + [--poc] + [--dry-run] [--force] +``` + +!!! danger "This does not restore in place" + The API builds a **new** instance and leaves the original untouched. A restore is + therefore a create: order/payment flow, real cost, asynchronous, and it needs a + name. + +### Placement is your choice + +`--zone-id` and `--subnet-ids` are **required**: a restored instance need not sit +where the original did, so the CLI does not inherit them. (The backup record could +not supply the subnet anyway — its `netIds` field holds the *network* the original +instance sat in, not a subnet.) + +!!! warning "Restoring into a different zone needs zone-matched flavor and storage" + Flavor ids and volume type names are zone-specific: id `180` and + `Gen2-NVMe2-IOPS3000-HCM03-1B` exist only in HCM03-1B. When the target zone + differs from the original's, pass `--package-id` and `--volume-type` from that + zone's [catalog](relational-catalog.md) as well. Otherwise the API answers + `Package ID … is invalid; Volume type … is invalid` — an error that names the + flavor and storage while the real problem is the zone. + +Everything else defaults to the backup: engine, version, flavor, storage type and +size, and config group. `--dry-run` shows the resolved request. + +```bash +grn vdb relational backup restore --backup-id bk-... --name restored-db \ + --zone-id HCM03-1B --subnet-ids sub-... --dry-run +``` + +--- + +## get-free-storage + +``` +grn vdb relational backup get-free-storage +``` + +Shows `freeBackupStorage` (the allowance in GB that comes with your instances' +flavors) and `backupUsage` (what your backups occupy now). diff --git a/docs/commands/vdb/relational-catalog.md b/docs/commands/vdb/relational-catalog.md new file mode 100644 index 0000000..c09f27b --- /dev/null +++ b/docs/commands/vdb/relational-catalog.md @@ -0,0 +1,280 @@ +# relational catalog + +Read-only lookups of what a vDB Relational Database instance can be built from: +engines, datastore versions, instance families, flavors, availability zones, +networks and volume types. + +```bash +grn vdb relational catalog [options] +``` + +These are the values that creation and resize commands expect, and the same +endpoints that back shell completion for `--datastore-type`, +`--datastore-version`, `--zone-id` and volume types. The vDB API declares no +enumerations anywhere, so this catalog is the only authoritative source of valid +values. + +## Commands + +| Command | Description | +|---------|-------------| +| [list-engines](#list-engines) | Database engines and their licences | +| [list-datastores](#list-datastores) | Deployable engine/version pairs | +| [list-flavors](#list-flavors) | Flavors (vCPU/RAM) for one engine version | +| [list-families](#list-families) | Instance families | +| [list-flavor-codes](#list-flavor-codes) | CPU platform codes | +| [list-zones](#list-zones) | Availability zones | +| [list-networks](#list-networks) | Networks available to instances | +| [list-subnets](#list-subnets) | Networks with their subnets | +| [list-volume-types](#list-volume-types) | Volume types and size limits | +| [list-config-groups](#list-config-groups) | Config groups an instance can use | + +--- + +## list-engines + +List the database engines offered by Relational Database. + +``` +grn vdb relational catalog list-engines +``` + +`--output table` shows `name` and `description`. The `image` field comes back +empty on every engine and `engineLicenses` is a nested array; both are in +`--output json` only. + +```bash +grn vdb relational catalog list-engines --output table +``` + +--- + +## list-datastores + +List the engine/version pairs that can actually be deployed. This is the input +you need for [list-flavors](#list-flavors). + +``` +grn vdb relational catalog list-datastores +``` + +`--output table` shows `type`, `version`, `name`, `versionName`, `licenseName`. + +Note that `type` is lowercase here (`postgresql`) while `instance list` reports +`datastoreType` in display form (`PostgreSQL`). Either form is accepted by +`--datastore-type`. + +```bash +grn vdb relational catalog list-datastores --output table +``` + +--- + +## list-flavors + +List the flavors (vCPU/RAM combinations) available for one engine version. + +### Synopsis + +``` +grn vdb relational catalog list-flavors + --datastore-type + --datastore-version + [--zone-id ] +``` + +### Options + +`--datastore-type` (string) — **required** +: Engine, e.g. `postgresql` or `PostgreSQL` — the API matches both. Run +[list-datastores](#list-datastores) for the valid values. + +`--datastore-version` (string) — **required** +: Engine version, e.g. `15`. The flag is *not* `--version`, which is reserved as +a global flag. + +`--zone-id` (string) +: Restrict results to one availability zone. Run [list-zones](#list-zones) for +the IDs. + +### Output + +`--output table` shows `id`, `name`, `vcpus`, `ram`, `volumeType`, `bandwidth`, +`familyType`, `platformType`, `locateZoneId`. + +The same flavor `name` is repeated once per availability zone with a different +`id`, so narrow with `--zone-id` when comparing. `volumeSize` and `monthlyCost` +are omitted from the table because the API returns `0` for both here — storage is +chosen separately when creating an instance. + +### Examples + +```bash +grn vdb relational catalog list-flavors \ + --datastore-type postgresql --datastore-version 15 \ + --zone-id HCM03-1A --output table +``` + +Just the flavor names available in a zone: + +```bash +grn vdb relational catalog list-flavors \ + --datastore-type mysql --datastore-version 8.0 \ + --zone-id HCM03-1A --query '[].name' +``` + +--- + +## list-families + +List the instance families that flavors belong to. + +``` +grn vdb relational catalog list-families +``` + +`--output table` shows `group`, `key`, `value`, `name`, `description`. + +!!! note "Two kinds of row" + The response mixes real families with custom zones, told apart by `group`: + `family` rows carry `key`, `value` and a `condition.codes` list of their CPU + platform codes, while `family_custom` rows carry only a `name` and are zones, + not families. + +```bash +grn vdb relational catalog list-families --query "[?group=='family']" +``` + +--- + +## list-flavor-codes + +List the CPU platform codes that flavors are grouped by (`code-a`, `code-s`, +`code-s2`). + +``` +grn vdb relational catalog list-flavor-codes +``` + +`--output table` shows `key`, `value`, `familyType`, `description`. + +--- + +## list-zones + +List the availability zones an instance can be placed in. + +``` +grn vdb relational catalog list-zones +``` + +`--output table` shows `uuid`, `name`, `status`, `zoneType`, `isDefault`, +`description`. The `uuid` is what `--zone-id` expects elsewhere (e.g. +`HCM03-1A`). + +--- + +## list-networks + +List the networks (VPCs) an instance can be attached to. + +``` +grn vdb relational catalog list-networks +``` + +`--output table` shows `id`, `displayName`, `cidr`, `status`, `createdAt`. + +--- + +## list-subnets + +List the same networks together with their subnets. + +### Synopsis + +``` +grn vdb relational catalog list-subnets + [--zone-id ] +``` + +### Options + +`--zone-id` (string) +: Restrict results to one availability zone. + +### Output + +Each row is a *network*, not a subnet: `--output table` shows `uuid`, +`displayName`, `status`, `networkId`, `zoneId`. The subnets themselves are a +nested array, so use `--output json` or a `--query` to see them. + +```bash +grn vdb relational catalog list-subnets \ + --query '[].{network:displayName,subnets:subnets[].{id:uuid,cidr:cidr,zone:zoneId}}' +``` + +--- + +## list-volume-types + +List the volume types available for instance storage, with their size limits and +provisioned IOPS. + +### Synopsis + +``` +grn vdb relational catalog list-volume-types + [--zone-id ] +``` + +### Options + +`--zone-id` (string) +: Restrict results to one availability zone. Volume types are per-zone, so this +is the usual way to read this list. + +### Output + +`--output table` shows `type`, `description`, `minVolumeSize`, `maxVolumeSize`, +`iops`, `zoneId`. `displayName` is null on every row — `description` is the +human-readable label. + +!!! note "This listing wraps its array in an object" + Unlike every other catalog command, the payload here is `{"data": [...]}`, so + a `--query` has to go through that key: `--query 'data[].type'`, not + `--query '[].type'`. + +```bash +grn vdb relational catalog list-volume-types --zone-id HCM03-1A --output table +``` + +--- + +## list-config-groups + +List the config groups a Relational Database instance can be attached to. + +``` +grn vdb relational catalog list-config-groups [--all] +``` + +Table columns: `id`, `name`, `datastoreName`, `datastoreVersionName`, `deployType`, +`created`. The `instanceCount` the API returns is left out: it is always 0, even for a +group with an instance attached, so the column would only mislead — +`configuration get` reports the real attachments under `instances`. The `id` is what +`--config-id` expects on +[`instance create`](relational-instance.md#create) and +[`instance update-config-group`](relational-instance.md#update-config-group). + +A config group only fits an instance with the **same engine and version**, so check +the `datastoreName` / `datastoreVersionName` columns before attaching one. + +!!! note "Cluster groups are filtered out" + The endpoint also returns PostgreSQL Cluster config groups (`deployType: + cluster`, IDs prefixed `pg-cfg-`), which a single instance cannot use, so they + are hidden by default. Pass `--all` to see them, or use + [`grn vdb postgresql catalog list-config-groups`](postgresql-catalog.md#list-config-groups). + +```bash +grn vdb relational catalog list-config-groups --output table +``` diff --git a/docs/commands/vdb/relational-configuration.md b/docs/commands/vdb/relational-configuration.md new file mode 100644 index 0000000..f3b3302 --- /dev/null +++ b/docs/commands/vdb/relational-configuration.md @@ -0,0 +1,142 @@ +# relational configuration + +Config groups hold database parameters for vDB Relational Database instances. + +```bash +grn vdb relational configuration [options] +``` + +A group is tied to one engine and version and only attaches to matching instances, +with +[`instance update-config-group`](relational-instance.md#update-config-group). + +## Commands + +| Command | Description | +|---------|-------------| +| [list](#list) | List config groups | +| [get](#get) | Show one group and its parameters | +| [create](#create) | Create an empty group | +| [update](#update) | Set parameters (**replaces the set**) | +| [delete](#delete) | Delete a group | +| [list-params](#list-params) | Parameters an engine accepts | + +--- + +## list + +``` +grn vdb relational configuration list [--page ] [--page-size ] +``` + +Paginated, items under `content`. Includes PostgreSQL Cluster groups (`deployType: +cluster`); +[`catalog list-config-groups`](relational-catalog.md#list-config-groups) is the +filtered view for attaching to a single instance. + +--- + +## get + +``` +grn vdb relational configuration get --config-id +``` + +!!! note "The ID goes in a query parameter" + This endpoint is `/configurations/id?id=…`, not `/configurations/id/` — + building the path form returns 404. + +Printed as key/value: the parameters live in a nested `values` map and the attached +instances in `instances`. + +--- + +## create + +``` +grn vdb relational configuration create + --name + --datastore-type + --datastore-version + [--deploy-type single_node|cluster] + [--description ] + [--dry-run] +``` + +Creating a group is free and touches no instance — it holds parameters until you +attach it — so there is no confirmation prompt. Set the parameters afterwards with +[update](#update). + +`--deploy-type cluster` produces a group for PostgreSQL Cluster (PostgreSQL only). + +--- + +## update + +``` +grn vdb relational configuration update + --config-id + --set [--set ...] + [--merge] + [--dry-run] [--force] +``` + +!!! danger "The API replaces the whole parameter set" + Whatever the group held and you do not repeat is **dropped** — verified against + the live API, and not what the spec suggests. Pass `--merge` to keep the existing + parameters and only add or override the ones you name. Either way the command + prints the parameters before and after, so the effect is visible before you + confirm. + +Values are sent **typed**: `1` as a number, `2.5` as a float, `true` as a boolean, +anything else as a string — the API's own parameter definitions are typed and its +example sends `{"autocommit": 1}`. Run [list-params](#list-params) for the accepted +names, ranges and whether a parameter needs a restart. + +The change is asynchronous: a read straight afterwards still shows the old +parameters, so the command points you at `get` instead of printing a stale result. + +```bash +# Add one parameter, keep the rest +grn vdb relational configuration update --config-id cfg-... --merge \ + --set long_query_time=5 + +# Make these the only parameters +grn vdb relational configuration update --config-id cfg-... \ + --set autocommit=1 --set long_query_time=5 +``` + +--- + +## delete + +``` +grn vdb relational configuration delete --config-id [--dry-run] [--force] +``` + +A group still attached to instances cannot be deleted — detach it first with +`instance update-config-group --detach`, then check [get](#get)'s `instances` to see what +is attached. The `instanceCount` the API returns is always 0, verified live against a +group that had an instance attached, so it cannot be used for this. + +The request is one of vdb's JSON **array** bodies, with no ID in the path. + +--- + +## list-params + +``` +grn vdb relational configuration list-params + --datastore-type + --datastore-version + [--deploy-type ] +``` + +The parameters one engine version accepts, with `type`, `min`, `max`, `modifiable` +and `restartRequired`. This is the reference for `update --set`: a parameter that is +not listed, or has `modifiable: false`, is rejected. + +```bash +grn vdb relational configuration list-params \ + --datastore-type MySQL --datastore-version 8.0 --output table +``` diff --git a/docs/commands/vdb/relational-instance.md b/docs/commands/vdb/relational-instance.md new file mode 100644 index 0000000..4950040 --- /dev/null +++ b/docs/commands/vdb/relational-instance.md @@ -0,0 +1,678 @@ +# relational instance + +Manage vDB Relational Database (MySQL, PostgreSQL, MariaDB) instances. + +```bash +grn vdb relational instance [options] +``` + +## Commands + +| Command | Description | +|---------|-------------| +| [list](#list) | List Relational Database instances | +| [get](#get) | Get details of one instance | +| [list-histories](#list-histories) | List an instance's action history | +| [list-secrules](#list-secrules) | List the security rules | +| [list-replicas](#list-replicas) | List an instance's read replicas | +| [create](#create) | Create an instance (**paid order**) | +| [resize-instance](#resize-instance) | Change the flavor (**paid order**) | +| [resize-storage](#resize-storage) | Change storage size or type (**paid order**) | +| [start](#start-stop-reboot) | Start a stopped instance | +| [stop](#start-stop-reboot) | Stop an instance | +| [reboot](#start-stop-reboot) | Restart the database | +| [delete](#delete) | Delete an instance | +| [create-replica](#create-replica) | Create a read replica (**paid order**) | +| [detach-replica](#detach-replica) | Promote a replica to standalone | +| [update-settings](#update-settings) | Password, public access, backup schedule | +| [update-config-group](#update-config-group) | Attach or detach a config group | +| [update-secrule](#update-secrule) | Replace the security rules | + +!!! warning "`db-` only for anything that changes state" + The listing, `get` and `list-histories` endpoints also serve PostgreSQL + Clusters (`pg-`), and so do the relational reboot, delete and secrule + endpoints. Every command here that changes an instance therefore **rejects a + `pg-` ID** — clusters are managed with + [`grn vdb postgresql cluster`](postgresql-cluster.md). + +--- + +## list + +List Relational Database instances in your project. + +### Synopsis + +``` +grn vdb relational instance list + [--page ] + [--page-size ] + [--name ] + [--status ] +``` + +### Options + +`--page` (integer) +: Page number, 1-based. Default: `1`. + +`--page-size` (integer) +: Number of items per page. Default: `50`. + +`--name` (string) +: Filter by instance name, substring match. Applied server-side. + +`--status` (string) +: Filter by instance status. Comma-separated for multiple values, e.g. +`--status ACTIVE,BUILDING`. Applied server-side. +Possible values are not fixed by the API; use the status shown in `list` output. + +!!! note "Filters do not apply to PostgreSQL Cluster rows" + This endpoint returns both Relational Database instances (IDs starting with + `db-`) and PostgreSQL Cluster records (IDs starting with `pg-`), because the + PostgreSQL Cluster API has no listing of its own. The `--name` and `--status` + filters are applied only to the `db-` rows; `pg-` rows are returned on every + query regardless of the filter. + +### Output + +`--output table` shows a fixed column set: `id`, `name`, `status`, +`datastoreType`, `datastoreVersion`, `vcpus`, `ram`, `volumeSize`, `ip`, +`created`. + +`--output json` (the default) returns the full instance object — roughly 68 +fields per instance, including billing metadata such as `cost`, `period` and +`enableAutoRenew` — alongside `pageObject`, which carries `totalPages` and +`totalElements` for paging. + +### Examples + +List the first page: + +```bash +grn vdb relational instance list +``` + +Show a readable table: + +```bash +grn vdb relational instance list --output table +``` + +Filter by name and status: + +```bash +grn vdb relational instance list --name prod --status ACTIVE +``` + +Filter by several statuses at once: + +```bash +grn vdb relational instance list --status ACTIVE,ERROR +``` + +Page through results: + +```bash +grn vdb relational instance list --page 2 --page-size 20 +``` + +Pull out just the fields you need: + +```bash +grn vdb relational instance list --query 'data[].{id:id,name:name,status:status}' +``` + +--- + +## get + +Show the full details of one database instance. + +### Synopsis + +``` +grn vdb relational instance get + --instance-id +``` + +### Options + +`--instance-id` (string) — **required** +: Database instance ID. Shell completion suggests the IDs in your project. + +!!! note "PostgreSQL Cluster IDs work here too" + Although this is the Relational Database endpoint, it also answers for the + PostgreSQL Cluster IDs (`pg-`) that appear in `instance list` — verified + against the live API. A cluster answers with `deployType: cluster`, + `numberOfNodes` and the `privateRwIp` / `publicRwIp` fields instead of a + single instance's `ip` list. + +### Output + +Always a key/value view of the whole object (~68 fields), never a fixed column +set: an instance carries four nested arrays (`ip`, `securityGroup`, `replicas`, +`sharedActions`) and a column-based table cannot pick between them reliably. + +### Examples + +Get one instance: + +```bash +grn vdb relational instance get --instance-id db-66a37ca3-e688-4a8a-9e35-a89fa69733c6 +``` + +Just the connection details: + +```bash +grn vdb relational instance get --instance-id db-66a37ca3-e688-4a8a-9e35-a89fa69733c6 \ + --query '{ip:ip,port:port,publicAccess:publicAccess}' +``` + +--- + +## list-histories + +List the recorded actions of an instance — create, update, restart, backup — with +their status and, for failures, the error message. + +### Synopsis + +``` +grn vdb relational instance list-histories + --instance-id + [--page ] + [--page-size ] +``` + +### Options + +`--instance-id` (string) — **required** +: Database instance ID. PostgreSQL Cluster IDs (`pg-`) are accepted as well. + +`--page` (integer) +: Page number, 1-based. Default: `1`. + +`--page-size` (integer) +: Number of items per page. Default: `50`. + +This endpoint takes no filters. + +### Output + +`--output table` shows `id`, `action`, `description`, `status`, `createdTime`, +`errorMessage`. + +`description` comes second because `action` on its own does not say what happened: a +settings change, a resize, and a config-group attach or detach are all recorded as +`Update`, and only the description tells them apart — +`Update database with following changes: Change flavor from db.s-general-2x4 (2 core, 4GB +RAM) to db.s-general-4x8 (4 core, 8GB RAM)`. `Create` and `Create replica` entries embed +the whole order cart and run past 200 characters, which is what the column costs; those +are also the two rows `action` alone already identifies. + +`updatedTime` is JSON-only. It equalled `createdTime` on every record observed, so in a +table it spends 31 characters repeating the column before it. + +!!! note "The item key is `content`, not `data`" + Unlike `instance list`, this listing returns its items under `content` + alongside `pageObject`, so a `--query` written for one does not transfer to + the other. + +### Examples + +Recent actions as a table: + +```bash +grn vdb relational instance list-histories \ + --instance-id db-66a37ca3-e688-4a8a-9e35-a89fa69733c6 --output table +``` + +Read the full description of each action: + +```bash +grn vdb relational instance list-histories \ + --instance-id db-66a37ca3-e688-4a8a-9e35-a89fa69733c6 \ + --query 'content[].{action:action,when:createdTime,detail:description}' +``` + +Find failed actions: + +```bash +grn vdb relational instance list-histories \ + --instance-id db-66a37ca3-e688-4a8a-9e35-a89fa69733c6 \ + --query "content[?status!='Finished']" +``` + +--- + +## list-secrules + +List the security group rules controlling access to an instance. + +``` +grn vdb relational instance list-secrules --instance-id +``` + +Table columns: `id`, `direction`, `protocol`, `portRangeMin`, `portRangeMax`, +`remoteIpPrefix`, `createdAt`. Run this before +[update-secrule](#update-secrule) — that command replaces the whole set. + +--- + +## list-replicas + +List the read replicas created from an instance. + +``` +grn vdb relational instance list-replicas --instance-id +``` + +The API answers with a **reduced** view of each replica — 19 fields, with no `ip`, +`port` or `securityGroup` — so table output shows `id`, `name`, `status`, +`datastoreType`, `datastoreVersion`, `vcpus`, `ram`, `volumeSize`, `created`. Run +`instance get` on a replica ID for its full details. + +An instance with no replicas returns an empty list. + +--- + +## create + +Create an instance. + +!!! danger "This places a paid order" + Creation goes through the order/payment flow: the API answers with an order + (`orderId`, `orderUrl`) and builds the instance asynchronously. A successful + response means the order was placed, not that the database is ready — poll + `instance get`. Use `--dry-run` first; the command also confirms before + ordering unless `--force` is given. `--user-type IAM_USER` switches from + Checkout to Auto Payment. + +### Synopsis + +``` +grn vdb relational instance create + --name + --datastore-type + --datastore-version + --package-id + --volume-type + --volume-size + --zone-id + --subnet-ids + --username + --database-name + [--password ] + [--character-set ] [--collate ] + [--config-id ] + [--public-access] + [--backup-auto --backup-duration --backup-time ] + [--poc] + [--dry-run] [--force] +``` + +### Options + +`--name` (string) — **required** +: Instance name. + +`--datastore-type` (string) — **required** +: `MySQL`, `MariaDB` or `PostgreSQL`. See +[`catalog list-datastores`](relational-catalog.md#list-datastores). + +`--datastore-version` (string) — **required** +: Engine version, e.g. `8.0`. + +`--package-id` (string) — **required** +: The flavor, as the **numeric id** from +[`catalog list-flavors`](relational-catalog.md#list-flavors) — e.g. `211`, not the +flavor name. Shell completion offers the valid ids once `--datastore-type` and +`--datastore-version` are on the command line (and narrows them further if +`--zone-id` is too), since the flavors endpoint needs those to answer. + +`--volume-type` (string) — **required** +: The volume type **NAME** — the `type` column of +[`catalog list-volume-types`](relational-catalog.md#list-volume-types). Note the +name is **zone-dependent**: the same storage is `Gen2-NVMe2-IOPS3000` in HCM03-1A +but `Gen2-NVMe2-IOPS3000-HCM03-1B` in HCM03-1B. Always read it from that command +with `--zone-id` set to the zone you are creating in. + +`--volume-size` (integer) — **required** +: Volume size in GB, within that type's `minVolumeSize`/`maxVolumeSize`. + +`--zone-id` (string) — **required** +: Availability zone, e.g. `HCM03-1A`. + +`--subnet-ids` (string) — **required** +: Subnet ID(s), comma-separated. + +`--username` (string) — **required** +: Master username. + +`--database-name` (string) — **required** +: Initial database. The API accepts exactly one at creation. + +`--password` (string) +: Master password. Read from `$GRN_VDB_MASTER_PASSWORD` when omitted, which keeps +it out of your shell history; it is masked as `***` in `--dry-run` output. Must be +**8–32 characters** of letters, digits and `$ ^ _ < >` — checked before the request +goes out, so a bad one names this flag instead of coming back as a 400. (MemoryStore +requires 16–128, so a password reused across products has to fall in 16–32.) + +`--character-set`, `--collate` (string) +: Character set and collation of the initial database (MySQL/MariaDB), e.g. +`utf8mb4` and `utf8mb4_general_ci`. + +`--config-id` (string) +: Config group to attach. See +[`catalog list-config-groups`](relational-catalog.md#list-config-groups). + +`--public-access` +: Allow public access. + +`--backup-auto`, `--backup-duration`, `--backup-time` +: Enable daily automatic backup. When `--backup-auto` is given, the API requires +both a retention of **2–14 days** and a time of day (`HH:MM`); the command rejects +the combination early rather than letting the API refuse it. Passing retention or +time without `--backup-auto` is also an error, since the API would ignore them. + +`--poc` +: Pay with PoC credit (Auto Payment only). + +!!! note "Not the same values as PostgreSQL Cluster" + `--package-id` here is a plain number and `--volume-type` is a name. The + cluster group uses opaque IDs instead (`pgp-…`, `pgst-…`) and the two sets are + not interchangeable. + +### Example + +```bash +export GRN_VDB_MASTER_PASSWORD='...' + +grn vdb relational instance create --dry-run \ + --name my-mysql \ + --datastore-type MySQL --datastore-version 8.0 \ + --package-id 211 \ + --volume-type Gen2-NVMe2-IOPS3000 --volume-size 40 \ + --zone-id HCM03-1A \ + --subnet-ids sub-66a5327f-1970-427d-b1a3-8eb146e94bab \ + --username dbadmin --database-name appdb \ + --character-set utf8mb4 --collate utf8mb4_general_ci \ + --backup-auto --backup-duration 7 --backup-time 02:00 +``` + +--- + +## resize-instance + +Move an instance to a different flavor (vCPU/RAM). + +``` +grn vdb relational instance resize-instance + --instance-id + --package-id + [--poc] + [--dry-run] [--force] +``` + +!!! danger "This places a paid order" + Same order/payment flow as create, asynchronous, and the database restarts on + the new flavor. + +`--package-id` is the numeric flavor id from +[`catalog list-flavors`](relational-catalog.md#list-flavors) — that command needs an +engine and version, and since neither is a flag here, this is one of the few flags with +**no shell completion**: the values would have to be resolved from the instance first. +The same applies to `create-replica` and `backup restore`. +Storage is resized separately with [resize-storage](#resize-storage). + +--- + +## resize-storage + +Grow an instance's volume, or move it to a different volume type. + +``` +grn vdb relational instance resize-storage + --instance-id + [--volume-size ] + [--volume-type ] + [--poc] + [--dry-run] [--force] +``` + +!!! danger "This places a paid order" + Asynchronous, like every resize. Storage can normally only grow — the command + refuses a size below the current one rather than letting the API fail. + +!!! note "The API wants both fields; the CLI fills in the one you omit" + This endpoint expects the size **and** the type on every request, using the + current value for whatever is not changing. The command therefore reads the + instance first and supplies the missing field, so `--volume-size 80` alone does + not silently reset the volume type. `--dry-run` shows the resolved request. It + also refuses a request that would change nothing. + +```bash +grn vdb relational instance resize-storage --instance-id db-... --volume-size 80 --dry-run +grn vdb relational instance resize-storage --instance-id db-... --volume-type Gen2-NVMe2-IOPS5000 +``` + +--- + +## start, stop, reboot + +``` +grn vdb relational instance start --instance-id [--dry-run] [--force] +grn vdb relational instance stop --instance-id [--dry-run] [--force] +grn vdb relational instance reboot --instance-id [--dry-run] [--force] +``` + +`stop` keeps the instance's storage and IP; `start` brings it back. Storage is +still billed while an instance is stopped, so stopping is not a way to pause costs +entirely. + +`stop` and `reboot` drop every open connection and therefore confirm first (skip +with `--force`). `start` does not prompt. + +!!! note "The API calls it `shutdown`" + The endpoint is `/shutdown`; the CLI verb is `stop`, the canonical verb across + `grn`. + +--- + +## delete + +Delete an instance and, optionally, its backups. + +``` +grn vdb relational instance delete + --instance-id + [--create-final-backup] + [--delete-all-backup] + [--dry-run] [--force] +``` + +### Options + +`--create-final-backup` +: Take one last backup before deleting. + +`--delete-all-backup` +: Also delete every existing backup of this instance. This is the only way to lose +the ability to restore; the confirmation prompt says so explicitly. + +Before doing anything the command prints the instance it is about to destroy — +including a replica count, since replicas may have to go first. This is +irreversible. + +--- + +## create-replica + +Create a read replica of an instance. + +``` +grn vdb relational instance create-replica + --instance-id + --name + [--package-id ] [--volume-type ] [--volume-size ] + [--zone-id ] [--subnet-ids ] [--config-id ] + [--public-access] + [--backup-auto --backup-duration --backup-time ] + [--poc] + [--dry-run] [--force] +``` + +!!! danger "This places a paid order" + A replica is a full instance and costs accordingly. + +**Everything defaults to the source instance.** Engine, version, flavor, volume +type and size, zone, subnet, config group, public access and backup schedule are +read from `--instance-id`, so in practice only that and `--name` are needed. Any +other flag overrides one of those; `--dry-run` shows the resolved request. + +Engine and version are deliberately **not** overridable — a replica must match its +source. + +```bash +grn vdb relational instance create-replica \ + --instance-id db-66a37ca3-e688-4a8a-9e35-a89fa69733c6 \ + --name docs-agent-replica --dry-run +``` + +--- + +## detach-replica + +Promote a read replica to a standalone instance. + +``` +grn vdb relational instance detach-replica + --instance-id + [--dry-run] [--force] +``` + +Pass the **replica's** ID, not the source's — run +[list-replicas](#list-replicas) on the source to find it. Replication stops and +cannot be re-established; the replica keeps its data, carries on as a normal +instance, and keeps costing what it costs. + +--- + +## update-settings + +Change the master password, public access, or the automatic-backup schedule. + +``` +grn vdb relational instance update-settings + --instance-id + [--password ] + [--public-access] + [--backup-auto --backup-duration --backup-time ] + [--dry-run] [--force] +``` + +Only the settings you pass change. That matters for `--public-access`: its zero value is +a real setting, so a body that always carried `false` would cut off public connectivity +every time someone rotated a password. Pass a flag only when you mean to change it. + +The **backup schedule is the exception, and always sent**: a request without `backupAuto` +is answered with HTTP 500, so the command reads the instance first and repeats its current +schedule. That also means `--backup-time` and `--backup-duration` can be changed on their +own, as long as automatic backup is on; repeating the schedule unchanged is not reported +as a change. + +`--password` falls back to `$GRN_VDB_MASTER_PASSWORD`, is masked in `--dry-run` output, +and follows the same 8–32 character rule as on [create](#create). When the API rejects a +password it echoes it back in its error message; the CLI masks any secret the request +carried before printing an error. + +!!! note "Asynchronous, and the response says nothing" + The API accepts the request and applies it in the background, answering 200 + with a payload whose fields are not meaningful (its `dbInstanceId` and + `projectId` even arrive transposed). The command therefore reports acceptance + and points you at `instance get` to confirm the result, rather than echoing the + payload. + +```bash +grn vdb relational instance update-settings --instance-id db-... --public-access=false +grn vdb relational instance update-settings --instance-id db-... \ + --backup-auto --backup-duration 7 --backup-time 03:00 +``` + +--- + +## update-config-group + +Attach a config group to an instance, or detach the current one. + +``` +grn vdb relational instance update-config-group + --instance-id + (--config-id | --detach) + [--dry-run] [--force] +``` + +The group must match the instance's engine and version — see +[`catalog list-config-groups`](relational-catalog.md#list-config-groups). The two +flags are mutually exclusive; `--detach` is required rather than accepting an empty +`--config-id`, because an unset shell variable should not silently detach your +configuration. + +!!! warning "Detaching leaves the instance RESTART_REQUIRED — and updates are then ignored" + After a detach the instance reports `RESTART_REQUIRED`. In that state this + endpoint still answers "accepted" but **silently does not apply** further + changes: attaching a group again does nothing until the instance is `ACTIVE`. + Run [reboot](#start-stop-reboot) first. The command warns when it sees a + non-`ACTIVE` status, but the API reports success either way, so the warning is + the only signal you get. + +Applying configuration can restart the database, so the command confirms first. +Like `update-settings`, it is asynchronous and reports acceptance. + +--- + +## update-secrule + +Set which networks may reach an instance. + +``` +grn vdb relational instance update-secrule + --instance-id + --rule [--rule ...] + [--add] + [--dry-run] [--force] +``` + +!!! warning "This replaces the entire rule set by default" + The API takes the full list, so any network currently allowed and not repeated + is revoked. Pass `--add` to keep the existing rules and append instead. The + command prints the current set and the resulting set before doing anything; run + it with `--dry-run` first. + +### Rule syntax + +Each `--rule` is a comma-separated list of `key=value` pairs, and the flag repeats: + +| Key | Meaning | +|-----|---------| +| `cidr` | The allowed network, e.g. `cidr=203.0.113.0/24` (**required**) | +| `port` | Single port; defaults to the instance's own port | +| `port-min`, `port-max` | Port range, given together | +| `id` | Existing rule ID, to keep a rule you are re-sending | + +The default port comes from the instance itself, so a rule needs no port for the +usual case — 3306 on MySQL and MariaDB, 5432 on PostgreSQL. An unknown key is an +error, not an ignored field: a typo'd `cird=` would otherwise send a rule with no +network and revoke everything. + +```bash +# Restrict to one network, replacing everything else +grn vdb relational instance update-secrule --instance-id db-... \ + --rule cidr=203.0.113.0/24 --dry-run + +# Add an office network without touching the existing rules +grn vdb relational instance update-secrule --instance-id db-... --add \ + --rule 'cidr=198.51.100.0/24' +``` diff --git a/go/cmd/register.go b/go/cmd/register.go index 2a87c89..5f09bb3 100644 --- a/go/cmd/register.go +++ b/go/cmd/register.go @@ -4,9 +4,10 @@ package cmd // here is the ONLY change needed to mount a new product CLI — root.go iterates // the registry and never needs editing. // -// vServer is registered in register_vserver.go behind the "!vks_only" build tag, -// so the public release binary (built with `-tags vks_only`) ships VKS only while -// vServer is still under development. +// vServer is registered in register_vserver.go and vDB in register_vdb.go, both +// behind the "!vks_only" build tag, so the public release binary (built with +// `-tags vks_only`) ships VKS only while those products are still under +// development. import ( _ "github.com/greennodehub/greennode-cli/cmd/vks" _ "github.com/greennodehub/greennode-cli/cmd/vserver" diff --git a/go/cmd/register_vdb.go b/go/cmd/register_vdb.go new file mode 100644 index 0000000..fb10f03 --- /dev/null +++ b/go/cmd/register_vdb.go @@ -0,0 +1,11 @@ +//go:build !vks_only + +package cmd + +// vDB command group. Compiled into the binary by default (so dev builds and CI +// test it), but EXCLUDED from the public release binary, which is built with +// `-tags vks_only` while vDB is still in development. Remove this tag (and the +// build flag in release.yml) once vDB is ready to ship. +import ( + _ "github.com/greennodehub/greennode-cli/cmd/vdb" +) diff --git a/go/cmd/vdb/CLAUDE.md b/go/cmd/vdb/CLAUDE.md new file mode 100644 index 0000000..e134760 --- /dev/null +++ b/go/cmd/vdb/CLAUDE.md @@ -0,0 +1,768 @@ +# vDB CLI — product notes + +Tier-2 notes for the `grn vdb` command group. The root `CLAUDE.md` has the +repo-wide conventions; this file records what is specific to vDB. + +**Do not carry vks or vserver assumptions over — vDB differs from both.** + +## Client & endpoint + +- Build the client with **`vdbclient.BuildClient(cmd)`**, which returns + `(client, error)`. It wraps `cli.NewClient(cmd, "vdb")` and attaches the + `user-type` header. +- Endpoint: `internal/config` REGIONS key `vdb_endpoint` + (`https://vdb-gateway.vngcloud.vn`). **HCM-3 only** — vDB is not offered in HAN, + so that region deliberately has no `vdb_endpoint` and `GetEndpoint` errors with + a message naming the service and region. +- **There is no project ID.** Unlike vserver, no vDB path or query takes one + (`projectId` appears only in responses). `vdbclient` has no `ProjectID` helper + on purpose — do not add one. + +## Four products, one gateway + +vDB is four separate APIs that share only a hostname: + +| Prefix | Group | Command root | +|---|---|---| +| `/vdb-relational/v1` | Relational (MySQL, PostgreSQL, MariaDB) | `grn vdb relational` | +| `/vdb-memory/v1` | MemoryStore (Redis) | `grn vdb memorystore` | +| `/vdb-kafka` | Kafka | `grn vdb kafka` | +| `/vdb-postgresql/v1` | PostgreSQL Cluster | `grn vdb postgresql` (+ 7 relational endpoints — see next section) | + +Two of them run PostgreSQL and are easy to confuse: `relational` is single +instances (`db-`), `postgresql` is 2-10 node clusters (`pg-`). Their flavors, +volume types and catalogs are NOT interchangeable, even where the values look +alike. + +They disagree on paths and HTTP verbs for the *same* operation — deleting a +backup is `DELETE /backups/{id}/delete` in relational but `POST /backups/delete` +in memory; getting an instance is `/database-instances/id/{id}` vs +`/database-instances/{id}`; the path parameter is `instanceId` in one and +`dbInstanceId` in the other. + +**Keep each group in its own subpackage and let paths be duplicated.** Share only +generic helpers (`vdbclient`), never a path builder — a "unified" path helper +across groups will be wrong for at least one of them. + +## PostgreSQL Cluster spans TWO prefixes — do not guess + +A `pg-` cluster is served by **21 operations: the 14 under `/vdb-postgresql/v1` +plus 7 borrowed from `/vdb-relational/v1`.** The list below is the console's own +API surface for the resource, supplied by the product team on 2026-08-13 — treat it +as the reference rather than deriving anything from the spec's grouping, which +hides the split. + +Borrowed from **relational** (the pg group has no equivalent at all): + +| Operation | Path | +|---|---| +| list (mixed with `db-` rows) | `GET /vdb-relational/v1/database-instances` | +| get by id | `GET /vdb-relational/v1/database-instances/id/{id}` | +| action history | `GET /vdb-relational/v1/database-instances/{id}/histories` | +| security rules read/write | `GET`+`PUT /vdb-relational/v1/database-instances/{id}/secrules` | +| reboot | `POST /vdb-relational/v1/database-instances/{id}/reboot` | +| delete | `POST /vdb-relational/v1/database-instances/{id}/delete` | + +Own, under `/vdb-postgresql/v1`: + +| Operation | Path | +|---|---| +| create (order flow, costs money) | `POST /cluster` | +| resize (nodes and/or volume) | `PUT /cluster/{id}/resize` | +| update settings (password, publicAccess) | `PUT /cluster/{id}/settings` | +| attach config group | `PUT /cluster/{id}/config-group` | +| volume used | `GET /cluster/{id}/volume-used` | +| catalog | `GET /cluster/datastore`, `GET /cluster/flavors?zoneId=`, `GET /cluster/volume-types?zoneId=` | +| backups | `GET /backup/backup-vdb`, `GET /backup/backup-vdb/{clusterId}/detail`, `GET /backup/backup-vdb/{clusterId}/restore-point`, `POST /backup/backup-vdb/{clusterId}/backup-now` | +| backup catalog | `GET /backup/location`, `GET /backup/policy` | + +Consequences for `grn vdb postgresql` (implemented — `cmd/vdb/postgresql/`): + +- **There is no `start` / `shutdown` / `resize-storage` for a cluster**, and no + replica endpoint. Storage growth is part of `PUT /cluster/{id}/resize`. +- The group is complete on its own: `cluster list/get/list-histories/list-secrules/ + update-secrule/reboot/delete` call the **relational** paths from inside + `cmd/vdb/postgresql/cluster`, so a user never has to know the split. Do not + invent `/vdb-postgresql` equivalents; they do not exist. +- **Every command in the group rejects a `db-` ID** via + `vdbclient.RequireIDWithPrefix`. This is load-bearing, not cosmetic: those + relational paths serve both products, so without the guard + `postgresql cluster delete --cluster-id db-…` would delete a Relational Database + instance. Any future command that reuses a shared endpoint needs the same guard. +- `cluster list` filters to `pg-` rows **and applies `--name`/`--status` + client-side**, because the server ignores both for cluster rows. `pageObject` + then describes the underlying mixed listing, not the filtered result — say so in + any doc. +- Config groups for a cluster come from relational `GET /configurations` filtered + to `deployType == "cluster"` (`catalog list-config-groups`); the cluster product + has no config-group listing either. +- **The backup paths take a CLUSTER id, not a backup id** — `…/backup-vdb/{id}/detail` + is "the backup state of this cluster". Do not model it on relational's + `/backups/detail/{backupId}`. +- Unlike relational's `update/setting` and `update/config-group`, the pg + equivalents **do** have a documented response (`InstanceActionResult`), so they + are not part of the 6-endpoint blind spot below. +- **Multi-AZ (spec update 2026-10-01): `netIds` takes one subnet per zone.** One + subnet places every node in its zone — the only mode before this; several subnets, + one per zone, spread the nodes across those zones. `cluster create` enforces the two + inferences client-side: no repeated subnet (a repeat names one zone twice) and at + most one subnet per node (every zone named has to receive a node). The flavors and + volume types a Multi-AZ cluster can use are a separate list — `GET /cluster/flavors` + and `GET /cluster/volume-types` take `?multiZone=true`, exposed as `--multi-zone` on + `catalog list-flavors` / `list-volume-types` and sent only when set, since the API + applies its own default when the param is absent. A multi-zone cluster reports its + per-zone placement as `multiZoneInfos[]` on the instance get/list payloads + (`zoneId`, `subnetId`, per-zone `privateRwIp`/`publicRwIp`/`privateRoIp`/`publicRoIp`, + `rwPort`/`roPort`, `status`), empty/null for single-zone clusters. None of this is + exercised live yet — the mapping is from the updated spec. +- The supplied capture writes every path with a leading `/vdb` + (`GET /vdb/vdb-relational/v1/…`). That is a **UI-only prefix, stripped by the + console's proxy_pass** before the request reaches the API (confirmed by the + product team). Ignore it: paths in this repo start at `/vdb-relational` / + `/vdb-postgresql`, as `vdb_endpoint` expects. + +Live-verified so far: list, get by id, histories (2026-08-13, HCM-3). The rest of +the mapping is from the supplied list, not yet exercised. + +## MemoryStore differs from Relational in paths, not just prefix + +`cmd/vdb/memorystore/` is implemented (42 commands, 44/44 endpoints). Same five nouns +as relational, and the *same operation* is spelled differently almost everywhere. +Copying a relational path and swapping the prefix is wrong in these places: + +| Operation | relational | memorystore | +|---|---|---| +| get instance | `/database-instances/id/{id}` | `/database-instances/{id}` | +| update settings | `/{id}/update/setting` | `/{id}/update-setting` | +| update config group | `/{id}/update/config-group` | `/{id}/update-config-group` | +| catalog root | `/database-instances/*` | `/database/*` | +| flavor codes | `…/flavor_zones/codes` | `/database/codes` | +| volume types | `…/volume/types` | `/database/volume-types` | +| backup detail | `/backups/detail/{id}` | `/backups/{id}/detail` | +| backup delete | `DELETE /backups/{id}/delete` | `POST /backups/delete` (no ID in path) | +| backups of an instance | `/backups/insId/{id}` | `/database-instances/{id}/backups` | +| config group detail | `GET /configurations/id?id=` | `GET /configurations/{id}/detail` | +| config group delete | `DELETE /configurations/delete` | `POST /configurations/delete` | +| **storage you own** | `/backup-storages/information` | **`/backup-storages`** | +| **storage you can buy** | `/backup-storages` | **`/backup-storages/packages`** | +| release storage | `/actions/deletions` | `/actions/delete` | + +The last three invert: `GET /backup-storages` lists purchasable packages in relational +and owned quota in memorystore. + +Product differences that shape the commands: + +- **No volume at all.** `CreateMemDbInstanceRequest` has no `volumeType`/`volumeSize`, + and there is no resize-storage endpoint — capacity is the flavor's RAM. A test + asserts the command does not exist. +- **A master password instead of a user/database pair**: `redisPasswordEnabled` + + `redisPassword`, no `user`, no `databases`. Two rules are enforced client-side: + public access requires the password, and passing a password implies requiring it + (otherwise the body sets a password and disables it in the same request — a bug this + code shipped for one build before being caught). +- **`update-setting` needs `editRedisPassword: true`** alongside any password change. + It is a marker, not a setting, so the CLI sets it rather than exposing a flag. +- **Zones and subnets come from the RELATIONAL catalog** — memorystore has no endpoint + for either (product owner, 2026-08-13), so the completion keys `vdb:relational-zone` + and `vdb:relational-subnet` are reused deliberately. +- **`/vdb-memory/v1/database/status` is OUTDATED — do not use it** (product owner, + 2026-08-13). It is the only status enumeration in the API and it has neither a + command nor a completer; `--status` completion reads the instance listing. +- Instance IDs are `db-…`, the SAME prefix relational uses, so the prefix guard cannot + tell the two products apart. Their listings are separate, which is what does. No + cross-product damage was observed (the wrong product's ID 404s), but do not rely on + the guard for that. + +## Kafka shares the gateway and nothing else + +`cmd/vdb/kafka/` is implemented — 36 commands over 35 of the API's 36 endpoints. It is +the group that breaks the most assumptions the other three teach: + +| Assumption from the other groups | Kafka | +|---|---| +| every response is `{code, message, data}` | only 9 of 36 are wrapped; the rest are bare objects/arrays/strings | +| lists are paginated, filters are server-side | no `pageNumber`, no filters, no `--page` at all | +| mutating arguments go in a JSON body | 6 endpoints take them in the QUERY STRING of a body-less PUT | +| the prefix carries `/v1` | `/vdb-kafka` has none | +| security rules are replaced as a set | added and removed one at a time, and there is **no endpoint that lists them** | +| there are backups | none, at all | +| a config group is edited in place | groups are versioned and immutable; a cluster attaches to a *version* | +| flavors and volume types are numeric ids / names | opaque `flav-…` and `vtype-…` strings | + +Consequences encoded in the code: + +- **`vdbclient.Client.Request`** exists for the query-string PUTs (`kafka-broker-count`, + `kafka-storage-size`, `kafka-storage-type`, `authentication`, `config-group`, + `public-access`). `Put` takes no params and `Delete` takes no body, so neither fits. + `--dry-run` for those prints the QUERY, via `previewQuery` in `kafka/cluster/helpers.go` + — `vdbclient.PreviewBody` would print `null`. +- **`vdbclient.Client.NoContent`** is used by the 11 mutating endpoints the spec declares + as a bare `string`. Live (2026-08-17) they answer 200/204 with an **empty body**, which + would print as `{}`. Per the product decision the HTTP status is the whole result: those + commands print their own confirmation and name the read command that shows the effect. +- **`cluster list-secrules` has no endpoint** — it reads the cluster and prints the nested + `securityGroupRules`. Note the LISTING omits that field even though `KafkaCluster` + declares it; only `GET /clusters/{id}` carries it (verified live). +- **`GET /vdb-kafka/database/configs` has no command** (product decision: it is the + service's own settings). It is read internally anyway, because it is the ONLY place the + Kafka version list exists — `kafkaVersions` is a JSON array encoded inside a string, + since the payload is a flat `map`. `catalog/completion.go` decodes it + twice. The same payload also confirms the client-side bounds this group enforces: + `minKafkaBrokers` 3 / `maxKafkaBrokers` 10, `maxTopicPartitions` 2048, + `minTopicRetentionHours` 1 / `maxTopicRetentionHours` 2160, + `secGroupRulePorts` `[9092,9094,9096,9194,9196]`. +- **`cluster create` requires an authentication mode and a resolvable project.** The + gateway's own validator refuses `mtlsAuthen`/`saslAuthen` both false — measured + live, a bare `bad_request: Need to enable at least one authentication mode` that + names no field — so the CLI rejects the combination where the error can name the + flags. `vserverProjectId` is resolved from `--vserver-project-id` or the profile's + `project_id` (`GRN_DEFAULT_PROJECT_ID`); when neither is set the command errors + naming both sources rather than sending `""`, which the API would blame elsewhere. + A user's topic-name lists are likewise always sent as `[]`, never `null`: the API + rejects null for those array fields (measured live — 400 creating a user with no + topic permissions), which is why `topicNames` exists next to `stringList`. +- **`tags` must be sent as an empty map — an absent field is a bare 500.** The Kafka + backend was designed to carry user tags on a cluster (`Map`); the + feature was dropped, but the validation code still calls `getTags().size()`, so a + create whose `tags` field is absent dies as a bare `HTTP 500 Internal Server Error` + — measured live 2026-10-19 on the auto-payment (`IAM_USER`) flow, which validates + immediately; the checkout (`ROOT_USER`) flow accepts the order first and would fail + later, at payment. `cluster create` therefore always sends `"tags": {}`. There is + still no way to set a non-empty value — no flag, and the request schema has no + `tags` property — while `KafkaCluster` responses carry the (empty) map. Do not + "clean up" the field away. +- **`user get-creds` answers with a ZIP archive, not JSON** (live, 2026-10-19): an + mTLS user gets `/mtls/{ca.crt, ca.p12, ca.password, user.crt, user.key, + user.p12, user.password, config.properties}`. On 2026-08-17 the same endpoint + answered a bare JSON array of strings — the API changed shape in between — so + `runGetCreds` reads via `vdbclient.Client.Raw` and branches on the `PK` magic: + ZIP → written to `--output-file` (default `kafka-creds-.zip`, 0600), + anything else → parsed as JSON and printed. Do not route this endpoint through + `Get`, which JSON-parses and dies on the archive. +- **`serverFlavorId` takes `flavorId`, `kafkaStorageType` takes `kafkaUuid`** — both + verified live against a running cluster. Not the numeric `id`, and not the display + `type` (`kafka.Gen2-NVMe2-IOPS3000`). Relational Database is the opposite on both counts. +- **`GET /database/volume-types` is the one catalog endpoint whose envelope does not hold + a bare array**: `data` is `{projectId, data: [...]}`. `catalog.go` strips the second + layer (`volumeTypeItemsKey`) for both output and completion. +- **The flavors `version` filter is accepted and ignored** (3.7.0 and 9.9.9 both return + all 24 rows, and the rows carry no version field), while `type` really filters and is + case-insensitive (`kafka`/`Kafka` fine, `bogus` → 0). `--datastore-type` is the one + hard-coded `cli.FlagValues` in the vDB tree, for want of an endpoint — the value was + measured, not assumed. +- **Every "update" that replaces a set reads the resource first**: `topic update`, + `user update` and `cluster update-authentication` all take the FULL set on every call, + so an unset flag means "keep the current value". `topic update` additionally OMITS a + retention field that is null on the topic and unset on the command line — sending 0 + would be an invalid value, not an absence. +- **Statuses are their own vocabulary**: `WAITING_CREATING`, `CREATING`, `ACTIVE`, + `WAITING_UPDATING`, `UPDATING`, `DELETING` — and the create path adds its own + pipeline, observed live 2026-10-19: `WAITING_WORKSPACE_CREATING` → + `WORKSPACE_CREATING` → `INFRA_CREATING` → `NODES_CREATING` → + `WAITING_CLUSTER_CREATING` → `CLUSTER_CREATING` → `ACTIVE`, ~8 minutes end to end. + Operations are refused while a resource is + busy, with a clear message (`Cluster … is not active`, `Topic … does not have + appropriate status`) — so anything scripted must poll. +- **`createdAt` differs by endpoint, as elsewhere in vDB**: listings return a localized + display string (`Aug 17, 2026, 10:05:03 AM`, GMT+7), detail endpoints ISO-8601 UTC. + Pass through unchanged; never compare across the two. +- **No prefix guard.** `RequireIDWithPrefix` is deliberately not used: no Kafka path is + shared with another product, so there is nothing for a guard to protect against, and the + ID formats (`clus-`, `topic-`, `user-`, `cgroup-`, `cgroupver-`) were confirmed only + from one project's data. + +Live-verified 2026-08-17 on HCM-3: every read command, every completer, and the full +topic write path (create → get → update → delete, including the busy-status refusals). +The cluster-level order flows (create, the three resizes) and the destructive cluster +commands were exercised only with `--dry-run`. + +Re-verified live 2026-10-19 on HCM-3, on a freshly created ACTIVE cluster: +`user create` (the API accepts `[]` for the topic-name lists and 400s on `null`), the +full `get-creds` ZIP path above, and `user delete`. A `get-creds` while the user is +still `CREATING` answers HTTP 400 with an empty message — poll `user get` for `ACTIVE` +first. + +## API quirks + +- **`update/setting` and `update-setting` reject a partial body: `backupAuto` is + mandatory.** Verified live on 2026-08-13 in BOTH relational and memorystore: a body of + `{dbInstanceId, redisPassword, editRedisPassword, redisPasswordEnabled}` answers + **HTTP 500**, and so does one carrying only `publicAccess`; add `backupAuto` (plus + `backupTime`/`backupDuration` when it is true) and the same request succeeds. Both + `settingsBody` implementations therefore take the current instance record and repeat its + schedule when the user named no backup flag. Do not "simplify" that back to + changed-fields-only. The pg `PUT /{id}/settings` endpoint is a different request class + with no backup fields and is unaffected. +- **`instanceCount` on a config group is always 0**, in all three products, even for a + group with an instance attached (live, 2026-08-13). It is excluded from every column set + for that reason. The truth is `configuration get` → `instances[]`. +- **An attached config group shows up as `configuration: {id, name}` on the instance, + while the sibling `configId` stays null.** Read `configuration`, never `configId`. A + detach leaves the instance `RESTART_REQUIRED` until a reboot. +- **"Accepted" is not "applied", and a second call while the first runs is silently + dropped.** The async endpoints return 202 regardless; if the instance is mid-change the + work is discarded and only `list-histories` shows it, as a `Failed` row with + `Cannot perform action EDIT, database [...] status is BUILDING`. Anything scripted must + poll for `ACTIVE` between changes. This is also why `warnIfNotActive` exists. +- **Master-password rules differ per product** (`internal/vdbclient/credential.go`): + 8–32 characters for relational, 16–128 for memorystore, and in both only letters, + digits and `$ ^ _ < >`. MemoryStore additionally refuses a password containing BOTH + `<` and `>` — either alone is fine (live probe). pg stays deliberately unchecked + client-side: the spec documents its rules as of 2026-10-01 (8–32, the same charset, + must start with a letter and end with a letter or digit), but they are not + live-verified — and the start/end rule is not enforced for relational either, whose + live probes never covered it — so the API's own message, surfaced in full by the + error layer, remains the check. +- **A rejected password comes back in the error message in plain text** + (`Redis password [hunter2…] contains invalid character`). `enrich` takes the request + body so it can mask any value the body carried under a sensitive key before the message + is printed. Keep passing the body when adding a client method. +- **`action` in a history entry does not identify the action; `description` does.** + Settings changes, resizes and config-group attach/detach all record as `Update`, and + only `description` says which (live, 2026-08-13). It is the second column of + `historyColumns` in all three products for that reason — an earlier version left it out + as "too long", which made the table useless for exactly the rows people look up. + `updatedTime` went the other way: it equalled `createdTime` on all 57 records seen, so + it is JSON-only now. Tests assert both. +- **A flavor name can appear twice in one zone**, once with `familyType`/`platformType` + populated and once with both null (e.g. `db.s-general-2x4` = id 179 with + `general-purpose`/`code-s`, id 227 with nulls). They are different package ids. When + looking one up for `--package-id`, filter on `platformType`, not the name alone. +- **Response envelope**: relational / memorystore / postgresql wrap every payload + as `{code, message, data}` — all 42 `Wrap*` schemas in the spec have exactly + those three properties. `vdbclient.Output` / `OutputWithColumns` call + `vdbclient.Unwrap` to strip that layer. + Kafka is mixed: only 9 of its 36 operations are wrapped, the rest return bare + objects, bare arrays or plain strings. `Unwrap` passes those through untouched, + so calling it is safe — just never assume a kafka response was wrapped. +- **The payload inside the envelope varies per endpoint — look it up, do not + generalise.** Across the 112 wrapped operations: + + | Payload shape | Endpoints | + |---|---| + | bare array | 76 | + | the resource object itself | 17 | + | paginated list (`{items[], pageObject}`) | 8 | + | **spec declares no shape at all** | 6 | + | unpaginated list wrapper | 3 | + | `map` | 1 | + | plain string | 1 | + +- **Paginated lists use two different item keys.** Only 2 of the 8 use `data` + (`GET /database-instances`); the other 6 use **`content`** (`/backups`, + `/configurations`, `/histories`). A `--query` example or doc page written for + one does not transfer to the other. +- **6 endpoints take a JSON ARRAY as the request body**, not an object — both + `delete` endpoints for backups and configurations, and both `secrules` updates. + Send a slice. +- **The action endpoints share ONE request class, but each accepts only its own + action.** `start`, `stop`, `reboot`, `detach-replica` and `delete` all take + `{databaseInstances[], action, resType}` with the ID repeated in the body, and the + spec lists every action value the family knows + (`start|stop|reboot|detach_replica`) on each of them. That is a spec artifact: + send the action matching the path and nothing else (confirmed by the product + team). `resType` is always `dbaas`. + Same story for the per-instance `config` on delete: the schema offers + `createFinalBackup` and `deleteAllBackup`, but **a PostgreSQL Cluster honours only + `createFinalBackup`** — `deleteAllBackup` is for Relational Database instances. + Do not read a shared schema as "everything applies everywhere". +- **6 endpoints have no documented response shape in the spec** — it declares + `data: {"type": "object"}` with no properties. They are the *same three operations + in each of the two instance families*. **All six are resolved**: real responses + supplied by the product team on 2026-08-13, recorded below. Use these; do not + re-derive them from the spec, which still says nothing. + + | Operation | Relational | MemoryStore | + |---|---|---| + | list replicas | `GET /database-instances/{id}/replicas` | `GET /database-instances/{id}/replicas` | + | update setting | `PUT /database-instances/{id}/update/setting` | `PUT /database-instances/{id}/update-setting` | + | update config group | `PUT /database-instances/{id}/update/config-group` | `PUT /database-instances/{id}/update-config-group` | + + **`/replicas` → `data` is a BARE ARRAY** of replica objects, identical in both + families. Each item is a *reduced* instance: 19 fields, not the ~68 of a full one + — `id`, `name`, `status`, `ram`, `vcpus`, `datastoreType`, `engineGroup`, + `datastoreVersion`, `volumeType`, `volumeSize`, `created`, `updated`, + `publicAccess`, `backupAuto`, `volumeTypeZoneId`, `quotaPackageId`, `poc`, + `bandwidth`, `enableAutoRenew`. + Two consequences: **there is no `ip`, `port` or `securityGroup`**, so the instance + `list` column set cannot be reused as-is; and since the items carry no nested + arrays at all, `OutputWithColumns` is unambiguous here. + + **The four `update-*` endpoints share one response**, and all four are + **asynchronous**: + + ```jsonc + {"code": 200, "message": "success", + "data": {"status": 202, "dbInstanceId": "...", "projectId": "..."}} + ``` + + Treat a successful HTTP status as "accepted", not "applied" — `data.status` is + 202 while the envelope `code` is 200 — and poll `instance get` for the effect. + Note this is NOT the `InstanceActionResult` that the PostgreSQL Cluster + equivalents return (no `success`, `action` or `errorMsg` field), which is why + copying the sibling shape would have been wrong. + **Ignore `data` entirely on these four.** `dbInstanceId` and `projectId` come back + transposed (the project ID sits in `dbInstanceId` and vice versa) and the product + team confirmed the payload carries no meaning: the HTTP status is the whole + result. A command here should print its own confirmation and tell the user to poll + `instance get` — never echo this `data`. + + Note the earlier count of "7" also included kafka `GET /database/configs`; that + one is documented after all — `map` via `additionalProperties`, + which the api-map script used to mislabel. +- **`created`/`updated` differ per endpoint in format AND timezone.** The instance + listing returns `2025-09-22 08:24:18.0` — no offset, and the value is local + GMT+7 — while `/replicas`, histories and backups return + `2026-08-13T03:31:27.000+00:00`, i.e. UTC with an explicit offset. Measured on the + same cluster, the two are exactly 7 hours apart, to the second. + + **Decision (product owner, 2026-08-13): these fields are display-only. Pass the + string through unchanged.** Do not parse, convert, normalise or offer a flag for + it — a converter would have to assume "missing offset means +07", which breaks + silently the moment the backend or the region changes. Any command that needs to + compare times must use the values from a single endpoint, never across two. + +**Look these up rather than guessing**: `docs/superpowers/vdb-api-map.py` +regenerates `vdb-api-map.md` — every operation with its path/query/header params, +request body and response shape, read straight from the spec. That directory is +local-only and git-ignored; run the script to recreate it. +- **Pagination is 1-based**: `pageNumber` (from **1**) + `pageSize`. Same as + vserver, opposite of vks. `--page` passes straight through — no offset. Build + the query with `vdbclient.BuildListQuery`. `pageObject.number` in the response + echoes the 1-based page, so it can be fed back in (verified live). +- **`filterRequest` is flattened**: the spec models list filters as a + `filterRequest` object, but the API takes plain query params + (`?name=x&status=ACTIVE`), with the key repeated for multiple statuses. Not JSON. + `name` is a substring match. Both verified live. +- **`GET /vdb-relational/v1/database-instances` is a MIXED listing.** It returns + Relational Database instances (id prefix `db-`, `dbBackendId` set) *and* + PostgreSQL Cluster records (id prefix `pg-`, `dbBackendId` null). That is by + design: the PostgreSQL Cluster API has **no list and no get-by-id endpoint at + all**, so this is the only way to enumerate clusters. + **The `name` and `status` filters do not apply to the `pg-` rows** — they come + back on every query, including `?name=` and `?status=`. + Never document these filters as exact, and filter client-side by id prefix if a + command must show only one product. + **Seven relational endpoints serve `pg-` IDs on purpose** — get-by-id, histories, + secrules (read + write), reboot and delete, besides this listing. A cluster comes + back with `deployType: cluster`, `numberOfNodes` and `privateRwIp`/`publicRwIp` + instead of an `ip` list. See the PostgreSQL Cluster section above for the full + map; resize, settings and backups are NOT among them. +- **Latency differs by an order of magnitude.** The catalog endpoints answer in + ~0.2s, `GET /database-instances` in ~1.3s with spikes past 6s (measured live in + HCM-3). That matters for `cli.FlagFromAPI` completion, whose 2s bound the + instance listing sometimes exceeds — instance-ID completion is best-effort by + nature, catalog-backed completion is reliable. +- **`user-type` header**: `ROOT_USER` (Checkout) or `IAM_USER` (Auto Payment) on + the ~19 chargeable endpoints. Exposed as the persistent `--user-type` flag on + `grn vdb`; `BuildClient` sends it only when non-empty so the API keeps its own + `ROOT_USER` default. +- **Create is an order/payment flow** (`/payment/database-instances`, + `createOrderCluster`) — it **costs money** and completes asynchronously. Every + `create` and `resize` needs `--dry-run` plus a confirmation and `--force`, even + though `conventions_test.go` only requires them for delete/stop/reboot. +- **Mutation response shapes, all verified live 2026-08-13** (relational): + + | Endpoint | Response | + |---|---| + | create, create-replicas | `OrderResponse[]` — `resourceId` is the NEW instance ID | + | resize-instance, resize-storage | `OrderResponse[]` with **`resourceId: null`** | + | start, stop, reboot | `ActionDbInstancesResponse[]` — `code: 202`, `success: true`, `status: PROCESSING` | + | delete, detach-replica | same shape but **`code: null`, `success: null`** — do not test `success == true` | + | update/setting, update/config-group | meaningless body, see below | + | secrules PUT | `SecurityGroupRuleEntity[]`, the resulting rule set | + + Note `databaseInstances` is a plain STRING (the instance ID) in an action + *response* while it is an array of objects in the *request*. +- **"Optional" fields that are effectively required, all found by running the real + API (2026-08-13). The pattern is the same each time: the request is accepted, then + the work fails in the background — so the spec cannot be trusted here and neither + can a 200.** + + | Endpoint | Field the spec calls optional | What happens without it | + |---|---|---| + | `POST /backups/create` | `description` | 200 + `success:true` + a backup ID, then the backup fails with *"An error occurred when communicating with system"*. 4 failures out of 4 (including `description: ""`), 2 successes out of 2 with a non-empty one. | + | `POST /backups/{id}/restore` | `locateZoneId` | HTTP 400 blaming the *flavor and volume type*: `Package ID 180 is invalid; Volume type Gen2-NVMe2-IOPS3000-HCM03-1B is invalid` — even when those are exactly what the source instance runs. The zone is the real cause. | + + Both are enforced client-side so the error names the actual problem. +- **`PUT /configurations/update` REPLACES the whole `values` map.** Setting one + parameter drops every other parameter the group held (verified live). The CLI shows + before/after and offers `--merge`. It is also asynchronous: a read straight + afterwards still returns the old map. +- **"Not found" can arrive as HTTP 200 with `data: null`.** A backup whose creation + failed answers `{"code":200,"message":"success","data":null}`. `Unwrap` hands back + the envelope in that case, so a caller reading fields off it sees empty strings and + zeroes — which is how a `restore` would end up sending a request built from + nothing. **Any command that fetches one resource and reads fields from it must use + `vdbclient.PayloadObject` and report the miss.** +- **Backup storage: the free allowance is per-instance, and `resourceId` behaves + differently from an instance resize.** Verified live 2026-08-13 by buying, resizing + and releasing a package: `freeBackupStorage` is the SUM of every instance's + `freeBackupSize` (100 GB for a 4x8 flavor), so it changed 300 -> 400 -> 300 as an + instance appeared and went; buying returns `OrderResponse[]` whose `resourceId` is + the new `db-bk-storage-…`; and unlike an instance resize, the storage resize DOES + return a `resourceId`. Both create and resize applied within seconds, not minutes. + `backupPackageName` is null in the listing even right after a purchase. +- **A resize to the value it already has is accepted, then fails asynchronously.** No + 4xx: the order goes through and the history records `Update ... Failed` with an + empty change description. That is why `resize-storage` refuses a no-op + client-side — the API will not tell you. +- **Several endpoints demand fields they could have defaulted themselves.** Where + that happens, read the current state and fill them in rather than making the user + retype them — and say so in `--dry-run`: + `relational resize-storage` must send size AND type on every request ("if + unchanged, use the current value"), so it GETs the instance first; + `relational create-replica` requires a dozen fields that all describe the source, + so it defaults every one of them to it. Two field-name traps there: the flavor is + `quotaPackageId` on an instance but `packageId` in a request, and an attached + config group appears only in the nested `configuration.id` — the flat `configId` + is null (verified live). +- **`--package-id` and volume types are spelled differently per product.** + Relational takes the flavor's **numeric id** and the volume type **NAME**; + PostgreSQL Cluster takes opaque IDs (`pgp-…`, `pgst-…`). Passing one product's + values to the other is rejected. Two details verified by a real create + (2026-08-13): + - `packageId` is accepted as a **JSON string** (`"179"`), which is what the CLI + sends, even though the console sends it as a number. Both work. + - **The volume type name is zone-dependent.** `Gen2-NVMe2-IOPS3000` in + HCM03-1A but `Gen2-NVMe2-IOPS3000-HCM03-1B` in HCM03-1B — the zone is part of + the name in some zones and not others. Always take the string from + `catalog list-volume-types --zone-id `; never + build it by hand or reuse one zone's value in another. + - Omitting `configId` works; the console sends `""` instead. Both mean + "no config group" **on create** — but not on update, where `""` detaches. +- **Automatic backup is a set of three fields**: `backupAuto` plus, when it is on, + `backupDuration` (2-14 days) and `backupTime` (`HH:MM`). Send all three together + or none; the CLI validates the combination client-side so the error names the + flag rather than coming back as a 400. +- **`update/config-group` detaches on `null`, NOT on `""` — and the two families + disagree.** The spec says "set to empty string to detach" for both. Verified live + 2026-08-13: + + | Group | Detach payload | Result | + |---|---|---| + | relational | `{"configId": ""}` | **HTTP 400** `The configId doesn't exist` | + | relational | `{"configId": null}` | detaches, instance becomes RESTART_REQUIRED | + | postgresql | `{"configGroupId": ""}` | accepted, `success: true` | + + This is the clearest case yet for the rule against extrapolating between families: + fixing relational to `null` and "helpfully" doing the same to postgresql would have + broken a working command. +- **These async updates can be accepted and silently dropped.** While an instance is + RESTART_REQUIRED, `update/config-group` answers 202 `success` and never applies the + change — an attach was issued, acknowledged and lost (verified live). The change + landed only once the instance was ACTIVE again. `warnIfNotActive` in + `relational/instance/async.go` warns before sending; nothing else can detect it, + since the API reports success either way. +- **Status values are mixed-case and include more states than the spec implies.** + Seen live: `BUILDING`, `ACTIVE`, `RESTART_REQUIRED`, `SHUTDOWN`, and lowercase + `stopping`, `starting`, `deleting`; action responses carry `PROCESSING`. **Compare + case-insensitively.** +- **A deleting instance can report ACTIVE again before it disappears.** Observed + `deleting` → `ACTIVE` → HTTP 404. A waiter must treat only the 404 as "deleted"; + keying on ACTIVE would call a deletion "finished" as a success. +- **A BUILDING instance reads back defaults, not what you sent.** During + provisioning `get` reported `backupAuto: false` with null duration and time, and a + null `ip`, for an instance created with backup enabled; all three appeared + correctly the moment status became ACTIVE (verified end-to-end, 2026-08-13, ~5 + minutes). So do not diff a create request against a BUILDING instance and conclude + the API ignored a field — wait for ACTIVE. Anything that verifies or waits must key + on `status`. +- **No enums in the spec**: status, datastore type, volume type and flavor are + free-form strings. Shell completion must use `cli.FlagFromAPI` against the + catalog endpoints (`/database/datastore`, `/database/volume-types`, + `/database/flavors`), not hard-coded `cli.FlagValues`. + **`/vdb-memory/v1/database/status` is the one status enumeration in the API, and it + is OUTDATED — do not use it** (product owner, 2026-08-13). Both groups derive + `--status` completion from their own instance listing instead, which cannot go + stale. +- **Case of `datastoreType` is not consistent.** `/database-instances/datastore` + reports lowercase (`postgresql`), instance payloads report display form + (`PostgreSQL`), and the `/flavors` query accepts either (verified live). + +## Command style + +- **Engine group + noun group + verb**: `grn vdb relational instance list`, + `grn vdb kafka topic create`. Chosen over a single `--engine` flag because the + four APIs share no shape; see the table above. +- Package layout mirrors the tree: `cmd/vdb///.go`. +- Rename these API operations to canonical verbs: `updateSecurityRules` → + **`update-secrule`**, `updateSettings`/`updateDatabaseSetting` → + **`update-settings`** and `update*ConfigGroup` → **`update-config-group`** in every + group. The API paths disagree on singular vs plural (`/settings` for pg, + `/update/setting` for relational); the CLI does not follow them there — one verb + per operation across products is the point of this list. + Kafka's `regenerateUserAuthenCredential` + (`PUT /vdb-kafka/clusters/{id}/users/{userId}/regenerate-creds`) is + **`generate-creds`** (`regenerate` is not one of the canonical verbs), and + `getUserAuthenCredential` is **`get-creds`**. + Kafka's three chargeable PUTs keep the verb that says what they cost: + `kafka-broker-count` → **`resize-brokers`**, `kafka-storage-size` → + **`resize-storage`** (matching relational, though Kafka's carries no type), and + `kafka-storage-type` → **`update-volume-type`**. +- **Credentials are a Kafka-only concept.** Kafka has + `GET /clusters/{id}/users/{userId}/authen-creds`, the regenerate above, and + `PUT /clusters/{id}/authentication`. **MemoryStore has no user or credential + endpoint at all** — a Redis password is changed through fields of its + `update-setting` request (`editRedisPassword`, `redisPasswordEnabled`, + `redisPassword`), so it is part of `update-settings`, not a command of its own. + (Corrected by the product owner on 2026-08-13 after I attributed `generate-creds` + to MemoryStore.) +- Mutating commands are gated beyond what `conventions_test.go` requires: the + order-flow ones (`create`, `resize`) and anything that can break connectivity + (`update-settings`, `update-config-group`, `update-secrule`) carry `--dry-run` + + `--force` + a confirmation, alongside the mandated `delete`/`reboot`. +- A `--dry-run` that prints a request body must **mask secrets** — see + `postgresql/cluster/preview.go`. Master passwords travel in create and + update-settings bodies, and dry-run output gets pasted into tickets. +- Master passwords are read from **`$GRN_VDB_MASTER_PASSWORD`** when `--password` + is omitted, so they stay out of shell history. +- The read-only lookup endpoints live under one **`catalog`** noun + (`catalog list-flavors`, `list-zones`, ...) rather than a noun each. They share a + shape — GET, no filter beyond an optional zone, a bare array in the envelope — + and one purpose: what you consult before creating an instance, and what + completion reads. The eight that need no arguments are declared in a table in + `catalog/catalog.go`; only `list-flavors`, which takes required flags, has its + own file. + +## Shell completion + +**Bind a flag to its completer in the `init()` of the file that DEFINES the +flag** — never from one central `registerCompletions()` in the package's parent +file, the way `cmd/vks/completion.go` does it. + +Within a package Go runs `init()` functions file by file in filename order, so a +central binding called from `instance.go` runs before `list.go` and +`list_histories.go` have added their flags. `RegisterFlagCompletionFunc` then +fails on the unknown flag, and the error is discarded at init time (it is the +`//nolint:errcheck` line everyone copies), so the completion looks wired and +silently does nothing. This actually happened here. Keep the completer functions +in `completion.go` and the bindings next to the flags; +`completion_test.go` / `catalog_test.go` assert each binding via +`cmd.GetFlagCompletionFunc`. + +Cross-package values go through the `cli` resource registry instead of an import, +so registration lives next to the endpoint that owns the shape and dispatch happens +at completion time (no init ordering involved). Registered today: + +| Key | Source | +|---|---| +| `vdb:relational-instance` | relational instance listing (all IDs, `db-` and `pg-`) | +| `vdb:relational-datastore-type`, `vdb:relational-datastore-version` | `/database-instances/datastore` | +| `vdb:relational-zone` | `/database-instances/zones` (project-wide; the pg group reuses it) | +| `vdb:relational-volume-type` | `/database-instances/volume/types` | +| `vdb:relational-subnet` | `/database-instances/networks/subnets`, one level down into the nested `subnets` | +| `vdb:relational-network` | `/database-instances/networks` (project-wide; Kafka's `--network-id` reuses it) | +| `vdb:relational-config-group` | `/database-instances/configuration`, minus the `cluster` deploy types | +| `vdb:relational-flavor` | `/database-instances/flavors` — **context-dependent**: reads `--datastore-type`/`--datastore-version` (and `--zone-id`) off the command being completed, and stays silent until they are set | +| `vdb:memorystore-instance`, `vdb:memorystore-datastore-type`, `vdb:memorystore-datastore-version`, `vdb:memorystore-config-group` | `/vdb-memory/v1/database*` (zones and subnets reuse the relational keys) | +| `vdb:memorystore-flavor` | `/database/flavors` — context-dependent like its relational twin, but on `--datastore-version` alone; `--datastore-type` defaults to Redis | +| `vdb:postgresql-cluster` | relational listing filtered to `pg-` | +| `vdb:postgresql-datastore-version`, `vdb:postgresql-flavor`, `vdb:postgresql-volume-type` | `/vdb-postgresql/v1/cluster/*` | +| `vdb:postgresql-config-group` | relational `/configurations` filtered to `deployType: cluster` | +| `vdb:postgresql-backup-location`, `vdb:postgresql-backup-policy` | `/vdb-postgresql/v1/backup/{location,policy}` | +| `vdb:kafka-cluster` | `/vdb-kafka/clusters` (the whole listing — no pagination to page through) | +| `vdb:kafka-secrule` | the `securityGroupRules` nested in `GET /clusters/{id}` — **context-dependent** on `--cluster-id`, since there is no listing endpoint | +| `vdb:kafka-topic`, `vdb:kafka-user` | `/clusters/{id}/{topics,users}` — context-dependent on `--cluster-id` | +| `vdb:kafka-topic-name` | the same topic listing, by **name**: the user permission flags take names, not IDs | +| `vdb:kafka-config-group`, `vdb:kafka-config-group-version` | `/vdb-kafka/config-groups`; the version key digs into each group's nested `versions`, narrowing to `--config-group-id` when that flag is set | +| `vdb:kafka-flavor`, `vdb:kafka-volume-type` | `/vdb-kafka/database/{flavors,volume-types}` — `flavorId` and `kafkaUuid`, NOT the numeric ids | +| `vdb:kafka-version` | `/vdb-kafka/database/configs` → `kafkaVersions`, the only source in the API | + +Consume with `cli.ResourceCompletion()`. Keep the per-product keys distinct even +where they look duplicated — the values are not interchangeable. + +Not every completer needs a key: `backup-storage`'s `--package-id` and `--storage-id` are +consumed only inside their own package, so they are plain local completers in +`backupstorage/completion.go`. Note which path feeds which there — `GET /backup-storages` +means "packages to buy" in relational and "storage you own" in memorystore, so a copied +completer would suggest the wrong list. + +**Ids that are JSON numbers need `vdbclient.ExtractIDValues`, not `cli.ExtractIDs`.** A +flavor `id` is `179` and a backup package `packageId` is `1` — numbers, which +`cli.ExtractIDs` skips, leaving completion silently empty (that is exactly how the +backup-storage gap went unnoticed until it was tried against the live API). Verify a new +completer by running `grn __complete vdb …` rather than trusting the binding test, which +only proves a function is attached. + +## Shared helpers vs per-group code + +`internal/vdbclient` now holds everything two groups would otherwise copy: + +| Helper | Why it is shared | +|---|---| +| `ActionBody` / `ResizeBody` / `ResizeConfigBody` | the `{databaseInstances[], action, resType}` shape; that resize spells it `resourceType`; that the resource type is `dbaas`, `dbaas-backup` or `dbaas-backup-storage` depending on the resource; and that restore's detail carries a config with NO `instancesId` | +| `Payload` / `PayloadObject` | the "HTTP 200 with `data: null` means not found" check | +| `ParseSecurityRules` / `SecurityRulesFrom` / `DescribeSecurityRules` / `SecurityRuleColumns` | identical between relational and memorystore; only the default port differs, so it is a parameter | +| `PreviewBody` | the `--dry-run` body preview, with secret masking | +| `RequireIDWithPrefix` | the `db-`/`pg-` guard | +| `Unwrap`, `BuildListQuery`, `Get`, `Output*`, `Client` | from PR 1 | + +**Still never shared: path builders.** Each group keeps its own path constants even +where the strings look alike — that asymmetry is the whole reason the groups are +separate packages. + +## Errors + +**Build the client with `vdbclient.BuildClient`, which returns a +`*vdbclient.Client`, not the shared `*client.GreennodeClient`.** The wrapper exists +for one reason: vDB puts a machine code in the error body's `message` and the +sentence that explains the problem in `errors[].message`: + +```jsonc +{"code":400, "message":"in_valid", + "errors":[{"typeError":"pageSize","fieldError":"invalid", + "message":"The field pageSize of the request must be greater than 0"}]} +``` + +The shared `formatError` stops at the first recognised field, so it always picks +`message` and the useful part was visible only under `--debug`. `Client` runs every +response through `enrich` (`internal/vdbclient/error.go`), which appends the +details: + +``` +API error (HTTP 400 Bad Request): in_valid: The field pageSize of the request must be greater than 0 (pageSize) +``` + +The machine code is kept — support greps for it. `*vdbclient.Error` embeds and +unwraps to `*client.APIError`, so `errors.As` still reaches `StatusCode`/`Body` for +waiters. An error with no usable details, or one that is not an API error, passes +through untouched. **Do not "fix" this in `internal/client`** — vks and vserver +depend on its current message format. + +`enrich` also redacts: it receives the request body from every `Client` method that has +one, collects the values under `sensitiveKeys` (shared with the `--dry-run` preview) and +replaces them in the message. That closes a real leak — the API echoes a rejected +password verbatim. Values shorter than 4 characters are ignored, so a toy password cannot +blank out unrelated text. The raw `APIError.Body` is left alone; `--debug` still prints +what the server actually said. + +Also worth knowing: the async `update-*` endpoints answer 200 with a `data` payload +whose fields are unreliable (see the transposed IDs above). For those, the HTTP +status is the only thing to trust — the product team confirmed the body carries no +meaning. Print a confirmation, not the payload. + +## Output + +- Table output in `vdbclient` converts every number to a plain decimal string + first (`plainNumbers`). JSON numbers decode to float64 and the table formatter + prints with `%v`, which renders a 5861298-byte backup as `5.861298e+06`. JSON and + text output keep the original numbers, so `--query` arithmetic is unaffected. + This is deliberately vdb-local: the shared formatter is left alone. +- List items are very wide (a database instance carries ~68 fields, billing + metadata included), so list commands **must** pass a column set to + `vdbclient.OutputWithColumns`. JSON output is unaffected and keeps every field. +- **`OutputWithColumns` is for lists only.** It goes through + `formatter.extractRows`, which returns the first slice it finds while ranging + over the map. That is deterministic for a list payload (one array: the items), + but a detail payload has several nested arrays — a database instance carries + `ip`, `securityGroup`, `replicas` and `sharedActions` — and Go randomises map + iteration, so table output would pick one of them at random. Detail commands + (`get`) must use `vdbclient.Output`, which renders an object as a key/value + table. + +## Build gating + +`cmd/vdb` is registered from `cmd/register_vdb.go` behind the `!vks_only` build +tag, so it is in dev/CI builds but excluded from the public release binary +(`-tags vks_only`) while vDB is under development. Drop the tag at GA. diff --git a/go/cmd/vdb/completion_test.go b/go/cmd/vdb/completion_test.go new file mode 100644 index 0000000..e5ee117 --- /dev/null +++ b/go/cmd/vdb/completion_test.go @@ -0,0 +1,89 @@ +package vdb + +import ( + "fmt" + "sort" + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// completableFlags are the flag names whose values come from the API or another vDB +// resource, so a user cannot reasonably type them from memory: ids, engine names, +// versions, zones. Every one of them must have a completion function bound, on every +// command that defines it. +// +// This test exists because the binding fails SILENTLY. RegisterFlagCompletionFunc +// returns an error when the flag does not exist yet, every call site discards it with +// //nolint:errcheck, and init() functions run in filename order — so a binding written in +// the wrong file compiles, ships, and simply does nothing. That happened once already +// (see the note in relational/instance/completion.go); walking the real command tree is +// the only way to notice. +var completableFlags = map[string]bool{ + "instance-id": true, "cluster-id": true, "config-id": true, "backup-id": true, + "storage-id": true, "package-id": true, "zone-id": true, "subnet-ids": true, + "datastore-type": true, "datastore-version": true, "volume-type": true, + "status": true, "user-type": true, + // Kafka's own resource flags. It has nouns no other product has (topics, users, + // versioned config groups) and its security rules are addressed by ID. + "topic-id": true, "user-id": true, "secrule-id": true, "flavor-id": true, + "kafka-version": true, "config-group-id": true, "config-group-version-id": true, + "network-id": true, "subnet-id": true, "from-version": true, +} + +// completionExceptions are the flags deliberately left without value completion, each +// for the same reason: the flavors endpoint needs an engine and version, and on these +// commands neither is on the command line — they come from the instance or backup being +// acted on. Completing them would mean a second API call to resolve that resource first, +// plus giving the catalog package the other package's paths. Until that is judged worth +// it, they offer nothing rather than offering wrong values. +// +// Anything added here needs a reason. A flag that merely "was forgotten" belongs in the +// fix, not in this map. +var completionExceptions = map[string]string{ + "vdb relational instance resize-instance --package-id": "engine and version come from the instance", + "vdb relational instance create-replica --package-id": "engine and version come from the source instance", + "vdb relational backup restore --package-id": "engine and version come from the backup", + "vdb memorystore instance resize-instance --package-id": "version comes from the instance", + "vdb memorystore instance create-replica --package-id": "version comes from the source instance", + "vdb memorystore backup restore --package-id": "version comes from the backup", +} + +func TestEveryCompletableFlagHasACompleter(t *testing.T) { + var missing, staleException []string + + var walk func(cmd *cobra.Command, path string) + walk = func(cmd *cobra.Command, path string) { + cmd.Flags().VisitAll(func(f *pflag.Flag) { + if !completableFlags[f.Name] { + return + } + key := fmt.Sprintf("%s --%s", path, f.Name) + _, bound := cmd.GetFlagCompletionFunc(f.Name) + _, excused := completionExceptions[key] + + switch { + case !bound && !excused: + missing = append(missing, key) + case bound && excused: + // The exception outlived its reason: someone wired the flag up. Drop the + // entry so the map keeps describing reality. + staleException = append(staleException, key) + } + }) + for _, sub := range cmd.Commands() { + walk(sub, path+" "+sub.Name()) + } + } + walk(VdbCmd, "vdb") + + sort.Strings(missing) + for _, key := range missing { + t.Errorf("no value completion bound for %q — bind it in the init() of the file that defines the flag", key) + } + sort.Strings(staleException) + for _, key := range staleException { + t.Errorf("%q now has a completer; remove it from completionExceptions", key) + } +} diff --git a/go/cmd/vdb/kafka/catalog/catalog.go b/go/cmd/vdb/kafka/catalog/catalog.go new file mode 100644 index 0000000..62002bc --- /dev/null +++ b/go/cmd/vdb/kafka/catalog/catalog.go @@ -0,0 +1,153 @@ +// Package catalog holds the Kafka read-only lookups. +// +// They live under `/vdb-kafka/database/*`, which looks like MemoryStore's +// `/vdb-memory/v1/database/*` but offers a different set: Kafka has no engine, +// datastore, network, subnet or config-group lookup here. Versions come from the +// flavor listing, networks and subnets from the RELATIONAL catalog (they are +// project-wide), and config groups have their own noun because they are versioned. +// +// `GET /vdb-kafka/database/configs` is deliberately not exposed as a command: it +// returns the service's own application settings as a map — regexes, +// per-user quotas, forced broker properties — which is not something a CLI user acts +// on. It IS read internally, because it is the only place the Kafka version list +// exists; see KafkaVersionResourceKey in completion.go. +package catalog + +import ( + "fmt" + "strings" + + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// CatalogCmd is the parent command for the Kafka lookups. +var CatalogCmd = &cobra.Command{ + Use: "catalog", + Short: "Look up Kafka versions, flavors and volume types", + Long: "Read-only lists of what a Kafka cluster can be built from: broker flavors, " + + "the instance families they belong to, CPU platform codes and volume types.\n\n" + + "Kafka VERSIONS come from 'list-flavors' — there is no datastore endpoint here, " + + "unlike the other vDB products.\n\n" + + "Networks and subnets are NOT here either: Kafka has no endpoint for them, so " + + "use 'grn vdb relational catalog list-networks' and 'list-subnets' — they are " + + "project-wide.", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() //nolint:errcheck + }, +} + +const ( + basePath = "/vdb-kafka/database" + + familiesPath = basePath + "/families" + codesPath = basePath + "/codes" + flavorsPath = basePath + "/flavors" + volumeTypesPath = basePath + "/volume-types" +) + +// volumeTypeItemsKey is the second level the volume-type listing hides its items +// under. It is the one Kafka catalog endpoint that does NOT put a bare array in the +// envelope: `data` is {projectId, data: [...]}, so the items are one level further +// down. Stripping it here means --output json shows the volume types rather than a +// wrapper, and the completer reads them at all. +const volumeTypeItemsKey = "data" + +// simpleList describes a lookup that takes no input at all. Kafka's catalog +// endpoints have no zone parameter, unlike relational's and memorystore's. +type simpleList struct { + use string + short string + long string + path string + itemsKey string + columns []string +} + +var simpleLists = []simpleList{ + { + use: "list-families", + short: "List instance families", + long: "List the instance families (CPU/memory profiles) that broker flavors belong to.", + path: familiesPath, + columns: []string{"group", "key", "value", "name", "description"}, + }, + { + use: "list-flavor-codes", + short: "List CPU platform codes", + long: "List the CPU platform codes that broker flavors are grouped by.", + path: codesPath, + columns: []string{"key", "value", "familyType", "description"}, + }, + { + use: "list-volume-types", + short: "List volume types for Kafka brokers", + long: "List the volume types a Kafka broker can use, with the size range each " + + "one allows.\n\n" + + "'cluster create --volume-type' and 'cluster update-volume-type' take the " + + "identifier from here. These values belong to Kafka alone — a volume type " + + "name from Relational Database or PostgreSQL Cluster is rejected.", + path: volumeTypesPath, + itemsKey: volumeTypeItemsKey, + columns: []string{"kafkaUuid", "type", "displayName", "minVolumeSize", "maxVolumeSize", "iops", "zoneId"}, + }, +} + +func newSimpleList(spec simpleList) *cobra.Command { + return &cobra.Command{ + Use: spec.use, + Short: spec.short, + Long: spec.long, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if err := get(cmd, spec.path, spec.itemsKey, spec.columns); err != nil { + return fmt.Errorf("failed to %s: %w", strings.ReplaceAll(spec.use, "-", " "), err) + } + return nil + }, + } +} + +func init() { + for _, spec := range simpleLists { + CatalogCmd.AddCommand(newSimpleList(spec)) + } + CatalogCmd.AddCommand(listFlavorsCmd) +} + +// get issues the GET and prints it with a column set. These four are among the 9 +// Kafka endpoints that DO use the {code, message, data} envelope, unlike the +// cluster/topic/user ones — so Unwrap actually strips something here. +func get(cmd *cobra.Command, path, itemsKey string, columns []string) error { + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Get(path, nil) + if err != nil { + return err + } + + return vdbclient.OutputWithColumns(cmd, items(vdbclient.Unwrap(result), itemsKey), columns) +} + +// items digs one level further into a payload that wraps its list under a key. With +// an empty key it is the identity, and it leaves a payload that does not carry the +// key alone rather than returning nothing — so a backend that flattens the shape +// later does not break the command. +func items(payload interface{}, key string) interface{} { + if key == "" { + return payload + } + object, ok := payload.(map[string]interface{}) + if !ok { + return payload + } + nested, ok := object[key] + if !ok { + return payload + } + return nested +} diff --git a/go/cmd/vdb/kafka/catalog/completion.go b/go/cmd/vdb/kafka/catalog/completion.go new file mode 100644 index 0000000..48476ed --- /dev/null +++ b/go/cmd/vdb/kafka/catalog/completion.go @@ -0,0 +1,133 @@ +package catalog + +import ( + "context" + "encoding/json" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// Completion keys owned by these endpoints. They are distinct from every other +// product's even where the names look alike — a Kafka flavor is not a relational one +// and a Kafka volume type is not a PostgreSQL Cluster one. +const ( + // KafkaVersionResourceKey lists the Kafka versions on offer. + // + // They come from GET /vdb-kafka/database/configs, which is the ONLY place in the + // API that carries them: the flavor listing has no version field, and Kafka has no + // datastore endpoint. That path deliberately has no command — it returns the + // service's own settings, which is not something a user acts on — but it is the + // right source for this one value. Verified live 2026-08-17: kafkaVersions is + // ["3.6.0","3.6.1","3.7.0"]. + KafkaVersionResourceKey = "vdb:kafka-version" + + // FlavorResourceKey lists broker flavors for --flavor-id. + // + // The request field `serverFlavorId` takes the flavor's `flavorId` — the opaque + // "flav-…" string, NOT the numeric `id` that Relational Database's --package-id + // uses. Confirmed live 2026-08-17 against a running cluster, whose serverFlavorId + // matched a flavorId in this listing. `id` stays as a fallback in case a listing + // omits the string. + FlavorResourceKey = "vdb:kafka-flavor" + + // VolumeTypeResourceKey lists volume types for --volume-type. + // + // The request field `kafkaStorageType` takes `kafkaUuid` — the "vtype-…" string + // that exists on VolumeTypeInfo for this product specifically — NOT the display + // `type` ("kafka.Gen2-NVMe2-IOPS3000") and not the numeric `id`. Confirmed live + // 2026-08-17 the same way. This is the opposite of Relational Database, where the + // volume type is passed by NAME. + VolumeTypeResourceKey = "vdb:kafka-volume-type" +) + +// configsPath is the service-settings endpoint. It is read here for the Kafka version +// list and nowhere else, and it intentionally has no command — see +// KafkaVersionResourceKey and the note in catalog.go. +const configsPath = basePath + "/configs" + +// kafkaVersionsKey is the entry holding the versions. Its value is a JSON array +// encoded INSIDE a string ("[\"3.6.0\",…]"), because the payload is a flat +// map — hence the second decode in kafkaVersions. +const kafkaVersionsKey = "kafkaVersions" + +// datastoreTypeValues is the one hard-coded completion in the vDB tree. +// +// The repo rule is to complete from the API rather than a literal, but there is no +// endpoint here to complete from: Kafka has no engine or datastore listing. The value +// is verified rather than assumed — live 2026-08-17, `?type=kafka` and `?type=Kafka` +// both return all 24 flavors while `?type=bogus` returns none, so the filter is real, +// case-insensitive, and has exactly this one value. +var datastoreTypeValues = []string{"kafka"} + +func init() { + cli.RegisterResourceCompleter(KafkaVersionResourceKey, cli.FlagFromAPI(kafkaVersions)) + cli.RegisterResourceCompleter(FlavorResourceKey, cli.FlagFromAPI(preferredField(flavorsPath, "", "flavorId", "id"))) + // The volume-type payload hides its items one level down — see volumeTypeItemsKey. + cli.RegisterResourceCompleter(VolumeTypeResourceKey, + cli.FlagFromAPI(preferredField(volumeTypesPath, volumeTypeItemsKey, "kafkaUuid", "id"))) +} + +func kafkaVersionCompletion() cli.CompFunc { + return cli.ResourceCompletion(KafkaVersionResourceKey) +} + +func datastoreTypeCompletion() cli.CompFunc { + return cli.FlagValues(datastoreTypeValues...) +} + +// kafkaVersions reads the version list out of the service settings map. +func kafkaVersions(_ context.Context, cmd *cobra.Command) ([]string, error) { + payload, err := listing(cmd, configsPath, "") + if err != nil { + return nil, err + } + settings, ok := payload.(map[string]interface{}) + if !ok { + return nil, nil + } + encoded, _ := settings[kafkaVersionsKey].(string) + if encoded == "" { + return nil, nil + } + + var versions []string + if err := json.Unmarshal([]byte(encoded), &versions); err != nil { + // A settings value that stops being a JSON array is not worth failing a + // completion over; offer nothing instead. + return nil, nil //nolint:nilerr + } + return versions, nil +} + +// preferredField reads `field`, falling back to `fallback` when the listing has no +// values under the first, so a backend that stops populating one of the pair leaves +// completion working rather than quietly empty. +func preferredField(path, itemsKey, field, fallback string) func(context.Context, *cobra.Command) ([]string, error) { + return func(_ context.Context, cmd *cobra.Command) ([]string, error) { + payload, err := listing(cmd, path, itemsKey) + if err != nil { + return nil, err + } + if values := vdbclient.ExtractIDValues(payload, field); len(values) > 0 { + return values, nil + } + return vdbclient.ExtractIDValues(payload, fallback), nil + } +} + +// listing fetches a catalog payload. ExtractIDValues is used on the result rather +// than cli.ExtractIDs because several Kafka catalog ids arrive as JSON numbers, which +// the string-only helper skips, leaving completion silently empty. +func listing(cmd *cobra.Command, path, itemsKey string) (interface{}, error) { + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return nil, err + } + result, err := apiClient.Get(path, nil) + if err != nil { + return nil, err + } + return items(vdbclient.Unwrap(result), itemsKey), nil +} diff --git a/go/cmd/vdb/kafka/catalog/list_flavors.go b/go/cmd/vdb/kafka/catalog/list_flavors.go new file mode 100644 index 0000000..f10cb71 --- /dev/null +++ b/go/cmd/vdb/kafka/catalog/list_flavors.go @@ -0,0 +1,64 @@ +package catalog + +import ( + "fmt" + "net/url" + + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// flavorColumns leads with flavorId because that is what 'cluster create --flavor-id' +// takes — the opaque "flav-…" string, not the numeric `id`, which is kept next to it +// only so the two are not confused. See FlavorResourceKey in completion.go. +var flavorColumns = []string{ + "flavorId", "id", "name", "vcpus", "ram", "priceKey", "description", "locateZoneId", +} + +var listFlavorsCmd = &cobra.Command{ + Use: "list-flavors", + Short: "List broker flavors, optionally filtered by Kafka version", + Long: "List the flavors a Kafka broker can run on.\n\n" + + "'cluster create --flavor-id' takes the flavorId column — the 'flav-...' string, " + + "NOT the numeric id that Relational Database's --package-id uses.\n\n" + + "--datastore-type filters (only 'kafka' matches anything). --datastore-version " + + "is accepted by the API and currently has NO effect: the same 24 flavors come " + + "back for a real version and for a made-up one, and the rows carry no version " + + "field to filter on client-side (verified live 2026-08-17). Shell completion for " + + "it still offers the real versions.\n\n" + + "Unlike the relational and memorystore catalogs, this endpoint takes NO zone " + + "parameter — flavors are not listed per zone here.", + Args: cobra.NoArgs, + RunE: runListFlavors, +} + +func init() { + f := listFlavorsCmd.Flags() + f.String("datastore-type", "", "Filter by engine type (the API's 'type' parameter)") + f.String("datastore-version", "", "Filter by Kafka version") + // Bound here, next to the flags — see the init-order note in completion.go. + listFlavorsCmd.RegisterFlagCompletionFunc("datastore-version", kafkaVersionCompletion()) //nolint:errcheck + listFlavorsCmd.RegisterFlagCompletionFunc("datastore-type", datastoreTypeCompletion()) //nolint:errcheck +} + +func runListFlavors(cmd *cobra.Command, args []string) error { + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return err + } + + query := url.Values{} + if datastoreType, _ := cmd.Flags().GetString("datastore-type"); datastoreType != "" { + query.Set("type", datastoreType) + } + if version, _ := cmd.Flags().GetString("datastore-version"); version != "" { + query.Set("version", version) + } + + result, err := vdbclient.Get(apiClient, flavorsPath, query) + if err != nil { + return fmt.Errorf("failed to list Kafka flavors: %w", err) + } + + return vdbclient.OutputWithColumns(cmd, result, flavorColumns) +} diff --git a/go/cmd/vdb/kafka/cluster/cluster.go b/go/cmd/vdb/kafka/cluster/cluster.go new file mode 100644 index 0000000..a0ca149 --- /dev/null +++ b/go/cmd/vdb/kafka/cluster/cluster.go @@ -0,0 +1,52 @@ +// Package cluster holds the Kafka cluster commands. +// +// Naming note: the noun is "cluster" here and in cmd/vdb/postgresql, but the two +// share no path, no ID space and no operation. Nothing in this package may be +// copied from there. +package cluster + +import ( + "github.com/spf13/cobra" +) + +// ClusterCmd is the parent command for Kafka cluster commands. +var ClusterCmd = &cobra.Command{ + Use: "cluster", + Short: "Manage Kafka clusters", + Long: "Create, inspect, resize and delete vDB Kafka clusters.\n\n" + + "A cluster is 3-10 brokers sharing one volume type and size per broker. Broker " + + "count and storage size are changed by separate, individually chargeable " + + "operations - there is no single 'resize'.\n\n" + + "Authentication (mTLS / SASL) is a cluster-level switch; the users allowed to " + + "connect and what they may do are managed with 'grn vdb kafka user'.", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() //nolint:errcheck + }, +} + +func init() { + // Read + ClusterCmd.AddCommand(listCmd) + ClusterCmd.AddCommand(getCmd) + ClusterCmd.AddCommand(listHistoriesCmd) + ClusterCmd.AddCommand(listSecrulesCmd) + + // Lifecycle + ClusterCmd.AddCommand(createCmd) + ClusterCmd.AddCommand(deleteCmd) + + // Chargeable changes, one endpoint each + ClusterCmd.AddCommand(resizeBrokersCmd) + ClusterCmd.AddCommand(resizeStorageCmd) + ClusterCmd.AddCommand(updateVolumeTypeCmd) + + // Settings + ClusterCmd.AddCommand(updateAuthenticationCmd) + ClusterCmd.AddCommand(updateConfigGroupCmd) + ClusterCmd.AddCommand(updatePublicAccessCmd) + + // Security rules + ClusterCmd.AddCommand(createSecruleCmd) + ClusterCmd.AddCommand(deleteSecruleCmd) +} diff --git a/go/cmd/vdb/kafka/cluster/completion.go b/go/cmd/vdb/kafka/cluster/completion.go new file mode 100644 index 0000000..e2e81a9 --- /dev/null +++ b/go/cmd/vdb/kafka/cluster/completion.go @@ -0,0 +1,112 @@ +package cluster + +import ( + "context" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// Completers live here; each flag is bound to one in the init() of the file that +// DEFINES the flag. init() runs in filename order within a package, so a central +// binding would fail silently for flags defined in a later file — see the note in +// cmd/vdb/CLAUDE.md. + +// ClusterResourceKey is the completion key for a Kafka cluster ID. Distinct from +// "vdb:postgresql-cluster" on purpose: the two products share the noun and nothing +// else, and suggesting one's IDs for the other would offer values the API rejects. +const ClusterResourceKey = "vdb:kafka-cluster" + +// SecruleResourceKey lists the security-rule IDs of the cluster named on the command +// line. Kafka has no listing endpoint for rules — they arrive nested in the cluster +// object — so the completer reads the cluster, which also makes it context-dependent: +// it yields nothing until --cluster-id is set. +const SecruleResourceKey = "vdb:kafka-secrule" + +// Keys owned by other Kafka packages, consumed here. +const ( + kafkaVersionResource = "vdb:kafka-version" + flavorResource = "vdb:kafka-flavor" + volumeTypeResource = "vdb:kafka-volume-type" + configGroupVersionResource = "vdb:kafka-config-group-version" +) + +func init() { + cli.RegisterResourceCompleter(ClusterResourceKey, clusterIDCompletion()) + cli.RegisterResourceCompleter(SecruleResourceKey, cli.FlagFromAPI(secruleIDs)) +} + +func clusterIDCompletion() cli.CompFunc { + return cli.FlagFromAPI(func(_ context.Context, cmd *cobra.Command) ([]string, error) { + rows, err := listClusters(cmd) + if err != nil { + return nil, err + } + return cli.ExtractIDs(rows, "id"), nil + }) +} + +// statusCompletion suggests the statuses actually present in the project. Kafka has +// no status enumeration endpoint at all, and the one MemoryStore offers is outdated, +// so deriving from the listing is the only source that cannot go stale. +func statusCompletion() cli.CompFunc { + return cli.FlagFromAPI(func(_ context.Context, cmd *cobra.Command) ([]string, error) { + rows, err := listClusters(cmd) + if err != nil { + return nil, err + } + return cli.ExtractIDs(rows, "status"), nil + }) +} + +// listClusters fetches the cluster listing for completion. There is no pagination on +// this endpoint, so this is the whole list. +func listClusters(cmd *cobra.Command) (interface{}, error) { + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return nil, err + } + result, err := apiClient.Get(BasePath, nil) + if err != nil { + return nil, err + } + return vdbclient.Unwrap(result), nil +} + +// secruleIDs reads the rules nested in the cluster named by --cluster-id. +func secruleIDs(_ context.Context, cmd *cobra.Command) ([]string, error) { + clusterID, _ := cmd.Flags().GetString("cluster-id") + if clusterID == "" { + return nil, nil + } + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return nil, err + } + clusterObj, err := fetchCluster(apiClient, clusterID) + if err != nil { + return nil, err + } + return cli.ExtractIDs(clusterObj["securityGroupRules"], "id"), nil +} + +func secruleIDCompletion() cli.CompFunc { + return cli.ResourceCompletion(SecruleResourceKey) +} + +func kafkaVersionCompletion() cli.CompFunc { + return cli.ResourceCompletion(kafkaVersionResource) +} + +func flavorIDCompletion() cli.CompFunc { + return cli.ResourceCompletion(flavorResource) +} + +func volumeTypeCompletion() cli.CompFunc { + return cli.ResourceCompletion(volumeTypeResource) +} + +func configGroupVersionCompletion() cli.CompFunc { + return cli.ResourceCompletion(configGroupVersionResource) +} diff --git a/go/cmd/vdb/kafka/cluster/create.go b/go/cmd/vdb/kafka/cluster/create.go new file mode 100644 index 0000000..80daade --- /dev/null +++ b/go/cmd/vdb/kafka/cluster/create.go @@ -0,0 +1,190 @@ +package cluster + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/config" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// Broker-count bounds, from the API's own description of kafkaBrokerCount +// ("Minimum 3 brokers and maximum 10 brokers"). Checked client-side so the error +// names the flag instead of coming back as a 400 on an order request. +const ( + minBrokerCount = 3 + maxBrokerCount = 10 +) + +var createCmd = &cobra.Command{ + Use: "create", + Short: "Create a Kafka cluster", + Long: "Create a vDB Kafka cluster.\n\n" + + "THIS COSTS MONEY: this is an order/payment flow and it completes " + + "asynchronously. Use --dry-run to see the request first, then poll " + + "'cluster get' until the status is active.\n\n" + + "A cluster has 3-10 brokers, each with its own volume of the given type and " + + "size. --config-group-version-id takes a config group VERSION, not a group: run " + + "'grn vdb kafka configuration list' and use the id of the version you want.\n\n" + + "At least one of --mtls-authen / --sasl-authen is REQUIRED: the gateway " + + "rejects a cluster with neither (measured live: \"Need to enable at least " + + "one authentication mode\"). Users and their per-topic permissions are " + + "managed with 'grn vdb kafka user'.", + Args: cobra.NoArgs, + RunE: runCreate, +} + +func init() { + f := createCmd.Flags() + f.String("name", "", "Name of the new cluster (required)") + f.String("kafka-version", "", "Kafka version (required; see 'kafka catalog list-flavors')") + f.String("flavor-id", "", "Flavor of each broker (required; the 'id' column of 'kafka catalog list-flavors')") + f.Int("broker-count", minBrokerCount, + fmt.Sprintf("Number of brokers, %d-%d", minBrokerCount, maxBrokerCount)) + f.String("volume-type", "", "Volume type for each broker (required; 'kafka catalog list-volume-types')") + f.Int("volume-size", 0, "Volume size per broker in GB (required)") + f.String("vserver-project-id", "", "vServer project ID (default: the profile's project_id)") + f.String("network-id", "", "Network (VPC) ID to attach the cluster to (required)") + f.String("subnet-id", "", "Subnet ID within that network (required)") + f.Bool("mtls-authen", false, "Enable mTLS authentication") + f.Bool("sasl-authen", false, "Enable SASL authentication") + f.String("config-group-version-id", "", "Config group VERSION to apply (optional)") + f.Bool("encryption-volume", false, "Encrypt the brokers' volumes") + f.Bool("dry-run", false, "Print the request that would be sent without placing an order") + f.Bool("force", false, "Skip the confirmation prompt") + + for _, name := range []string{"name", "kafka-version", "flavor-id", "volume-type", "volume-size", "network-id", "subnet-id"} { + createCmd.MarkFlagRequired(name) //nolint:errcheck + } + + // Bound here, next to the flags — see the init-order note in completion.go. + createCmd.RegisterFlagCompletionFunc("kafka-version", kafkaVersionCompletion()) //nolint:errcheck + createCmd.RegisterFlagCompletionFunc("flavor-id", flavorIDCompletion()) //nolint:errcheck + createCmd.RegisterFlagCompletionFunc("volume-type", volumeTypeCompletion()) //nolint:errcheck + createCmd.RegisterFlagCompletionFunc("network-id", cli.ResourceCompletion(networkResource)) //nolint:errcheck + createCmd.RegisterFlagCompletionFunc("subnet-id", cli.ResourceCompletion(subnetResource)) //nolint:errcheck + createCmd.RegisterFlagCompletionFunc("config-group-version-id", configGroupVersionCompletion()) //nolint:errcheck +} + +// networkResource and subnetResource are the RELATIONAL keys, reused deliberately: +// Kafka has no networks or subnets endpoint of its own, and both are project-wide +// infrastructure. MemoryStore reuses the same pair for the same reason. +const ( + networkResource = "vdb:relational-network" + subnetResource = "vdb:relational-subnet" +) + +func runCreate(cmd *cobra.Command, args []string) error { + f := cmd.Flags() + + brokerCount, _ := f.GetInt("broker-count") + if brokerCount < minBrokerCount || brokerCount > maxBrokerCount { + return fmt.Errorf("invalid --broker-count %d: a Kafka cluster has %d to %d brokers", + brokerCount, minBrokerCount, maxBrokerCount) + } + + volumeSize, _ := f.GetInt("volume-size") + if volumeSize <= 0 { + return fmt.Errorf("invalid --volume-size %d: must be greater than 0", volumeSize) + } + + name, _ := f.GetString("name") + kafkaVersion, _ := f.GetString("kafka-version") + flavorID, _ := f.GetString("flavor-id") + volumeType, _ := f.GetString("volume-type") + networkID, _ := f.GetString("network-id") + subnetID, _ := f.GetString("subnet-id") + mtls, _ := f.GetBool("mtls-authen") + sasl, _ := f.GetBool("sasl-authen") + // The gateway's own validator refuses both modes off — measured live, a bare + // `bad_request: Need to enable at least one authentication mode` that names no + // field — so the combination is rejected here, where the error can name the flags. + if !mtls && !sasl { + return fmt.Errorf("enable at least one authentication mode: --mtls-authen or --sasl-authen") + } + configGroupVersionID, _ := f.GetString("config-group-version-id") + encryptionVolume, _ := f.GetBool("encryption-volume") + + projectID, err := vserverProjectID(cmd) + if err != nil { + return err + } + + // configGroupVersionId is always sent, "" meaning "no config group" — confirmed + // against the live API (via the MCP repo): the empty string is read as none, not + // as a malformed version id. + // + // tags is always sent as an empty map, and MUST be: the backend's validation + // calls getTags().size() on every auto-payment create, so an absent field is a + // bare HTTP 500 (measured live, 2026-10-19) — the dropped tagging feature left + // the call behind. There is no way to set a non-empty value: no flag, and the + // request schema has no tags property. + body := map[string]interface{}{ + "name": name, + "kafkaVersion": kafkaVersion, + "serverFlavorId": flavorID, + "kafkaBrokerCount": brokerCount, + "kafkaStorageType": volumeType, + "kafkaStorageSize": volumeSize, + "vserverProjectId": projectID, + "networkId": networkID, + "subnetId": subnetID, + "mtlsAuthen": mtls, + "saslAuthen": sasl, + "encryptionVolume": encryptionVolume, + "configGroupVersionId": configGroupVersionID, + "tags": map[string]interface{}{}, + } + + dryRun, _ := f.GetBool("dry-run") + force, _ := f.GetBool("force") + + if dryRun { + vdbclient.PreviewBody("create", fmt.Sprintf("Kafka cluster %q", name), body) + return nil + } + if !cli.Confirm(force, fmt.Sprintf( + "Create Kafka cluster %q with %d brokers of %d GB? This places a paid order.", + name, brokerCount, volumeSize)) { + fmt.Println("Aborted.") + return nil + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Post(BasePath, body) + if err != nil { + return fmt.Errorf("failed to create Kafka cluster %q: %w", name, err) + } + + // One of the 9 wrapped Kafka operations: OrderResponse[] inside the usual + // envelope, whose resourceId is the new cluster's ID. + return vdbclient.Output(cmd, result) +} + +// vserverProjectID falls back to the profile's project_id. The API needs the field +// and the CLI already knows it, so making the user retype it would be busywork — +// the same reason relational's resize-storage reads the instance for its current +// values. When neither the flag nor the profile supplies one there is no request +// to build, so say that instead of sending an empty string the API would blame +// elsewhere. +func vserverProjectID(cmd *cobra.Command) (string, error) { + if explicit, _ := cmd.Flags().GetString("vserver-project-id"); explicit != "" { + return explicit, nil + } + profile, _ := cmd.Flags().GetString("profile") + cfg, err := config.LoadConfig(profile) + if err != nil { + return "", err + } + if cfg.ProjectID != "" { + return cfg.ProjectID, nil + } + return "", fmt.Errorf("vserverProjectId was not supplied and the configured profile has no " + + "project_id. Pass --vserver-project-id ('pro-…') or set project_id in " + + "~/.greennode/config (or the GRN_DEFAULT_PROJECT_ID environment variable).") +} diff --git a/go/cmd/vdb/kafka/cluster/create_test.go b/go/cmd/vdb/kafka/cluster/create_test.go new file mode 100644 index 0000000..e73f6cf --- /dev/null +++ b/go/cmd/vdb/kafka/cluster/create_test.go @@ -0,0 +1,36 @@ +package cluster + +import ( + "strings" + "testing" +) + +// Both authentication modes off is rejected client-side: the gateway refuses the +// combination with a bare "Need to enable at least one authentication mode" that +// names no field (measured live), so the error here names the flags instead. +func TestCreateRequiresAuthenticationMode(t *testing.T) { + f := createCmd.Flags() + f.Set("volume-size", "100") //nolint:errcheck — past the earlier bounds checks + f.Set("mtls-authen", "false") //nolint:errcheck + f.Set("sasl-authen", "false") //nolint:errcheck + + err := runCreate(createCmd, nil) + if err == nil || !strings.Contains(err.Error(), "--mtls-authen") || + !strings.Contains(err.Error(), "--sasl-authen") { + t.Fatalf("expected the both-off error to name the flags, got %v", err) + } +} + +// An explicit --vserver-project-id wins without reading the profile, so the flag +// never depends on what the local config happens to contain. +func TestVserverProjectIDExplicitFlag(t *testing.T) { + createCmd.Flags().Set("vserver-project-id", "pro-123") //nolint:errcheck + + got, err := vserverProjectID(createCmd) + if err != nil { + t.Fatalf("vserverProjectID: %v", err) + } + if got != "pro-123" { + t.Errorf("vserverProjectID = %q, want pro-123", got) + } +} diff --git a/go/cmd/vdb/kafka/cluster/helpers.go b/go/cmd/vdb/kafka/cluster/helpers.go new file mode 100644 index 0000000..d48ce5a --- /dev/null +++ b/go/cmd/vdb/kafka/cluster/helpers.go @@ -0,0 +1,109 @@ +package cluster + +import ( + "fmt" + "net/url" + "sort" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/validator" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// BasePath is the Kafka cluster collection. Note the prefix has NO /v1 segment — +// relational, memorystore and postgresql all carry one, Kafka does not. +const BasePath = "/vdb-kafka/clusters" + +// ClusterPath builds a per-cluster sub-path. Exported because the topic and user +// packages hang off the same cluster path. +func ClusterPath(clusterID, suffix string) string { + return BasePath + "/" + clusterID + suffix +} + +// ValidateClusterID checks the ID is URL-safe. +// +// There is deliberately NO prefix guard here, unlike relational ("db-") and +// postgresql ("pg-"): no Kafka path is shared with another product, so there is +// nothing for a prefix to protect against, and the ID format was not confirmed +// against the live API. Do not invent one from a sample ID. +func ValidateClusterID(clusterID string) error { + return validator.ValidateID(clusterID, "cluster-id") +} + +// listColumns is the table view of a KafkaCluster. The full object carries ~29 +// fields including billing metadata and the nested securityGroupRules, so table +// output needs a subset; --output json keeps everything. +var listColumns = []string{ + "id", "name", "status", "kafkaVersion", "kafkaBrokerCount", + "kafkaStorageType", "kafkaStorageSize", "vcpus", "ram", "createdAt", +} + +// historyColumns matches HistoryDto, which is Kafka's own shape and NOT the +// HistoryResponse the other products return: it has startedAt/finishedAt instead of +// createdTime/updatedTime, and a clusterId. As elsewhere, description leads because +// `action` alone rarely says what changed. +var historyColumns = []string{ + "id", "action", "description", "status", "startedAt", "finishedAt", "errorMessage", +} + +// secruleColumns matches KafkaSecurityGroupRule, the items nested in a cluster's +// securityGroupRules. +var secruleColumns = []string{"id", "remoteIp", "port", "status", "createdAt"} + +func createClient(cmd *cobra.Command) (*vdbclient.Client, error) { + return vdbclient.BuildClient(cmd) +} + +// fetchCluster reads one cluster for the commands that need its current values — +// delete prints what it is about to remove, list-secrules digs into it, and +// resize-brokers refuses a no-op. +// +// PayloadObject rather than a plain type assertion: vDB answers "not found" with +// HTTP 200 and a null payload on some endpoints, and reading fields off the +// envelope would silently yield zeroes. +func fetchCluster(apiClient *vdbclient.Client, clusterID string) (map[string]interface{}, error) { + result, err := apiClient.Get(BasePath+"/"+clusterID, nil) + if err != nil { + return nil, fmt.Errorf("failed to read Kafka cluster %s: %w", clusterID, err) + } + clusterObj, ok := vdbclient.PayloadObject(result) + if !ok { + return nil, fmt.Errorf("Kafka cluster %s not found (the API returned an empty payload)", clusterID) + } + return clusterObj, nil +} + +// previewQuery is --dry-run for the Kafka operations whose arguments travel in the +// QUERY STRING rather than a body: vdbclient.PreviewBody prints a JSON body, and +// these have none, so it would show "null" and hide the very values being changed. +// Keys are sorted so the output is stable. +func previewQuery(verb, target, method, path string, params map[string]string) { + fmt.Println("=== DRY RUN ===") + if target != "" { + fmt.Printf("Would %s %s with:\n", verb, target) + } + keys := make([]string, 0, len(params)) + for k := range params { + keys = append(keys, k) + } + sort.Strings(keys) + + query := url.Values{} + for _, k := range keys { + fmt.Printf(" %s: %s\n", k, params[k]) + query.Set(k, params[k]) + } + fmt.Printf("\n %s %s?%s\n", method, path, query.Encode()) + cli.DryRunNotice(verb) +} + +func stringField(payload map[string]interface{}, key string) string { + value, _ := payload[key].(string) + return value +} + +func intField(payload map[string]interface{}, key string) int { + value, _ := payload[key].(float64) + return int(value) +} diff --git a/go/cmd/vdb/kafka/cluster/lifecycle.go b/go/cmd/vdb/kafka/cluster/lifecycle.go new file mode 100644 index 0000000..c3bcb0c --- /dev/null +++ b/go/cmd/vdb/kafka/cluster/lifecycle.go @@ -0,0 +1,293 @@ +package cluster + +import ( + "fmt" + "strconv" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +var deleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete a Kafka cluster", + Long: "Delete a Kafka cluster and everything on it.\n\n" + + "This is irreversible and there is nothing to restore from: Kafka is the one vDB " + + "product with no backup service, so the topics and their data go with the " + + "cluster.", + Args: cobra.NoArgs, + RunE: runDelete, +} + +var resizeBrokersCmd = &cobra.Command{ + Use: "resize-brokers", + Short: "Change the number of brokers in a Kafka cluster", + Long: "Add or remove brokers.\n\n" + + "THIS COSTS MONEY: order/payment flow, asynchronous. Use --dry-run first.\n\n" + + "--rebalance moves existing partitions onto the new broker set. Without it the " + + "brokers change but the partitions stay where they are, so a newly added broker " + + "carries no traffic until a topic is created or rebalanced.", + Args: cobra.NoArgs, + RunE: runResizeBrokers, +} + +var resizeStorageCmd = &cobra.Command{ + Use: "resize-storage", + Short: "Change the volume size of a Kafka cluster", + Long: "Change the volume size of every broker.\n\n" + + "THIS COSTS MONEY: order/payment flow, asynchronous. Use --dry-run first.\n\n" + + "The size is PER BROKER, and it is a separate operation from the volume type — " + + "unlike Relational Database, where one resize carries both. Use " + + "'cluster update-volume-type' for the type.", + Args: cobra.NoArgs, + RunE: runResizeStorage, +} + +var updateVolumeTypeCmd = &cobra.Command{ + Use: "update-volume-type", + Short: "Change the volume type of a Kafka cluster", + Long: "Move every broker's volume to a different type.\n\n" + + "THIS COSTS MONEY: like the resize commands this is an order/payment flow and it " + + "completes asynchronously. Use --dry-run first.\n\n" + + "Take the value from 'kafka catalog list-volume-types' — do not reuse a volume " + + "type name from another vDB product, they are not interchangeable.", + Args: cobra.NoArgs, + RunE: runUpdateVolumeType, +} + +func init() { + d := deleteCmd.Flags() + d.String("cluster-id", "", "Kafka cluster ID (required)") + d.Bool("dry-run", false, "Print the request that would be sent without deleting") + d.Bool("force", false, "Skip the confirmation prompt") + + b := resizeBrokersCmd.Flags() + b.String("cluster-id", "", "Kafka cluster ID (required)") + b.Int("broker-count", 0, + fmt.Sprintf("New number of brokers, %d-%d (required)", minBrokerCount, maxBrokerCount)) + b.Bool("rebalance", false, "Rebalance existing partitions across the new broker set") + b.Bool("dry-run", false, "Print the request that would be sent without placing an order") + b.Bool("force", false, "Skip the confirmation prompt") + resizeBrokersCmd.MarkFlagRequired("broker-count") //nolint:errcheck + + s := resizeStorageCmd.Flags() + s.String("cluster-id", "", "Kafka cluster ID (required)") + s.Int("volume-size", 0, "New volume size per broker in GB (required)") + s.Bool("dry-run", false, "Print the request that would be sent without placing an order") + s.Bool("force", false, "Skip the confirmation prompt") + resizeStorageCmd.MarkFlagRequired("volume-size") //nolint:errcheck + + t := updateVolumeTypeCmd.Flags() + t.String("cluster-id", "", "Kafka cluster ID (required)") + t.String("volume-type", "", "New volume type (required; 'kafka catalog list-volume-types')") + t.Bool("dry-run", false, "Print the request that would be sent without placing an order") + t.Bool("force", false, "Skip the confirmation prompt") + updateVolumeTypeCmd.MarkFlagRequired("volume-type") //nolint:errcheck + updateVolumeTypeCmd.RegisterFlagCompletionFunc("volume-type", volumeTypeCompletion()) //nolint:errcheck + + for _, cmd := range []*cobra.Command{deleteCmd, resizeBrokersCmd, resizeStorageCmd, updateVolumeTypeCmd} { + cmd.MarkFlagRequired("cluster-id") //nolint:errcheck + cmd.RegisterFlagCompletionFunc("cluster-id", clusterIDCompletion()) //nolint:errcheck + } +} + +func runDelete(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + if err := ValidateClusterID(clusterID); err != nil { + return err + } + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + printDeleteTarget(apiClient, clusterID) + + if dryRun { + fmt.Println("=== DRY RUN ===") + fmt.Printf("Would send DELETE %s\n", ClusterPath(clusterID, "")) + cli.DryRunNotice("delete") + return nil + } + if !cli.Confirm(force, fmt.Sprintf( + "Delete Kafka cluster %s? This cannot be undone and Kafka has no backups.", clusterID)) { + fmt.Println("Aborted.") + return nil + } + + // The spec declares this response a bare string with no schema; the HTTP status + // is the whole result. See vdbclient.Client.NoContent. + if err := apiClient.NoContent("DELETE", BasePath+"/"+clusterID, nil, nil); err != nil { + return fmt.Errorf("failed to delete Kafka cluster %s: %w", clusterID, err) + } + + fmt.Printf("Deletion of Kafka cluster %s accepted. Poll 'grn vdb kafka cluster list' until it disappears.\n", clusterID) + return nil +} + +func runResizeBrokers(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + if err := ValidateClusterID(clusterID); err != nil { + return err + } + + brokerCount, _ := cmd.Flags().GetInt("broker-count") + if brokerCount < minBrokerCount || brokerCount > maxBrokerCount { + return fmt.Errorf("invalid --broker-count %d: a Kafka cluster has %d to %d brokers", + brokerCount, minBrokerCount, maxBrokerCount) + } + rebalance, _ := cmd.Flags().GetBool("rebalance") + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + // Refuse a no-op rather than letting it become a paid order that fails in the + // background: a resize to the value already in place is accepted and then fails + // asynchronously with an empty change description (verified live on the + // relational product, and there is no reason Kafka differs). + if current, err := fetchCluster(apiClient, clusterID); err == nil { + if have := intField(current, "kafkaBrokerCount"); have == brokerCount { + return fmt.Errorf("Kafka cluster %s already has %d brokers; nothing to do", clusterID, have) + } + } + + params := map[string]string{ + "count": strconv.Itoa(brokerCount), + "rebalance": strconv.FormatBool(rebalance), + } + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + previewQuery("resize", fmt.Sprintf("Kafka cluster %s", clusterID), + "PUT", ClusterPath(clusterID, "/kafka-broker-count"), params) + return nil + } + if !cli.Confirm(force, fmt.Sprintf( + "Change Kafka cluster %s to %d brokers? This places a paid order.", clusterID, brokerCount)) { + fmt.Println("Aborted.") + return nil + } + + result, err := apiClient.Request("PUT", ClusterPath(clusterID, "/kafka-broker-count"), params, nil) + if err != nil { + return fmt.Errorf("failed to resize brokers of Kafka cluster %s: %w", clusterID, err) + } + + return vdbclient.Output(cmd, result) +} + +func runResizeStorage(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + if err := ValidateClusterID(clusterID); err != nil { + return err + } + + volumeSize, _ := cmd.Flags().GetInt("volume-size") + if volumeSize <= 0 { + return fmt.Errorf("invalid --volume-size %d: must be greater than 0", volumeSize) + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + if current, err := fetchCluster(apiClient, clusterID); err == nil { + if have := intField(current, "kafkaStorageSize"); have == volumeSize { + return fmt.Errorf("Kafka cluster %s already has %d GB per broker; nothing to do", clusterID, have) + } + } + + params := map[string]string{"size": strconv.Itoa(volumeSize)} + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + previewQuery("resize", fmt.Sprintf("Kafka cluster %s", clusterID), + "PUT", ClusterPath(clusterID, "/kafka-storage-size"), params) + return nil + } + if !cli.Confirm(force, fmt.Sprintf( + "Change Kafka cluster %s to %d GB per broker? This places a paid order.", clusterID, volumeSize)) { + fmt.Println("Aborted.") + return nil + } + + result, err := apiClient.Request("PUT", ClusterPath(clusterID, "/kafka-storage-size"), params, nil) + if err != nil { + return fmt.Errorf("failed to resize storage of Kafka cluster %s: %w", clusterID, err) + } + + return vdbclient.Output(cmd, result) +} + +func runUpdateVolumeType(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + if err := ValidateClusterID(clusterID); err != nil { + return err + } + + volumeType, _ := cmd.Flags().GetString("volume-type") + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + if current, err := fetchCluster(apiClient, clusterID); err == nil { + if have := stringField(current, "kafkaStorageType"); have == volumeType { + return fmt.Errorf("Kafka cluster %s already uses volume type %s; nothing to do", clusterID, have) + } + } + + params := map[string]string{"storageType": volumeType} + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + previewQuery("update", fmt.Sprintf("Kafka cluster %s", clusterID), + "PUT", ClusterPath(clusterID, "/kafka-storage-type"), params) + return nil + } + if !cli.Confirm(force, fmt.Sprintf( + "Move Kafka cluster %s to volume type %s? This places a paid order.", clusterID, volumeType)) { + fmt.Println("Aborted.") + return nil + } + + result, err := apiClient.Request("PUT", ClusterPath(clusterID, "/kafka-storage-type"), params, nil) + if err != nil { + return fmt.Errorf("failed to change the volume type of Kafka cluster %s: %w", clusterID, err) + } + + return vdbclient.Output(cmd, result) +} + +func printDeleteTarget(apiClient *vdbclient.Client, clusterID string) { + clusterObj, err := fetchCluster(apiClient, clusterID) + if err != nil { + fmt.Printf("Warning: could not read Kafka cluster %s before deleting (%v).\n", clusterID, err) + return + } + + fmt.Println("The following Kafka cluster will be deleted:") + fmt.Println() + fmt.Printf(" ID: %v\n", clusterObj["id"]) + fmt.Printf(" Name: %v\n", clusterObj["name"]) + fmt.Printf(" Status: %v\n", clusterObj["status"]) + fmt.Printf(" Version: %v\n", clusterObj["kafkaVersion"]) + fmt.Printf(" Brokers: %v x %v GB (%v)\n", + clusterObj["kafkaBrokerCount"], clusterObj["kafkaStorageSize"], clusterObj["kafkaStorageType"]) + fmt.Println() +} diff --git a/go/cmd/vdb/kafka/cluster/read.go b/go/cmd/vdb/kafka/cluster/read.go new file mode 100644 index 0000000..6f6ba10 --- /dev/null +++ b/go/cmd/vdb/kafka/cluster/read.go @@ -0,0 +1,188 @@ +package cluster + +import ( + "fmt" + "strings" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List Kafka clusters", + Long: "List the vDB Kafka clusters in the project.\n\n" + + "The endpoint takes no pagination and no filters — it returns every cluster in " + + "one response — so there is no --page here, and --name and --status are applied " + + "by the CLI after the fact rather than by the server.", + Args: cobra.NoArgs, + RunE: runList, +} + +var getCmd = &cobra.Command{ + Use: "get", + Short: "Get details of a Kafka cluster", + Long: "Show the full details of one Kafka cluster, including its broker addresses, " + + "authentication switches and security rules.\n\n" + + "This is the only place the security rules and the attached config group version " + + "are visible: Kafka has no separate endpoint for either.", + Args: cobra.NoArgs, + RunE: runGet, +} + +var listHistoriesCmd = &cobra.Command{ + Use: "list-histories", + Short: "List the action history of a Kafka cluster", + Long: "List the recorded actions of a cluster with their status and, for failures, " + + "the error message.\n\n" + + "Kafka records history in its own shape: startedAt/finishedAt rather than the " + + "createdTime/updatedTime the other vDB products use, and no pagination.", + Args: cobra.NoArgs, + RunE: runListHistories, +} + +var listSecrulesCmd = &cobra.Command{ + Use: "list-secrules", + Short: "List the security rules of a Kafka cluster", + Long: "List the remote IP / port pairs allowed to reach the cluster.\n\n" + + "Kafka has no endpoint that lists rules: they are read out of the cluster " + + "object, which is what this command does. Their IDs are what " + + "'cluster delete-secrule --secrule-id' expects.", + Args: cobra.NoArgs, + RunE: runListSecrules, +} + +func init() { + l := listCmd.Flags() + l.String("name", "", "Show only clusters whose name contains this text (applied client-side)") + l.String("status", "", "Show only clusters in these statuses (comma-separated, applied client-side)") + listCmd.RegisterFlagCompletionFunc("status", statusCompletion()) //nolint:errcheck + + for _, cmd := range []*cobra.Command{getCmd, listHistoriesCmd, listSecrulesCmd} { + cmd.Flags().String("cluster-id", "", "Kafka cluster ID (required)") + cmd.MarkFlagRequired("cluster-id") //nolint:errcheck + cmd.RegisterFlagCompletionFunc("cluster-id", clusterIDCompletion()) //nolint:errcheck + } +} + +func runList(cmd *cobra.Command, args []string) error { + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Get(BasePath, nil) + if err != nil { + return fmt.Errorf("failed to list Kafka clusters: %w", err) + } + + name, _ := cmd.Flags().GetString("name") + status, _ := cmd.Flags().GetString("status") + filtered := filterClusters(vdbclient.Unwrap(result), name, cli.ParseCommaSeparated(status)) + + return vdbclient.OutputWithColumns(cmd, filtered, listColumns) +} + +// filterClusters applies --name and --status in the CLI. The endpoint accepts no +// filter parameters at all, so doing it here is the only option; it also means the +// result is the complete match set rather than one page of it. Matching is +// case-insensitive because vDB status values are mixed-case (ACTIVE alongside +// lowercase "deleting"). +func filterClusters(payload interface{}, name string, statuses []string) interface{} { + items, ok := payload.([]interface{}) + if !ok || (name == "" && len(statuses) == 0) { + return payload + } + + out := make([]interface{}, 0, len(items)) + for _, item := range items { + row, ok := item.(map[string]interface{}) + if !ok { + continue + } + if name != "" && !strings.Contains( + strings.ToLower(stringField(row, "name")), strings.ToLower(name)) { + continue + } + if len(statuses) > 0 && !matchesAnyStatus(stringField(row, "status"), statuses) { + continue + } + out = append(out, item) + } + return out +} + +func matchesAnyStatus(value string, statuses []string) bool { + for _, want := range statuses { + if strings.EqualFold(value, want) { + return true + } + } + return false +} + +func runGet(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + if err := ValidateClusterID(clusterID); err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Get(BasePath+"/"+clusterID, nil) + if err != nil { + return fmt.Errorf("failed to get Kafka cluster %s: %w", clusterID, err) + } + + // A cluster nests several arrays (fixedIps, floatingIps, securityGroupRules, + // kafkaStorageUsage), so print key/value rather than letting table extraction + // pick one of them at random. + return vdbclient.Output(cmd, result) +} + +func runListHistories(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + if err := ValidateClusterID(clusterID); err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Get(ClusterPath(clusterID, "/history"), nil) + if err != nil { + return fmt.Errorf("failed to list history of Kafka cluster %s: %w", clusterID, err) + } + + return vdbclient.OutputWithColumns(cmd, result, historyColumns) +} + +func runListSecrules(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + if err := ValidateClusterID(clusterID); err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + clusterObj, err := fetchCluster(apiClient, clusterID) + if err != nil { + return err + } + + rules, _ := clusterObj["securityGroupRules"].([]interface{}) + if rules == nil { + // An empty slice prints as an empty list; a nil would print as "null". + rules = []interface{}{} + } + return vdbclient.OutputWithColumns(cmd, rules, secruleColumns) +} diff --git a/go/cmd/vdb/kafka/cluster/secrules.go b/go/cmd/vdb/kafka/cluster/secrules.go new file mode 100644 index 0000000..3bea646 --- /dev/null +++ b/go/cmd/vdb/kafka/cluster/secrules.go @@ -0,0 +1,172 @@ +package cluster + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/validator" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// allowedPorts are the ports the API accepts on a rule, quoted from its own +// description of SecurityGroupRuleCreateRequest.port. Checked client-side so a typo +// names the flag rather than coming back as a 400. +var allowedPorts = []int{9092, 9094, 9096, 9194, 9196} + +// Kafka's security rules are managed one at a time — POST to add, DELETE to remove — +// unlike relational and memorystore, where a single PUT replaces the whole rule set. +// Nothing from vdbclient's ParseSecurityRules/SecurityRulesFrom applies here. + +var createSecruleCmd = &cobra.Command{ + Use: "create-secrule", + Short: "Allow a remote IP range to reach a Kafka cluster", + Long: "Add one security rule: a remote IP prefix and the port it may reach.\n\n" + + "Rules are added and removed individually here, one endpoint each — unlike " + + "Relational Database and MemoryStore, where a single call replaces the whole " + + "rule set. Existing rules are untouched.\n\n" + + "Allowed ports are 9092, 9094, 9096, 9194 and 9196; which one applies depends on " + + "the cluster's authentication and whether the client is inside the VPC.", + Args: cobra.NoArgs, + RunE: runCreateSecrule, +} + +var deleteSecruleCmd = &cobra.Command{ + Use: "delete-secrule", + Short: "Remove a security rule from a Kafka cluster", + Long: "Delete one security rule by ID.\n\n" + + "Run 'cluster list-secrules' for the IDs. Clients covered only by this rule lose " + + "access as soon as it is gone.", + Args: cobra.NoArgs, + RunE: runDeleteSecrule, +} + +func init() { + c := createSecruleCmd.Flags() + c.String("cluster-id", "", "Kafka cluster ID (required)") + c.String("remote-ip", "", "Remote IP prefix in CIDR form, e.g. 10.0.0.0/24 (required)") + c.Int("port", 0, fmt.Sprintf("Port to allow, one of %v (required)", allowedPorts)) + c.Bool("dry-run", false, "Print the request that would be sent without creating the rule") + c.Bool("force", false, "Skip the confirmation prompt") + createSecruleCmd.MarkFlagRequired("remote-ip") //nolint:errcheck + createSecruleCmd.MarkFlagRequired("port") //nolint:errcheck + createSecruleCmd.RegisterFlagCompletionFunc("port", portCompletion()) //nolint:errcheck + + d := deleteSecruleCmd.Flags() + d.String("cluster-id", "", "Kafka cluster ID (required)") + d.String("secrule-id", "", "ID of the rule to delete (required; 'cluster list-secrules')") + d.Bool("dry-run", false, "Print the request that would be sent without deleting") + d.Bool("force", false, "Skip the confirmation prompt") + deleteSecruleCmd.MarkFlagRequired("secrule-id") //nolint:errcheck + deleteSecruleCmd.RegisterFlagCompletionFunc("secrule-id", secruleIDCompletion()) //nolint:errcheck + + for _, cmd := range []*cobra.Command{createSecruleCmd, deleteSecruleCmd} { + cmd.MarkFlagRequired("cluster-id") //nolint:errcheck + cmd.RegisterFlagCompletionFunc("cluster-id", clusterIDCompletion()) //nolint:errcheck + } +} + +func portCompletion() cli.CompFunc { + values := make([]string, 0, len(allowedPorts)) + for _, port := range allowedPorts { + values = append(values, fmt.Sprint(port)) + } + return cli.FlagValues(values...) +} + +func runCreateSecrule(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + if err := ValidateClusterID(clusterID); err != nil { + return err + } + + remoteIP, _ := cmd.Flags().GetString("remote-ip") + port, _ := cmd.Flags().GetInt("port") + if !isAllowedPort(port) { + return fmt.Errorf("invalid --port %d: a Kafka security rule accepts only %v", port, allowedPorts) + } + + body := map[string]interface{}{ + "remoteIp": remoteIP, + "port": port, + } + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + vdbclient.PreviewBody("create", fmt.Sprintf("a security rule on Kafka cluster %s", clusterID), body) + return nil + } + if !cli.Confirm(force, fmt.Sprintf( + "Allow %s to reach Kafka cluster %s on port %d?", remoteIP, clusterID, port)) { + fmt.Println("Aborted.") + return nil + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Post(ClusterPath(clusterID, "/security-group-rules"), body) + if err != nil { + return fmt.Errorf("failed to create a security rule on Kafka cluster %s: %w", clusterID, err) + } + + // This POST does return the created rule (SecurityGroupRuleDto), unwrapped — + // unlike its DELETE counterpart, whose response is a bare string. + return vdbclient.Output(cmd, result) +} + +func runDeleteSecrule(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + if err := ValidateClusterID(clusterID); err != nil { + return err + } + + secruleID, _ := cmd.Flags().GetString("secrule-id") + if err := validator.ValidateID(secruleID, "secrule-id"); err != nil { + return err + } + + path := ClusterPath(clusterID, "/security-group-rules/"+secruleID) + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + fmt.Println("=== DRY RUN ===") + fmt.Printf("Would send DELETE %s\n", path) + cli.DryRunNotice("delete") + return nil + } + if !cli.Confirm(force, fmt.Sprintf( + "Delete security rule %s from Kafka cluster %s? Clients covered only by it lose access.", + secruleID, clusterID)) { + fmt.Println("Aborted.") + return nil + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + if err := apiClient.NoContent("DELETE", path, nil, nil); err != nil { + return fmt.Errorf("failed to delete security rule %s from Kafka cluster %s: %w", + secruleID, clusterID, err) + } + + fmt.Printf("Security rule %s deleted from Kafka cluster %s.\n", secruleID, clusterID) + return nil +} + +func isAllowedPort(port int) bool { + for _, allowed := range allowedPorts { + if port == allowed { + return true + } + } + return false +} diff --git a/go/cmd/vdb/kafka/cluster/update.go b/go/cmd/vdb/kafka/cluster/update.go new file mode 100644 index 0000000..874bd12 --- /dev/null +++ b/go/cmd/vdb/kafka/cluster/update.go @@ -0,0 +1,240 @@ +package cluster + +import ( + "fmt" + "strconv" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/spf13/cobra" +) + +// All three commands in this file share a shape that no other vDB group has: the +// arguments go in the query string of a PUT with no body, and the response is an +// unspecified bare string, so the command reports the HTTP outcome and points at the +// read command that shows the effect. + +var updateAuthenticationCmd = &cobra.Command{ + Use: "update-authentication", + Short: "Turn mTLS and SASL authentication on or off for a Kafka cluster", + Long: "Set which authentication mechanisms the cluster accepts.\n\n" + + "BOTH switches are sent on every call — the endpoint takes mtlsAuthen and " + + "saslAuthen together, so this replaces the pair rather than editing one of them. " + + "Pass both flags to be explicit; unset flags default to the cluster's current " + + "values, which are read first.\n\n" + + "THIS CAN BREAK EVERY CLIENT: turning a mechanism off invalidates the " + + "credentials that use it, and turning both off leaves the cluster open to " + + "anyone who can reach its network.", + Args: cobra.NoArgs, + RunE: runUpdateAuthentication, +} + +var updateConfigGroupCmd = &cobra.Command{ + Use: "update-config-group", + Short: "Apply a config group version to a Kafka cluster", + Long: "Point a cluster at a config group VERSION.\n\n" + + "Kafka config groups are versioned and a cluster attaches to one specific " + + "version, not to the group — so the value here is a version id from " + + "'grn vdb kafka configuration get', not a group id. Editing a group means " + + "creating a new version with 'configuration create-version' and then applying it " + + "here.\n\n" + + "Broker properties change under the cluster, so this can disrupt connected " + + "clients.", + Args: cobra.NoArgs, + RunE: runUpdateConfigGroup, +} + +var updatePublicAccessCmd = &cobra.Command{ + Use: "update-public-access", + Short: "Turn public access on or off for a Kafka cluster", + Long: "Attach or detach the cluster's public (floating) addresses.\n\n" + + "THIS CHANGES THE ADDRESSES CLIENTS CONNECT TO: turning it off drops the public " + + "endpoints, and anything reaching the cluster from outside the VPC stops " + + "working. Turning it on exposes the brokers to the internet, filtered only by " + + "the cluster's security rules — check 'cluster list-secrules' first.", + Args: cobra.NoArgs, + RunE: runUpdatePublicAccess, +} + +func init() { + a := updateAuthenticationCmd.Flags() + a.String("cluster-id", "", "Kafka cluster ID (required)") + a.Bool("mtls-authen", false, "Accept mTLS authentication (default: leave as it is)") + a.Bool("sasl-authen", false, "Accept SASL authentication (default: leave as it is)") + a.Bool("dry-run", false, "Print the request that would be sent without changing anything") + a.Bool("force", false, "Skip the confirmation prompt") + + c := updateConfigGroupCmd.Flags() + c.String("cluster-id", "", "Kafka cluster ID (required)") + c.String("config-group-version-id", "", "Config group VERSION to apply (required)") + c.Bool("dry-run", false, "Print the request that would be sent without changing anything") + c.Bool("force", false, "Skip the confirmation prompt") + updateConfigGroupCmd.MarkFlagRequired("config-group-version-id") //nolint:errcheck + updateConfigGroupCmd.RegisterFlagCompletionFunc("config-group-version-id", configGroupVersionCompletion()) //nolint:errcheck + + p := updatePublicAccessCmd.Flags() + p.String("cluster-id", "", "Kafka cluster ID (required)") + p.Bool("enable", false, "Attach public addresses; omit or pass --enable=false to detach them") + p.Bool("dry-run", false, "Print the request that would be sent without changing anything") + p.Bool("force", false, "Skip the confirmation prompt") + + for _, cmd := range []*cobra.Command{updateAuthenticationCmd, updateConfigGroupCmd, updatePublicAccessCmd} { + cmd.MarkFlagRequired("cluster-id") //nolint:errcheck + cmd.RegisterFlagCompletionFunc("cluster-id", clusterIDCompletion()) //nolint:errcheck + } +} + +func runUpdateAuthentication(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + if err := ValidateClusterID(clusterID); err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + // The endpoint requires both switches on every call, so an unset flag has to + // mean "keep what the cluster has" rather than "false" — otherwise changing one + // would silently turn the other off. + current, err := fetchCluster(apiClient, clusterID) + if err != nil { + return err + } + mtls := boolFlagOr(cmd, "mtls-authen", current["mtlsAuthen"]) + sasl := boolFlagOr(cmd, "sasl-authen", current["saslAuthen"]) + + params := map[string]string{ + "mtlsAuthen": strconv.FormatBool(mtls), + "saslAuthen": strconv.FormatBool(sasl), + } + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + previewQuery("update", fmt.Sprintf("authentication of Kafka cluster %s", clusterID), + "PUT", ClusterPath(clusterID, "/authentication"), params) + return nil + } + + prompt := fmt.Sprintf("Set Kafka cluster %s to mTLS=%t, SASL=%t? Clients using a disabled mechanism stop working.", + clusterID, mtls, sasl) + if !mtls && !sasl { + prompt = fmt.Sprintf( + "Turn OFF both mTLS and SASL on Kafka cluster %s? The brokers then accept unauthenticated clients.", + clusterID) + } + if !cli.Confirm(force, prompt) { + fmt.Println("Aborted.") + return nil + } + + if err := apiClient.NoContent("PUT", ClusterPath(clusterID, "/authentication"), params, nil); err != nil { + return fmt.Errorf("failed to update authentication of Kafka cluster %s: %w", clusterID, err) + } + + fmt.Printf("Authentication change accepted for Kafka cluster %s (mTLS=%t, SASL=%t). Run 'grn vdb kafka cluster get --cluster-id %s' to confirm it applied.\n", + clusterID, mtls, sasl, clusterID) + return nil +} + +func runUpdateConfigGroup(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + if err := ValidateClusterID(clusterID); err != nil { + return err + } + + versionID, _ := cmd.Flags().GetString("config-group-version-id") + params := map[string]string{"configGroupVersionId": versionID} + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + previewQuery("update", fmt.Sprintf("config group of Kafka cluster %s", clusterID), + "PUT", ClusterPath(clusterID, "/config-group"), params) + return nil + } + if !cli.Confirm(force, fmt.Sprintf( + "Apply config group version %s to Kafka cluster %s? Broker properties change under connected clients.", + versionID, clusterID)) { + fmt.Println("Aborted.") + return nil + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + if err := apiClient.NoContent("PUT", ClusterPath(clusterID, "/config-group"), params, nil); err != nil { + return fmt.Errorf("failed to apply config group version %s to Kafka cluster %s: %w", + versionID, clusterID, err) + } + + fmt.Printf("Config group version %s accepted for Kafka cluster %s. Run 'grn vdb kafka cluster get --cluster-id %s' to confirm it applied.\n", + versionID, clusterID, clusterID) + return nil +} + +func runUpdatePublicAccess(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + if err := ValidateClusterID(clusterID); err != nil { + return err + } + + enable, _ := cmd.Flags().GetBool("enable") + // The query parameter is declared as a STRING, not a boolean, unlike the + // mtlsAuthen/saslAuthen pair on the sibling endpoint. Send the same "true"/"false" + // text either way; do not assume the two endpoints agree just because both take a + // switch. + params := map[string]string{"enable": strconv.FormatBool(enable)} + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + previewQuery("update", fmt.Sprintf("public access of Kafka cluster %s", clusterID), + "PUT", ClusterPath(clusterID, "/public-access"), params) + return nil + } + + prompt := fmt.Sprintf( + "Turn public access ON for Kafka cluster %s? The brokers become reachable from the internet, filtered only by its security rules.", + clusterID) + if !enable { + prompt = fmt.Sprintf( + "Turn public access OFF for Kafka cluster %s? Clients connecting to its public addresses stop working.", + clusterID) + } + if !cli.Confirm(force, prompt) { + fmt.Println("Aborted.") + return nil + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + if err := apiClient.NoContent("PUT", ClusterPath(clusterID, "/public-access"), params, nil); err != nil { + return fmt.Errorf("failed to update public access of Kafka cluster %s: %w", clusterID, err) + } + + fmt.Printf("Public access change accepted for Kafka cluster %s (enable=%t). Run 'grn vdb kafka cluster get --cluster-id %s' to see the resulting addresses.\n", + clusterID, enable, clusterID) + return nil +} + +// boolFlagOr returns the flag when the user set it, and the cluster's current value +// otherwise. Cobra's GetBool cannot distinguish "--flag=false" from "not passed", so +// Changed is the only way to tell them apart. +func boolFlagOr(cmd *cobra.Command, name string, current interface{}) bool { + if cmd.Flags().Changed(name) { + value, _ := cmd.Flags().GetBool(name) + return value + } + value, _ := current.(bool) + return value +} diff --git a/go/cmd/vdb/kafka/configuration/commands.go b/go/cmd/vdb/kafka/configuration/commands.go new file mode 100644 index 0000000..d8d32d3 --- /dev/null +++ b/go/cmd/vdb/kafka/configuration/commands.go @@ -0,0 +1,371 @@ +package configuration + +import ( + "fmt" + "sort" + "strings" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// groupColumns is the table view of a ConfigGroupDto. `versions` is left out: it is a +// nested array, so it cannot be a table cell, and including it would make +// OutputWithColumns treat the versions as the rows. Use 'configuration get' for them. +var groupColumns = []string{"id", "name", "description", "status", "createdAt"} + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List Kafka config groups", + Long: "List the Kafka config groups in the project.\n\n" + + "Each group's versions come back nested in its row, so use --output json to see " + + "them — the version IDs are what a cluster attaches to.", + Args: cobra.NoArgs, + RunE: runList, +} + +var getCmd = &cobra.Command{ + Use: "get", + Short: "Get details of a Kafka config group", + Long: "Show one config group with all of its versions.\n\n" + + "This is where the version IDs for " + + "'grn vdb kafka cluster update-config-group --config-group-version-id' come from.", + Args: cobra.NoArgs, + RunE: runGet, +} + +var getVersionCmd = &cobra.Command{ + Use: "get-version", + Short: "Get one version of a Kafka config group", + Long: "Show a single version of a config group: its properties and the clusters " + + "currently using it.\n\n" + + "The associatedClusterIds/associatedClusterNames fields answer 'what breaks if I " + + "change this?' — check them before applying a different version to a cluster.", + Args: cobra.NoArgs, + RunE: runGetVersion, +} + +var createCmd = &cobra.Command{ + Use: "create", + Short: "Create a Kafka config group", + Long: "Create a config group and its first version.\n\n" + + "Properties are given as key=value pairs, repeat --property for each one:\n" + + " --property num.partitions=3 --property log.retention.hours=168\n\n" + + "Kafka has no endpoint listing the valid property names, so they are not " + + "validated or completed here — a wrong key is rejected by the API.", + Args: cobra.NoArgs, + RunE: runCreate, +} + +var createVersionCmd = &cobra.Command{ + Use: "create-version", + Short: "Add a version to a Kafka config group", + Long: "Create a new version of an existing config group.\n\n" + + "This is how a config group is 'edited': versions are immutable, so a change " + + "means a new version, which then has to be applied with " + + "'grn vdb kafka cluster update-config-group'. Creating one changes nothing on " + + "any cluster by itself.\n\n" + + "The properties given here are the WHOLE version — they do not merge with the " + + "previous one. Use --from-version to start from an existing version's " + + "properties and override only what you pass.", + Args: cobra.NoArgs, + RunE: runCreateVersion, +} + +var deleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete a Kafka config group", + Long: "Delete a config group and every version of it.\n\n" + + "Check 'configuration get' first: a version in use by a cluster is listed in its " + + "associatedClusterIds, and deleting the group it belongs to is not something the " + + "CLI can undo.", + Args: cobra.NoArgs, + RunE: runDelete, +} + +func init() { + for _, cmd := range []*cobra.Command{getCmd, createVersionCmd, deleteCmd, getVersionCmd} { + cmd.Flags().String("config-group-id", "", "Kafka config group ID (required)") + cmd.MarkFlagRequired("config-group-id") //nolint:errcheck + cmd.RegisterFlagCompletionFunc("config-group-id", configGroupIDCompletion()) //nolint:errcheck + } + + getVersionCmd.Flags().String("config-group-version-id", "", "Config group version ID (required)") + getVersionCmd.MarkFlagRequired("config-group-version-id") //nolint:errcheck + getVersionCmd.RegisterFlagCompletionFunc("config-group-version-id", configGroupVersionIDCompletion()) //nolint:errcheck + + c := createCmd.Flags() + c.String("name", "", "Name of the new config group (required)") + c.String("description", "", "Description of the config group") + c.StringArray("property", nil, "Broker property as key=value; repeat for each one (required)") + createCmd.MarkFlagRequired("name") //nolint:errcheck + createCmd.MarkFlagRequired("property") //nolint:errcheck + + v := createVersionCmd.Flags() + v.StringArray("property", nil, "Broker property as key=value; repeat for each one") + v.String("from-version", "", "Start from this version's properties and override only what --property sets") + v.Bool("dry-run", false, "Print the request that would be sent without creating a version") + v.Bool("force", false, "Skip the confirmation prompt") + createVersionCmd.RegisterFlagCompletionFunc("from-version", configGroupVersionIDCompletion()) //nolint:errcheck + + d := deleteCmd.Flags() + d.Bool("dry-run", false, "Print the request that would be sent without deleting") + d.Bool("force", false, "Skip the confirmation prompt") +} + +func runList(cmd *cobra.Command, args []string) error { + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Get(basePath, nil) + if err != nil { + return fmt.Errorf("failed to list Kafka config groups: %w", err) + } + + return vdbclient.OutputWithColumns(cmd, result, groupColumns) +} + +func runGet(cmd *cobra.Command, args []string) error { + configGroupID, _ := cmd.Flags().GetString("config-group-id") + if err := validateConfigGroupID(configGroupID); err != nil { + return err + } + + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Get(groupPath(configGroupID), nil) + if err != nil { + return fmt.Errorf("failed to get Kafka config group %s: %w", configGroupID, err) + } + + // A group nests its versions, so print key/value rather than letting table + // extraction turn the versions into the rows. + return vdbclient.Output(cmd, result) +} + +func runGetVersion(cmd *cobra.Command, args []string) error { + configGroupID, _ := cmd.Flags().GetString("config-group-id") + versionID, _ := cmd.Flags().GetString("config-group-version-id") + if err := validateConfigGroupID(configGroupID); err != nil { + return err + } + if err := validateVersionID(versionID); err != nil { + return err + } + + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Get(versionPath(configGroupID, versionID), nil) + if err != nil { + return fmt.Errorf("failed to get version %s of Kafka config group %s: %w", + versionID, configGroupID, err) + } + + return vdbclient.Output(cmd, result) +} + +func runCreate(cmd *cobra.Command, args []string) error { + name, _ := cmd.Flags().GetString("name") + description, _ := cmd.Flags().GetString("description") + pairs, _ := cmd.Flags().GetStringArray("property") + + properties, err := parseProperties(pairs) + if err != nil { + return err + } + + body := map[string]interface{}{ + "name": name, + "description": description, + "properties": properties, + } + + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Post(basePath, body) + if err != nil { + return fmt.Errorf("failed to create Kafka config group %q: %w", name, err) + } + + return vdbclient.Output(cmd, result) +} + +func runCreateVersion(cmd *cobra.Command, args []string) error { + configGroupID, _ := cmd.Flags().GetString("config-group-id") + if err := validateConfigGroupID(configGroupID); err != nil { + return err + } + + pairs, _ := cmd.Flags().GetStringArray("property") + overrides, err := parsePropertyMap(pairs) + if err != nil { + return err + } + + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return err + } + + // --from-version exists because the new version REPLACES rather than extends: the + // request carries the complete property set, so without a starting point every + // property has to be retyped to change one. Same trap as relational's + // `configuration update`, which drops every parameter it is not given. + base := map[string]string{} + if fromVersion, _ := cmd.Flags().GetString("from-version"); fromVersion != "" { + if err := validateVersionID(fromVersion); err != nil { + return err + } + base, err = propertiesOfVersion(apiClient, configGroupID, fromVersion) + if err != nil { + return err + } + } + for key, value := range overrides { + base[key] = value + } + if len(base) == 0 { + return fmt.Errorf("nothing to create: pass --property, or --from-version to copy an existing version") + } + + body := map[string]interface{}{"properties": propertyList(base)} + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + vdbclient.PreviewBody("create", + fmt.Sprintf("a version of Kafka config group %s", configGroupID), body) + return nil + } + if !cli.Confirm(force, fmt.Sprintf( + "Create a new version of Kafka config group %s with %d properties? No cluster changes until you apply it.", + configGroupID, len(base))) { + fmt.Println("Aborted.") + return nil + } + + result, err := apiClient.Post(versionsPath(configGroupID), body) + if err != nil { + return fmt.Errorf("failed to create a version of Kafka config group %s: %w", configGroupID, err) + } + + fmt.Println("Apply it with 'grn vdb kafka cluster update-config-group --config-group-version-id '.") + return vdbclient.Output(cmd, result) +} + +func runDelete(cmd *cobra.Command, args []string) error { + configGroupID, _ := cmd.Flags().GetString("config-group-id") + if err := validateConfigGroupID(configGroupID); err != nil { + return err + } + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + fmt.Println("=== DRY RUN ===") + fmt.Printf("Would send DELETE %s\n", groupPath(configGroupID)) + cli.DryRunNotice("delete") + return nil + } + if !cli.Confirm(force, fmt.Sprintf( + "Delete Kafka config group %s and every version of it? Check 'configuration get' for clusters still using one.", + configGroupID)) { + fmt.Println("Aborted.") + return nil + } + + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return err + } + + // The response is an unspecified bare string; the HTTP status is the result. + if err := apiClient.NoContent("DELETE", groupPath(configGroupID), nil, nil); err != nil { + return fmt.Errorf("failed to delete Kafka config group %s: %w", configGroupID, err) + } + + fmt.Printf("Kafka config group %s deleted.\n", configGroupID) + return nil +} + +// parseProperties turns key=value flags into the API's list of {key, value} objects. +// Note the shape: a LIST of pairs, not the map that relational's config groups use. +func parseProperties(pairs []string) ([]interface{}, error) { + values, err := parsePropertyMap(pairs) + if err != nil { + return nil, err + } + return propertyList(values), nil +} + +func parsePropertyMap(pairs []string) (map[string]string, error) { + out := map[string]string{} + for _, pair := range pairs { + key, value, found := strings.Cut(pair, "=") + key = strings.TrimSpace(key) + if !found || key == "" { + return nil, fmt.Errorf("invalid --property %q: expected key=value", pair) + } + out[key] = value + } + return out, nil +} + +// propertyList renders the map back as the API's array, sorted so a --dry-run of the +// same input always prints the same thing. +func propertyList(values map[string]string) []interface{} { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + + out := make([]interface{}, 0, len(keys)) + for _, key := range keys { + out = append(out, map[string]interface{}{"key": key, "value": values[key]}) + } + return out +} + +// propertiesOfVersion reads an existing version's properties for --from-version. +func propertiesOfVersion(apiClient *vdbclient.Client, configGroupID, versionID string) (map[string]string, error) { + result, err := apiClient.Get(versionPath(configGroupID, versionID), nil) + if err != nil { + return nil, fmt.Errorf("failed to read version %s of Kafka config group %s: %w", + versionID, configGroupID, err) + } + version, ok := vdbclient.PayloadObject(result) + if !ok { + return nil, fmt.Errorf("version %s of Kafka config group %s not found (the API returned an empty payload)", + versionID, configGroupID) + } + + out := map[string]string{} + items, _ := version["properties"].([]interface{}) + for _, item := range items { + property, ok := item.(map[string]interface{}) + if !ok { + continue + } + key, _ := property["key"].(string) + value, _ := property["value"].(string) + if key != "" { + out[key] = value + } + } + return out, nil +} diff --git a/go/cmd/vdb/kafka/configuration/completion.go b/go/cmd/vdb/kafka/configuration/completion.go new file mode 100644 index 0000000..7a98ce3 --- /dev/null +++ b/go/cmd/vdb/kafka/configuration/completion.go @@ -0,0 +1,95 @@ +package configuration + +import ( + "context" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/validator" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// Completers live here; flags are bound in the init() of the file that DEFINES +// them — see the init-order note in cmd/vdb/CLAUDE.md. + +const ( + // ConfigGroupResourceKey lists Kafka config GROUP ids. + ConfigGroupResourceKey = "vdb:kafka-config-group" + + // ConfigGroupVersionResourceKey lists config group VERSION ids — the values a + // cluster is attached to. It is a separate key because the two are not + // interchangeable: 'cluster update-config-group' takes a version and rejects a + // group. Kafka has no endpoint listing versions across groups, so the completer + // reads the group listing and collects the versions nested in each row. + ConfigGroupVersionResourceKey = "vdb:kafka-config-group-version" +) + +func init() { + cli.RegisterResourceCompleter(ConfigGroupResourceKey, cli.FlagFromAPI(configGroupIDs)) + cli.RegisterResourceCompleter(ConfigGroupVersionResourceKey, cli.FlagFromAPI(configGroupVersionIDs)) +} + +func configGroupIDCompletion() cli.CompFunc { + return cli.ResourceCompletion(ConfigGroupResourceKey) +} + +func configGroupVersionIDCompletion() cli.CompFunc { + return cli.ResourceCompletion(ConfigGroupVersionResourceKey) +} + +func listConfigGroups(cmd *cobra.Command) ([]interface{}, error) { + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return nil, err + } + result, err := apiClient.Get(basePath, nil) + if err != nil { + return nil, err + } + groups, _ := vdbclient.Unwrap(result).([]interface{}) + return groups, nil +} + +func configGroupIDs(_ context.Context, cmd *cobra.Command) ([]string, error) { + groups, err := listConfigGroups(cmd) + if err != nil { + return nil, err + } + return cli.ExtractIDs(groups, "id"), nil +} + +// configGroupVersionIDs digs one level into each group's nested versions array. When +// --config-group-id is on the command line it narrows to that group, which is what +// 'configuration get-version' wants; otherwise it offers every version, which is what +// 'cluster update-config-group' wants. +func configGroupVersionIDs(_ context.Context, cmd *cobra.Command) ([]string, error) { + groups, err := listConfigGroups(cmd) + if err != nil { + return nil, err + } + wanted, _ := cmd.Flags().GetString("config-group-id") + + var out []string + for _, item := range groups { + group, ok := item.(map[string]interface{}) + if !ok { + continue + } + if wanted != "" { + if id, _ := group["id"].(string); id != wanted { + continue + } + } + versions, _ := group["versions"].([]interface{}) + out = append(out, cli.ExtractIDs(versions, "id")...) + } + return out, nil +} + +func validateConfigGroupID(configGroupID string) error { + return validator.ValidateID(configGroupID, "config-group-id") +} + +func validateVersionID(versionID string) error { + return validator.ValidateID(versionID, "config-group-version-id") +} diff --git a/go/cmd/vdb/kafka/configuration/configuration.go b/go/cmd/vdb/kafka/configuration/configuration.go new file mode 100644 index 0000000..049d966 --- /dev/null +++ b/go/cmd/vdb/kafka/configuration/configuration.go @@ -0,0 +1,61 @@ +// Package configuration holds the Kafka config-group commands. +// +// Kafka config groups differ from the ones in Relational Database and MemoryStore in +// the way that matters most: they are VERSIONED and IMMUTABLE. A group is a named +// container; the properties live in a version of it, and a cluster is attached to a +// specific version. There is therefore no update endpoint — changing a property +// means creating a new version and applying that to the cluster. +// +// Two other differences: the properties are a list of {key, value} pairs rather than +// a map, and there is no parameter-catalogue endpoint (relational's +// `configuration list-params`), so the CLI cannot tell you which keys are valid. +package configuration + +import ( + "github.com/spf13/cobra" +) + +const ( + // basePath is the config-group collection. Note it hangs off /vdb-kafka + // directly, NOT off a cluster: a group is project-wide and can be applied to + // several clusters. + basePath = "/vdb-kafka/config-groups" +) + +func groupPath(configGroupID string) string { + return basePath + "/" + configGroupID +} + +func versionsPath(configGroupID string) string { + return groupPath(configGroupID) + "/versions" +} + +func versionPath(configGroupID, versionID string) string { + return versionsPath(configGroupID) + "/" + versionID +} + +// ConfigurationCmd is the parent command for Kafka config groups. +var ConfigurationCmd = &cobra.Command{ + Use: "configuration", + Short: "Manage Kafka config groups", + Long: "Manage the config groups that carry Kafka broker properties.\n\n" + + "Kafka config groups are VERSIONED: a group holds versions, a version holds the " + + "properties, and a cluster is attached to one version. There is no update " + + "command because a version cannot be edited — use 'create-version' and then " + + "'grn vdb kafka cluster update-config-group' to apply it.\n\n" + + "Unlike the other vDB products, Kafka has no endpoint listing the valid " + + "property names, so there is no 'list-params' here.", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() //nolint:errcheck + }, +} + +func init() { + ConfigurationCmd.AddCommand(listCmd) + ConfigurationCmd.AddCommand(getCmd) + ConfigurationCmd.AddCommand(getVersionCmd) + ConfigurationCmd.AddCommand(createCmd) + ConfigurationCmd.AddCommand(createVersionCmd) + ConfigurationCmd.AddCommand(deleteCmd) +} diff --git a/go/cmd/vdb/kafka/kafka.go b/go/cmd/vdb/kafka/kafka.go new file mode 100644 index 0000000..baa1a79 --- /dev/null +++ b/go/cmd/vdb/kafka/kafka.go @@ -0,0 +1,59 @@ +// Package kafka is the command root for vDB Kafka. +// +// Kafka is the odd one out among the four vDB products, and almost nothing learned +// from relational, memorystore or postgresql transfers. The differences that shape +// every file under here: +// +// - **No response envelope on most operations.** Only 9 of the 36 endpoints wrap +// their payload in {code, message, data}; the rest return bare objects, bare +// arrays or plain strings. vdbclient.Unwrap passes those through untouched, so +// calling it stays correct — but nothing may ASSUME a wrapper. +// - **No pagination.** The listings take no pageNumber/pageSize and no filters, so +// the list commands have no --page or --name. +// - **Mutating arguments travel in the query string.** Changing the broker count is +// `PUT /clusters/{id}/kafka-broker-count?count=6&rebalance=true` with an empty +// body — hence vdbclient.Client.Request, which the verb helpers cannot express. +// - **Eleven mutating endpoints answer with an unspecified bare string.** Those +// commands report the HTTP outcome and print nothing of the body; see +// vdbclient.Client.NoContent. +// - **Kafka owns nouns no other product has**: topics, users with per-topic +// permissions, and config groups that are VERSIONED — a cluster is attached to a +// config group *version*, not to the group. +// +// The path prefix is `/vdb-kafka`, with no `/v1` segment that the other three carry. +package kafka + +import ( + "github.com/greennodehub/greennode-cli/cmd/vdb/kafka/catalog" + "github.com/greennodehub/greennode-cli/cmd/vdb/kafka/cluster" + "github.com/greennodehub/greennode-cli/cmd/vdb/kafka/configuration" + "github.com/greennodehub/greennode-cli/cmd/vdb/kafka/topic" + "github.com/greennodehub/greennode-cli/cmd/vdb/kafka/user" + "github.com/spf13/cobra" +) + +// KafkaCmd groups the Kafka commands. +var KafkaCmd = &cobra.Command{ + Use: "kafka", + Short: "Manage Kafka clusters", + Long: "Manage vDB Kafka clusters, their topics, users, config groups and " + + "security rules.\n\n" + + "A Kafka cluster is a set of 3-10 brokers. Unlike the other vDB products it has " + + "no backups, no replicas and no config group attached directly: config groups " + + "are versioned, and a cluster points at a specific VERSION of one.\n\n" + + "Access is per-user and per-topic. Create a user with 'kafka user create', give " + + "it produce/consume/admin permissions on the topics it needs, then read its " + + "credentials with 'kafka user get-creds'.", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() //nolint:errcheck + }, +} + +func init() { + KafkaCmd.AddCommand(cluster.ClusterCmd) + KafkaCmd.AddCommand(topic.TopicCmd) + KafkaCmd.AddCommand(user.UserCmd) + KafkaCmd.AddCommand(configuration.ConfigurationCmd) + KafkaCmd.AddCommand(catalog.CatalogCmd) +} diff --git a/go/cmd/vdb/kafka/paths_test.go b/go/cmd/vdb/kafka/paths_test.go new file mode 100644 index 0000000..5bdff1c --- /dev/null +++ b/go/cmd/vdb/kafka/paths_test.go @@ -0,0 +1,128 @@ +package kafka + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" +) + +// TestGroupIsComplete pins the five nouns and every command under them, so a dropped +// registration shows up here rather than as a missing command. +// +// 36 commands cover 35 of the API's 36 Kafka endpoints. The two numbers differ for +// two deliberate reasons, both asserted below: +// +// - `cluster list-secrules` has NO endpoint. Kafka never lists security rules on +// their own; they arrive nested in the cluster object, and their IDs are needed +// for delete-secrule, so the command reads the cluster and prints them. +// - `GET /vdb-kafka/database/configs` has NO command. It returns the service's own +// application settings as a map — not something a user acts on. +func TestGroupIsComplete(t *testing.T) { + want := map[string][]string{ + "cluster": { + "list", "get", "list-histories", "list-secrules", + "create", "delete", + "resize-brokers", "resize-storage", "update-volume-type", + "update-authentication", "update-config-group", "update-public-access", + "create-secrule", "delete-secrule", + }, + "topic": {"list", "get", "create", "update", "delete"}, + "user": {"list", "get", "get-creds", "create", "update", "delete", "generate-creds"}, + "configuration": { + "list", "get", "get-version", "create", "create-version", "delete", + }, + "catalog": {"list-families", "list-flavor-codes", "list-flavors", "list-volume-types"}, + } + + for noun, commands := range want { + group, _, err := KafkaCmd.Find([]string{noun}) + if err != nil || group.Name() != noun { + t.Errorf("noun %q is not registered", noun) + continue + } + present := map[string]bool{} + for _, sub := range group.Commands() { + present[sub.Name()] = true + } + for _, name := range commands { + if !present[name] { + t.Errorf("kafka %s %s is missing", noun, name) + } + } + } +} + +// TestNoConfigsCommand: /vdb-kafka/database/configs returns the service's own +// application settings, which no user acts on. It must not gain a command by someone +// mechanically walking the spec. +func TestNoConfigsCommand(t *testing.T) { + catalog, _, err := KafkaCmd.Find([]string{"catalog"}) + if err != nil { + t.Fatal("catalog noun is not registered") + } + for _, sub := range catalog.Commands() { + if strings.Contains(sub.Name(), "config") { + t.Errorf("catalog %s exists; /vdb-kafka/database/configs is service configuration, not a user lookup", + sub.Name()) + } + } +} + +// TestNoBackupNoun: Kafka is the one vDB product with no backup service. A backup +// noun here would mean someone copied it from another group. +func TestNoBackupNoun(t *testing.T) { + for _, sub := range KafkaCmd.Commands() { + name := sub.Name() + if strings.Contains(name, "backup") { + t.Errorf("kafka %s exists, but the Kafka API has no backup endpoints at all", name) + } + } +} + +// TestEveryLeafHasHelp mirrors the repo-wide convention test, scoped to this group so +// a new command cannot land without a Short. +func TestEveryLeafHasHelp(t *testing.T) { + var walk func(*cobra.Command) + walk = func(c *cobra.Command) { + for _, sub := range c.Commands() { + if sub.Runnable() && len(sub.Commands()) == 0 && strings.TrimSpace(sub.Short) == "" { + t.Errorf("%s has no Short description", sub.CommandPath()) + } + walk(sub) + } + } + walk(KafkaCmd) +} + +// TestMutatingCommandsAreGated goes beyond the repo-wide rule, which only covers +// delete/stop/reboot. Every Kafka command that places a paid order, changes who can +// reach the cluster, or replaces a set of values wholesale must offer --dry-run and +// --force too. +func TestMutatingCommandsAreGated(t *testing.T) { + gated := map[string][]string{ + "cluster": { + "create", "delete", "resize-brokers", "resize-storage", "update-volume-type", + "update-authentication", "update-config-group", "update-public-access", + "create-secrule", "delete-secrule", + }, + "topic": {"update", "delete"}, + "user": {"update", "delete", "generate-creds"}, + "configuration": {"create-version", "delete"}, + } + + for noun, commands := range gated { + for _, name := range commands { + cmd, _, err := KafkaCmd.Find([]string{noun, name}) + if err != nil || cmd.Name() != name { + t.Errorf("kafka %s %s is not registered", noun, name) + continue + } + for _, flag := range []string{"dry-run", "force"} { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("kafka %s %s must define --%s", noun, name, flag) + } + } + } + } +} diff --git a/go/cmd/vdb/kafka/topic/completion.go b/go/cmd/vdb/kafka/topic/completion.go new file mode 100644 index 0000000..8be088d --- /dev/null +++ b/go/cmd/vdb/kafka/topic/completion.go @@ -0,0 +1,95 @@ +package topic + +import ( + "context" + "fmt" + + "github.com/greennodehub/greennode-cli/cmd/vdb/kafka/cluster" + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/validator" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// Completers live here; flags are bound in the init() of the file that DEFINES +// them — see the init-order note in cmd/vdb/CLAUDE.md. + +// TopicResourceKey lists the topic IDs of the cluster named on the command line. It +// is CONTEXT-DEPENDENT: topics are scoped to a cluster, so it yields nothing until +// --cluster-id is set. +const TopicResourceKey = "vdb:kafka-topic" + +// TopicNameResourceKey lists topic NAMES rather than IDs. The user permission flags +// take names, not IDs — the one place in the Kafka API where a topic is referenced +// by name — so a completer that offered IDs there would suggest values the API +// silently ignores. +const TopicNameResourceKey = "vdb:kafka-topic-name" + +func init() { + cli.RegisterResourceCompleter(TopicResourceKey, cli.FlagFromAPI(topicField("id"))) + cli.RegisterResourceCompleter(TopicNameResourceKey, cli.FlagFromAPI(topicField("name"))) +} + +func clusterIDCompletion() cli.CompFunc { + return cli.ResourceCompletion(cluster.ClusterResourceKey) +} + +func topicIDCompletion() cli.CompFunc { + return cli.ResourceCompletion(TopicResourceKey) +} + +// topicField reads one field from every topic of the cluster on the command line. +func topicField(field string) func(context.Context, *cobra.Command) ([]string, error) { + return func(_ context.Context, cmd *cobra.Command) ([]string, error) { + clusterID, _ := cmd.Flags().GetString("cluster-id") + if clusterID == "" { + return nil, nil + } + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return nil, err + } + result, err := apiClient.Get(topicsPath(clusterID), nil) + if err != nil { + return nil, err + } + return cli.ExtractIDs(vdbclient.Unwrap(result), field), nil + } +} + +// topicsPath is the topic collection of a cluster. The path is built from the +// cluster package's helper rather than by string-joining "/vdb-kafka/clusters" here: +// one owner per path prefix, per the rule in cmd/vdb/CLAUDE.md. +func topicsPath(clusterID string) string { + return cluster.ClusterPath(clusterID, "/topics") +} + +func topicPath(clusterID, topicID string) string { + return topicsPath(clusterID) + "/" + topicID +} + +func validateClusterID(clusterID string) error { + return cluster.ValidateClusterID(clusterID) +} + +func validateIDs(clusterID, topicID string) error { + if err := cluster.ValidateClusterID(clusterID); err != nil { + return err + } + return validator.ValidateID(topicID, "topic-id") +} + +// fetchTopic reads one topic, reporting a null payload as a miss rather than +// handing back zeroed fields. +func fetchTopic(apiClient *vdbclient.Client, clusterID, topicID string) (map[string]interface{}, error) { + result, err := apiClient.Get(topicPath(clusterID, topicID), nil) + if err != nil { + return nil, fmt.Errorf("failed to read topic %s of Kafka cluster %s: %w", topicID, clusterID, err) + } + topicObj, ok := vdbclient.PayloadObject(result) + if !ok { + return nil, fmt.Errorf("topic %s of Kafka cluster %s not found (the API returned an empty payload)", + topicID, clusterID) + } + return topicObj, nil +} diff --git a/go/cmd/vdb/kafka/topic/read.go b/go/cmd/vdb/kafka/topic/read.go new file mode 100644 index 0000000..6906197 --- /dev/null +++ b/go/cmd/vdb/kafka/topic/read.go @@ -0,0 +1,88 @@ +package topic + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// topicColumns matches TopicDto. Every field fits, so this is the whole object +// rather than a subset — Kafka's topics are the one narrow resource in vDB. +var topicColumns = []string{ + "id", "name", "partitions", "replicas", "retentionSeconds", "retentionBytes", + "status", "createdAt", +} + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List the topics of a Kafka cluster", + Long: "List every topic on a cluster with its partition count, replication factor " + + "and retention limits.\n\n" + + "The endpoint takes no pagination and no filters, so this is the complete list.", + Args: cobra.NoArgs, + RunE: runList, +} + +var getCmd = &cobra.Command{ + Use: "get", + Short: "Get details of a Kafka topic", + Long: "Show one topic of a cluster.", + Args: cobra.NoArgs, + RunE: runGet, +} + +func init() { + listCmd.Flags().String("cluster-id", "", "Kafka cluster ID (required)") + listCmd.MarkFlagRequired("cluster-id") //nolint:errcheck + listCmd.RegisterFlagCompletionFunc("cluster-id", clusterIDCompletion()) //nolint:errcheck + + g := getCmd.Flags() + g.String("cluster-id", "", "Kafka cluster ID (required)") + g.String("topic-id", "", "Topic ID (required)") + getCmd.MarkFlagRequired("cluster-id") //nolint:errcheck + getCmd.MarkFlagRequired("topic-id") //nolint:errcheck + getCmd.RegisterFlagCompletionFunc("cluster-id", clusterIDCompletion()) //nolint:errcheck + getCmd.RegisterFlagCompletionFunc("topic-id", topicIDCompletion()) //nolint:errcheck +} + +func runList(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + if err := validateClusterID(clusterID); err != nil { + return err + } + + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Get(topicsPath(clusterID), nil) + if err != nil { + return fmt.Errorf("failed to list topics of Kafka cluster %s: %w", clusterID, err) + } + + return vdbclient.OutputWithColumns(cmd, result, topicColumns) +} + +func runGet(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + topicID, _ := cmd.Flags().GetString("topic-id") + if err := validateIDs(clusterID, topicID); err != nil { + return err + } + + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Get(topicPath(clusterID, topicID), nil) + if err != nil { + return fmt.Errorf("failed to get topic %s of Kafka cluster %s: %w", topicID, clusterID, err) + } + + // A topic carries no nested arrays, so a column set would be unambiguous here — + // but a single resource still reads better as a key/value table. + return vdbclient.Output(cmd, result) +} diff --git a/go/cmd/vdb/kafka/topic/topic.go b/go/cmd/vdb/kafka/topic/topic.go new file mode 100644 index 0000000..1553c4f --- /dev/null +++ b/go/cmd/vdb/kafka/topic/topic.go @@ -0,0 +1,34 @@ +// Package topic holds the Kafka topic commands. +// +// Topics have no equivalent in the other three vDB products: they hang off a +// cluster path (`/vdb-kafka/clusters/{clusterId}/topics`), so every command needs +// --cluster-id alongside the topic's own ID. +package topic + +import ( + "github.com/spf13/cobra" +) + +// TopicCmd is the parent command for Kafka topic commands. +var TopicCmd = &cobra.Command{ + Use: "topic", + Short: "Manage the topics of a Kafka cluster", + Long: "List, create, update and delete the topics of a vDB Kafka cluster.\n\n" + + "Every command takes --cluster-id: topics live under a cluster and their IDs are " + + "only meaningful there.\n\n" + + "Retention is set per topic, by time (--retention-seconds) and by size " + + "(--retention-bytes); whichever limit is reached first removes the oldest " + + "records.", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() //nolint:errcheck + }, +} + +func init() { + TopicCmd.AddCommand(listCmd) + TopicCmd.AddCommand(getCmd) + TopicCmd.AddCommand(createCmd) + TopicCmd.AddCommand(updateCmd) + TopicCmd.AddCommand(deleteCmd) +} diff --git a/go/cmd/vdb/kafka/topic/write.go b/go/cmd/vdb/kafka/topic/write.go new file mode 100644 index 0000000..d3f46e8 --- /dev/null +++ b/go/cmd/vdb/kafka/topic/write.go @@ -0,0 +1,332 @@ +package topic + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// Bounds quoted from the API's own field descriptions, checked client-side so the +// error names the flag instead of arriving as a 400. +const ( + minPartitions = 1 + maxPartitions = 2048 + + minRetentionSeconds = 3600 + maxRetentionSeconds = 7776000 + + maxRetentionBytes = 1099511627776 + + // unlimitedRetentionBytes is the documented sentinel for "no size limit". It is + // the reason --retention-bytes is validated as "-1 or within range" rather than + // "positive". + unlimitedRetentionBytes = -1 +) + +var createCmd = &cobra.Command{ + Use: "create", + Short: "Create a topic on a Kafka cluster", + Long: "Create a topic.\n\n" + + "--partitions sets the parallelism (1-2048) and --replicas the replication " + + "factor, which cannot exceed the cluster's broker count.\n\n" + + "Retention limits are optional; Kafka applies its own defaults when they are " + + "omitted. --retention-seconds is 3600-7776000 (1 hour to 90 days), and " + + "--retention-bytes accepts -1 for unlimited.", + Args: cobra.NoArgs, + RunE: runCreate, +} + +var updateCmd = &cobra.Command{ + Use: "update", + Short: "Change the partitions, replicas or retention of a topic", + Long: "Update a topic.\n\n" + + "Flags left out keep their current values: the endpoint takes the full set on " + + "every call, so the CLI reads the topic first and repeats what you did not " + + "change. Without that, omitting a field would reset it.\n\n" + + "Partition and replica counts normally only go UP in Kafka, and increasing " + + "partitions changes which partition a key lands on — consumers that rely on " + + "key ordering are affected.", + Args: cobra.NoArgs, + RunE: runUpdate, +} + +var deleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete a topic from a Kafka cluster", + Long: "Delete a topic and every record in it.\n\n" + + "This is irreversible and Kafka has no backup service, so nothing can bring the " + + "records back.", + Args: cobra.NoArgs, + RunE: runDelete, +} + +func init() { + c := createCmd.Flags() + c.String("cluster-id", "", "Kafka cluster ID (required)") + c.String("name", "", "Topic name (required)") + c.Int("partitions", 0, fmt.Sprintf("Partition count, %d-%d (required)", minPartitions, maxPartitions)) + c.Int("replicas", 0, "Replication factor, at least 1 and no more than the broker count (required)") + c.Int("retention-seconds", 0, + fmt.Sprintf("Retention time in seconds, %d-%d (default: Kafka's own)", minRetentionSeconds, maxRetentionSeconds)) + c.Int64("retention-bytes", 0, + fmt.Sprintf("Retention size in bytes, up to %d, or -1 for unlimited (default: Kafka's own)", maxRetentionBytes)) + createCmd.MarkFlagRequired("name") //nolint:errcheck + createCmd.MarkFlagRequired("partitions") //nolint:errcheck + createCmd.MarkFlagRequired("replicas") //nolint:errcheck + + u := updateCmd.Flags() + u.String("cluster-id", "", "Kafka cluster ID (required)") + u.String("topic-id", "", "Topic ID (required)") + u.Int("partitions", 0, fmt.Sprintf("New partition count, %d-%d (default: unchanged)", minPartitions, maxPartitions)) + u.Int("replicas", 0, "New replication factor (default: unchanged)") + u.Int("retention-seconds", 0, + fmt.Sprintf("New retention time in seconds, %d-%d (default: unchanged)", minRetentionSeconds, maxRetentionSeconds)) + u.Int64("retention-bytes", 0, + fmt.Sprintf("New retention size in bytes, up to %d, or -1 for unlimited (default: unchanged)", maxRetentionBytes)) + u.Bool("dry-run", false, "Print the request that would be sent without changing anything") + u.Bool("force", false, "Skip the confirmation prompt") + + d := deleteCmd.Flags() + d.String("cluster-id", "", "Kafka cluster ID (required)") + d.String("topic-id", "", "Topic ID (required)") + d.Bool("dry-run", false, "Print the request that would be sent without deleting") + d.Bool("force", false, "Skip the confirmation prompt") + + // Bound here, next to the flags — see the init-order note in completion.go. + for _, cmd := range []*cobra.Command{createCmd, updateCmd, deleteCmd} { + cmd.MarkFlagRequired("cluster-id") //nolint:errcheck + cmd.RegisterFlagCompletionFunc("cluster-id", clusterIDCompletion()) //nolint:errcheck + } + for _, cmd := range []*cobra.Command{updateCmd, deleteCmd} { + cmd.MarkFlagRequired("topic-id") //nolint:errcheck + cmd.RegisterFlagCompletionFunc("topic-id", topicIDCompletion()) //nolint:errcheck + } +} + +func runCreate(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + if err := validateClusterID(clusterID); err != nil { + return err + } + + name, _ := cmd.Flags().GetString("name") + partitions, _ := cmd.Flags().GetInt("partitions") + replicas, _ := cmd.Flags().GetInt("replicas") + + if err := checkPartitions(int64(partitions)); err != nil { + return err + } + if replicas < 1 { + return fmt.Errorf("invalid --replicas %d: the replication factor is at least 1", replicas) + } + + body := map[string]interface{}{ + "name": name, + "partitions": partitions, + "replicas": replicas, + } + // Retention is only sent when asked for: the API applies Kafka's own defaults for + // an absent field, and a zero would be a value, not an absence. + if cmd.Flags().Changed("retention-seconds") { + seconds, _ := cmd.Flags().GetInt("retention-seconds") + if err := checkRetentionSeconds(int64(seconds)); err != nil { + return err + } + body["retentionSeconds"] = seconds + } + if cmd.Flags().Changed("retention-bytes") { + bytes, _ := cmd.Flags().GetInt64("retention-bytes") + if err := checkRetentionBytes(bytes); err != nil { + return err + } + body["retentionBytes"] = bytes + } + + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Post(topicsPath(clusterID), body) + if err != nil { + return fmt.Errorf("failed to create topic %q on Kafka cluster %s: %w", name, clusterID, err) + } + + // This POST returns the created topic (TopicDto), unwrapped — unlike the PUT and + // DELETE below, whose responses are bare strings. + return vdbclient.Output(cmd, result) +} + +func runUpdate(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + topicID, _ := cmd.Flags().GetString("topic-id") + if err := validateIDs(clusterID, topicID); err != nil { + return err + } + + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return err + } + + // The request carries the whole set, so unspecified flags are filled from the + // topic as it stands. Sending only what changed would reset the rest — the same + // trap that made relational's update/setting need the current backup schedule. + current, err := fetchTopic(apiClient, clusterID, topicID) + if err != nil { + return err + } + + body := map[string]interface{}{ + "partitions": intFlagOr(cmd, "partitions", current["partitions"]), + "replicas": intFlagOr(cmd, "replicas", current["replicas"]), + } + // Retention is left OUT when the topic has none and the user asked for none: + // a topic created without limits reads back retentionSeconds/retentionBytes as + // null (verified live), and sending 0 would be a value — an invalid one, since + // the minimum is 3600 seconds / 1 byte — rather than "unchanged". + if value, ok := optionalInt(cmd, "retention-seconds", current["retentionSeconds"]); ok { + if err := checkRetentionSeconds(value); err != nil { + return err + } + body["retentionSeconds"] = value + } + if value, ok := optionalInt64(cmd, "retention-bytes", current["retentionBytes"]); ok { + if err := checkRetentionBytes(value); err != nil { + return err + } + body["retentionBytes"] = value + } + + if err := checkPartitions(body["partitions"].(int64)); err != nil { + return err + } + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + vdbclient.PreviewBody("update", + fmt.Sprintf("topic %s of Kafka cluster %s", topicID, clusterID), body) + return nil + } + if !cli.Confirm(force, fmt.Sprintf( + "Update topic %s of Kafka cluster %s to %v partitions / %v replicas? Increasing partitions changes key-to-partition mapping.", + topicID, clusterID, body["partitions"], body["replicas"])) { + fmt.Println("Aborted.") + return nil + } + + // The response is an unspecified bare string; the HTTP status is the result. + if err := apiClient.NoContent("PUT", topicPath(clusterID, topicID), nil, body); err != nil { + return fmt.Errorf("failed to update topic %s of Kafka cluster %s: %w", topicID, clusterID, err) + } + + fmt.Printf("Topic %s of Kafka cluster %s updated. Run 'grn vdb kafka topic get --cluster-id %s --topic-id %s' to confirm.\n", + topicID, clusterID, clusterID, topicID) + return nil +} + +func runDelete(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + topicID, _ := cmd.Flags().GetString("topic-id") + if err := validateIDs(clusterID, topicID); err != nil { + return err + } + + path := topicPath(clusterID, topicID) + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + fmt.Println("=== DRY RUN ===") + fmt.Printf("Would send DELETE %s\n", path) + cli.DryRunNotice("delete") + return nil + } + if !cli.Confirm(force, fmt.Sprintf( + "Delete topic %s from Kafka cluster %s? Every record in it is lost and Kafka has no backups.", + topicID, clusterID)) { + fmt.Println("Aborted.") + return nil + } + + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return err + } + + if err := apiClient.NoContent("DELETE", path, nil, nil); err != nil { + return fmt.Errorf("failed to delete topic %s from Kafka cluster %s: %w", topicID, clusterID, err) + } + + fmt.Printf("Topic %s deleted from Kafka cluster %s.\n", topicID, clusterID) + return nil +} + +func checkPartitions(partitions int64) error { + if partitions < minPartitions || partitions > maxPartitions { + return fmt.Errorf("invalid --partitions %d: must be between %d and %d", + partitions, minPartitions, maxPartitions) + } + return nil +} + +func checkRetentionSeconds(seconds int64) error { + if seconds < minRetentionSeconds || seconds > maxRetentionSeconds { + return fmt.Errorf("invalid --retention-seconds %d: must be between %d and %d", + seconds, minRetentionSeconds, maxRetentionSeconds) + } + return nil +} + +// checkRetentionBytes allows the documented -1 sentinel alongside the range, and +// allows 0 for "the API's own default" — the only two values outside 1..max that +// mean something. +func checkRetentionBytes(bytes int64) error { + if bytes == unlimitedRetentionBytes || bytes == 0 { + return nil + } + if bytes < 1 || bytes > maxRetentionBytes { + return fmt.Errorf("invalid --retention-bytes %d: must be between 1 and %d, or -1 for unlimited", + bytes, maxRetentionBytes) + } + return nil +} + +// intFlagOr returns the flag when the user set it and the resource's current value +// otherwise. It returns int64 so callers have one type to check; current values +// arrive as float64, since that is what every JSON number decodes to. +func intFlagOr(cmd *cobra.Command, name string, current interface{}) int64 { + if cmd.Flags().Changed(name) { + value, _ := cmd.Flags().GetInt(name) + return int64(value) + } + value, _ := current.(float64) + return int64(value) +} + +// optionalInt and optionalInt64 are intFlagOr for a field that may legitimately be +// absent. The second return says whether the field should be sent at all: false means +// the user named no flag AND the resource carries null, so the request omits it +// rather than asserting a zero. +func optionalInt(cmd *cobra.Command, name string, current interface{}) (int64, bool) { + if cmd.Flags().Changed(name) { + value, _ := cmd.Flags().GetInt(name) + return int64(value), true + } + value, ok := current.(float64) + return int64(value), ok +} + +func optionalInt64(cmd *cobra.Command, name string, current interface{}) (int64, bool) { + if cmd.Flags().Changed(name) { + value, _ := cmd.Flags().GetInt64(name) + return value, true + } + value, ok := current.(float64) + return int64(value), ok +} diff --git a/go/cmd/vdb/kafka/user/completion.go b/go/cmd/vdb/kafka/user/completion.go new file mode 100644 index 0000000..74ce971 --- /dev/null +++ b/go/cmd/vdb/kafka/user/completion.go @@ -0,0 +1,92 @@ +package user + +import ( + "context" + "fmt" + + "github.com/greennodehub/greennode-cli/cmd/vdb/kafka/cluster" + "github.com/greennodehub/greennode-cli/cmd/vdb/kafka/topic" + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/validator" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// Completers live here; flags are bound in the init() of the file that DEFINES +// them — see the init-order note in cmd/vdb/CLAUDE.md. + +// UserResourceKey lists the user IDs of the cluster named on the command line. +// CONTEXT-DEPENDENT: users are scoped to a cluster, so it yields nothing until +// --cluster-id is set. +const UserResourceKey = "vdb:kafka-user" + +func init() { + cli.RegisterResourceCompleter(UserResourceKey, cli.FlagFromAPI(userIDs)) +} + +func clusterIDCompletion() cli.CompFunc { + return cli.ResourceCompletion(cluster.ClusterResourceKey) +} + +func userIDCompletion() cli.CompFunc { + return cli.ResourceCompletion(UserResourceKey) +} + +// topicNameCompletion completes the permission flags. It uses the topic NAME key, +// not the ID one: the permission fields are lists of topic names, and an ID there is +// accepted and then matches nothing. +func topicNameCompletion() cli.CompFunc { + return cli.ResourceCompletion(topic.TopicNameResourceKey) +} + +func userIDs(_ context.Context, cmd *cobra.Command) ([]string, error) { + clusterID, _ := cmd.Flags().GetString("cluster-id") + if clusterID == "" { + return nil, nil + } + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return nil, err + } + result, err := apiClient.Get(usersPath(clusterID), nil) + if err != nil { + return nil, err + } + return cli.ExtractIDs(vdbclient.Unwrap(result), "id"), nil +} + +// usersPath is the user collection of a cluster, built from the cluster package's +// helper rather than by string-joining the prefix here: one owner per path. +func usersPath(clusterID string) string { + return cluster.ClusterPath(clusterID, "/users") +} + +func userPath(clusterID, userID string) string { + return usersPath(clusterID) + "/" + userID +} + +func validateClusterID(clusterID string) error { + return cluster.ValidateClusterID(clusterID) +} + +func validateIDs(clusterID, userID string) error { + if err := cluster.ValidateClusterID(clusterID); err != nil { + return err + } + return validator.ValidateID(userID, "user-id") +} + +// fetchUser reads one user, reporting a null payload as a miss rather than handing +// back zeroed fields. +func fetchUser(apiClient *vdbclient.Client, clusterID, userID string) (map[string]interface{}, error) { + result, err := apiClient.Get(userPath(clusterID, userID), nil) + if err != nil { + return nil, fmt.Errorf("failed to read user %s of Kafka cluster %s: %w", userID, clusterID, err) + } + userObj, ok := vdbclient.PayloadObject(result) + if !ok { + return nil, fmt.Errorf("user %s of Kafka cluster %s not found (the API returned an empty payload)", + userID, clusterID) + } + return userObj, nil +} diff --git a/go/cmd/vdb/kafka/user/read.go b/go/cmd/vdb/kafka/user/read.go new file mode 100644 index 0000000..6f178e9 --- /dev/null +++ b/go/cmd/vdb/kafka/user/read.go @@ -0,0 +1,168 @@ +package user + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// userColumns is the table view of a UserDto. The four topic-name lists are left out +// on purpose: they are arrays, so a table cell cannot show them usefully and their +// presence would make OutputWithColumns pick one of them as the rows of a detail +// payload. The *All booleans stay, since they are what usually answers "may this +// user read everything?"; the lists come out of --output json or 'user get'. +var userColumns = []string{ + "id", "name", "status", "produceAll", "consumeAll", "produceConsumeAll", + "adminAll", "mtlsAuthen", "saslAuthen", "createdAt", +} + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List the users of a Kafka cluster", + Long: "List every user of a cluster with its authentication mechanisms and whether " + + "its permissions cover all topics.\n\n" + + "The per-topic lists are arrays and do not fit a table — use --output json, or " + + "'user get' for one user.", + Args: cobra.NoArgs, + RunE: runList, +} + +var getCmd = &cobra.Command{ + Use: "get", + Short: "Get details of a Kafka user", + Long: "Show one user of a cluster, including its per-topic permission lists.", + Args: cobra.NoArgs, + RunE: runGet, +} + +var getCredsCmd = &cobra.Command{ + Use: "get-creds", + Short: "Read the authentication credentials of a Kafka user", + Long: "Read back the credentials the cluster issued for a user.\n\n" + + "THE OUTPUT IS SECRET: the API returns the credential material itself — an " + + "mTLS answer is a ZIP of certificates, keys and passwords. A ZIP cannot be " + + "printed, so it is written to --output-file (default: " + + "kafka-creds-.zip) with 0600 permissions instead. Keep the file " + + "private and out of tickets and shared logs.\n\n" + + "Use 'user generate-creds' to replace them; that invalidates whatever this " + + "returns now.", + Args: cobra.NoArgs, + RunE: runGetCreds, +} + +func init() { + listCmd.Flags().String("cluster-id", "", "Kafka cluster ID (required)") + listCmd.MarkFlagRequired("cluster-id") //nolint:errcheck + listCmd.RegisterFlagCompletionFunc("cluster-id", clusterIDCompletion()) //nolint:errcheck + + for _, cmd := range []*cobra.Command{getCmd, getCredsCmd} { + f := cmd.Flags() + f.String("cluster-id", "", "Kafka cluster ID (required)") + f.String("user-id", "", "Kafka user ID (required)") + cmd.MarkFlagRequired("cluster-id") //nolint:errcheck + cmd.MarkFlagRequired("user-id") //nolint:errcheck + cmd.RegisterFlagCompletionFunc("cluster-id", clusterIDCompletion()) //nolint:errcheck + cmd.RegisterFlagCompletionFunc("user-id", userIDCompletion()) //nolint:errcheck + } + + getCredsCmd.Flags().String("output-file", "", + "File to write the credential ZIP to (default: kafka-creds-.zip)") +} + +func runList(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + if err := validateClusterID(clusterID); err != nil { + return err + } + + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Get(usersPath(clusterID), nil) + if err != nil { + return fmt.Errorf("failed to list users of Kafka cluster %s: %w", clusterID, err) + } + + return vdbclient.OutputWithColumns(cmd, result, userColumns) +} + +func runGet(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + userID, _ := cmd.Flags().GetString("user-id") + if err := validateIDs(clusterID, userID); err != nil { + return err + } + + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Get(userPath(clusterID, userID), nil) + if err != nil { + return fmt.Errorf("failed to get user %s of Kafka cluster %s: %w", userID, clusterID, err) + } + + // A user carries four nested arrays (the topic-name lists), so print key/value + // rather than letting table extraction pick one of them at random. + return vdbclient.Output(cmd, result) +} + +func runGetCreds(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + userID, _ := cmd.Flags().GetString("user-id") + if err := validateIDs(clusterID, userID); err != nil { + return err + } + + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return err + } + + // The endpoint's answer is not always JSON: an mTLS user gets a ZIP archive of + // certificates, keys and passwords (measured live 2026-10-19), while the same + // endpoint answered a bare JSON array of strings on 2026-08-17. Read raw and + // branch on the ZIP magic so either shape works. + raw, err := apiClient.Raw("GET", userPath(clusterID, userID)+"/authen-creds", nil) + if err != nil { + return fmt.Errorf("failed to read the credentials of user %s of Kafka cluster %s: %w", + userID, clusterID, err) + } + + if strings.HasPrefix(raw, "PK") { + outputFile, _ := cmd.Flags().GetString("output-file") + outputFile, err = writeCredsZip(outputFile, userID, raw) + if err != nil { + return fmt.Errorf("failed to write the credentials of user %s: %w", userID, err) + } + fmt.Printf("Credentials of user %s written to %s — a ZIP of certificates and passwords, permissions 0600. Keep it private.\n", + userID, outputFile) + return nil + } + + var result interface{} + if err := json.Unmarshal([]byte(raw), &result); err != nil { + return fmt.Errorf("failed to read the credentials of user %s of Kafka cluster %s: %w", + userID, clusterID, err) + } + + // A JSON payload has been a bare array of strings with no field names; Output + // prints it as-is. + return vdbclient.Output(cmd, result) +} + +// writeCredsZip writes a credential ZIP to outputFile, defaulting the name to the +// user id. 0600: the archive carries private keys and passwords. +func writeCredsZip(outputFile, userID, body string) (string, error) { + if outputFile == "" { + outputFile = fmt.Sprintf("kafka-creds-%s.zip", userID) + } + return outputFile, os.WriteFile(outputFile, []byte(body), 0600) +} diff --git a/go/cmd/vdb/kafka/user/user.go b/go/cmd/vdb/kafka/user/user.go new file mode 100644 index 0000000..766629c --- /dev/null +++ b/go/cmd/vdb/kafka/user/user.go @@ -0,0 +1,42 @@ +// Package user holds the Kafka user commands. +// +// Users are a Kafka-only concept in vDB. The other three products have nothing +// comparable: MemoryStore authenticates with a single master password set through +// its instance settings, and Relational Database creates database users as part of +// the instance order. Do not model anything here on either. +// +// A user is a set of per-topic permissions plus the authentication mechanisms it may +// use. Its credentials are issued by the cluster and read back with `get-creds`. +package user + +import ( + "github.com/spf13/cobra" +) + +// UserCmd is the parent command for Kafka user commands. +var UserCmd = &cobra.Command{ + Use: "user", + Short: "Manage the users of a Kafka cluster", + Long: "Create, inspect and delete the users of a vDB Kafka cluster, and read or " + + "regenerate their credentials.\n\n" + + "A user holds four kinds of permission — produce, consume, produce+consume and " + + "admin — each either on a named list of topics or on all of them. Topics are " + + "referenced by NAME here, not by ID.\n\n" + + "A user can only use a mechanism the cluster itself accepts: enabling SASL on a " + + "user does nothing until 'cluster update-authentication' enables it on the " + + "cluster.", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() //nolint:errcheck + }, +} + +func init() { + UserCmd.AddCommand(listCmd) + UserCmd.AddCommand(getCmd) + UserCmd.AddCommand(getCredsCmd) + UserCmd.AddCommand(createCmd) + UserCmd.AddCommand(updateCmd) + UserCmd.AddCommand(deleteCmd) + UserCmd.AddCommand(generateCredsCmd) +} diff --git a/go/cmd/vdb/kafka/user/write.go b/go/cmd/vdb/kafka/user/write.go new file mode 100644 index 0000000..8b1df17 --- /dev/null +++ b/go/cmd/vdb/kafka/user/write.go @@ -0,0 +1,339 @@ +package user + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// permissions are the four permission kinds a Kafka user can hold. Each is a pair: +// a list of topic names and an "all topics" switch that, when true, makes the API +// IGNORE the list (its own words). They are declared once and looped over so a fifth +// kind cannot be half-added. +var permissions = []struct { + listFlag string // --produce-topics + allFlag string // --produce-all + listField string // produceTopicNames + allField string // produceAll + label string +}{ + {"produce-topics", "produce-all", "produceTopicNames", "produceAll", "produce"}, + {"consume-topics", "consume-all", "consumeTopicNames", "consumeAll", "consume"}, + {"produce-consume-topics", "produce-consume-all", "produceConsumeTopicNames", "produceConsumeAll", "produce+consume"}, + {"admin-topics", "admin-all", "adminTopicNames", "adminAll", "admin"}, +} + +var createCmd = &cobra.Command{ + Use: "create", + Short: "Create a user on a Kafka cluster", + Long: "Create a user with a set of per-topic permissions.\n\n" + + "Topics are named, not identified: --produce-topics and friends take topic " + + "NAMES from 'grn vdb kafka topic list'.\n\n" + + "Each --*-all switch OVERRIDES its list — the API ignores the topic names when " + + "the matching switch is true, so passing both is not an error, just a list that " + + "does nothing.\n\n" + + "--mtls-authen and --sasl-authen say which mechanisms the user may use. They " + + "have no effect unless the cluster accepts the same mechanism; check " + + "'cluster get'. Read the resulting credentials with 'user get-creds'.", + Args: cobra.NoArgs, + RunE: runCreate, +} + +var updateCmd = &cobra.Command{ + Use: "update", + Short: "Change the permissions of a Kafka user", + Long: "Replace a user's permissions and authentication mechanisms.\n\n" + + "The endpoint takes the full set on every call, so flags left out keep their " + + "current values: the CLI reads the user first and repeats what you did not " + + "change. Without that, omitting a permission would revoke it.\n\n" + + "A permission list you DO pass replaces the previous one — it is not merged. " + + "Run 'user get' first to see what is there.", + Args: cobra.NoArgs, + RunE: runUpdate, +} + +var deleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete a user from a Kafka cluster", + Long: "Delete a user and its credentials.\n\n" + + "Anything connecting as that user stops working immediately.", + Args: cobra.NoArgs, + RunE: runDelete, +} + +var generateCredsCmd = &cobra.Command{ + Use: "generate-creds", + Short: "Reissue the authentication credentials of a Kafka user", + Long: "Replace a user's credentials with new ones.\n\n" + + "THIS INVALIDATES THE CURRENT CREDENTIALS: every client still using them is cut " + + "off until it is given the new ones, which are read with 'user get-creds'.\n\n" + + "The API returns no usable body here, so this command reports the outcome and " + + "leaves fetching the new values to 'user get-creds' — that way the secret is " + + "written out only when it is asked for.", + Args: cobra.NoArgs, + RunE: runGenerateCreds, +} + +func init() { + c := createCmd.Flags() + c.String("cluster-id", "", "Kafka cluster ID (required)") + c.String("name", "", "User name (required)") + c.Bool("mtls-authen", false, "Let this user authenticate with mTLS") + c.Bool("sasl-authen", false, "Let this user authenticate with SASL") + createCmd.MarkFlagRequired("name") //nolint:errcheck + + u := updateCmd.Flags() + u.String("cluster-id", "", "Kafka cluster ID (required)") + u.String("user-id", "", "Kafka user ID (required)") + u.Bool("mtls-authen", false, "Let this user authenticate with mTLS (default: unchanged)") + u.Bool("sasl-authen", false, "Let this user authenticate with SASL (default: unchanged)") + u.Bool("dry-run", false, "Print the request that would be sent without changing anything") + u.Bool("force", false, "Skip the confirmation prompt") + + for _, cmd := range []*cobra.Command{createCmd, updateCmd} { + for _, permission := range permissions { + cmd.Flags().String(permission.listFlag, "", + fmt.Sprintf("Topic NAMES this user may %s, comma-separated", permission.label)) + cmd.Flags().Bool(permission.allFlag, false, + fmt.Sprintf("Let this user %s on ALL topics (overrides --%s)", permission.label, permission.listFlag)) + cmd.RegisterFlagCompletionFunc(permission.listFlag, topicNameCompletion()) //nolint:errcheck + } + } + + d := deleteCmd.Flags() + d.String("cluster-id", "", "Kafka cluster ID (required)") + d.String("user-id", "", "Kafka user ID (required)") + d.Bool("dry-run", false, "Print the request that would be sent without deleting") + d.Bool("force", false, "Skip the confirmation prompt") + + g := generateCredsCmd.Flags() + g.String("cluster-id", "", "Kafka cluster ID (required)") + g.String("user-id", "", "Kafka user ID (required)") + g.Bool("dry-run", false, "Print the request that would be sent without reissuing anything") + g.Bool("force", false, "Skip the confirmation prompt") + + // Bound here, next to the flags — see the init-order note in completion.go. + for _, cmd := range []*cobra.Command{createCmd, updateCmd, deleteCmd, generateCredsCmd} { + cmd.MarkFlagRequired("cluster-id") //nolint:errcheck + cmd.RegisterFlagCompletionFunc("cluster-id", clusterIDCompletion()) //nolint:errcheck + } + for _, cmd := range []*cobra.Command{updateCmd, deleteCmd, generateCredsCmd} { + cmd.MarkFlagRequired("user-id") //nolint:errcheck + cmd.RegisterFlagCompletionFunc("user-id", userIDCompletion()) //nolint:errcheck + } +} + +func runCreate(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + if err := validateClusterID(clusterID); err != nil { + return err + } + + name, _ := cmd.Flags().GetString("name") + mtls, _ := cmd.Flags().GetBool("mtls-authen") + sasl, _ := cmd.Flags().GetBool("sasl-authen") + + body := map[string]interface{}{ + "name": name, + "mtlsAuthen": mtls, + "saslAuthen": sasl, + } + for _, permission := range permissions { + list, _ := cmd.Flags().GetString(permission.listFlag) + all, _ := cmd.Flags().GetBool(permission.allFlag) + body[permission.listField] = topicNames(list) + body[permission.allField] = all + } + + if !mtls && !sasl { + fmt.Println("Warning: neither --mtls-authen nor --sasl-authen was given, so this user has no way to authenticate.") + } + + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Post(usersPath(clusterID), body) + if err != nil { + return fmt.Errorf("failed to create user %q on Kafka cluster %s: %w", name, clusterID, err) + } + + // This POST returns the created user (UserDto), unwrapped. + return vdbclient.Output(cmd, result) +} + +func runUpdate(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + userID, _ := cmd.Flags().GetString("user-id") + if err := validateIDs(clusterID, userID); err != nil { + return err + } + + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return err + } + + // The request replaces the whole permission set, so unspecified flags are filled + // from the user as it stands — otherwise changing one permission would revoke the + // other three. + current, err := fetchUser(apiClient, clusterID, userID) + if err != nil { + return err + } + + body := map[string]interface{}{ + "mtlsAuthen": boolFlagOr(cmd, "mtls-authen", current["mtlsAuthen"]), + "saslAuthen": boolFlagOr(cmd, "sasl-authen", current["saslAuthen"]), + } + for _, permission := range permissions { + if cmd.Flags().Changed(permission.listFlag) { + list, _ := cmd.Flags().GetString(permission.listFlag) + body[permission.listField] = topicNames(list) + } else { + body[permission.listField] = stringList(current[permission.listField]) + } + body[permission.allField] = boolFlagOr(cmd, permission.allFlag, current[permission.allField]) + } + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + vdbclient.PreviewBody("update", + fmt.Sprintf("user %s of Kafka cluster %s", userID, clusterID), body) + return nil + } + if !cli.Confirm(force, fmt.Sprintf( + "Replace the permissions of user %s on Kafka cluster %s? Clients relying on a revoked permission stop working.", + userID, clusterID)) { + fmt.Println("Aborted.") + return nil + } + + // The response is an unspecified bare string; the HTTP status is the result. + if err := apiClient.NoContent("PUT", userPath(clusterID, userID), nil, body); err != nil { + return fmt.Errorf("failed to update user %s of Kafka cluster %s: %w", userID, clusterID, err) + } + + fmt.Printf("User %s of Kafka cluster %s updated. Run 'grn vdb kafka user get --cluster-id %s --user-id %s' to confirm.\n", + userID, clusterID, clusterID, userID) + return nil +} + +func runDelete(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + userID, _ := cmd.Flags().GetString("user-id") + if err := validateIDs(clusterID, userID); err != nil { + return err + } + + path := userPath(clusterID, userID) + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + fmt.Println("=== DRY RUN ===") + fmt.Printf("Would send DELETE %s\n", path) + cli.DryRunNotice("delete") + return nil + } + if !cli.Confirm(force, fmt.Sprintf( + "Delete user %s from Kafka cluster %s? Anything connecting as that user stops working.", + userID, clusterID)) { + fmt.Println("Aborted.") + return nil + } + + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return err + } + + if err := apiClient.NoContent("DELETE", path, nil, nil); err != nil { + return fmt.Errorf("failed to delete user %s from Kafka cluster %s: %w", userID, clusterID, err) + } + + fmt.Printf("User %s deleted from Kafka cluster %s.\n", userID, clusterID) + return nil +} + +func runGenerateCreds(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + userID, _ := cmd.Flags().GetString("user-id") + if err := validateIDs(clusterID, userID); err != nil { + return err + } + + path := userPath(clusterID, userID) + "/regenerate-creds" + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + fmt.Println("=== DRY RUN ===") + fmt.Printf("Would send PUT %s\n", path) + cli.DryRunNotice("reissue the credentials") + return nil + } + if !cli.Confirm(force, fmt.Sprintf( + "Reissue the credentials of user %s on Kafka cluster %s? The current ones stop working immediately.", + userID, clusterID)) { + fmt.Println("Aborted.") + return nil + } + + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return err + } + + if err := apiClient.NoContent("PUT", path, nil, nil); err != nil { + return fmt.Errorf("failed to reissue the credentials of user %s of Kafka cluster %s: %w", + userID, clusterID, err) + } + + fmt.Printf("New credentials issued for user %s of Kafka cluster %s. Read them with 'grn vdb kafka user get-creds --cluster-id %s --user-id %s' and give them to every client.\n", + userID, clusterID, clusterID, userID) + return nil +} + +// boolFlagOr returns the flag when the user set it and the resource's current value +// otherwise. GetBool cannot tell "--flag=false" from "not passed"; Changed can. +func boolFlagOr(cmd *cobra.Command, name string, current interface{}) bool { + if cmd.Flags().Changed(name) { + value, _ := cmd.Flags().GetBool(name) + return value + } + value, _ := current.(bool) + return value +} + +// stringList converts a decoded JSON array back into []string, dropping anything +// that is not a string. A nil becomes an empty slice so the field is sent as [] and +// not as null — the API distinguishes the two on a list it is going to replace. +func stringList(value interface{}) []string { + items, _ := value.([]interface{}) + out := make([]string, 0, len(items)) + for _, item := range items { + if text, ok := item.(string); ok { + out = append(out, text) + } + } + return out +} + +// topicNames parses a --*-topics flag into a NON-nil slice. An unset flag must be +// sent as [] and not as null: the API rejects null for these array fields — +// measured live, creating a user without topic permissions answers 400 bad_request. +func topicNames(flagValue string) []string { + names := cli.ParseCommaSeparated(flagValue) + if names == nil { + return []string{} + } + return names +} diff --git a/go/cmd/vdb/kafka/user/write_test.go b/go/cmd/vdb/kafka/user/write_test.go new file mode 100644 index 0000000..fd71d2b --- /dev/null +++ b/go/cmd/vdb/kafka/user/write_test.go @@ -0,0 +1,55 @@ +package user + +import ( + "os" + "path/filepath" + "testing" +) + +// An unset --*-topics flag must reach the API as [] and not as null: the API +// rejects null for these array fields — measured live, creating a user without +// topic permissions answers 400 bad_request. +func TestTopicNamesNeverNil(t *testing.T) { + if got := topicNames(""); got == nil || len(got) != 0 { + t.Errorf("topicNames(\"\") = %#v, want an empty non-nil slice", got) + } + got := topicNames("orders, payments") + if len(got) != 2 || got[0] != "orders" || got[1] != "payments" { + t.Errorf("topicNames(\"orders, payments\") = %#v", got) + } +} + +// The credential ZIP lands at --output-file when given and at the user-id default +// otherwise, with 0600 permissions: the archive carries private keys and passwords. +func TestWriteCredsZip(t *testing.T) { + dir := t.TempDir() + + named := filepath.Join(dir, "creds.zip") + got, err := writeCredsZip(named, "user-1", "PK\x03\x04rest-of-archive") + if err != nil || got != named { + t.Fatalf("writeCredsZip(named) = %q, %v", got, err) + } + info, err := os.Stat(named) + if err != nil || info.Mode().Perm() != 0o600 { + t.Errorf("permissions of %s = %v (err %v), want 0600", named, info.Mode().Perm(), err) + } + + // Empty --output-file falls back to kafka-creds-.zip in the cwd, so + // chdir into the temp dir for the default-name case. + wd, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Skipf("cannot chdir: %v", err) + } + defer os.Chdir(wd) //nolint:errcheck + + got, err = writeCredsZip("", "user-1", "PK\x03\x04") + if err != nil { + t.Fatalf("writeCredsZip(default) error: %v", err) + } + if got != "kafka-creds-user-1.zip" { + t.Errorf("writeCredsZip(default) = %q, want kafka-creds-user-1.zip", got) + } + if _, err := os.Stat(got); err != nil { + t.Errorf("default file not written: %v", err) + } +} diff --git a/go/cmd/vdb/memorystore/backup/backup.go b/go/cmd/vdb/memorystore/backup/backup.go new file mode 100644 index 0000000..695281c --- /dev/null +++ b/go/cmd/vdb/memorystore/backup/backup.go @@ -0,0 +1,94 @@ +// Package backup holds the MemoryStore backup commands. +// +// Every path differs from the relational backup API, and two of them differ in a way +// that changes the request, not just the URL: +// +// operation relational memorystore +// get one GET /backups/detail/{id} GET /backups/{id}/detail +// delete DELETE /backups/{id}/delete POST /backups/delete (no ID in the path) +// list by instance GET /backups/insId/{id} GET /database-instances/{id}/backups +// +// Both deletes take a JSON ARRAY body, so the memorystore one carries the ID only +// there — which also means it could delete several at once, though this command sends +// one to keep the confirmation honest. +package backup + +import ( + "github.com/greennodehub/greennode-cli/internal/validator" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// BackupCmd is the parent command for MemoryStore backup commands. +var BackupCmd = &cobra.Command{ + Use: "backup", + Short: "Manage MemoryStore backups", + Long: "List, inspect, create, delete and restore backups of vDB MemoryStore " + + "instances.\n\n" + + "Backups are billed against your backup storage quota beyond the free allowance — " + + "see 'backup get-free-storage' and 'grn vdb memorystore backup-storage'.", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() //nolint:errcheck + }, +} + +const ( + basePath = "/vdb-memory/v1/backups" + instanceBase = "/vdb-memory/v1/database-instances" + + backupIDPrefix = "bk-" + instanceIDPrefix = "db-" + + // Backup retention bounds, as in every other vdb group. + minBackupDuration = 2 + maxBackupDuration = 14 +) + +// Completion keys. The instance one is MemoryStore's own: relational IDs share the +// "db-" prefix but belong to a different listing. +const ( + instanceResourceKey = "vdb:memorystore-instance" + zoneResourceKey = "vdb:relational-zone" + subnetResourceKey = "vdb:relational-subnet" + configGroupResource = "vdb:memorystore-config-group" +) + +func init() { + BackupCmd.AddCommand(listCmd) + BackupCmd.AddCommand(getCmd) + BackupCmd.AddCommand(createCmd) + BackupCmd.AddCommand(deleteCmd) + BackupCmd.AddCommand(restoreCmd) + BackupCmd.AddCommand(getFreeStorageCmd) +} + +func createClient(cmd *cobra.Command) (*vdbclient.Client, error) { + return vdbclient.BuildClient(cmd) +} + +func requireBackupID(backupID string) error { + return vdbclient.RequireIDWithPrefix(backupID, "backup-id", backupIDPrefix, + "Backup IDs start with 'bk-'; run 'backup list' to find one") +} + +func requireInstanceID(instanceID string) error { + return vdbclient.RequireIDWithPrefix(instanceID, "instance-id", instanceIDPrefix, + "MemoryStore instance IDs start with 'db-'; run 'memorystore instance list' to find one") +} + +func validateInstanceID(instanceID string) error { + return validator.ValidateID(instanceID, "instance-id") +} + +var backupColumns = []string{ + "id", "name", "dbInstanceId", "instanceName", "backupType", + "status", "size", "datastoreType", "datastoreVersion", "created", +} + +// instanceBackupColumns drops the instance identity: the per-instance endpoint +// leaves those fields empty, as its relational counterpart does. +var instanceBackupColumns = []string{ + "id", "name", "backupType", "status", "size", "parentName", + "datastoreType", "datastoreVersion", "created", +} diff --git a/go/cmd/vdb/memorystore/backup/read.go b/go/cmd/vdb/memorystore/backup/read.go new file mode 100644 index 0000000..3ab2d6a --- /dev/null +++ b/go/cmd/vdb/memorystore/backup/read.go @@ -0,0 +1,126 @@ +package backup + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List backups, all or per instance", + Long: "List the MemoryStore backups in your project, or just those of one instance " + + "with --instance-id.\n\n" + + "These are two different endpoints: without --instance-id the listing is " + + "paginated (items under 'content'), with it the API returns every backup of that " + + "instance as a plain array and --page/--page-size do not apply.", + Args: cobra.NoArgs, + RunE: runList, +} + +var getCmd = &cobra.Command{ + Use: "get", + Short: "Get details of a backup", + Long: "Show the full record of one backup.\n\n" + + "It carries the spec of the instance the backup came from — flavor, config group, " + + "subnets — which is what 'backup restore' uses to build the new instance.", + Args: cobra.NoArgs, + RunE: runGet, +} + +var getFreeStorageCmd = &cobra.Command{ + Use: "get-free-storage", + Short: "Show the free backup storage allowance and how much is used", + Long: "Show the free backup storage that comes with your instances' flavors and how " + + "much of it your MemoryStore backups occupy.\n\n" + + "The allowance is the sum of your instances' own allowances, so it changes as " + + "instances come and go. Storage beyond it is billed; buy or resize it with " + + "'grn vdb memorystore backup-storage'.", + Args: cobra.NoArgs, + RunE: runGetFreeStorage, +} + +func init() { + l := listCmd.Flags() + l.String("instance-id", "", "List only the backups of this instance") + l.Int("page", 1, "Page number (1-based; ignored with --instance-id)") + l.Int("page-size", vdbclient.DefaultPageSize, "Items per page (ignored with --instance-id)") + listCmd.RegisterFlagCompletionFunc("instance-id", cli.ResourceCompletion(instanceResourceKey)) //nolint:errcheck + + g := getCmd.Flags() + g.String("backup-id", "", "Backup ID (required)") + getCmd.MarkFlagRequired("backup-id") //nolint:errcheck + getCmd.RegisterFlagCompletionFunc("backup-id", cli.ResourceCompletion(BackupResourceKey)) //nolint:errcheck +} + +func runList(cmd *cobra.Command, args []string) error { + instanceID, _ := cmd.Flags().GetString("instance-id") + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + if instanceID != "" { + if err := validateInstanceID(instanceID); err != nil { + return err + } + // Per-instance listing hangs off the INSTANCE, not the backups collection. + result, err := apiClient.Get(instanceBase+"/"+instanceID+"/backups", nil) + if err != nil { + return fmt.Errorf("failed to list the backups of MemoryStore instance %s: %w", instanceID, err) + } + return vdbclient.OutputWithColumns(cmd, result, instanceBackupColumns) + } + + page, _ := cmd.Flags().GetInt("page") + pageSize, _ := cmd.Flags().GetInt("page-size") + query := vdbclient.BuildListQuery(vdbclient.ListOptions{Page: page, PageSize: pageSize}) + + result, err := vdbclient.Get(apiClient, basePath, query) + if err != nil { + return fmt.Errorf("failed to list MemoryStore backups: %w", err) + } + + return vdbclient.OutputWithColumns(cmd, result, backupColumns) +} + +func runGet(cmd *cobra.Command, args []string) error { + backupID, _ := cmd.Flags().GetString("backup-id") + if err := requireBackupID(backupID); err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + // /backups/{id}/detail — the relational API spells it /backups/detail/{id}. + result, err := apiClient.Get(basePath+"/"+backupID+"/detail", nil) + if err != nil { + return fmt.Errorf("failed to get backup %s: %w", backupID, err) + } + if _, ok := vdbclient.PayloadObject(result); !ok { + return fmt.Errorf("backup %s not found (the API returned an empty payload; "+ + "a backup whose creation failed reads this way — check 'instance list-histories')", backupID) + } + + return vdbclient.Output(cmd, result) +} + +func runGetFreeStorage(cmd *cobra.Command, args []string) error { + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Get(basePath+"/free-backup", nil) + if err != nil { + return fmt.Errorf("failed to read free backup storage usage: %w", err) + } + + return vdbclient.Output(cmd, result) +} diff --git a/go/cmd/vdb/memorystore/backup/write.go b/go/cmd/vdb/memorystore/backup/write.go new file mode 100644 index 0000000..31469d0 --- /dev/null +++ b/go/cmd/vdb/memorystore/backup/write.go @@ -0,0 +1,430 @@ +package backup + +import ( + "fmt" + "os" + "strings" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// BackupResourceKey is the completion key for a MemoryStore backup ID. +const BackupResourceKey = "vdb:memorystore-backup" + +// passwordEnv is the same variable the other groups use for a master password. +const passwordEnv = "GRN_VDB_MASTER_PASSWORD" + +var backupTypes = []string{"FULL", "INCREMENTAL"} + +var createCmd = &cobra.Command{ + Use: "create", + Short: "Take a backup of a MemoryStore instance now", + Long: "Create an on-demand backup, in addition to whatever the instance's schedule " + + "produces.\n\n" + + "The backup counts towards your backup storage, which is billable beyond the free " + + "allowance, so the command confirms first.\n\n" + + "--description is REQUIRED: on the Relational Database API a backup without one is " + + "accepted and then fails in the background (measured), and the two share this " + + "request schema — so the CLI requires it here too rather than risk the same silent " + + "failure.\n\n" + + "--backup-type INCREMENTAL requires --parent-id, the backup to build on.", + Args: cobra.NoArgs, + RunE: runCreate, +} + +var deleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete a backup", + Long: "Delete one backup.\n\n" + + "Irreversible, and it can break a chain: an INCREMENTAL backup builds on its " + + "parent, so deleting a parent may leave its children unrestorable.\n\n" + + "Note the request shape: this is a POST to /backups/delete with a JSON ARRAY body " + + "and no ID in the path at all — the relational API uses DELETE with the ID in the " + + "path instead.", + Args: cobra.NoArgs, + RunE: runDelete, +} + +var restoreCmd = &cobra.Command{ + Use: "restore", + Short: "Create a new MemoryStore instance from a backup", + Long: "Restore a backup.\n\n" + + "**This does not restore in place.** The API builds a NEW instance from the backup " + + "and leaves the original untouched — so a restore is a create: order/payment flow, " + + "real cost, asynchronous, and it needs a name.\n\n" + + "You choose where it lands: --zone-id and --subnet-ids are required, because a " + + "restore is a placement decision. Everything else defaults to the backup's record " + + "(engine, version, flavor, config group). A master password is needed if the new " + + "instance should require one.", + Args: cobra.NoArgs, + RunE: runRestore, +} + +func createFlags(f *pflag.FlagSet) { + f.String("instance-id", "", "Instance to back up (required)") + f.String("name", "", "Backup name (required)") + f.String("description", "", "Free-text description (required — see the note in --help)") + f.String("backup-type", "FULL", fmt.Sprintf("Backup type: %s", strings.Join(backupTypes, " or "))) + f.String("parent-id", "", "Parent backup ID (required with --backup-type INCREMENTAL)") + f.Bool("dry-run", false, "Print the request that would be sent without taking a backup") + f.Bool("force", false, "Skip the confirmation prompt") +} + +func restoreFlags(f *pflag.FlagSet) { + f.String("backup-id", "", "Backup to restore (required)") + f.String("name", "", "Name of the new instance (required)") + f.String("zone-id", "", "Availability zone for the new instance (required)") + f.String("subnet-ids", "", "Subnet ID(s) for the new instance, comma-separated (required)") + f.String("package-id", "", "Flavor id (default: the backup's)") + f.String("config-id", "", "Config group to attach (default: the backup's, if any)") + f.String("redis-password", "", "Master password for the new instance (defaults to $"+passwordEnv+")") + f.Bool("public-access", false, "Allow public access — requires a master password") + f.Bool("backup-auto", false, "Enable daily automatic backup on the new instance") + f.Int("backup-duration", 0, fmt.Sprintf("Backup retention in days, %d-%d (with --backup-auto)", minBackupDuration, maxBackupDuration)) + f.String("backup-time", "", "Time of day to run the backup, HH:MM (with --backup-auto)") + f.Bool("poc", false, "Pay with PoC credit (Auto Payment only)") + f.Bool("dry-run", false, "Print the request that would be sent without placing an order") + f.Bool("force", false, "Skip the confirmation prompt") +} + +func init() { + createFlags(createCmd.Flags()) + for _, required := range []string{"instance-id", "name", "description"} { + createCmd.MarkFlagRequired(required) //nolint:errcheck + } + createCmd.RegisterFlagCompletionFunc("instance-id", cli.ResourceCompletion(instanceResourceKey)) //nolint:errcheck + createCmd.RegisterFlagCompletionFunc("backup-type", cli.FlagValues(backupTypes...)) //nolint:errcheck + createCmd.RegisterFlagCompletionFunc("parent-id", cli.ResourceCompletion(BackupResourceKey)) //nolint:errcheck + + d := deleteCmd.Flags() + d.String("backup-id", "", "Backup ID (required)") + d.Bool("dry-run", false, "Print the request that would be sent without deleting") + d.Bool("force", false, "Skip the confirmation prompt") + deleteCmd.MarkFlagRequired("backup-id") //nolint:errcheck + deleteCmd.RegisterFlagCompletionFunc("backup-id", cli.ResourceCompletion(BackupResourceKey)) //nolint:errcheck + + restoreFlags(restoreCmd.Flags()) + for _, required := range []string{"backup-id", "name", "zone-id", "subnet-ids"} { + restoreCmd.MarkFlagRequired(required) //nolint:errcheck + } + restoreCmd.RegisterFlagCompletionFunc("backup-id", cli.ResourceCompletion(BackupResourceKey)) //nolint:errcheck + restoreCmd.RegisterFlagCompletionFunc("zone-id", cli.ResourceCompletion(zoneResourceKey)) //nolint:errcheck + restoreCmd.RegisterFlagCompletionFunc("subnet-ids", cli.ResourceCompletion(subnetResourceKey)) //nolint:errcheck + restoreCmd.RegisterFlagCompletionFunc("config-id", cli.ResourceCompletion(configGroupResource)) //nolint:errcheck +} + +func runCreate(cmd *cobra.Command, args []string) error { + instanceID, _ := cmd.Flags().GetString("instance-id") + if err := requireInstanceID(instanceID); err != nil { + return err + } + + body, err := createBody(cmd.Flags(), instanceID) + if err != nil { + return err + } + + name, _ := cmd.Flags().GetString("name") + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + vdbclient.PreviewBody("create", fmt.Sprintf("backup %q of %s", name, instanceID), body) + return nil + } + if !cli.Confirm(force, fmt.Sprintf( + "Take a %v backup %q of MemoryStore instance %s? It counts towards billable backup storage.", + body["backupType"], name, instanceID)) { + fmt.Println("Aborted.") + return nil + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Post(basePath+"/create", body) + if err != nil { + return fmt.Errorf("failed to create backup %q of MemoryStore instance %s: %w", name, instanceID, err) + } + + return vdbclient.Output(cmd, result) +} + +// createBody assembles CreateBackupRequest — the same schema the relational API uses, +// including the description rule enforced there. +func createBody(flags *pflag.FlagSet, instanceID string) (map[string]interface{}, error) { + name, _ := flags.GetString("name") + description, _ := flags.GetString("description") + backupType, _ := flags.GetString("backup-type") + parentID, _ := flags.GetString("parent-id") + + backupType = strings.ToUpper(backupType) + if backupType != "FULL" && backupType != "INCREMENTAL" { + return nil, fmt.Errorf("invalid --backup-type %q: must be %s", backupType, strings.Join(backupTypes, " or ")) + } + if backupType == "INCREMENTAL" && parentID == "" { + return nil, fmt.Errorf("--backup-type INCREMENTAL requires --parent-id, the backup to build on") + } + if parentID != "" { + if err := requireBackupID(parentID); err != nil { + return nil, err + } + if backupType != "INCREMENTAL" { + return nil, fmt.Errorf("--parent-id only applies to --backup-type INCREMENTAL") + } + } + if strings.TrimSpace(description) == "" { + return nil, fmt.Errorf("--description must not be empty: on the relational API, which shares " + + "this request schema, a backup without one is accepted and then fails in the background") + } + + body := map[string]interface{}{ + "dbInstanceId": instanceID, + "name": name, + "backupType": backupType, + "description": description, + } + if parentID != "" { + body["parentId"] = parentID + } + return body, nil +} + +func runDelete(cmd *cobra.Command, args []string) error { + backupID, _ := cmd.Flags().GetString("backup-id") + if err := requireBackupID(backupID); err != nil { + return err + } + + // The whole request is the array; the path carries no ID. + body := []interface{}{ + map[string]interface{}{"backupId": backupID}, + } + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + printDeleteTarget(apiClient, backupID) + + if dryRun { + vdbclient.PreviewBody("delete", fmt.Sprintf("backup %s", backupID), body) + return nil + } + if !cli.Confirm(force, fmt.Sprintf("Delete backup %s? This cannot be undone.", backupID)) { + fmt.Println("Aborted.") + return nil + } + + result, err := apiClient.Post(basePath+"/delete", body) + if err != nil { + return fmt.Errorf("failed to delete backup %s: %w", backupID, err) + } + + return vdbclient.Output(cmd, result) +} + +func runRestore(cmd *cobra.Command, args []string) error { + backupID, _ := cmd.Flags().GetString("backup-id") + if err := requireBackupID(backupID); err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + backup, err := fetchBackup(apiClient, backupID) + if err != nil { + return err + } + + body, err := restoreBody(cmd.Flags(), backupID, backup) + if err != nil { + return err + } + + name, _ := cmd.Flags().GetString("name") + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + vdbclient.PreviewBody("restore", fmt.Sprintf("backup %s into a new instance %q", backupID, name), body) + return nil + } + if !cli.Confirm(force, fmt.Sprintf( + "Restore backup %s into a NEW MemoryStore instance %q? This places a paid order; the original instance is not touched.", + backupID, name)) { + fmt.Println("Aborted.") + return nil + } + + result, err := apiClient.Post(basePath+"/"+backupID+"/restore", body) + if err != nil { + return fmt.Errorf("failed to restore backup %s: %w", backupID, err) + } + + return vdbclient.Output(cmd, result) +} + +// restoreBody assembles RestoreMemBackupRequest: the same envelope as the relational +// restore — action "restore_backup", resourceType "dbaas-backup", a detail carrying +// only a config — with the Redis password fields in place of user/databases and no +// volume fields. +func restoreBody(flags *pflag.FlagSet, backupID string, backup map[string]interface{}) (map[string]interface{}, error) { + name, _ := flags.GetString("name") + poc, _ := flags.GetBool("poc") + publicAccess, _ := flags.GetBool("public-access") + + zoneID, _ := flags.GetString("zone-id") + if zoneID == "" { + return nil, fmt.Errorf("--zone-id must not be empty: a restored instance needs a zone to be placed in") + } + + // The relational restore rejects a request whose flavor and volume type do not + // match the zone, blaming those fields rather than the zone. Sending the zone + // explicitly is what avoids that class of error. + config := map[string]interface{}{ + "backupId": backupID, + "name": name, + "poc": poc, + "locateZoneId": zoneID, + "publicAccess": publicAccess, + "datastoreType": stringField(backup, "datastoreType"), + "datastoreVersion": stringField(backup, "datastoreVersion"), + "packageId": stringOrDefault(flags, "package-id", stringField(backup, "packageId")), + } + + subnets := cli.ParseCommaSeparated(mustString(flags, "subnet-ids")) + if len(subnets) == 0 { + return nil, fmt.Errorf("--subnet-ids must not be empty: pass a subnet in %s", zoneID) + } + config["netIds"] = toInterfaces(subnets) + + if configID := stringOrDefault(flags, "config-id", stringField(backup, "configId")); configID != "" { + config["configId"] = configID + } + + password, _ := flags.GetString("redis-password") + if password == "" { + password = os.Getenv(passwordEnv) + } + if publicAccess && password == "" { + return nil, fmt.Errorf("--public-access requires a master password: pass --redis-password or set $%s", passwordEnv) + } + config["redisPasswordEnabled"] = password != "" + if password != "" { + if err := vdbclient.ValidateRedisPassword(password, "redis-password"); err != nil { + return nil, err + } + config["redisPassword"] = password + } + + if err := applyBackupSchedule(flags, config); err != nil { + return nil, err + } + if config["packageId"] == "" { + return nil, fmt.Errorf("could not determine a flavor from backup %s: pass --package-id", backupID) + } + + return vdbclient.ResizeConfigBody(vdbclient.ResourceTypeBackup, "restore_backup", config), nil +} + +func applyBackupSchedule(flags *pflag.FlagSet, config map[string]interface{}) error { + backupAuto, _ := flags.GetBool("backup-auto") + duration, _ := flags.GetInt("backup-duration") + backupTime, _ := flags.GetString("backup-time") + + config["backupAuto"] = backupAuto + + if !backupAuto { + if duration != 0 || backupTime != "" { + return fmt.Errorf("--backup-duration and --backup-time only apply with --backup-auto") + } + return nil + } + if duration < minBackupDuration || duration > maxBackupDuration { + return fmt.Errorf("--backup-auto requires --backup-duration between %d and %d days, got %d", + minBackupDuration, maxBackupDuration, duration) + } + if backupTime == "" { + return fmt.Errorf("--backup-auto requires --backup-time, e.g. --backup-time 02:00") + } + + config["backupDuration"] = duration + config["backupTime"] = backupTime + return nil +} + +func fetchBackup(apiClient *vdbclient.Client, backupID string) (map[string]interface{}, error) { + result, err := apiClient.Get(basePath+"/"+backupID+"/detail", nil) + if err != nil { + return nil, fmt.Errorf("failed to read backup %s: %w", backupID, err) + } + backup, ok := vdbclient.PayloadObject(result) + if !ok { + return nil, fmt.Errorf("backup %s not found or unusable (the API returned an empty payload; "+ + "a backup whose creation failed reads this way)", backupID) + } + return backup, nil +} + +func printDeleteTarget(apiClient *vdbclient.Client, backupID string) { + result, err := apiClient.Get(basePath+"/"+backupID+"/detail", nil) + if err != nil { + fmt.Printf("Warning: could not read backup %s before deleting (%v).\n", backupID, err) + return + } + backup, ok := vdbclient.PayloadObject(result) + if !ok { + fmt.Printf("Warning: backup %s returned an empty payload — it may already be gone.\n", backupID) + return + } + + fmt.Println("The following backup will be deleted:") + fmt.Println() + fmt.Printf(" ID: %v\n", backup["id"]) + fmt.Printf(" Name: %v\n", backup["name"]) + fmt.Printf(" Type: %v\n", backup["backupType"]) + fmt.Printf(" Instance: %v (%v)\n", backup["instanceName"], backup["dbInstanceId"]) + fmt.Printf(" Created: %v\n", backup["created"]) + fmt.Println() +} + +// Payload readers and flag defaulting, as in the other groups. + +func stringField(payload map[string]interface{}, key string) string { + value, _ := payload[key].(string) + return value +} + +func stringOrDefault(flags *pflag.FlagSet, name, fallback string) string { + if !flags.Changed(name) { + return fallback + } + value, _ := flags.GetString(name) + return value +} + +func mustString(flags *pflag.FlagSet, name string) string { + value, _ := flags.GetString(name) + return value +} + +func toInterfaces(values []string) []interface{} { + out := make([]interface{}, len(values)) + for i, v := range values { + out[i] = v + } + return out +} diff --git a/go/cmd/vdb/memorystore/backupstorage/backupstorage.go b/go/cmd/vdb/memorystore/backupstorage/backupstorage.go new file mode 100644 index 0000000..b922c3f --- /dev/null +++ b/go/cmd/vdb/memorystore/backupstorage/backupstorage.go @@ -0,0 +1,64 @@ +// Package backupstorage holds the MemoryStore backup-storage commands. +// +// Two of the four paths mean the OPPOSITE of their relational namesakes, which is the +// trap here: +// +// what you own relational: /backup-storages/information memorystore: /backup-storages +// what you can buy relational: /backup-storages memorystore: /backup-storages/packages +// release relational: /actions/deletions (plural) memorystore: /actions/delete (singular) +// +// So a `GET /backup-storages` written for relational lists purchasable packages and +// the same call here lists the quota you already have. +package backupstorage + +import ( + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// BackupStorageCmd is the parent command for MemoryStore backup storage. +var BackupStorageCmd = &cobra.Command{ + Use: "backup-storage", + Short: "Manage MemoryStore backup storage", + Long: "Inspect, buy, resize and release the backup storage quota used by vDB " + + "MemoryStore backups.\n\n" + + "Backups first consume the free allowance that comes with an instance's flavor; " + + "beyond that they need purchased storage. 'backup-storage list' shows what you " + + "have, 'list-packages' what you can buy.", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() //nolint:errcheck + }, +} + +const ( + basePath = "/vdb-memory/v1/backup-storages" + paymentPath = "/vdb-memory/v1/payment/backup-storages" + + storageIDPrefix = "db-bk-storage-" +) + +func init() { + BackupStorageCmd.AddCommand(listCmd) + BackupStorageCmd.AddCommand(listPackagesCmd) + BackupStorageCmd.AddCommand(createCmd) + BackupStorageCmd.AddCommand(resizeCmd) + BackupStorageCmd.AddCommand(deleteCmd) +} + +func createClient(cmd *cobra.Command) (*vdbclient.Client, error) { + return vdbclient.BuildClient(cmd) +} + +func requireStorageID(storageID string) error { + return vdbclient.RequireIDWithPrefix(storageID, "storage-id", storageIDPrefix, + "Backup storage IDs start with 'db-bk-storage-'; run 'backup-storage list' to find one") +} + +var storageColumns = []string{ + "id", "name", "usage", "quota", "status", "backupPackageName", "engineGroup", +} + +var packageColumns = []string{ + "engineGroup", "packageId", "packageName", "packageQuota", "price", "sku", "description", +} diff --git a/go/cmd/vdb/memorystore/backupstorage/commands.go b/go/cmd/vdb/memorystore/backupstorage/commands.go new file mode 100644 index 0000000..6f3fb9f --- /dev/null +++ b/go/cmd/vdb/memorystore/backupstorage/commands.go @@ -0,0 +1,300 @@ +package backupstorage + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List the backup storage you own", + Long: "List your MemoryStore backup storage: its quota, how much of it your backups " + + "occupy, and which package it came from.\n\n" + + "This reads GET /backup-storages — the same path that, in the Relational Database " + + "API, lists the packages available to BUY. Here that catalogue is " + + "'list-packages'.", + Args: cobra.NoArgs, + RunE: runList, +} + +var listPackagesCmd = &cobra.Command{ + Use: "list-packages", + Short: "List the backup storage packages available to buy", + Long: "List the purchasable backup storage packages, with their quota and price.\n\n" + + "The packageId is what --package-id expects on 'backup-storage create' and " + + "'backup-storage resize'.", + Args: cobra.NoArgs, + RunE: runListPackages, +} + +var createCmd = &cobra.Command{ + Use: "create", + Short: "Buy backup storage", + Long: "Buy a backup storage package.\n\n" + + "THIS COSTS MONEY: order/payment flow, asynchronous. Run 'backup-storage " + + "list-packages' for the package IDs, and 'backup-storage list' to see whether you " + + "already own storage — growing what you have is 'backup-storage resize'.", + Args: cobra.NoArgs, + RunE: runCreate, +} + +var resizeCmd = &cobra.Command{ + Use: "resize", + Short: "Change the package of existing backup storage", + Long: "Move existing backup storage to a different package, usually a larger one.\n\n" + + "THIS COSTS MONEY: order/payment flow. Shrinking below what your backups already " + + "occupy is rejected by the API — check the usage column in 'backup-storage list'.", + Args: cobra.NoArgs, + RunE: runResize, +} + +var deleteCmd = &cobra.Command{ + Use: "delete", + Short: "Release backup storage", + Long: "Give up a backup storage quota.\n\n" + + "Any backup that relies on this storage is at risk: release it only when your " + + "backups fit in the free allowance, or after deleting them.\n\n" + + "Note the endpoint is /actions/delete, singular — the relational one is " + + "/actions/deletions.", + Args: cobra.NoArgs, + RunE: runDelete, +} + +func init() { + c := createCmd.Flags() + c.String("package-id", "", "Backup storage package to buy (required; see 'backup-storage list-packages')") + c.Bool("dry-run", false, "Print the request that would be sent without placing an order") + c.Bool("force", false, "Skip the confirmation prompt") + createCmd.MarkFlagRequired("package-id") //nolint:errcheck + + r := resizeCmd.Flags() + r.String("storage-id", "", "Backup storage to change (required; see 'backup-storage list')") + r.String("package-id", "", "New package (required)") + r.Bool("poc", false, "Pay with PoC credit (Auto Payment only)") + r.Bool("dry-run", false, "Print the request that would be sent without placing an order") + r.Bool("force", false, "Skip the confirmation prompt") + resizeCmd.MarkFlagRequired("storage-id") //nolint:errcheck + resizeCmd.MarkFlagRequired("package-id") //nolint:errcheck + + d := deleteCmd.Flags() + d.String("storage-id", "", "Backup storage to release (required)") + d.Bool("dry-run", false, "Print the request that would be sent without releasing anything") + d.Bool("force", false, "Skip the confirmation prompt") + deleteCmd.MarkFlagRequired("storage-id") //nolint:errcheck + + // Bound here, next to the flags: see the init-order note in completion.go. + createCmd.RegisterFlagCompletionFunc("package-id", packageIDCompletion()) //nolint:errcheck + resizeCmd.RegisterFlagCompletionFunc("storage-id", storageIDCompletion()) //nolint:errcheck + resizeCmd.RegisterFlagCompletionFunc("package-id", packageIDCompletion()) //nolint:errcheck + deleteCmd.RegisterFlagCompletionFunc("storage-id", storageIDCompletion()) //nolint:errcheck +} + +func runList(cmd *cobra.Command, args []string) error { + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Get(basePath, nil) + if err != nil { + return fmt.Errorf("failed to list backup storage: %w", err) + } + + return vdbclient.OutputWithColumns(cmd, result, storageColumns) +} + +func runListPackages(cmd *cobra.Command, args []string) error { + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Get(basePath+"/packages", nil) + if err != nil { + return fmt.Errorf("failed to list backup storage packages: %w", err) + } + + return vdbclient.OutputWithColumns(cmd, flattenPackages(vdbclient.Unwrap(result)), packageColumns) +} + +// flattenPackages turns the response's two levels into one row per package. The +// payload is an array of {engineGroup, packages[]}, and table output picks the first +// array it finds — the outer one — so each package becomes its own row, keeping its +// engine group. +func flattenPackages(payload interface{}) interface{} { + groups, ok := payload.([]interface{}) + if !ok { + return payload + } + + var rows []interface{} + for _, item := range groups { + group, ok := item.(map[string]interface{}) + if !ok { + continue + } + packages, _ := group["packages"].([]interface{}) + for _, entry := range packages { + pkg, ok := entry.(map[string]interface{}) + if !ok { + continue + } + row := make(map[string]interface{}, len(pkg)+1) + for k, v := range pkg { + row[k] = v + } + row["engineGroup"] = group["engineGroup"] + rows = append(rows, row) + } + } + if rows == nil { + return payload + } + return rows +} + +func runCreate(cmd *cobra.Command, args []string) error { + packageID, _ := cmd.Flags().GetString("package-id") + + body := map[string]interface{}{"backupPackageId": packageID} + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + vdbclient.PreviewBody("buy", fmt.Sprintf("backup storage package %s", packageID), body) + return nil + } + if !cli.Confirm(force, fmt.Sprintf("Buy backup storage package %s? This places a paid order.", packageID)) { + fmt.Println("Aborted.") + return nil + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Post(paymentPath, body) + if err != nil { + return fmt.Errorf("failed to buy backup storage package %s: %w", packageID, err) + } + + return vdbclient.Output(cmd, result) +} + +func runResize(cmd *cobra.Command, args []string) error { + storageID, _ := cmd.Flags().GetString("storage-id") + if err := requireStorageID(storageID); err != nil { + return err + } + + packageID, _ := cmd.Flags().GetString("package-id") + poc, _ := cmd.Flags().GetBool("poc") + + body := vdbclient.ResizeBody(vdbclient.ResourceTypeBackupStorage, storageID, "resize", + map[string]interface{}{ + "backupPackageId": packageID, + "poc": poc, + }) + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + vdbclient.PreviewBody("resize", fmt.Sprintf("backup storage %s", storageID), body) + return nil + } + if !cli.Confirm(force, fmt.Sprintf( + "Move backup storage %s to package %s? This places a paid order.", storageID, packageID)) { + fmt.Println("Aborted.") + return nil + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Post(basePath+"/actions/resize", body) + if err != nil { + return fmt.Errorf("failed to resize backup storage %s: %w", storageID, err) + } + + return vdbclient.Output(cmd, result) +} + +func runDelete(cmd *cobra.Command, args []string) error { + storageID, _ := cmd.Flags().GetString("storage-id") + if err := requireStorageID(storageID); err != nil { + return err + } + + body := vdbclient.ActionBody(vdbclient.ResourceTypeBackupStorage, storageID, "delete", nil) + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + printDeleteTarget(apiClient, storageID) + + if dryRun { + vdbclient.PreviewBody("release", fmt.Sprintf("backup storage %s", storageID), body) + return nil + } + if !cli.Confirm(force, fmt.Sprintf( + "Release backup storage %s? Backups that depend on it may be lost.", storageID)) { + fmt.Println("Aborted.") + return nil + } + + // Singular /actions/delete here; the relational endpoint is /actions/deletions. + result, err := apiClient.Post(basePath+"/actions/delete", body) + if err != nil { + return fmt.Errorf("failed to release backup storage %s: %w", storageID, err) + } + + return vdbclient.Output(cmd, result) +} + +func printDeleteTarget(apiClient *vdbclient.Client, storageID string) { + result, err := apiClient.Get(basePath, nil) + if err != nil { + return + } + items, ok := vdbclient.Unwrap(result).([]interface{}) + if !ok { + return + } + + for _, item := range items { + storage, ok := item.(map[string]interface{}) + if !ok || storage["id"] != storageID { + continue + } + fmt.Println("The following backup storage will be released:") + fmt.Println() + fmt.Printf(" ID: %v\n", storage["id"]) + fmt.Printf(" Name: %v\n", storage["name"]) + fmt.Printf(" Package: %s\n", orDash(storage["backupPackageName"])) + fmt.Printf(" Usage: %v of %v\n", storage["usage"], storage["quota"]) + fmt.Println() + return + } +} + +// orDash renders a value the API often leaves null. +func orDash(value interface{}) string { + text, ok := value.(string) + if !ok || text == "" { + return "-" + } + return text +} diff --git a/go/cmd/vdb/memorystore/backupstorage/completion.go b/go/cmd/vdb/memorystore/backupstorage/completion.go new file mode 100644 index 0000000..6bb9c64 --- /dev/null +++ b/go/cmd/vdb/memorystore/backupstorage/completion.go @@ -0,0 +1,49 @@ +package backupstorage + +import ( + "context" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// Completers for the two ids these commands take. Each is bound in the init() of the +// file that defines the flag — see the init-order note in +// cmd/vdb/relational/instance/completion.go. +// +// Both are API-backed and read the same endpoints the read commands use, which is why +// the mapping is worth stating once: on THIS API, basePath lists the storage you own +// and basePath+"/packages" the packages you can buy. Relational inverts that pair. + +func storageIDCompletion() cli.CompFunc { + return cli.FlagFromAPI(func(_ context.Context, cmd *cobra.Command) ([]string, error) { + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return nil, err + } + result, err := apiClient.Get(basePath, nil) + if err != nil { + return nil, err + } + return cli.ExtractIDs(vdbclient.Unwrap(result), "id"), nil + }) +} + +// packageIDCompletion suggests packageId, not id. Two details matter: the packages can +// arrive nested under their engine group, so the listing is flattened first — the same +// shape the table shows — and packageId is a JSON NUMBER, so it needs +// vdbclient.ExtractIDValues rather than cli.ExtractIDs, which would find nothing. +func packageIDCompletion() cli.CompFunc { + return cli.FlagFromAPI(func(_ context.Context, cmd *cobra.Command) ([]string, error) { + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return nil, err + } + result, err := apiClient.Get(basePath+"/packages", nil) + if err != nil { + return nil, err + } + return vdbclient.ExtractIDValues(flattenPackages(vdbclient.Unwrap(result)), "packageId"), nil + }) +} diff --git a/go/cmd/vdb/memorystore/catalog/catalog.go b/go/cmd/vdb/memorystore/catalog/catalog.go new file mode 100644 index 0000000..15b417f --- /dev/null +++ b/go/cmd/vdb/memorystore/catalog/catalog.go @@ -0,0 +1,183 @@ +// Package catalog holds the MemoryStore read-only lookups. +// +// They live under `/vdb-memory/v1/database/*`, NOT `/database-instances/*` as in the +// relational API, and the set is not the same: MemoryStore has **no zones or subnets +// endpoint** — those come from the Relational Database catalog, which is where the +// product owner confirmed they live. +// +// `/database/status` is deliberately unused: the product owner reported it OUTDATED +// on 2026-08-13, so neither a command nor completion reads it. Status values are +// taken from the instance listing instead, as the relational group does. +package catalog + +import ( + "fmt" + "net/url" + "strings" + + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// CatalogCmd is the parent command for the MemoryStore lookups. +var CatalogCmd = &cobra.Command{ + Use: "catalog", + Short: "Look up MemoryStore engines, flavors, networks and config groups", + Long: "Read-only lists of what a MemoryStore instance can be built from: engines, " + + "versions, instance families, flavors, CPU platform codes, networks, volume types " + + "and config groups.\n\n" + + "Zones and subnets are NOT here: MemoryStore has no endpoint for them, so use " + + "'grn vdb relational catalog list-zones' and 'list-subnets' — they are " + + "project-wide.", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() //nolint:errcheck + }, +} + +const ( + basePath = "/vdb-memory/v1/database" + + datastorePath = basePath + "/datastore" + enginePath = basePath + "/engine" + familiesPath = basePath + "/families" + codesPath = basePath + "/codes" + flavorsPath = basePath + "/flavors" + networksPath = basePath + "/networks" + subnetsPath = basePath + "/networks/subnets" + volumeTypesPath = basePath + "/volume-types" + configPath = basePath + "/configuration" +) + +// simpleList describes a lookup needing no input beyond an optional zone. +type simpleList struct { + use string + short string + long string + path string + byZone bool + columns []string +} + +var simpleLists = []simpleList{ + { + use: "list-engines", + short: "List available engines", + long: "List the engines MemoryStore offers. In practice this is Redis.", + path: enginePath, + columns: []string{"name", "description"}, + }, + { + use: "list-datastores", + short: "List available engine versions", + long: "List the engine/version pairs that can be deployed. The 'version' value is " + + "what --datastore-version expects on 'instance create'.", + path: datastorePath, + columns: []string{"type", "version", "name", "versionName", "licenseName"}, + }, + { + use: "list-families", + short: "List instance families", + long: "List the instance families (CPU/memory profiles) that flavors belong to.", + path: familiesPath, + columns: []string{"group", "key", "value", "name", "description"}, + }, + { + use: "list-flavor-codes", + short: "List CPU platform codes", + long: "List the CPU platform codes that flavors are grouped by.", + path: codesPath, + columns: []string{"key", "value", "familyType", "description"}, + }, + { + use: "list-networks", + short: "List networks available to instances", + long: "List the networks (VPCs) a MemoryStore instance can be attached to.", + path: networksPath, + columns: []string{"id", "displayName", "cidr", "status", "createdAt"}, + }, + { + use: "list-subnets", + short: "List networks with their subnets", + long: "List the networks available to MemoryStore together with their subnets.\n\n" + + "Each row is a network; its subnets are a nested array, so use --output json " + + "(or --query) to see them. This mirrors the relational endpoint of the same " + + "name, which is also where 'instance create --subnet-ids' values come from.", + path: subnetsPath, + byZone: true, + columns: []string{"uuid", "displayName", "status", "networkId", "zoneId"}, + }, + { + use: "list-volume-types", + short: "List volume types", + long: "List the volume types the API reports for MemoryStore.\n\n" + + "Informational: a MemoryStore instance takes no volume type or size — its " + + "capacity is the flavor's RAM — so nothing on 'instance create' consumes these.", + path: volumeTypesPath, + byZone: true, + columns: []string{"type", "description", "minVolumeSize", "maxVolumeSize", "iops", "zoneId"}, + }, + { + use: "list-config-groups", + short: "List config groups that can be attached to an instance", + long: "List the config groups available to MemoryStore instances.\n\n" + + "A group only fits an instance with the same engine and version. Its ID is what " + + "--config-id expects on 'instance create' and 'instance update-config-group'.", + path: configPath, + columns: []string{"id", "name", "datastoreName", "datastoreVersionName", "created"}, + }, +} + +func newSimpleList(spec simpleList) *cobra.Command { + cmd := &cobra.Command{ + Use: spec.use, + Short: spec.short, + Long: spec.long, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if err := get(cmd, spec.path, zoneQuery(cmd), spec.columns); err != nil { + return fmt.Errorf("failed to %s: %w", strings.ReplaceAll(spec.use, "-", " "), err) + } + return nil + }, + } + if spec.byZone { + cmd.Flags().String("zone-id", "", "Restrict results to one availability zone (e.g. HCM03-1A)") + cmd.RegisterFlagCompletionFunc("zone-id", zoneIDCompletion()) //nolint:errcheck + } + return cmd +} + +func init() { + for _, spec := range simpleLists { + CatalogCmd.AddCommand(newSimpleList(spec)) + } + CatalogCmd.AddCommand(listFlavorsCmd) +} + +func createClient(cmd *cobra.Command) (*vdbclient.Client, error) { + return vdbclient.BuildClient(cmd) +} + +// get issues the GET and prints it with a column set. +func get(cmd *cobra.Command, path string, query url.Values, columns []string) error { + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := vdbclient.Get(apiClient, path, query) + if err != nil { + return err + } + + return vdbclient.OutputWithColumns(cmd, result, columns) +} + +func zoneQuery(cmd *cobra.Command) url.Values { + query := url.Values{} + if zoneID, _ := cmd.Flags().GetString("zone-id"); zoneID != "" { + query.Set("zoneId", zoneID) + } + return query +} diff --git a/go/cmd/vdb/memorystore/catalog/completion.go b/go/cmd/vdb/memorystore/catalog/completion.go new file mode 100644 index 0000000..c6f7113 --- /dev/null +++ b/go/cmd/vdb/memorystore/catalog/completion.go @@ -0,0 +1,101 @@ +package catalog + +import ( + "context" + "net/url" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// Completion keys owned by these endpoints. They are distinct from the relational +// ones because the values are not interchangeable — a MemoryStore flavor id is not a +// relational flavor id, and a Redis config group cannot be attached to MySQL. +const ( + DatastoreTypeResourceKey = "vdb:memorystore-datastore-type" + DatastoreVersionResourceKey = "vdb:memorystore-datastore-version" + ConfigGroupResourceKey = "vdb:memorystore-config-group" + + // defaultDatastoreType is what the consuming commands default --datastore-type to. + defaultDatastoreType = "Redis" + + // FlavorResourceKey lists flavor ids for --package-id. It is CONTEXT-DEPENDENT: + // the endpoint requires an engine version, which the completer reads from the + // command's own --datastore-version (and --zone-id when given), so it yields + // nothing until that is set. + FlavorResourceKey = "vdb:memorystore-flavor" +) + +// zoneResourceKey is the RELATIONAL key, reused: MemoryStore has no zones endpoint. +const zoneResourceKey = "vdb:relational-zone" + +func init() { + cli.RegisterResourceCompleter(DatastoreTypeResourceKey, cli.FlagFromAPI(fieldFrom(datastorePath, "type"))) + cli.RegisterResourceCompleter(DatastoreVersionResourceKey, cli.FlagFromAPI(fieldFrom(datastorePath, "version"))) + cli.RegisterResourceCompleter(ConfigGroupResourceKey, cli.FlagFromAPI(fieldFrom(configPath, "id"))) + cli.RegisterResourceCompleter(FlavorResourceKey, cli.FlagFromAPI(flavorIDs)) +} + +func datastoreTypeCompletion() cli.CompFunc { + return cli.ResourceCompletion(DatastoreTypeResourceKey) +} + +func datastoreVersionCompletion() cli.CompFunc { + return cli.ResourceCompletion(DatastoreVersionResourceKey) +} + +func zoneIDCompletion() cli.CompFunc { + return cli.ResourceCompletion(zoneResourceKey) +} + +// flavorIDs suggests package ids for the engine version named on the command line. The +// ids are JSON numbers, so they are formatted rather than read as strings, and duplicates +// are dropped: one flavor name can appear twice in a zone (once with familyType and +// platformType populated, once with both null) under different ids. +func flavorIDs(_ context.Context, cmd *cobra.Command) ([]string, error) { + datastoreVersion, _ := cmd.Flags().GetString("datastore-version") + if datastoreVersion == "" { + return nil, nil + } + + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return nil, err + } + + query := url.Values{} + query.Set("version", datastoreVersion) + // The type flag is optional on the consuming commands; Redis is the only engine + // MemoryStore serves, so its own default is what the endpoint expects. + datastoreType, _ := cmd.Flags().GetString("datastore-type") + if datastoreType == "" { + datastoreType = defaultDatastoreType + } + query.Set("type", datastoreType) + if zoneID, _ := cmd.Flags().GetString("zone-id"); zoneID != "" { + query.Set("zoneId", zoneID) + } + + result, err := vdbclient.Get(apiClient, flavorsPath, query) + if err != nil { + return nil, err + } + return vdbclient.ExtractIDValues(vdbclient.Unwrap(result), "id"), nil +} + +// fieldFrom collects one field from every item of a catalog listing. Unwrap runs +// first: the values sit inside the {code, message, data} envelope. +func fieldFrom(path, field string) func(context.Context, *cobra.Command) ([]string, error) { + return func(_ context.Context, cmd *cobra.Command) ([]string, error) { + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return nil, err + } + result, err := apiClient.Get(path, nil) + if err != nil { + return nil, err + } + return cli.ExtractIDs(vdbclient.Unwrap(result), field), nil + } +} diff --git a/go/cmd/vdb/memorystore/catalog/list_flavors.go b/go/cmd/vdb/memorystore/catalog/list_flavors.go new file mode 100644 index 0000000..7b28f90 --- /dev/null +++ b/go/cmd/vdb/memorystore/catalog/list_flavors.go @@ -0,0 +1,63 @@ +package catalog + +import ( + "fmt" + "net/url" + + "github.com/spf13/cobra" +) + +// flavorColumns omits volumeSize and monthlyCost, which the API returns as 0, and +// the pricing SKUs. ram is the number that matters for Redis: it is the instance's +// capacity. +var flavorColumns = []string{ + "id", "name", "vcpus", "ram", "bandwidth", "familyType", "platformType", "locateZoneId", +} + +var listFlavorsCmd = &cobra.Command{ + Use: "list-flavors", + Short: "List flavors for an engine version", + Long: "List the flavors (vCPU/RAM combinations) available for one engine version.\n\n" + + "Both --datastore-type and --datastore-version are required by the API; run " + + "'catalog list-datastores' for the valid pairs. Note the flag is " + + "--datastore-version, not --version, which is reserved globally.\n\n" + + "The 'id' column is what --package-id expects on 'instance create' and " + + "'instance resize-instance'. RAM is the instance's capacity — a MemoryStore " + + "instance has no volume.", + Args: cobra.NoArgs, + RunE: runListFlavors, +} + +func init() { + f := listFlavorsCmd.Flags() + f.String("datastore-type", "", "Engine, e.g. Redis (required)") + f.String("datastore-version", "", "Engine version, e.g. 7.2 (required)") + f.String("zone-id", "", "Restrict results to one availability zone") + + listFlavorsCmd.MarkFlagRequired("datastore-type") //nolint:errcheck + listFlavorsCmd.MarkFlagRequired("datastore-version") //nolint:errcheck + + // Bound here, next to the flags: see the init-order note in completion.go. + listFlavorsCmd.RegisterFlagCompletionFunc("datastore-type", datastoreTypeCompletion()) //nolint:errcheck + listFlavorsCmd.RegisterFlagCompletionFunc("datastore-version", datastoreVersionCompletion()) //nolint:errcheck + listFlavorsCmd.RegisterFlagCompletionFunc("zone-id", zoneIDCompletion()) //nolint:errcheck +} + +// flavorsQuery maps the flags onto the endpoint's query params. The API names them +// "type" and "version"; the flags are prefixed because --version is taken by the +// root command. +func flavorsQuery(cmd *cobra.Command) url.Values { + query := zoneQuery(cmd) + datastoreType, _ := cmd.Flags().GetString("datastore-type") + datastoreVersion, _ := cmd.Flags().GetString("datastore-version") + query.Set("type", datastoreType) + query.Set("version", datastoreVersion) + return query +} + +func runListFlavors(cmd *cobra.Command, args []string) error { + if err := get(cmd, flavorsPath, flavorsQuery(cmd), flavorColumns); err != nil { + return fmt.Errorf("failed to list flavors: %w", err) + } + return nil +} diff --git a/go/cmd/vdb/memorystore/configuration/commands.go b/go/cmd/vdb/memorystore/configuration/commands.go new file mode 100644 index 0000000..c7f2e0c --- /dev/null +++ b/go/cmd/vdb/memorystore/configuration/commands.go @@ -0,0 +1,417 @@ +package configuration + +import ( + "fmt" + "net/url" + "sort" + "strconv" + "strings" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List config groups", + Long: "List the MemoryStore config groups in your project.\n\n" + + "Results are paginated; --page is 1-based and the items come back under 'content'.", + Args: cobra.NoArgs, + RunE: runList, +} + +var getCmd = &cobra.Command{ + Use: "get", + Short: "Get details of a config group", + Long: "Show one config group, including every parameter it sets.\n\n" + + "The parameters live in the nested 'values' map, so this prints a key/value view " + + "rather than a table. Note the path: /configurations/{id}/detail, where the " + + "relational API takes the ID as a query parameter instead.", + Args: cobra.NoArgs, + RunE: runGet, +} + +var createCmd = &cobra.Command{ + Use: "create", + Short: "Create a config group", + Long: "Create an empty config group for one Redis version.\n\n" + + "Creating a group costs nothing and changes no instance: it holds parameters until " + + "you attach it with 'instance update-config-group'. Set the parameters afterwards " + + "with 'configuration update'.\n\n" + + "Unlike the relational equivalent there is no --deploy-type: MemoryStore has one " + + "deployment shape.", + Args: cobra.NoArgs, + RunE: runCreate, +} + +var updateCmd = &cobra.Command{ + Use: "update", + Short: "Replace the parameters of a config group", + Long: "Set Redis parameters in a config group.\n\n" + + "THE API REPLACES THE WHOLE PARAMETER SET — verified on the relational endpoint, " + + "which shares this request. Whatever the group held and you do not repeat is " + + "dropped. Pass --merge to keep the existing parameters and only add or override " + + "the ones you name; either way the command prints the parameters before and " + + "after.\n\n" + + "Values are sent typed: 1 as a number, true as a boolean, anything else as a " + + "string. Run 'configuration list-params' for the accepted names and ranges.\n\n" + + "The change is asynchronous, so a read straight afterwards may still show the old " + + "parameters.", + Args: cobra.NoArgs, + RunE: runUpdate, +} + +var deleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete a config group", + Long: "Delete a config group.\n\n" + + "A group still attached to instances cannot be deleted — detach it first with " + + "'instance update-config-group --detach'. 'configuration get' lists what is " + + "attached under `instances`; the instanceCount the API returns is always 0 and " + + "cannot be used for this.\n\n" + + "The request is a POST with a JSON ARRAY body and no ID in the path.", + Args: cobra.NoArgs, + RunE: runDelete, +} + +var listParamsCmd = &cobra.Command{ + Use: "list-params", + Short: "List the parameters Redis accepts in a config group", + Long: "List the Redis parameters that can be set for one version, with their type, " + + "allowed range or values, and whether changing one restarts the instance.\n\n" + + "This is the reference for 'configuration update --set': a parameter not listed " + + "here, or one with modifiable false, will be rejected.", + Args: cobra.NoArgs, + RunE: runListParams, +} + +func init() { + l := listCmd.Flags() + l.Int("page", 1, "Page number (1-based)") + l.Int("page-size", vdbclient.DefaultPageSize, "Number of items per page") + + g := getCmd.Flags() + g.String("config-id", "", "Config group ID (required)") + getCmd.MarkFlagRequired("config-id") //nolint:errcheck + getCmd.RegisterFlagCompletionFunc("config-id", cli.ResourceCompletion(configGroupResource)) //nolint:errcheck + + c := createCmd.Flags() + c.String("name", "", "Config group name (required)") + c.String("datastore-type", "Redis", "Engine (required)") + c.String("datastore-version", "", "Engine version, e.g. 7.2 (required)") + c.String("description", "", "Free-text description") + c.Bool("dry-run", false, "Print the request that would be sent without creating anything") + createCmd.MarkFlagRequired("name") //nolint:errcheck + createCmd.MarkFlagRequired("datastore-version") //nolint:errcheck + createCmd.RegisterFlagCompletionFunc("datastore-type", cli.ResourceCompletion(datastoreTypeResource)) //nolint:errcheck + createCmd.RegisterFlagCompletionFunc("datastore-version", cli.ResourceCompletion(datastoreVersionResource)) //nolint:errcheck + + u := updateCmd.Flags() + u.String("config-id", "", "Config group ID (required)") + u.StringArray("set", nil, "Parameter as name=value, repeatable (required)") + u.Bool("merge", false, "Keep the group's existing parameters and only add or override the ones given") + u.Bool("dry-run", false, "Print the request that would be sent without applying it") + u.Bool("force", false, "Skip the confirmation prompt") + updateCmd.MarkFlagRequired("config-id") //nolint:errcheck + updateCmd.MarkFlagRequired("set") //nolint:errcheck + updateCmd.RegisterFlagCompletionFunc("config-id", cli.ResourceCompletion(configGroupResource)) //nolint:errcheck + + d := deleteCmd.Flags() + d.String("config-id", "", "Config group ID (required)") + d.Bool("dry-run", false, "Print the request that would be sent without deleting") + d.Bool("force", false, "Skip the confirmation prompt") + deleteCmd.MarkFlagRequired("config-id") //nolint:errcheck + deleteCmd.RegisterFlagCompletionFunc("config-id", cli.ResourceCompletion(configGroupResource)) //nolint:errcheck + + p := listParamsCmd.Flags() + p.String("datastore-type", "Redis", "Engine (required)") + p.String("datastore-version", "", "Engine version, e.g. 7.2 (required)") + listParamsCmd.MarkFlagRequired("datastore-version") //nolint:errcheck + listParamsCmd.RegisterFlagCompletionFunc("datastore-type", cli.ResourceCompletion(datastoreTypeResource)) //nolint:errcheck + listParamsCmd.RegisterFlagCompletionFunc("datastore-version", cli.ResourceCompletion(datastoreVersionResource)) //nolint:errcheck +} + +func runList(cmd *cobra.Command, args []string) error { + page, _ := cmd.Flags().GetInt("page") + pageSize, _ := cmd.Flags().GetInt("page-size") + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + query := vdbclient.BuildListQuery(vdbclient.ListOptions{Page: page, PageSize: pageSize}) + result, err := vdbclient.Get(apiClient, basePath, query) + if err != nil { + return fmt.Errorf("failed to list config groups: %w", err) + } + + return vdbclient.OutputWithColumns(cmd, result, listColumns) +} + +func runGet(cmd *cobra.Command, args []string) error { + configID, _ := cmd.Flags().GetString("config-id") + if err := requireConfigID(configID); err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + group, err := fetchGroup(apiClient, configID) + if err != nil { + return err + } + + return vdbclient.Output(cmd, group) +} + +func runCreate(cmd *cobra.Command, args []string) error { + flags := cmd.Flags() + + name, _ := flags.GetString("name") + datastoreType, _ := flags.GetString("datastore-type") + datastoreVersion, _ := flags.GetString("datastore-version") + description, _ := flags.GetString("description") + + // CreateMemConfigGroupRequest has exactly these four fields — no deployType. + body := map[string]interface{}{ + "name": name, + "datastoreType": datastoreType, + "datastoreVersion": datastoreVersion, + } + if description != "" { + body["description"] = description + } + + if dryRun, _ := flags.GetBool("dry-run"); dryRun { + vdbclient.PreviewBody("create", fmt.Sprintf("config group %q", name), body) + return nil + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + // No confirmation: creating a group is free and touches no instance. + result, err := apiClient.Post(basePath+"/create", body) + if err != nil { + return fmt.Errorf("failed to create config group %q: %w", name, err) + } + + return vdbclient.Output(cmd, result) +} + +func runUpdate(cmd *cobra.Command, args []string) error { + configID, _ := cmd.Flags().GetString("config-id") + if err := requireConfigID(configID); err != nil { + return err + } + + specs, _ := cmd.Flags().GetStringArray("set") + values, err := parseValues(specs) + if err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + existing, err := currentValues(apiClient, configID) + if err != nil { + return err + } + + merge, _ := cmd.Flags().GetBool("merge") + if merge { + merged := make(map[string]interface{}, len(existing)+len(values)) + for name, value := range existing { + merged[name] = value + } + for name, value := range values { + merged[name] = value + } + values = merged + } + + fmt.Printf("Current parameters in %s: %s\n", configID, describeValues(existing)) + fmt.Printf("Parameters after this change: %s\n", describeValues(values)) + if !merge && len(existing) > 0 { + fmt.Println("\nThis REPLACES the parameter set; anything above that is not in the new set is dropped.") + } + + body := map[string]interface{}{"id": configID, "values": values} + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + vdbclient.PreviewBody("update", fmt.Sprintf("config group %s", configID), body) + return nil + } + if !cli.Confirm(force, fmt.Sprintf( + "Apply this parameter set to config group %s? Attached instances may need a reboot to pick it up.", + configID)) { + fmt.Println("Aborted.") + return nil + } + + if _, err := apiClient.Put(basePath+"/update", body); err != nil { + return fmt.Errorf("failed to update config group %s: %w", configID, err) + } + + fmt.Printf("\nAccepted: %d parameter(s) on config group %s.\n", len(values), configID) + fmt.Println("The API applies this asynchronously — run the following to confirm:") + fmt.Printf("\n grn vdb memorystore configuration get --config-id %s --query values\n", configID) + return nil +} + +func runDelete(cmd *cobra.Command, args []string) error { + configID, _ := cmd.Flags().GetString("config-id") + if err := requireConfigID(configID); err != nil { + return err + } + + body := []interface{}{ + map[string]interface{}{"id": configID}, + } + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + vdbclient.PreviewBody("delete", fmt.Sprintf("config group %s", configID), body) + return nil + } + if !cli.Confirm(force, fmt.Sprintf("Delete config group %s?", configID)) { + fmt.Println("Aborted.") + return nil + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + // POST, not DELETE — the relational API uses DELETE for the same operation. + result, err := apiClient.Post(basePath+"/delete", body) + if err != nil { + return fmt.Errorf("failed to delete config group %s: %w", configID, err) + } + + return vdbclient.Output(cmd, result) +} + +func runListParams(cmd *cobra.Command, args []string) error { + datastoreType, _ := cmd.Flags().GetString("datastore-type") + datastoreVersion, _ := cmd.Flags().GetString("datastore-version") + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + query := url.Values{} + query.Set("datastoreType", datastoreType) + query.Set("datastoreVersion", datastoreVersion) + + result, err := vdbclient.Get(apiClient, basePath+"/params", query) + if err != nil { + return fmt.Errorf("failed to list config parameters for %s %s: %w", datastoreType, datastoreVersion, err) + } + + return vdbclient.OutputWithColumns(cmd, result, paramColumns) +} + +// fetchGroup reads one config group. The ID is a path segment ending in /detail. +func fetchGroup(apiClient *vdbclient.Client, configID string) (map[string]interface{}, error) { + result, err := apiClient.Get(basePath+"/"+configID+"/detail", nil) + if err != nil { + return nil, fmt.Errorf("failed to read config group %s: %w", configID, err) + } + group, ok := vdbclient.PayloadObject(result) + if !ok { + return nil, fmt.Errorf("config group %s not found (the API returned an empty payload)", configID) + } + return group, nil +} + +func currentValues(apiClient *vdbclient.Client, configID string) (map[string]interface{}, error) { + group, err := fetchGroup(apiClient, configID) + if err != nil { + return nil, err + } + values, _ := group["values"].(map[string]interface{}) + return values, nil +} + +// parseValues turns each name=value into an entry of the request's values map, +// inferring the JSON type — the API's parameter definitions are typed. +func parseValues(specs []string) (map[string]interface{}, error) { + values := map[string]interface{}{} + + for _, spec := range specs { + name, raw, found := strings.Cut(spec, "=") + name = strings.TrimSpace(name) + if !found || name == "" { + return nil, fmt.Errorf("invalid --set %q: expected name=value", spec) + } + if _, duplicate := values[name]; duplicate { + return nil, fmt.Errorf("invalid --set: %q given more than once", name) + } + values[name] = typedValue(strings.TrimSpace(raw)) + } + + if len(values) == 0 { + return nil, fmt.Errorf("no parameters given: pass at least one --set name=value") + } + return values, nil +} + +func typedValue(raw string) interface{} { + if number, err := strconv.ParseInt(raw, 10, 64); err == nil { + return number + } + if number, err := strconv.ParseFloat(raw, 64); err == nil { + return number + } + switch strings.ToLower(raw) { + case "true": + return true + case "false": + return false + } + return raw +} + +// describeValues renders a parameter map sorted, with numbers in plain decimal — +// %v would print a float64 8388608 as 8.388608e+06. +func describeValues(values map[string]interface{}) string { + if len(values) == 0 { + return "(none)" + } + names := make([]string, 0, len(values)) + for name := range values { + names = append(names, name) + } + sort.Strings(names) + + parts := make([]string, 0, len(names)) + for _, name := range names { + parts = append(parts, fmt.Sprintf("%s=%s", name, plainValue(values[name]))) + } + return strings.Join(parts, ", ") +} + +func plainValue(value interface{}) string { + if number, ok := value.(float64); ok { + return strconv.FormatFloat(number, 'f', -1, 64) + } + return fmt.Sprintf("%v", value) +} diff --git a/go/cmd/vdb/memorystore/configuration/configuration.go b/go/cmd/vdb/memorystore/configuration/configuration.go new file mode 100644 index 0000000..24f6eb9 --- /dev/null +++ b/go/cmd/vdb/memorystore/configuration/configuration.go @@ -0,0 +1,76 @@ +// Package configuration holds the MemoryStore config-group commands. +// +// Differences from the relational config-group API: +// +// operation relational memorystore +// get one GET /configurations/id?id=… GET /configurations/{configId}/detail +// delete DELETE /configurations/delete POST /configurations/delete +// create body has deployType has no deployType (Redis has one shape) +// +// The rest — the paginated listing keyed on "content", the params lookup, and the +// update that REPLACES the whole values map — behaves as it does in relational. +package configuration + +import ( + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// ConfigurationCmd is the parent command for MemoryStore config groups. +var ConfigurationCmd = &cobra.Command{ + Use: "configuration", + Short: "Manage MemoryStore config groups", + Long: "Create, inspect, change and delete the config groups that hold Redis " + + "parameters for vDB MemoryStore instances.\n\n" + + "A group is tied to one engine version and can only be attached to matching " + + "instances, with 'grn vdb memorystore instance update-config-group'. Use " + + "'configuration list-params' to see which parameters Redis accepts before setting " + + "them.", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() //nolint:errcheck + }, +} + +const ( + basePath = "/vdb-memory/v1/configurations" + configIDPrefix = "cfg-" +) + +// Completion keys owned by the memorystore catalog package. +const ( + datastoreTypeResource = "vdb:memorystore-datastore-type" + datastoreVersionResource = "vdb:memorystore-datastore-version" + configGroupResource = "vdb:memorystore-config-group" +) + +func init() { + ConfigurationCmd.AddCommand(listCmd) + ConfigurationCmd.AddCommand(getCmd) + ConfigurationCmd.AddCommand(createCmd) + ConfigurationCmd.AddCommand(updateCmd) + ConfigurationCmd.AddCommand(deleteCmd) + ConfigurationCmd.AddCommand(listParamsCmd) +} + +func createClient(cmd *cobra.Command) (*vdbclient.Client, error) { + return vdbclient.BuildClient(cmd) +} + +func requireConfigID(configID string) error { + return vdbclient.RequireIDWithPrefix(configID, "config-id", configIDPrefix, + "Config group IDs start with 'cfg-'; run 'configuration list' to find one") +} + +// listColumns omits deployType, which MemoryStore groups do not carry, and +// instanceCount, which the listing endpoint always answers 0 — verified live on +// 2026-08-13 with a group that had an instance attached. Showing it would tell the user +// a group is unused when it is not; 'configuration get' reports the truth in `instances`. +var listColumns = []string{ + "id", "name", "datastoreName", "datastoreVersionName", + "description", "created", +} + +var paramColumns = []string{ + "name", "type", "min", "max", "modifiable", "restartRequired", "description", +} diff --git a/go/cmd/vdb/memorystore/instance/actions.go b/go/cmd/vdb/memorystore/instance/actions.go new file mode 100644 index 0000000..fc499fd --- /dev/null +++ b/go/cmd/vdb/memorystore/instance/actions.go @@ -0,0 +1,107 @@ +package instance + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// start / stop / reboot differ only in path segment, action value and wording. The +// API's endpoint for stop is /shutdown; the CLI verb is `stop`, which is also what +// conventions_test.go requires --dry-run and --force for. +type action struct { + use string + path string + name string + short string + long string + confirm string // empty means do not prompt +} + +var actions = []action{ + { + use: "start", + path: "/start", + name: "start", + short: "Start a stopped MemoryStore instance", + long: "Start an instance that was stopped with 'instance stop'. No confirmation is " + + "asked.", + }, + { + use: "stop", + path: "/shutdown", + name: "stop", + short: "Stop a MemoryStore instance", + long: "Stop the instance. It keeps its IP and can be restarted with " + + "'instance start'.\n\n" + + "Clients lose their connections immediately, and because Redis is an in-memory " + + "store, anything not persisted by its own snapshot or AOF settings is gone. The " + + "command confirms first. Note the API calls this endpoint 'shutdown'.", + confirm: "Stop MemoryStore instance %s? Clients lose their connections and unpersisted data is lost.", + }, + { + use: "reboot", + path: "/reboot", + name: "reboot", + short: "Reboot a MemoryStore instance", + long: "Restart the instance. Connections are dropped while it restarts.", + confirm: "Reboot MemoryStore instance %s? Open connections will be dropped.", + }, +} + +func newActionCmd(spec action) *cobra.Command { + cmd := &cobra.Command{ + Use: spec.use, + Short: spec.short, + Long: spec.long, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runAction(cmd, spec) + }, + } + + f := cmd.Flags() + f.String("instance-id", "", "MemoryStore instance ID (required)") + f.Bool("dry-run", false, fmt.Sprintf("Print the request that would be sent without %sing", spec.use)) + f.Bool("force", false, "Skip the confirmation prompt") + + cmd.MarkFlagRequired("instance-id") //nolint:errcheck + cmd.RegisterFlagCompletionFunc("instance-id", instanceIDCompletion()) //nolint:errcheck + + return cmd +} + +func runAction(cmd *cobra.Command, spec action) error { + instanceID, _ := cmd.Flags().GetString("instance-id") + if err := requireInstanceID(instanceID); err != nil { + return err + } + + body := vdbclient.ActionBody(vdbclient.ResourceTypeInstance, instanceID, spec.name, nil) + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + vdbclient.PreviewBody(spec.use, fmt.Sprintf("MemoryStore instance %s", instanceID), body) + return nil + } + if spec.confirm != "" && !cli.Confirm(force, fmt.Sprintf(spec.confirm, instanceID)) { + fmt.Println("Aborted.") + return nil + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Post(instancePath(instanceID, spec.path), body) + if err != nil { + return fmt.Errorf("failed to %s MemoryStore instance %s: %w", spec.use, instanceID, err) + } + + return vdbclient.Output(cmd, result) +} diff --git a/go/cmd/vdb/memorystore/instance/completion.go b/go/cmd/vdb/memorystore/instance/completion.go new file mode 100644 index 0000000..378d829 --- /dev/null +++ b/go/cmd/vdb/memorystore/instance/completion.go @@ -0,0 +1,111 @@ +package instance + +import ( + "context" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// Completers live here; each flag is bound to one in the init() of the file that +// DEFINES the flag — init() runs in filename order, so a central binding would +// silently fail for flags defined later. See cmd/vdb/relational/instance/completion.go. + +// InstanceResourceKey is the completion key for a MemoryStore instance ID. It is +// deliberately distinct from "vdb:relational-instance": both products issue "db-…" +// IDs but keep separate listings, so suggesting one product's IDs for the other's +// command would offer values the API rejects. +const InstanceResourceKey = "vdb:memorystore-instance" + +// Keys owned by the memorystore catalog package. +const ( + datastoreTypeResource = "vdb:memorystore-datastore-type" + datastoreVersionResource = "vdb:memorystore-datastore-version" + configGroupResource = "vdb:memorystore-config-group" + flavorResource = "vdb:memorystore-flavor" +) + +// zoneResource and subnetResource are the RELATIONAL keys, reused on purpose: +// MemoryStore has no zones or subnets endpoint of its own — zones and subnets are +// project infrastructure, and the product owner confirmed the relational catalog is +// where they come from. +const ( + zoneResource = "vdb:relational-zone" + subnetResource = "vdb:relational-subnet" +) + +func init() { + cli.RegisterResourceCompleter(InstanceResourceKey, instanceIDCompletion()) +} + +func instanceIDCompletion() cli.CompFunc { + return cli.FlagFromAPI(func(_ context.Context, cmd *cobra.Command) ([]string, error) { + rows, err := listInstances(cmd) + if err != nil { + return nil, err + } + return cli.ExtractIDs(rows, "id"), nil + }) +} + +// listInstances fetches one page of instances for completion. pageSize is capped at +// 100 by the API, so a project with more gets suggestions from the first page. +func listInstances(cmd *cobra.Command) (interface{}, error) { + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return nil, err + } + query := vdbclient.BuildListQuery(vdbclient.ListOptions{Page: 1, PageSize: 100}) + result, err := vdbclient.Get(apiClient, basePath, query) + if err != nil { + return nil, err + } + return vdbclient.Unwrap(result), nil +} + +// statusCompletion suggests the statuses actually present in the project, read from +// the instance listing. +// +// MemoryStore does have a `/database/status` endpoint — the only status enumeration +// in the whole vDB API — but the product owner reported it OUTDATED on 2026-08-13, so +// it is not used at all. Deriving from the listing costs nothing extra here and cannot +// go stale. +func statusCompletion() cli.CompFunc { + return cli.FlagFromAPI(func(_ context.Context, cmd *cobra.Command) ([]string, error) { + rows, err := listInstances(cmd) + if err != nil { + return nil, err + } + return cli.ExtractIDs(rows, "status"), nil + }) +} + +func datastoreTypeCompletion() cli.CompFunc { + return cli.ResourceCompletion(datastoreTypeResource) +} + +func datastoreVersionCompletion() cli.CompFunc { + return cli.ResourceCompletion(datastoreVersionResource) +} + +func configIDCompletion() cli.CompFunc { + return cli.ResourceCompletion(configGroupResource) +} + +// packageIDCompletion needs the engine version to be known: the flavors endpoint takes a +// version, which the completer reads from the same command line. 'create' has that flag, +// so completion works once it is set. 'resize-instance' and 'create-replica' do not — the +// version comes from the existing instance — so they deliberately do not offer it; see +// the note on each. +func packageIDCompletion() cli.CompFunc { + return cli.ResourceCompletion(flavorResource) +} + +func zoneIDCompletion() cli.CompFunc { + return cli.ResourceCompletion(zoneResource) +} + +func subnetCompletion() cli.CompFunc { + return cli.ResourceCompletion(subnetResource) +} diff --git a/go/cmd/vdb/memorystore/instance/create.go b/go/cmd/vdb/memorystore/instance/create.go new file mode 100644 index 0000000..9f81a61 --- /dev/null +++ b/go/cmd/vdb/memorystore/instance/create.go @@ -0,0 +1,228 @@ +package instance + +import ( + "fmt" + "os" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// passwordEnv keeps the Redis master password out of shell history. The relational +// group uses the same variable for its master password. +const passwordEnv = "GRN_VDB_MASTER_PASSWORD" + +const ( + minBackupDuration = 2 + maxBackupDuration = 14 +) + +var createCmd = &cobra.Command{ + Use: "create", + Short: "Create a MemoryStore instance", + Long: "Create a vDB MemoryStore (Redis) instance.\n\n" + + "THIS COSTS MONEY. Creation goes through the order/payment flow: the API answers " + + "with an order (orderId, orderUrl) and builds the instance asynchronously — a " + + "successful response means the order was placed, not that Redis is ready. Poll " + + "'instance get'. Use --dry-run first; --user-type IAM_USER switches from Checkout " + + "to Auto Payment.\n\n" + + "No storage flags: a MemoryStore instance has no volume, its capacity is the " + + "flavor's RAM. --package-id is the numeric flavor id from " + + "'catalog list-flavors'.\n\n" + + "Access is a master password on the instance, not a database user: " + + "--redis-password (or $" + passwordEnv + ") sets it, and it is REQUIRED whenever " + + "--public-access is on. The password is masked in --dry-run output.\n\n" + + "Zones and subnets come from the Relational Database catalog — MemoryStore has " + + "none of its own — so use 'grn vdb relational catalog list-zones' and " + + "'list-subnets'.", + Args: cobra.NoArgs, + RunE: runCreate, +} + +func createFlags(f *pflag.FlagSet) { + f.String("name", "", "Instance name (required)") + f.String("datastore-type", "Redis", "Engine (required; see 'catalog list-datastores')") + f.String("datastore-version", "", "Engine version, e.g. 7.2 (required)") + f.String("package-id", "", "Flavor id, numeric (required; the 'id' column of 'catalog list-flavors')") + f.String("zone-id", "", "Availability zone, e.g. HCM03-1A (required)") + f.String("subnet-ids", "", "Subnet ID(s), comma-separated (required)") + f.String("redis-password", "", "Redis master password (defaults to $"+passwordEnv+")") + f.Bool("redis-password-enabled", false, "Require a master password to connect (implied by --public-access)") + f.String("config-id", "", "Config group ID to attach (see 'catalog list-config-groups')") + f.Bool("public-access", false, "Allow public access — requires a master password") + f.Bool("backup-auto", false, "Enable daily automatic backup") + f.Int("backup-duration", 0, fmt.Sprintf("Backup retention in days, %d-%d (required with --backup-auto)", minBackupDuration, maxBackupDuration)) + f.String("backup-time", "", "Time of day to run the backup, HH:MM (required with --backup-auto)") + f.Bool("poc", false, "Pay with PoC credit (Auto Payment only)") + f.Bool("dry-run", false, "Print the request that would be sent without placing an order") + f.Bool("force", false, "Skip the confirmation prompt") +} + +func init() { + createFlags(createCmd.Flags()) + + for _, required := range []string{"name", "datastore-version", "package-id", "zone-id", "subnet-ids"} { + createCmd.MarkFlagRequired(required) //nolint:errcheck + } + + // Bound here, next to the flags: see the init-order note in completion.go. + createCmd.RegisterFlagCompletionFunc("datastore-type", datastoreTypeCompletion()) //nolint:errcheck + createCmd.RegisterFlagCompletionFunc("datastore-version", datastoreVersionCompletion()) //nolint:errcheck + createCmd.RegisterFlagCompletionFunc("zone-id", zoneIDCompletion()) //nolint:errcheck + createCmd.RegisterFlagCompletionFunc("subnet-ids", subnetCompletion()) //nolint:errcheck + createCmd.RegisterFlagCompletionFunc("config-id", configIDCompletion()) //nolint:errcheck + // The flavor completer reads --datastore-version (and --zone-id when given) off this + // same command line, so it yields nothing until the version is set. + createCmd.RegisterFlagCompletionFunc("package-id", packageIDCompletion()) //nolint:errcheck +} + +func runCreate(cmd *cobra.Command, args []string) error { + body, err := createBody(cmd) + if err != nil { + return err + } + + name, _ := cmd.Flags().GetString("name") + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + vdbclient.PreviewBody("create", fmt.Sprintf("MemoryStore instance %q", name), body) + return nil + } + if !cli.Confirm(force, fmt.Sprintf( + "Create %v %v instance %q? This places a paid order.", + body["datastoreType"], body["datastoreVersion"], name)) { + fmt.Println("Aborted.") + return nil + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Post(paymentPath, body) + if err != nil { + return fmt.Errorf("failed to create MemoryStore instance %q: %w", name, err) + } + + return vdbclient.Output(cmd, result) +} + +// createBody assembles CreateMemDbInstanceRequest. +// +// Compared with the relational create it has no `user`, no `databases`, no +// `volumeType` and no `volumeSize` — Redis has neither users nor a volume — and adds +// the two password fields. +func createBody(cmd *cobra.Command) (map[string]interface{}, error) { + flags := cmd.Flags() + + name, _ := flags.GetString("name") + datastoreType, _ := flags.GetString("datastore-type") + datastoreVersion, _ := flags.GetString("datastore-version") + packageID, _ := flags.GetString("package-id") + zoneID, _ := flags.GetString("zone-id") + subnetIDs, _ := flags.GetString("subnet-ids") + configID, _ := flags.GetString("config-id") + publicAccess, _ := flags.GetBool("public-access") + poc, _ := flags.GetBool("poc") + + subnets := cli.ParseCommaSeparated(subnetIDs) + if len(subnets) == 0 { + return nil, fmt.Errorf("invalid --subnet-ids: at least one subnet ID is required") + } + + body := map[string]interface{}{ + "name": name, + "datastoreType": datastoreType, + "datastoreVersion": datastoreVersion, + "packageId": packageID, + "locateZoneId": zoneID, + "netIds": toInterfaces(subnets), + "publicAccess": publicAccess, + "poc": poc, + } + if configID != "" { + body["configId"] = configID + } + + if err := applyPasswordFlags(flags, body, publicAccess); err != nil { + return nil, err + } + if err := applyBackupFlags(flags, body); err != nil { + return nil, err + } + + return body, nil +} + +// applyPasswordFlags handles the Redis authentication pair and the one cross-field +// rule the API states: public access requires the master password to be enabled. +// Enforcing it here turns a 400 into a message that names the flags. +func applyPasswordFlags(flags *pflag.FlagSet, body map[string]interface{}, publicAccess bool) error { + password, _ := flags.GetString("redis-password") + if password == "" { + password = os.Getenv(passwordEnv) + } + + enabled, _ := flags.GetBool("redis-password-enabled") + // A password given without the flag plainly means "enable it"; and public access + // requires it whether or not the user thought about it. + if password != "" || publicAccess { + enabled = true + } + + if enabled && password == "" { + if publicAccess { + return fmt.Errorf("--public-access requires a master password: pass --redis-password or set $%s", passwordEnv) + } + return fmt.Errorf("--redis-password-enabled requires a password: pass --redis-password or set $%s", passwordEnv) + } + + body["redisPasswordEnabled"] = enabled + if password != "" { + if err := vdbclient.ValidateRedisPassword(password, "redis-password"); err != nil { + return err + } + body["redisPassword"] = password + } + return nil +} + +// applyBackupFlags adds the three backup fields, which the API wants together. +func applyBackupFlags(flags *pflag.FlagSet, body map[string]interface{}) error { + backupAuto, _ := flags.GetBool("backup-auto") + duration, _ := flags.GetInt("backup-duration") + backupTime, _ := flags.GetString("backup-time") + + body["backupAuto"] = backupAuto + + if !backupAuto { + if duration != 0 || backupTime != "" { + return fmt.Errorf("--backup-duration and --backup-time only apply with --backup-auto") + } + return nil + } + if duration < minBackupDuration || duration > maxBackupDuration { + return fmt.Errorf("--backup-auto requires --backup-duration between %d and %d days, got %d", + minBackupDuration, maxBackupDuration, duration) + } + if backupTime == "" { + return fmt.Errorf("--backup-auto requires --backup-time, e.g. --backup-time 02:00") + } + + body["backupDuration"] = duration + body["backupTime"] = backupTime + return nil +} + +func toInterfaces(values []string) []interface{} { + out := make([]interface{}, len(values)) + for i, v := range values { + out[i] = v + } + return out +} diff --git a/go/cmd/vdb/memorystore/instance/create_replica.go b/go/cmd/vdb/memorystore/instance/create_replica.go new file mode 100644 index 0000000..9c61fc1 --- /dev/null +++ b/go/cmd/vdb/memorystore/instance/create_replica.go @@ -0,0 +1,216 @@ +package instance + +import ( + "fmt" + "os" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +var createReplicaCmd = &cobra.Command{ + Use: "create-replica", + Short: "Create a read replica of a MemoryStore instance", + Long: "Create a read replica from an existing instance.\n\n" + + "THIS COSTS MONEY: a replica is a full instance and goes through the same " + + "order/payment flow as 'instance create', asynchronously. Use --dry-run first.\n\n" + + "Every property defaults to the SOURCE instance's — engine, version, flavor, " + + "zone, subnet, public access and backup settings are read from it, so normally " + + "only --instance-id and --name are needed. Any other flag overrides one; " + + "--dry-run shows the resolved request. Engine and version are deliberately not " + + "overridable: a replica must match its source.", + Args: cobra.NoArgs, + RunE: runCreateReplica, +} + +func init() { + replicaFlagsOn(createReplicaCmd.Flags()) + + createReplicaCmd.MarkFlagRequired("instance-id") //nolint:errcheck + createReplicaCmd.MarkFlagRequired("name") //nolint:errcheck + + // Bound here, next to the flags: see the init-order note in completion.go. + createReplicaCmd.RegisterFlagCompletionFunc("instance-id", instanceIDCompletion()) //nolint:errcheck + createReplicaCmd.RegisterFlagCompletionFunc("zone-id", zoneIDCompletion()) //nolint:errcheck + createReplicaCmd.RegisterFlagCompletionFunc("subnet-ids", subnetCompletion()) //nolint:errcheck + createReplicaCmd.RegisterFlagCompletionFunc("config-id", configIDCompletion()) //nolint:errcheck +} + +func runCreateReplica(cmd *cobra.Command, args []string) error { + sourceID, _ := cmd.Flags().GetString("instance-id") + if err := requireInstanceID(sourceID); err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + source, err := fetchInstance(apiClient, sourceID) + if err != nil { + return err + } + + body, err := replicaBody(cmd.Flags(), sourceID, source) + if err != nil { + return err + } + + name, _ := cmd.Flags().GetString("name") + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + vdbclient.PreviewBody("create", fmt.Sprintf("replica %q of %s", name, sourceID), body) + return nil + } + if !cli.Confirm(force, fmt.Sprintf( + "Create replica %q of %s (flavor %v)? This places a paid order.", + name, sourceID, body["packageId"])) { + fmt.Println("Aborted.") + return nil + } + + result, err := apiClient.Post(instancePath(sourceID, "/create-replicas"), body) + if err != nil { + return fmt.Errorf("failed to create replica %q of MemoryStore instance %s: %w", name, sourceID, err) + } + + return vdbclient.Output(cmd, result) +} + +// replicaBody assembles CreateMemDbInstanceReplicaRequest, defaulting every unset +// field to the source. Like the relational version, the flavor is `quotaPackageId` +// on an instance but `packageId` in the request, and an attached config group is only +// reported in the nested `configuration.id`. +func replicaBody(flags *pflag.FlagSet, sourceID string, source map[string]interface{}) (map[string]interface{}, error) { + name, _ := flags.GetString("name") + poc, _ := flags.GetBool("poc") + + body := map[string]interface{}{ + "name": name, + "replicaSourceId": sourceID, + "poc": poc, + "datastoreType": stringField(source, "datastoreType"), + "datastoreVersion": stringField(source, "datastoreVersion"), + "packageId": stringOrDefault(flags, "package-id", stringField(source, "quotaPackageId")), + "locateZoneId": stringOrDefault(flags, "zone-id", stringField(source, "zoneId")), + "publicAccess": boolOrDefault(flags, "public-access", boolField(source, "publicAccess")), + } + + subnets := cli.ParseCommaSeparated(mustString(flags, "subnet-ids")) + if len(subnets) == 0 { + if sourceSubnet := stringField(source, "subnetId"); sourceSubnet != "" { + subnets = []string{sourceSubnet} + } + } + if len(subnets) == 0 { + return nil, fmt.Errorf("could not determine a subnet: the source instance reports none, so pass --subnet-ids") + } + body["netIds"] = toInterfaces(subnets) + + if configID := stringOrDefault(flags, "config-id", sourceConfigID(source)); configID != "" { + body["configId"] = configID + } + + // A replica is a new instance and needs its own password when the source has one + // enabled, since the source's cannot be read back. + if boolField(source, "redisPasswordEnabled") || flags.Changed("redis-password") { + password, _ := flags.GetString("redis-password") + if password == "" { + password = os.Getenv(passwordEnv) + } + if password == "" { + return nil, fmt.Errorf( + "the source instance requires a master password, so the replica needs one too: pass --redis-password or set $%s", + passwordEnv) + } + if err := vdbclient.ValidateRedisPassword(password, "redis-password"); err != nil { + return nil, err + } + body["redisPasswordEnabled"] = true + body["redisPassword"] = password + } else { + body["redisPasswordEnabled"] = false + } + + if err := applyReplicaBackupFlags(flags, source, body); err != nil { + return nil, err + } + + if body["packageId"] == "" { + return nil, fmt.Errorf("could not determine a flavor from the source instance: pass --package-id") + } + return body, nil +} + +func applyReplicaBackupFlags(flags *pflag.FlagSet, source, body map[string]interface{}) error { + backupAuto := boolOrDefault(flags, "backup-auto", boolField(source, "backupAuto")) + body["backupAuto"] = backupAuto + + if !backupAuto { + return nil + } + + duration := intOrDefault(flags, "backup-duration", intField(source, "backupDuration")) + backupTime := stringOrDefault(flags, "backup-time", stringField(source, "backupTime")) + + if duration < minBackupDuration || duration > maxBackupDuration { + return fmt.Errorf("backup retention must be between %d and %d days, got %d: pass --backup-duration", + minBackupDuration, maxBackupDuration, duration) + } + if backupTime == "" { + return fmt.Errorf("automatic backup needs a time of day: pass --backup-time, e.g. --backup-time 02:00") + } + + body["backupDuration"] = duration + body["backupTime"] = backupTime + return nil +} + +// sourceConfigID reads the attached config group. The flat configId is null even +// when one is attached; the nested configuration object is where it appears. +func sourceConfigID(source map[string]interface{}) string { + if configID := stringField(source, "configId"); configID != "" { + return configID + } + if configuration, ok := source["configuration"].(map[string]interface{}); ok { + return stringField(configuration, "id") + } + return "" +} + +// stringOrDefault and friends return the flag when the user set it, otherwise the +// fallback. Changed() rather than emptiness, so an explicit false is an override. + +func stringOrDefault(flags *pflag.FlagSet, name, fallback string) string { + if !flags.Changed(name) { + return fallback + } + value, _ := flags.GetString(name) + return value +} + +func intOrDefault(flags *pflag.FlagSet, name string, fallback int) int { + if !flags.Changed(name) { + return fallback + } + value, _ := flags.GetInt(name) + return value +} + +func boolOrDefault(flags *pflag.FlagSet, name string, fallback bool) bool { + if !flags.Changed(name) { + return fallback + } + value, _ := flags.GetBool(name) + return value +} + +func mustString(flags *pflag.FlagSet, name string) string { + value, _ := flags.GetString(name) + return value +} diff --git a/go/cmd/vdb/memorystore/instance/helpers.go b/go/cmd/vdb/memorystore/instance/helpers.go new file mode 100644 index 0000000..2c87932 --- /dev/null +++ b/go/cmd/vdb/memorystore/instance/helpers.go @@ -0,0 +1,112 @@ +package instance + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/validator" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +const ( + basePath = "/vdb-memory/v1/database-instances" + + // paymentPath is where instances are CREATED — an order, not a POST to the + // collection. + paymentPath = "/vdb-memory/v1/payment/database-instances" + + // instanceIDPrefix is shared with Relational Database: both products issue + // "db-…" IDs, so the prefix cannot tell them apart. It still rejects a cluster + // ("pg-"), a backup ("bk-") or a config group ("cfg-") reaching a path. + instanceIDPrefix = "db-" + + // redisPort is what a MemoryStore instance listens on, used as the default for a + // security rule that names no port. + redisPort = 6379 +) + +// detailPath is get-by-id. **No `/id/` segment** — that is the relational spelling +// and it 404s here. +func detailPath(instanceID string) string { + return basePath + "/" + instanceID +} + +// instancePath builds any per-instance sub-path. +func instancePath(instanceID, suffix string) string { + return basePath + "/" + instanceID + suffix +} + +func validateInstanceID(instanceID string) error { + return validator.ValidateID(instanceID, "instance-id") +} + +// requireInstanceID rejects IDs that belong to another resource. Note it cannot +// distinguish a MemoryStore instance from a Relational Database one — both are +// "db-…" — so a mixed-up ID is caught by the API, not here. +func requireInstanceID(instanceID string) error { + return vdbclient.RequireIDWithPrefix(instanceID, "instance-id", instanceIDPrefix, + "MemoryStore instance IDs start with 'db-'; run 'memorystore instance list' to find one") +} + +// listColumns is the table view of a MemoryStore instance. There is no volume type +// column: Redis capacity comes from the flavor, so ram is the size that matters. +var listColumns = []string{ + "id", "name", "status", "datastoreType", "datastoreVersion", + "vcpus", "ram", "ip", "created", +} + +// historyColumns is the table view of a HistoryResponse entry. It leads with description +// because `action` alone does not say what happened: every settings change, resize and +// config-group attach or detach is recorded as "Update", and only the description +// distinguishes them ("… Change redis password", "… Change flavor from db.s-general-2x4 +// … to db.s-general-4x8", "… Attach config X"). Create and create-replica entries embed +// the whole order cart and run past 200 characters, which is the cost of having the +// column; it is worth paying, since those two rows are the ones a reader can already +// identify from `action`. +// +// updatedTime is left to JSON output: it equalled createdTime on all 57 records seen +// across three instances in both products, so in a table it is 31 characters spent +// repeating the previous column. errorMessage stays — it is the only place a Failed row +// explains itself. +var historyColumns = []string{ + "id", "action", "description", "status", "createdTime", "errorMessage", +} + +// replicaColumns matches the reduced replica payload — 19 fields, no ip or port. +var replicaColumns = []string{ + "id", "name", "status", "datastoreType", "datastoreVersion", + "vcpus", "ram", "created", +} + +func createClient(cmd *cobra.Command) (*vdbclient.Client, error) { + return vdbclient.BuildClient(cmd) +} + +// fetchInstance reads one instance for the commands that need its current values: +// create-replica defaults from it, and update-secrule takes its port from it. +func fetchInstance(apiClient *vdbclient.Client, instanceID string) (map[string]interface{}, error) { + result, err := apiClient.Get(detailPath(instanceID), nil) + if err != nil { + return nil, fmt.Errorf("failed to read MemoryStore instance %s: %w", instanceID, err) + } + instance, ok := vdbclient.PayloadObject(result) + if !ok { + return nil, fmt.Errorf("MemoryStore instance %s not found (the API returned an empty payload)", instanceID) + } + return instance, nil +} + +func stringField(payload map[string]interface{}, key string) string { + value, _ := payload[key].(string) + return value +} + +func intField(payload map[string]interface{}, key string) int { + value, _ := payload[key].(float64) + return int(value) +} + +func boolField(payload map[string]interface{}, key string) bool { + value, _ := payload[key].(bool) + return value +} diff --git a/go/cmd/vdb/memorystore/instance/instance.go b/go/cmd/vdb/memorystore/instance/instance.go new file mode 100644 index 0000000..41b57fb --- /dev/null +++ b/go/cmd/vdb/memorystore/instance/instance.go @@ -0,0 +1,46 @@ +package instance + +import ( + "github.com/spf13/cobra" +) + +// InstanceCmd is the parent command for MemoryStore instance commands. +var InstanceCmd = &cobra.Command{ + Use: "instance", + Short: "Manage MemoryStore instances", + Long: "List, inspect and manage vDB MemoryStore (Redis) instances.\n\n" + + "Two things differ from Relational Database and shape most of these commands: an " + + "instance has no volume — capacity comes from the flavor, so there is no " + + "storage resize — and access is guarded by a master password on the instance " + + "rather than a database user.", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() //nolint:errcheck + }, +} + +func init() { + // Read + InstanceCmd.AddCommand(listCmd) + InstanceCmd.AddCommand(getCmd) + InstanceCmd.AddCommand(listHistoriesCmd) + InstanceCmd.AddCommand(listReplicasCmd) + InstanceCmd.AddCommand(listSecrulesCmd) + + // Lifecycle. Note there is no resize-storage: MemoryStore has no volume. + InstanceCmd.AddCommand(createCmd) + InstanceCmd.AddCommand(resizeInstanceCmd) + InstanceCmd.AddCommand(deleteCmd) + for _, spec := range actions { + InstanceCmd.AddCommand(newActionCmd(spec)) + } + + // Replicas + InstanceCmd.AddCommand(createReplicaCmd) + InstanceCmd.AddCommand(detachReplicaCmd) + + // Configuration + InstanceCmd.AddCommand(updateSettingsCmd) + InstanceCmd.AddCommand(updateConfigGroupCmd) + InstanceCmd.AddCommand(updateSecruleCmd) +} diff --git a/go/cmd/vdb/memorystore/instance/instance_test.go b/go/cmd/vdb/memorystore/instance/instance_test.go new file mode 100644 index 0000000..8553b11 --- /dev/null +++ b/go/cmd/vdb/memorystore/instance/instance_test.go @@ -0,0 +1,442 @@ +package instance + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// validRedisPassword satisfies the product rules the CLI now enforces: 16-128 +// characters, letters/digits/$^_<> only. +const validRedisPassword = "RedisCliTest2026abcd" + +// liveInstance is a captured MemoryStore instance, used to test the defaulting that +// create-replica does and the port that update-secrule picks. +func liveInstance() map[string]interface{} { + return map[string]interface{}{ + "id": "db-dc665cbe-63f9-4f43-8fd9-5586c4ff038b", + "name": "nhontt-test", + "status": "ACTIVE", + "datastoreType": "Redis", + "datastoreVersion": "7.2", + "quotaPackageId": "284", + "zoneId": "HCM03-1B", + "subnetId": "sub-7cc39ad2-a00f-4edd-8e8b-5f5aaa3eeffe", + "port": float64(6379), + "publicAccess": false, + "redisPasswordEnabled": false, + "backupAuto": true, + "backupDuration": float64(2), + "backupTime": "00:00", + "configId": nil, + "configuration": nil, + } +} + +func freshCmd(t *testing.T, define func(*pflag.FlagSet), values map[string]string) *cobra.Command { + t.Helper() + + cmd := &cobra.Command{Use: "test"} + define(cmd.Flags()) + for name, value := range values { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("setting --%s=%q: %v", name, value, err) + } + } + return cmd +} + +// TestPathsDifferFromRelational pins the spellings that a copy from the relational +// package would get wrong. +func TestPathsDifferFromRelational(t *testing.T) { + const id = "db-1234" + + cases := map[string]string{ + // No /id/ segment — that is the relational form. + detailPath(id): "/vdb-memory/v1/database-instances/db-1234", + // Hyphenated, where relational nests these under /update/. + instancePath(id, "/update-setting"): "/vdb-memory/v1/database-instances/db-1234/update-setting", + instancePath(id, "/update-config-group"): "/vdb-memory/v1/database-instances/db-1234/update-config-group", + instancePath(id, "/shutdown"): "/vdb-memory/v1/database-instances/db-1234/shutdown", + instancePath(id, "/secrules"): "/vdb-memory/v1/database-instances/db-1234/secrules", + instancePath(id, "/replicas"): "/vdb-memory/v1/database-instances/db-1234/replicas", + } + for got, want := range cases { + if got != want { + t.Errorf("path = %q, want %q", got, want) + } + } + + if paymentPath != "/vdb-memory/v1/payment/database-instances" { + t.Errorf("paymentPath = %q", paymentPath) + } +} + +// TestNoResizeStorageCommand: MemoryStore has no volume, so a storage resize would be +// a command with no endpoint behind it. +func TestNoResizeStorageCommand(t *testing.T) { + for _, sub := range InstanceCmd.Commands() { + if sub.Name() == "resize-storage" { + t.Error("memorystore has no volume and no resize-storage endpoint; this command should not exist") + } + } + if _, _, err := InstanceCmd.Find([]string{"resize-instance"}); err != nil { + t.Errorf("resize-instance should exist: %v", err) + } +} + +func TestCreateBodyHasNoVolumeAndSetsThePassword(t *testing.T) { + t.Setenv(passwordEnv, "") + + cmd := freshCmd(t, createFlags, map[string]string{ + "name": "my-redis", "datastore-version": "7.2", "package-id": "284", + "zone-id": "HCM03-1B", "subnet-ids": "sub-1", "redis-password": validRedisPassword, + }) + + body, err := createBody(cmd) + if err != nil { + t.Fatalf("createBody: %v", err) + } + + for field, want := range map[string]interface{}{ + "name": "my-redis", + "datastoreType": "Redis", + "datastoreVersion": "7.2", + "packageId": "284", + "locateZoneId": "HCM03-1B", + // A password given without the enable flag means "require it" — otherwise the + // body would set a password and disable it in the same breath. + "redisPasswordEnabled": true, + "redisPassword": validRedisPassword, + "backupAuto": false, + } { + if body[field] != want { + t.Errorf("body[%q] = %v, want %v", field, body[field], want) + } + } + + // Redis has no volume and no database user. + for _, field := range []string{"volumeType", "volumeSize", "user", "databases"} { + if _, present := body[field]; present { + t.Errorf("body carries %q, which MemoryStore has no concept of", field) + } + } +} + +// TestCreateRequiresPasswordForPublicAccess: the API documents that public access +// needs the master password enabled, and rejecting it here names the flags. +func TestCreateRequiresPasswordForPublicAccess(t *testing.T) { + t.Setenv(passwordEnv, "") + + base := map[string]string{ + "name": "r", "datastore-version": "7.2", "package-id": "284", + "zone-id": "Z", "subnet-ids": "sub-1", + } + with := func(extra map[string]string) map[string]string { + flags := map[string]string{} + for k, v := range base { + flags[k] = v + } + for k, v := range extra { + flags[k] = v + } + return flags + } + + if _, err := createBody(freshCmd(t, createFlags, with(map[string]string{"public-access": "true"}))); err == nil { + t.Error("--public-access without a password was accepted") + } + if _, err := createBody(freshCmd(t, createFlags, with(map[string]string{"redis-password-enabled": "true"}))); err == nil { + t.Error("--redis-password-enabled without a password was accepted") + } + + // With a password, public access is fine and the requirement is implied. + body, err := createBody(freshCmd(t, createFlags, with(map[string]string{ + "public-access": "true", "redis-password": validRedisPassword, + }))) + if err != nil { + t.Fatalf("createBody: %v", err) + } + if body["redisPasswordEnabled"] != true || body["publicAccess"] != true { + t.Errorf("body = %v", body) + } + + // No password at all is valid as long as nothing requires one. + body, err = createBody(freshCmd(t, createFlags, base)) + if err != nil { + t.Fatalf("createBody without a password: %v", err) + } + if body["redisPasswordEnabled"] != false { + t.Errorf("redisPasswordEnabled = %v, want false", body["redisPasswordEnabled"]) + } + if _, present := body["redisPassword"]; present { + t.Error("an empty password must not be sent") + } +} + +// TestSettingsBodySetsEditRedisPassword: the API needs that marker alongside any +// change to the password or to whether it is required. It is not a setting, so the CLI +// sets it rather than exposing a flag for it. +func TestSettingsBodySetsEditRedisPassword(t *testing.T) { + t.Setenv(passwordEnv, "") + + body, _, err := settingsBody(freshCmd(t, settingsFlags, map[string]string{"redis-password": validRedisPassword}).Flags(), "db-1", backupsOff) + if err != nil { + t.Fatalf("settingsBody: %v", err) + } + if body["editRedisPassword"] != true { + t.Error("editRedisPassword must accompany a password change") + } + // A password implies the requirement; sending false with a password would + // contradict itself. + if body["redisPasswordEnabled"] != true || body["redisPassword"] != validRedisPassword { + t.Errorf("body = %v", body) + } + if _, present := body["publicAccess"]; present { + t.Error("publicAccess sent although its flag was not given") + } + + // Disabling explicitly is allowed and needs no password. + body, _, err = settingsBody(freshCmd(t, settingsFlags, map[string]string{"redis-password-enabled": "false"}).Flags(), "db-1", backupsOff) + if err != nil { + t.Fatalf("settingsBody disable: %v", err) + } + if body["editRedisPassword"] != true || body["redisPasswordEnabled"] != false { + t.Errorf("body = %v", body) + } + + for name, flags := range map[string]map[string]string{ + "enable without a password": {"redis-password-enabled": "true"}, + "public access with it disabled": {"public-access": "true", "redis-password-enabled": "false"}, + "schedule while backups are off": {"backup-duration": "7"}, + "nothing at all": {}, + } { + if _, _, err := settingsBody(freshCmd(t, settingsFlags, flags).Flags(), "db-1", backupsOff); err == nil { + t.Errorf("%s: accepted, want a rejection", name) + } + } +} + +// backupsOff / backupsOn stand in for the instance record settingsBody reads the current +// backup schedule from. +var ( + backupsOff = map[string]interface{}{"backupAuto": false} + backupsOn = map[string]interface{}{"backupAuto": true, "backupTime": "01:00", "backupDuration": float64(7)} +) + +// TestSettingsBodyAlwaysCarriesTheBackupSchedule guards a live finding: the API answers +// HTTP 500 to any update-setting body without backupAuto, so a password-only or +// public-access-only change must still repeat the instance's own schedule. +func TestSettingsBodyAlwaysCarriesTheBackupSchedule(t *testing.T) { + t.Setenv(passwordEnv, "") + + body, summary, err := settingsBody( + freshCmd(t, settingsFlags, map[string]string{"redis-password": validRedisPassword}).Flags(), "db-1", backupsOn) + if err != nil { + t.Fatalf("settingsBody: %v", err) + } + if body["backupAuto"] != true || body["backupTime"] != "01:00" || body["backupDuration"] != 7 { + t.Errorf("the instance's schedule must be repeated verbatim, body = %v", body) + } + // Repeating it is not a change, so it must not be reported as one. + if strings.Contains(summary, "backup") { + t.Errorf("summary claims a backup change: %q", summary) + } + + // backupAuto false is carried too — the field's presence is what the API needs. + body, _, err = settingsBody( + freshCmd(t, settingsFlags, map[string]string{"redis-password": validRedisPassword}).Flags(), "db-1", backupsOff) + if err != nil { + t.Fatalf("settingsBody: %v", err) + } + if body["backupAuto"] != false { + t.Errorf("backupAuto missing or wrong, body = %v", body) + } + if _, present := body["backupTime"]; present { + t.Error("backupTime sent although backups are off") + } + + // The time alone can be changed while backups are on; retention comes from the + // instance. + body, summary, err = settingsBody( + freshCmd(t, settingsFlags, map[string]string{"backup-time": "05:30"}).Flags(), "db-1", backupsOn) + if err != nil { + t.Fatalf("settingsBody time only: %v", err) + } + if body["backupTime"] != "05:30" || body["backupDuration"] != 7 || body["backupAuto"] != true { + t.Errorf("body = %v", body) + } + if !strings.Contains(summary, "05:30") { + t.Errorf("summary must name the new time: %q", summary) + } +} + +func TestReplicaBodyDefaultsToTheSource(t *testing.T) { + t.Setenv(passwordEnv, "") + + cmd := freshCmd(t, replicaFlagsOn, map[string]string{ + "instance-id": "db-dc665cbe-63f9-4f43-8fd9-5586c4ff038b", "name": "redis-replica", + }) + + body, err := replicaBody(cmd.Flags(), "db-dc665cbe-63f9-4f43-8fd9-5586c4ff038b", liveInstance()) + if err != nil { + t.Fatalf("replicaBody: %v", err) + } + + for field, want := range map[string]interface{}{ + "name": "redis-replica", + "replicaSourceId": "db-dc665cbe-63f9-4f43-8fd9-5586c4ff038b", + "datastoreType": "Redis", + "datastoreVersion": "7.2", + // quotaPackageId on an instance, packageId in the request. + "packageId": "284", + "locateZoneId": "HCM03-1B", + "publicAccess": false, + "redisPasswordEnabled": false, + "backupAuto": true, + } { + if body[field] != want { + t.Errorf("body[%q] = %v, want %v", field, body[field], want) + } + } + if netIDs := body["netIds"].([]interface{}); len(netIDs) != 1 || netIDs[0] != "sub-7cc39ad2-a00f-4edd-8e8b-5f5aaa3eeffe" { + t.Errorf("netIds = %v, want the source's subnet", body["netIds"]) + } + if body["backupDuration"] != 2 || body["backupTime"] != "00:00" { + t.Errorf("backup schedule = %v / %v", body["backupDuration"], body["backupTime"]) + } + // No volume fields anywhere. + for _, field := range []string{"volumeType", "volumeSize"} { + if _, present := body[field]; present { + t.Errorf("body carries %q", field) + } + } +} + +// TestReplicaNeedsItsOwnPassword: a source that requires a password cannot hand its +// own over, so the replica needs one supplied. +func TestReplicaNeedsItsOwnPassword(t *testing.T) { + t.Setenv(passwordEnv, "") + + source := liveInstance() + source["redisPasswordEnabled"] = true + + cmd := freshCmd(t, replicaFlagsOn, map[string]string{"instance-id": "db-1", "name": "r"}) + if _, err := replicaBody(cmd.Flags(), "db-1", source); err == nil { + t.Error("replicaBody accepted a password-protected source without a password") + } + + cmd = freshCmd(t, replicaFlagsOn, map[string]string{ + "instance-id": "db-1", "name": "r", "redis-password": validRedisPassword, + }) + body, err := replicaBody(cmd.Flags(), "db-1", source) + if err != nil { + t.Fatalf("replicaBody: %v", err) + } + if body["redisPasswordEnabled"] != true || body["redisPassword"] != validRedisPassword { + t.Errorf("body = %v", body) + } +} + +// TestDefaultPortOf: Redis listens on 6379, and there is one engine here — unlike +// relational, which needs a per-engine map. +func TestDefaultPortOf(t *testing.T) { + if got := defaultPortOf(liveInstance()); got != 6379 { + t.Errorf("port from the instance = %d, want 6379", got) + } + if got := defaultPortOf(map[string]interface{}{}); got != redisPort { + t.Errorf("fallback port = %d, want %d", got, redisPort) + } +} + +func TestRequireInstanceIDRejectsOtherResources(t *testing.T) { + if err := requireInstanceID("db-dc665cbe-63f9"); err != nil { + t.Errorf("valid instance ID rejected: %v", err) + } + // It cannot tell a relational instance apart — both are "db-…" — but everything + // else is caught. + for _, id := range []string{"pg-2e6f2253", "bk-740f7917", "cfg-b8f60c5c", "", "db id with spaces"} { + if err := requireInstanceID(id); err == nil { + t.Errorf("requireInstanceID(%q) = nil, want an error", id) + } + } +} + +func TestMutatingCommandsAreGated(t *testing.T) { + cmds := []*cobra.Command{ + createCmd, createReplicaCmd, detachReplicaCmd, resizeInstanceCmd, deleteCmd, + updateSettingsCmd, updateConfigGroupCmd, updateSecruleCmd, + } + for _, spec := range actions { + cmds = append(cmds, newActionCmd(spec)) + } + + for _, cmd := range cmds { + for _, flag := range []string{"dry-run", "force"} { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("%s must define --%s", cmd.Name(), flag) + } + } + } +} + +func TestFlagCompletionsAreRegistered(t *testing.T) { + cases := []struct { + cmd *cobra.Command + flags []string + }{ + {listCmd, []string{"status"}}, + {getCmd, []string{"instance-id"}}, + {listHistoriesCmd, []string{"instance-id"}}, + {listReplicasCmd, []string{"instance-id"}}, + {listSecrulesCmd, []string{"instance-id"}}, + {createCmd, []string{"datastore-type", "datastore-version", "zone-id", "subnet-ids", "config-id"}}, + {createReplicaCmd, []string{"instance-id", "zone-id", "subnet-ids", "config-id"}}, + {resizeInstanceCmd, []string{"instance-id"}}, + {deleteCmd, []string{"instance-id"}}, + {detachReplicaCmd, []string{"instance-id"}}, + {updateSettingsCmd, []string{"instance-id"}}, + {updateConfigGroupCmd, []string{"instance-id", "config-id"}}, + {updateSecruleCmd, []string{"instance-id"}}, + } + + for _, c := range cases { + for _, flag := range c.flags { + if c.cmd.Flags().Lookup(flag) == nil { + t.Errorf("%s has no --%s flag", c.cmd.Name(), flag) + continue + } + if _, ok := c.cmd.GetFlagCompletionFunc(flag); !ok { + t.Errorf("%s --%s has no completion function registered", c.cmd.Name(), flag) + } + } + } +} + +// TestHistoryColumnsCarryDescription: `action` is "Update" for every settings change, +// resize and config-group attach or detach, so a history table without description cannot +// tell them apart — which is why it leads the column set. +func TestHistoryColumnsCarryDescription(t *testing.T) { + var hasDescription, hasUpdated bool + for _, column := range historyColumns { + switch column { + case "description": + hasDescription = true + case "updatedTime": + hasUpdated = true + } + } + if !hasDescription { + t.Errorf("history table has no description column: %v", historyColumns) + } + // updatedTime duplicated createdTime on every record observed live. If that ever + // changes this is the place to reconsider, rather than a silent re-add. + if hasUpdated { + t.Errorf("updatedTime is JSON-only, it repeats createdTime: %v", historyColumns) + } + if historyColumns[1] != "action" || historyColumns[2] != "description" { + t.Errorf("description must follow action so a row reads as one phrase: %v", historyColumns) + } +} diff --git a/go/cmd/vdb/memorystore/instance/lifecycle.go b/go/cmd/vdb/memorystore/instance/lifecycle.go new file mode 100644 index 0000000..65b7046 --- /dev/null +++ b/go/cmd/vdb/memorystore/instance/lifecycle.go @@ -0,0 +1,244 @@ +package instance + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +var resizeInstanceCmd = &cobra.Command{ + Use: "resize-instance", + Short: "Change the flavor (vCPU/RAM) of a MemoryStore instance", + Long: "Move an instance to a different flavor.\n\n" + + "THIS COSTS MONEY: order/payment flow, asynchronous, and Redis restarts on the " + + "new flavor. Use --dry-run first.\n\n" + + "This is the only resize MemoryStore has: an instance holds its data in the " + + "flavor's RAM and has no volume, so there is no storage resize. Growing capacity " + + "means a larger flavor.", + Args: cobra.NoArgs, + RunE: runResizeInstance, +} + +var deleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete a MemoryStore instance", + Long: "Delete an instance and, optionally, its backups.\n\n" + + "This is irreversible. --create-final-backup takes one last backup first; " + + "--delete-all-backup also removes the existing ones, which is the only way to " + + "lose the ability to restore.", + Args: cobra.NoArgs, + RunE: runDelete, +} + +var detachReplicaCmd = &cobra.Command{ + Use: "detach-replica", + Short: "Promote a read replica to a standalone instance", + Long: "Detach a read replica from its source, turning it into an independent " + + "instance.\n\n" + + "Pass the REPLICA's ID, not the source's — run 'instance list-replicas' on the " + + "source to find it. Replication stops and cannot be re-established; the replica " + + "carries on as a normal instance and keeps costing what it costs.", + Args: cobra.NoArgs, + RunE: runDetachReplica, +} + +func init() { + r := resizeInstanceCmd.Flags() + r.String("instance-id", "", "MemoryStore instance ID (required)") + r.String("package-id", "", "New flavor id, numeric (required; the 'id' column of 'catalog list-flavors')") + r.Bool("poc", false, "Pay with PoC credit (Auto Payment only)") + r.Bool("dry-run", false, "Print the request that would be sent without placing an order") + r.Bool("force", false, "Skip the confirmation prompt") + resizeInstanceCmd.MarkFlagRequired("instance-id") //nolint:errcheck + resizeInstanceCmd.MarkFlagRequired("package-id") //nolint:errcheck + + d := deleteCmd.Flags() + d.String("instance-id", "", "MemoryStore instance ID (required)") + d.Bool("create-final-backup", false, "Take a final backup before deleting") + d.Bool("delete-all-backup", false, "Also delete every existing backup of this instance") + d.Bool("dry-run", false, "Print the request that would be sent without deleting") + d.Bool("force", false, "Skip the confirmation prompt") + deleteCmd.MarkFlagRequired("instance-id") //nolint:errcheck + + t := detachReplicaCmd.Flags() + t.String("instance-id", "", "ID of the replica to detach (required)") + t.Bool("dry-run", false, "Print the request that would be sent without detaching") + t.Bool("force", false, "Skip the confirmation prompt") + detachReplicaCmd.MarkFlagRequired("instance-id") //nolint:errcheck + + // Bound here, next to the flags: see the init-order note in completion.go. + for _, cmd := range []*cobra.Command{resizeInstanceCmd, deleteCmd, detachReplicaCmd} { + cmd.RegisterFlagCompletionFunc("instance-id", instanceIDCompletion()) //nolint:errcheck + } +} + +func runResizeInstance(cmd *cobra.Command, args []string) error { + instanceID, _ := cmd.Flags().GetString("instance-id") + if err := requireInstanceID(instanceID); err != nil { + return err + } + + packageID, _ := cmd.Flags().GetString("package-id") + poc, _ := cmd.Flags().GetBool("poc") + + // ResizeBody, not ActionBody: the resize endpoints spell the resource-type field + // "resourceType" where the action endpoints use "resType". + body := vdbclient.ResizeBody(vdbclient.ResourceTypeInstance, instanceID, "resize", + map[string]interface{}{ + "packageId": packageID, + "poc": poc, + }) + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + vdbclient.PreviewBody("resize", fmt.Sprintf("MemoryStore instance %s", instanceID), body) + return nil + } + if !cli.Confirm(force, fmt.Sprintf( + "Move MemoryStore instance %s to flavor %s? This places a paid order and restarts Redis.", + instanceID, packageID)) { + fmt.Println("Aborted.") + return nil + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Post(instancePath(instanceID, "/resize-instance"), body) + if err != nil { + return fmt.Errorf("failed to resize MemoryStore instance %s: %w", instanceID, err) + } + + return vdbclient.Output(cmd, result) +} + +func runDelete(cmd *cobra.Command, args []string) error { + instanceID, _ := cmd.Flags().GetString("instance-id") + if err := requireInstanceID(instanceID); err != nil { + return err + } + + createFinalBackup, _ := cmd.Flags().GetBool("create-final-backup") + deleteAllBackup, _ := cmd.Flags().GetBool("delete-all-backup") + + body := vdbclient.ActionBody(vdbclient.ResourceTypeInstance, instanceID, "delete", + map[string]interface{}{ + "createFinalBackup": createFinalBackup, + "deleteAllBackup": deleteAllBackup, + }) + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + printDeleteTarget(apiClient, instanceID) + + if dryRun { + vdbclient.PreviewBody("delete", fmt.Sprintf("MemoryStore instance %s", instanceID), body) + return nil + } + + prompt := fmt.Sprintf("Delete MemoryStore instance %s? This cannot be undone.", instanceID) + if deleteAllBackup { + prompt = fmt.Sprintf( + "Delete MemoryStore instance %s AND every backup of it? This cannot be undone and leaves nothing to restore from.", + instanceID) + } + if !cli.Confirm(force, prompt) { + fmt.Println("Aborted.") + return nil + } + + result, err := apiClient.Post(instancePath(instanceID, "/delete"), body) + if err != nil { + return fmt.Errorf("failed to delete MemoryStore instance %s: %w", instanceID, err) + } + + return vdbclient.Output(cmd, result) +} + +func runDetachReplica(cmd *cobra.Command, args []string) error { + instanceID, _ := cmd.Flags().GetString("instance-id") + if err := requireInstanceID(instanceID); err != nil { + return err + } + + // detach_replica, with an underscore — the only action in the family that is not + // a single word. + body := vdbclient.ActionBody(vdbclient.ResourceTypeInstance, instanceID, "detach_replica", nil) + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + vdbclient.PreviewBody("detach", fmt.Sprintf("replica %s", instanceID), body) + return nil + } + if !cli.Confirm(force, fmt.Sprintf( + "Detach replica %s from its source? Replication cannot be re-established.", instanceID)) { + fmt.Println("Aborted.") + return nil + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Post(instancePath(instanceID, "/detach-replica"), body) + if err != nil { + return fmt.Errorf("failed to detach replica %s: %w", instanceID, err) + } + + return vdbclient.Output(cmd, result) +} + +func printDeleteTarget(apiClient *vdbclient.Client, instanceID string) { + instance, err := fetchInstance(apiClient, instanceID) + if err != nil { + fmt.Printf("Warning: could not read MemoryStore instance %s before deleting (%v).\n", instanceID, err) + return + } + + fmt.Println("The following MemoryStore instance will be deleted:") + fmt.Println() + fmt.Printf(" ID: %v\n", instance["id"]) + fmt.Printf(" Name: %v\n", instance["name"]) + fmt.Printf(" Status: %v\n", instance["status"]) + fmt.Printf(" Engine: %v %v\n", instance["datastoreType"], instance["datastoreVersion"]) + fmt.Printf(" Flavor: %v vCPU / %v GB RAM\n", instance["vcpus"], instance["ram"]) + if replicas, ok := instance["replicas"].([]interface{}); ok && len(replicas) > 0 { + fmt.Printf(" Replicas: %d — delete or detach them first if the API refuses\n", len(replicas)) + } + fmt.Println() +} + +// replicaFlagsOn is shared by create-replica; kept here so the flag set can be built +// for tests without touching the mounted command. +func replicaFlagsOn(f *pflag.FlagSet) { + f.String("instance-id", "", "ID of the source instance to replicate (required)") + f.String("name", "", "Name of the new replica (required)") + f.String("package-id", "", "Flavor id for the replica (default: same as the source)") + f.String("zone-id", "", "Availability zone (default: same as the source)") + f.String("subnet-ids", "", "Subnet ID(s), comma-separated (default: the source's subnet)") + f.String("config-id", "", "Config group ID to attach (default: the source's, if any)") + f.Bool("public-access", false, "Allow public access to the replica (default: same as the source)") + f.Bool("backup-auto", false, "Enable daily automatic backup (default: same as the source)") + f.Int("backup-duration", 0, fmt.Sprintf("Backup retention in days, %d-%d", minBackupDuration, maxBackupDuration)) + f.String("backup-time", "", "Time of day to run the backup, HH:MM") + f.String("redis-password", "", "Redis master password for the replica (defaults to $"+passwordEnv+")") + f.Bool("poc", false, "Pay with PoC credit (Auto Payment only)") + f.Bool("dry-run", false, "Print the request that would be sent without placing an order") + f.Bool("force", false, "Skip the confirmation prompt") +} diff --git a/go/cmd/vdb/memorystore/instance/read.go b/go/cmd/vdb/memorystore/instance/read.go new file mode 100644 index 0000000..e5d2e4a --- /dev/null +++ b/go/cmd/vdb/memorystore/instance/read.go @@ -0,0 +1,163 @@ +package instance + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List MemoryStore instances", + Long: "List vDB MemoryStore (Redis) instances.\n\n" + + "Results are paginated; --page is 1-based. --name and --status filter " + + "server-side. Unlike the Relational Database listing this one returns only " + + "MemoryStore instances — no PostgreSQL Clusters mixed in.", + Args: cobra.NoArgs, + RunE: runList, +} + +var getCmd = &cobra.Command{ + Use: "get", + Short: "Get details of a MemoryStore instance", + Long: "Show the full details of one MemoryStore instance.\n\n" + + "Note for anyone porting code: the path here is /database-instances/{id}, " + + "without the /id/ segment the Relational Database API uses.", + Args: cobra.NoArgs, + RunE: runGet, +} + +var listHistoriesCmd = &cobra.Command{ + Use: "list-histories", + Short: "List the action history of a MemoryStore instance", + Long: "List the recorded actions of an instance with their status and, for failures, " + + "the error message.\n\n" + + "Results are paginated; --page is 1-based and no filters are accepted. Table " + + "output omits description, which embeds the whole order cart — use --output json.", + Args: cobra.NoArgs, + RunE: runListHistories, +} + +var listReplicasCmd = &cobra.Command{ + Use: "list-replicas", + Short: "List the read replicas of a MemoryStore instance", + Long: "List the replicas created from an instance.\n\n" + + "The API answers with a reduced view of each replica — no ip or port — so run " + + "'instance get' on a replica ID for its full details.", + Args: cobra.NoArgs, + RunE: runListReplicas, +} + +func init() { + l := listCmd.Flags() + l.Int("page", 1, "Page number (1-based)") + l.Int("page-size", vdbclient.DefaultPageSize, "Number of items per page") + l.String("name", "", "Filter by instance name") + l.String("status", "", "Filter by status (comma-separated for multiple values)") + listCmd.RegisterFlagCompletionFunc("status", statusCompletion()) //nolint:errcheck + + for _, cmd := range []*cobra.Command{getCmd, listHistoriesCmd, listReplicasCmd} { + cmd.Flags().String("instance-id", "", "MemoryStore instance ID (required)") + cmd.MarkFlagRequired("instance-id") //nolint:errcheck + cmd.RegisterFlagCompletionFunc("instance-id", instanceIDCompletion()) //nolint:errcheck + } + + h := listHistoriesCmd.Flags() + h.Int("page", 1, "Page number (1-based)") + h.Int("page-size", vdbclient.DefaultPageSize, "Number of items per page") +} + +// listOptions maps the list flags onto the shared query. MemoryStore honours the +// flattened name/status filters, same as relational. +func listOptions(cmd *cobra.Command) vdbclient.ListOptions { + page, _ := cmd.Flags().GetInt("page") + pageSize, _ := cmd.Flags().GetInt("page-size") + name, _ := cmd.Flags().GetString("name") + status, _ := cmd.Flags().GetString("status") + + return vdbclient.ListOptions{ + Page: page, + PageSize: pageSize, + Name: name, + Statuses: cli.ParseCommaSeparated(status), + } +} + +func runList(cmd *cobra.Command, args []string) error { + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := vdbclient.Get(apiClient, basePath, vdbclient.BuildListQuery(listOptions(cmd))) + if err != nil { + return fmt.Errorf("failed to list MemoryStore instances: %w", err) + } + + return vdbclient.OutputWithColumns(cmd, result, listColumns) +} + +func runGet(cmd *cobra.Command, args []string) error { + instanceID, _ := cmd.Flags().GetString("instance-id") + if err := validateInstanceID(instanceID); err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Get(detailPath(instanceID), nil) + if err != nil { + return fmt.Errorf("failed to get MemoryStore instance %s: %w", instanceID, err) + } + + // Detail payloads nest several arrays (ip, securityGroup, replicas), so print + // key/value rather than letting table extraction pick one at random. + return vdbclient.Output(cmd, result) +} + +func runListHistories(cmd *cobra.Command, args []string) error { + instanceID, _ := cmd.Flags().GetString("instance-id") + if err := validateInstanceID(instanceID); err != nil { + return err + } + + page, _ := cmd.Flags().GetInt("page") + pageSize, _ := cmd.Flags().GetInt("page-size") + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + query := vdbclient.BuildListQuery(vdbclient.ListOptions{Page: page, PageSize: pageSize}) + result, err := vdbclient.Get(apiClient, instancePath(instanceID, "/histories"), query) + if err != nil { + return fmt.Errorf("failed to list history of MemoryStore instance %s: %w", instanceID, err) + } + + return vdbclient.OutputWithColumns(cmd, result, historyColumns) +} + +func runListReplicas(cmd *cobra.Command, args []string) error { + instanceID, _ := cmd.Flags().GetString("instance-id") + if err := validateInstanceID(instanceID); err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Get(instancePath(instanceID, "/replicas"), nil) + if err != nil { + return fmt.Errorf("failed to list replicas of MemoryStore instance %s: %w", instanceID, err) + } + + return vdbclient.OutputWithColumns(cmd, result, replicaColumns) +} diff --git a/go/cmd/vdb/memorystore/instance/secrules.go b/go/cmd/vdb/memorystore/instance/secrules.go new file mode 100644 index 0000000..9cda8d3 --- /dev/null +++ b/go/cmd/vdb/memorystore/instance/secrules.go @@ -0,0 +1,151 @@ +package instance + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +var listSecrulesCmd = &cobra.Command{ + Use: "list-secrules", + Short: "List the security rules of a MemoryStore instance", + Long: "List the security group rules controlling access to an instance.\n\n" + + "Use 'instance update-secrule' to change them — note that update REPLACES the " + + "whole rule set, so start from this listing.", + Args: cobra.NoArgs, + RunE: runListSecrules, +} + +var updateSecruleCmd = &cobra.Command{ + Use: "update-secrule", + Short: "Replace the security rules of a MemoryStore instance", + Long: "Set which networks may reach an instance.\n\n" + + "The API REPLACES the entire rule set with what is sent, so by default the rules " + + "you pass become the only rules — anything currently allowed and not repeated is " + + "revoked. Pass --add to keep the existing rules and append instead.\n\n" + + "Each --rule is a comma-separated list of key=value pairs:\n" + + " cidr the allowed network, e.g. cidr=203.0.113.0/24 (required)\n" + + " port single port; defaults to the instance's own port (6379)\n" + + " port-min low end of a port range (use with port-max)\n" + + " port-max high end of a port range\n" + + " id existing rule ID, to keep a rule you are re-sending\n\n" + + "Repeat --rule for each rule. Run 'instance list-secrules' first and --dry-run to " + + "see exactly what would be sent; locking yourself out is the failure mode here.", + Args: cobra.NoArgs, + RunE: runUpdateSecrule, +} + +func init() { + l := listSecrulesCmd.Flags() + l.String("instance-id", "", "MemoryStore instance ID (required)") + listSecrulesCmd.MarkFlagRequired("instance-id") //nolint:errcheck + listSecrulesCmd.RegisterFlagCompletionFunc("instance-id", instanceIDCompletion()) //nolint:errcheck + + u := updateSecruleCmd.Flags() + u.String("instance-id", "", "MemoryStore instance ID (required)") + u.StringArray("rule", nil, "Rule as key=value pairs, repeatable (see --help)") + u.Bool("add", false, "Append to the existing rules instead of replacing them") + u.Bool("dry-run", false, "Print the request that would be sent without applying it") + u.Bool("force", false, "Skip the confirmation prompt") + updateSecruleCmd.MarkFlagRequired("instance-id") //nolint:errcheck + updateSecruleCmd.MarkFlagRequired("rule") //nolint:errcheck + updateSecruleCmd.RegisterFlagCompletionFunc("instance-id", instanceIDCompletion()) //nolint:errcheck +} + +func runListSecrules(cmd *cobra.Command, args []string) error { + instanceID, _ := cmd.Flags().GetString("instance-id") + if err := validateInstanceID(instanceID); err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := fetchSecrules(apiClient, instanceID) + if err != nil { + return err + } + + return vdbclient.OutputWithColumns(cmd, result, vdbclient.SecurityRuleColumns) +} + +func runUpdateSecrule(cmd *cobra.Command, args []string) error { + instanceID, _ := cmd.Flags().GetString("instance-id") + if err := requireInstanceID(instanceID); err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + instance, err := fetchInstance(apiClient, instanceID) + if err != nil { + return err + } + + specs, _ := cmd.Flags().GetStringArray("rule") + rules, err := vdbclient.ParseSecurityRules(specs, defaultPortOf(instance)) + if err != nil { + return err + } + + current, err := fetchSecrules(apiClient, instanceID) + if err != nil { + return err + } + existing := vdbclient.SecurityRulesFrom(current) + + add, _ := cmd.Flags().GetBool("add") + if add { + rules = append(existing, rules...) + } + + fmt.Printf("Current rules on %s: %s\n", instanceID, vdbclient.DescribeSecurityRules(existing)) + fmt.Printf("Rules after this change: %s\n", vdbclient.DescribeSecurityRules(rules)) + if !add && len(existing) > 0 { + fmt.Println("\nThis REPLACES the rule set; anything above that is not in the new set is revoked.") + } + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + vdbclient.PreviewBody("update", fmt.Sprintf("the security rules of MemoryStore instance %s", instanceID), rules) + return nil + } + if !cli.Confirm(force, fmt.Sprintf("Apply this rule set to MemoryStore instance %s?", instanceID)) { + fmt.Println("Aborted.") + return nil + } + + result, err := apiClient.Put(instancePath(instanceID, "/secrules"), rules) + if err != nil { + return fmt.Errorf("failed to update the security rules of MemoryStore instance %s: %w", instanceID, err) + } + + return vdbclient.OutputWithColumns(cmd, result, vdbclient.SecurityRuleColumns) +} + +func fetchSecrules(apiClient *vdbclient.Client, instanceID string) (interface{}, error) { + result, err := apiClient.Get(instancePath(instanceID, "/secrules"), nil) + if err != nil { + return nil, fmt.Errorf("failed to list the security rules of MemoryStore instance %s: %w", instanceID, err) + } + return result, nil +} + +// defaultPortOf is the port a rule gets when it names none: the instance's own port, +// falling back to Redis's 6379. There is only one engine here, so no per-engine map +// is needed — unlike relational, which serves MySQL (3306) and PostgreSQL (5432). +func defaultPortOf(instance map[string]interface{}) int { + if port := intField(instance, "port"); port > 0 { + return port + } + return redisPort +} diff --git a/go/cmd/vdb/memorystore/instance/update.go b/go/cmd/vdb/memorystore/instance/update.go new file mode 100644 index 0000000..108c2f1 --- /dev/null +++ b/go/cmd/vdb/memorystore/instance/update.go @@ -0,0 +1,350 @@ +package instance + +import ( + "fmt" + "os" + "strings" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +var updateSettingsCmd = &cobra.Command{ + Use: "update-settings", + Short: "Update the master password, public access or backup schedule of an instance", + Long: "Change a MemoryStore instance's Redis master password, its public-access " + + "setting or its automatic-backup schedule.\n\n" + + "Only the settings you pass change; the one exception is the backup schedule, " + + "which the API rejects a request without (HTTP 500), so the instance's current " + + "schedule is read and repeated for you. The password is read from $" + passwordEnv + + " when --redis-password is omitted, and is masked in --dry-run output.\n\n" + + "--backup-time and --backup-duration can therefore be changed on their own, " + + "as long as automatic backup is on.\n\n" + + "Two API rules are enforced here rather than left to a 400: public access " + + "requires the master password to be enabled, and any change to the password or to " + + "whether it is required must travel with editRedisPassword — a flag the CLI sets " + + "for you.\n\n" + + "The change is ASYNCHRONOUS and its response body carries nothing useful, so this " + + "command reports acceptance and points you at 'instance get'.", + Args: cobra.NoArgs, + RunE: runUpdateSettings, +} + +var updateConfigGroupCmd = &cobra.Command{ + Use: "update-config-group", + Short: "Attach or detach the config group of a MemoryStore instance", + Long: "Attach a config group to an instance, or detach the current one with " + + "--detach.\n\n" + + "The group must match the instance's engine and version — see " + + "'catalog list-config-groups'. Applying parameters can restart Redis, so the " + + "command confirms first. Asynchronous, like update-settings.", + Args: cobra.NoArgs, + RunE: runUpdateConfigGroup, +} + +func settingsFlags(f *pflag.FlagSet) { + f.String("instance-id", "", "MemoryStore instance ID (required)") + f.String("redis-password", "", "New Redis master password (defaults to $"+passwordEnv+")") + f.Bool("redis-password-enabled", false, "Whether a master password is required to connect") + f.Bool("public-access", false, "Whether the instance is reachable publicly") + f.Bool("backup-auto", false, "Whether daily automatic backup is enabled") + f.Int("backup-duration", 0, fmt.Sprintf("Backup retention in days, %d-%d (with --backup-auto)", minBackupDuration, maxBackupDuration)) + f.String("backup-time", "", "Time of day to run the backup, HH:MM (with --backup-auto)") + f.Bool("dry-run", false, "Print the request that would be sent without applying it") + f.Bool("force", false, "Skip the confirmation prompt") +} + +func init() { + settingsFlags(updateSettingsCmd.Flags()) + updateSettingsCmd.MarkFlagRequired("instance-id") //nolint:errcheck + + c := updateConfigGroupCmd.Flags() + c.String("instance-id", "", "MemoryStore instance ID (required)") + c.String("config-id", "", "Config group ID to attach (required unless --detach)") + c.Bool("detach", false, "Detach the current config group instead of attaching one") + c.Bool("dry-run", false, "Print the request that would be sent without applying it") + c.Bool("force", false, "Skip the confirmation prompt") + updateConfigGroupCmd.MarkFlagRequired("instance-id") //nolint:errcheck + updateConfigGroupCmd.MarkFlagsMutuallyExclusive("config-id", "detach") + + // Bound here, next to the flags: see the init-order note in completion.go. + updateSettingsCmd.RegisterFlagCompletionFunc("instance-id", instanceIDCompletion()) //nolint:errcheck + updateConfigGroupCmd.RegisterFlagCompletionFunc("instance-id", instanceIDCompletion()) //nolint:errcheck + updateConfigGroupCmd.RegisterFlagCompletionFunc("config-id", configIDCompletion()) //nolint:errcheck +} + +func runUpdateSettings(cmd *cobra.Command, args []string) error { + instanceID, _ := cmd.Flags().GetString("instance-id") + if err := requireInstanceID(instanceID); err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + // The instance is read up front, even for --dry-run: the request needs its current + // backup schedule (see settingsBody). + current, err := fetchInstance(apiClient, instanceID) + if err != nil { + return err + } + + body, summary, err := settingsBody(cmd.Flags(), instanceID, current) + if err != nil { + return err + } + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + previewAsyncUpdate("update", fmt.Sprintf("the settings of MemoryStore instance %s", instanceID), body) + return nil + } + if !cli.Confirm(force, fmt.Sprintf("Update %s on MemoryStore instance %s?", summary, instanceID)) { + fmt.Println("Aborted.") + return nil + } + + warnIfNotActive(apiClient, instanceID) + + // Hyphenated here; the relational API nests the same operation under /update/. + if _, err := apiClient.Put(instancePath(instanceID, "/update-setting"), body); err != nil { + return fmt.Errorf("failed to update the settings of MemoryStore instance %s: %w", instanceID, err) + } + + reportAsyncAccepted(instanceID, summary) + return nil +} + +// settingsBody assembles UpdateMemDbSettingRequest from the flags the user set. +// +// Two Redis-specific rules live here: +// +// - editRedisPassword must be true whenever redisPasswordEnabled or redisPassword +// changes. It is a "I meant to touch the password" marker, not a setting, so the +// CLI sets it rather than exposing it. +// - publicAccess requires the password to be enabled, so asking for public access +// without one is refused before the request goes out. +// +// The backup schedule is always sent, taken from `current` when the user names none of +// the backup flags: a body without backupAuto is answered with HTTP 500 (verified live +// on 2026-08-13 — password-only and password+publicAccess bodies both failed, the same +// bodies with backupAuto succeeded). Repeating the instance's own schedule keeps the +// call a no-op for backups while satisfying the API. +func settingsBody(flags *pflag.FlagSet, instanceID string, current map[string]interface{}) (map[string]interface{}, string, error) { + body := map[string]interface{}{"dbInstanceId": instanceID} + var changes []string + + password, _ := flags.GetString("redis-password") + if password == "" { + password = os.Getenv(passwordEnv) + } + passwordEnabledChanged := flags.Changed("redis-password-enabled") + passwordEnabled, _ := flags.GetBool("redis-password-enabled") + + publicAccessChanged := flags.Changed("public-access") + publicAccess, _ := flags.GetBool("public-access") + + // Two implications, both so the body cannot contradict itself: + // - giving a password means "require it" (otherwise we would send a new password + // alongside redisPasswordEnabled false); + // - turning public access on means "require it" too, which the API demands. + // Either can still be overridden by passing --redis-password-enabled explicitly. + if !passwordEnabledChanged && (password != "" || (publicAccessChanged && publicAccess)) { + passwordEnabled = true + passwordEnabledChanged = true + } + + if password != "" || passwordEnabledChanged { + if passwordEnabled && password == "" { + return nil, "", fmt.Errorf( + "enabling the master password needs one: pass --redis-password or set $%s", passwordEnv) + } + if publicAccessChanged && publicAccess && !passwordEnabled { + return nil, "", fmt.Errorf("--public-access requires the master password to stay enabled") + } + + // The marker the API demands alongside any password change. + body["editRedisPassword"] = true + body["redisPasswordEnabled"] = passwordEnabled + if password != "" { + if err := vdbclient.ValidateRedisPassword(password, "redis-password"); err != nil { + return nil, "", err + } + body["redisPassword"] = password + } + + if password != "" { + changes = append(changes, "the master password") + } else { + changes = append(changes, fmt.Sprintf("password requirement (-> %t)", passwordEnabled)) + } + } + + if publicAccessChanged { + body["publicAccess"] = publicAccess + changes = append(changes, fmt.Sprintf("public access (-> %t)", publicAccess)) + } + + // Start from the instance's own schedule and overwrite only what the user named, so + // backupAuto is always present without changing anything the user did not ask to + // change. + backupAuto := boolField(current, "backupAuto") + backupTime := stringField(current, "backupTime") + duration := intField(current, "backupDuration") + scheduleChanged := false + + if flags.Changed("backup-auto") { + backupAuto, _ = flags.GetBool("backup-auto") + scheduleChanged = true + } + if flags.Changed("backup-time") { + backupTime, _ = flags.GetString("backup-time") + scheduleChanged = true + } + if flags.Changed("backup-duration") { + duration, _ = flags.GetInt("backup-duration") + scheduleChanged = true + } + + if !backupAuto && (flags.Changed("backup-time") || flags.Changed("backup-duration")) { + return nil, "", fmt.Errorf("--backup-time and --backup-duration apply only while automatic backup is on: add --backup-auto") + } + + body["backupAuto"] = backupAuto + if backupAuto { + if duration < minBackupDuration || duration > maxBackupDuration { + return nil, "", fmt.Errorf("--backup-duration must be between %d and %d days, got %d", + minBackupDuration, maxBackupDuration, duration) + } + if backupTime == "" { + return nil, "", fmt.Errorf("automatic backup needs a time of day: pass --backup-time, e.g. --backup-time 02:00") + } + body["backupDuration"] = duration + body["backupTime"] = backupTime + } + + if scheduleChanged { + // Name the schedule: "automatic backup (-> true)" alone hides the time and + // retention just set, and the response body carries nothing to check it against. + if backupAuto { + changes = append(changes, fmt.Sprintf("automatic backup (-> on, %s, %d days)", backupTime, duration)) + } else { + changes = append(changes, "automatic backup (-> off)") + } + } + + if len(changes) == 0 { + return nil, "", fmt.Errorf( + "nothing to update: pass --redis-password (or set $%s), --redis-password-enabled, --public-access or --backup-auto", + passwordEnv) + } + + summary := changes[0] + for _, change := range changes[1:] { + summary += " and " + change + } + return body, summary, nil +} + +func runUpdateConfigGroup(cmd *cobra.Command, args []string) error { + instanceID, _ := cmd.Flags().GetString("instance-id") + if err := requireInstanceID(instanceID); err != nil { + return err + } + + configID, _ := cmd.Flags().GetString("config-id") + detach, _ := cmd.Flags().GetBool("detach") + + if !detach && configID == "" { + return fmt.Errorf("pass --config-id to attach a config group, or --detach to remove the current one") + } + + // Detach sends null, not "". The relational endpoint rejects the empty string + // with "The configId doesn't exist" (verified live) while null works; MemoryStore + // shares the request schema, so it gets the same treatment — and unlike the + // PostgreSQL Cluster endpoint, which does accept "". + body := map[string]interface{}{"dbInstanceId": instanceID} + if detach { + body["configId"] = nil + } else { + body["configId"] = configID + } + + summary := fmt.Sprintf("attach config group %s", configID) + if detach { + summary = "detach the config group" + } + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + previewAsyncUpdate("update", fmt.Sprintf("the config group of MemoryStore instance %s", instanceID), body) + return nil + } + if !cli.Confirm(force, fmt.Sprintf("%s on MemoryStore instance %s? This may restart Redis.", + capitalize(summary), instanceID)) { + fmt.Println("Aborted.") + return nil + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + warnIfNotActive(apiClient, instanceID) + + if _, err := apiClient.Put(instancePath(instanceID, "/update-config-group"), body); err != nil { + return fmt.Errorf("failed to update the config group of MemoryStore instance %s: %w", instanceID, err) + } + + reportAsyncAccepted(instanceID, summary) + return nil +} + +func capitalize(s string) string { + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +// The two update endpoints are asynchronous and their response payload is +// meaningless — see the relational equivalents in +// cmd/vdb/relational/instance/async.go for the measurements behind this. + +func previewAsyncUpdate(verb, target string, body map[string]interface{}) { + vdbclient.PreviewBody(verb, target, body) + fmt.Println("\nThe API applies this in the background; poll 'instance get' to confirm the result.") +} + +func reportAsyncAccepted(instanceID, summary string) { + fmt.Printf("Accepted: %s on MemoryStore instance %s.\n", summary, instanceID) + fmt.Println("The API applies this asynchronously — run the following to confirm:") + fmt.Printf("\n grn vdb memorystore instance get --instance-id %s\n", instanceID) +} + +// warnIfNotActive mirrors the relational guard: while an instance is +// RESTART_REQUIRED these endpoints answer 202 and silently drop the change. +func warnIfNotActive(apiClient *vdbclient.Client, instanceID string) { + instance, err := fetchInstance(apiClient, instanceID) + if err != nil { + return + } + status := stringField(instance, "status") + if strings.EqualFold(status, "ACTIVE") { + return + } + + fmt.Printf("Warning: MemoryStore instance %s is %s, not ACTIVE.\n", instanceID, status) + fmt.Println("The API may accept this change and silently not apply it — this is known to") + fmt.Println("happen while an instance is RESTART_REQUIRED. Consider 'instance reboot' first.") + fmt.Println() +} diff --git a/go/cmd/vdb/memorystore/memorystore.go b/go/cmd/vdb/memorystore/memorystore.go new file mode 100644 index 0000000..c1f30b2 --- /dev/null +++ b/go/cmd/vdb/memorystore/memorystore.go @@ -0,0 +1,57 @@ +// Package memorystore is the command root for vDB MemoryStore (Redis). +// +// MemoryStore has the same 5 nouns as Relational Database and almost none of the +// same paths. The differences are not cosmetic — see cmd/vdb/CLAUDE.md for the full +// table — but the ones that bite hardest: +// +// - get instance is `/database-instances/{id}`, with no `/id/` segment. +// - `update-setting` and `update-config-group` are hyphenated, where relational +// nests them under `/update/`. +// - the catalog lives under `/database/*`, not `/database-instances/*`. +// - deleting a backup or a config group is a POST with no ID in the path. +// - `GET /backup-storages` lists what you OWN here, while in relational the same +// path lists what you can BUY. +// +// Redis-specific behaviour, which has no relational equivalent: +// +// - There is no volume: an instance takes no volume type or size, and there is no +// resize-storage command. Only the flavor is resized. +// - Authentication is a master password on the instance (`redisPasswordEnabled` + +// `redisPassword`), not a user/database pair. Public access requires it. +// - Changing that password needs `editRedisPassword: true` alongside it. +package memorystore + +import ( + "github.com/greennodehub/greennode-cli/cmd/vdb/memorystore/backup" + "github.com/greennodehub/greennode-cli/cmd/vdb/memorystore/backupstorage" + "github.com/greennodehub/greennode-cli/cmd/vdb/memorystore/catalog" + "github.com/greennodehub/greennode-cli/cmd/vdb/memorystore/configuration" + "github.com/greennodehub/greennode-cli/cmd/vdb/memorystore/instance" + "github.com/spf13/cobra" +) + +// MemorystoreCmd groups the MemoryStore commands. +var MemorystoreCmd = &cobra.Command{ + Use: "memorystore", + Short: "Manage MemoryStore (Redis) resources", + Long: "Manage vDB MemoryStore instances, their backups, config groups and backup " + + "storage.\n\n" + + "MemoryStore is Redis. An instance has no volume — its capacity comes from the " + + "flavor — so there is no storage resize, and access is controlled by a master " + + "password rather than a database user.\n\n" + + "Instance IDs start with 'db-', the same prefix Relational Database uses: the two " + + "products keep separate listings, so use 'memorystore instance list' to find IDs " + + "for these commands.", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() //nolint:errcheck + }, +} + +func init() { + MemorystoreCmd.AddCommand(instance.InstanceCmd) + MemorystoreCmd.AddCommand(catalog.CatalogCmd) + MemorystoreCmd.AddCommand(backup.BackupCmd) + MemorystoreCmd.AddCommand(configuration.ConfigurationCmd) + MemorystoreCmd.AddCommand(backupstorage.BackupStorageCmd) +} diff --git a/go/cmd/vdb/memorystore/paths_test.go b/go/cmd/vdb/memorystore/paths_test.go new file mode 100644 index 0000000..cd865df --- /dev/null +++ b/go/cmd/vdb/memorystore/paths_test.go @@ -0,0 +1,70 @@ +package memorystore + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" +) + +// TestGroupIsComplete: the five nouns, and every command under them, so a dropped +// registration shows up as a test failure rather than a missing command. +func TestGroupIsComplete(t *testing.T) { + want := map[string][]string{ + "instance": { + "list", "get", "list-histories", "list-replicas", "list-secrules", + "create", "resize-instance", "delete", "start", "stop", "reboot", + "create-replica", "detach-replica", + "update-settings", "update-config-group", "update-secrule", + }, + "catalog": { + "list-engines", "list-datastores", "list-families", "list-flavor-codes", + "list-flavors", "list-networks", "list-subnets", "list-volume-types", + "list-config-groups", + }, + "backup": {"list", "get", "create", "delete", "restore", "get-free-storage"}, + "configuration": {"list", "get", "create", "update", "delete", "list-params"}, + "backup-storage": {"list", "list-packages", "create", "resize", "delete"}, + } + + for noun, commands := range want { + group, _, err := MemorystoreCmd.Find([]string{noun}) + if err != nil || group.Name() != noun { + t.Errorf("noun %q is not registered", noun) + continue + } + present := map[string]bool{} + for _, sub := range group.Commands() { + present[sub.Name()] = true + } + for _, name := range commands { + if !present[name] { + t.Errorf("memorystore %s %s is missing", noun, name) + } + } + } + + // The status listing deliberately has no command: the product owner reported the + // endpoint outdated (2026-08-13). + catalog, _, _ := MemorystoreCmd.Find([]string{"catalog"}) + for _, sub := range catalog.Commands() { + if strings.Contains(sub.Name(), "status") { + t.Errorf("catalog %s exists, but /database/status is outdated and must not be used", sub.Name()) + } + } +} + +// TestEveryLeafHasHelp mirrors the repo-wide convention test, scoped to this group so +// a new command cannot land without a Short. +func TestEveryLeafHasHelp(t *testing.T) { + var walk func(*cobra.Command) + walk = func(c *cobra.Command) { + for _, sub := range c.Commands() { + if sub.Runnable() && len(sub.Commands()) == 0 && strings.TrimSpace(sub.Short) == "" { + t.Errorf("%s has no Short description", sub.CommandPath()) + } + walk(sub) + } + } + walk(MemorystoreCmd) +} diff --git a/go/cmd/vdb/postgresql/backup/backup.go b/go/cmd/vdb/postgresql/backup/backup.go new file mode 100644 index 0000000..aef8bcf --- /dev/null +++ b/go/cmd/vdb/postgresql/backup/backup.go @@ -0,0 +1,66 @@ +// Package backup holds the PostgreSQL Cluster backup commands. +// +// These are the cluster product's own endpoints under /vdb-postgresql/v1/backup +// and they are NOT shaped like the relational ones. The most important +// difference: every path parameter here is a CLUSTER id, not a backup id — +// ".../backup-vdb/{clusterId}/detail" means "the backup record of this cluster", +// where relational's "/backups/detail/{backupId}" means "this backup". Restore +// points are the individual snapshots. +package backup + +import ( + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// BackupCmd is the parent command for PostgreSQL Cluster backup commands. +var BackupCmd = &cobra.Command{ + Use: "backup", + Short: "Manage PostgreSQL Cluster backups", + Long: "Inspect the backup record of a vDB PostgreSQL Cluster, list its restore " + + "points and take an on-demand backup.\n\n" + + "A cluster has one backup record (its policy, location and total size) holding " + + "many restore points. Scheduled backups come from the policy chosen at creation; " + + "'backup create' takes one immediately.", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() //nolint:errcheck + }, +} + +const ( + backupBase = "/vdb-postgresql/v1/backup/backup-vdb" +) + +// clusterPath builds a path whose parameter is a CLUSTER id. +func clusterPath(clusterID, suffix string) string { + return backupBase + "/" + clusterID + suffix +} + +func init() { + BackupCmd.AddCommand(listCmd) + BackupCmd.AddCommand(getCmd) + BackupCmd.AddCommand(listRestorePointsCmd) + BackupCmd.AddCommand(createCmd) +} + +func createClient(cmd *cobra.Command) (*vdbclient.Client, error) { + return vdbclient.BuildClient(cmd) +} + +// backupColumns is the table view of a BackupDatabase record. databaseId is the +// cluster the record belongs to. +// +// backupPolicyName and backupDestinationName are deliberately absent: the API +// leaves both null and puts the real values in the nested policy / +// backupDestination objects, which table output cannot reach into. Use +// --output json, or --query 'policy.name', for those. +var backupColumns = []string{ + "id", "name", "databaseId", "status", "backupEnabled", + "totalBackupSize", "latestRecord", "createdAt", +} + +var restorePointColumns = []string{ + "id", "backupName", "status", "time", "engineVersion", + "compressedSize", "uncompressedSize", "createdAt", +} diff --git a/go/cmd/vdb/postgresql/backup/backup_test.go b/go/cmd/vdb/postgresql/backup/backup_test.go new file mode 100644 index 0000000..841d74d --- /dev/null +++ b/go/cmd/vdb/postgresql/backup/backup_test.go @@ -0,0 +1,65 @@ +package backup + +import ( + "testing" + + "github.com/spf13/cobra" +) + +// TestClusterPathTakesAClusterID pins the shape that is easiest to get wrong in +// this package: the path parameter is the CLUSTER, not the backup. Relational's +// equivalent ("/backups/detail/{backupId}") means the opposite. +func TestClusterPathTakesAClusterID(t *testing.T) { + cases := map[string]string{ + "/detail": "/vdb-postgresql/v1/backup/backup-vdb/pg-1/detail", + "/restore-point": "/vdb-postgresql/v1/backup/backup-vdb/pg-1/restore-point", + "/backup-now": "/vdb-postgresql/v1/backup/backup-vdb/pg-1/backup-now", + } + + for suffix, want := range cases { + if got := clusterPath("pg-1", suffix); got != want { + t.Errorf("clusterPath(%q) = %q, want %q", suffix, got, want) + } + } +} + +func TestRequireClusterIDRejectsInstanceIDs(t *testing.T) { + if err := requireClusterID("pg-2e6f2253-9032-466f-975f-d6d6b6ec8330"); err != nil { + t.Errorf("valid cluster ID rejected: %v", err) + } + for _, id := range []string{"db-66a37ca3-e688-4a8a-9e35-a89fa69733c6", "bk-db-1", ""} { + if err := requireClusterID(id); err == nil { + t.Errorf("requireClusterID(%q) = nil, want an error", id) + } + } +} + +func TestCommandsAreWired(t *testing.T) { + want := map[string]bool{"list": true, "get": true, "list-restore-points": true, "create": true} + for _, sub := range BackupCmd.Commands() { + delete(want, sub.Name()) + } + for name := range want { + t.Errorf("backup %s is not registered", name) + } + + // Taking a backup consumes billable backup storage, so it is gated like the + // other mutating vdb commands. + for _, flag := range []string{"dry-run", "force"} { + if createCmd.Flags().Lookup(flag) == nil { + t.Errorf("backup create must define --%s", flag) + } + } +} + +func TestFlagCompletionsAreRegistered(t *testing.T) { + for _, cmd := range []*cobra.Command{getCmd, listRestorePointsCmd, createCmd} { + if cmd.Flags().Lookup("cluster-id") == nil { + t.Errorf("%s has no --cluster-id flag", cmd.Name()) + continue + } + if _, ok := cmd.GetFlagCompletionFunc("cluster-id"); !ok { + t.Errorf("%s --cluster-id has no completion function registered", cmd.Name()) + } + } +} diff --git a/go/cmd/vdb/postgresql/backup/create.go b/go/cmd/vdb/postgresql/backup/create.go new file mode 100644 index 0000000..2b48bbc --- /dev/null +++ b/go/cmd/vdb/postgresql/backup/create.go @@ -0,0 +1,71 @@ +package backup + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +var createCmd = &cobra.Command{ + Use: "create", + Short: "Take an on-demand backup of a PostgreSQL Cluster", + Long: "Take a backup of a cluster now, in addition to whatever its policy schedules.\n\n" + + "The backup is stored in the cluster's backup location and counts towards its " + + "backup storage, which is chargeable beyond the free allowance. The API takes no " + + "parameters — the location, policy and retention come from the cluster's backup " + + "record ('backup get').", + Args: cobra.NoArgs, + RunE: runCreate, +} + +func init() { + f := createCmd.Flags() + f.String("cluster-id", "", "PostgreSQL Cluster ID (required)") + f.Bool("dry-run", false, "Print the request that would be sent without taking a backup") + f.Bool("force", false, "Skip the confirmation prompt") + + createCmd.MarkFlagRequired("cluster-id") //nolint:errcheck + + // Bound here, next to the flag: see the init-order note in + // cmd/vdb/relational/instance/completion.go. + createCmd.RegisterFlagCompletionFunc("cluster-id", cli.ResourceCompletion(clusterResourceKey)) //nolint:errcheck +} + +func runCreate(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + if err := requireClusterID(clusterID); err != nil { + return err + } + + path := clusterPath(clusterID, "/backup-now") + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + fmt.Println("=== DRY RUN ===") + fmt.Printf("Would POST %s (no request body).\n", path) + cli.DryRunNotice("take the backup") + return nil + } + if !cli.Confirm(force, fmt.Sprintf( + "Take a backup of PostgreSQL Cluster %s now? It counts towards billable backup storage.", clusterID)) { + fmt.Println("Aborted.") + return nil + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + // The endpoint takes no body; the API answers with a plain string inside the + // envelope rather than an object. + result, err := apiClient.Post(path, nil) + if err != nil { + return fmt.Errorf("failed to back up PostgreSQL Cluster %s: %w", clusterID, err) + } + + return vdbclient.Output(cmd, result) +} diff --git a/go/cmd/vdb/postgresql/backup/get.go b/go/cmd/vdb/postgresql/backup/get.go new file mode 100644 index 0000000..5950314 --- /dev/null +++ b/go/cmd/vdb/postgresql/backup/get.go @@ -0,0 +1,51 @@ +package backup + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +var getCmd = &cobra.Command{ + Use: "get", + Short: "Get the backup record of a PostgreSQL Cluster", + Long: "Show one cluster's backup record: whether backup is enabled, which policy " + + "and location it uses, the latest record and the total size.\n\n" + + "The flag is --cluster-id, not a backup ID: this API keys backups by the cluster " + + "they belong to.", + Args: cobra.NoArgs, + RunE: runGet, +} + +func init() { + f := getCmd.Flags() + f.String("cluster-id", "", "PostgreSQL Cluster ID (required)") + getCmd.MarkFlagRequired("cluster-id") //nolint:errcheck + + // Bound here, next to the flag: see the init-order note in + // cmd/vdb/relational/instance/completion.go. + getCmd.RegisterFlagCompletionFunc("cluster-id", cli.ResourceCompletion(clusterResourceKey)) //nolint:errcheck +} + +func runGet(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + if err := requireClusterID(clusterID); err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Get(clusterPath(clusterID, "/detail"), nil) + if err != nil { + return fmt.Errorf("failed to get the backup record of PostgreSQL Cluster %s: %w", clusterID, err) + } + + // A backup record nests backupDestination and policy objects, so print it as + // key/value rather than picking a nested array at random. + return vdbclient.Output(cmd, result) +} diff --git a/go/cmd/vdb/postgresql/backup/helpers.go b/go/cmd/vdb/postgresql/backup/helpers.go new file mode 100644 index 0000000..8f2e4cf --- /dev/null +++ b/go/cmd/vdb/postgresql/backup/helpers.go @@ -0,0 +1,19 @@ +package backup + +import ( + "github.com/greennodehub/greennode-cli/internal/vdbclient" +) + +const ( + clusterIDPrefix = "pg-" + + // clusterResourceKey is the completion key registered by the cluster package. + // Consumers use the literal, as vks does for the vserver keys, so no package + // dependency is needed just to share a constant. + clusterResourceKey = "vdb:postgresql-cluster" +) + +func requireClusterID(clusterID string) error { + return vdbclient.RequireIDWithPrefix(clusterID, "cluster-id", clusterIDPrefix, + "PostgreSQL Cluster IDs start with 'pg-'; backups of Relational Database instances live under 'grn vdb relational'") +} diff --git a/go/cmd/vdb/postgresql/backup/list.go b/go/cmd/vdb/postgresql/backup/list.go new file mode 100644 index 0000000..b63a1cc --- /dev/null +++ b/go/cmd/vdb/postgresql/backup/list.go @@ -0,0 +1,33 @@ +package backup + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List the backup records of every PostgreSQL Cluster", + Long: "List the backup record of each cluster in the project: its policy, its " + + "location and how much space its backups take.\n\n" + + "This endpoint takes no pagination or filters — it returns the whole list. Use " + + "'backup list-restore-points' for the individual snapshots of one cluster.", + Args: cobra.NoArgs, + RunE: runList, +} + +func runList(cmd *cobra.Command, args []string) error { + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Get(backupBase, nil) + if err != nil { + return fmt.Errorf("failed to list cluster backups: %w", err) + } + + return vdbclient.OutputWithColumns(cmd, result, backupColumns) +} diff --git a/go/cmd/vdb/postgresql/backup/list_restore_points.go b/go/cmd/vdb/postgresql/backup/list_restore_points.go new file mode 100644 index 0000000..a092aff --- /dev/null +++ b/go/cmd/vdb/postgresql/backup/list_restore_points.go @@ -0,0 +1,49 @@ +package backup + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +var listRestorePointsCmd = &cobra.Command{ + Use: "list-restore-points", + Short: "List the restore points of a PostgreSQL Cluster", + Long: "List the individual snapshots taken for a cluster, with their size and the " + + "point in time they restore to.\n\n" + + "A restore point ID ('bk-db-pt-...') is what --backup-point-id expects on " + + "'cluster create' when creating a cluster from a backup.", + Args: cobra.NoArgs, + RunE: runListRestorePoints, +} + +func init() { + f := listRestorePointsCmd.Flags() + f.String("cluster-id", "", "PostgreSQL Cluster ID (required)") + listRestorePointsCmd.MarkFlagRequired("cluster-id") //nolint:errcheck + + // Bound here, next to the flag: see the init-order note in + // cmd/vdb/relational/instance/completion.go. + listRestorePointsCmd.RegisterFlagCompletionFunc("cluster-id", cli.ResourceCompletion(clusterResourceKey)) //nolint:errcheck +} + +func runListRestorePoints(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + if err := requireClusterID(clusterID); err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Get(clusterPath(clusterID, "/restore-point"), nil) + if err != nil { + return fmt.Errorf("failed to list restore points of PostgreSQL Cluster %s: %w", clusterID, err) + } + + return vdbclient.OutputWithColumns(cmd, result, restorePointColumns) +} diff --git a/go/cmd/vdb/postgresql/catalog/catalog.go b/go/cmd/vdb/postgresql/catalog/catalog.go new file mode 100644 index 0000000..ef7a0cc --- /dev/null +++ b/go/cmd/vdb/postgresql/catalog/catalog.go @@ -0,0 +1,185 @@ +// Package catalog holds the read-only lookups a PostgreSQL Cluster is built +// from: datastore versions, flavors, volume types and the vBackup location and +// policy lists. +// +// These are separate endpoints from the relational catalog and the values are NOT +// interchangeable — a cluster flavor ID looks like "pgp-...", a cluster volume +// type like "pgst-...", and the spec says each "can only be compatible with a +// PostgreSQL Cluster". Never feed a relational catalog value into a cluster +// create. +package catalog + +import ( + "fmt" + "net/url" + "strings" + + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// CatalogCmd is the parent command for the PostgreSQL Cluster lookups. +var CatalogCmd = &cobra.Command{ + Use: "catalog", + Short: "Look up PostgreSQL Cluster versions, flavors, volume types and backup options", + Long: "Read-only lists of what a PostgreSQL Cluster can be built from: datastore " + + "versions, flavors, volume types, and the backup locations and policies its " + + "backups are stored under.\n\n" + + "These values are specific to the cluster product — flavor IDs start with " + + "'pgp-' and volume type IDs with 'pgst-'. The relational catalog " + + "('grn vdb relational catalog') lists different, incompatible values.", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() //nolint:errcheck + }, +} + +const ( + pgBase = "/vdb-postgresql/v1" + datastorePath = pgBase + "/cluster/datastore" + flavorsPath = pgBase + "/cluster/flavors" + volumeTypesPath = pgBase + "/cluster/volume-types" + locationsPath = pgBase + "/backup/location" + policiesPath = pgBase + "/backup/policy" + + // configGroupsPath is relational: the cluster product has no config-group + // endpoint of its own, so config groups are listed there and filtered to the + // 'cluster' deploy type, which is what a cluster's --config-id accepts. + configGroupsPath = "/vdb-relational/v1/configurations" +) + +// simpleList describes a lookup that needs no input beyond an optional zone. +type simpleList struct { + use string + short string + long string + path string + byZone bool // offer --zone-id, sent as ?zoneId= + multiZone bool // offer --multi-zone, sent as ?multiZone=true — the values a Multi-AZ cluster can use + columns []string +} + +var simpleLists = []simpleList{ + { + use: "list-datastores", + short: "List PostgreSQL Cluster versions", + long: "List the PostgreSQL versions a cluster can be created with. The 'version' " + + "value is what --datastore-version expects on 'cluster create'.", + path: datastorePath, + columns: []string{"type", "version", "name", "versionName", "licenseName"}, + }, + { + use: "list-flavors", + short: "List PostgreSQL Cluster flavors", + long: "List the flavors (vCPU/RAM packages) available to a cluster. The 'id' " + + "('pgp-...') is what --package-id expects on 'cluster create' and " + + "'cluster resize'. With --multi-zone, only the flavors a Multi-AZ cluster " + + "can use — the ones compatible with a create that passes several " + + "--subnet-ids, one per zone.", + path: flavorsPath, + byZone: true, + multiZone: true, + columns: []string{"id", "name", "vcpus", "ram", "platformType", "backupSize", "status", "locateZoneId"}, + }, + { + use: "list-volume-types", + short: "List PostgreSQL Cluster volume types", + long: "List the volume types available to a cluster, with their size limits and " + + "provisioned IOPS. The 'id' ('pgst-...') is what --volume-type-id expects. " + + "With --multi-zone, only the volume types a Multi-AZ cluster can use — the " + + "ones compatible with a create that passes several --subnet-ids, one per " + + "zone.", + path: volumeTypesPath, + byZone: true, + multiZone: true, + columns: []string{"id", "type", "name", "minVolumeSize", "maxVolumeSize", "iops", "status", "zoneId"}, + }, + { + use: "list-backup-locations", + short: "List backup locations", + long: "List the vBackup locations a cluster's backups can be stored in. The 'id' " + + "('bk-des-...') is what --backup-location-id expects on 'cluster create'.", + path: locationsPath, + columns: []string{"id", "name", "type", "product", "status", "isDefault", "numberOfBackupInstances"}, + }, + { + use: "list-backup-policies", + short: "List backup policies", + long: "List the vBackup policies a cluster's backups can follow. The 'id' " + + "('bk-pol-...') is what --backup-policy-id expects on 'cluster create'. The " + + "schedule and retention live in the nested config object — use --output json.", + path: policiesPath, + columns: []string{"id", "name", "product", "isDefault", "backupInstanceCount", "createdAt"}, + }, +} + +func newSimpleList(spec simpleList) *cobra.Command { + cmd := &cobra.Command{ + Use: spec.use, + Short: spec.short, + Long: spec.long, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if err := get(cmd, spec.path, catalogQuery(cmd, spec.multiZone), spec.columns); err != nil { + return fmt.Errorf("failed to %s: %w", strings.ReplaceAll(spec.use, "-", " "), err) + } + return nil + }, + } + if spec.byZone { + cmd.Flags().String("zone-id", "", "Restrict results to one availability zone (e.g. HCM03-1A)") + // Bound here rather than in a central function: the flag only exists on the + // specs that ask for it, so this is the one place it is guaranteed to be defined. + cmd.RegisterFlagCompletionFunc("zone-id", zoneIDCompletion()) //nolint:errcheck + } + if spec.multiZone { + cmd.Flags().Bool("multi-zone", false, "List only the values a Multi-AZ cluster can use (see 'cluster create --subnet-ids')") + } + return cmd +} + +func init() { + for _, spec := range simpleLists { + CatalogCmd.AddCommand(newSimpleList(spec)) + } + CatalogCmd.AddCommand(listConfigGroupsCmd) +} + +func createClient(cmd *cobra.Command) (*vdbclient.Client, error) { + return vdbclient.BuildClient(cmd) +} + +func get(cmd *cobra.Command, path string, query url.Values, columns []string) error { + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := vdbclient.Get(apiClient, path, query) + if err != nil { + return err + } + + return vdbclient.OutputWithColumns(cmd, result, columns) +} + +func zoneQuery(cmd *cobra.Command) url.Values { + query := url.Values{} + if zoneID, _ := cmd.Flags().GetString("zone-id"); zoneID != "" { + query.Set("zoneId", zoneID) + } + return query +} + +// catalogQuery adds the params a lookup takes beyond the zone: ?multiZone=true, +// only when --multi-zone is set. It is sent set or absent, never false — the API +// applies its own default when the param is missing. +func catalogQuery(cmd *cobra.Command, multiZone bool) url.Values { + query := zoneQuery(cmd) + if multiZone { + if set, _ := cmd.Flags().GetBool("multi-zone"); set { + query.Set("multiZone", "true") + } + } + return query +} diff --git a/go/cmd/vdb/postgresql/catalog/catalog_test.go b/go/cmd/vdb/postgresql/catalog/catalog_test.go new file mode 100644 index 0000000..1e208dd --- /dev/null +++ b/go/cmd/vdb/postgresql/catalog/catalog_test.go @@ -0,0 +1,130 @@ +package catalog + +import ( + "strings" + "testing" +) + +func TestSimpleListsAreWired(t *testing.T) { + byName := map[string]bool{} + for _, sub := range CatalogCmd.Commands() { + byName[sub.Name()] = true + } + + for _, spec := range simpleLists { + if !byName[spec.use] { + t.Errorf("%q is declared but not registered under catalog", spec.use) + continue + } + if spec.short == "" || spec.long == "" { + t.Errorf("%q is missing help text", spec.use) + } + if len(spec.columns) == 0 { + t.Errorf("%q has no columns", spec.use) + } + if !strings.HasPrefix(spec.path, pgBase) { + t.Errorf("%q path = %q, want it under %q", spec.use, spec.path, pgBase) + } + + cmd, _, err := CatalogCmd.Find([]string{spec.use}) + if err != nil { + t.Errorf("finding %q: %v", spec.use, err) + continue + } + if hasZone := cmd.Flags().Lookup("zone-id") != nil; hasZone != spec.byZone { + t.Errorf("%q has --zone-id = %v, want %v", spec.use, hasZone, spec.byZone) + } + if hasMulti := cmd.Flags().Lookup("multi-zone") != nil; hasMulti != spec.multiZone { + t.Errorf("%q has --multi-zone = %v, want %v", spec.use, hasMulti, spec.multiZone) + } + } +} + +// TestMultiZoneQuery: --multi-zone becomes ?multiZone=true, set only when the flag is +// given — the API applies its own default when the param is absent, so an explicit +// false would silently mean the same thing as true if the two ever disagreed. +func TestMultiZoneQuery(t *testing.T) { + cmd := CatalogCmd + flavors, _, err := cmd.Find([]string{"list-flavors"}) + if err != nil { + t.Fatalf("finding list-flavors: %v", err) + } + + if err := flavors.Flags().Set("multi-zone", "true"); err != nil { + t.Fatalf("setting --multi-zone: %v", err) + } + defer flavors.Flags().Set("multi-zone", "false") //nolint:errcheck + + query := catalogQuery(flavors, true) + if got := query.Get("multiZone"); got != "true" { + t.Errorf("multiZone = %q, want %q", got, "true") + } + + // An unset flag sends nothing, and a spec without the flag never reads it even + // though the command carries one from an earlier call. + query = catalogQuery(flavors, false) + if got := query.Get("multiZone"); got != "" { + t.Errorf("multiZone = %q, want absent", got) + } + + flavors.Flags().Set("multi-zone", "false") //nolint:errcheck + query = catalogQuery(flavors, true) + if got := query.Get("multiZone"); got != "" { + t.Errorf("multiZone = %q with the flag unset, want absent", got) + } +} + +// TestConfigGroupsPathIsRelational documents the one borrowed endpoint in this +// package: the cluster product has no config-group listing, so a cluster's +// --config-id values come from the relational one. +func TestConfigGroupsPathIsRelational(t *testing.T) { + if !strings.HasPrefix(configGroupsPath, "/vdb-relational/") { + t.Errorf("configGroupsPath = %q, want the relational listing", configGroupsPath) + } +} + +// TestKeepClusterGroups: only 'cluster' deploy types can be attached to a +// cluster, and the endpoint has no filter for it. +func TestKeepClusterGroups(t *testing.T) { + payload := map[string]interface{}{ + "content": []interface{}{ + map[string]interface{}{"id": "pg-cfg-1", "deployType": "cluster"}, + map[string]interface{}{"id": "cfg-2", "deployType": "single_node"}, + map[string]interface{}{"id": "cfg-3"}, // no deployType at all + }, + "pageObject": map[string]interface{}{"totalElements": float64(3)}, + } + + result, ok := keepClusterGroups(payload).(map[string]interface{}) + if !ok { + t.Fatalf("keepClusterGroups returned %T", result) + } + + // Items live under "content" here, not "data" — 6 of vdb's 8 paginated + // endpoints key them that way, and filtering the wrong key would silently + // return everything. + kept, ok := result["content"].([]interface{}) + if !ok { + t.Fatalf("content = %T, want a slice", result["content"]) + } + if len(kept) != 1 { + t.Fatalf("kept %d groups, want 1", len(kept)) + } + if kept[0].(map[string]interface{})["id"] != "pg-cfg-1" { + t.Errorf("kept %v, want the cluster config group", kept[0]) + } + if result["pageObject"] == nil { + t.Error("keepClusterGroups dropped pageObject") + } + if len(payload["content"].([]interface{})) != 3 { + t.Error("keepClusterGroups mutated the original payload") + } +} + +func TestListConfigGroupsHasAllEscapeHatch(t *testing.T) { + // Filtering client-side is a guess about intent; --all must be there to see + // everything the endpoint returned. + if listConfigGroupsCmd.Flags().Lookup("all") == nil { + t.Error("list-config-groups must offer --all") + } +} diff --git a/go/cmd/vdb/postgresql/catalog/completion.go b/go/cmd/vdb/postgresql/catalog/completion.go new file mode 100644 index 0000000..28579ab --- /dev/null +++ b/go/cmd/vdb/postgresql/catalog/completion.go @@ -0,0 +1,78 @@ +package catalog + +import ( + "context" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// Completion keys for the cluster-specific values these endpoints own. The +// cluster and backup packages consume them through cli.ResourceCompletion, so +// nothing has to import this package — and registration stays next to the +// endpoint that actually knows the shape. +// +// These are deliberately separate from the relational keys: a cluster flavor +// ('pgp-...') and a relational flavor are not interchangeable, so sharing a key +// would suggest values the API rejects. Zones are the exception — they are +// project-wide, so 'cluster create --zone-id' reuses vdb:relational-zone. +const ( + DatastoreVersionResourceKey = "vdb:postgresql-datastore-version" + FlavorResourceKey = "vdb:postgresql-flavor" + VolumeTypeResourceKey = "vdb:postgresql-volume-type" + ConfigGroupResourceKey = "vdb:postgresql-config-group" + BackupLocationResourceKey = "vdb:postgresql-backup-location" + BackupPolicyResourceKey = "vdb:postgresql-backup-policy" + + // zoneResourceKey is the RELATIONAL key, reused on purpose: zones are project-wide + // and pg has no zones endpoint of its own. + zoneResourceKey = "vdb:relational-zone" +) + +// zoneIDCompletion serves the --zone-id that the zone-aware catalog lookups define. +func zoneIDCompletion() cli.CompFunc { + return cli.ResourceCompletion(zoneResourceKey) +} + +func init() { + cli.RegisterResourceCompleter(DatastoreVersionResourceKey, cli.FlagFromAPI(fieldFrom(datastorePath, "version"))) + cli.RegisterResourceCompleter(FlavorResourceKey, cli.FlagFromAPI(fieldFrom(flavorsPath, "id"))) + cli.RegisterResourceCompleter(VolumeTypeResourceKey, cli.FlagFromAPI(fieldFrom(volumeTypesPath, "id"))) + cli.RegisterResourceCompleter(BackupLocationResourceKey, cli.FlagFromAPI(fieldFrom(locationsPath, "id"))) + cli.RegisterResourceCompleter(BackupPolicyResourceKey, cli.FlagFromAPI(fieldFrom(policiesPath, "id"))) + cli.RegisterResourceCompleter(ConfigGroupResourceKey, cli.FlagFromAPI(clusterConfigGroupIDs)) +} + +// fieldFrom builds a completion fetcher that GETs a catalog path and collects one +// field from every item. Unwrap runs first: the values sit inside the vDB +// {code, message, data} envelope. +func fieldFrom(path, field string) func(context.Context, *cobra.Command) ([]string, error) { + return func(_ context.Context, cmd *cobra.Command) ([]string, error) { + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return nil, err + } + result, err := apiClient.Get(path, nil) + if err != nil { + return nil, err + } + return cli.ExtractIDs(vdbclient.Unwrap(result), field), nil + } +} + +// clusterConfigGroupIDs suggests only config groups a cluster can actually use. +// The listing is the relational one, which has no deployType filter, so the +// filtering happens here — same rule as list-config-groups. +func clusterConfigGroupIDs(_ context.Context, cmd *cobra.Command) ([]string, error) { + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return nil, err + } + query := vdbclient.BuildListQuery(vdbclient.ListOptions{Page: 1, PageSize: 100}) + result, err := vdbclient.Get(apiClient, configGroupsPath, query) + if err != nil { + return nil, err + } + return cli.ExtractIDs(keepClusterGroups(vdbclient.Unwrap(result)), "id"), nil +} diff --git a/go/cmd/vdb/postgresql/catalog/list_config_groups.go b/go/cmd/vdb/postgresql/catalog/list_config_groups.go new file mode 100644 index 0000000..9b4d502 --- /dev/null +++ b/go/cmd/vdb/postgresql/catalog/list_config_groups.go @@ -0,0 +1,92 @@ +package catalog + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// clusterDeployType is the value a config group must carry to be usable by a +// cluster; the spec is explicit that --config-id "can only be compatible with a +// config group with a 'cluster' deploy type". +const clusterDeployType = "cluster" + +var configGroupColumns = []string{ + "id", "name", "datastoreName", "datastoreVersionName", "deployType", "created", +} + +var listConfigGroupsCmd = &cobra.Command{ + Use: "list-config-groups", + Short: "List config groups usable by a PostgreSQL Cluster", + Long: "List the database config groups a cluster can be attached to.\n\n" + + "The cluster product has no config-group endpoint of its own, so this reads the " + + "Relational Database config groups and keeps only those with deployType " + + "'cluster' — the only ones --config-id accepts. Pass --all to see every config " + + "group, including the single-instance ones.", + Args: cobra.NoArgs, + RunE: runListConfigGroups, +} + +func init() { + f := listConfigGroupsCmd.Flags() + f.Int("page", 1, "Page number (1-based)") + f.Int("page-size", vdbclient.DefaultPageSize, "Number of items per page") + f.Bool("all", false, "Do not filter by deploy type; list single-instance config groups too") +} + +func runListConfigGroups(cmd *cobra.Command, args []string) error { + page, _ := cmd.Flags().GetInt("page") + pageSize, _ := cmd.Flags().GetInt("page-size") + all, _ := cmd.Flags().GetBool("all") + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + query := vdbclient.BuildListQuery(vdbclient.ListOptions{Page: page, PageSize: pageSize}) + result, err := vdbclient.Get(apiClient, configGroupsPath, query) + if err != nil { + return fmt.Errorf("failed to list config groups: %w", err) + } + + payload := vdbclient.Unwrap(result) + if !all { + payload = keepClusterGroups(payload) + } + + return vdbclient.OutputWithColumns(cmd, payload, configGroupColumns) +} + +// keepClusterGroups filters the paginated payload's items in place of the server, +// which offers no deployType filter. This listing keys its items on "content", +// not "data" — 6 of vdb's 8 paginated endpoints do. +func keepClusterGroups(payload interface{}) interface{} { + obj, ok := payload.(map[string]interface{}) + if !ok { + return payload + } + items, ok := obj["content"].([]interface{}) + if !ok { + return payload + } + + kept := make([]interface{}, 0, len(items)) + for _, item := range items { + row, ok := item.(map[string]interface{}) + if !ok { + continue + } + if deployType, _ := row["deployType"].(string); deployType == clusterDeployType { + kept = append(kept, row) + } + } + + out := make(map[string]interface{}, len(obj)) + for k, v := range obj { + out[k] = v + } + out["content"] = kept + return out +} diff --git a/go/cmd/vdb/postgresql/cluster/cluster.go b/go/cmd/vdb/postgresql/cluster/cluster.go new file mode 100644 index 0000000..a5f098b --- /dev/null +++ b/go/cmd/vdb/postgresql/cluster/cluster.go @@ -0,0 +1,41 @@ +package cluster + +import ( + "github.com/spf13/cobra" +) + +// ClusterCmd is the parent command for PostgreSQL Cluster commands. +var ClusterCmd = &cobra.Command{ + Use: "cluster", + Short: "Manage PostgreSQL Clusters", + Long: "Create, inspect, resize and delete vDB PostgreSQL Clusters.\n\n" + + "Some of these call the Relational Database API, which is where the cluster " + + "product's read, reboot, delete and security-rule operations live; each " + + "command's help says which. Cluster IDs start with 'pg-' and a 'db-' ID is " + + "rejected, since those relational endpoints would otherwise act on a " + + "Relational Database instance.", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() //nolint:errcheck + }, +} + +func init() { + // Read + ClusterCmd.AddCommand(listCmd) + ClusterCmd.AddCommand(getCmd) + ClusterCmd.AddCommand(listHistoriesCmd) + ClusterCmd.AddCommand(getVolumeUsedCmd) + ClusterCmd.AddCommand(listSecrulesCmd) + + // Lifecycle + ClusterCmd.AddCommand(createCmd) + ClusterCmd.AddCommand(resizeCmd) + ClusterCmd.AddCommand(rebootCmd) + ClusterCmd.AddCommand(deleteCmd) + + // Configuration + ClusterCmd.AddCommand(updateSettingsCmd) + ClusterCmd.AddCommand(updateConfigGroupCmd) + ClusterCmd.AddCommand(updateSecruleCmd) +} diff --git a/go/cmd/vdb/postgresql/cluster/cluster_test.go b/go/cmd/vdb/postgresql/cluster/cluster_test.go new file mode 100644 index 0000000..2c573ac --- /dev/null +++ b/go/cmd/vdb/postgresql/cluster/cluster_test.go @@ -0,0 +1,454 @@ +package cluster + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// TestRequireClusterIDRejectsInstanceIDs is the safety net for this package: the +// relational endpoints it calls for get, histories, secrules, reboot and delete +// serve Relational Database instances too, so a "db-" ID must never get through. +func TestRequireClusterIDRejectsInstanceIDs(t *testing.T) { + if err := requireClusterID("pg-2e6f2253-9032-466f-975f-d6d6b6ec8330"); err != nil { + t.Errorf("valid cluster ID rejected: %v", err) + } + + for _, id := range []string{ + "db-66a37ca3-e688-4a8a-9e35-a89fa69733c6", // a relational instance + "2e6f2253-9032-466f-975f-d6d6b6ec8330", // bare UUID + "", + "pg id with spaces", + } { + if err := requireClusterID(id); err == nil { + t.Errorf("requireClusterID(%q) = nil, want an error", id) + } + } +} + +func TestPathsUseTheRightPrefix(t *testing.T) { + const id = "pg-1234" + + cases := []struct { + name string + got string + want string + }{ + // Relational: the operations the cluster product does not have. + {"get", detailPath(id), "/vdb-relational/v1/database-instances/id/pg-1234"}, + {"histories", relPath(id, "/histories"), "/vdb-relational/v1/database-instances/pg-1234/histories"}, + {"secrules", relPath(id, "/secrules"), "/vdb-relational/v1/database-instances/pg-1234/secrules"}, + {"reboot", relPath(id, "/reboot"), "/vdb-relational/v1/database-instances/pg-1234/reboot"}, + {"delete", relPath(id, "/delete"), "/vdb-relational/v1/database-instances/pg-1234/delete"}, + // PostgreSQL: its own operations. + {"resize", pgPath(id, "/resize"), "/vdb-postgresql/v1/cluster/pg-1234/resize"}, + {"settings", pgPath(id, "/settings"), "/vdb-postgresql/v1/cluster/pg-1234/settings"}, + {"config-group", pgPath(id, "/config-group"), "/vdb-postgresql/v1/cluster/pg-1234/config-group"}, + {"volume-used", pgPath(id, "/volume-used"), "/vdb-postgresql/v1/cluster/pg-1234/volume-used"}, + } + + for _, c := range cases { + if c.got != c.want { + t.Errorf("%s path = %q, want %q", c.name, c.got, c.want) + } + } +} + +// TestKeepClusters: the listing is shared with Relational Database instances, and +// its server-side filters do not apply to cluster rows, so both the product +// filtering and --name/--status happen here. +func TestKeepClusters(t *testing.T) { + payload := map[string]interface{}{ + "projectId": "pro-1", + "data": []interface{}{ + map[string]interface{}{"id": "pg-a", "name": "prod-cluster", "status": "ACTIVE"}, + map[string]interface{}{"id": "db-b", "name": "prod-instance", "status": "ACTIVE"}, + map[string]interface{}{"id": "pg-c", "name": "staging", "status": "BUILDING"}, + }, + "pageObject": map[string]interface{}{"totalElements": float64(3)}, + } + + ids := func(result interface{}) []string { + obj := result.(map[string]interface{}) + var out []string + for _, item := range obj["data"].([]interface{}) { + out = append(out, item.(map[string]interface{})["id"].(string)) + } + return out + } + + cases := []struct { + name string + filter string + statuses []string + want string + }{ + {"no filter drops db- rows", "", nil, "pg-a,pg-c"}, + {"name is a substring match", "prod", nil, "pg-a"}, + {"name is case-insensitive", "PROD", nil, "pg-a"}, + {"status filters", "", []string{"BUILDING"}, "pg-c"}, + {"several statuses", "", []string{"ACTIVE", "BUILDING"}, "pg-a,pg-c"}, + {"name and status combine", "prod", []string{"BUILDING"}, ""}, + } + + for _, c := range cases { + got := strings.Join(ids(keepClusters(payload, c.filter, c.statuses)), ",") + if got != c.want { + t.Errorf("%s: kept %q, want %q", c.name, got, c.want) + } + } + + // The rest of the payload must survive: pageObject is how a user pages. + result := keepClusters(payload, "", nil).(map[string]interface{}) + if result["pageObject"] == nil || result["projectId"] != "pro-1" { + t.Error("keepClusters dropped keys other than the item array") + } + if len(payload["data"].([]interface{})) != 3 { + t.Error("keepClusters mutated the original payload") + } +} + +func TestCreateBodyMapsFlagsToAPIFields(t *testing.T) { + cmd := freshCreateCmd(t, map[string]string{ + "name": "my-cluster", + "datastore-version": "17", + "package-id": "pgp-1", + "volume-type-id": "pgst-1", + "volume-size": "40", + "number-of-nodes": "3", + "zone-id": "HCM03-1A", + "subnet-ids": "sub-1, sub-2", + "username": "pgadmin", + "password": "secret", + "database-name": "appdb", + }) + + body, err := createBody(cmd) + if err != nil { + t.Fatalf("createBody: %v", err) + } + + // The flag names are CLI-shaped; the API's are not. + for field, want := range map[string]interface{}{ + "name": "my-cluster", + "datastoreVersion": "17", + "packageId": "pgp-1", + "volumeTypeId": "pgst-1", + "volumeSize": 40, + "numberOfNodes": 3, + "locateZoneId": "HCM03-1A", + } { + if body[field] != want { + t.Errorf("body[%q] = %v, want %v", field, body[field], want) + } + } + + // netIds is a list even for one subnet; user and databases are nested. + netIDs, ok := body["netIds"].([]interface{}) + if !ok || len(netIDs) != 2 || netIDs[0] != "sub-1" || netIDs[1] != "sub-2" { + t.Errorf("netIds = %v, want [sub-1 sub-2]", body["netIds"]) + } + user, ok := body["user"].(map[string]interface{}) + if !ok || user["name"] != "pgadmin" || user["password"] != "secret" { + t.Errorf("user = %v, want the master credentials", body["user"]) + } + databases, ok := body["databases"].([]interface{}) + if !ok || len(databases) != 1 { + t.Fatalf("databases = %v, want one entry", body["databases"]) + } + if databases[0].(map[string]interface{})["name"] != "appdb" { + t.Errorf("databases[0] = %v, want name appdb", databases[0]) + } + + // Optional IDs must be absent rather than empty: an empty configId means + // "detach" on the update endpoint, so empty strings are not inert here. + for _, field := range []string{"configId", "backupLocationId", "backupPolicyId", "backupPointId"} { + if _, present := body[field]; present { + t.Errorf("body carries %q even though the flag was not set", field) + } + } +} + +func TestCreateBodyRejectsBadInput(t *testing.T) { + base := map[string]string{ + "name": "c", "datastore-version": "17", "package-id": "pgp-1", + "volume-type-id": "pgst-1", "volume-size": "40", "zone-id": "HCM03-1A", + "subnet-ids": "sub-1", "username": "u", "password": "p", "database-name": "d", + } + + cases := []struct { + name string + override map[string]string + }{ + {"one node is below the cluster minimum", map[string]string{"number-of-nodes": "1"}}, + {"eleven nodes is above the maximum", map[string]string{"number-of-nodes": "11"}}, + {"zero volume size", map[string]string{"volume-size": "0"}}, + {"no password anywhere", map[string]string{"password": ""}}, + {"no subnet", map[string]string{"subnet-ids": ""}}, + // Multi-AZ: one subnet per zone, so a repeated subnet names a zone twice and + // more subnets than nodes leaves a zone with no node to place in it. + {"repeated subnet", map[string]string{"subnet-ids": "sub-1, sub-1"}}, + {"more subnets than nodes", map[string]string{"subnet-ids": "sub-1,sub-2,sub-3,sub-4"}}, + } + + for _, c := range cases { + flags := map[string]string{} + for k, v := range base { + flags[k] = v + } + for k, v := range c.override { + flags[k] = v + } + + // The password test must not pick up a real environment value. + t.Setenv(passwordEnv, "") + + if _, err := createBody(freshCreateCmd(t, flags)); err == nil { + t.Errorf("%s: createBody = nil error, want a rejection", c.name) + } + } +} + +// TestCreatePromptNamesZonesForMultiAZ: node placement across zones is the one thing +// the order fixes, so a multi-subnet create must say so before it takes money. +func TestCreatePromptNamesZonesForMultiAZ(t *testing.T) { + single := createPrompt("c", 3, 1) + if strings.Contains(single, "Multi-AZ") || strings.Contains(single, "zone") { + t.Errorf("single-zone prompt mentions zones: %q", single) + } + + multi := createPrompt("c", 4, 2) + for _, want := range []string{"4 nodes", "across 2 zones", "Multi-AZ", "paid order"} { + if !strings.Contains(multi, want) { + t.Errorf("multi-AZ prompt %q missing %q", multi, want) + } + } +} + +func TestCreateBodyReadsPasswordFromEnv(t *testing.T) { + t.Setenv(passwordEnv, "from-env") + + cmd := freshCreateCmd(t, map[string]string{ + "name": "c", "datastore-version": "17", "package-id": "pgp-1", + "volume-type-id": "pgst-1", "volume-size": "40", "zone-id": "HCM03-1A", + "subnet-ids": "sub-1", "username": "u", "database-name": "d", + }) + + body, err := createBody(cmd) + if err != nil { + t.Fatalf("createBody: %v", err) + } + if got := body["user"].(map[string]interface{})["password"]; got != "from-env" { + t.Errorf("password = %v, want the value from $%s", got, passwordEnv) + } +} + +func TestResizeBodySendsOnlyTheChosenDimension(t *testing.T) { + cases := []struct { + resizeType string + flags map[string]string + wantField string + wantValue interface{} + absent []string + }{ + {"VOLUME-SIZE", map[string]string{"volume-size": "80"}, "volumeSize", 80, + []string{"volumeTypeId", "numberOfNodes"}}, + {"VOLUME-TYPE", map[string]string{"volume-type-id": "pgst-2"}, "volumeTypeId", "pgst-2", + []string{"volumeSize", "numberOfNodes"}}, + {"NUMBER-OF-NODES", map[string]string{"number-of-nodes": "5"}, "numberOfNodes", 5, + []string{"volumeSize", "volumeTypeId"}}, + } + + for _, c := range cases { + flags := map[string]string{"cluster-id": "pg-1", "type": c.resizeType} + for k, v := range c.flags { + flags[k] = v + } + + body, summary, err := resizeBody(freshResizeCmd(t, flags)) + if err != nil { + t.Errorf("%s: resizeBody: %v", c.resizeType, err) + continue + } + if body["type"] != c.resizeType { + t.Errorf("%s: type = %v", c.resizeType, body["type"]) + } + if body[c.wantField] != c.wantValue { + t.Errorf("%s: body[%q] = %v, want %v", c.resizeType, c.wantField, body[c.wantField], c.wantValue) + } + for _, field := range c.absent { + if _, present := body[field]; present { + t.Errorf("%s: body carries unrelated field %q", c.resizeType, field) + } + } + if summary == "" { + t.Errorf("%s: no summary for the confirmation prompt", c.resizeType) + } + } +} + +func TestResizeBodyRequiresTheMatchingFlag(t *testing.T) { + // Each mode reads exactly one flag; without it the API would accept an order + // that changes nothing. + for resizeType, missing := range resizeFlagFor { + _, _, err := resizeBody(freshResizeCmd(t, map[string]string{"cluster-id": "pg-1", "type": resizeType})) + if err == nil { + t.Errorf("--type %s without --%s = nil error, want a rejection", resizeType, missing) + } + } + + if _, _, err := resizeBody(freshResizeCmd(t, map[string]string{"cluster-id": "pg-1", "type": "GROW"})); err == nil { + t.Error("unknown --type accepted") + } +} + +func TestSettingsBodyOnlySendsWhatWasSet(t *testing.T) { + t.Setenv(passwordEnv, "") + + // publicAccess false is a real, connectivity-breaking setting, so it must be + // sent only when the flag was actually given. + body, _, err := settingsBody(freshSettingsCmd(t, map[string]string{"password": "new-secret"})) + if err != nil { + t.Fatalf("settingsBody: %v", err) + } + if body["password"] != "new-secret" { + t.Errorf("password not sent: %v", body) + } + if _, present := body["publicAccess"]; present { + t.Error("publicAccess sent although --public-access was not given") + } + + body, _, err = settingsBody(freshSettingsCmd(t, map[string]string{"public-access": "false"})) + if err != nil { + t.Fatalf("settingsBody: %v", err) + } + if body["publicAccess"] != false { + t.Errorf("publicAccess = %v, want false", body["publicAccess"]) + } + if _, present := body["password"]; present { + t.Error("password sent although none was given") + } + + if _, _, err := settingsBody(freshSettingsCmd(t, nil)); err == nil { + t.Error("empty update accepted; it would be a pointless API call") + } +} + +// TestDeleteHasNoDeleteAllBackupFlag: a cluster honours only createFinalBackup — +// the shared schema's deleteAllBackup is a Relational Database instance option. +// (The action-body shape itself is covered in internal/vdbclient.) +func TestDeleteHasNoDeleteAllBackupFlag(t *testing.T) { + if deleteCmd.Flags().Lookup("delete-all-backup") != nil { + t.Error("cluster delete defines --delete-all-backup, which a cluster does not support") + } +} + +func TestDestructiveAndPaidCommandsAreGated(t *testing.T) { + // The repo-wide conventions test only requires this of delete/stop/reboot; the + // vdb notes extend it to create and resize, which place paid orders, and to the + // updates that can break connectivity. + for _, cmd := range []*cobra.Command{ + createCmd, resizeCmd, deleteCmd, rebootCmd, + updateSettingsCmd, updateConfigGroupCmd, updateSecruleCmd, + } { + for _, flag := range []string{"dry-run", "force"} { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("%s must define --%s", cmd.Name(), flag) + } + } + } +} + +func TestClusterFlagCompletionsAreRegistered(t *testing.T) { + cases := []struct { + cmd *cobra.Command + flags []string + }{ + {getCmd, []string{"cluster-id"}}, + {listCmd, []string{"status"}}, + {listHistoriesCmd, []string{"cluster-id"}}, + {getVolumeUsedCmd, []string{"cluster-id"}}, + {listSecrulesCmd, []string{"cluster-id"}}, + {createCmd, []string{"datastore-version", "package-id", "volume-type-id", "zone-id", + "subnet-ids", "config-id", "backup-location-id", "backup-policy-id"}}, + {resizeCmd, []string{"cluster-id", "type", "volume-type-id"}}, + {rebootCmd, []string{"cluster-id"}}, + {deleteCmd, []string{"cluster-id"}}, + {updateSettingsCmd, []string{"cluster-id"}}, + {updateConfigGroupCmd, []string{"cluster-id", "config-id"}}, + {updateSecruleCmd, []string{"cluster-id"}}, + } + + for _, c := range cases { + for _, flag := range c.flags { + if c.cmd.Flags().Lookup(flag) == nil { + t.Errorf("%s has no --%s flag", c.cmd.Name(), flag) + continue + } + if _, ok := c.cmd.GetFlagCompletionFunc(flag); !ok { + t.Errorf("%s --%s has no completion function registered", c.cmd.Name(), flag) + } + } + } +} + +// freshCreateCmd and friends build a throwaway command carrying the same flags as +// the real one. Tests use these rather than the package-level commands, which are +// mounted in the assembled command tree: setting flags on those leaks values +// between tests and into the running CLI. +func freshCreateCmd(t *testing.T, values map[string]string) *cobra.Command { + t.Helper() + return freshCmd(t, createFlags, values) +} + +func freshResizeCmd(t *testing.T, values map[string]string) *cobra.Command { + t.Helper() + return freshCmd(t, resizeFlagsOn, values) +} + +func freshSettingsCmd(t *testing.T, values map[string]string) *cobra.Command { + t.Helper() + return freshCmd(t, settingsFlagsOn, values) +} + +func freshCmd(t *testing.T, define func(*pflag.FlagSet), values map[string]string) *cobra.Command { + t.Helper() + + cmd := &cobra.Command{Use: "test"} + define(cmd.Flags()) + + for name, value := range values { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("setting --%s=%q: %v", name, value, err) + } + } + return cmd +} + +// TestHistoryColumnsCarryDescription: `action` is "Update" for every settings change, +// resize and config-group attach or detach, so a history table without description cannot +// tell them apart — which is why it leads the column set. +func TestHistoryColumnsCarryDescription(t *testing.T) { + var hasDescription, hasUpdated bool + for _, column := range historyColumns { + switch column { + case "description": + hasDescription = true + case "updatedTime": + hasUpdated = true + } + } + if !hasDescription { + t.Errorf("history table has no description column: %v", historyColumns) + } + // updatedTime duplicated createdTime on every record observed live. If that ever + // changes this is the place to reconsider, rather than a silent re-add. + if hasUpdated { + t.Errorf("updatedTime is JSON-only, it repeats createdTime: %v", historyColumns) + } + if historyColumns[1] != "action" || historyColumns[2] != "description" { + t.Errorf("description must follow action so a row reads as one phrase: %v", historyColumns) + } +} diff --git a/go/cmd/vdb/postgresql/cluster/completion.go b/go/cmd/vdb/postgresql/cluster/completion.go new file mode 100644 index 0000000..670a308 --- /dev/null +++ b/go/cmd/vdb/postgresql/cluster/completion.go @@ -0,0 +1,141 @@ +package cluster + +import ( + "context" + "strings" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// This file holds the completers; each flag is bound to one in the init() of the +// file that DEFINES the flag. init() functions run in filename order within a +// package, so a central binding would silently fail for flags defined in a later +// file — see the note in cmd/vdb/relational/instance/completion.go. + +// ClusterResourceKey is the shared completion key for a PostgreSQL Cluster ID. +const ClusterResourceKey = "vdb:postgresql-cluster" + +func init() { + cli.RegisterResourceCompleter(ClusterResourceKey, clusterIDCompletion()) +} + +// The catalog values a create or resize needs are registered by the catalog +// package, which owns those endpoints; these wrappers dispatch through the shared +// registry at completion time, so no import and no init ordering is involved. +// Zones are the exception: they are project-wide, so the relational key is reused +// rather than duplicated. +func datastoreVersionCompletion() cli.CompFunc { + return cli.ResourceCompletion("vdb:postgresql-datastore-version") +} + +func packageIDCompletion() cli.CompFunc { + return cli.ResourceCompletion("vdb:postgresql-flavor") +} + +func volumeTypeIDCompletion() cli.CompFunc { + return cli.ResourceCompletion("vdb:postgresql-volume-type") +} + +func configIDCompletion() cli.CompFunc { + return cli.ResourceCompletion("vdb:postgresql-config-group") +} + +func backupLocationCompletion() cli.CompFunc { + return cli.ResourceCompletion("vdb:postgresql-backup-location") +} + +func backupPolicyCompletion() cli.CompFunc { + return cli.ResourceCompletion("vdb:postgresql-backup-policy") +} + +func zoneIDCompletion() cli.CompFunc { + return cli.ResourceCompletion("vdb:relational-zone") +} + +// subnetCompletion also reuses the relational listing: subnets are project +// infrastructure, and that endpoint is the one that reports which of them vDB +// accepts. +func subnetCompletion() cli.CompFunc { + return cli.ResourceCompletion("vdb:relational-subnet") +} + +// clusterIDCompletion suggests only "pg-" IDs. The listing behind it is the mixed +// relational one, and every command in this package rejects a "db-" ID, so +// offering those would only invite an error. +// +// Best-effort: that listing answers in ~1.3s but has been seen to spike past 6s, +// which is beyond cli.FlagFromAPI's bound; the shell then gets no suggestions. +func clusterIDCompletion() cli.CompFunc { + return cli.FlagFromAPI(func(_ context.Context, cmd *cobra.Command) ([]string, error) { + rows, err := fetchClusterRows(cmd) + if err != nil { + return nil, err + } + return field(rows, "id"), nil + }) +} + +// statusCompletion suggests the statuses actually present on clusters. Neither +// vdb group has a relational status-listing endpoint (the only one in the spec +// belongs to MemoryStore), and the values are free-form strings. +func statusCompletion() cli.CompFunc { + return cli.FlagFromAPI(func(_ context.Context, cmd *cobra.Command) ([]string, error) { + rows, err := fetchClusterRows(cmd) + if err != nil { + return nil, err + } + return field(rows, "status"), nil + }) +} + +// fetchClusterRows returns one page of cluster rows for completion. pageSize is +// capped at 100 by the API, so a project with more instances than that gets +// suggestions from the first page only. +func fetchClusterRows(cmd *cobra.Command) ([]interface{}, error) { + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return nil, err + } + query := vdbclient.BuildListQuery(vdbclient.ListOptions{Page: 1, PageSize: 100}) + result, err := vdbclient.Get(apiClient, relBase, query) + if err != nil { + return nil, err + } + + payload, ok := vdbclient.PayloadObject(result) + if !ok { + return nil, nil + } + items, _ := payload["data"].([]interface{}) + + rows := make([]interface{}, 0, len(items)) + for _, item := range items { + row, ok := item.(map[string]interface{}) + if !ok { + continue + } + if id, _ := row["id"].(string); strings.HasPrefix(id, clusterIDPrefix) { + rows = append(rows, row) + } + } + return rows, nil +} + +// field collects one string field from rows, de-duplicated in order. +func field(rows []interface{}, name string) []string { + var out []string + seen := map[string]bool{} + for _, item := range rows { + row, ok := item.(map[string]interface{}) + if !ok { + continue + } + if v, _ := row[name].(string); v != "" && !seen[v] { + seen[v] = true + out = append(out, v) + } + } + return out +} diff --git a/go/cmd/vdb/postgresql/cluster/create.go b/go/cmd/vdb/postgresql/cluster/create.go new file mode 100644 index 0000000..48ffb60 --- /dev/null +++ b/go/cmd/vdb/postgresql/cluster/create.go @@ -0,0 +1,247 @@ +package cluster + +import ( + "fmt" + "os" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// passwordEnv lets the master password stay out of shell history and process +// listings. --password still exists for scripting, but the env var is the +// documented way. +const passwordEnv = "GRN_VDB_MASTER_PASSWORD" + +const ( + minNodes = 2 + maxNodes = 10 +) + +var createCmd = &cobra.Command{ + Use: "create", + Short: "Create a PostgreSQL Cluster", + Long: "Create a vDB PostgreSQL Cluster.\n\n" + + "THIS COSTS MONEY. Creation goes through the order/payment flow: the API " + + "answers with an order (orderId, orderUrl) and the cluster is built " + + "asynchronously — a successful response means the order was placed, not that the " + + "cluster is ready. Poll 'cluster get' for status. Use --dry-run to see the exact " + + "request first; --user-type IAM_USER switches from Checkout to Auto Payment.\n\n" + + "Every ID comes from 'grn vdb postgresql catalog' — its flavors, volume types, " + + "versions, config groups, backup locations and policies are cluster-specific and " + + "the relational ones are not interchangeable.\n\n" + + "Give --subnet-ids one subnet and every node lands in that subnet's zone. Give it " + + "several — one per zone — and the nodes are spread across those zones (Multi-AZ); " + + "a Multi-AZ cluster can only use the flavors and volume types that " + + "'catalog list-flavors --multi-zone' and 'catalog list-volume-types --multi-zone' " + + "list.\n\n" + + "The master password is read from $" + passwordEnv + " when --password is omitted, " + + "which keeps it out of your shell history. It is masked in --dry-run output.", + Args: cobra.NoArgs, + RunE: runCreate, +} + +// createFlags defines the request flags on f. It is a function rather than inline +// init() code so tests can build a throwaway command with the same flag set +// instead of mutating the one mounted in the command tree. +func createFlags(f *pflag.FlagSet) { + f.String("name", "", "Cluster name (required)") + f.String("datastore-version", "", "PostgreSQL version, e.g. 16 (required; see 'catalog list-datastores')") + f.String("package-id", "", "Flavor ID, 'pgp-...' (required; see 'catalog list-flavors')") + f.String("volume-type-id", "", "Volume type ID, 'pgst-...' (required; see 'catalog list-volume-types')") + f.Int("volume-size", 0, "Volume size in GB (required)") + f.Int("number-of-nodes", 3, fmt.Sprintf("Number of nodes (%d-%d)", minNodes, maxNodes)) + f.String("zone-id", "", "Availability zone, e.g. HCM03-1A (required)") + f.String("subnet-ids", "", "Subnet IDs, comma-separated (required; see 'grn vdb relational catalog list-subnets'). "+ + "One subnet places all nodes in its zone; several — one per zone — spread the nodes across zones (Multi-AZ)") + f.String("username", "", "Master username (required)") + f.String("password", "", "Master password (required; defaults to $"+passwordEnv+")") + f.String("database-name", "", "Initial database name (required; the API accepts exactly one at creation)") + f.String("config-id", "", "Config group ID with deploy type 'cluster' (see 'catalog list-config-groups')") + f.Bool("public-access", false, "Allow public access to the cluster") + f.String("backup-location-id", "", "Backup location ID, 'bk-des-...' (see 'catalog list-backup-locations')") + f.String("backup-policy-id", "", "Backup policy ID, 'bk-pol-...' (see 'catalog list-backup-policies')") + f.String("backup-point-id", "", "Restore point to build the cluster from, 'bk-db-pt-...' (see 'backup list-restore-points')") + f.Bool("poc", false, "Pay with PoC credit (Auto Payment only)") + f.Bool("dry-run", false, "Print the request that would be sent without placing an order") + f.Bool("force", false, "Skip the confirmation prompt") +} + +func init() { + createFlags(createCmd.Flags()) + + for _, required := range []string{ + "name", "datastore-version", "package-id", "volume-type-id", + "volume-size", "zone-id", "subnet-ids", "username", "database-name", + } { + createCmd.MarkFlagRequired(required) //nolint:errcheck + } + + // Bound here, next to the flags: see the init-order note in completion.go. + createCmd.RegisterFlagCompletionFunc("datastore-version", datastoreVersionCompletion()) //nolint:errcheck + createCmd.RegisterFlagCompletionFunc("package-id", packageIDCompletion()) //nolint:errcheck + createCmd.RegisterFlagCompletionFunc("volume-type-id", volumeTypeIDCompletion()) //nolint:errcheck + createCmd.RegisterFlagCompletionFunc("zone-id", zoneIDCompletion()) //nolint:errcheck + createCmd.RegisterFlagCompletionFunc("config-id", configIDCompletion()) //nolint:errcheck + createCmd.RegisterFlagCompletionFunc("backup-location-id", backupLocationCompletion()) //nolint:errcheck + createCmd.RegisterFlagCompletionFunc("backup-policy-id", backupPolicyCompletion()) //nolint:errcheck + createCmd.RegisterFlagCompletionFunc("subnet-ids", subnetCompletion()) //nolint:errcheck +} + +func runCreate(cmd *cobra.Command, args []string) error { + body, err := createBody(cmd) + if err != nil { + return err + } + + name, _ := cmd.Flags().GetString("name") + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + vdbclient.PreviewBody("create", fmt.Sprintf("PostgreSQL Cluster %q", name), body) + return nil + } + + nodes, _ := body["numberOfNodes"].(int) + zones := 1 + if netIDs, ok := body["netIds"].([]interface{}); ok && len(netIDs) > 0 { + zones = len(netIDs) + } + if !cli.Confirm(force, createPrompt(name, nodes, zones)) { + fmt.Println("Aborted.") + return nil + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Post(pgBase, body) + if err != nil { + return fmt.Errorf("failed to create PostgreSQL Cluster %q: %w", name, err) + } + + return vdbclient.Output(cmd, result) +} + +// createPrompt words the confirmation. A Multi-AZ create (several subnets, one per +// zone) says so — node placement across zones is the one thing the order fixes and +// nothing later can change. +func createPrompt(name string, nodes, zones int) string { + if zones > 1 { + return fmt.Sprintf("Create PostgreSQL Cluster %q with %d nodes across %d zones (Multi-AZ)? This places a paid order.", + name, nodes, zones) + } + return fmt.Sprintf("Create PostgreSQL Cluster %q with %d nodes? This places a paid order.", name, nodes) +} + +// createBody assembles CreatePostgreClusterRequest. Split out from runCreate so +// the flag-to-field mapping — including the two nested objects and the node-count +// bounds — is testable without a client. +func createBody(cmd *cobra.Command) (map[string]interface{}, error) { + flags := cmd.Flags() + + name, _ := flags.GetString("name") + version, _ := flags.GetString("datastore-version") + packageID, _ := flags.GetString("package-id") + volumeTypeID, _ := flags.GetString("volume-type-id") + volumeSize, _ := flags.GetInt("volume-size") + nodes, _ := flags.GetInt("number-of-nodes") + zoneID, _ := flags.GetString("zone-id") + subnetIDs, _ := flags.GetString("subnet-ids") + username, _ := flags.GetString("username") + databaseName, _ := flags.GetString("database-name") + configID, _ := flags.GetString("config-id") + publicAccess, _ := flags.GetBool("public-access") + backupLocationID, _ := flags.GetString("backup-location-id") + backupPolicyID, _ := flags.GetString("backup-policy-id") + backupPointID, _ := flags.GetString("backup-point-id") + poc, _ := flags.GetBool("poc") + + if nodes < minNodes || nodes > maxNodes { + return nil, fmt.Errorf("invalid --number-of-nodes %d: a PostgreSQL Cluster takes %d to %d nodes", + nodes, minNodes, maxNodes) + } + if volumeSize <= 0 { + return nil, fmt.Errorf("invalid --volume-size %d: must be greater than 0 "+ + "(see minVolumeSize/maxVolumeSize in 'catalog list-volume-types')", volumeSize) + } + + password, _ := flags.GetString("password") + if password == "" { + password = os.Getenv(passwordEnv) + } + if password == "" { + return nil, fmt.Errorf("master password not set: pass --password or set $%s", passwordEnv) + } + + subnets := cli.ParseCommaSeparated(subnetIDs) + if len(subnets) == 0 { + return nil, fmt.Errorf("invalid --subnet-ids: at least one subnet ID is required") + } + // Multi-AZ takes one subnet per zone, so a repeated subnet names one zone twice + // and more subnets than nodes leaves a zone with nothing to place in it. The API + // documents the one-per-zone rule but does not spell out either failure, so both + // are caught here where the error can name the flag. + seen := make(map[string]bool, len(subnets)) + for _, subnet := range subnets { + if seen[subnet] { + return nil, fmt.Errorf("invalid --subnet-ids: %q is listed more than once — Multi-AZ takes one subnet per zone", subnet) + } + seen[subnet] = true + } + if len(subnets) > nodes { + return nil, fmt.Errorf("invalid --subnet-ids: %d subnets for %d nodes — a Multi-AZ cluster takes at most one subnet per node (one per zone the nodes spread across)", + len(subnets), nodes) + } + + body := map[string]interface{}{ + "name": name, + "locateZoneId": zoneID, + "packageId": packageID, + "volumeTypeId": volumeTypeID, + "volumeSize": volumeSize, + "numberOfNodes": nodes, + "datastoreVersion": version, + "netIds": toInterfaces(subnets), + "publicAccess": publicAccess, + "isPoc": poc, + "user": map[string]interface{}{ + "name": username, + "password": password, + }, + // The API takes a list but documents that exactly one database may be + // created with the cluster. + "databases": []interface{}{ + map[string]interface{}{"name": databaseName}, + }, + } + + // Optional IDs are omitted rather than sent empty: an empty configId detaches + // a config group on the update endpoint, so empty strings are not inert in + // this API. + for key, value := range map[string]string{ + "configId": configID, + "backupLocationId": backupLocationID, + "backupPolicyId": backupPolicyID, + "backupPointId": backupPointID, + } { + if value != "" { + body[key] = value + } + } + + return body, nil +} + +func toInterfaces(values []string) []interface{} { + out := make([]interface{}, len(values)) + for i, v := range values { + out[i] = v + } + return out +} diff --git a/go/cmd/vdb/postgresql/cluster/delete.go b/go/cmd/vdb/postgresql/cluster/delete.go new file mode 100644 index 0000000..cb59c2e --- /dev/null +++ b/go/cmd/vdb/postgresql/cluster/delete.go @@ -0,0 +1,108 @@ +package cluster + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +var deleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete a PostgreSQL Cluster", + Long: "Delete a cluster.\n\n" + + "Served by the Relational Database delete endpoint. This is irreversible: the " + + "cluster and its data are gone. --create-final-backup takes one last backup " + + "first.\n\n" + + "A cluster takes only that one option. The shared request schema also carries " + + "deleteAllBackup, which applies to Relational Database instances and not here, " + + "so existing backups survive the deletion and stay restorable.", + Args: cobra.NoArgs, + RunE: runDelete, +} + +func init() { + f := deleteCmd.Flags() + f.String("cluster-id", "", "PostgreSQL Cluster ID (required)") + f.Bool("create-final-backup", false, "Take a final backup before deleting") + f.Bool("dry-run", false, "Print the request that would be sent without deleting") + f.Bool("force", false, "Skip the confirmation prompt") + + deleteCmd.MarkFlagRequired("cluster-id") //nolint:errcheck + + // Bound here, next to the flag: see the init-order note in completion.go. + deleteCmd.RegisterFlagCompletionFunc("cluster-id", clusterIDCompletion()) //nolint:errcheck +} + +func runDelete(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + if err := requireClusterID(clusterID); err != nil { + return err + } + + createFinalBackup, _ := cmd.Flags().GetBool("create-final-backup") + + // createFinalBackup is the only per-cluster option this endpoint honours. The + // shared DeleteDbInstanceConfig schema also has deleteAllBackup, but that is a + // Relational Database instance option, so it is not sent here. + body := vdbclient.ActionBody(vdbclient.ResourceTypeInstance, clusterID, "delete", map[string]interface{}{ + "createFinalBackup": createFinalBackup, + }) + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + // Show what is about to be destroyed. A best-effort lookup: if the cluster + // cannot be read (already gone, or a transient error), say so and let the user + // decide rather than refusing to continue. + printDeleteTarget(apiClient, clusterID) + + if dryRun { + vdbclient.PreviewBody("delete", fmt.Sprintf("PostgreSQL Cluster %s", clusterID), body) + return nil + } + + if !cli.Confirm(force, fmt.Sprintf( + "Delete PostgreSQL Cluster %s? This cannot be undone.", clusterID)) { + fmt.Println("Aborted.") + return nil + } + + result, err := apiClient.Post(relPath(clusterID, "/delete"), body) + if err != nil { + return fmt.Errorf("failed to delete PostgreSQL Cluster %s: %w", clusterID, err) + } + + return vdbclient.Output(cmd, result) +} + +func printDeleteTarget(apiClient interface { + Get(string, map[string]string) (interface{}, error) +}, clusterID string) { + result, err := apiClient.Get(detailPath(clusterID), nil) + if err != nil { + fmt.Printf("Warning: could not read PostgreSQL Cluster %s before deleting (%v).\n", clusterID, err) + return + } + cluster, ok := vdbclient.PayloadObject(result) + if !ok { + fmt.Printf("Warning: could not read PostgreSQL Cluster %s before deleting (not found or empty payload).\n", clusterID) + return + } + + fmt.Println("The following PostgreSQL Cluster will be deleted:") + fmt.Println() + fmt.Printf(" ID: %v\n", cluster["id"]) + fmt.Printf(" Name: %v\n", cluster["name"]) + fmt.Printf(" Status: %v\n", cluster["status"]) + fmt.Printf(" Version: PostgreSQL %v\n", cluster["datastoreVersion"]) + fmt.Printf(" Nodes: %v\n", cluster["numberOfNodes"]) + fmt.Printf(" Storage: %v GB (%v)\n", cluster["volumeSize"], cluster["volumeType"]) + fmt.Println() +} diff --git a/go/cmd/vdb/postgresql/cluster/get.go b/go/cmd/vdb/postgresql/cluster/get.go new file mode 100644 index 0000000..77e4cae --- /dev/null +++ b/go/cmd/vdb/postgresql/cluster/get.go @@ -0,0 +1,49 @@ +package cluster + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +var getCmd = &cobra.Command{ + Use: "get", + Short: "Get details of a PostgreSQL Cluster", + Long: "Show the full details of one vDB PostgreSQL Cluster.\n\n" + + "Served by the Relational Database get-by-id endpoint, which accepts cluster " + + "IDs; a cluster answers with deployType 'cluster', numberOfNodes and the " + + "privateRwIp / publicRwIp / privateRoIp / publicRoIp fields.", + Args: cobra.NoArgs, + RunE: runGet, +} + +func init() { + f := getCmd.Flags() + f.String("cluster-id", "", "PostgreSQL Cluster ID (required)") + getCmd.MarkFlagRequired("cluster-id") //nolint:errcheck + + // Bound here, next to the flag: see the init-order note in completion.go. + getCmd.RegisterFlagCompletionFunc("cluster-id", clusterIDCompletion()) //nolint:errcheck +} + +func runGet(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + if err := requireClusterID(clusterID); err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Get(detailPath(clusterID), nil) + if err != nil { + return fmt.Errorf("failed to get PostgreSQL Cluster %s: %w", clusterID, err) + } + + // Output, not OutputWithColumns: a detail payload has several nested arrays + // and table extraction would pick one at random. + return vdbclient.Output(cmd, result) +} diff --git a/go/cmd/vdb/postgresql/cluster/get_volume_used.go b/go/cmd/vdb/postgresql/cluster/get_volume_used.go new file mode 100644 index 0000000..d560341 --- /dev/null +++ b/go/cmd/vdb/postgresql/cluster/get_volume_used.go @@ -0,0 +1,46 @@ +package cluster + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +var getVolumeUsedCmd = &cobra.Command{ + Use: "get-volume-used", + Short: "Show storage used by a PostgreSQL Cluster", + Long: "Show how much of a cluster's provisioned storage is in use.\n\n" + + "One of the cluster's own endpoints (/vdb-postgresql/v1). The API answers with " + + "an array — one entry per node — so this prints it as-is rather than as a table.", + Args: cobra.NoArgs, + RunE: runGetVolumeUsed, +} + +func init() { + f := getVolumeUsedCmd.Flags() + f.String("cluster-id", "", "PostgreSQL Cluster ID (required)") + getVolumeUsedCmd.MarkFlagRequired("cluster-id") //nolint:errcheck + + // Bound here, next to the flag: see the init-order note in completion.go. + getVolumeUsedCmd.RegisterFlagCompletionFunc("cluster-id", clusterIDCompletion()) //nolint:errcheck +} + +func runGetVolumeUsed(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + if err := requireClusterID(clusterID); err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Get(pgPath(clusterID, "/volume-used"), nil) + if err != nil { + return fmt.Errorf("failed to get storage usage of PostgreSQL Cluster %s: %w", clusterID, err) + } + + return vdbclient.Output(cmd, result) +} diff --git a/go/cmd/vdb/postgresql/cluster/helpers.go b/go/cmd/vdb/postgresql/cluster/helpers.go new file mode 100644 index 0000000..cb06388 --- /dev/null +++ b/go/cmd/vdb/postgresql/cluster/helpers.go @@ -0,0 +1,74 @@ +package cluster + +import ( + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// A PostgreSQL Cluster is served by two API prefixes. Its own operations +// (create, resize, settings, config-group, volume-used) live under pgBase; the +// ones the product never got — list, get-by-id, histories, secrules, reboot, +// delete — are served by the RELATIONAL endpoints under relBase, which accept +// "pg-" IDs on purpose. Neither set can be derived from the other, so both live +// here as plain constants and each command states which one it uses. +const ( + pgBase = "/vdb-postgresql/v1/cluster" + relBase = "/vdb-relational/v1/database-instances" +) + +// clusterIDPrefix identifies a PostgreSQL Cluster. The relational endpoints this +// package calls also serve Relational Database instances ("db-"), so every +// command validates the prefix before sending: without that check +// `postgresql cluster delete --cluster-id db-...` would happily delete a +// Relational Database instance from the PostgreSQL command group. +const clusterIDPrefix = "pg-" + +func pgPath(clusterID, suffix string) string { + return pgBase + "/" + clusterID + suffix +} + +func relPath(clusterID, suffix string) string { + return relBase + "/" + clusterID + suffix +} + +// detailPath is the relational get-by-id path, which puts the ID behind an extra +// "id" segment. +func detailPath(clusterID string) string { + return relBase + "/id/" + clusterID +} + +// requireClusterID validates the ID and rejects anything that is not a cluster. +func requireClusterID(clusterID string) error { + return vdbclient.RequireIDWithPrefix(clusterID, "cluster-id", clusterIDPrefix, + "PostgreSQL Cluster IDs start with 'pg-'; use 'grn vdb relational instance' for Relational Database instances") +} + +// listColumns is the table view of a cluster. The listing is the relational one, +// so rows carry all ~68 relational fields; the cluster-specific ones are +// numberOfNodes and privateRwIp/publicRwIp, which replace a single instance's ip +// list. +var listColumns = []string{ + "id", "name", "status", "datastoreVersion", "numberOfNodes", + "vcpus", "ram", "volumeSize", "privateRwIp", "created", +} + +// historyColumns is the table view of a HistoryResponse entry. It leads with description +// because `action` alone does not say what happened: every settings change, resize and +// config-group attach or detach is recorded as "Update", and only the description +// distinguishes them ("… Change redis password", "… Change flavor from db.s-general-2x4 +// … to db.s-general-4x8", "… Attach config X"). Create and create-replica entries embed +// the whole order cart and run past 200 characters, which is the cost of having the +// column; it is worth paying, since those two rows are the ones a reader can already +// identify from `action`. +// +// updatedTime is left to JSON output: it equalled createdTime on all 57 records seen +// across three instances in both products, so in a table it is 31 characters spent +// repeating the previous column. errorMessage stays — it is the only place a Failed row +// explains itself. +var historyColumns = []string{ + "id", "action", "description", "status", "createdTime", "errorMessage", +} + +func createClient(cmd *cobra.Command) (*vdbclient.Client, error) { + return vdbclient.BuildClient(cmd) +} diff --git a/go/cmd/vdb/postgresql/cluster/list.go b/go/cmd/vdb/postgresql/cluster/list.go new file mode 100644 index 0000000..109f86a --- /dev/null +++ b/go/cmd/vdb/postgresql/cluster/list.go @@ -0,0 +1,122 @@ +package cluster + +import ( + "fmt" + "strings" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List PostgreSQL Clusters", + Long: "List vDB PostgreSQL Clusters.\n\n" + + "This calls the Relational Database listing, the only endpoint that enumerates " + + "clusters, and keeps just the 'pg-' rows. Because that endpoint's server-side " + + "filters do not apply to cluster rows, --name and --status are applied here, on " + + "the client: --page and --page-size still page the underlying mixed listing, so " + + "a page can come back with fewer clusters than --page-size, and pageObject in " + + "JSON output describes that mixed listing rather than the filtered result.", + Args: cobra.NoArgs, + RunE: runList, +} + +func init() { + f := listCmd.Flags() + f.Int("page", 1, "Page number (1-based)") + f.Int("page-size", vdbclient.DefaultPageSize, "Number of items per page") + f.String("name", "", "Filter by cluster name (substring match, applied client-side)") + f.String("status", "", "Filter by status (comma-separated for multiple values, applied client-side)") + + // Bound here, next to the flag: see the init-order note in completion.go. + listCmd.RegisterFlagCompletionFunc("status", statusCompletion()) //nolint:errcheck +} + +func listOptions(cmd *cobra.Command) vdbclient.ListOptions { + page, _ := cmd.Flags().GetInt("page") + pageSize, _ := cmd.Flags().GetInt("page-size") + + // Name and Statuses are deliberately NOT put on the query: the API ignores + // them for pg- rows, so sending them would filter the db- rows we discard + // anyway while silently doing nothing to the ones we keep. + return vdbclient.ListOptions{Page: page, PageSize: pageSize} +} + +func runList(cmd *cobra.Command, args []string) error { + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := vdbclient.Get(apiClient, relBase, vdbclient.BuildListQuery(listOptions(cmd))) + if err != nil { + return fmt.Errorf("failed to list PostgreSQL Clusters: %w", err) + } + + name, _ := cmd.Flags().GetString("name") + statusFlag, _ := cmd.Flags().GetString("status") + statuses := cli.ParseCommaSeparated(statusFlag) + + return vdbclient.OutputWithColumns(cmd, keepClusters(vdbclient.Unwrap(result), name, statuses), listColumns) +} + +// keepClusters replaces the listing's item array with only the cluster rows that +// match the client-side filters, leaving every other key (projectId, pageObject) +// untouched. It takes an already-unwrapped payload. +func keepClusters(payload interface{}, name string, statuses []string) interface{} { + obj, ok := payload.(map[string]interface{}) + if !ok { + return payload + } + items, ok := obj["data"].([]interface{}) + if !ok { + return payload + } + + kept := make([]interface{}, 0, len(items)) + for _, item := range items { + row, ok := item.(map[string]interface{}) + if !ok { + continue + } + if id, _ := row["id"].(string); !strings.HasPrefix(id, clusterIDPrefix) { + continue + } + if !matchesName(row, name) || !matchesStatus(row, statuses) { + continue + } + kept = append(kept, row) + } + + out := make(map[string]interface{}, len(obj)) + for k, v := range obj { + out[k] = v + } + out["data"] = kept + return out +} + +func matchesName(row map[string]interface{}, name string) bool { + if name == "" { + return true + } + rowName, _ := row["name"].(string) + // Substring, case-insensitive — the server-side filter this replaces is a + // substring match, and a cluster's generated name mixes case. + return strings.Contains(strings.ToLower(rowName), strings.ToLower(name)) +} + +func matchesStatus(row map[string]interface{}, statuses []string) bool { + if len(statuses) == 0 { + return true + } + rowStatus, _ := row["status"].(string) + for _, want := range statuses { + if strings.EqualFold(rowStatus, want) { + return true + } + } + return false +} diff --git a/go/cmd/vdb/postgresql/cluster/list_histories.go b/go/cmd/vdb/postgresql/cluster/list_histories.go new file mode 100644 index 0000000..06f4350 --- /dev/null +++ b/go/cmd/vdb/postgresql/cluster/list_histories.go @@ -0,0 +1,60 @@ +package cluster + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +var listHistoriesCmd = &cobra.Command{ + Use: "list-histories", + Short: "List the action history of a PostgreSQL Cluster", + Long: "List the recorded actions of a cluster — create, resize, restart, backup — " + + "with their status and, for failures, the error message.\n\n" + + "Served by the Relational Database histories endpoint. Results are paginated; " + + "--page is 1-based and no filters are accepted. Table output omits the " + + "description field, which embeds the whole order cart; use --output json for it.", + Args: cobra.NoArgs, + RunE: runListHistories, +} + +func init() { + f := listHistoriesCmd.Flags() + f.String("cluster-id", "", "PostgreSQL Cluster ID (required)") + f.Int("page", 1, "Page number (1-based)") + f.Int("page-size", vdbclient.DefaultPageSize, "Number of items per page") + listHistoriesCmd.MarkFlagRequired("cluster-id") //nolint:errcheck + + // Bound here, next to the flag: see the init-order note in completion.go. + listHistoriesCmd.RegisterFlagCompletionFunc("cluster-id", clusterIDCompletion()) //nolint:errcheck +} + +// historiesOptions maps the paging flags onto the shared list query. This +// endpoint accepts pageNumber/pageSize only. +func historiesOptions(cmd *cobra.Command) vdbclient.ListOptions { + page, _ := cmd.Flags().GetInt("page") + pageSize, _ := cmd.Flags().GetInt("page-size") + + return vdbclient.ListOptions{Page: page, PageSize: pageSize} +} + +func runListHistories(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + if err := requireClusterID(clusterID); err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := vdbclient.Get(apiClient, relPath(clusterID, "/histories"), + vdbclient.BuildListQuery(historiesOptions(cmd))) + if err != nil { + return fmt.Errorf("failed to list history of PostgreSQL Cluster %s: %w", clusterID, err) + } + + return vdbclient.OutputWithColumns(cmd, result, historyColumns) +} diff --git a/go/cmd/vdb/postgresql/cluster/list_secrules.go b/go/cmd/vdb/postgresql/cluster/list_secrules.go new file mode 100644 index 0000000..d9fe704 --- /dev/null +++ b/go/cmd/vdb/postgresql/cluster/list_secrules.go @@ -0,0 +1,59 @@ +package cluster + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +var listSecrulesCmd = &cobra.Command{ + Use: "list-secrules", + Short: "List the security rules of a PostgreSQL Cluster", + Long: "List the security group rules controlling access to a cluster.\n\n" + + "Served by the Relational Database secrules endpoint. Use " + + "'cluster update-secrule' to change them — note that update REPLACES the whole " + + "rule set, so start from this listing.", + Args: cobra.NoArgs, + RunE: runListSecrules, +} + +func init() { + f := listSecrulesCmd.Flags() + f.String("cluster-id", "", "PostgreSQL Cluster ID (required)") + listSecrulesCmd.MarkFlagRequired("cluster-id") //nolint:errcheck + + // Bound here, next to the flag: see the init-order note in completion.go. + listSecrulesCmd.RegisterFlagCompletionFunc("cluster-id", clusterIDCompletion()) //nolint:errcheck +} + +func runListSecrules(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + if err := requireClusterID(clusterID); err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := fetchSecrules(apiClient, clusterID) + if err != nil { + return err + } + + return vdbclient.OutputWithColumns(cmd, result, vdbclient.SecurityRuleColumns) +} + +// fetchSecrules is shared with update-secrule, which reads the current rules to +// show what a replacement would change. +func fetchSecrules(apiClient interface { + Get(string, map[string]string) (interface{}, error) +}, clusterID string) (interface{}, error) { + result, err := apiClient.Get(relPath(clusterID, "/secrules"), nil) + if err != nil { + return nil, fmt.Errorf("failed to list security rules of PostgreSQL Cluster %s: %w", clusterID, err) + } + return result, nil +} diff --git a/go/cmd/vdb/postgresql/cluster/reboot.go b/go/cmd/vdb/postgresql/cluster/reboot.go new file mode 100644 index 0000000..9313d10 --- /dev/null +++ b/go/cmd/vdb/postgresql/cluster/reboot.go @@ -0,0 +1,65 @@ +package cluster + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +var rebootCmd = &cobra.Command{ + Use: "reboot", + Short: "Reboot a PostgreSQL Cluster", + Long: "Restart the database on a cluster.\n\n" + + "Served by the Relational Database reboot endpoint. Connections are dropped " + + "while the nodes restart, so the command confirms first.", + Args: cobra.NoArgs, + RunE: runReboot, +} + +func init() { + f := rebootCmd.Flags() + f.String("cluster-id", "", "PostgreSQL Cluster ID (required)") + f.Bool("dry-run", false, "Print the request that would be sent without rebooting") + f.Bool("force", false, "Skip the confirmation prompt") + + rebootCmd.MarkFlagRequired("cluster-id") //nolint:errcheck + + // Bound here, next to the flag: see the init-order note in completion.go. + rebootCmd.RegisterFlagCompletionFunc("cluster-id", clusterIDCompletion()) //nolint:errcheck +} + +func runReboot(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + if err := requireClusterID(clusterID); err != nil { + return err + } + + body := vdbclient.ActionBody(vdbclient.ResourceTypeInstance, clusterID, "reboot", nil) + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + vdbclient.PreviewBody("reboot", fmt.Sprintf("PostgreSQL Cluster %s", clusterID), body) + return nil + } + if !cli.Confirm(force, fmt.Sprintf( + "Reboot PostgreSQL Cluster %s? Open connections will be dropped.", clusterID)) { + fmt.Println("Aborted.") + return nil + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Post(relPath(clusterID, "/reboot"), body) + if err != nil { + return fmt.Errorf("failed to reboot PostgreSQL Cluster %s: %w", clusterID, err) + } + + return vdbclient.Output(cmd, result) +} diff --git a/go/cmd/vdb/postgresql/cluster/resize.go b/go/cmd/vdb/postgresql/cluster/resize.go new file mode 100644 index 0000000..442c797 --- /dev/null +++ b/go/cmd/vdb/postgresql/cluster/resize.go @@ -0,0 +1,152 @@ +package cluster + +import ( + "fmt" + "strings" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// resizeTypes are the API's three resize modes. Each one reads exactly one of the +// new-value flags and ignores the others, so the command requires the matching +// flag instead of letting a silent no-op order go through. +var resizeTypes = []string{"VOLUME-SIZE", "VOLUME-TYPE", "NUMBER-OF-NODES"} + +// resizeFlagFor maps a resize type to the flag that carries its new value. +var resizeFlagFor = map[string]string{ + "VOLUME-SIZE": "volume-size", + "VOLUME-TYPE": "volume-type-id", + "NUMBER-OF-NODES": "number-of-nodes", +} + +var resizeCmd = &cobra.Command{ + Use: "resize", + Short: "Resize a PostgreSQL Cluster", + Long: "Change a cluster's storage size, storage type or node count.\n\n" + + "THIS COSTS MONEY: like create, resize goes through the order/payment flow and " + + "completes asynchronously. Use --dry-run first.\n\n" + + "One resize does one thing: --type picks which, and only that dimension's flag " + + "is read (VOLUME-SIZE reads --volume-size, VOLUME-TYPE reads --volume-type-id, " + + "NUMBER-OF-NODES reads --number-of-nodes). Storage can normally only grow.", + Args: cobra.NoArgs, + RunE: runResize, +} + +// resizeFlagsOn defines the flags on f; see the note on createFlags. +func resizeFlagsOn(f *pflag.FlagSet) { + f.String("cluster-id", "", "PostgreSQL Cluster ID (required)") + f.String("type", "", fmt.Sprintf("What to resize: %s (required)", strings.Join(resizeTypes, ", "))) + f.Int("volume-size", 0, "New volume size in GB (with --type VOLUME-SIZE)") + f.String("volume-type-id", "", "New volume type ID, 'pgst-...' (with --type VOLUME-TYPE)") + f.Int("number-of-nodes", 0, fmt.Sprintf("New node count, %d-%d (with --type NUMBER-OF-NODES)", minNodes, maxNodes)) + f.Bool("poc", false, "Pay with PoC credit (Auto Payment only)") + f.Bool("dry-run", false, "Print the request that would be sent without placing an order") + f.Bool("force", false, "Skip the confirmation prompt") +} + +func init() { + resizeFlagsOn(resizeCmd.Flags()) + + resizeCmd.MarkFlagRequired("cluster-id") //nolint:errcheck + resizeCmd.MarkFlagRequired("type") //nolint:errcheck + + // Bound here, next to the flags: see the init-order note in completion.go. + resizeCmd.RegisterFlagCompletionFunc("cluster-id", clusterIDCompletion()) //nolint:errcheck + resizeCmd.RegisterFlagCompletionFunc("type", cli.FlagValues(resizeTypes...)) //nolint:errcheck + resizeCmd.RegisterFlagCompletionFunc("volume-type-id", volumeTypeIDCompletion()) //nolint:errcheck +} + +func runResize(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + if err := requireClusterID(clusterID); err != nil { + return err + } + + body, summary, err := resizeBody(cmd) + if err != nil { + return err + } + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + vdbclient.PreviewBody("resize", fmt.Sprintf("PostgreSQL Cluster %s", clusterID), body) + return nil + } + if !cli.Confirm(force, fmt.Sprintf( + "Resize PostgreSQL Cluster %s (%s)? This places a paid order.", clusterID, summary)) { + fmt.Println("Aborted.") + return nil + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Put(pgPath(clusterID, "/resize"), body) + if err != nil { + return fmt.Errorf("failed to resize PostgreSQL Cluster %s: %w", clusterID, err) + } + + return vdbclient.Output(cmd, result) +} + +// resizeBody assembles ResizePostgreClusterRequest and returns a one-line summary +// of the change for the confirmation prompt. +func resizeBody(cmd *cobra.Command) (map[string]interface{}, string, error) { + flags := cmd.Flags() + + resizeType, _ := flags.GetString("type") + resizeType = strings.ToUpper(resizeType) + + flagName, ok := resizeFlagFor[resizeType] + if !ok { + return nil, "", fmt.Errorf("invalid --type '%s': must be one of %s", + resizeType, strings.Join(resizeTypes, ", ")) + } + + poc, _ := flags.GetBool("poc") + body := map[string]interface{}{"type": resizeType, "isPoc": poc} + + var summary string + switch resizeType { + case "VOLUME-SIZE": + size, _ := flags.GetInt("volume-size") + if size <= 0 { + return nil, "", missingResizeFlag(resizeType, flagName) + } + body["volumeSize"] = size + summary = fmt.Sprintf("volume size -> %d GB", size) + case "VOLUME-TYPE": + volumeTypeID, _ := flags.GetString("volume-type-id") + if volumeTypeID == "" { + return nil, "", missingResizeFlag(resizeType, flagName) + } + body["volumeTypeId"] = volumeTypeID + summary = fmt.Sprintf("volume type -> %s", volumeTypeID) + case "NUMBER-OF-NODES": + nodes, _ := flags.GetInt("number-of-nodes") + // Distinguish "flag not given" from "given out of range", so the error + // names the flag the user actually forgot. + if nodes == 0 { + return nil, "", missingResizeFlag(resizeType, flagName) + } + if nodes < minNodes || nodes > maxNodes { + return nil, "", fmt.Errorf("invalid --number-of-nodes %d: a PostgreSQL Cluster takes %d to %d nodes", + nodes, minNodes, maxNodes) + } + body["numberOfNodes"] = nodes + summary = fmt.Sprintf("node count -> %d", nodes) + } + + return body, summary, nil +} + +func missingResizeFlag(resizeType, flagName string) error { + return fmt.Errorf("--type %s requires --%s", resizeType, flagName) +} diff --git a/go/cmd/vdb/postgresql/cluster/update_config_group.go b/go/cmd/vdb/postgresql/cluster/update_config_group.go new file mode 100644 index 0000000..84e010c --- /dev/null +++ b/go/cmd/vdb/postgresql/cluster/update_config_group.go @@ -0,0 +1,94 @@ +package cluster + +import ( + "fmt" + "strings" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +var updateConfigGroupCmd = &cobra.Command{ + Use: "update-config-group", + Short: "Attach or detach the config group of a PostgreSQL Cluster", + Long: "Attach a config group to a cluster, or detach the current one with --detach.\n\n" + + "Only config groups with deploy type 'cluster' are accepted — see " + + "'catalog list-config-groups'. Changing configuration parameters can restart the " + + "database, so the command confirms first.", + Args: cobra.NoArgs, + RunE: runUpdateConfigGroup, +} + +func init() { + f := updateConfigGroupCmd.Flags() + f.String("cluster-id", "", "PostgreSQL Cluster ID (required)") + f.String("config-id", "", "Config group ID to attach (required unless --detach)") + f.Bool("detach", false, "Detach the current config group instead of attaching one") + f.Bool("dry-run", false, "Print the request that would be sent without applying it") + f.Bool("force", false, "Skip the confirmation prompt") + + updateConfigGroupCmd.MarkFlagRequired("cluster-id") //nolint:errcheck + // Mutually exclusive rather than "empty means detach": the API detaches on an + // empty string, which is far too easy to send by accident from a script whose + // variable is unset. + updateConfigGroupCmd.MarkFlagsMutuallyExclusive("config-id", "detach") + + // Bound here, next to the flags: see the init-order note in completion.go. + updateConfigGroupCmd.RegisterFlagCompletionFunc("cluster-id", clusterIDCompletion()) //nolint:errcheck + updateConfigGroupCmd.RegisterFlagCompletionFunc("config-id", configIDCompletion()) //nolint:errcheck +} + +func runUpdateConfigGroup(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + if err := requireClusterID(clusterID); err != nil { + return err + } + + configID, _ := cmd.Flags().GetString("config-id") + detach, _ := cmd.Flags().GetBool("detach") + + if !detach && configID == "" { + return fmt.Errorf("pass --config-id to attach a config group, or --detach to remove the current one") + } + + // An empty configGroupId is what the API reads as "detach". + body := map[string]interface{}{"configGroupId": configID} + + action := fmt.Sprintf("attach config group %s to", configID) + if detach { + action = "detach the config group of" + } + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + vdbclient.PreviewBody("update", fmt.Sprintf("the config group of PostgreSQL Cluster %s", clusterID), body) + return nil + } + if !cli.Confirm(force, fmt.Sprintf("%s PostgreSQL Cluster %s? This may restart the database.", + capitalize(action), clusterID)) { + fmt.Println("Aborted.") + return nil + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Put(pgPath(clusterID, "/config-group"), body) + if err != nil { + return fmt.Errorf("failed to update the config group of PostgreSQL Cluster %s: %w", clusterID, err) + } + + return vdbclient.Output(cmd, result) +} + +func capitalize(s string) string { + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} diff --git a/go/cmd/vdb/postgresql/cluster/update_secrule.go b/go/cmd/vdb/postgresql/cluster/update_secrule.go new file mode 100644 index 0000000..d587c3e --- /dev/null +++ b/go/cmd/vdb/postgresql/cluster/update_secrule.go @@ -0,0 +1,112 @@ +package cluster + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// postgresPort is the port a PostgreSQL Cluster listens on, and the only value +// the API documents for this product, so a rule that omits a port gets it. +const postgresPort = 5432 + +var updateSecruleCmd = &cobra.Command{ + Use: "update-secrule", + Short: "Replace the security rules of a PostgreSQL Cluster", + Long: "Set which networks may reach a cluster.\n\n" + + "The API REPLACES the entire rule set with what is sent, so by default the rules " + + "you pass become the only rules — anything currently allowed and not repeated is " + + "revoked. Pass --add to keep the existing rules and append instead.\n\n" + + "Each --rule is a comma-separated list of key=value pairs:\n" + + " cidr the allowed network, e.g. cidr=203.0.113.0/24 (required)\n" + + " port single port; defaults to 5432\n" + + " port-min low end of a port range (use with port-max)\n" + + " port-max high end of a port range\n" + + " id existing rule ID, to keep a rule you are re-sending\n\n" + + "Repeat --rule for each rule. Run 'cluster list-secrules' first and --dry-run to " + + "see exactly what would be sent; locking yourself out is the failure mode here.", + Args: cobra.NoArgs, + RunE: runUpdateSecrule, +} + +func init() { + f := updateSecruleCmd.Flags() + f.String("cluster-id", "", "PostgreSQL Cluster ID (required)") + f.StringArray("rule", nil, "Rule as key=value pairs, repeatable (see --help)") + f.Bool("add", false, "Append to the existing rules instead of replacing them") + f.Bool("dry-run", false, "Print the request that would be sent without applying it") + f.Bool("force", false, "Skip the confirmation prompt") + + updateSecruleCmd.MarkFlagRequired("cluster-id") //nolint:errcheck + updateSecruleCmd.MarkFlagRequired("rule") //nolint:errcheck + + // Bound here, next to the flag: see the init-order note in completion.go. + updateSecruleCmd.RegisterFlagCompletionFunc("cluster-id", clusterIDCompletion()) //nolint:errcheck +} + +func runUpdateSecrule(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + if err := requireClusterID(clusterID); err != nil { + return err + } + + specs, _ := cmd.Flags().GetStringArray("rule") + rules, err := vdbclient.ParseSecurityRules(specs, postgresPort) + if err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + add, _ := cmd.Flags().GetBool("add") + existing, err := currentRules(apiClient, clusterID) + if err != nil { + return err + } + if add { + rules = append(existing, rules...) + } + + fmt.Printf("Current rules on %s: %s\n", clusterID, vdbclient.DescribeSecurityRules(existing)) + fmt.Printf("Rules after this change: %s\n", vdbclient.DescribeSecurityRules(rules)) + if !add && len(existing) > 0 { + fmt.Println("\nThis REPLACES the rule set; anything above that is not in the new set is revoked.") + } + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + // The body is a bare JSON ARRAY, not an object — one of the six vdb + // endpoints shaped that way. + vdbclient.PreviewBody("update", fmt.Sprintf("the security rules of PostgreSQL Cluster %s", clusterID), rules) + return nil + } + if !cli.Confirm(force, fmt.Sprintf("Apply this rule set to PostgreSQL Cluster %s?", clusterID)) { + fmt.Println("Aborted.") + return nil + } + + result, err := apiClient.Put(relPath(clusterID, "/secrules"), rules) + if err != nil { + return fmt.Errorf("failed to update the security rules of PostgreSQL Cluster %s: %w", clusterID, err) + } + + return vdbclient.OutputWithColumns(cmd, result, vdbclient.SecurityRuleColumns) +} + +// currentRules reads the cluster's rules in the shape the update endpoint expects. +func currentRules(apiClient interface { + Get(string, map[string]string) (interface{}, error) +}, clusterID string) ([]interface{}, error) { + result, err := fetchSecrules(apiClient, clusterID) + if err != nil { + return nil, err + } + return vdbclient.SecurityRulesFrom(result), nil +} diff --git a/go/cmd/vdb/postgresql/cluster/update_settings.go b/go/cmd/vdb/postgresql/cluster/update_settings.go new file mode 100644 index 0000000..3dcca97 --- /dev/null +++ b/go/cmd/vdb/postgresql/cluster/update_settings.go @@ -0,0 +1,116 @@ +package cluster + +import ( + "fmt" + "os" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +var updateSettingsCmd = &cobra.Command{ + Use: "update-settings", + Short: "Update the master password or public access of a PostgreSQL Cluster", + Long: "Change a cluster's master password, its public-access setting, or both.\n\n" + + "Only the settings you pass are sent: --public-access is read solely when you " + + "give it, so updating the password cannot silently flip network exposure.\n\n" + + "The new password is read from $" + passwordEnv + " when --password is omitted, and " + + "is masked in --dry-run output. Both changes affect live connections, so the " + + "command confirms first.", + Args: cobra.NoArgs, + RunE: runUpdateSettings, +} + +// settingsFlagsOn defines the flags on f; see the note on createFlags. +func settingsFlagsOn(f *pflag.FlagSet) { + f.String("cluster-id", "", "PostgreSQL Cluster ID (required)") + f.String("password", "", "New master password (defaults to $"+passwordEnv+")") + f.Bool("public-access", false, "Whether the cluster is reachable publicly") + f.Bool("dry-run", false, "Print the request that would be sent without applying it") + f.Bool("force", false, "Skip the confirmation prompt") +} + +func init() { + settingsFlagsOn(updateSettingsCmd.Flags()) + + updateSettingsCmd.MarkFlagRequired("cluster-id") //nolint:errcheck + + // Bound here, next to the flag: see the init-order note in completion.go. + updateSettingsCmd.RegisterFlagCompletionFunc("cluster-id", clusterIDCompletion()) //nolint:errcheck +} + +func runUpdateSettings(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + if err := requireClusterID(clusterID); err != nil { + return err + } + + body, summary, err := settingsBody(cmd) + if err != nil { + return err + } + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + vdbclient.PreviewBody("update", fmt.Sprintf("the settings of PostgreSQL Cluster %s", clusterID), body) + return nil + } + if !cli.Confirm(force, fmt.Sprintf("Update %s on PostgreSQL Cluster %s?", summary, clusterID)) { + fmt.Println("Aborted.") + return nil + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Put(pgPath(clusterID, "/settings"), body) + if err != nil { + return fmt.Errorf("failed to update settings of PostgreSQL Cluster %s: %w", clusterID, err) + } + + return vdbclient.Output(cmd, result) +} + +// settingsBody assembles UpdatePostgreClusterSettingsRequest from the flags the +// user actually set, and describes the change for the prompt. +// +// Sending only changed fields matters here: publicAccess is a boolean whose zero +// value is a real, destructive setting. A body that always carried +// "publicAccess": false would cut off public connectivity every time someone +// rotated a password. +func settingsBody(cmd *cobra.Command) (map[string]interface{}, string, error) { + flags := cmd.Flags() + body := map[string]interface{}{} + var changes []string + + password, _ := flags.GetString("password") + if password == "" { + password = os.Getenv(passwordEnv) + } + if password != "" { + body["password"] = password + changes = append(changes, "the master password") + } + + if flags.Changed("public-access") { + publicAccess, _ := flags.GetBool("public-access") + body["publicAccess"] = publicAccess + changes = append(changes, fmt.Sprintf("public access (-> %t)", publicAccess)) + } + + if len(body) == 0 { + return nil, "", fmt.Errorf("nothing to update: pass --password (or set $%s) and/or --public-access", passwordEnv) + } + + summary := changes[0] + if len(changes) > 1 { + summary = changes[0] + " and " + changes[1] + } + return body, summary, nil +} diff --git a/go/cmd/vdb/postgresql/postgresql.go b/go/cmd/vdb/postgresql/postgresql.go new file mode 100644 index 0000000..cc28205 --- /dev/null +++ b/go/cmd/vdb/postgresql/postgresql.go @@ -0,0 +1,37 @@ +// Package postgresql is the command root for vDB PostgreSQL Cluster. +// +// The product is unusual: a cluster is served by 21 API operations spread over +// TWO prefixes — 14 of its own under /vdb-postgresql/v1, and 7 borrowed from +// /vdb-relational/v1 (list, get-by-id, histories, secrules read+write, reboot and +// delete), which the PostgreSQL group simply does not have. The command tree hides +// that split: every command a user needs is here, and each one calls whichever +// prefix actually serves it. See go/cmd/vdb/CLAUDE.md for the full map. +package postgresql + +import ( + "github.com/greennodehub/greennode-cli/cmd/vdb/postgresql/backup" + "github.com/greennodehub/greennode-cli/cmd/vdb/postgresql/catalog" + "github.com/greennodehub/greennode-cli/cmd/vdb/postgresql/cluster" + "github.com/spf13/cobra" +) + +// PostgresqlCmd groups the PostgreSQL Cluster commands. +var PostgresqlCmd = &cobra.Command{ + Use: "postgresql", + Short: "Manage PostgreSQL Cluster resources", + Long: "Manage vDB PostgreSQL Clusters: cluster lifecycle, backups and the " + + "catalog of flavors, volume types and backup policies they are built from.\n\n" + + "PostgreSQL Cluster is a distinct product from the single-instance PostgreSQL " + + "offered by 'grn vdb relational' — a cluster has 2-10 nodes, its own flavors " + + "and its own backup service. Cluster IDs start with 'pg-'.", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() //nolint:errcheck + }, +} + +func init() { + PostgresqlCmd.AddCommand(cluster.ClusterCmd) + PostgresqlCmd.AddCommand(catalog.CatalogCmd) + PostgresqlCmd.AddCommand(backup.BackupCmd) +} diff --git a/go/cmd/vdb/relational/backup/backup.go b/go/cmd/vdb/relational/backup/backup.go new file mode 100644 index 0000000..6e6b2ef --- /dev/null +++ b/go/cmd/vdb/relational/backup/backup.go @@ -0,0 +1,84 @@ +// Package backup holds the Relational Database backup commands. +// +// Two things set this API apart from its MemoryStore and PostgreSQL Cluster +// counterparts, and both are load-bearing: +// +// - Deleting a backup is `DELETE /backups/{id}/delete` with a JSON **ARRAY** +// body — the ID travels in the path AND in the array. MemoryStore uses +// `POST /backups/delete` instead. +// - Restoring does not restore in place. It creates a NEW instance from the +// backup, through the order/payment flow, with a request that describes the +// whole target instance. See restore.go. +package backup + +import ( + "github.com/greennodehub/greennode-cli/internal/validator" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// BackupCmd is the parent command for Relational Database backup commands. +var BackupCmd = &cobra.Command{ + Use: "backup", + Short: "Manage Relational Database backups", + Long: "List, inspect, create, delete and restore backups of vDB Relational " + + "Database instances.\n\n" + + "Backups are billed against your backup storage quota beyond the free " + + "allowance — see 'backup get-free-storage' and " + + "'grn vdb relational backup-storage'.", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() //nolint:errcheck + }, +} + +const ( + basePath = "/vdb-relational/v1/backups" + + // instanceIDPrefix / backupIDPrefix: the listing endpoints are shared with + // PostgreSQL Cluster, and a backup ID is not an instance ID, so both are + // checked before they reach a path. + instanceIDPrefix = "db-" + backupIDPrefix = "bk-" +) + +func init() { + BackupCmd.AddCommand(listCmd) + BackupCmd.AddCommand(getCmd) + BackupCmd.AddCommand(createCmd) + BackupCmd.AddCommand(deleteCmd) + BackupCmd.AddCommand(restoreCmd) + BackupCmd.AddCommand(getFreeStorageCmd) +} + +func createClient(cmd *cobra.Command) (*vdbclient.Client, error) { + return vdbclient.BuildClient(cmd) +} + +// requireBackupID rejects anything that is not a backup ID. Passing an instance ID +// to `backup delete` would otherwise reach the API as a path segment. +func requireBackupID(backupID string) error { + return vdbclient.RequireIDWithPrefix(backupID, "backup-id", backupIDPrefix, + "Backup IDs start with 'bk-'; run 'backup list' to find one") +} + +func validateInstanceID(instanceID string) error { + return validator.ValidateID(instanceID, "instance-id") +} + +// backupColumns is the table view of a BackupInfo. The record carries 35 fields, +// including the full instance spec the backup was taken from (ram, vcpu, packageId, +// netIds, username) — those are what `backup restore` reads, and JSON output keeps +// them. +var backupColumns = []string{ + "id", "name", "dbInstanceId", "instanceName", "backupType", + "status", "size", "datastoreType", "datastoreVersion", "created", +} + +// instanceBackupColumns is the same view minus the instance identity, for the +// per-instance listing: that endpoint returns dbInstanceId and instanceName empty +// on every row (verified live). +var instanceBackupColumns = []string{ + "id", "name", "backupType", "status", "size", "parentName", + "datastoreType", "datastoreVersion", "created", +} diff --git a/go/cmd/vdb/relational/backup/backup_test.go b/go/cmd/vdb/relational/backup/backup_test.go new file mode 100644 index 0000000..83a866b --- /dev/null +++ b/go/cmd/vdb/relational/backup/backup_test.go @@ -0,0 +1,280 @@ +package backup + +import ( + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// liveBackup is a captured record of a real backup, used to test the defaulting +// that `restore` does. Note netIds: the API recorded the NETWORK the instance sits +// in, not the subnet the restore request needs. +func liveBackup() map[string]interface{} { + return map[string]interface{}{ + "id": "bk-740f7917-8d6a-4a8d-a464-ce823c8b68b8", + "name": "phase4-test-backup", + "dbInstanceId": "db-ceb5fd7b-4483-462a-be42-fb66bc44c4de", + "backupType": "FULL", + "status": "COMPLETED", + "datastoreType": "MySQL", + "datastoreVersion": "8.0", + "packageId": "180", + "storageType": "Gen2-NVMe2-IOPS3000-HCM03-1B", + "storageSize": float64(30), + "configId": "cfg-b8f60c5c-e96a-42a8-aca6-8f0f7e5a7530", + "username": "test_user", + "netIds": []interface{}{"net-ed67af02-c24d-48ba-9db8-ca9d77fcce0c"}, + } +} + +func freshCmd(t *testing.T, define func(*pflag.FlagSet), values map[string]string) *cobra.Command { + t.Helper() + + cmd := &cobra.Command{Use: "test"} + define(cmd.Flags()) + for name, value := range values { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("setting --%s=%q: %v", name, value, err) + } + } + return cmd +} + +func TestRequireBackupIDRejectsOtherResources(t *testing.T) { + if err := requireBackupID("bk-740f7917-8d6a"); err != nil { + t.Errorf("valid backup ID rejected: %v", err) + } + // An instance ID reaching a backup path is the mistake this prevents. + for _, id := range []string{"db-ceb5fd7b-4483", "pg-2e6f2253", "", "bk id with spaces"} { + if err := requireBackupID(id); err == nil { + t.Errorf("requireBackupID(%q) = nil, want an error", id) + } + } +} + +func TestCreateBodyIncrementalNeedsParent(t *testing.T) { + body, err := createBody(freshCmd(t, createFlagsForTest, map[string]string{ + "name": "b1", "backup-type": "FULL", "description": "nightly", + }), "db-1") + if err != nil { + t.Fatalf("createBody: %v", err) + } + if body["dbInstanceId"] != "db-1" || body["name"] != "b1" || body["backupType"] != "FULL" { + t.Errorf("body = %v", body) + } + if body["description"] != "nightly" { + t.Errorf("description = %v", body["description"]) + } + if _, present := body["parentId"]; present { + t.Error("body carries parentId although the flag was not set") + } + + // Lowercase input is accepted and normalised, since the API's values are uppercase. + body, err = createBody(freshCmd(t, createFlagsForTest, map[string]string{ + "name": "b2", "backup-type": "incremental", "parent-id": "bk-parent", "description": "d", + }), "db-1") + if err != nil { + t.Fatalf("createBody incremental: %v", err) + } + if body["backupType"] != "INCREMENTAL" || body["parentId"] != "bk-parent" { + t.Errorf("body = %v", body) + } + + for name, flags := range map[string]map[string]string{ + "incremental without a parent": {"name": "b", "backup-type": "INCREMENTAL", "description": "d"}, + "parent on a full backup": {"name": "b", "backup-type": "FULL", "parent-id": "bk-parent", "description": "d"}, + "unknown type": {"name": "b", "backup-type": "SNAPSHOT", "description": "d"}, + "parent that is not a backup": {"name": "b", "backup-type": "INCREMENTAL", "parent-id": "db-1", "description": "d"}, + // An empty description is accepted by the API and then fails the backup + // asynchronously — the CLI refuses it up front. + "empty description": {"name": "b", "backup-type": "FULL", "description": ""}, + "whitespace description": {"name": "b", "backup-type": "FULL", "description": " "}, + } { + if _, err := createBody(freshCmd(t, createFlagsForTest, flags), "db-1"); err == nil { + t.Errorf("%s: createBody = nil error, want a rejection", name) + } + } +} + +// createFlagsForTest mirrors create's flag set. create defines its flags inline in +// init(), so this keeps the test independent of the mounted command. +func createFlagsForTest(f *pflag.FlagSet) { + f.String("name", "", "") + f.String("backup-type", "FULL", "") + f.String("parent-id", "", "") + f.String("description", "", "") +} + +// TestRestoreBodyDefaultsFromTheBackup: a restore needs a full instance spec, and +// every part of it except the name is already recorded in the backup. +func TestRestoreBodyDefaultsFromTheBackup(t *testing.T) { + cmd := freshCmd(t, restoreFlags, map[string]string{ + "backup-id": "bk-740f7917-8d6a-4a8d-a464-ce823c8b68b8", + "name": "restored", + // Placement is the user's choice, so both are required rather than inherited. + "zone-id": "HCM03-1B", + "subnet-ids": "sub-7cc39ad2", + }) + + body, err := restoreBody(cmd.Flags(), "bk-740f7917-8d6a-4a8d-a464-ce823c8b68b8", liveBackup()) + if err != nil { + t.Fatalf("restoreBody: %v", err) + } + + // The envelope: action restore_backup, resource type dbaas-backup, and a detail + // with only a config. + if body["action"] != "restore_backup" || body["resourceType"] != "dbaas-backup" { + t.Errorf("envelope = %v", body) + } + detail := body["databaseInstances"].([]interface{})[0].(map[string]interface{}) + if _, present := detail["instancesId"]; present { + t.Error("restore must not send instancesId") + } + + config := detail["config"].(map[string]interface{}) + for field, want := range map[string]interface{}{ + "backupId": "bk-740f7917-8d6a-4a8d-a464-ce823c8b68b8", + "name": "restored", + "datastoreType": "MySQL", + "datastoreVersion": "8.0", + "packageId": "180", + // storageType -> volumeType and storageSize -> volumeSize: the field names + // differ between a backup record and this request. + "volumeType": "Gen2-NVMe2-IOPS3000-HCM03-1B", + "volumeSize": 30, + "configId": "cfg-b8f60c5c-e96a-42a8-aca6-8f0f7e5a7530", + "locateZoneId": "HCM03-1B", + } { + if config[field] != want { + t.Errorf("config[%q] = %v, want %v", field, config[field], want) + } + } + if netIDs := config["netIds"].([]interface{}); len(netIDs) != 1 || netIDs[0] != "sub-7cc39ad2" { + t.Errorf("netIds = %v", config["netIds"]) + } +} + +// TestRestoreBodyNeedsPlacement: zone and subnet are the user's decision, and the +// backup cannot supply either — its netIds field records the NETWORK the original +// instance sat in, not a subnet. Sending that network would be rejected by the API. +func TestRestoreBodyNeedsPlacement(t *testing.T) { + for name, flags := range map[string]map[string]string{ + "no zone": {"backup-id": "bk-1", "name": "r", "subnet-ids": "sub-1"}, + "no subnet": {"backup-id": "bk-1", "name": "r", "zone-id": "HCM03-1B"}, + } { + cmd := freshCmd(t, restoreFlags, flags) + if _, err := restoreBody(cmd.Flags(), "bk-1", liveBackup()); err == nil { + t.Errorf("%s: restoreBody = nil error, want a rejection", name) + } + } + + // And the network from the backup is never silently reused as a subnet. + cmd := freshCmd(t, restoreFlags, map[string]string{ + "backup-id": "bk-1", "name": "r", "zone-id": "HCM03-1B", "subnet-ids": "sub-1", + }) + body, err := restoreBody(cmd.Flags(), "bk-1", liveBackup()) + if err != nil { + t.Fatalf("restoreBody: %v", err) + } + config := body["databaseInstances"].([]interface{})[0].(map[string]interface{})["config"].(map[string]interface{}) + netIDs := config["netIds"].([]interface{}) + if len(netIDs) != 1 || netIDs[0] != "sub-1" { + t.Errorf("netIds = %v, want only the subnet that was passed", netIDs) + } +} + +// TestRestoreIsRequiredToPlaceTheInstance pins the flags as required, since the API +// gives a misleading error when the zone is missing (it blames the flavor and the +// volume type instead). +func TestRestoreIsRequiredToPlaceTheInstance(t *testing.T) { + for _, flag := range []string{"backup-id", "name", "zone-id", "subnet-ids"} { + annotations := restoreCmd.Flags().Lookup(flag).Annotations[cobra.BashCompOneRequiredFlag] + if len(annotations) == 0 || annotations[0] != "true" { + t.Errorf("--%s must be marked required on backup restore", flag) + } + } +} + +func TestRestoreBodyBackupSchedule(t *testing.T) { + base := map[string]string{"backup-id": "bk-1", "name": "r", "zone-id": "HCM03-1B", "subnet-ids": "sub-1"} + with := func(extra map[string]string) map[string]string { + flags := map[string]string{} + for k, v := range base { + flags[k] = v + } + for k, v := range extra { + flags[k] = v + } + return flags + } + + body, err := restoreBody(freshCmd(t, restoreFlags, with(map[string]string{ + "backup-auto": "true", "backup-duration": "7", "backup-time": "02:00", + })).Flags(), "bk-1", liveBackup()) + if err != nil { + t.Fatalf("restoreBody: %v", err) + } + config := body["databaseInstances"].([]interface{})[0].(map[string]interface{})["config"].(map[string]interface{}) + if config["backupDuration"] != 7 || config["backupTime"] != "02:00" { + t.Errorf("schedule = %v / %v", config["backupDuration"], config["backupTime"]) + } + + for name, flags := range map[string]map[string]string{ + "retention without backup-auto": {"backup-duration": "7"}, + "backup on without a schedule": {"backup-auto": "true"}, + "retention out of range": {"backup-auto": "true", "backup-duration": "1", "backup-time": "02:00"}, + } { + if _, err := restoreBody(freshCmd(t, restoreFlags, with(flags)).Flags(), "bk-1", liveBackup()); err == nil { + t.Errorf("%s: accepted, want a rejection", name) + } + } +} + +func TestCommandsAndGates(t *testing.T) { + want := map[string]bool{ + "list": true, "get": true, "create": true, + "delete": true, "restore": true, "get-free-storage": true, + } + for _, sub := range BackupCmd.Commands() { + delete(want, sub.Name()) + } + for name := range want { + t.Errorf("backup %s is not registered", name) + } + + // Creating a backup consumes billable storage; deleting one is irreversible; + // restoring places an order. All three are gated. + for _, cmd := range []*cobra.Command{createCmd, deleteCmd, restoreCmd} { + for _, flag := range []string{"dry-run", "force"} { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("backup %s must define --%s", cmd.Name(), flag) + } + } + } +} + +func TestFlagCompletionsAreRegistered(t *testing.T) { + cases := []struct { + cmd *cobra.Command + flags []string + }{ + {listCmd, []string{"instance-id"}}, + {getCmd, []string{"backup-id"}}, + {createCmd, []string{"instance-id", "backup-type", "parent-id"}}, + {deleteCmd, []string{"backup-id"}}, + {restoreCmd, []string{"backup-id", "volume-type", "zone-id", "subnet-ids", "config-id"}}, + } + + for _, c := range cases { + for _, flag := range c.flags { + if c.cmd.Flags().Lookup(flag) == nil { + t.Errorf("%s has no --%s flag", c.cmd.Name(), flag) + continue + } + if _, ok := c.cmd.GetFlagCompletionFunc(flag); !ok { + t.Errorf("%s --%s has no completion function registered", c.cmd.Name(), flag) + } + } + } +} diff --git a/go/cmd/vdb/relational/backup/completion.go b/go/cmd/vdb/relational/backup/completion.go new file mode 100644 index 0000000..c0d9982 --- /dev/null +++ b/go/cmd/vdb/relational/backup/completion.go @@ -0,0 +1,36 @@ +package backup + +import ( + "context" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// BackupResourceKey is the shared completion key for a backup ID. Registered here +// because this package owns the listing; consumers dispatch through the registry. +const BackupResourceKey = "vdb:relational-backup" + +func init() { + cli.RegisterResourceCompleter(BackupResourceKey, cli.FlagFromAPI(backupIDs)) +} + +// backupIDs suggests backup IDs from the paginated listing. One page of 100 is +// plenty for completion; a project with more simply gets the first page. +func backupIDs(_ context.Context, cmd *cobra.Command) ([]string, error) { + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return nil, err + } + + query := vdbclient.BuildListQuery(vdbclient.ListOptions{Page: 1, PageSize: 100}) + result, err := vdbclient.Get(apiClient, basePath, query) + if err != nil { + return nil, err + } + + // The items live under "content", which cli.ExtractIDs finds by scanning for the + // only array in the payload. + return cli.ExtractIDs(vdbclient.Unwrap(result), "id"), nil +} diff --git a/go/cmd/vdb/relational/backup/create.go b/go/cmd/vdb/relational/backup/create.go new file mode 100644 index 0000000..376df03 --- /dev/null +++ b/go/cmd/vdb/relational/backup/create.go @@ -0,0 +1,138 @@ +package backup + +import ( + "fmt" + "strings" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// Backup types the API documents. INCREMENTAL needs a parent backup to build on. +var backupTypes = []string{"FULL", "INCREMENTAL"} + +var createCmd = &cobra.Command{ + Use: "create", + Short: "Take a backup of a Relational Database instance now", + Long: "Create an on-demand backup, in addition to whatever the instance's schedule " + + "produces.\n\n" + + "The backup counts towards your backup storage, which is billable beyond the " + + "free allowance ('backup get-free-storage'), so the command confirms first.\n\n" + + "--backup-type INCREMENTAL requires --parent-id, the backup to build on; the " + + "default FULL needs nothing else.\n\n" + + "--description is REQUIRED even though the API documents it as optional. Without " + + "a non-empty description the request is accepted (HTTP 200, success true, a " + + "backup ID handed back) and the backup then fails in the background with " + + "\"An error occurred when communicating with system\" — measured 4 times out of 4, " + + "against 2 successes with one. Requiring it here turns a silent, delayed failure " + + "into an error before anything is sent.", + Args: cobra.NoArgs, + RunE: runCreate, +} + +func init() { + f := createCmd.Flags() + f.String("instance-id", "", "Instance to back up (required)") + f.String("name", "", "Backup name (required)") + f.String("backup-type", "FULL", fmt.Sprintf("Backup type: %s", strings.Join(backupTypes, " or "))) + f.String("parent-id", "", "Parent backup ID (required with --backup-type INCREMENTAL)") + f.String("description", "", "Free-text description (required — see the note in --help)") + f.Bool("dry-run", false, "Print the request that would be sent without taking a backup") + f.Bool("force", false, "Skip the confirmation prompt") + + createCmd.MarkFlagRequired("instance-id") //nolint:errcheck + createCmd.MarkFlagRequired("name") //nolint:errcheck + createCmd.MarkFlagRequired("description") //nolint:errcheck + + createCmd.RegisterFlagCompletionFunc("instance-id", cli.ResourceCompletion(instanceResourceKey)) //nolint:errcheck + createCmd.RegisterFlagCompletionFunc("backup-type", cli.FlagValues(backupTypes...)) //nolint:errcheck + createCmd.RegisterFlagCompletionFunc("parent-id", cli.ResourceCompletion(BackupResourceKey)) //nolint:errcheck +} + +func runCreate(cmd *cobra.Command, args []string) error { + instanceID, _ := cmd.Flags().GetString("instance-id") + if err := vdbclient.RequireIDWithPrefix(instanceID, "instance-id", instanceIDPrefix, + "Relational Database instance IDs start with 'db-'; PostgreSQL Cluster backups live under 'grn vdb postgresql backup'"); err != nil { + return err + } + + body, err := createBody(cmd, instanceID) + if err != nil { + return err + } + + name, _ := cmd.Flags().GetString("name") + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + vdbclient.PreviewBody("create", fmt.Sprintf("backup %q of %s", name, instanceID), body) + return nil + } + if !cli.Confirm(force, fmt.Sprintf( + "Take a %v backup %q of database instance %s? It counts towards billable backup storage.", + body["backupType"], name, instanceID)) { + fmt.Println("Aborted.") + return nil + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Post(basePath+"/create", body) + if err != nil { + return fmt.Errorf("failed to create backup %q of database instance %s: %w", name, instanceID, err) + } + + return vdbclient.Output(cmd, result) +} + +// createBody assembles CreateBackupRequest and enforces the one rule the API states +// but does not validate for us: an incremental backup needs a parent. +func createBody(cmd *cobra.Command, instanceID string) (map[string]interface{}, error) { + flags := cmd.Flags() + + name, _ := flags.GetString("name") + backupType, _ := flags.GetString("backup-type") + parentID, _ := flags.GetString("parent-id") + description, _ := flags.GetString("description") + + backupType = strings.ToUpper(backupType) + if backupType != "FULL" && backupType != "INCREMENTAL" { + return nil, fmt.Errorf("invalid --backup-type %q: must be %s", backupType, strings.Join(backupTypes, " or ")) + } + if backupType == "INCREMENTAL" && parentID == "" { + return nil, fmt.Errorf("--backup-type INCREMENTAL requires --parent-id, the backup to build on") + } + if parentID != "" { + if err := requireBackupID(parentID); err != nil { + return nil, err + } + if backupType != "INCREMENTAL" { + return nil, fmt.Errorf("--parent-id only applies to --backup-type INCREMENTAL") + } + } + + body := map[string]interface{}{ + "dbInstanceId": instanceID, + "name": name, + "backupType": backupType, + } + if parentID != "" { + body["parentId"] = parentID + } + // description is required in practice, not just recommended: an absent OR EMPTY + // one is accepted by the API and then fails asynchronously (verified live, see the + // command's Long text). MarkFlagRequired catches an omitted flag; this catches + // --description "". + if strings.TrimSpace(description) == "" { + return nil, fmt.Errorf("--description must not be empty: the API accepts a backup " + + "without one and then fails it in the background") + } + body["description"] = description + + return body, nil +} diff --git a/go/cmd/vdb/relational/backup/delete.go b/go/cmd/vdb/relational/backup/delete.go new file mode 100644 index 0000000..ecdfbe0 --- /dev/null +++ b/go/cmd/vdb/relational/backup/delete.go @@ -0,0 +1,99 @@ +package backup + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +var deleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete a backup", + Long: "Delete one backup.\n\n" + + "This is irreversible and can break a chain: an INCREMENTAL backup builds on " + + "its parent, so deleting a parent may make its children unrestorable. Check " + + "'backup list --instance-id ' for children before deleting a FULL backup.", + Args: cobra.NoArgs, + RunE: runDelete, +} + +func init() { + f := deleteCmd.Flags() + f.String("backup-id", "", "Backup ID (required)") + f.Bool("dry-run", false, "Print the request that would be sent without deleting") + f.Bool("force", false, "Skip the confirmation prompt") + + deleteCmd.MarkFlagRequired("backup-id") //nolint:errcheck + + deleteCmd.RegisterFlagCompletionFunc("backup-id", cli.ResourceCompletion(BackupResourceKey)) //nolint:errcheck +} + +func runDelete(cmd *cobra.Command, args []string) error { + backupID, _ := cmd.Flags().GetString("backup-id") + if err := requireBackupID(backupID); err != nil { + return err + } + + // The body is a JSON ARRAY and the ID appears in it as well as in the path — one + // of the six vdb endpoints shaped that way. MemoryStore does the same operation + // as POST /backups/delete with no ID in the path at all. + body := []interface{}{ + map[string]interface{}{"backupId": backupID}, + } + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + printDeleteTarget(apiClient, backupID) + + if dryRun { + vdbclient.PreviewBody("delete", fmt.Sprintf("backup %s", backupID), body) + return nil + } + if !cli.Confirm(force, fmt.Sprintf("Delete backup %s? This cannot be undone.", backupID)) { + fmt.Println("Aborted.") + return nil + } + + // DELETE with a body: the shared client has DeleteWithBody for exactly this. + result, err := apiClient.DeleteWithBody(basePath+"/"+backupID+"/delete", body) + if err != nil { + return fmt.Errorf("failed to delete backup %s: %w", backupID, err) + } + + return vdbclient.Output(cmd, result) +} + +// printDeleteTarget shows what is about to go. Best-effort: a backup that cannot be +// read is reported and the user decides. +func printDeleteTarget(apiClient *vdbclient.Client, backupID string) { + result, err := apiClient.Get(basePath+"/detail/"+backupID, nil) + if err != nil { + fmt.Printf("Warning: could not read backup %s before deleting (%v).\n", backupID, err) + return + } + backup, ok := vdbclient.PayloadObject(result) + if !ok { + fmt.Printf("Warning: backup %s returned an empty payload — it may already be gone.\n", backupID) + return + } + + fmt.Println("The following backup will be deleted:") + fmt.Println() + fmt.Printf(" ID: %v\n", backup["id"]) + fmt.Printf(" Name: %v\n", backup["name"]) + fmt.Printf(" Type: %v\n", backup["backupType"]) + fmt.Printf(" Instance: %v (%v)\n", backup["instanceName"], backup["dbInstanceId"]) + fmt.Printf(" Created: %v\n", backup["created"]) + if parent, _ := backup["parent"].(string); parent != "" { + fmt.Printf(" Parent: %v\n", parent) + } + fmt.Println() +} diff --git a/go/cmd/vdb/relational/backup/fields.go b/go/cmd/vdb/relational/backup/fields.go new file mode 100644 index 0000000..c6debb2 --- /dev/null +++ b/go/cmd/vdb/relational/backup/fields.go @@ -0,0 +1,70 @@ +package backup + +import ( + "github.com/spf13/pflag" +) + +// Readers for an API payload, where every JSON number decodes to float64 and any +// field may be null. + +func stringField(payload map[string]interface{}, key string) string { + value, _ := payload[key].(string) + return value +} + +func intField(payload map[string]interface{}, key string) int { + value, _ := payload[key].(float64) + return int(value) +} + +func stringSlice(payload map[string]interface{}, key string) []string { + items, _ := payload[key].([]interface{}) + out := make([]string, 0, len(items)) + for _, item := range items { + if value, ok := item.(string); ok && value != "" { + out = append(out, value) + } + } + return out +} + +// stringOrDefault and friends return the flag when the user set it, otherwise the +// fallback. Changed() rather than emptiness, so "--public-access=false" reads as a +// deliberate override. + +func stringOrDefault(flags *pflag.FlagSet, name, fallback string) string { + if !flags.Changed(name) { + return fallback + } + value, _ := flags.GetString(name) + return value +} + +func intOrDefault(flags *pflag.FlagSet, name string, fallback int) int { + if !flags.Changed(name) { + return fallback + } + value, _ := flags.GetInt(name) + return value +} + +func boolOrDefault(flags *pflag.FlagSet, name string, fallback bool) bool { + if !flags.Changed(name) { + return fallback + } + value, _ := flags.GetBool(name) + return value +} + +func mustString(flags *pflag.FlagSet, name string) string { + value, _ := flags.GetString(name) + return value +} + +func toInterfaces(values []string) []interface{} { + out := make([]interface{}, len(values)) + for i, v := range values { + out[i] = v + } + return out +} diff --git a/go/cmd/vdb/relational/backup/get.go b/go/cmd/vdb/relational/backup/get.go new file mode 100644 index 0000000..6488e5a --- /dev/null +++ b/go/cmd/vdb/relational/backup/get.go @@ -0,0 +1,63 @@ +package backup + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// instanceResourceKey is the completion key registered by the instance package. +// Consumers use the literal, as vks does for the vserver keys, so no package +// dependency is needed just to share a constant. +const instanceResourceKey = "vdb:relational-instance" + +var getCmd = &cobra.Command{ + Use: "get", + Short: "Get details of a backup", + Long: "Show the full record of one backup.\n\n" + + "Beyond the obvious fields it carries the spec of the instance the backup was " + + "taken from — flavor, storage, subnets, config group, username — which is what " + + "'backup restore' uses to build the new instance.", + Args: cobra.NoArgs, + RunE: runGet, +} + +func init() { + f := getCmd.Flags() + f.String("backup-id", "", "Backup ID (required)") + getCmd.MarkFlagRequired("backup-id") //nolint:errcheck + + getCmd.RegisterFlagCompletionFunc("backup-id", cli.ResourceCompletion(BackupResourceKey)) //nolint:errcheck +} + +func runGet(cmd *cobra.Command, args []string) error { + backupID, _ := cmd.Flags().GetString("backup-id") + if err := requireBackupID(backupID); err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + // Note the path shape: /backups/detail/{backupId}. MemoryStore spells the same + // operation /backups/{backupId}/detail. + result, err := apiClient.Get(basePath+"/detail/"+backupID, nil) + if err != nil { + return fmt.Errorf("failed to get backup %s: %w", backupID, err) + } + + // The API answers "not found" with HTTP 200 and a null payload, so a miss has to + // be detected here rather than by the status code. + if _, ok := vdbclient.PayloadObject(result); !ok { + return fmt.Errorf("backup %s not found (the API returned an empty payload; "+ + "a backup whose creation failed reads this way — check 'instance list-histories')", backupID) + } + + // A backup record has nested arrays (netIds, sharedActions), so print it as + // key/value rather than letting table extraction pick one. + return vdbclient.Output(cmd, result) +} diff --git a/go/cmd/vdb/relational/backup/get_free_storage.go b/go/cmd/vdb/relational/backup/get_free_storage.go new file mode 100644 index 0000000..38e615a --- /dev/null +++ b/go/cmd/vdb/relational/backup/get_free_storage.go @@ -0,0 +1,35 @@ +package backup + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +var getFreeStorageCmd = &cobra.Command{ + Use: "get-free-storage", + Short: "Show the free backup storage allowance and how much is used", + Long: "Show the free backup storage that comes with your instances' flavors and how " + + "much of it your backups currently occupy.\n\n" + + "Storage beyond this allowance is billed; buy or resize it with " + + "'grn vdb relational backup-storage'.", + Args: cobra.NoArgs, + RunE: runGetFreeStorage, +} + +func runGetFreeStorage(cmd *cobra.Command, args []string) error { + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Get(basePath+"/free-backup", nil) + if err != nil { + return fmt.Errorf("failed to read free backup storage usage: %w", err) + } + + // Two scalar fields (freeBackupStorage, backupUsage), so a key/value view is the + // whole story. + return vdbclient.Output(cmd, result) +} diff --git a/go/cmd/vdb/relational/backup/list.go b/go/cmd/vdb/relational/backup/list.go new file mode 100644 index 0000000..f982260 --- /dev/null +++ b/go/cmd/vdb/relational/backup/list.go @@ -0,0 +1,67 @@ +package backup + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List backups", + Long: "List the backups in your project, or just those of one instance with " + + "--instance-id.\n\n" + + "These are two different endpoints: without --instance-id the listing is " + + "paginated (items under 'content'), with it the API returns every backup of " + + "that instance as a plain array and --page/--page-size do not apply.", + Args: cobra.NoArgs, + RunE: runList, +} + +func init() { + f := listCmd.Flags() + f.String("instance-id", "", "List only the backups of this instance") + f.Int("page", 1, "Page number (1-based; ignored with --instance-id)") + f.Int("page-size", vdbclient.DefaultPageSize, "Items per page (ignored with --instance-id)") + + // Bound here, next to the flag: see the init-order note in + // cmd/vdb/relational/instance/completion.go. + listCmd.RegisterFlagCompletionFunc("instance-id", cli.ResourceCompletion(instanceResourceKey)) //nolint:errcheck +} + +func runList(cmd *cobra.Command, args []string) error { + instanceID, _ := cmd.Flags().GetString("instance-id") + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + if instanceID != "" { + if err := validateInstanceID(instanceID); err != nil { + return err + } + // Per-instance listing: a bare array, no paging. + result, err := apiClient.Get(basePath+"/insId/"+instanceID, nil) + if err != nil { + return fmt.Errorf("failed to list the backups of database instance %s: %w", instanceID, err) + } + // This endpoint leaves dbInstanceId and instanceName empty on every row — you + // asked by instance, so it does not repeat it back. Showing them would be two + // blank columns. + return vdbclient.OutputWithColumns(cmd, result, instanceBackupColumns) + } + + page, _ := cmd.Flags().GetInt("page") + pageSize, _ := cmd.Flags().GetInt("page-size") + query := vdbclient.BuildListQuery(vdbclient.ListOptions{Page: page, PageSize: pageSize}) + + result, err := vdbclient.Get(apiClient, basePath, query) + if err != nil { + return fmt.Errorf("failed to list backups: %w", err) + } + + return vdbclient.OutputWithColumns(cmd, result, backupColumns) +} diff --git a/go/cmd/vdb/relational/backup/restore.go b/go/cmd/vdb/relational/backup/restore.go new file mode 100644 index 0000000..95c5269 --- /dev/null +++ b/go/cmd/vdb/relational/backup/restore.go @@ -0,0 +1,257 @@ +package backup + +import ( + "fmt" + "os" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// passwordEnv keeps the master password out of shell history. A restore may need +// one, since it builds a new instance. +const passwordEnv = "GRN_VDB_MASTER_PASSWORD" + +const ( + minBackupDuration = 2 + maxBackupDuration = 14 +) + +var restoreCmd = &cobra.Command{ + Use: "restore", + Short: "Create a new Relational Database instance from a backup", + Long: "Restore a backup.\n\n" + + "**This does not restore in place.** The API builds a NEW instance from the " + + "backup and leaves the original untouched — so a restore is a create: it goes " + + "through the order/payment flow, costs money, completes asynchronously, and " + + "needs a name for the new instance.\n\n" + + "You choose where it lands: --zone-id and --subnet-ids are required, because a " + + "restore is a placement decision — the new instance need not sit where the " + + "original did.\n\n" + + "Everything else defaults to the backup's own record: engine, version, flavor, " + + "storage type and size, and config group are read from it. Any of those flags " + + "overrides one; --dry-run shows the resolved request.\n\n" + + "IMPORTANT when restoring into a DIFFERENT zone from the original: flavor ids and " + + "volume type names are zone-specific (id 180 and " + + "'Gen2-NVMe2-IOPS3000-HCM03-1B' exist only in HCM03-1B), so pass --package-id and " + + "--volume-type from that zone's 'grn vdb relational catalog' too. Without a " + + "matching zone the API rejects the request with 'Package ID … is invalid; Volume " + + "type … is invalid'.", + Args: cobra.NoArgs, + RunE: runRestore, +} + +func restoreFlags(f *pflag.FlagSet) { + f.String("backup-id", "", "Backup to restore (required)") + f.String("name", "", "Name of the new instance (required)") + f.String("package-id", "", "Flavor id for the new instance (default: the backup's)") + f.String("volume-type", "", "Volume type NAME (default: the backup's)") + f.Int("volume-size", 0, "Volume size in GB (default: the backup's)") + f.String("zone-id", "", "Availability zone for the new instance (required)") + f.String("subnet-ids", "", "Subnet ID(s) for the new instance, comma-separated (required)") + f.String("config-id", "", "Config group to attach (default: the backup's, if any)") + f.String("password", "", "Master password for the new instance (defaults to $"+passwordEnv+")") + f.Bool("public-access", false, "Allow public access to the new instance") + f.Bool("backup-auto", false, "Enable daily automatic backup on the new instance") + f.Int("backup-duration", 0, fmt.Sprintf("Backup retention in days, %d-%d (with --backup-auto)", minBackupDuration, maxBackupDuration)) + f.String("backup-time", "", "Time of day to run the backup, HH:MM (with --backup-auto)") + f.Bool("poc", false, "Pay with PoC credit (Auto Payment only)") + f.Bool("dry-run", false, "Print the request that would be sent without placing an order") + f.Bool("force", false, "Skip the confirmation prompt") +} + +func init() { + restoreFlags(restoreCmd.Flags()) + + restoreCmd.MarkFlagRequired("backup-id") //nolint:errcheck + restoreCmd.MarkFlagRequired("name") //nolint:errcheck + restoreCmd.MarkFlagRequired("zone-id") //nolint:errcheck + restoreCmd.MarkFlagRequired("subnet-ids") //nolint:errcheck + + restoreCmd.RegisterFlagCompletionFunc("backup-id", cli.ResourceCompletion(BackupResourceKey)) //nolint:errcheck + restoreCmd.RegisterFlagCompletionFunc("volume-type", cli.ResourceCompletion(volumeTypeResourceKey)) //nolint:errcheck + restoreCmd.RegisterFlagCompletionFunc("zone-id", cli.ResourceCompletion(zoneResourceKey)) //nolint:errcheck + restoreCmd.RegisterFlagCompletionFunc("subnet-ids", cli.ResourceCompletion(subnetResourceKey)) //nolint:errcheck + restoreCmd.RegisterFlagCompletionFunc("config-id", cli.ResourceCompletion(configGroupResourceKey)) //nolint:errcheck +} + +// Completion keys owned by the relational catalog package. +const ( + volumeTypeResourceKey = "vdb:relational-volume-type" + zoneResourceKey = "vdb:relational-zone" + subnetResourceKey = "vdb:relational-subnet" + configGroupResourceKey = "vdb:relational-config-group" +) + +func runRestore(cmd *cobra.Command, args []string) error { + backupID, _ := cmd.Flags().GetString("backup-id") + if err := requireBackupID(backupID); err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + backup, err := fetchBackup(apiClient, backupID) + if err != nil { + return err + } + + body, err := restoreBody(cmd.Flags(), backupID, backup) + if err != nil { + return err + } + + name, _ := cmd.Flags().GetString("name") + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + vdbclient.PreviewBody("restore", fmt.Sprintf("backup %s into a new instance %q", backupID, name), body) + return nil + } + if !cli.Confirm(force, fmt.Sprintf( + "Restore backup %s into a NEW instance %q? This places a paid order; the original instance is not touched.", + backupID, name)) { + fmt.Println("Aborted.") + return nil + } + + result, err := apiClient.Post(basePath+"/"+backupID+"/restore", body) + if err != nil { + return fmt.Errorf("failed to restore backup %s: %w", backupID, err) + } + + return vdbclient.Output(cmd, result) +} + +func fetchBackup(apiClient *vdbclient.Client, backupID string) (map[string]interface{}, error) { + result, err := apiClient.Get(basePath+"/detail/"+backupID, nil) + if err != nil { + return nil, fmt.Errorf("failed to read backup %s: %w", backupID, err) + } + backup, ok := vdbclient.PayloadObject(result) + if !ok { + // HTTP 200 with a null payload is how this API says "not found"; without this + // check the restore would be built from an empty record. + return nil, fmt.Errorf("backup %s not found or unusable (the API returned an empty payload; "+ + "a backup whose creation failed reads this way — check 'backup list' and 'instance list-histories')", backupID) + } + return backup, nil +} + +// restoreBody assembles RestoreBackupRequest. +// +// The shape is unlike every other action request: the detail carries ONLY a config — +// no instancesId — because the thing being acted on is named inside it, and the rest +// of the config describes the instance to create. The action is "restore_backup" and +// the resource type "dbaas-backup", not "dbaas". +// +// Field names differ between a backup record and this request, which is the main +// reason the defaulting is centralised here: +// +// backup.storageType -> volumeType backup.storageSize -> volumeSize +// backup.packageId -> packageId backup.netIds -> netIds +func restoreBody(flags *pflag.FlagSet, backupID string, backup map[string]interface{}) (map[string]interface{}, error) { + name, _ := flags.GetString("name") + poc, _ := flags.GetBool("poc") + + config := map[string]interface{}{ + "backupId": backupID, + "name": name, + "poc": poc, + "datastoreType": stringField(backup, "datastoreType"), + "datastoreVersion": stringField(backup, "datastoreVersion"), + "packageId": stringOrDefault(flags, "package-id", stringField(backup, "packageId")), + "volumeType": stringOrDefault(flags, "volume-type", stringField(backup, "storageType")), + "volumeSize": intOrDefault(flags, "volume-size", intField(backup, "storageSize")), + "publicAccess": boolOrDefault(flags, "public-access", false), + } + + // locateZoneId must be sent, though the spec marks it optional: without it the API + // validates the flavor and volume type against some other zone and rejects both + // with "Package ID … is invalid; Volume type … is invalid", even when they are + // exactly what the source instance runs (verified live 2026-08-13). It is a required + // flag rather than a value inherited from the backup, because where a restored + // instance lands is the user's decision. + zoneID, _ := flags.GetString("zone-id") + if zoneID == "" { + return nil, fmt.Errorf("--zone-id must not be empty: a restored instance needs a zone to be placed in") + } + config["locateZoneId"] = zoneID + + // The request wants SUBNET ids ("sub-…"), but a backup record's netIds field can + // hold NETWORK ids ("net-…") — verified on a real backup, whose instance was + // created with a subnet yet whose record reports the network. Defaulting blindly + // would send a network id and fail, so only subnet-shaped values are reused. + // Subnets come from the flag only. The backup's own netIds field is no help: it + // records the NETWORK ("net-…") the original instance sat in, not a subnet, and a + // subnet belongs to one zone anyway — which the user has just chosen. + subnets := cli.ParseCommaSeparated(mustString(flags, "subnet-ids")) + if len(subnets) == 0 { + return nil, fmt.Errorf("--subnet-ids must not be empty: pass a subnet in %s", zoneID) + } + config["netIds"] = toInterfaces(subnets) + + if configID := stringOrDefault(flags, "config-id", stringField(backup, "configId")); configID != "" { + config["configId"] = configID + } + + // A restored instance is a new instance, so it needs master credentials. The + // backup records the username; the password cannot be recovered from it. + password, _ := flags.GetString("password") + if password == "" { + password = os.Getenv(passwordEnv) + } + if password != "" { + if err := vdbclient.ValidateDBPassword(password, "password"); err != nil { + return nil, err + } + config["password"] = password + } + + if err := applyBackupSchedule(flags, config); err != nil { + return nil, err + } + + if config["volumeSize"].(int) <= 0 { + return nil, fmt.Errorf("could not determine a volume size from backup %s: pass --volume-size", backupID) + } + if config["packageId"] == "" { + return nil, fmt.Errorf("could not determine a flavor from backup %s: pass --package-id", backupID) + } + + return vdbclient.ResizeConfigBody(vdbclient.ResourceTypeBackup, "restore_backup", config), nil +} + +// applyBackupSchedule mirrors the create rules: the three backup fields travel +// together or not at all. +func applyBackupSchedule(flags *pflag.FlagSet, config map[string]interface{}) error { + backupAuto, _ := flags.GetBool("backup-auto") + duration, _ := flags.GetInt("backup-duration") + backupTime, _ := flags.GetString("backup-time") + + config["backupAuto"] = backupAuto + + if !backupAuto { + if duration != 0 || backupTime != "" { + return fmt.Errorf("--backup-duration and --backup-time only apply with --backup-auto") + } + return nil + } + if duration < minBackupDuration || duration > maxBackupDuration { + return fmt.Errorf("--backup-auto requires --backup-duration between %d and %d days, got %d", + minBackupDuration, maxBackupDuration, duration) + } + if backupTime == "" { + return fmt.Errorf("--backup-auto requires --backup-time, e.g. --backup-time 02:00") + } + + config["backupDuration"] = duration + config["backupTime"] = backupTime + return nil +} diff --git a/go/cmd/vdb/relational/backupstorage/backupstorage.go b/go/cmd/vdb/relational/backupstorage/backupstorage.go new file mode 100644 index 0000000..77d236b --- /dev/null +++ b/go/cmd/vdb/relational/backupstorage/backupstorage.go @@ -0,0 +1,60 @@ +// Package backupstorage holds the Relational Database backup-storage commands. +// +// Backup storage is a purchased quota, not a per-instance setting: you buy a package, +// resize it, or give it up. Backups beyond the free allowance that comes with your +// instances' flavors consume it — see `grn vdb relational backup get-free-storage`. +// +// Its action requests use the resource type "dbaas-backup-storage", not "dbaas", and +// the resource IDs look like "db-bk-storage-…". +package backupstorage + +import ( + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// BackupStorageCmd is the parent command for backup-storage commands. +var BackupStorageCmd = &cobra.Command{ + Use: "backup-storage", + Short: "Manage Relational Database backup storage", + Long: "Inspect, buy, resize and release the backup storage quota used by vDB " + + "Relational Database backups.\n\n" + + "Backups first consume the free allowance that comes with an instance's flavor; " + + "beyond that they need purchased storage. 'backup-storage list' shows what you " + + "have, 'list-packages' what you can buy.", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() //nolint:errcheck + }, +} + +const ( + basePath = "/vdb-relational/v1/backup-storages" + paymentPath = "/vdb-relational/v1/payment/backup-storages" + + // storageIDPrefix is what the API's own examples show for a backup storage. + storageIDPrefix = "db-bk-storage-" +) + +func init() { + BackupStorageCmd.AddCommand(listCmd) + BackupStorageCmd.AddCommand(listPackagesCmd) + BackupStorageCmd.AddCommand(createCmd) + BackupStorageCmd.AddCommand(resizeCmd) + BackupStorageCmd.AddCommand(deleteCmd) +} + +func createClient(cmd *cobra.Command) (*vdbclient.Client, error) { + return vdbclient.BuildClient(cmd) +} + +func requireStorageID(storageID string) error { + return vdbclient.RequireIDWithPrefix(storageID, "storage-id", storageIDPrefix, + "Backup storage IDs start with 'db-bk-storage-'; run 'backup-storage list' to find one") +} + +// storageColumns is the table view of a BackupStorageDetail: what you own and how +// much of it is in use. +var storageColumns = []string{ + "id", "name", "usage", "quota", "status", "backupPackageName", "engineGroup", +} diff --git a/go/cmd/vdb/relational/backupstorage/backupstorage_test.go b/go/cmd/vdb/relational/backupstorage/backupstorage_test.go new file mode 100644 index 0000000..bf150ad --- /dev/null +++ b/go/cmd/vdb/relational/backupstorage/backupstorage_test.go @@ -0,0 +1,92 @@ +package backupstorage + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestRequireStorageIDRejectsOtherResources(t *testing.T) { + if err := requireStorageID("db-bk-storage-7a0c736d-ba65"); err != nil { + t.Errorf("valid storage ID rejected: %v", err) + } + // An instance ID starts with "db-" too, which is exactly why the prefix checked + // here is the longer "db-bk-storage-". + for _, id := range []string{"db-ceb5fd7b-4483", "bk-740f7917", "", "db-bk-storage id"} { + if err := requireStorageID(id); err == nil { + t.Errorf("requireStorageID(%q) = nil, want an error", id) + } + } +} + +// TestFlattenPackages: the API nests packages under engine groups, and table output +// picks the first array it finds — the outer one — so each package has to become a +// row of its own, keeping its engine group. +func TestFlattenPackages(t *testing.T) { + payload := []interface{}{ + map[string]interface{}{ + "engineGroup": float64(1), + "packages": []interface{}{ + map[string]interface{}{"packageId": float64(1), "packageName": "db.backup.quota.100GB", "packageQuota": "100"}, + map[string]interface{}{"packageId": float64(2), "packageName": "db.backup.quota.200GB", "packageQuota": "200"}, + }, + }, + map[string]interface{}{ + "engineGroup": float64(2), + "packages": []interface{}{ + map[string]interface{}{"packageId": float64(9), "packageName": "redis.backup.quota.100GB"}, + }, + }, + } + + rows, ok := flattenPackages(payload).([]interface{}) + if !ok { + t.Fatalf("flattenPackages returned %T", flattenPackages(payload)) + } + if len(rows) != 3 { + t.Fatalf("got %d rows, want 3 (one per package)", len(rows)) + } + + first := rows[0].(map[string]interface{}) + if first["packageId"] != float64(1) || first["engineGroup"] != float64(1) { + t.Errorf("first row = %v, want the package with its engine group", first) + } + last := rows[2].(map[string]interface{}) + if last["engineGroup"] != float64(2) { + t.Errorf("last row lost its engine group: %v", last) + } + + // The original payload must survive for JSON output. + if len(payload[0].(map[string]interface{})["packages"].([]interface{})) != 2 { + t.Error("flattenPackages mutated the payload") + } + + // Anything unexpected is handed back untouched rather than silently emptied. + if got := flattenPackages("not a list"); got != "not a list" { + t.Errorf("flattenPackages(non-list) = %v, want it unchanged", got) + } + if got := flattenPackages([]interface{}{map[string]interface{}{"engineGroup": float64(1)}}); got == nil { + t.Error("a group with no packages should not produce nil") + } +} + +func TestCommandsAndGates(t *testing.T) { + want := map[string]bool{ + "list": true, "list-packages": true, "create": true, "resize": true, "delete": true, + } + for _, sub := range BackupStorageCmd.Commands() { + delete(want, sub.Name()) + } + for name := range want { + t.Errorf("backup-storage %s is not registered", name) + } + + // create and resize place paid orders; delete can strand backups. + for _, cmd := range []*cobra.Command{createCmd, resizeCmd, deleteCmd} { + for _, flag := range []string{"dry-run", "force"} { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("backup-storage %s must define --%s", cmd.Name(), flag) + } + } + } +} diff --git a/go/cmd/vdb/relational/backupstorage/completion.go b/go/cmd/vdb/relational/backupstorage/completion.go new file mode 100644 index 0000000..76709e0 --- /dev/null +++ b/go/cmd/vdb/relational/backupstorage/completion.go @@ -0,0 +1,49 @@ +package backupstorage + +import ( + "context" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// Completers for the two ids these commands take. Each is bound in the init() of the +// file that defines the flag — see the init-order note in +// cmd/vdb/relational/instance/completion.go. +// +// Both are API-backed and read the same endpoints the read commands use, which is why +// the mapping is worth stating once: on THIS API, basePath lists the packages you can +// buy and basePath+"/information" the storage you own. MemoryStore inverts that pair. + +func storageIDCompletion() cli.CompFunc { + return cli.FlagFromAPI(func(_ context.Context, cmd *cobra.Command) ([]string, error) { + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return nil, err + } + result, err := apiClient.Get(basePath+"/information", nil) + if err != nil { + return nil, err + } + return cli.ExtractIDs(vdbclient.Unwrap(result), "id"), nil + }) +} + +// packageIDCompletion suggests packageId, not id. Two details matter: the packages can +// arrive nested under their engine group, so the listing is flattened first — the same +// shape the table shows — and packageId is a JSON NUMBER, so it needs +// vdbclient.ExtractIDValues rather than cli.ExtractIDs, which would find nothing. +func packageIDCompletion() cli.CompFunc { + return cli.FlagFromAPI(func(_ context.Context, cmd *cobra.Command) ([]string, error) { + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return nil, err + } + result, err := apiClient.Get(basePath, nil) + if err != nil { + return nil, err + } + return vdbclient.ExtractIDValues(flattenPackages(vdbclient.Unwrap(result)), "packageId"), nil + }) +} diff --git a/go/cmd/vdb/relational/backupstorage/read.go b/go/cmd/vdb/relational/backupstorage/read.go new file mode 100644 index 0000000..30c85db --- /dev/null +++ b/go/cmd/vdb/relational/backupstorage/read.go @@ -0,0 +1,102 @@ +package backupstorage + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List the backup storage you own", + Long: "List your backup storage: its quota, how much of it your backups occupy, and " + + "which package it came from.\n\n" + + "This endpoint takes no pagination or filters.", + Args: cobra.NoArgs, + RunE: runList, +} + +var listPackagesCmd = &cobra.Command{ + Use: "list-packages", + Short: "List the backup storage packages available to buy", + Long: "List the purchasable backup storage packages, with their quota and price.\n\n" + + "The packageId is what --package-id expects on 'backup-storage create' and " + + "'backup-storage resize'.", + Args: cobra.NoArgs, + RunE: runListPackages, +} + +var packageColumns = []string{ + "engineGroup", "packageId", "packageName", "packageQuota", "price", "sku", "description", +} + +func runList(cmd *cobra.Command, args []string) error { + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + // Note the path: the listing of what you OWN is /backup-storages/information, + // while /backup-storages (see list-packages) is the catalogue of what you can buy. + result, err := apiClient.Get(basePath+"/information", nil) + if err != nil { + return fmt.Errorf("failed to list backup storage: %w", err) + } + + return vdbclient.OutputWithColumns(cmd, result, storageColumns) +} + +func runListPackages(cmd *cobra.Command, args []string) error { + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Get(basePath, nil) + if err != nil { + return fmt.Errorf("failed to list backup storage packages: %w", err) + } + + return vdbclient.OutputWithColumns(cmd, flattenPackages(vdbclient.Unwrap(result)), packageColumns) +} + +// flattenPackages turns the response's two levels into one row per package. +// +// The payload is an array of {engineGroup, packages[]}, so a table would otherwise +// show one row per engine group with the packages collapsed into a Go-printed slice — +// and OutputWithColumns picks the first array it finds, which is the outer one. +// Flattening keeps engineGroup on each row so the grouping is not lost. +func flattenPackages(payload interface{}) interface{} { + groups, ok := payload.([]interface{}) + if !ok { + return payload + } + + var rows []interface{} + for _, item := range groups { + group, ok := item.(map[string]interface{}) + if !ok { + continue + } + packages, _ := group["packages"].([]interface{}) + for _, entry := range packages { + pkg, ok := entry.(map[string]interface{}) + if !ok { + continue + } + row := make(map[string]interface{}, len(pkg)+1) + for k, v := range pkg { + row[k] = v + } + row["engineGroup"] = group["engineGroup"] + rows = append(rows, row) + } + } + if rows == nil { + // Nothing recognisable: hand back the original so JSON output is unchanged and + // the user can see what the API actually said. + return payload + } + return rows +} diff --git a/go/cmd/vdb/relational/backupstorage/write.go b/go/cmd/vdb/relational/backupstorage/write.go new file mode 100644 index 0000000..2a93ed9 --- /dev/null +++ b/go/cmd/vdb/relational/backupstorage/write.go @@ -0,0 +1,224 @@ +package backupstorage + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +var createCmd = &cobra.Command{ + Use: "create", + Short: "Buy backup storage", + Long: "Buy a backup storage package.\n\n" + + "THIS COSTS MONEY: it goes through the order/payment flow and completes " + + "asynchronously. Run 'backup-storage list-packages' for the package IDs and " + + "prices, and 'backup-storage list' to see whether you already own storage — " + + "growing what you have is 'backup-storage resize'.", + Args: cobra.NoArgs, + RunE: runCreate, +} + +var resizeCmd = &cobra.Command{ + Use: "resize", + Short: "Change the package of existing backup storage", + Long: "Move existing backup storage to a different package, usually a larger one.\n\n" + + "THIS COSTS MONEY: order/payment flow, asynchronous. Shrinking below what your " + + "backups already occupy is rejected by the API — check the usage column in " + + "'backup-storage list' first.", + Args: cobra.NoArgs, + RunE: runResize, +} + +var deleteCmd = &cobra.Command{ + Use: "delete", + Short: "Release backup storage", + Long: "Give up a backup storage quota.\n\n" + + "Any backup that relies on this storage is at risk: release it only when your " + + "backups fit in the free allowance, or after deleting them. Check " + + "'backup-storage list' for usage and 'grn vdb relational backup list' for what " + + "is stored.", + Args: cobra.NoArgs, + RunE: runDelete, +} + +func init() { + c := createCmd.Flags() + c.String("package-id", "", "Backup storage package to buy (required; see 'backup-storage list-packages')") + c.Bool("dry-run", false, "Print the request that would be sent without placing an order") + c.Bool("force", false, "Skip the confirmation prompt") + createCmd.MarkFlagRequired("package-id") //nolint:errcheck + + r := resizeCmd.Flags() + r.String("storage-id", "", "Backup storage to change (required; see 'backup-storage list')") + r.String("package-id", "", "New package (required; see 'backup-storage list-packages')") + r.Bool("poc", false, "Pay with PoC credit (Auto Payment only)") + r.Bool("dry-run", false, "Print the request that would be sent without placing an order") + r.Bool("force", false, "Skip the confirmation prompt") + resizeCmd.MarkFlagRequired("storage-id") //nolint:errcheck + resizeCmd.MarkFlagRequired("package-id") //nolint:errcheck + + d := deleteCmd.Flags() + d.String("storage-id", "", "Backup storage to release (required)") + d.Bool("dry-run", false, "Print the request that would be sent without releasing anything") + d.Bool("force", false, "Skip the confirmation prompt") + deleteCmd.MarkFlagRequired("storage-id") //nolint:errcheck + + // Bound here, next to the flags: see the init-order note in completion.go. + createCmd.RegisterFlagCompletionFunc("package-id", packageIDCompletion()) //nolint:errcheck + resizeCmd.RegisterFlagCompletionFunc("storage-id", storageIDCompletion()) //nolint:errcheck + resizeCmd.RegisterFlagCompletionFunc("package-id", packageIDCompletion()) //nolint:errcheck + deleteCmd.RegisterFlagCompletionFunc("storage-id", storageIDCompletion()) //nolint:errcheck +} + +func runCreate(cmd *cobra.Command, args []string) error { + packageID, _ := cmd.Flags().GetString("package-id") + + // CreateBackupStorageRequest is a single field — the only vdb create that is not + // a description of the resource. + body := map[string]interface{}{"backupPackageId": packageID} + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + vdbclient.PreviewBody("buy", fmt.Sprintf("backup storage package %s", packageID), body) + return nil + } + if !cli.Confirm(force, fmt.Sprintf("Buy backup storage package %s? This places a paid order.", packageID)) { + fmt.Println("Aborted.") + return nil + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Post(paymentPath, body) + if err != nil { + return fmt.Errorf("failed to buy backup storage package %s: %w", packageID, err) + } + + return vdbclient.Output(cmd, result) +} + +func runResize(cmd *cobra.Command, args []string) error { + storageID, _ := cmd.Flags().GetString("storage-id") + if err := requireStorageID(storageID); err != nil { + return err + } + + packageID, _ := cmd.Flags().GetString("package-id") + poc, _ := cmd.Flags().GetBool("poc") + + // Same envelope as an instance resize, but the resource type is + // dbaas-backup-storage — and the field is spelled resourceType, not resType. + body := vdbclient.ResizeBody(vdbclient.ResourceTypeBackupStorage, storageID, "resize", + map[string]interface{}{ + "backupPackageId": packageID, + "poc": poc, + }) + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + vdbclient.PreviewBody("resize", fmt.Sprintf("backup storage %s", storageID), body) + return nil + } + if !cli.Confirm(force, fmt.Sprintf( + "Move backup storage %s to package %s? This places a paid order.", storageID, packageID)) { + fmt.Println("Aborted.") + return nil + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Post(basePath+"/actions/resize", body) + if err != nil { + return fmt.Errorf("failed to resize backup storage %s: %w", storageID, err) + } + + return vdbclient.Output(cmd, result) +} + +func runDelete(cmd *cobra.Command, args []string) error { + storageID, _ := cmd.Flags().GetString("storage-id") + if err := requireStorageID(storageID); err != nil { + return err + } + + // The delete action uses resType (not resourceType) with the backup-storage + // resource type. Note the endpoint is POST /actions/deletions, plural. + body := vdbclient.ActionBody(vdbclient.ResourceTypeBackupStorage, storageID, "delete", nil) + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + printDeleteTarget(apiClient, storageID) + + if dryRun { + vdbclient.PreviewBody("release", fmt.Sprintf("backup storage %s", storageID), body) + return nil + } + if !cli.Confirm(force, fmt.Sprintf( + "Release backup storage %s? Backups that depend on it may be lost.", storageID)) { + fmt.Println("Aborted.") + return nil + } + + result, err := apiClient.Post(basePath+"/actions/deletions", body) + if err != nil { + return fmt.Errorf("failed to release backup storage %s: %w", storageID, err) + } + + return vdbclient.Output(cmd, result) +} + +// printDeleteTarget shows the quota and, importantly, how much of it is in use. +func printDeleteTarget(apiClient *vdbclient.Client, storageID string) { + result, err := apiClient.Get(basePath+"/information", nil) + if err != nil { + return + } + items, ok := vdbclient.Unwrap(result).([]interface{}) + if !ok { + return + } + + for _, item := range items { + storage, ok := item.(map[string]interface{}) + if !ok || storage["id"] != storageID { + continue + } + fmt.Println("The following backup storage will be released:") + fmt.Println() + fmt.Printf(" ID: %v\n", storage["id"]) + fmt.Printf(" Name: %v\n", storage["name"]) + // backupPackageName comes back null even on a storage bought minutes ago + // (verified live), so print a dash rather than "". + fmt.Printf(" Package: %s\n", orDash(storage["backupPackageName"])) + fmt.Printf(" Usage: %v of %v\n", storage["usage"], storage["quota"]) + fmt.Println() + return + } +} + +// orDash renders a value that the API often leaves null. +func orDash(value interface{}) string { + text, ok := value.(string) + if !ok || text == "" { + return "-" + } + return text +} diff --git a/go/cmd/vdb/relational/catalog/catalog.go b/go/cmd/vdb/relational/catalog/catalog.go new file mode 100644 index 0000000..0febf20 --- /dev/null +++ b/go/cmd/vdb/relational/catalog/catalog.go @@ -0,0 +1,142 @@ +package catalog + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +// CatalogCmd is the parent command for the Relational Database lookup lists. +var CatalogCmd = &cobra.Command{ + Use: "catalog", + Short: "Look up Relational Database engines, flavors, zones and networks", + Long: "Read-only lists of what a Relational Database instance can be built from: " + + "engines, datastore versions, instance families, flavors, availability zones, " + + "networks and volume types.", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() //nolint:errcheck + }, +} + +// simpleList describes a catalog listing that needs no input beyond an optional +// zone filter. Eight of them differ only in path, wording and column set, so they +// are declared here instead of in eight near-identical files; list-flavors, the +// one taking required arguments, has its own file. +type simpleList struct { + use string + short string + long string + path string + byZone bool // offer --zone-id, sent as ?zoneId= + columns []string +} + +var simpleLists = []simpleList{ + { + use: "list-engines", + short: "List available database engines", + long: "List the database engines offered by Relational Database (MySQL, MariaDB, " + + "PostgreSQL) with their licences.\n\n" + + "Use 'catalog list-datastores' for the engine/version pairs that can actually " + + "be deployed.", + path: enginePath, + // image comes back empty on every engine and engineLicenses is a nested + // array; both are left out of the table but kept in JSON output. + columns: []string{"name", "description"}, + }, + { + use: "list-datastores", + short: "List available datastore types and versions", + long: "List the engine/version pairs that can be deployed.\n\n" + + "The 'type' and 'version' values are what --datastore-type and " + + "--datastore-version expect on 'catalog list-flavors'.", + path: datastorePath, + columns: []string{"type", "version", "name", "versionName", "licenseName"}, + }, + { + use: "list-families", + short: "List instance families", + long: "List the instance families (CPU/memory profiles) that flavors belong to.\n\n" + + "The response mixes two kinds of row, told apart by the group field: " + + "'family' rows are the real families (key + value set, condition.codes " + + "listing their CPU platform codes), while 'family_custom' rows are custom " + + "zones carrying only a name.", + path: familiesPath, + columns: []string{"group", "key", "value", "name", "description"}, + }, + { + use: "list-flavor-codes", + short: "List CPU platform codes", + long: "List the CPU platform codes that flavors are grouped by.", + path: flavorCodesPath, + columns: []string{"key", "value", "familyType", "description"}, + }, + { + use: "list-zones", + short: "List availability zones", + long: "List the availability zones a Relational Database instance can be placed in.", + path: zonesPath, + columns: []string{"uuid", "name", "status", "zoneType", "isDefault", "description"}, + }, + { + use: "list-networks", + short: "List networks available to database instances", + long: "List the networks (VPCs) a Relational Database instance can be attached to.", + path: networksPath, + columns: []string{"id", "displayName", "cidr", "status", "createdAt"}, + }, + { + use: "list-subnets", + short: "List networks with their subnets", + long: "List the networks available to database instances together with their " + + "subnets, optionally restricted to one availability zone.\n\n" + + "Each row is a network; its subnets are a nested array, so use " + + "--output json (or --query) to see them.", + path: subnetsPath, + byZone: true, + columns: []string{"uuid", "displayName", "status", "networkId", "zoneId"}, + }, + { + use: "list-volume-types", + short: "List volume types", + long: "List the volume types available for database instance storage, with their " + + "size limits and provisioned IOPS.\n\n" + + "Unlike the other catalog listings this one wraps its array in an object, so a " + + "--query has to go through that key: --query 'data[].type'.", + path: volumeTypesPath, + byZone: true, + // displayName is null on every row; the human-readable label is description. + columns: []string{"type", "description", "minVolumeSize", "maxVolumeSize", "iops", "zoneId"}, + }, +} + +func newSimpleList(spec simpleList) *cobra.Command { + cmd := &cobra.Command{ + Use: spec.use, + Short: spec.short, + Long: spec.long, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if err := get(cmd, spec.path, zoneQuery(cmd), spec.columns); err != nil { + // "list-volume-types" -> "failed to list volume types: ..." + return fmt.Errorf("failed to %s: %w", strings.ReplaceAll(spec.use, "-", " "), err) + } + return nil + }, + } + if spec.byZone { + cmd.Flags().String("zone-id", "", "Restrict results to one availability zone (run 'catalog list-zones' for IDs)") + cmd.RegisterFlagCompletionFunc("zone-id", zoneIDCompletion()) //nolint:errcheck + } + return cmd +} + +func init() { + for _, spec := range simpleLists { + CatalogCmd.AddCommand(newSimpleList(spec)) + } + CatalogCmd.AddCommand(listFlavorsCmd) + CatalogCmd.AddCommand(listConfigGroupsCmd) +} diff --git a/go/cmd/vdb/relational/catalog/catalog_test.go b/go/cmd/vdb/relational/catalog/catalog_test.go new file mode 100644 index 0000000..af79aef --- /dev/null +++ b/go/cmd/vdb/relational/catalog/catalog_test.go @@ -0,0 +1,104 @@ +package catalog + +import ( + "strings" + "testing" +) + +// TestSimpleListsAreWired checks the declarative table actually produces usable +// commands: every spec becomes a subcommand, carries help text and a column set, +// and offers --zone-id exactly when it is meant to be filterable by zone. +func TestSimpleListsAreWired(t *testing.T) { + byName := map[string]bool{} + for _, sub := range CatalogCmd.Commands() { + byName[sub.Name()] = true + } + + for _, spec := range simpleLists { + if !byName[spec.use] { + t.Errorf("%q is declared but not registered under catalog", spec.use) + continue + } + if spec.short == "" || spec.long == "" { + t.Errorf("%q is missing help text", spec.use) + } + if len(spec.columns) == 0 { + t.Errorf("%q has no columns; table output of a wide vdb payload is unreadable without them", spec.use) + } + if !strings.HasPrefix(spec.path, basePath) { + t.Errorf("%q path = %q, want it under %q", spec.use, spec.path, basePath) + } + + cmd, _, err := CatalogCmd.Find([]string{spec.use}) + if err != nil { + t.Errorf("finding %q: %v", spec.use, err) + continue + } + hasZone := cmd.Flags().Lookup("zone-id") != nil + if hasZone != spec.byZone { + t.Errorf("%q has --zone-id = %v, want %v", spec.use, hasZone, spec.byZone) + } + if !spec.byZone { + continue + } + if _, ok := cmd.GetFlagCompletionFunc("zone-id"); !ok { + t.Errorf("%q --zone-id has no completion function registered", spec.use) + } + } +} + +// TestFlavorFlagsAndCompletions pins the flag names and their bindings. The +// binding check matters because RegisterFlagCompletionFunc fails silently when it +// runs before the flag exists — see the note in completion.go. +func TestFlavorFlagsAndCompletions(t *testing.T) { + for _, flag := range []string{"datastore-type", "datastore-version", "zone-id"} { + if listFlavorsCmd.Flags().Lookup(flag) == nil { + t.Errorf("list-flavors has no --%s flag", flag) + continue + } + if _, ok := listFlavorsCmd.GetFlagCompletionFunc(flag); !ok { + t.Errorf("list-flavors --%s has no completion function registered", flag) + } + } + + // --version belongs to the root command; the datastore version must not + // shadow it. + if listFlavorsCmd.Flags().Lookup("version") != nil { + t.Error("list-flavors defines --version, which collides with the global flag") + } +} + +func TestFlavorsQueryUsesAPIParamNames(t *testing.T) { + defer func() { + // listFlavorsCmd is package-level state shared with the command tree. + _ = listFlavorsCmd.Flags().Set("datastore-type", "") + _ = listFlavorsCmd.Flags().Set("datastore-version", "") + _ = listFlavorsCmd.Flags().Set("zone-id", "") + }() + + for flag, value := range map[string]string{ + "datastore-type": "postgresql", "datastore-version": "15", "zone-id": "HCM03-1A", + } { + if err := listFlavorsCmd.Flags().Set(flag, value); err != nil { + t.Fatalf("setting --%s: %v", flag, err) + } + } + + got := flavorsQuery(listFlavorsCmd).Encode() + want := "type=postgresql&version=15&zoneId=HCM03-1A" + if got != want { + t.Errorf("query = %q, want %q", got, want) + } +} + +// TestZoneQueryOmitsEmptyZone: an empty zoneId is not the same as "all zones" to +// every backend, so the param must be left out entirely when the flag is unset. +func TestZoneQueryOmitsEmptyZone(t *testing.T) { + cmd, _, err := CatalogCmd.Find([]string{"list-volume-types"}) + if err != nil { + t.Fatalf("finding list-volume-types: %v", err) + } + if got := zoneQuery(cmd).Encode(); got != "" { + t.Errorf("query with no --zone-id = %q, want empty", got) + } +} diff --git a/go/cmd/vdb/relational/catalog/completion.go b/go/cmd/vdb/relational/catalog/completion.go new file mode 100644 index 0000000..3b2bd4b --- /dev/null +++ b/go/cmd/vdb/relational/catalog/completion.go @@ -0,0 +1,179 @@ +package catalog + +import ( + "context" + "net/url" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// This file holds the completers. Flags are bound to them in the init() of the +// file that DEFINES the flag (or right after the flag is added, for the +// generated listings) — never from a central function here, because init() +// functions run in filename order and a binding for a flag defined in a later +// file fails silently. See the note in instance/completion.go. + +// Completion keys for values that come from these catalog endpoints. Later +// phases (instance create, resize, replica create) take the same values as flags +// and should complete them with cli.ResourceCompletion() rather than +// importing this package. +const ( + DatastoreTypeResourceKey = "vdb:relational-datastore-type" + DatastoreVersionResourceKey = "vdb:relational-datastore-version" + ZoneResourceKey = "vdb:relational-zone" + VolumeTypeResourceKey = "vdb:relational-volume-type" + + // SubnetResourceKey lists the subnets a vDB instance or cluster can be placed + // in. It is vdb's own listing, not vserver's: this endpoint returns the subnets + // vDB will actually accept, and unlike "vserver:subnet" it needs no --vpc-id on + // the command. + SubnetResourceKey = "vdb:relational-subnet" + + // NetworkResourceKey lists the networks (VPCs) vDB will accept. Same reasoning + // as SubnetResourceKey: "vserver:network" is the platform's own listing, this + // one is what vDB agrees to place a resource in. Consumed by Kafka's + // 'cluster create --network-id' — Kafka has no networks endpoint of its own, + // and networks are project-wide. + NetworkResourceKey = "vdb:relational-network" + + // ConfigGroupResourceKey lists config groups usable by an instance. + ConfigGroupResourceKey = "vdb:relational-config-group" + + // FlavorResourceKey lists flavor ids for --package-id. Unlike the other keys + // this one is CONTEXT-DEPENDENT: the endpoint requires an engine and version, + // which the completer reads from the command's own --datastore-type and + // --datastore-version flags, so it yields nothing until those are set. + FlavorResourceKey = "vdb:relational-flavor" +) + +func init() { + cli.RegisterResourceCompleter(DatastoreTypeResourceKey, datastoreTypeCompletion()) + cli.RegisterResourceCompleter(DatastoreVersionResourceKey, datastoreVersionCompletion()) + cli.RegisterResourceCompleter(ZoneResourceKey, zoneIDCompletion()) + cli.RegisterResourceCompleter(VolumeTypeResourceKey, volumeTypeCompletion()) + cli.RegisterResourceCompleter(SubnetResourceKey, subnetCompletion()) + cli.RegisterResourceCompleter(NetworkResourceKey, cli.FlagFromAPI(fieldFrom(networksPath, "id"))) + cli.RegisterResourceCompleter(ConfigGroupResourceKey, cli.FlagFromAPI(instanceConfigGroupIDs)) + cli.RegisterResourceCompleter(FlavorResourceKey, cli.FlagFromAPI(flavorIDs)) +} + +// subnetCompletion suggests subnet UUIDs. The listing returns NETWORKS, each with +// its subnets nested, so the UUIDs have to be gathered a level down — the generic +// cli.ExtractIDs would return the network UUIDs instead. +func subnetCompletion() cli.CompFunc { + return cli.FlagFromAPI(func(_ context.Context, cmd *cobra.Command) ([]string, error) { + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return nil, err + } + result, err := apiClient.Get(subnetsPath, nil) + if err != nil { + return nil, err + } + networks, ok := vdbclient.Unwrap(result).([]interface{}) + if !ok { + return nil, nil + } + + var out []string + for _, item := range networks { + network, ok := item.(map[string]interface{}) + if !ok { + continue + } + subnets, _ := network["subnets"].([]interface{}) + out = append(out, cli.ExtractIDs(subnets, "uuid")...) + } + return out, nil + }) +} + +func datastoreTypeCompletion() cli.CompFunc { + return cli.FlagFromAPI(fieldFrom(datastorePath, "type")) +} + +// datastoreVersionCompletion suggests every version the API offers. It does not +// narrow by --datastore-type: the datastore listing pairs type with version, but +// filtering would silently offer nothing when --datastore-type is misspelled, +// which reads as "no versions exist" rather than "wrong type". +func datastoreVersionCompletion() cli.CompFunc { + return cli.FlagFromAPI(fieldFrom(datastorePath, "version")) +} + +func zoneIDCompletion() cli.CompFunc { + return cli.FlagFromAPI(fieldFrom(zonesPath, "uuid")) +} + +func volumeTypeCompletion() cli.CompFunc { + return cli.FlagFromAPI(fieldFrom(volumeTypesPath, "type")) +} + +// flavorIDs suggests the flavor ids valid for the engine the user has already +// named on the same command line, the way vserver's subnet completer reads +// --vpc-id. Without both --datastore-type and --datastore-version the endpoint has +// nothing to filter on, so the completer stays silent rather than guessing. +// +// The ids come back as JSON NUMBERS while the create request wants them as strings, +// so they are formatted here — cli.ExtractIDs, which only collects strings, would +// return nothing. +func flavorIDs(_ context.Context, cmd *cobra.Command) ([]string, error) { + datastoreType, _ := cmd.Flags().GetString("datastore-type") + datastoreVersion, _ := cmd.Flags().GetString("datastore-version") + if datastoreType == "" || datastoreVersion == "" { + return nil, nil + } + + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return nil, err + } + + query := url.Values{} + query.Set("type", datastoreType) + query.Set("version", datastoreVersion) + if zoneID, _ := cmd.Flags().GetString("zone-id"); zoneID != "" { + query.Set("zoneId", zoneID) + } + + result, err := vdbclient.Get(apiClient, flavorsPath, query) + if err != nil { + return nil, err + } + + return vdbclient.ExtractIDValues(vdbclient.Unwrap(result), "id"), nil +} + +// instanceConfigGroupIDs suggests only config groups a Relational Database +// instance can actually use — the endpoint also returns PostgreSQL Cluster groups, +// which it would reject. +func instanceConfigGroupIDs(_ context.Context, cmd *cobra.Command) ([]string, error) { + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return nil, err + } + result, err := apiClient.Get(configGroupsPath, nil) + if err != nil { + return nil, err + } + return cli.ExtractIDs(dropClusterGroups(vdbclient.Unwrap(result)), "id"), nil +} + +// fieldFrom builds a completion fetcher that GETs a catalog path and collects one +// field from every item. Unwrap runs first: the values live inside the vDB +// {code, message, data} envelope, and cli.ExtractIDs would otherwise find no +// slice to walk. +func fieldFrom(path, field string) func(context.Context, *cobra.Command) ([]string, error) { + return func(_ context.Context, cmd *cobra.Command) ([]string, error) { + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return nil, err + } + result, err := apiClient.Get(path, nil) + if err != nil { + return nil, err + } + return cli.ExtractIDs(vdbclient.Unwrap(result), field), nil + } +} diff --git a/go/cmd/vdb/relational/catalog/helpers.go b/go/cmd/vdb/relational/catalog/helpers.go new file mode 100644 index 0000000..4c6c385 --- /dev/null +++ b/go/cmd/vdb/relational/catalog/helpers.go @@ -0,0 +1,65 @@ +// Package catalog holds the read-only lookup endpoints of the Relational +// Database API — engines, datastore versions, instance families, flavors, zones, +// networks and volume types. +// +// They are grouped under one noun because they share a shape (GET, no filters +// beyond an optional zone, a bare array in the envelope) and one purpose: they +// are what a user consults before creating an instance, and what shell +// completion reads to suggest values for --datastore-type, --datastore-version +// and --zone-id. vDB declares no enums anywhere in its spec, so these endpoints +// are the only source of valid values. +package catalog + +import ( + "net/url" + + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// basePath — every relational catalog endpoint hangs off the instance collection +// rather than a catalog prefix of its own. +const basePath = "/vdb-relational/v1/database-instances" + +const ( + enginePath = basePath + "/engine" + datastorePath = basePath + "/datastore" + familiesPath = basePath + "/families" + flavorCodesPath = basePath + "/flavor_zones/codes" + flavorsPath = basePath + "/flavors" + zonesPath = basePath + "/zones" + networksPath = basePath + "/networks" + subnetsPath = basePath + "/networks/subnets" + volumeTypesPath = basePath + "/volume/types" +) + +func createClient(cmd *cobra.Command) (*vdbclient.Client, error) { + return vdbclient.BuildClient(cmd) +} + +// get issues the GET and prints it with a column set. Every catalog endpoint +// returns a single array inside the envelope (volume types wrap theirs in a +// one-key object), so table extraction is unambiguous — unlike instance detail. +func get(cmd *cobra.Command, path string, query url.Values, columns []string) error { + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := vdbclient.Get(apiClient, path, query) + if err != nil { + return err + } + + return vdbclient.OutputWithColumns(cmd, result, columns) +} + +// zoneQuery renders the optional --zone-id filter shared by the flavor, subnet +// and volume-type listings. +func zoneQuery(cmd *cobra.Command) url.Values { + query := url.Values{} + if zoneID, _ := cmd.Flags().GetString("zone-id"); zoneID != "" { + query.Set("zoneId", zoneID) + } + return query +} diff --git a/go/cmd/vdb/relational/catalog/list_config_groups.go b/go/cmd/vdb/relational/catalog/list_config_groups.go new file mode 100644 index 0000000..3e8f90b --- /dev/null +++ b/go/cmd/vdb/relational/catalog/list_config_groups.go @@ -0,0 +1,83 @@ +package catalog + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// configGroupsPath is the config groups an instance can be attached to. Note this +// is NOT /vdb-relational/v1/configurations, the paginated management listing — this +// one is the instance-facing lookup and returns a bare array. +const configGroupsPath = basePath + "/configuration" + +// clusterDeployType marks a config group built for PostgreSQL Cluster. The endpoint +// returns those alongside the single-instance ones (verified live: IDs prefixed +// "pg-cfg-"), but a Relational Database instance cannot use them. +const clusterDeployType = "cluster" + +var configGroupColumns = []string{ + "id", "name", "datastoreName", "datastoreVersionName", "deployType", "created", +} + +var listConfigGroupsCmd = &cobra.Command{ + Use: "list-config-groups", + Short: "List config groups that can be attached to an instance", + Long: "List the database config groups available to Relational Database instances.\n\n" + + "A config group only fits an instance with the same engine and version — check the " + + "datastoreName / datastoreVersionName columns. Its ID is what --config-id expects " + + "on 'instance create' and 'instance update-config-group'.\n\n" + + "The endpoint also returns PostgreSQL Cluster groups (deployType 'cluster', IDs " + + "prefixed 'pg-cfg-'), which an instance cannot use, so they are filtered out here; " + + "pass --all to see them, or use 'grn vdb postgresql catalog list-config-groups'.", + Args: cobra.NoArgs, + RunE: runListConfigGroups, +} + +func init() { + listConfigGroupsCmd.Flags().Bool("all", false, + "Do not filter by deploy type; include the PostgreSQL Cluster config groups too") +} + +func runListConfigGroups(cmd *cobra.Command, args []string) error { + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Get(configGroupsPath, nil) + if err != nil { + return fmt.Errorf("failed to list config groups: %w", err) + } + + payload := vdbclient.Unwrap(result) + if all, _ := cmd.Flags().GetBool("all"); !all { + payload = dropClusterGroups(payload) + } + + return vdbclient.OutputWithColumns(cmd, payload, configGroupColumns) +} + +// dropClusterGroups removes the cluster config groups from a bare array payload. +// Unlike the paginated /configurations listing there is no wrapper object here, so +// the filter works on the slice directly. +func dropClusterGroups(payload interface{}) interface{} { + items, ok := payload.([]interface{}) + if !ok { + return payload + } + + kept := make([]interface{}, 0, len(items)) + for _, item := range items { + row, ok := item.(map[string]interface{}) + if !ok { + continue + } + if deployType, _ := row["deployType"].(string); deployType == clusterDeployType { + continue + } + kept = append(kept, row) + } + return kept +} diff --git a/go/cmd/vdb/relational/catalog/list_flavors.go b/go/cmd/vdb/relational/catalog/list_flavors.go new file mode 100644 index 0000000..9a3c890 --- /dev/null +++ b/go/cmd/vdb/relational/catalog/list_flavors.go @@ -0,0 +1,66 @@ +package catalog + +import ( + "fmt" + "net/url" + + "github.com/spf13/cobra" +) + +// flavorColumns omits the pricing fields (packageSku, priceKey, monthlyCost — the +// last comes back 0 on every flavor) and volumeSize, which is 0 here because +// storage is chosen separately. locateZoneId earns its place: the same flavor name +// is repeated once per zone with a different id. JSON output keeps all 19 fields. +var flavorColumns = []string{ + "id", "name", "vcpus", "ram", "volumeType", "bandwidth", + "familyType", "platformType", "locateZoneId", +} + +var listFlavorsCmd = &cobra.Command{ + Use: "list-flavors", + Short: "List flavors for a datastore type and version", + Long: "List the flavors (vCPU/RAM combinations) available for one engine version.\n\n" + + "Both --datastore-type and --datastore-version are required by the API; run " + + "'catalog list-datastores' to see the valid pairs. --datastore-type takes either " + + "the lowercase form listed there ('postgresql') or the display form " + + "('PostgreSQL') — the API matches both. Note the flag is --datastore-version, " + + "not --version, which is reserved globally.\n\n" + + "The same flavor name appears once per availability zone; narrow with --zone-id.", + Args: cobra.NoArgs, + RunE: runListFlavors, +} + +func init() { + f := listFlavorsCmd.Flags() + f.String("datastore-type", "", "Datastore type, e.g. MySQL (required)") + f.String("datastore-version", "", "Datastore version, e.g. 8.0 (required)") + f.String("zone-id", "", "Restrict results to one availability zone (run 'catalog list-zones' for IDs)") + + listFlavorsCmd.MarkFlagRequired("datastore-type") //nolint:errcheck + listFlavorsCmd.MarkFlagRequired("datastore-version") //nolint:errcheck + + // Bound here, next to the flags: see the init-order note in completion.go. + listFlavorsCmd.RegisterFlagCompletionFunc("datastore-type", datastoreTypeCompletion()) //nolint:errcheck + listFlavorsCmd.RegisterFlagCompletionFunc("datastore-version", datastoreVersionCompletion()) //nolint:errcheck + listFlavorsCmd.RegisterFlagCompletionFunc("zone-id", zoneIDCompletion()) //nolint:errcheck +} + +// flavorsQuery maps the flags onto the endpoint's query params. The API names +// them "type" and "version"; the flags are prefixed because --version is taken by +// the root command. Split out from runListFlavors so the mapping is testable +// without a configured client. +func flavorsQuery(cmd *cobra.Command) url.Values { + query := zoneQuery(cmd) + datastoreType, _ := cmd.Flags().GetString("datastore-type") + datastoreVersion, _ := cmd.Flags().GetString("datastore-version") + query.Set("type", datastoreType) + query.Set("version", datastoreVersion) + return query +} + +func runListFlavors(cmd *cobra.Command, args []string) error { + if err := get(cmd, flavorsPath, flavorsQuery(cmd), flavorColumns); err != nil { + return fmt.Errorf("failed to list flavors: %w", err) + } + return nil +} diff --git a/go/cmd/vdb/relational/configuration/configuration.go b/go/cmd/vdb/relational/configuration/configuration.go new file mode 100644 index 0000000..5d755f1 --- /dev/null +++ b/go/cmd/vdb/relational/configuration/configuration.go @@ -0,0 +1,78 @@ +// Package configuration holds the Relational Database config-group commands. +// +// Three shapes here are unlike the rest of vdb: +// +// - `GET /configurations/id` takes the ID as a **query parameter**, not a path +// segment. Building `/configurations/id/` returns 404. +// - `DELETE /configurations/delete` takes a JSON **ARRAY** body and no ID in the +// path at all. +// - `PUT /configurations/update` carries the parameters as a **map** under +// `values`, whose entries are numbers, booleans or strings depending on the +// parameter — see update.go. +package configuration + +import ( + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// ConfigurationCmd is the parent command for config-group commands. +var ConfigurationCmd = &cobra.Command{ + Use: "configuration", + Short: "Manage Relational Database config groups", + Long: "Create, inspect, change and delete the config groups that hold database " + + "parameters for vDB Relational Database instances.\n\n" + + "A config group is tied to one engine and version and can only be attached to " + + "matching instances, with " + + "'grn vdb relational instance update-config-group'. Use " + + "'configuration list-params' to see which parameters an engine accepts before " + + "setting them.", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() //nolint:errcheck + }, +} + +const ( + basePath = "/vdb-relational/v1/configurations" + configIDPrefix = "cfg-" + clusterIDPrefix = "pg-cfg-" +) + +func init() { + ConfigurationCmd.AddCommand(listCmd) + ConfigurationCmd.AddCommand(getCmd) + ConfigurationCmd.AddCommand(createCmd) + ConfigurationCmd.AddCommand(updateCmd) + ConfigurationCmd.AddCommand(deleteCmd) + ConfigurationCmd.AddCommand(listParamsCmd) +} + +func createClient(cmd *cobra.Command) (*vdbclient.Client, error) { + return vdbclient.BuildClient(cmd) +} + +// requireConfigID accepts both single-instance ("cfg-") and cluster ("pg-cfg-") +// group IDs: this endpoint family serves both, and the deploy type in the payload is +// what tells them apart. It only guards against an ID from another resource +// entirely. +func requireConfigID(configID string) error { + if err := vdbclient.RequireIDWithPrefix(configID, "config-id", configIDPrefix, ""); err == nil { + return nil + } + return vdbclient.RequireIDWithPrefix(configID, "config-id", clusterIDPrefix, + "Config group IDs start with 'cfg-' (or 'pg-cfg-' for PostgreSQL Cluster groups); run 'configuration list' to find one") +} + +// listColumns is the table view of an ItemConfigInfo. The `values` map and the +// `instances` array are JSON-only: a group can hold dozens of parameters and be +// attached to many instances. +// +// instanceCount is left out although the listing carries it: the API always answers 0, +// verified live on 2026-08-13 against a group with an instance attached. A column +// claiming every group is unused is worse than no column; 'configuration get' reports +// the attachments in `instances`. +var listColumns = []string{ + "id", "name", "datastoreName", "datastoreVersionName", "deployType", + "description", "created", +} diff --git a/go/cmd/vdb/relational/configuration/configuration_test.go b/go/cmd/vdb/relational/configuration/configuration_test.go new file mode 100644 index 0000000..7f4297a --- /dev/null +++ b/go/cmd/vdb/relational/configuration/configuration_test.go @@ -0,0 +1,137 @@ +package configuration + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestRequireConfigIDAcceptsBothProducts(t *testing.T) { + // The endpoint family serves single-instance and cluster groups alike, so both + // prefixes are valid; only an ID from another resource is not. + for _, id := range []string{"cfg-b8f60c5c-e96a", "pg-cfg-b8cf4290-9c11"} { + if err := requireConfigID(id); err != nil { + t.Errorf("requireConfigID(%q) = %v, want nil", id, err) + } + } + for _, id := range []string{"db-ceb5fd7b", "bk-740f7917", "", "cfg id with spaces"} { + if err := requireConfigID(id); err == nil { + t.Errorf("requireConfigID(%q) = nil, want an error", id) + } + } +} + +// TestParseValuesTypesTheValues: the API's parameter definitions are typed and its +// own example sends {"autocommit": 1} — a number. Sending "1" leaves the caller at +// the mercy of the backend's coercion. +func TestParseValuesTypesTheValues(t *testing.T) { + values, err := parseValues([]string{ + "autocommit=1", + "long_query_time=2.5", + "general_log=true", + "sql_mode=STRICT_TRANS_TABLES", + }) + if err != nil { + t.Fatalf("parseValues: %v", err) + } + + if got, ok := values["autocommit"].(int64); !ok || got != 1 { + t.Errorf("autocommit = %#v, want int64(1)", values["autocommit"]) + } + if got, ok := values["long_query_time"].(float64); !ok || got != 2.5 { + t.Errorf("long_query_time = %#v, want float64(2.5)", values["long_query_time"]) + } + if got, ok := values["general_log"].(bool); !ok || !got { + t.Errorf("general_log = %#v, want true", values["general_log"]) + } + if got, ok := values["sql_mode"].(string); !ok || got != "STRICT_TRANS_TABLES" { + t.Errorf("sql_mode = %#v, want the string", values["sql_mode"]) + } +} + +func TestParseValuesRejectsBadInput(t *testing.T) { + for name, specs := range map[string][]string{ + "no equals sign": {"autocommit"}, + "empty name": {"=1"}, + "nothing at all": {}, + "same name twice": {"autocommit=1", "autocommit=0"}, + } { + if _, err := parseValues(specs); err == nil { + t.Errorf("%s: parseValues(%v) = nil error, want a rejection", name, specs) + } + } +} + +// TestDescribeValuesKeepsNumbersReadable: parameter values are float64 after JSON +// decoding, and %v renders 8388608 as 8.388608e+06. +func TestDescribeValuesKeepsNumbersReadable(t *testing.T) { + got := describeValues(map[string]interface{}{ + "bulk_insert_buffer_size": float64(8388608), + "autocommit": float64(1), + "sql_mode": "STRICT", + }) + + if strings.Contains(got, "e+") { + t.Errorf("describeValues used exponent form: %s", got) + } + // Sorted, so two runs can be compared by eye. + want := "autocommit=1, bulk_insert_buffer_size=8388608, sql_mode=STRICT" + if got != want { + t.Errorf("describeValues = %q, want %q", got, want) + } + if describeValues(nil) != "(none)" { + t.Error("an empty parameter set should read as (none)") + } +} + +// TestUpdateOffersMerge: the API replaces the whole parameter set, so a way to keep +// the existing parameters is not optional. +func TestUpdateOffersMerge(t *testing.T) { + if updateCmd.Flags().Lookup("merge") == nil { + t.Error("configuration update must offer --merge; the API replaces the whole set") + } + for _, flag := range []string{"dry-run", "force"} { + if updateCmd.Flags().Lookup(flag) == nil { + t.Errorf("configuration update must define --%s", flag) + } + } +} + +func TestCommandsAreWired(t *testing.T) { + want := map[string]bool{ + "list": true, "get": true, "create": true, + "update": true, "delete": true, "list-params": true, + } + for _, sub := range ConfigurationCmd.Commands() { + delete(want, sub.Name()) + } + for name := range want { + t.Errorf("configuration %s is not registered", name) + } +} + +func TestFlagCompletionsAreRegistered(t *testing.T) { + cases := []struct { + cmd *cobra.Command + flags []string + }{ + {getCmd, []string{"config-id"}}, + {updateCmd, []string{"config-id"}}, + {deleteCmd, []string{"config-id"}}, + {createCmd, []string{"datastore-type", "datastore-version", "deploy-type"}}, + {listParamsCmd, []string{"datastore-type", "datastore-version", "deploy-type"}}, + } + + for _, c := range cases { + for _, flag := range c.flags { + if c.cmd.Flags().Lookup(flag) == nil { + t.Errorf("%s has no --%s flag", c.cmd.Name(), flag) + continue + } + if _, ok := c.cmd.GetFlagCompletionFunc(flag); !ok { + t.Errorf("%s --%s has no completion function registered", c.cmd.Name(), flag) + } + } + } +} diff --git a/go/cmd/vdb/relational/configuration/read.go b/go/cmd/vdb/relational/configuration/read.go new file mode 100644 index 0000000..d10afe5 --- /dev/null +++ b/go/cmd/vdb/relational/configuration/read.go @@ -0,0 +1,143 @@ +package configuration + +import ( + "fmt" + "net/url" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List config groups", + Long: "List the config groups in your project.\n\n" + + "Results are paginated; --page is 1-based and the items come back under " + + "'content'. The listing includes PostgreSQL Cluster groups (deployType " + + "'cluster'); 'grn vdb relational catalog list-config-groups' is the filtered " + + "view meant for attaching to a single instance.", + Args: cobra.NoArgs, + RunE: runList, +} + +var getCmd = &cobra.Command{ + Use: "get", + Short: "Get details of a config group", + Long: "Show one config group, including every parameter it sets.\n\n" + + "The parameters live in the nested 'values' map and the attached instances in " + + "'instances', so this prints a key/value view rather than a table.", + Args: cobra.NoArgs, + RunE: runGet, +} + +var listParamsCmd = &cobra.Command{ + Use: "list-params", + Short: "List the parameters an engine accepts in a config group", + Long: "List the database parameters that can be set for one engine and version, with " + + "their type, allowed range or values, and whether changing one restarts the " + + "database.\n\n" + + "This is the reference for 'configuration update --set': a parameter not listed " + + "here, or one with modifiable false, will be rejected.", + Args: cobra.NoArgs, + RunE: runListParams, +} + +var paramColumns = []string{ + "name", "type", "min", "max", "modifiable", "restartRequired", "description", +} + +func init() { + l := listCmd.Flags() + l.Int("page", 1, "Page number (1-based)") + l.Int("page-size", vdbclient.DefaultPageSize, "Number of items per page") + + g := getCmd.Flags() + g.String("config-id", "", "Config group ID (required)") + getCmd.MarkFlagRequired("config-id") //nolint:errcheck + getCmd.RegisterFlagCompletionFunc("config-id", cli.ResourceCompletion(configGroupResource)) //nolint:errcheck + + p := listParamsCmd.Flags() + p.String("datastore-type", "", "Engine: MySQL, MariaDB or PostgreSQL (required)") + p.String("datastore-version", "", "Engine version, e.g. 8.0 (required)") + p.String("deploy-type", "", "Deploy type: single_node or cluster (PostgreSQL only)") + listParamsCmd.MarkFlagRequired("datastore-type") //nolint:errcheck + listParamsCmd.MarkFlagRequired("datastore-version") //nolint:errcheck + + listParamsCmd.RegisterFlagCompletionFunc("datastore-type", cli.ResourceCompletion(datastoreTypeResource)) //nolint:errcheck + listParamsCmd.RegisterFlagCompletionFunc("datastore-version", cli.ResourceCompletion(datastoreVersionResource)) //nolint:errcheck + listParamsCmd.RegisterFlagCompletionFunc("deploy-type", cli.FlagValues(deployTypes...)) //nolint:errcheck +} + +// Completion keys owned by the relational catalog package. +const ( + configGroupResource = "vdb:relational-config-group" + datastoreTypeResource = "vdb:relational-datastore-type" + datastoreVersionResource = "vdb:relational-datastore-version" +) + +func runList(cmd *cobra.Command, args []string) error { + page, _ := cmd.Flags().GetInt("page") + pageSize, _ := cmd.Flags().GetInt("page-size") + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + query := vdbclient.BuildListQuery(vdbclient.ListOptions{Page: page, PageSize: pageSize}) + result, err := vdbclient.Get(apiClient, basePath, query) + if err != nil { + return fmt.Errorf("failed to list config groups: %w", err) + } + + return vdbclient.OutputWithColumns(cmd, result, listColumns) +} + +func runGet(cmd *cobra.Command, args []string) error { + configID, _ := cmd.Flags().GetString("config-id") + if err := requireConfigID(configID); err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + // The ID is a QUERY parameter here, not a path segment: /configurations/id?id=… + query := url.Values{} + query.Set("id", configID) + + result, err := vdbclient.Get(apiClient, basePath+"/id", query) + if err != nil { + return fmt.Errorf("failed to get config group %s: %w", configID, err) + } + + return vdbclient.Output(cmd, result) +} + +func runListParams(cmd *cobra.Command, args []string) error { + datastoreType, _ := cmd.Flags().GetString("datastore-type") + datastoreVersion, _ := cmd.Flags().GetString("datastore-version") + deployType, _ := cmd.Flags().GetString("deploy-type") + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + query := url.Values{} + query.Set("datastoreType", datastoreType) + query.Set("datastoreVersion", datastoreVersion) + if deployType != "" { + query.Set("deployType", deployType) + } + + result, err := vdbclient.Get(apiClient, basePath+"/params", query) + if err != nil { + return fmt.Errorf("failed to list config parameters for %s %s: %w", datastoreType, datastoreVersion, err) + } + + return vdbclient.OutputWithColumns(cmd, result, paramColumns) +} diff --git a/go/cmd/vdb/relational/configuration/write.go b/go/cmd/vdb/relational/configuration/write.go new file mode 100644 index 0000000..dc8df19 --- /dev/null +++ b/go/cmd/vdb/relational/configuration/write.go @@ -0,0 +1,353 @@ +package configuration + +import ( + "fmt" + "net/url" + "sort" + "strconv" + "strings" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// deployTypes are the values the API documents. Only PostgreSQL offers "cluster". +var deployTypes = []string{"single_node", "cluster"} + +var createCmd = &cobra.Command{ + Use: "create", + Short: "Create a config group", + Long: "Create an empty config group for one engine and version.\n\n" + + "Creating a group costs nothing and changes no instance: it holds parameters " + + "until you attach it with 'instance update-config-group'. Set the parameters " + + "afterwards with 'configuration update'.\n\n" + + "--deploy-type cluster produces a group usable by PostgreSQL Cluster instead of " + + "single instances (PostgreSQL only).", + Args: cobra.NoArgs, + RunE: runCreate, +} + +var updateCmd = &cobra.Command{ + Use: "update", + Short: "Replace the parameters of a config group", + Long: "Set database parameters in a config group.\n\n" + + "THE API REPLACES THE WHOLE PARAMETER SET. Whatever the group held and you do " + + "not repeat is dropped — verified against the live API, and not what the spec " + + "suggests. Pass --merge to keep the existing parameters and only add or override " + + "the ones you name. Either way the command prints the parameters before and " + + "after, and --dry-run shows the exact request.\n\n" + + "Repeat --set for each parameter: --set autocommit=1 --set long_query_time=5. " + + "Values are sent typed — 1 as a number, true as a boolean, anything else as a " + + "string — because the API's own parameter definitions are typed. Run " + + "'configuration list-params' for the accepted names, ranges and whether a " + + "parameter needs a restart.\n\n" + + "Attached instances may need a reboot for a change to take effect.", + Args: cobra.NoArgs, + RunE: runUpdate, +} + +var deleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete a config group", + Long: "Delete a config group.\n\n" + + "A group that is still attached to instances cannot be deleted — detach it first " + + "with 'instance update-config-group --detach'. 'configuration get' lists what is " + + "attached under `instances`; the instanceCount the API returns is always 0 and " + + "cannot be used for this.", + Args: cobra.NoArgs, + RunE: runDelete, +} + +func init() { + c := createCmd.Flags() + c.String("name", "", "Config group name (required)") + c.String("datastore-type", "", "Engine: MySQL, MariaDB or PostgreSQL (required)") + c.String("datastore-version", "", "Engine version, e.g. 8.0 (required)") + c.String("deploy-type", "", fmt.Sprintf("Deploy type: %s (default: single_node)", strings.Join(deployTypes, " or "))) + c.String("description", "", "Free-text description") + c.Bool("dry-run", false, "Print the request that would be sent without creating anything") + createCmd.MarkFlagRequired("name") //nolint:errcheck + createCmd.MarkFlagRequired("datastore-type") //nolint:errcheck + createCmd.MarkFlagRequired("datastore-version") //nolint:errcheck + + createCmd.RegisterFlagCompletionFunc("datastore-type", cli.ResourceCompletion(datastoreTypeResource)) //nolint:errcheck + createCmd.RegisterFlagCompletionFunc("datastore-version", cli.ResourceCompletion(datastoreVersionResource)) //nolint:errcheck + createCmd.RegisterFlagCompletionFunc("deploy-type", cli.FlagValues(deployTypes...)) //nolint:errcheck + + u := updateCmd.Flags() + u.String("config-id", "", "Config group ID (required)") + u.StringArray("set", nil, "Parameter as name=value, repeatable (required)") + u.Bool("merge", false, "Keep the group's existing parameters and only add or override the ones given") + u.Bool("dry-run", false, "Print the request that would be sent without applying it") + u.Bool("force", false, "Skip the confirmation prompt") + updateCmd.MarkFlagRequired("config-id") //nolint:errcheck + updateCmd.MarkFlagRequired("set") //nolint:errcheck + + updateCmd.RegisterFlagCompletionFunc("config-id", cli.ResourceCompletion(configGroupResource)) //nolint:errcheck + + d := deleteCmd.Flags() + d.String("config-id", "", "Config group ID (required)") + d.Bool("dry-run", false, "Print the request that would be sent without deleting") + d.Bool("force", false, "Skip the confirmation prompt") + deleteCmd.MarkFlagRequired("config-id") //nolint:errcheck + + deleteCmd.RegisterFlagCompletionFunc("config-id", cli.ResourceCompletion(configGroupResource)) //nolint:errcheck +} + +func runCreate(cmd *cobra.Command, args []string) error { + flags := cmd.Flags() + + name, _ := flags.GetString("name") + datastoreType, _ := flags.GetString("datastore-type") + datastoreVersion, _ := flags.GetString("datastore-version") + deployType, _ := flags.GetString("deploy-type") + description, _ := flags.GetString("description") + + if deployType != "" && deployType != "single_node" && deployType != "cluster" { + return fmt.Errorf("invalid --deploy-type %q: must be %s", deployType, strings.Join(deployTypes, " or ")) + } + + body := map[string]interface{}{ + "name": name, + "datastoreType": datastoreType, + "datastoreVersion": datastoreVersion, + } + if deployType != "" { + body["deployType"] = deployType + } + if description != "" { + body["description"] = description + } + + if dryRun, _ := flags.GetBool("dry-run"); dryRun { + vdbclient.PreviewBody("create", fmt.Sprintf("config group %q", name), body) + return nil + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + // No confirmation: creating a group is free and touches no instance. + result, err := apiClient.Post(basePath+"/create", body) + if err != nil { + return fmt.Errorf("failed to create config group %q: %w", name, err) + } + + return vdbclient.Output(cmd, result) +} + +func runUpdate(cmd *cobra.Command, args []string) error { + configID, _ := cmd.Flags().GetString("config-id") + if err := requireConfigID(configID); err != nil { + return err + } + + specs, _ := cmd.Flags().GetStringArray("set") + values, err := parseValues(specs) + if err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + // Read the current parameters: needed for --merge, and shown either way so the + // replace semantics are visible before anything is sent. + existing, err := currentValues(apiClient, configID) + if err != nil { + return err + } + + merge, _ := cmd.Flags().GetBool("merge") + if merge { + merged := make(map[string]interface{}, len(existing)+len(values)) + for name, value := range existing { + merged[name] = value + } + for name, value := range values { + merged[name] = value + } + values = merged + } + + fmt.Printf("Current parameters in %s: %s\n", configID, describeValues(existing)) + fmt.Printf("Parameters after this change: %s\n", describeValues(values)) + if !merge && len(existing) > 0 { + fmt.Println("\nThis REPLACES the parameter set; anything above that is not in the new set is dropped.") + } + + body := map[string]interface{}{"id": configID, "values": values} + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + vdbclient.PreviewBody("update", fmt.Sprintf("config group %s", configID), body) + return nil + } + if !cli.Confirm(force, fmt.Sprintf( + "Apply this parameter set to config group %s? Attached instances may need a reboot to pick it up.", + configID)) { + fmt.Println("Aborted.") + return nil + } + + if _, err := apiClient.Put(basePath+"/update", body); err != nil { + return fmt.Errorf("failed to update config group %s: %w", configID, err) + } + + // Two reasons this prints a pointer instead of the result: the update response is + // a stub (id null, values empty), and the change is applied ASYNCHRONOUSLY — a + // re-read straight afterwards still returns the old parameters (observed live), + // so showing it would be worse than showing nothing. + fmt.Printf("\nAccepted: %d parameter(s) on config group %s.\n", len(values), configID) + fmt.Println("The API applies this asynchronously — run the following to confirm:") + fmt.Printf("\n grn vdb relational configuration get --config-id %s --query values\n", configID) + return nil +} + +// currentValues reads a config group's parameter map. +func currentValues(apiClient *vdbclient.Client, configID string) (map[string]interface{}, error) { + group, err := fetchGroup(apiClient, configID) + if err != nil { + return nil, err + } + values, _ := group["values"].(map[string]interface{}) + return values, nil +} + +func fetchGroup(apiClient *vdbclient.Client, configID string) (map[string]interface{}, error) { + query := url.Values{} + query.Set("id", configID) + + result, err := vdbclient.Get(apiClient, basePath+"/id", query) + if err != nil { + return nil, fmt.Errorf("failed to read config group %s: %w", configID, err) + } + group, ok := vdbclient.PayloadObject(result) + if !ok { + return nil, fmt.Errorf("config group %s not found (the API returned an empty payload)", configID) + } + return group, nil +} + +// describeValues renders a parameter map as "autocommit=1, long_query_time=5", +// sorted so two runs can be compared by eye. +func describeValues(values map[string]interface{}) string { + if len(values) == 0 { + return "(none)" + } + names := make([]string, 0, len(values)) + for name := range values { + names = append(names, name) + } + sort.Strings(names) + + parts := make([]string, 0, len(names)) + for _, name := range names { + parts = append(parts, fmt.Sprintf("%s=%s", name, plainValue(values[name]))) + } + return strings.Join(parts, ", ") +} + +// plainValue keeps numbers readable. Every JSON number decodes to float64, and %v +// switches to exponent form once that is shorter — a buffer size of 8388608 would +// print as 8.388608e+06. +func plainValue(value interface{}) string { + if number, ok := value.(float64); ok { + return strconv.FormatFloat(number, 'f', -1, 64) + } + return fmt.Sprintf("%v", value) +} + +func runDelete(cmd *cobra.Command, args []string) error { + configID, _ := cmd.Flags().GetString("config-id") + if err := requireConfigID(configID); err != nil { + return err + } + + // A JSON ARRAY body, and no ID in the path — the whole request is the array. + body := []interface{}{ + map[string]interface{}{"id": configID}, + } + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + vdbclient.PreviewBody("delete", fmt.Sprintf("config group %s", configID), body) + return nil + } + if !cli.Confirm(force, fmt.Sprintf("Delete config group %s?", configID)) { + fmt.Println("Aborted.") + return nil + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.DeleteWithBody(basePath+"/delete", body) + if err != nil { + return fmt.Errorf("failed to delete config group %s: %w", configID, err) + } + + return vdbclient.Output(cmd, result) +} + +// parseValues turns each name=value into an entry of the request's `values` map, +// inferring the JSON type. +// +// Typing matters: the API's parameter definitions are typed (integer, boolean, +// string), and its example sends `{"autocommit": 1}` — a number, not "1". Sending +// every value as a string would leave the caller at the mercy of whatever coercion +// the backend happens to do. +func parseValues(specs []string) (map[string]interface{}, error) { + values := map[string]interface{}{} + + for _, spec := range specs { + name, raw, found := strings.Cut(spec, "=") + name = strings.TrimSpace(name) + if !found || name == "" { + return nil, fmt.Errorf("invalid --set %q: expected name=value", spec) + } + if _, duplicate := values[name]; duplicate { + return nil, fmt.Errorf("invalid --set: %q given more than once", name) + } + values[name] = typedValue(strings.TrimSpace(raw)) + } + + if len(values) == 0 { + return nil, fmt.Errorf("no parameters given: pass at least one --set name=value") + } + return values, nil +} + +// typedValue converts a flag value to the JSON type it looks like: an integer or +// float stays a number, true/false become booleans, everything else is a string. +// +// Limitation worth knowing: there is no way to force a numeric-looking value to be +// sent as a string — shell quoting does not survive into the flag value. No +// documented parameter needs that today; if one appears, this needs an explicit +// syntax rather than a guess. +func typedValue(raw string) interface{} { + if number, err := strconv.ParseInt(raw, 10, 64); err == nil { + return number + } + if number, err := strconv.ParseFloat(raw, 64); err == nil { + return number + } + switch strings.ToLower(raw) { + case "true": + return true + case "false": + return false + } + return raw +} diff --git a/go/cmd/vdb/relational/instance/actions.go b/go/cmd/vdb/relational/instance/actions.go new file mode 100644 index 0000000..08e3cf0 --- /dev/null +++ b/go/cmd/vdb/relational/instance/actions.go @@ -0,0 +1,111 @@ +package instance + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// The start / stop / reboot endpoints differ only in their path segment, their +// action value and their wording, so they are declared here rather than in three +// near-identical files. delete has its own file: it takes options and prints the +// instance it is about to destroy. +// +// The API's shutdown endpoint is exposed as `stop`, the canonical CLI verb — +// `conventions_test.go` requires --dry-run and --force for it. +type action struct { + use string + path string // path segment + name string // "action" value in the body; each endpoint accepts only its own + short string + long string + confirm string // confirmation prompt; empty means do not prompt +} + +var actions = []action{ + { + use: "start", + path: "/start", + name: "start", + short: "Start a stopped Relational Database instance", + long: "Start an instance that was stopped with 'instance stop'.\n\n" + + "Billing for a stopped instance continues for its storage, so starting one " + + "does not change what you pay. No confirmation is asked.", + }, + { + use: "stop", + path: "/shutdown", + name: "stop", + short: "Stop a Relational Database instance", + long: "Stop the database. The instance keeps its storage and its IP, and can be " + + "restarted with 'instance start'.\n\n" + + "Applications lose their connections immediately, so the command confirms first. " + + "Note the API calls this endpoint 'shutdown'; the CLI verb is 'stop'.", + confirm: "Stop database instance %s? Applications will lose their connections.", + }, + { + use: "reboot", + path: "/reboot", + name: "reboot", + short: "Reboot a Relational Database instance", + long: "Restart the database. Open connections are dropped while it restarts.", + confirm: "Reboot database instance %s? Open connections will be dropped.", + }, +} + +func newActionCmd(spec action) *cobra.Command { + cmd := &cobra.Command{ + Use: spec.use, + Short: spec.short, + Long: spec.long, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runAction(cmd, spec) + }, + } + + f := cmd.Flags() + f.String("instance-id", "", "Database instance ID (required)") + f.Bool("dry-run", false, fmt.Sprintf("Print the request that would be sent without %sing", spec.use)) + f.Bool("force", false, "Skip the confirmation prompt") + + cmd.MarkFlagRequired("instance-id") //nolint:errcheck + cmd.RegisterFlagCompletionFunc("instance-id", relationalInstanceIDsFunc()) //nolint:errcheck + + return cmd +} + +func runAction(cmd *cobra.Command, spec action) error { + instanceID, _ := cmd.Flags().GetString("instance-id") + if err := requireRelationalID(instanceID); err != nil { + return err + } + + body := vdbclient.ActionBody(vdbclient.ResourceTypeInstance, instanceID, spec.name, nil) + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + vdbclient.PreviewBody(spec.use, fmt.Sprintf("database instance %s", instanceID), body) + return nil + } + if spec.confirm != "" && !cli.Confirm(force, fmt.Sprintf(spec.confirm, instanceID)) { + fmt.Println("Aborted.") + return nil + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Post(instancePath(instanceID, spec.path), body) + if err != nil { + return fmt.Errorf("failed to %s database instance %s: %w", spec.use, instanceID, err) + } + + return vdbclient.Output(cmd, result) +} diff --git a/go/cmd/vdb/relational/instance/async.go b/go/cmd/vdb/relational/instance/async.go new file mode 100644 index 0000000..f7a4ad3 --- /dev/null +++ b/go/cmd/vdb/relational/instance/async.go @@ -0,0 +1,69 @@ +package instance + +import ( + "fmt" + "strings" + + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// The two update endpoints — update/setting and update/config-group — behave +// unlike anything else in vdb, so their reporting lives here rather than being +// repeated: +// +// - They are asynchronous. The envelope comes back with code 200 while the +// payload's own status is 202: the request was accepted, not applied. +// - **Their response body is meaningless.** It carries only status, dbInstanceId +// and projectId, and the last two arrive transposed (the project ID in +// dbInstanceId and vice versa). The product team confirmed the body has no +// meaning and the HTTP status is the whole result, so these commands print +// their own confirmation instead of echoing the payload — printing it would +// show the user a wrong instance ID. + +// activeStatus is the only state in which the two async updates are actually +// applied. Status values are mixed-case in this API ("ACTIVE", "SHUTDOWN", but +// "stopping", "starting", "deleting"), so compare case-insensitively. +const activeStatus = "ACTIVE" + +// previewAsyncUpdate is PreviewBody plus the async warning, so --dry-run says the +// same thing the real run will. +func previewAsyncUpdate(verb, target string, body map[string]interface{}) { + vdbclient.PreviewBody(verb, target, body) + fmt.Println("\nThe API applies this in the background; poll 'instance get' to confirm the result.") +} + +// warnIfNotActive is the guard for a trap found on the live API: while an instance +// is RESTART_REQUIRED, these endpoints still answer 202 "success" and then **do +// nothing** — an attach can be issued, acknowledged, and silently dropped +// (2026-08-13). Since the failure leaves no trace anywhere, the command says so up +// front rather than letting the user believe a change landed. +// +// It warns instead of refusing: the instance may legitimately be in another state, +// and this is only the one case that was observed to swallow updates. +func warnIfNotActive(apiClient *vdbclient.Client, instanceID string) { + instance, err := fetchInstance(apiClient, instanceID) + if err != nil { + // Not worth blocking the update over: the request itself will report any + // real problem. + return + } + + status := stringField(instance, "status") + if strings.EqualFold(status, activeStatus) { + return + } + + fmt.Printf("Warning: database instance %s is %s, not %s.\n", instanceID, status, activeStatus) + fmt.Println("The API may accept this change and silently not apply it — this is known to") + fmt.Println("happen while an instance is RESTART_REQUIRED. Consider 'instance reboot' first,") + fmt.Println("then re-check with 'instance get'.") + fmt.Println() +} + +// reportAsyncAccepted is what these commands print instead of the response body. +func reportAsyncAccepted(cmd *cobra.Command, instanceID, summary string) { + fmt.Printf("Accepted: %s on database instance %s.\n", summary, instanceID) + fmt.Println("The API applies this asynchronously — run the following to confirm:") + fmt.Printf("\n grn vdb relational instance get --instance-id %s\n", instanceID) +} diff --git a/go/cmd/vdb/relational/instance/completion.go b/go/cmd/vdb/relational/instance/completion.go new file mode 100644 index 0000000..9edf961 --- /dev/null +++ b/go/cmd/vdb/relational/instance/completion.go @@ -0,0 +1,143 @@ +package instance + +import ( + "context" + "strings" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// This file holds the completers; each flag is bound to one in the init() of the +// file that DEFINES the flag, not from a central function here. +// +// Why: within a package, Go runs init() functions file by file in filename order. +// A central registerCompletions() called from instance.go would run before +// list.go and list_histories.go have defined their flags, and +// RegisterFlagCompletionFunc fails on an unknown flag — silently, since the error +// is discarded at init time. That produced completion that looked wired but was +// not; completion_test.go now guards against it. +// +// vDB declares no enums anywhere in its spec (status, datastore type and flavor +// are free-form strings), so every completer here is API-backed. cli.FlagFromAPI +// bounds each call with a timeout and yields nothing on error, so a slow or +// unreachable backend never breaks the shell. + +// InstanceResourceKey is the shared completion key for a Relational Database +// instance ID. Other vDB groups (backups, configurations) take an instance ID as +// a flag and can complete it with cli.ResourceCompletion(InstanceResourceKey) +// instead of importing this package. +// +// It suggests every ID the listing returns, including the PostgreSQL Cluster +// records ("pg-"), because the two commands consuming it — instance get and +// list-histories — were verified to serve those clusters as well. A future +// command that only works on true Relational instances must filter by the "db-" +// prefix itself, after checking how the endpoint actually behaves. +const InstanceResourceKey = "vdb:relational-instance" + +func init() { + cli.RegisterResourceCompleter(InstanceResourceKey, instanceIDCompletion()) +} + +// instanceIDCompletion is best-effort. The instance listing endpoint normally +// answers in ~1.3s (measured live in HCM-3, where it aggregates Relational +// instances and PostgreSQL Clusters), but spikes past 6s were observed; when it +// exceeds cli.FlagFromAPI's bound the shell simply gets no suggestions. The +// catalog endpoints are an order of magnitude faster (~0.2s). +func instanceIDCompletion() cli.CompFunc { + return cli.FlagFromAPI(fetchRelationalInstanceIDs) +} + +func statusCompletion() cli.CompFunc { + return cli.FlagFromAPI(fetchStatuses) +} + +// relationalInstanceIDsFunc suggests only "db-" IDs. Every mutating command in this +// package rejects a "pg-" cluster, so offering those would only invite an error; +// the read-only commands share the completer for consistency, since a cluster is +// better inspected through `grn vdb postgresql cluster`. +func relationalInstanceIDsFunc() cli.CompFunc { + return cli.FlagFromAPI(func(ctx context.Context, cmd *cobra.Command) ([]string, error) { + ids, err := fetchRelationalInstanceIDs(ctx, cmd) + if err != nil { + return nil, err + } + out := make([]string, 0, len(ids)) + for _, id := range ids { + if strings.HasPrefix(id, instanceIDPrefix) { + out = append(out, id) + } + } + return out, nil + }) +} + +// The catalog values a create, resize or config change needs are registered by the +// catalog package, which owns those endpoints; these wrappers dispatch through the +// shared registry at completion time, so no import and no init ordering is +// involved. +func datastoreTypeCompletion() cli.CompFunc { + return cli.ResourceCompletion("vdb:relational-datastore-type") +} + +func datastoreVersionCompletion() cli.CompFunc { + return cli.ResourceCompletion("vdb:relational-datastore-version") +} + +func volumeTypeCompletion() cli.CompFunc { + return cli.ResourceCompletion("vdb:relational-volume-type") +} + +func zoneIDCompletion() cli.CompFunc { + return cli.ResourceCompletion("vdb:relational-zone") +} + +func subnetCompletion() cli.CompFunc { + return cli.ResourceCompletion("vdb:relational-subnet") +} + +func configIDCompletion() cli.CompFunc { + return cli.ResourceCompletion("vdb:relational-config-group") +} + +// packageIDCompletion needs the engine to be known: the flavors endpoint takes a +// type and a version, which the completer reads from the same command line. On +// 'create' those are flags, so completion works once they are set; 'resize-instance' +// has no such flags, so it deliberately does not offer this — see the note there. +func packageIDCompletion() cli.CompFunc { + return cli.ResourceCompletion("vdb:relational-flavor") +} + +// listAll fetches one large page of instances for completion purposes. pageSize +// is capped at 100 by the API (pageObject.maxSize), so asking for more is +// pointless; a project with more instances than that simply gets the first page +// suggested. +func listAll(cmd *cobra.Command) (interface{}, error) { + apiClient, err := vdbclient.BuildClient(cmd) + if err != nil { + return nil, err + } + query := vdbclient.BuildListQuery(vdbclient.ListOptions{Page: 1, PageSize: 100}) + return vdbclient.Get(apiClient, basePath, query) +} + +func fetchRelationalInstanceIDs(_ context.Context, cmd *cobra.Command) ([]string, error) { + result, err := listAll(cmd) + if err != nil { + return nil, err + } + return cli.ExtractIDs(vdbclient.Unwrap(result), "id"), nil +} + +// fetchStatuses suggests the statuses actually present in the project. The +// relational API has no status-listing endpoint, and the one MemoryStore has +// (/vdb-memory/v1/database/status) is outdated — the product owner ruled it out on +// 2026-08-13 — so both groups read their own listing. +func fetchStatuses(_ context.Context, cmd *cobra.Command) ([]string, error) { + result, err := listAll(cmd) + if err != nil { + return nil, err + } + return cli.ExtractIDs(vdbclient.Unwrap(result), "status"), nil +} diff --git a/go/cmd/vdb/relational/instance/completion_test.go b/go/cmd/vdb/relational/instance/completion_test.go new file mode 100644 index 0000000..f0419ba --- /dev/null +++ b/go/cmd/vdb/relational/instance/completion_test.go @@ -0,0 +1,34 @@ +package instance + +import ( + "testing" + + "github.com/spf13/cobra" +) + +// TestFlagCompletionsAreRegistered guards a bug that already happened: the +// completions used to be bound from one function called in instance.go's init(), +// which runs before list.go and list_histories.go have defined their flags, so +// cobra rejected those bindings and discarded the error. Everything looked wired +// and no flag actually completed. Binding now lives beside each flag; this test +// fails if it drifts back. +func TestCompletionBindingSurvivesInitOrder(t *testing.T) { + cases := []struct { + cmd *cobra.Command + flag string + }{ + {getCmd, "instance-id"}, + {listHistoriesCmd, "instance-id"}, + {listCmd, "status"}, + } + + for _, c := range cases { + if c.cmd.Flags().Lookup(c.flag) == nil { + t.Errorf("%s has no --%s flag", c.cmd.Name(), c.flag) + continue + } + if _, ok := c.cmd.GetFlagCompletionFunc(c.flag); !ok { + t.Errorf("%s --%s has no completion function registered", c.cmd.Name(), c.flag) + } + } +} diff --git a/go/cmd/vdb/relational/instance/create.go b/go/cmd/vdb/relational/instance/create.go new file mode 100644 index 0000000..962587a --- /dev/null +++ b/go/cmd/vdb/relational/instance/create.go @@ -0,0 +1,254 @@ +package instance + +import ( + "fmt" + "os" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// passwordEnv keeps the master password out of shell history and process listings. +// --password still exists for scripting, but the env var is the documented way. +const passwordEnv = "GRN_VDB_MASTER_PASSWORD" + +// Backup retention bounds, from the API docs for backupDuration. +const ( + minBackupDuration = 2 + maxBackupDuration = 14 +) + +var createCmd = &cobra.Command{ + Use: "create", + Short: "Create a Relational Database instance", + Long: "Create a vDB Relational Database instance (MySQL, MariaDB or PostgreSQL).\n\n" + + "THIS COSTS MONEY. Creation goes through the order/payment flow: the API answers " + + "with an order (orderId, orderUrl) and the instance is built asynchronously — a " + + "successful response means the order was placed, not that the database is ready. " + + "Poll 'instance get' for status. Use --dry-run to see the exact request first; " + + "--user-type IAM_USER switches from Checkout to Auto Payment.\n\n" + + "Values come from 'grn vdb relational catalog'. Two of them are easy to mix up " + + "with the PostgreSQL Cluster group: --package-id is the numeric flavor id from " + + "'catalog list-flavors' (e.g. 211), and --volume-type is the volume type NAME " + + "(e.g. Gen2-NVMe2-IOPS3000), not an ID.\n\n" + + "The master password is read from $" + passwordEnv + " when --password is omitted. " + + "It is masked in --dry-run output.", + Args: cobra.NoArgs, + RunE: runCreate, +} + +// createFlags defines the request flags on f. A function rather than inline init() +// code so tests can build a throwaway command with the same flag set instead of +// mutating the one mounted in the command tree. +func createFlags(f *pflag.FlagSet) { + f.String("name", "", "Instance name (required)") + f.String("datastore-type", "", "Engine: MySQL, MariaDB or PostgreSQL (required; see 'catalog list-datastores')") + f.String("datastore-version", "", "Engine version, e.g. 8.0 (required; see 'catalog list-datastores')") + f.String("package-id", "", "Flavor id, numeric, e.g. 211 (required; the 'id' column of 'catalog list-flavors')") + f.String("volume-type", "", "Volume type NAME, e.g. Gen2-NVMe2-IOPS3000 (required; the 'type' column of 'catalog list-volume-types')") + f.Int("volume-size", 0, "Volume size in GB (required)") + f.String("zone-id", "", "Availability zone, e.g. HCM03-1A (required)") + f.String("subnet-ids", "", "Subnet ID(s), comma-separated (required; see 'catalog list-subnets')") + f.String("username", "", "Master username (required)") + f.String("password", "", "Master password (required; defaults to $"+passwordEnv+")") + f.String("database-name", "", "Initial database name (required; the API accepts exactly one at creation)") + f.String("character-set", "", "Character set of the initial database, e.g. utf8mb4 (MySQL/MariaDB)") + f.String("collate", "", "Collation of the initial database, e.g. utf8mb4_general_ci (MySQL/MariaDB)") + f.String("config-id", "", "Config group ID to attach (see 'catalog list-config-groups')") + f.Bool("public-access", false, "Allow public access to the instance") + f.Bool("backup-auto", false, "Enable daily automatic backup") + f.Int("backup-duration", 0, fmt.Sprintf("Backup retention in days, %d-%d (required with --backup-auto)", minBackupDuration, maxBackupDuration)) + f.String("backup-time", "", "Time of day to run the backup, HH:MM (required with --backup-auto)") + f.Bool("poc", false, "Pay with PoC credit (Auto Payment only)") + f.Bool("dry-run", false, "Print the request that would be sent without placing an order") + f.Bool("force", false, "Skip the confirmation prompt") +} + +func init() { + createFlags(createCmd.Flags()) + + for _, required := range []string{ + "name", "datastore-type", "datastore-version", "package-id", + "volume-type", "volume-size", "zone-id", "subnet-ids", "username", "database-name", + } { + createCmd.MarkFlagRequired(required) //nolint:errcheck + } + + // Bound here, next to the flags: see the init-order note in completion.go. + createCmd.RegisterFlagCompletionFunc("datastore-type", datastoreTypeCompletion()) //nolint:errcheck + createCmd.RegisterFlagCompletionFunc("datastore-version", datastoreVersionCompletion()) //nolint:errcheck + createCmd.RegisterFlagCompletionFunc("volume-type", volumeTypeCompletion()) //nolint:errcheck + // --package-id completes only once --datastore-type and --datastore-version are + // on the command line: the flavors endpoint requires them. + createCmd.RegisterFlagCompletionFunc("package-id", packageIDCompletion()) //nolint:errcheck + createCmd.RegisterFlagCompletionFunc("zone-id", zoneIDCompletion()) //nolint:errcheck + createCmd.RegisterFlagCompletionFunc("subnet-ids", subnetCompletion()) //nolint:errcheck + createCmd.RegisterFlagCompletionFunc("config-id", configIDCompletion()) //nolint:errcheck +} + +func runCreate(cmd *cobra.Command, args []string) error { + body, err := createBody(cmd) + if err != nil { + return err + } + + name, _ := cmd.Flags().GetString("name") + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + vdbclient.PreviewBody("create", fmt.Sprintf("database instance %q", name), body) + return nil + } + if !cli.Confirm(force, fmt.Sprintf( + "Create %v %v instance %q? This places a paid order.", + body["datastoreType"], body["datastoreVersion"], name)) { + fmt.Println("Aborted.") + return nil + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Post(paymentPath, body) + if err != nil { + return fmt.Errorf("failed to create database instance %q: %w", name, err) + } + + return vdbclient.Output(cmd, result) +} + +// createBody assembles CreateDbInstanceRequest. Split out from runCreate so the +// flag-to-field mapping and the backup rules are testable without a client. +func createBody(cmd *cobra.Command) (map[string]interface{}, error) { + flags := cmd.Flags() + + name, _ := flags.GetString("name") + datastoreType, _ := flags.GetString("datastore-type") + datastoreVersion, _ := flags.GetString("datastore-version") + packageID, _ := flags.GetString("package-id") + volumeType, _ := flags.GetString("volume-type") + volumeSize, _ := flags.GetInt("volume-size") + zoneID, _ := flags.GetString("zone-id") + subnetIDs, _ := flags.GetString("subnet-ids") + username, _ := flags.GetString("username") + databaseName, _ := flags.GetString("database-name") + characterSet, _ := flags.GetString("character-set") + collate, _ := flags.GetString("collate") + configID, _ := flags.GetString("config-id") + publicAccess, _ := flags.GetBool("public-access") + poc, _ := flags.GetBool("poc") + + if volumeSize <= 0 { + return nil, fmt.Errorf("invalid --volume-size %d: must be greater than 0 "+ + "(see minVolumeSize/maxVolumeSize in 'catalog list-volume-types')", volumeSize) + } + + password, err := masterPassword(flags) + if err != nil { + return nil, err + } + + subnets := cli.ParseCommaSeparated(subnetIDs) + if len(subnets) == 0 { + return nil, fmt.Errorf("invalid --subnet-ids: at least one subnet ID is required") + } + + database := map[string]interface{}{"name": databaseName} + if characterSet != "" { + database["characterSet"] = characterSet + } + if collate != "" { + database["collate"] = collate + } + + body := map[string]interface{}{ + "name": name, + "datastoreType": datastoreType, + "datastoreVersion": datastoreVersion, + "packageId": packageID, + "volumeType": volumeType, + "volumeSize": volumeSize, + "locateZoneId": zoneID, + "netIds": toInterfaces(subnets), + "publicAccess": publicAccess, + "poc": poc, + "user": map[string]interface{}{ + "name": username, + "password": password, + }, + // The API takes a list but documents that exactly one database may be + // created with the instance. + "databases": []interface{}{database}, + } + + if configID != "" { + // Omitted rather than sent empty: an empty configId means "detach" on the + // update endpoint, so empty strings are not inert in this API. + body["configId"] = configID + } + + if err := applyBackupFlags(flags, body); err != nil { + return nil, err + } + + return body, nil +} + +// applyBackupFlags adds the three backup fields. The API requires duration and +// time whenever backupAuto is on, and ignores them otherwise, so they are only +// sent together. +func applyBackupFlags(flags *pflag.FlagSet, body map[string]interface{}) error { + backupAuto, _ := flags.GetBool("backup-auto") + duration, _ := flags.GetInt("backup-duration") + backupTime, _ := flags.GetString("backup-time") + + body["backupAuto"] = backupAuto + + if !backupAuto { + if duration != 0 || backupTime != "" { + return fmt.Errorf("--backup-duration and --backup-time only apply with --backup-auto") + } + return nil + } + + if duration < minBackupDuration || duration > maxBackupDuration { + return fmt.Errorf("--backup-auto requires --backup-duration between %d and %d days, got %d", + minBackupDuration, maxBackupDuration, duration) + } + if backupTime == "" { + return fmt.Errorf("--backup-auto requires --backup-time, e.g. --backup-time 02:00") + } + + body["backupDuration"] = duration + body["backupTime"] = backupTime + return nil +} + +// masterPassword resolves --password, falling back to the environment, and checks it +// against the product's rules before it can reach a request body. +func masterPassword(flags *pflag.FlagSet) (string, error) { + password, _ := flags.GetString("password") + if password == "" { + password = os.Getenv(passwordEnv) + } + if password == "" { + return "", fmt.Errorf("master password not set: pass --password or set $%s", passwordEnv) + } + if err := vdbclient.ValidateDBPassword(password, "password"); err != nil { + return "", err + } + return password, nil +} + +func toInterfaces(values []string) []interface{} { + out := make([]interface{}, len(values)) + for i, v := range values { + out[i] = v + } + return out +} diff --git a/go/cmd/vdb/relational/instance/create_replica.go b/go/cmd/vdb/relational/instance/create_replica.go new file mode 100644 index 0000000..11e5d4b --- /dev/null +++ b/go/cmd/vdb/relational/instance/create_replica.go @@ -0,0 +1,229 @@ +package instance + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +var createReplicaCmd = &cobra.Command{ + Use: "create-replica", + Short: "Create a read replica of a Relational Database instance", + Long: "Create a read replica from an existing instance.\n\n" + + "THIS COSTS MONEY: a replica is a full instance and goes through the same " + + "order/payment flow as 'instance create', asynchronously. Use --dry-run first.\n\n" + + "Every property of the replica defaults to the SOURCE instance's — engine, " + + "version, flavor, volume type and size, zone, subnet, public access and backup " + + "settings are read from it, so normally only --instance-id and --name are needed. " + + "Pass any of the other flags to override one; --dry-run shows the resolved " + + "request. A replica must run the same engine and version as its source, so those " + + "two are deliberately not overridable.", + Args: cobra.NoArgs, + RunE: runCreateReplica, +} + +func createReplicaFlags(f *pflag.FlagSet) { + f.String("instance-id", "", "ID of the source instance to replicate (required)") + f.String("name", "", "Name of the new replica (required)") + f.String("package-id", "", "Flavor id for the replica (default: same as the source)") + f.String("volume-type", "", "Volume type NAME for the replica (default: same as the source)") + f.Int("volume-size", 0, "Volume size in GB (default: same as the source)") + f.String("zone-id", "", "Availability zone to place the replica in (default: same as the source)") + f.String("subnet-ids", "", "Subnet ID(s), comma-separated (default: the source's subnet)") + f.String("config-id", "", "Config group ID to attach (default: the source's, if any)") + f.Bool("public-access", false, "Allow public access to the replica (default: same as the source)") + f.Bool("backup-auto", false, "Enable daily automatic backup (default: same as the source)") + f.Int("backup-duration", 0, fmt.Sprintf("Backup retention in days, %d-%d", minBackupDuration, maxBackupDuration)) + f.String("backup-time", "", "Time of day to run the backup, HH:MM") + f.Bool("poc", false, "Pay with PoC credit (Auto Payment only)") + f.Bool("dry-run", false, "Print the request that would be sent without placing an order") + f.Bool("force", false, "Skip the confirmation prompt") +} + +func init() { + createReplicaFlags(createReplicaCmd.Flags()) + + createReplicaCmd.MarkFlagRequired("instance-id") //nolint:errcheck + createReplicaCmd.MarkFlagRequired("name") //nolint:errcheck + + // Bound here, next to the flags: see the init-order note in completion.go. + createReplicaCmd.RegisterFlagCompletionFunc("instance-id", relationalInstanceIDsFunc()) //nolint:errcheck + createReplicaCmd.RegisterFlagCompletionFunc("volume-type", volumeTypeCompletion()) //nolint:errcheck + createReplicaCmd.RegisterFlagCompletionFunc("zone-id", zoneIDCompletion()) //nolint:errcheck + createReplicaCmd.RegisterFlagCompletionFunc("subnet-ids", subnetCompletion()) //nolint:errcheck + createReplicaCmd.RegisterFlagCompletionFunc("config-id", configIDCompletion()) //nolint:errcheck +} + +func runCreateReplica(cmd *cobra.Command, args []string) error { + sourceID, _ := cmd.Flags().GetString("instance-id") + if err := requireRelationalID(sourceID); err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + source, err := fetchInstance(apiClient, sourceID) + if err != nil { + return err + } + + body, err := replicaBody(cmd, sourceID, source) + if err != nil { + return err + } + + name, _ := cmd.Flags().GetString("name") + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + vdbclient.PreviewBody("create", fmt.Sprintf("replica %q of %s", name, sourceID), body) + return nil + } + if !cli.Confirm(force, fmt.Sprintf( + "Create replica %q of %s (%v GB, flavor %v)? This places a paid order.", + name, sourceID, body["volumeSize"], body["packageId"])) { + fmt.Println("Aborted.") + return nil + } + + result, err := apiClient.Post(instancePath(sourceID, "/create-replicas"), body) + if err != nil { + return fmt.Errorf("failed to create replica %q of database instance %s: %w", name, sourceID, err) + } + + return vdbclient.Output(cmd, result) +} + +// replicaBody assembles CreateDbInstanceReplicaRequest, defaulting every unset +// field to the source instance. Split out from runCreateReplica so the defaulting +// rules are testable against a captured instance payload. +func replicaBody(cmd *cobra.Command, sourceID string, source map[string]interface{}) (map[string]interface{}, error) { + flags := cmd.Flags() + + name, _ := flags.GetString("name") + poc, _ := flags.GetBool("poc") + + body := map[string]interface{}{ + "name": name, + "replicaSourceId": sourceID, + "poc": poc, + // A replica must match its source's engine, so these are taken from it and + // have no flags. + "datastoreType": stringField(source, "datastoreType"), + "datastoreVersion": stringField(source, "datastoreVersion"), + "packageId": stringOrDefault(flags, "package-id", sourcePackageID(source)), + "volumeType": stringOrDefault(flags, "volume-type", stringField(source, "volumeType")), + "volumeSize": intOrDefault(flags, "volume-size", intField(source, "volumeSize")), + "locateZoneId": stringOrDefault(flags, "zone-id", stringField(source, "zoneId")), + "publicAccess": boolOrDefault(flags, "public-access", boolField(source, "publicAccess")), + } + + subnetIDs, _ := flags.GetString("subnet-ids") + subnets := cli.ParseCommaSeparated(subnetIDs) + if len(subnets) == 0 { + if sourceSubnet := stringField(source, "subnetId"); sourceSubnet != "" { + subnets = []string{sourceSubnet} + } + } + if len(subnets) == 0 { + return nil, fmt.Errorf("could not determine a subnet: the source instance reports none, so pass --subnet-ids") + } + body["netIds"] = toInterfaces(subnets) + + if configID := stringOrDefault(flags, "config-id", sourceConfigID(source)); configID != "" { + body["configId"] = configID + } + + if err := applyReplicaBackupFlags(flags, source, body); err != nil { + return nil, err + } + + if body["volumeSize"].(int) <= 0 { + return nil, fmt.Errorf("could not determine a volume size from the source instance: pass --volume-size") + } + if body["packageId"] == "" { + return nil, fmt.Errorf("could not determine a flavor from the source instance: pass --package-id") + } + + return body, nil +} + +// applyReplicaBackupFlags mirrors the source's backup settings unless the user +// changes them. When backup is on, the API demands duration and time together, so +// the defaults come from the source as a set. +func applyReplicaBackupFlags(flags *pflag.FlagSet, source, body map[string]interface{}) error { + backupAuto := boolOrDefault(flags, "backup-auto", boolField(source, "backupAuto")) + body["backupAuto"] = backupAuto + + if !backupAuto { + return nil + } + + duration := intOrDefault(flags, "backup-duration", intField(source, "backupDuration")) + backupTime := stringOrDefault(flags, "backup-time", stringField(source, "backupTime")) + + if duration < minBackupDuration || duration > maxBackupDuration { + return fmt.Errorf("backup retention must be between %d and %d days, got %d: pass --backup-duration", + minBackupDuration, maxBackupDuration, duration) + } + if backupTime == "" { + return fmt.Errorf("automatic backup needs a time of day: pass --backup-time, e.g. --backup-time 02:00") + } + + body["backupDuration"] = duration + body["backupTime"] = backupTime + return nil +} + +// sourcePackageID is the source's flavor. The instance payload calls it +// quotaPackageId while the create request calls it packageId. +func sourcePackageID(source map[string]interface{}) string { + return stringField(source, "quotaPackageId") +} + +// sourceConfigID reads the attached config group. The instance payload leaves the +// flat configId null even when one is attached and reports it in the nested +// configuration object instead (verified live), so check both. +func sourceConfigID(source map[string]interface{}) string { + if configID := stringField(source, "configId"); configID != "" { + return configID + } + if configuration, ok := source["configuration"].(map[string]interface{}); ok { + return stringField(configuration, "id") + } + return "" +} + +// stringOrDefault and friends return the flag when the user set it, otherwise the +// fallback. Changed() rather than emptiness, so "--public-access=false" is a +// deliberate override rather than an unset flag. +func stringOrDefault(flags *pflag.FlagSet, name, fallback string) string { + if !flags.Changed(name) { + return fallback + } + value, _ := flags.GetString(name) + return value +} + +func intOrDefault(flags *pflag.FlagSet, name string, fallback int) int { + if !flags.Changed(name) { + return fallback + } + value, _ := flags.GetInt(name) + return value +} + +func boolOrDefault(flags *pflag.FlagSet, name string, fallback bool) bool { + if !flags.Changed(name) { + return fallback + } + value, _ := flags.GetBool(name) + return value +} diff --git a/go/cmd/vdb/relational/instance/delete.go b/go/cmd/vdb/relational/instance/delete.go new file mode 100644 index 0000000..e25ec6c --- /dev/null +++ b/go/cmd/vdb/relational/instance/delete.go @@ -0,0 +1,107 @@ +package instance + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +var deleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete a Relational Database instance", + Long: "Delete an instance and, optionally, its backups.\n\n" + + "This is irreversible: the instance and its data are gone. " + + "--create-final-backup takes one last backup first; --delete-all-backup also " + + "removes the existing ones, which is the only way to lose the ability to " + + "restore. Both are Relational Database options — a PostgreSQL Cluster accepts " + + "only the first.", + Args: cobra.NoArgs, + RunE: runDelete, +} + +func init() { + f := deleteCmd.Flags() + f.String("instance-id", "", "Database instance ID (required)") + f.Bool("create-final-backup", false, "Take a final backup before deleting") + f.Bool("delete-all-backup", false, "Also delete every existing backup of this instance") + f.Bool("dry-run", false, "Print the request that would be sent without deleting") + f.Bool("force", false, "Skip the confirmation prompt") + + deleteCmd.MarkFlagRequired("instance-id") //nolint:errcheck + + // Bound here, next to the flag: see the init-order note in completion.go. + deleteCmd.RegisterFlagCompletionFunc("instance-id", relationalInstanceIDsFunc()) //nolint:errcheck +} + +func runDelete(cmd *cobra.Command, args []string) error { + instanceID, _ := cmd.Flags().GetString("instance-id") + if err := requireRelationalID(instanceID); err != nil { + return err + } + + createFinalBackup, _ := cmd.Flags().GetBool("create-final-backup") + deleteAllBackup, _ := cmd.Flags().GetBool("delete-all-backup") + + body := vdbclient.ActionBody(vdbclient.ResourceTypeInstance, instanceID, "delete", map[string]interface{}{ + "createFinalBackup": createFinalBackup, + "deleteAllBackup": deleteAllBackup, + }) + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + // Show what is about to be destroyed. Best-effort: if the instance cannot be + // read (already gone, or a transient error), say so and let the user decide + // rather than refusing to continue. + printDeleteTarget(apiClient, instanceID) + + if dryRun { + vdbclient.PreviewBody("delete", fmt.Sprintf("database instance %s", instanceID), body) + return nil + } + + prompt := fmt.Sprintf("Delete database instance %s? This cannot be undone.", instanceID) + if deleteAllBackup { + prompt = fmt.Sprintf( + "Delete database instance %s AND every backup of it? This cannot be undone and leaves nothing to restore from.", + instanceID) + } + if !cli.Confirm(force, prompt) { + fmt.Println("Aborted.") + return nil + } + + result, err := apiClient.Post(instancePath(instanceID, "/delete"), body) + if err != nil { + return fmt.Errorf("failed to delete database instance %s: %w", instanceID, err) + } + + return vdbclient.Output(cmd, result) +} + +func printDeleteTarget(apiClient *vdbclient.Client, instanceID string) { + instance, err := fetchInstance(apiClient, instanceID) + if err != nil { + fmt.Printf("Warning: could not read database instance %s before deleting (%v).\n", instanceID, err) + return + } + + fmt.Println("The following database instance will be deleted:") + fmt.Println() + fmt.Printf(" ID: %v\n", instance["id"]) + fmt.Printf(" Name: %v\n", instance["name"]) + fmt.Printf(" Status: %v\n", instance["status"]) + fmt.Printf(" Engine: %v %v\n", instance["datastoreType"], instance["datastoreVersion"]) + fmt.Printf(" Storage: %v GB (%v)\n", instance["volumeSize"], instance["volumeType"]) + if replicas, ok := instance["replicas"].([]interface{}); ok && len(replicas) > 0 { + fmt.Printf(" Replicas: %d — delete or detach them first if the API refuses\n", len(replicas)) + } + fmt.Println() +} diff --git a/go/cmd/vdb/relational/instance/detach_replica.go b/go/cmd/vdb/relational/instance/detach_replica.go new file mode 100644 index 0000000..b14bc07 --- /dev/null +++ b/go/cmd/vdb/relational/instance/detach_replica.go @@ -0,0 +1,70 @@ +package instance + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +var detachReplicaCmd = &cobra.Command{ + Use: "detach-replica", + Short: "Promote a read replica to a standalone instance", + Long: "Detach a read replica from its source, turning it into an independent " + + "instance.\n\n" + + "Pass the REPLICA's ID, not the source's. Replication stops and cannot be " + + "re-established — the replica keeps its data and carries on as a normal " + + "instance, and it keeps costing what it costs. Run 'instance list-replicas' on " + + "the source to see which IDs are replicas.", + Args: cobra.NoArgs, + RunE: runDetachReplica, +} + +func init() { + f := detachReplicaCmd.Flags() + f.String("instance-id", "", "ID of the replica to detach (required)") + f.Bool("dry-run", false, "Print the request that would be sent without detaching") + f.Bool("force", false, "Skip the confirmation prompt") + + detachReplicaCmd.MarkFlagRequired("instance-id") //nolint:errcheck + + // Bound here, next to the flag: see the init-order note in completion.go. + detachReplicaCmd.RegisterFlagCompletionFunc("instance-id", relationalInstanceIDsFunc()) //nolint:errcheck +} + +func runDetachReplica(cmd *cobra.Command, args []string) error { + instanceID, _ := cmd.Flags().GetString("instance-id") + if err := requireRelationalID(instanceID); err != nil { + return err + } + + // The API's action value is detach_replica, with an underscore — the only action + // in the family that is not a single word. + body := vdbclient.ActionBody(vdbclient.ResourceTypeInstance, instanceID, "detach_replica", nil) + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + vdbclient.PreviewBody("detach", fmt.Sprintf("replica %s", instanceID), body) + return nil + } + if !cli.Confirm(force, fmt.Sprintf( + "Detach replica %s from its source? Replication cannot be re-established.", instanceID)) { + fmt.Println("Aborted.") + return nil + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Post(instancePath(instanceID, "/detach-replica"), body) + if err != nil { + return fmt.Errorf("failed to detach replica %s: %w", instanceID, err) + } + + return vdbclient.Output(cmd, result) +} diff --git a/go/cmd/vdb/relational/instance/get.go b/go/cmd/vdb/relational/instance/get.go new file mode 100644 index 0000000..27d79b8 --- /dev/null +++ b/go/cmd/vdb/relational/instance/get.go @@ -0,0 +1,52 @@ +package instance + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/validator" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +var getCmd = &cobra.Command{ + Use: "get", + Short: "Get details of a Relational Database instance", + Long: "Show the full details of a single vDB Relational Database instance.\n\n" + + "This also accepts the PostgreSQL Cluster IDs ('pg-') that appear in " + + "'instance list' — verified against the live API. Cluster records answer with " + + "deployType 'cluster', numberOfNodes and the privateRwIp/publicRwIp fields " + + "instead of a single instance's ip list.", + RunE: runGet, +} + +func init() { + f := getCmd.Flags() + f.String("instance-id", "", "Database instance ID (required)") + getCmd.MarkFlagRequired("instance-id") //nolint:errcheck + + // Bound here, next to the flag: see the init-order note in completion.go. + getCmd.RegisterFlagCompletionFunc("instance-id", instanceIDCompletion()) //nolint:errcheck +} + +func runGet(cmd *cobra.Command, args []string) error { + instanceID, _ := cmd.Flags().GetString("instance-id") + + if err := validator.ValidateID(instanceID, "instance-id"); err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Get(detailPath(instanceID), nil) + if err != nil { + return fmt.Errorf("failed to get database instance %s: %w", instanceID, err) + } + + // Output, not OutputWithColumns: a single instance carries four nested arrays + // (ip, securityGroup, replicas, sharedActions) and table extraction would pick + // one of them at random. See internal/vdbclient/output.go. + return vdbclient.Output(cmd, result) +} diff --git a/go/cmd/vdb/relational/instance/helpers.go b/go/cmd/vdb/relational/instance/helpers.go new file mode 100644 index 0000000..6df7d0d --- /dev/null +++ b/go/cmd/vdb/relational/instance/helpers.go @@ -0,0 +1,134 @@ +package instance + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/validator" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// basePath is the Relational Database instance collection. Note the sibling +// MemoryStore API uses the same resource name under a different prefix but with +// different sub-paths, so nothing here is reusable across groups by design. +const basePath = "/vdb-relational/v1/database-instances" + +// paymentPath is where instances are CREATED. Creation is an order, not a POST to +// the collection — a detail with no equivalent in vks or vserver. +const paymentPath = "/vdb-relational/v1/payment/database-instances" + +// instanceIDPrefix marks a Relational Database instance. The listing and several +// action endpoints also serve PostgreSQL Clusters ("pg-"), which belong to +// `grn vdb postgresql`; commands that MUTATE must reject those IDs so a cluster is +// never rebooted or deleted through the wrong command group. +const instanceIDPrefix = "db-" + +// detailPath is the get-by-id path. Relational puts the ID behind an extra "id" +// segment (`/database-instances/id/{dbInstanceId}`) where MemoryStore uses +// `/database-instances/{dbInstanceId}` — one of the asymmetries that rules out a +// shared path builder. +func detailPath(instanceID string) string { + return basePath + "/id/" + instanceID +} + +// historiesPath is the instance action log. Here the path parameter is named +// instanceId, while detailPath's is dbInstanceId — same resource, two names. +func historiesPath(instanceID string) string { + return basePath + "/" + instanceID + "/histories" +} + +// instancePath builds any other per-instance path (actions, secrules, replicas). +func instancePath(instanceID, suffix string) string { + return basePath + "/" + instanceID + suffix +} + +// validateInstanceID accepts any vdb instance ID. Read-only commands use it, +// because get and list-histories serve PostgreSQL Clusters too (verified live). +func validateInstanceID(instanceID string) error { + return validator.ValidateID(instanceID, "instance-id") +} + +// requireRelationalID additionally rejects PostgreSQL Cluster IDs. Every command +// that changes state uses this: the relational reboot and delete endpoints happily +// act on a "pg-" cluster, and doing that from `grn vdb relational` would disturb a +// resource the user manages elsewhere. +func requireRelationalID(instanceID string) error { + return vdbclient.RequireIDWithPrefix(instanceID, "instance-id", instanceIDPrefix, + "Relational Database instance IDs start with 'db-'; use 'grn vdb postgresql cluster' for 'pg-' clusters") +} + +// listColumns is the table view of a database instance. The API returns ~68 +// fields per instance (including billing metadata such as cost, period and +// enableAutoRenew); JSON output keeps all of them. +var listColumns = []string{ + "id", "name", "status", "datastoreType", "datastoreVersion", + "vcpus", "ram", "volumeSize", "ip", "created", +} + +// historyColumns is the table view of a HistoryResponse entry. It leads with description +// because `action` alone does not say what happened: every settings change, resize and +// config-group attach or detach is recorded as "Update", and only the description +// distinguishes them ("… Change redis password", "… Change flavor from db.s-general-2x4 +// … to db.s-general-4x8", "… Attach config X"). Create and create-replica entries embed +// the whole order cart and run past 200 characters, which is the cost of having the +// column; it is worth paying, since those two rows are the ones a reader can already +// identify from `action`. +// +// updatedTime is left to JSON output: it equalled createdTime on all 57 records seen +// across three instances in both products, so in a table it is 31 characters spent +// repeating the previous column. errorMessage stays — it is the only place a Failed row +// explains itself. +var historyColumns = []string{ + "id", "action", "description", "status", "createdTime", "errorMessage", +} + +// replicaColumns is the table view of a replica. A replica listing returns a +// REDUCED instance — 19 fields, with no ip, port or securityGroup — so listColumns +// cannot be reused here (shape confirmed by the product team). +var replicaColumns = []string{ + "id", "name", "status", "datastoreType", "datastoreVersion", + "vcpus", "ram", "volumeSize", "created", +} + +func createClient(cmd *cobra.Command) (*vdbclient.Client, error) { + return vdbclient.BuildClient(cmd) +} + +func outputList(cmd *cobra.Command, data interface{}) error { + return vdbclient.OutputWithColumns(cmd, data, listColumns) +} + +// fetchInstance reads one instance, unwrapped, for commands that need its current +// values: resize-storage must send the fields it is NOT changing, create-replica +// defaults everything to the source instance, and update-secrule takes its default +// port from it. +func fetchInstance(apiClient *vdbclient.Client, instanceID string) (map[string]interface{}, error) { + result, err := apiClient.Get(detailPath(instanceID), nil) + if err != nil { + return nil, fmt.Errorf("failed to read database instance %s: %w", instanceID, err) + } + instance, ok := vdbclient.PayloadObject(result) + if !ok { + // vDB reports "not found" as HTTP 200 with a null payload on some endpoints, so + // this is a miss rather than a malformed response. + return nil, fmt.Errorf("database instance %s not found (the API returned an empty payload)", instanceID) + } + return instance, nil +} + +// stringField reads a string field from an instance payload, tolerating null. +func stringField(instance map[string]interface{}, key string) string { + value, _ := instance[key].(string) + return value +} + +// intField reads a numeric field. Every JSON number decodes to float64. +func intField(instance map[string]interface{}, key string) int { + value, _ := instance[key].(float64) + return int(value) +} + +func boolField(instance map[string]interface{}, key string) bool { + value, _ := instance[key].(bool) + return value +} diff --git a/go/cmd/vdb/relational/instance/instance.go b/go/cmd/vdb/relational/instance/instance.go new file mode 100644 index 0000000..6f3c00d --- /dev/null +++ b/go/cmd/vdb/relational/instance/instance.go @@ -0,0 +1,47 @@ +package instance + +import ( + "github.com/spf13/cobra" +) + +// InstanceCmd is the parent command for Relational Database instance commands. +var InstanceCmd = &cobra.Command{ + Use: "instance", + Short: "Manage Relational Database instances", + Long: "List, inspect and manage vDB Relational Database instances " + + "(MySQL, MariaDB, PostgreSQL).\n\n" + + "Instance IDs start with 'db-'. The listing, get and history endpoints also serve " + + "PostgreSQL Clusters ('pg-'), but every command here that CHANGES an instance " + + "rejects those IDs — clusters are managed with 'grn vdb postgresql cluster'.", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() //nolint:errcheck + }, +} + +func init() { + // Read + InstanceCmd.AddCommand(listCmd) + InstanceCmd.AddCommand(getCmd) + InstanceCmd.AddCommand(listHistoriesCmd) + InstanceCmd.AddCommand(listSecrulesCmd) + InstanceCmd.AddCommand(listReplicasCmd) + + // Lifecycle + InstanceCmd.AddCommand(createCmd) + InstanceCmd.AddCommand(resizeInstanceCmd) + InstanceCmd.AddCommand(resizeStorageCmd) + InstanceCmd.AddCommand(deleteCmd) + for _, spec := range actions { + InstanceCmd.AddCommand(newActionCmd(spec)) + } + + // Replicas + InstanceCmd.AddCommand(createReplicaCmd) + InstanceCmd.AddCommand(detachReplicaCmd) + + // Configuration + InstanceCmd.AddCommand(updateSettingsCmd) + InstanceCmd.AddCommand(updateConfigGroupCmd) + InstanceCmd.AddCommand(updateSecruleCmd) +} diff --git a/go/cmd/vdb/relational/instance/lifecycle_test.go b/go/cmd/vdb/relational/instance/lifecycle_test.go new file mode 100644 index 0000000..30e608e --- /dev/null +++ b/go/cmd/vdb/relational/instance/lifecycle_test.go @@ -0,0 +1,622 @@ +package instance + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// sourceInstance is a captured live payload of a real instance, used to test the +// commands that default from or validate against current state. +func sourceInstance() map[string]interface{} { + return map[string]interface{}{ + "id": "db-66a37ca3-e688-4a8a-9e35-a89fa69733c6", + "name": "docs-agent", + "datastoreType": "PostgreSQL", + "datastoreVersion": "15", + "quotaPackageId": "103", + "volumeType": "Gen2-NVMe2-IOPS3000", + "volumeSize": float64(60), + "zoneId": "HCM03-1A", + "subnetId": "sub-246df886-3ef8-4d70-87e7-6b87f0caea50", + "publicAccess": true, + "backupAuto": true, + "backupDuration": float64(2), + "backupTime": "00:00", + "port": float64(5432), + // The flat configId is null even when a group is attached; the nested object + // is where the API reports it. + "configId": nil, + "configuration": map[string]interface{}{"id": "cfg-87d6499f", "name": "config-group"}, + } +} + +func freshCmd(t *testing.T, define func(*pflag.FlagSet), values map[string]string) *cobra.Command { + t.Helper() + + cmd := &cobra.Command{Use: "test"} + define(cmd.Flags()) + for name, value := range values { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("setting --%s=%q: %v", name, value, err) + } + } + return cmd +} + +// TestMutatingCommandsRejectClusterIDs is the safety net for this package. The +// relational reboot, delete and resize endpoints also serve PostgreSQL Clusters, so +// a "pg-" ID reaching them from here would disturb a resource managed by +// `grn vdb postgresql cluster`. +func TestMutatingCommandsRejectClusterIDs(t *testing.T) { + if err := requireRelationalID("db-66a37ca3-e688"); err != nil { + t.Errorf("valid instance ID rejected: %v", err) + } + for _, id := range []string{"pg-2e6f2253-9032", "66a37ca3", "", "db id with spaces"} { + if err := requireRelationalID(id); err == nil { + t.Errorf("requireRelationalID(%q) = nil, want an error", id) + } + } + + // Read-only commands accept both, because get and list-histories serve clusters. + if err := validateInstanceID("pg-2e6f2253-9032"); err != nil { + t.Errorf("read-only validation rejected a cluster ID: %v", err) + } +} + +func TestInstancePaths(t *testing.T) { + const id = "db-1234" + + cases := map[string]string{ + detailPath(id): "/vdb-relational/v1/database-instances/id/db-1234", + historiesPath(id): "/vdb-relational/v1/database-instances/db-1234/histories", + instancePath(id, "/secrules"): "/vdb-relational/v1/database-instances/db-1234/secrules", + instancePath(id, "/replicas"): "/vdb-relational/v1/database-instances/db-1234/replicas", + instancePath(id, "/shutdown"): "/vdb-relational/v1/database-instances/db-1234/shutdown", + instancePath(id, "/resize-storage"): "/vdb-relational/v1/database-instances/db-1234/resize-storage", + instancePath(id, "/update/setting"): "/vdb-relational/v1/database-instances/db-1234/update/setting", + } + for got, want := range cases { + if got != want { + t.Errorf("path = %q, want %q", got, want) + } + } + + // Creation is an order, not a POST to the collection. + if paymentPath != "/vdb-relational/v1/payment/database-instances" { + t.Errorf("paymentPath = %q", paymentPath) + } +} + +// TestActionSpecs: the CLI verb and the API path diverge for stop, and each +// endpoint accepts only its own action value. +func TestActionSpecs(t *testing.T) { + byUse := map[string]action{} + for _, spec := range actions { + byUse[spec.use] = spec + } + + stop, ok := byUse["stop"] + if !ok { + t.Fatal("no stop command declared") + } + if stop.path != "/shutdown" { + t.Errorf("stop path = %q, want /shutdown (the API's name for it)", stop.path) + } + if stop.name != "stop" { + t.Errorf("stop action = %q, want stop", stop.name) + } + if stop.confirm == "" { + t.Error("stop must confirm: applications lose their connections") + } + if byUse["reboot"].confirm == "" { + t.Error("reboot must confirm") + } + if byUse["start"].confirm != "" { + t.Error("start should not prompt; it breaks nothing") + } + + // conventions_test.go requires --dry-run and --force on stop and reboot; give + // them to start too so scripts can treat the three alike. + for _, spec := range actions { + cmd := newActionCmd(spec) + for _, flag := range []string{"instance-id", "dry-run", "force"} { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("%s must define --%s", spec.use, flag) + } + } + if _, registered := cmd.GetFlagCompletionFunc("instance-id"); !registered { + t.Errorf("%s --instance-id has no completion function registered", spec.use) + } + } +} + +// validDBPassword satisfies the product rules the CLI now enforces: 8-32 characters, +// letters/digits/$^_<> only. +const validDBPassword = "GrnCliTest2026$x" + +func TestCreateBodyMapsFlagsToAPIFields(t *testing.T) { + cmd := freshCmd(t, createFlags, map[string]string{ + "name": "my-mysql", "datastore-type": "MySQL", "datastore-version": "8.0", + "package-id": "211", "volume-type": "Gen2-NVMe2-IOPS3000", "volume-size": "40", + "zone-id": "HCM03-1A", "subnet-ids": "sub-1, sub-2", "username": "dbadmin", + "password": validDBPassword, "database-name": "appdb", + "character-set": "utf8mb4", "collate": "utf8mb4_general_ci", + }) + + body, err := createBody(cmd) + if err != nil { + t.Fatalf("createBody: %v", err) + } + + for field, want := range map[string]interface{}{ + "name": "my-mysql", + "datastoreType": "MySQL", + "datastoreVersion": "8.0", + // packageId is the flavor id as a STRING, and volumeType is the type NAME — + // the PostgreSQL Cluster group uses opaque pgp-/pgst- IDs instead. + "packageId": "211", + "volumeType": "Gen2-NVMe2-IOPS3000", + "volumeSize": 40, + "locateZoneId": "HCM03-1A", + "backupAuto": false, + } { + if body[field] != want { + t.Errorf("body[%q] = %v, want %v", field, body[field], want) + } + } + + netIDs, ok := body["netIds"].([]interface{}) + if !ok || len(netIDs) != 2 { + t.Errorf("netIds = %v, want two entries", body["netIds"]) + } + user, ok := body["user"].(map[string]interface{}) + if !ok || user["name"] != "dbadmin" || user["password"] != validDBPassword { + t.Errorf("user = %v", body["user"]) + } + database := body["databases"].([]interface{})[0].(map[string]interface{}) + if database["name"] != "appdb" || database["characterSet"] != "utf8mb4" || database["collate"] != "utf8mb4_general_ci" { + t.Errorf("databases[0] = %v", database) + } + + // configId absent rather than empty: an empty configId means "detach" on the + // update endpoint, so empty strings are not inert in this API. + if _, present := body["configId"]; present { + t.Error("body carries configId although the flag was not set") + } + // Retention fields must not appear when automatic backup is off. + for _, field := range []string{"backupDuration", "backupTime"} { + if _, present := body[field]; present { + t.Errorf("body carries %q with backupAuto false", field) + } + } +} + +func TestCreateBodyBackupRules(t *testing.T) { + base := map[string]string{ + "name": "n", "datastore-type": "MySQL", "datastore-version": "8.0", + "package-id": "211", "volume-type": "T", "volume-size": "40", + "zone-id": "Z", "subnet-ids": "sub-1", "username": "u", + "password": validDBPassword, "database-name": "d", + } + with := func(extra map[string]string) map[string]string { + flags := map[string]string{} + for k, v := range base { + flags[k] = v + } + for k, v := range extra { + flags[k] = v + } + return flags + } + + // The API requires retention and time whenever automatic backup is on. + body, err := createBody(freshCmd(t, createFlags, with(map[string]string{ + "backup-auto": "true", "backup-duration": "7", "backup-time": "02:00", + }))) + if err != nil { + t.Fatalf("createBody with backup: %v", err) + } + if body["backupDuration"] != 7 || body["backupTime"] != "02:00" { + t.Errorf("backup fields = %v / %v", body["backupDuration"], body["backupTime"]) + } + + for name, flags := range map[string]map[string]string{ + "backup on, no retention": {"backup-auto": "true"}, + "backup on, no time": {"backup-auto": "true", "backup-duration": "7"}, + "retention below the minimum": {"backup-auto": "true", "backup-duration": "1", "backup-time": "02:00"}, + "retention above the maximum": {"backup-auto": "true", "backup-duration": "15", "backup-time": "02:00"}, + "retention without backup-auto": {"backup-duration": "7"}, + "time without backup-auto": {"backup-time": "02:00"}, + "zero volume size": {"volume-size": "0"}, + } { + t.Setenv(passwordEnv, "") + if _, err := createBody(freshCmd(t, createFlags, with(flags))); err == nil { + t.Errorf("%s: createBody = nil error, want a rejection", name) + } + } +} + +func TestCreateBodyReadsPasswordFromEnv(t *testing.T) { + t.Setenv(passwordEnv, validDBPassword) + + body, err := createBody(freshCmd(t, createFlags, map[string]string{ + "name": "n", "datastore-type": "MySQL", "datastore-version": "8.0", + "package-id": "211", "volume-type": "T", "volume-size": "40", + "zone-id": "Z", "subnet-ids": "sub-1", "username": "u", "database-name": "d", + })) + if err != nil { + t.Fatalf("createBody: %v", err) + } + if got := body["user"].(map[string]interface{})["password"]; got != validDBPassword { + t.Errorf("password = %v, want the value from $%s", got, passwordEnv) + } +} + +// TestReplicaBodyDefaultsToTheSource is the point of create-replica: a replica has +// a dozen required fields that all describe the source, so the command reads them +// instead of making the user retype them. +func TestReplicaBodyDefaultsToTheSource(t *testing.T) { + source := sourceInstance() + cmd := freshCmd(t, createReplicaFlags, map[string]string{ + "instance-id": "db-66a37ca3-e688-4a8a-9e35-a89fa69733c6", + "name": "docs-agent-replica", + }) + + body, err := replicaBody(cmd, "db-66a37ca3-e688-4a8a-9e35-a89fa69733c6", source) + if err != nil { + t.Fatalf("replicaBody: %v", err) + } + + for field, want := range map[string]interface{}{ + "name": "docs-agent-replica", + "replicaSourceId": "db-66a37ca3-e688-4a8a-9e35-a89fa69733c6", + "datastoreType": "PostgreSQL", + "datastoreVersion": "15", + // quotaPackageId in the instance payload, packageId in the request. + "packageId": "103", + "volumeType": "Gen2-NVMe2-IOPS3000", + "volumeSize": 60, + "locateZoneId": "HCM03-1A", + "publicAccess": true, + "backupAuto": true, + } { + if body[field] != want { + t.Errorf("body[%q] = %v, want %v", field, body[field], want) + } + } + + // The source's subnet becomes a one-element list. + netIDs := body["netIds"].([]interface{}) + if len(netIDs) != 1 || netIDs[0] != "sub-246df886-3ef8-4d70-87e7-6b87f0caea50" { + t.Errorf("netIds = %v", body["netIds"]) + } + // The attached config group is only in the nested object, not the flat field. + if body["configId"] != "cfg-87d6499f" { + t.Errorf("configId = %v, want the group from configuration.id", body["configId"]) + } + // Backup is on for the source, so its schedule must come along. + if body["backupDuration"] != 2 || body["backupTime"] != "00:00" { + t.Errorf("backup schedule = %v / %v", body["backupDuration"], body["backupTime"]) + } +} + +func TestReplicaBodyOverrides(t *testing.T) { + cmd := freshCmd(t, createReplicaFlags, map[string]string{ + "instance-id": "db-1", "name": "r", + "package-id": "271", "volume-size": "120", "zone-id": "HCM03-1B", + "subnet-ids": "sub-x", "public-access": "false", + }) + + body, err := replicaBody(cmd, "db-1", sourceInstance()) + if err != nil { + t.Fatalf("replicaBody: %v", err) + } + + for field, want := range map[string]interface{}{ + "packageId": "271", + "volumeSize": 120, + "locateZoneId": "HCM03-1B", + // Changed() rather than emptiness, so an explicit false is an override and + // not an unset flag. + "publicAccess": false, + } { + if body[field] != want { + t.Errorf("body[%q] = %v, want %v", field, body[field], want) + } + } + if netIDs := body["netIds"].([]interface{}); netIDs[0] != "sub-x" { + t.Errorf("netIds = %v, want the override", body["netIds"]) + } + // Engine and version are never overridable: a replica must match its source. + if body["datastoreType"] != "PostgreSQL" || body["datastoreVersion"] != "15" { + t.Errorf("engine = %v %v, want the source's", body["datastoreType"], body["datastoreVersion"]) + } +} + +func TestReplicaBodyNeedsWhatTheSourceCannotProvide(t *testing.T) { + bare := map[string]interface{}{"datastoreType": "MySQL", "datastoreVersion": "8.0"} + + cmd := freshCmd(t, createReplicaFlags, map[string]string{"instance-id": "db-1", "name": "r"}) + if _, err := replicaBody(cmd, "db-1", bare); err == nil { + t.Error("replicaBody accepted a source with no subnet, size or flavor") + } +} + +// TestStorageBodyFillsTheUnchangedField: the API wants both size and type on every +// request, using the current value for whichever is not changing, so omitting one +// is not the same as leaving it alone. +func TestStorageBodyFillsTheUnchangedField(t *testing.T) { + instance := sourceInstance() + + cmd := freshCmd(t, resizeStorageFlags, map[string]string{"instance-id": "db-1", "volume-size": "80"}) + body, summary, err := storageBody(cmd.Flags(), instance, "db-1") + if err != nil { + t.Fatalf("storageBody: %v", err) + } + config := body["databaseInstances"].([]interface{})[0].(map[string]interface{})["config"].(map[string]interface{}) + if config["volumeSize"] != 80 { + t.Errorf("volumeSize = %v, want 80", config["volumeSize"]) + } + if config["volumeType"] != "Gen2-NVMe2-IOPS3000" { + t.Errorf("volumeType = %v, want the instance's current type", config["volumeType"]) + } + if !strings.Contains(summary, "60 -> 80") { + t.Errorf("summary = %q, want it to name the change", summary) + } + + // Type-only change keeps the current size. + cmd = freshCmd(t, resizeStorageFlags, map[string]string{"instance-id": "db-1", "volume-type": "Gen2-NVMe2-IOPS5000"}) + body, _, err = storageBody(cmd.Flags(), instance, "db-1") + if err != nil { + t.Fatalf("storageBody type-only: %v", err) + } + config = body["databaseInstances"].([]interface{})[0].(map[string]interface{})["config"].(map[string]interface{}) + if config["volumeSize"] != 60 || config["volumeType"] != "Gen2-NVMe2-IOPS5000" { + t.Errorf("config = %v", config) + } +} + +func TestStorageBodyRefusesPointlessOrHarmfulResizes(t *testing.T) { + instance := sourceInstance() + + for name, flags := range map[string]map[string]string{ + "shrinking": {"instance-id": "db-1", "volume-size": "20"}, + "no change at all": {"instance-id": "db-1", "volume-size": "60"}, + "same type as now": {"instance-id": "db-1", "volume-type": "Gen2-NVMe2-IOPS3000"}, + } { + cmd := freshCmd(t, resizeStorageFlags, flags) + if _, _, err := storageBody(cmd.Flags(), instance, "db-1"); err == nil { + t.Errorf("%s: storageBody = nil error, want a rejection", name) + } + } +} + +// backupsOff / backupsOn stand in for the instance record settingsBody reads the +// current backup schedule from. +var ( + backupsOff = map[string]interface{}{"backupAuto": false} + backupsOn = map[string]interface{}{"backupAuto": true, "backupTime": "01:00", "backupDuration": float64(7)} +) + +func TestSettingsBodyOnlySendsWhatWasSet(t *testing.T) { + t.Setenv(passwordEnv, "") + + // publicAccess is a boolean whose zero value is a real, destructive setting, so it + // must be sent only when given. + body, _, err := settingsBody(freshCmd(t, settingsFlags, map[string]string{"password": validDBPassword}).Flags(), "db-1", backupsOff) + if err != nil { + t.Fatalf("settingsBody: %v", err) + } + if body["password"] != validDBPassword || body["dbInstanceId"] != "db-1" { + t.Errorf("body = %v", body) + } + if _, present := body["publicAccess"]; present { + t.Error("publicAccess sent although its flag was not given") + } + + // An explicit false must reach the API. + body, _, err = settingsBody(freshCmd(t, settingsFlags, map[string]string{"public-access": "false"}).Flags(), "db-1", backupsOff) + if err != nil { + t.Fatalf("settingsBody public-access: %v", err) + } + if body["publicAccess"] != false { + t.Errorf("publicAccess = %v, want false", body["publicAccess"]) + } + + if _, _, err := settingsBody(freshCmd(t, settingsFlags, nil).Flags(), "db-1", backupsOff); err == nil { + t.Error("empty update accepted; it would be a pointless API call") + } +} + +// TestSettingsBodyAlwaysCarriesTheBackupSchedule guards a live finding: the API answers +// HTTP 500 to any update/setting body without backupAuto, so a password-only or +// public-access-only change must still repeat the instance's own schedule. +func TestSettingsBodyAlwaysCarriesTheBackupSchedule(t *testing.T) { + t.Setenv(passwordEnv, "") + + body, summary, err := settingsBody( + freshCmd(t, settingsFlags, map[string]string{"password": validDBPassword}).Flags(), "db-1", backupsOn) + if err != nil { + t.Fatalf("settingsBody: %v", err) + } + if body["backupAuto"] != true || body["backupTime"] != "01:00" || body["backupDuration"] != 7 { + t.Errorf("the instance's schedule must be repeated verbatim, body = %v", body) + } + // Repeating it is not a change, so it must not be reported as one. + if strings.Contains(summary, "backup") { + t.Errorf("summary claims a backup change: %q", summary) + } + + // backupAuto false is carried too — the field's presence is what the API needs. + body, _, err = settingsBody( + freshCmd(t, settingsFlags, map[string]string{"password": validDBPassword}).Flags(), "db-1", backupsOff) + if err != nil { + t.Fatalf("settingsBody: %v", err) + } + if body["backupAuto"] != false { + t.Errorf("backupAuto missing or wrong, body = %v", body) + } + if _, present := body["backupTime"]; present { + t.Error("backupTime sent although backups are off") + } + + // The time alone can be changed while backups are on; retention comes from the + // instance. + body, summary, err = settingsBody( + freshCmd(t, settingsFlags, map[string]string{"backup-time": "05:30"}).Flags(), "db-1", backupsOn) + if err != nil { + t.Fatalf("settingsBody time only: %v", err) + } + if body["backupTime"] != "05:30" || body["backupDuration"] != 7 || body["backupAuto"] != true { + t.Errorf("body = %v", body) + } + if !strings.Contains(summary, "05:30") { + t.Errorf("summary must name the new time: %q", summary) + } +} + +func TestSettingsBodyBackupRules(t *testing.T) { + t.Setenv(passwordEnv, "") + + body, _, err := settingsBody(freshCmd(t, settingsFlags, map[string]string{ + "backup-auto": "true", "backup-duration": "7", "backup-time": "03:00", + }).Flags(), "db-1", backupsOff) + if err != nil { + t.Fatalf("settingsBody: %v", err) + } + if body["backupDuration"] != 7 || body["backupTime"] != "03:00" { + t.Errorf("body = %v", body) + } + + // Turning backup off needs neither, and must not carry them. + body, _, err = settingsBody(freshCmd(t, settingsFlags, map[string]string{"backup-auto": "false"}).Flags(), "db-1", backupsOn) + if err != nil { + t.Fatalf("settingsBody backup off: %v", err) + } + if body["backupAuto"] != false { + t.Errorf("backupAuto = %v", body["backupAuto"]) + } + if _, present := body["backupDuration"]; present { + t.Error("retention sent while disabling backup") + } + + for name, flags := range map[string]map[string]string{ + "schedule while backups are off": {"backup-duration": "7"}, + "backup on without retention": {"backup-auto": "true", "backup-time": "03:00"}, + "backup on without time": {"backup-auto": "true", "backup-duration": "7"}, + } { + if _, _, err := settingsBody(freshCmd(t, settingsFlags, flags).Flags(), "db-1", backupsOff); err == nil { + t.Errorf("%s: accepted, want a rejection", name) + } + } +} + +// TestDefaultPortOf: MySQL and MariaDB listen on 3306 and PostgreSQL on 5432, so a +// single hard-coded default for --rule would be wrong for some instances. +func TestDefaultPortOf(t *testing.T) { + if got := defaultPortOf(sourceInstance()); got != 5432 { + t.Errorf("port from the instance = %d, want 5432", got) + } + + // No port reported: fall back to the engine. + for engine, want := range map[string]int{"MySQL": 3306, "MariaDB": 3306, "PostgreSQL": 5432} { + got := defaultPortOf(map[string]interface{}{"datastoreType": engine}) + if got != want { + t.Errorf("%s fallback = %d, want %d", engine, got, want) + } + } + // Neither available: still a usable number rather than 0, which the API rejects. + if got := defaultPortOf(map[string]interface{}{}); got <= 0 { + t.Errorf("last-resort port = %d, want a valid port", got) + } +} + +func TestMutatingCommandsAreGated(t *testing.T) { + // conventions_test.go requires --dry-run and --force of delete/stop/reboot; the + // vdb notes extend it to the order-flow commands and to anything that can break + // connectivity. + for _, cmd := range []*cobra.Command{ + createCmd, createReplicaCmd, detachReplicaCmd, + resizeInstanceCmd, resizeStorageCmd, deleteCmd, + updateSettingsCmd, updateConfigGroupCmd, updateSecruleCmd, + } { + for _, flag := range []string{"dry-run", "force"} { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("%s must define --%s", cmd.Name(), flag) + } + } + } +} + +func TestFlagCompletionsAreRegistered(t *testing.T) { + cases := []struct { + cmd *cobra.Command + flags []string + }{ + {listCmd, []string{"status"}}, + {getCmd, []string{"instance-id"}}, + {listHistoriesCmd, []string{"instance-id"}}, + {listSecrulesCmd, []string{"instance-id"}}, + {listReplicasCmd, []string{"instance-id"}}, + {createCmd, []string{"datastore-type", "datastore-version", "package-id", "volume-type", "zone-id", "subnet-ids", "config-id"}}, + {createReplicaCmd, []string{"instance-id", "volume-type", "zone-id", "subnet-ids", "config-id"}}, + {detachReplicaCmd, []string{"instance-id"}}, + {resizeInstanceCmd, []string{"instance-id"}}, + {resizeStorageCmd, []string{"instance-id", "volume-type"}}, + {deleteCmd, []string{"instance-id"}}, + {updateSettingsCmd, []string{"instance-id"}}, + {updateConfigGroupCmd, []string{"instance-id", "config-id"}}, + {updateSecruleCmd, []string{"instance-id"}}, + } + + for _, c := range cases { + for _, flag := range c.flags { + if c.cmd.Flags().Lookup(flag) == nil { + t.Errorf("%s has no --%s flag", c.cmd.Name(), flag) + continue + } + if _, ok := c.cmd.GetFlagCompletionFunc(flag); !ok { + t.Errorf("%s --%s has no completion function registered", c.cmd.Name(), flag) + } + } + } + + // resize-instance deliberately does NOT complete --package-id: the flavors + // endpoint needs an engine and version, which this command has no flags for, and + // deriving them would mean fetching the instance inside the completion timeout — + // against the slowest endpoint in the API. Asserted so the omission reads as a + // decision rather than an oversight. + if _, ok := resizeInstanceCmd.GetFlagCompletionFunc("package-id"); ok { + t.Error("resize-instance --package-id now completes; if that is intentional, " + + "check it does not fetch the instance listing inside the completion bound") + } +} + +// TestHistoryColumnsCarryDescription: `action` is "Update" for every settings change, +// resize and config-group attach or detach, so a history table without description cannot +// tell them apart — which is why it leads the column set. +func TestHistoryColumnsCarryDescription(t *testing.T) { + var hasDescription, hasUpdated bool + for _, column := range historyColumns { + switch column { + case "description": + hasDescription = true + case "updatedTime": + hasUpdated = true + } + } + if !hasDescription { + t.Errorf("history table has no description column: %v", historyColumns) + } + // updatedTime duplicated createdTime on every record observed live. If that ever + // changes this is the place to reconsider, rather than a silent re-add. + if hasUpdated { + t.Errorf("updatedTime is JSON-only, it repeats createdTime: %v", historyColumns) + } + if historyColumns[1] != "action" || historyColumns[2] != "description" { + t.Errorf("description must follow action so a row reads as one phrase: %v", historyColumns) + } +} diff --git a/go/cmd/vdb/relational/instance/list.go b/go/cmd/vdb/relational/instance/list.go new file mode 100644 index 0000000..e7eaba4 --- /dev/null +++ b/go/cmd/vdb/relational/instance/list.go @@ -0,0 +1,61 @@ +package instance + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List Relational Database instances", + Long: "List vDB Relational Database instances.\n\n" + + "Results are paginated; --page is 1-based. Use --name and --status to filter " + + "server-side.", + RunE: runList, +} + +func init() { + f := listCmd.Flags() + f.Int("page", 1, "Page number (1-based)") + f.Int("page-size", vdbclient.DefaultPageSize, "Number of items per page") + f.String("name", "", "Filter by instance name") + f.String("status", "", "Filter by status (comma-separated for multiple values)") + + // Bound here, next to the flag: see the init-order note in completion.go. + listCmd.RegisterFlagCompletionFunc("status", statusCompletion()) //nolint:errcheck +} + +// listOptions maps the command's flags onto the shared list query. Split out +// from runList so the flag wiring is testable without a configured client. +func listOptions(cmd *cobra.Command) vdbclient.ListOptions { + page, _ := cmd.Flags().GetInt("page") + pageSize, _ := cmd.Flags().GetInt("page-size") + name, _ := cmd.Flags().GetString("name") + status, _ := cmd.Flags().GetString("status") + + return vdbclient.ListOptions{ + Page: page, + PageSize: pageSize, + Name: name, + Statuses: cli.ParseCommaSeparated(status), + } +} + +func runList(cmd *cobra.Command, args []string) error { + query := vdbclient.BuildListQuery(listOptions(cmd)) + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := vdbclient.Get(apiClient, basePath, query) + if err != nil { + return fmt.Errorf("failed to list database instances: %w", err) + } + + return outputList(cmd, result) +} diff --git a/go/cmd/vdb/relational/instance/list_histories.go b/go/cmd/vdb/relational/instance/list_histories.go new file mode 100644 index 0000000..219f4cc --- /dev/null +++ b/go/cmd/vdb/relational/instance/list_histories.go @@ -0,0 +1,64 @@ +package instance + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/validator" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +var listHistoriesCmd = &cobra.Command{ + Use: "list-histories", + Short: "List the action history of a Relational Database instance", + Long: "List the recorded actions (create, resize, reboot, backup, ...) of a vDB " + + "Relational Database instance.\n\n" + + "Results are paginated; --page is 1-based. This endpoint takes no filters. " + + "Table output leaves out the description field, which runs to several hundred " + + "characters — use --output json to read it. PostgreSQL Cluster IDs ('pg-') work " + + "here too.", + RunE: runListHistories, +} + +func init() { + f := listHistoriesCmd.Flags() + f.String("instance-id", "", "Database instance ID (required)") + f.Int("page", 1, "Page number (1-based)") + f.Int("page-size", vdbclient.DefaultPageSize, "Number of items per page") + listHistoriesCmd.MarkFlagRequired("instance-id") //nolint:errcheck + + // Bound here, next to the flag: see the init-order note in completion.go. + listHistoriesCmd.RegisterFlagCompletionFunc("instance-id", instanceIDCompletion()) //nolint:errcheck +} + +// historiesOptions maps the paging flags onto the shared list query. This +// endpoint accepts pageNumber/pageSize only — no name/status filter — so the +// filter fields of ListOptions are deliberately left empty. +func historiesOptions(cmd *cobra.Command) vdbclient.ListOptions { + page, _ := cmd.Flags().GetInt("page") + pageSize, _ := cmd.Flags().GetInt("page-size") + + return vdbclient.ListOptions{Page: page, PageSize: pageSize} +} + +func runListHistories(cmd *cobra.Command, args []string) error { + instanceID, _ := cmd.Flags().GetString("instance-id") + + if err := validator.ValidateID(instanceID, "instance-id"); err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + query := vdbclient.BuildListQuery(historiesOptions(cmd)) + + result, err := vdbclient.Get(apiClient, historiesPath(instanceID), query) + if err != nil { + return fmt.Errorf("failed to list history of database instance %s: %w", instanceID, err) + } + + return vdbclient.OutputWithColumns(cmd, result, historyColumns) +} diff --git a/go/cmd/vdb/relational/instance/list_histories_test.go b/go/cmd/vdb/relational/instance/list_histories_test.go new file mode 100644 index 0000000..be78b53 --- /dev/null +++ b/go/cmd/vdb/relational/instance/list_histories_test.go @@ -0,0 +1,32 @@ +package instance + +import ( + "testing" + + "github.com/greennodehub/greennode-cli/internal/vdbclient" +) + +func TestHistoriesQueryHasPagingOnly(t *testing.T) { + // The histories endpoint accepts pageNumber/pageSize and nothing else, and its + // pages are 1-based like every other vdb listing. + got := vdbclient.BuildListQuery(historiesOptions(listHistoriesCmd)).Encode() + want := "pageNumber=1&pageSize=50" + + if got != want { + t.Errorf("default query = %q, want %q", got, want) + } +} + +func TestHistoriesPathDiffersFromDetailPath(t *testing.T) { + // Relational hangs get-by-id off an extra "id" segment but the action log + // directly off the instance ID. Getting these two the wrong way round is a 404 + // that looks like a missing instance. + const id = "db-1234" + + if got, want := detailPath(id), "/vdb-relational/v1/database-instances/id/db-1234"; got != want { + t.Errorf("detailPath = %q, want %q", got, want) + } + if got, want := historiesPath(id), "/vdb-relational/v1/database-instances/db-1234/histories"; got != want { + t.Errorf("historiesPath = %q, want %q", got, want) + } +} diff --git a/go/cmd/vdb/relational/instance/list_replicas.go b/go/cmd/vdb/relational/instance/list_replicas.go new file mode 100644 index 0000000..3f56869 --- /dev/null +++ b/go/cmd/vdb/relational/instance/list_replicas.go @@ -0,0 +1,48 @@ +package instance + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +var listReplicasCmd = &cobra.Command{ + Use: "list-replicas", + Short: "List the read replicas of a Relational Database instance", + Long: "List the read replicas created from an instance.\n\n" + + "The API answers with a reduced view of each replica — no ip, port or security " + + "group — so run 'instance get' on a replica ID for its full details.", + Args: cobra.NoArgs, + RunE: runListReplicas, +} + +func init() { + f := listReplicasCmd.Flags() + f.String("instance-id", "", "ID of the source instance whose replicas to list (required)") + listReplicasCmd.MarkFlagRequired("instance-id") //nolint:errcheck + + // Bound here, next to the flag: see the init-order note in completion.go. + listReplicasCmd.RegisterFlagCompletionFunc("instance-id", relationalInstanceIDsFunc()) //nolint:errcheck +} + +func runListReplicas(cmd *cobra.Command, args []string) error { + instanceID, _ := cmd.Flags().GetString("instance-id") + if err := validateInstanceID(instanceID); err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + // The path parameter is named replicaSourceId here — same instance, a third + // name for it after dbInstanceId and instanceId. + result, err := apiClient.Get(instancePath(instanceID, "/replicas"), nil) + if err != nil { + return fmt.Errorf("failed to list replicas of database instance %s: %w", instanceID, err) + } + + return vdbclient.OutputWithColumns(cmd, result, replicaColumns) +} diff --git a/go/cmd/vdb/relational/instance/list_test.go b/go/cmd/vdb/relational/instance/list_test.go new file mode 100644 index 0000000..7051135 --- /dev/null +++ b/go/cmd/vdb/relational/instance/list_test.go @@ -0,0 +1,51 @@ +package instance + +import ( + "testing" + + "github.com/greennodehub/greennode-cli/internal/vdbclient" +) + +func TestListFlagDefaults(t *testing.T) { + // A fresh command must page from 1: vdb's pageNumber is 1-based, so a 0 + // default would skip the first page of results. + got := listOptions(listCmd) + + if got.Page != 1 { + t.Errorf("default page = %d, want 1", got.Page) + } + if got.PageSize != vdbclient.DefaultPageSize { + t.Errorf("default page-size = %d, want %d", got.PageSize, vdbclient.DefaultPageSize) + } + if got.Name != "" { + t.Errorf("default name = %q, want empty", got.Name) + } + if len(got.Statuses) != 0 { + t.Errorf("default statuses = %v, want empty", got.Statuses) + } +} + +func TestListFlagsMapToQuery(t *testing.T) { + cmd := listCmd + defer func() { + // listCmd is package-level state shared with the command tree; restore it. + _ = cmd.Flags().Set("page", "1") + _ = cmd.Flags().Set("page-size", "50") + _ = cmd.Flags().Set("name", "") + _ = cmd.Flags().Set("status", "") + }() + + for flag, value := range map[string]string{ + "page": "2", "page-size": "10", "name": "my-db", "status": "ACTIVE, ERROR", + } { + if err := cmd.Flags().Set(flag, value); err != nil { + t.Fatalf("setting --%s: %v", flag, err) + } + } + + got := vdbclient.BuildListQuery(listOptions(cmd)).Encode() + want := "name=my-db&pageNumber=2&pageSize=10&status=ACTIVE&status=ERROR" + if got != want { + t.Errorf("query = %q, want %q", got, want) + } +} diff --git a/go/cmd/vdb/relational/instance/resize_instance.go b/go/cmd/vdb/relational/instance/resize_instance.go new file mode 100644 index 0000000..f213a8e --- /dev/null +++ b/go/cmd/vdb/relational/instance/resize_instance.go @@ -0,0 +1,83 @@ +package instance + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +var resizeInstanceCmd = &cobra.Command{ + Use: "resize-instance", + Short: "Change the flavor (vCPU/RAM) of a Relational Database instance", + Long: "Move an instance to a different flavor.\n\n" + + "THIS COSTS MONEY: resizing goes through the order/payment flow and completes " + + "asynchronously; the database restarts on the new flavor. Use --dry-run first.\n\n" + + "--package-id is the numeric flavor id from 'catalog list-flavors'. Storage is " + + "resized separately with 'instance resize-storage'.", + Args: cobra.NoArgs, + RunE: runResizeInstance, +} + +func init() { + f := resizeInstanceCmd.Flags() + f.String("instance-id", "", "Database instance ID (required)") + f.String("package-id", "", "New flavor id, numeric (required; the 'id' column of 'catalog list-flavors')") + f.Bool("poc", false, "Pay with PoC credit (Auto Payment only)") + f.Bool("dry-run", false, "Print the request that would be sent without placing an order") + f.Bool("force", false, "Skip the confirmation prompt") + + resizeInstanceCmd.MarkFlagRequired("instance-id") //nolint:errcheck + resizeInstanceCmd.MarkFlagRequired("package-id") //nolint:errcheck + + // Bound here, next to the flag: see the init-order note in completion.go. + resizeInstanceCmd.RegisterFlagCompletionFunc("instance-id", relationalInstanceIDsFunc()) //nolint:errcheck + // --package-id has no completer here on purpose. The flavors endpoint needs an + // engine and version, which this command does not take as flags; deriving them + // would mean fetching the instance inside the completion timeout, and that + // listing is the slowest endpoint in the API. Run 'catalog list-flavors' instead. +} + +func runResizeInstance(cmd *cobra.Command, args []string) error { + instanceID, _ := cmd.Flags().GetString("instance-id") + if err := requireRelationalID(instanceID); err != nil { + return err + } + + packageID, _ := cmd.Flags().GetString("package-id") + poc, _ := cmd.Flags().GetBool("poc") + + // Note ResizeBody, not ActionBody: the resize endpoints spell the resource-type + // field "resourceType" where the action endpoints use "resType". + body := vdbclient.ResizeBody(vdbclient.ResourceTypeInstance, instanceID, "resize", map[string]interface{}{ + "packageId": packageID, + "poc": poc, + }) + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + vdbclient.PreviewBody("resize", fmt.Sprintf("database instance %s", instanceID), body) + return nil + } + if !cli.Confirm(force, fmt.Sprintf( + "Move database instance %s to flavor %s? This places a paid order and restarts the database.", + instanceID, packageID)) { + fmt.Println("Aborted.") + return nil + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Post(instancePath(instanceID, "/resize-instance"), body) + if err != nil { + return fmt.Errorf("failed to resize database instance %s: %w", instanceID, err) + } + + return vdbclient.Output(cmd, result) +} diff --git a/go/cmd/vdb/relational/instance/resize_storage.go b/go/cmd/vdb/relational/instance/resize_storage.go new file mode 100644 index 0000000..bcca5e1 --- /dev/null +++ b/go/cmd/vdb/relational/instance/resize_storage.go @@ -0,0 +1,136 @@ +package instance + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +var resizeStorageCmd = &cobra.Command{ + Use: "resize-storage", + Short: "Change the storage size or type of a Relational Database instance", + Long: "Grow an instance's volume, or move it to a different volume type.\n\n" + + "THIS COSTS MONEY: resizing goes through the order/payment flow and completes " + + "asynchronously. Storage can normally only grow. Use --dry-run first.\n\n" + + "The API expects BOTH the size and the type on every request, using the current " + + "value for whichever is not changing — so this command reads the instance first " + + "and fills in the field you did not pass. --dry-run shows the resolved request.", + Args: cobra.NoArgs, + RunE: runResizeStorage, +} + +// resizeStorageFlags defines the flags on f; a function so tests can build a +// throwaway command with the same set instead of mutating the mounted one. +func resizeStorageFlags(f *pflag.FlagSet) { + f.String("instance-id", "", "Database instance ID (required)") + f.Int("volume-size", 0, "New volume size in GB (default: unchanged)") + f.String("volume-type", "", "New volume type NAME (default: unchanged; see 'catalog list-volume-types')") + f.Bool("poc", false, "Pay with PoC credit (Auto Payment only)") + f.Bool("dry-run", false, "Print the request that would be sent without placing an order") + f.Bool("force", false, "Skip the confirmation prompt") +} + +func init() { + resizeStorageFlags(resizeStorageCmd.Flags()) + + resizeStorageCmd.MarkFlagRequired("instance-id") //nolint:errcheck + + // Bound here, next to the flag: see the init-order note in completion.go. + resizeStorageCmd.RegisterFlagCompletionFunc("instance-id", relationalInstanceIDsFunc()) //nolint:errcheck + resizeStorageCmd.RegisterFlagCompletionFunc("volume-type", volumeTypeCompletion()) //nolint:errcheck +} + +func runResizeStorage(cmd *cobra.Command, args []string) error { + instanceID, _ := cmd.Flags().GetString("instance-id") + if err := requireRelationalID(instanceID); err != nil { + return err + } + + flags := cmd.Flags() + if !flags.Changed("volume-size") && !flags.Changed("volume-type") { + return fmt.Errorf("nothing to resize: pass --volume-size and/or --volume-type") + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + instance, err := fetchInstance(apiClient, instanceID) + if err != nil { + return err + } + + body, summary, err := storageBody(flags, instance, instanceID) + if err != nil { + return err + } + + dryRun, _ := flags.GetBool("dry-run") + force, _ := flags.GetBool("force") + + if dryRun { + vdbclient.PreviewBody("resize", fmt.Sprintf("the storage of database instance %s", instanceID), body) + return nil + } + if !cli.Confirm(force, fmt.Sprintf( + "Resize the storage of database instance %s (%s)? This places a paid order.", instanceID, summary)) { + fmt.Println("Aborted.") + return nil + } + + result, err := apiClient.Post(instancePath(instanceID, "/resize-storage"), body) + if err != nil { + return fmt.Errorf("failed to resize the storage of database instance %s: %w", instanceID, err) + } + + return vdbclient.Output(cmd, result) +} + +// storageBody assembles ResizeVolumeRequest, taking the unchanged field from the +// instance, and returns a summary of the change for the prompt. +// +// Both fields are always sent: the API documents "if unchanged, use the current +// volume size/type of the DB instance", so omitting one is not the same as leaving +// it alone. +func storageBody(flags *pflag.FlagSet, instance map[string]interface{}, instanceID string) (map[string]interface{}, string, error) { + currentSize := intField(instance, "volumeSize") + currentType := stringField(instance, "volumeType") + + size := intOrDefault(flags, "volume-size", currentSize) + volumeType := stringOrDefault(flags, "volume-type", currentType) + poc, _ := flags.GetBool("poc") + + if size <= 0 { + return nil, "", fmt.Errorf("invalid volume size %d for database instance %s: pass --volume-size", size, instanceID) + } + if volumeType == "" { + return nil, "", fmt.Errorf("could not read the current volume type of database instance %s: pass --volume-type", instanceID) + } + if size < currentSize { + return nil, "", fmt.Errorf("cannot shrink storage: instance %s is at %d GB and --volume-size is %d", + instanceID, currentSize, size) + } + + var summary string + switch { + case size != currentSize && volumeType != currentType: + summary = fmt.Sprintf("%d -> %d GB, %s -> %s", currentSize, size, currentType, volumeType) + case size != currentSize: + summary = fmt.Sprintf("%d -> %d GB, volume type unchanged (%s)", currentSize, size, currentType) + case volumeType != currentType: + summary = fmt.Sprintf("%s -> %s, size unchanged (%d GB)", currentType, volumeType, currentSize) + default: + return nil, "", fmt.Errorf("nothing would change: instance %s is already %d GB on %s", + instanceID, currentSize, currentType) + } + + return vdbclient.ResizeBody(vdbclient.ResourceTypeInstance, instanceID, "resize", map[string]interface{}{ + "volumeSize": size, + "volumeType": volumeType, + "poc": poc, + }), summary, nil +} diff --git a/go/cmd/vdb/relational/instance/secrules.go b/go/cmd/vdb/relational/instance/secrules.go new file mode 100644 index 0000000..8fd7fad --- /dev/null +++ b/go/cmd/vdb/relational/instance/secrules.go @@ -0,0 +1,168 @@ +package instance + +import ( + "fmt" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// Default ports per engine, used when a --rule names none. The instance's own +// `port` field is preferred; this is the fallback when it is missing. +var enginePorts = map[string]int{ + "MySQL": 3306, + "MariaDB": 3306, + "PostgreSQL": 5432, +} + +var listSecrulesCmd = &cobra.Command{ + Use: "list-secrules", + Short: "List the security rules of a Relational Database instance", + Long: "List the security group rules controlling access to an instance.\n\n" + + "Use 'instance update-secrule' to change them — note that update REPLACES the " + + "whole rule set, so start from this listing.", + Args: cobra.NoArgs, + RunE: runListSecrules, +} + +var updateSecruleCmd = &cobra.Command{ + Use: "update-secrule", + Short: "Replace the security rules of a Relational Database instance", + Long: "Set which networks may reach an instance.\n\n" + + "The API REPLACES the entire rule set with what is sent, so by default the rules " + + "you pass become the only rules — anything currently allowed and not repeated is " + + "revoked. Pass --add to keep the existing rules and append instead.\n\n" + + "Each --rule is a comma-separated list of key=value pairs:\n" + + " cidr the allowed network, e.g. cidr=203.0.113.0/24 (required)\n" + + " port single port; defaults to the instance's own port\n" + + " port-min low end of a port range (use with port-max)\n" + + " port-max high end of a port range\n" + + " id existing rule ID, to keep a rule you are re-sending\n\n" + + "Repeat --rule for each rule. Run 'instance list-secrules' first and --dry-run to " + + "see exactly what would be sent; locking yourself out is the failure mode here.", + Args: cobra.NoArgs, + RunE: runUpdateSecrule, +} + +func init() { + f := listSecrulesCmd.Flags() + f.String("instance-id", "", "Database instance ID (required)") + listSecrulesCmd.MarkFlagRequired("instance-id") //nolint:errcheck + listSecrulesCmd.RegisterFlagCompletionFunc("instance-id", relationalInstanceIDsFunc()) //nolint:errcheck + + u := updateSecruleCmd.Flags() + u.String("instance-id", "", "Database instance ID (required)") + u.StringArray("rule", nil, "Rule as key=value pairs, repeatable (see --help)") + u.Bool("add", false, "Append to the existing rules instead of replacing them") + u.Bool("dry-run", false, "Print the request that would be sent without applying it") + u.Bool("force", false, "Skip the confirmation prompt") + + updateSecruleCmd.MarkFlagRequired("instance-id") //nolint:errcheck + updateSecruleCmd.MarkFlagRequired("rule") //nolint:errcheck + updateSecruleCmd.RegisterFlagCompletionFunc("instance-id", relationalInstanceIDsFunc()) //nolint:errcheck +} + +func runListSecrules(cmd *cobra.Command, args []string) error { + instanceID, _ := cmd.Flags().GetString("instance-id") + if err := validateInstanceID(instanceID); err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := fetchSecrules(apiClient, instanceID) + if err != nil { + return err + } + + return vdbclient.OutputWithColumns(cmd, result, vdbclient.SecurityRuleColumns) +} + +func runUpdateSecrule(cmd *cobra.Command, args []string) error { + instanceID, _ := cmd.Flags().GetString("instance-id") + if err := requireRelationalID(instanceID); err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + instance, err := fetchInstance(apiClient, instanceID) + if err != nil { + return err + } + + specs, _ := cmd.Flags().GetStringArray("rule") + rules, err := vdbclient.ParseSecurityRules(specs, defaultPortOf(instance)) + if err != nil { + return err + } + + current, err := fetchSecrules(apiClient, instanceID) + if err != nil { + return err + } + existing := vdbclient.SecurityRulesFrom(current) + + add, _ := cmd.Flags().GetBool("add") + if add { + rules = append(existing, rules...) + } + + fmt.Printf("Current rules on %s: %s\n", instanceID, vdbclient.DescribeSecurityRules(existing)) + fmt.Printf("Rules after this change: %s\n", vdbclient.DescribeSecurityRules(rules)) + if !add && len(existing) > 0 { + fmt.Println("\nThis REPLACES the rule set; anything above that is not in the new set is revoked.") + } + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + // The body is a bare JSON ARRAY, not an object — one of the six vdb endpoints + // shaped that way. + vdbclient.PreviewBody("update", fmt.Sprintf("the security rules of database instance %s", instanceID), rules) + return nil + } + if !cli.Confirm(force, fmt.Sprintf("Apply this rule set to database instance %s?", instanceID)) { + fmt.Println("Aborted.") + return nil + } + + result, err := apiClient.Put(instancePath(instanceID, "/secrules"), rules) + if err != nil { + return fmt.Errorf("failed to update the security rules of database instance %s: %w", instanceID, err) + } + + return vdbclient.OutputWithColumns(cmd, result, vdbclient.SecurityRuleColumns) +} + +func fetchSecrules(apiClient *vdbclient.Client, instanceID string) (interface{}, error) { + result, err := apiClient.Get(instancePath(instanceID, "/secrules"), nil) + if err != nil { + return nil, fmt.Errorf("failed to list the security rules of database instance %s: %w", instanceID, err) + } + return result, nil +} + +// defaultPortOf is the port a rule gets when it names none: the instance's own +// port, falling back to the engine's default. MySQL and MariaDB listen on 3306, +// PostgreSQL on 5432, so a single hard-coded default would be wrong for some +// instances. +func defaultPortOf(instance map[string]interface{}) int { + if port := intField(instance, "port"); port > 0 { + return port + } + if port, ok := enginePorts[stringField(instance, "datastoreType")]; ok { + return port + } + // Last resort: MySQL's port, the most common engine here. A rule that needs + // something else can always name it explicitly. + return 3306 +} diff --git a/go/cmd/vdb/relational/instance/update_config_group.go b/go/cmd/vdb/relational/instance/update_config_group.go new file mode 100644 index 0000000..908e82f --- /dev/null +++ b/go/cmd/vdb/relational/instance/update_config_group.go @@ -0,0 +1,108 @@ +package instance + +import ( + "fmt" + "strings" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/spf13/cobra" +) + +var updateConfigGroupCmd = &cobra.Command{ + Use: "update-config-group", + Short: "Attach or detach the config group of a Relational Database instance", + Long: "Attach a config group to an instance, or detach the current one with " + + "--detach.\n\n" + + "The config group must match the instance's engine and version — see " + + "'catalog list-config-groups'. Applying configuration parameters can restart the " + + "database, so the command confirms first.\n\n" + + "The change is ASYNCHRONOUS and its response body carries nothing useful, so this " + + "command reports acceptance and points you at 'instance get'.", + Args: cobra.NoArgs, + RunE: runUpdateConfigGroup, +} + +func init() { + f := updateConfigGroupCmd.Flags() + f.String("instance-id", "", "Database instance ID (required)") + f.String("config-id", "", "Config group ID to attach (required unless --detach)") + f.Bool("detach", false, "Detach the current config group instead of attaching one") + f.Bool("dry-run", false, "Print the request that would be sent without applying it") + f.Bool("force", false, "Skip the confirmation prompt") + + updateConfigGroupCmd.MarkFlagRequired("instance-id") //nolint:errcheck + // Mutually exclusive rather than "empty means detach": the API detaches on an + // empty string, which is far too easy to send by accident from a script whose + // variable is unset. + updateConfigGroupCmd.MarkFlagsMutuallyExclusive("config-id", "detach") + + // Bound here, next to the flags: see the init-order note in completion.go. + updateConfigGroupCmd.RegisterFlagCompletionFunc("instance-id", relationalInstanceIDsFunc()) //nolint:errcheck + updateConfigGroupCmd.RegisterFlagCompletionFunc("config-id", configIDCompletion()) //nolint:errcheck +} + +func runUpdateConfigGroup(cmd *cobra.Command, args []string) error { + instanceID, _ := cmd.Flags().GetString("instance-id") + if err := requireRelationalID(instanceID); err != nil { + return err + } + + configID, _ := cmd.Flags().GetString("config-id") + detach, _ := cmd.Flags().GetBool("detach") + + if !detach && configID == "" { + return fmt.Errorf("pass --config-id to attach a config group, or --detach to remove the current one") + } + + // dbInstanceId is required in the body as well as the path. + // + // Detaching sends configId **null**, not the empty string the spec describes: + // `""` is rejected with `The configId doesn't exist` (HTTP 400), while null + // detaches and leaves the instance RESTART_REQUIRED. Verified against the live + // API on 2026-08-13 — do not "fix" this back to the spec. + body := map[string]interface{}{"dbInstanceId": instanceID} + if detach { + body["configId"] = nil + } else { + body["configId"] = configID + } + + summary := fmt.Sprintf("attach config group %s", configID) + if detach { + summary = "detach the config group" + } + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + previewAsyncUpdate("update", fmt.Sprintf("the config group of database instance %s", instanceID), body) + return nil + } + if !cli.Confirm(force, fmt.Sprintf("%s on database instance %s? This may restart the database.", + capitalize(summary), instanceID)) { + fmt.Println("Aborted.") + return nil + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + warnIfNotActive(apiClient, instanceID) + + if _, err := apiClient.Put(instancePath(instanceID, "/update/config-group"), body); err != nil { + return fmt.Errorf("failed to update the config group of database instance %s: %w", instanceID, err) + } + + reportAsyncAccepted(cmd, instanceID, summary) + return nil +} + +func capitalize(s string) string { + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} diff --git a/go/cmd/vdb/relational/instance/update_settings.go b/go/cmd/vdb/relational/instance/update_settings.go new file mode 100644 index 0000000..59cbb2e --- /dev/null +++ b/go/cmd/vdb/relational/instance/update_settings.go @@ -0,0 +1,192 @@ +package instance + +import ( + "fmt" + "os" + + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +var updateSettingsCmd = &cobra.Command{ + Use: "update-settings", + Short: "Update the password, public access or backup schedule of an instance", + Long: "Change an instance's master password, its public-access setting or its " + + "automatic-backup schedule.\n\n" + + "Only the settings you pass change, so updating the password cannot silently " + + "flip network exposure or turn backups off. The one exception is the backup " + + "schedule, which the API rejects a request without (HTTP 500), so the instance's " + + "current schedule is read and repeated for you — which also means --backup-time " + + "and --backup-duration can be changed on their own while automatic backup is on. " + + "The new password is read from $" + passwordEnv + " when --password is omitted, and " + + "is masked in --dry-run output.\n\n" + + "The change is ASYNCHRONOUS: the API accepts the request and applies it in the " + + "background, and its response body carries nothing useful, so this command " + + "reports acceptance and points you at 'instance get' to confirm the result.", + Args: cobra.NoArgs, + RunE: runUpdateSettings, +} + +func settingsFlags(f *pflag.FlagSet) { + f.String("instance-id", "", "Database instance ID (required)") + f.String("password", "", "New master password (defaults to $"+passwordEnv+")") + f.Bool("public-access", false, "Whether the instance is reachable publicly") + f.Bool("backup-auto", false, "Whether daily automatic backup is enabled") + f.Int("backup-duration", 0, fmt.Sprintf("Backup retention in days, %d-%d (with --backup-auto)", minBackupDuration, maxBackupDuration)) + f.String("backup-time", "", "Time of day to run the backup, HH:MM (with --backup-auto)") + f.Bool("dry-run", false, "Print the request that would be sent without applying it") + f.Bool("force", false, "Skip the confirmation prompt") +} + +func init() { + settingsFlags(updateSettingsCmd.Flags()) + + updateSettingsCmd.MarkFlagRequired("instance-id") //nolint:errcheck + + // Bound here, next to the flag: see the init-order note in completion.go. + updateSettingsCmd.RegisterFlagCompletionFunc("instance-id", relationalInstanceIDsFunc()) //nolint:errcheck +} + +func runUpdateSettings(cmd *cobra.Command, args []string) error { + instanceID, _ := cmd.Flags().GetString("instance-id") + if err := requireRelationalID(instanceID); err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + // Read up front, even for --dry-run: the request needs the instance's current backup + // schedule (see settingsBody). + current, err := fetchInstance(apiClient, instanceID) + if err != nil { + return err + } + + body, summary, err := settingsBody(cmd.Flags(), instanceID, current) + if err != nil { + return err + } + + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + + if dryRun { + previewAsyncUpdate("update", fmt.Sprintf("the settings of database instance %s", instanceID), body) + return nil + } + if !cli.Confirm(force, fmt.Sprintf("Update %s on database instance %s?", summary, instanceID)) { + fmt.Println("Aborted.") + return nil + } + + warnIfNotActive(apiClient, instanceID) + + if _, err := apiClient.Put(instancePath(instanceID, "/update/setting"), body); err != nil { + return fmt.Errorf("failed to update the settings of database instance %s: %w", instanceID, err) + } + + reportAsyncAccepted(cmd, instanceID, summary) + return nil +} + +// settingsBody assembles UpdateDbSettingRequest from the flags the user actually +// set, and describes the change for the prompt. +// +// Sending only changed fields matters here: publicAccess and backupAuto are +// booleans whose zero value is a real, destructive setting. A body that always +// carried "publicAccess": false would cut off public connectivity every time +// someone rotated a password, and "backupAuto": false would silently disable +// backups. +// +// backupAuto is the exception, and not by choice: a body without it is answered with +// HTTP 500 (verified live on 2026-08-13 against both this API and its MemoryStore twin, +// with a body that only repeated the instance's own publicAccess). So the schedule is +// taken from `current` and repeated whenever the user names none of the backup flags, +// which keeps the call a no-op for backups while satisfying the API. +func settingsBody(flags *pflag.FlagSet, instanceID string, current map[string]interface{}) (map[string]interface{}, string, error) { + // dbInstanceId is required in the body as well as the path. + body := map[string]interface{}{"dbInstanceId": instanceID} + var changes []string + + password, _ := flags.GetString("password") + if password == "" { + password = os.Getenv(passwordEnv) + } + if password != "" { + if err := vdbclient.ValidateDBPassword(password, "password"); err != nil { + return nil, "", err + } + body["password"] = password + changes = append(changes, "the master password") + } + + if flags.Changed("public-access") { + publicAccess, _ := flags.GetBool("public-access") + body["publicAccess"] = publicAccess + changes = append(changes, fmt.Sprintf("public access (-> %t)", publicAccess)) + } + + // Start from the instance's own schedule and overwrite only what the user named. + backupAuto := boolField(current, "backupAuto") + backupTime := stringField(current, "backupTime") + duration := intField(current, "backupDuration") + scheduleChanged := false + + if flags.Changed("backup-auto") { + backupAuto, _ = flags.GetBool("backup-auto") + scheduleChanged = true + } + if flags.Changed("backup-time") { + backupTime, _ = flags.GetString("backup-time") + scheduleChanged = true + } + if flags.Changed("backup-duration") { + duration, _ = flags.GetInt("backup-duration") + scheduleChanged = true + } + + if !backupAuto && (flags.Changed("backup-time") || flags.Changed("backup-duration")) { + return nil, "", fmt.Errorf("--backup-time and --backup-duration apply only while automatic backup is on: add --backup-auto") + } + + body["backupAuto"] = backupAuto + if backupAuto { + if duration < minBackupDuration || duration > maxBackupDuration { + return nil, "", fmt.Errorf("--backup-duration must be between %d and %d days, got %d", + minBackupDuration, maxBackupDuration, duration) + } + if backupTime == "" { + return nil, "", fmt.Errorf("automatic backup needs a time of day: pass --backup-time, e.g. --backup-time 02:00") + } + body["backupDuration"] = duration + body["backupTime"] = backupTime + } + + if scheduleChanged { + // Name the schedule in the summary: "automatic backup (-> true)" alone hides the + // time and retention just set, and the response carries nothing to check. + if backupAuto { + changes = append(changes, fmt.Sprintf("automatic backup (-> on, %s, %d days)", backupTime, duration)) + } else { + changes = append(changes, "automatic backup (-> off)") + } + } + + // The body always carries the schedule now, so emptiness is judged by what the user + // actually asked to change. + if len(changes) == 0 { + return nil, "", fmt.Errorf("nothing to update: pass --password (or set $%s), --public-access or --backup-auto", + passwordEnv) + } + + summary := changes[0] + for _, change := range changes[1:] { + summary += " and " + change + } + return body, summary, nil +} diff --git a/go/cmd/vdb/relational/relational.go b/go/cmd/vdb/relational/relational.go new file mode 100644 index 0000000..f1649ad --- /dev/null +++ b/go/cmd/vdb/relational/relational.go @@ -0,0 +1,30 @@ +package relational + +import ( + "github.com/greennodehub/greennode-cli/cmd/vdb/relational/backup" + "github.com/greennodehub/greennode-cli/cmd/vdb/relational/backupstorage" + "github.com/greennodehub/greennode-cli/cmd/vdb/relational/catalog" + "github.com/greennodehub/greennode-cli/cmd/vdb/relational/configuration" + "github.com/greennodehub/greennode-cli/cmd/vdb/relational/instance" + "github.com/spf13/cobra" +) + +// RelationalCmd groups the Relational Database commands, which sit under the +// /vdb-relational/v1 API prefix (MySQL / PostgreSQL single instances). +var RelationalCmd = &cobra.Command{ + Use: "relational", + Short: "Manage Relational Database (MySQL, PostgreSQL) resources", + Long: "Manage vDB Relational Database instances, backups and configurations.", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() //nolint:errcheck + }, +} + +func init() { + RelationalCmd.AddCommand(instance.InstanceCmd) + RelationalCmd.AddCommand(catalog.CatalogCmd) + RelationalCmd.AddCommand(backup.BackupCmd) + RelationalCmd.AddCommand(configuration.ConfigurationCmd) + RelationalCmd.AddCommand(backupstorage.BackupStorageCmd) +} diff --git a/go/cmd/vdb/vdb.go b/go/cmd/vdb/vdb.go new file mode 100644 index 0000000..60a78eb --- /dev/null +++ b/go/cmd/vdb/vdb.go @@ -0,0 +1,44 @@ +package vdb + +import ( + "github.com/greennodehub/greennode-cli/cmd/vdb/kafka" + "github.com/greennodehub/greennode-cli/cmd/vdb/memorystore" + "github.com/greennodehub/greennode-cli/cmd/vdb/postgresql" + "github.com/greennodehub/greennode-cli/cmd/vdb/relational" + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/vdbclient" + "github.com/spf13/cobra" +) + +// VdbCmd is the parent command for all vDB subcommands. +// +// vDB is four separate products behind one gateway (Relational, MemoryStore, +// Kafka, PostgreSQL Cluster) whose APIs share almost nothing — different paths, +// different response envelopes, different HTTP verbs for the same operation. The +// command tree mirrors that split instead of hiding it behind an --engine flag, +// so each group can follow its own API without leaking flags that only apply to +// one of them. +var VdbCmd = &cobra.Command{ + Use: "vdb", + Short: "VNG Database (vDB) commands", + Long: "Manage vDB database instances and clusters.", + // Reject unknown subcommands (parent groups don't error by default in cobra). + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() //nolint:errcheck + }, +} + +func init() { + VdbCmd.PersistentFlags().String("user-type", "", + "Billing flow for chargeable operations: ROOT_USER (Checkout) or IAM_USER (Auto Payment). Default: ROOT_USER") + VdbCmd.RegisterFlagCompletionFunc("user-type", cli.FlagValues(vdbclient.UserTypeValues...)) //nolint:errcheck + + VdbCmd.AddCommand(relational.RelationalCmd) + VdbCmd.AddCommand(postgresql.PostgresqlCmd) + VdbCmd.AddCommand(memorystore.MemorystoreCmd) + VdbCmd.AddCommand(kafka.KafkaCmd) + + // Self-register with the CLI so root.go mounts this product automatically. + cli.RegisterService(VdbCmd) +} diff --git a/go/internal/client/client.go b/go/internal/client/client.go index 58e4eb3..d0fb10f 100644 --- a/go/internal/client/client.go +++ b/go/internal/client/client.go @@ -63,6 +63,7 @@ type GreennodeClient struct { tokenProvider TokenProvider httpClient *http.Client debug bool + extraHeaders map[string]string } // NewGreennodeClient creates a new API client. connectTimeout bounds the TCP @@ -97,6 +98,30 @@ func NewGreennodeClient(baseURL string, tokenProvider TokenProvider, connectTime } } +// SetHeaders registers extra headers sent on every request, alongside the +// built-in Authorization/Content-Type/User-Agent. Needed by services whose API +// takes request metadata in headers rather than the body — vdb's "user-type" +// (ROOT_USER/IAM_USER) on its billing endpoints, for example. Values are applied +// on both the initial request and the post-401 retry. Returns the client so it +// can be chained onto a constructor call. +func (c *GreennodeClient) SetHeaders(headers map[string]string) *GreennodeClient { + c.extraHeaders = headers + return c +} + +// applyHeaders sets the standard headers plus any registered extras. Extras are +// applied last so a service can override Content-Type if it ever needs to. +func (c *GreennodeClient) applyHeaders(req *http.Request, token string) { + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", UserAgent) + for k, v := range c.extraHeaders { + if v != "" { + req.Header.Set(k, v) + } + } +} + // Get performs a GET request. func (c *GreennodeClient) Get(path string, params map[string]string) (interface{}, error) { return c.request("GET", path, params, nil) @@ -127,6 +152,30 @@ func (c *GreennodeClient) GetRaw(path string, params map[string]string) (string, return c.requestRaw("GET", path, params, nil) } +// Request performs any method with both query parameters and a JSON body. +// +// The verb-named helpers above each fix one of the two: Put/Post take a body but +// no params, Delete takes params but no body. vDB's Kafka API needs both at once — +// its mutating endpoints put their arguments in the QUERY STRING of a PUT +// (?count=&rebalance=, ?mtlsAuthen=&saslAuthen=), which no existing helper can +// express. Additive: nothing else changes behaviour. +func (c *GreennodeClient) Request(method, path string, params map[string]string, body interface{}) (interface{}, error) { + return c.request(method, path, params, body) +} + +// RequestRaw is Request without the JSON parse, returning the response body as +// text. +// +// It exists for endpoints whose success response is not a JSON document. Eleven +// Kafka operations declare a bare `string` in the spec; in practice they answer +// 200 or 204 with an EMPTY body (verified live 2026-08-17), and a non-JSON body +// would make request() fail on a call that actually succeeded. Skipping the parse +// makes the outcome depend on the HTTP status alone. Errors are still returned as +// *APIError, exactly as for Request. +func (c *GreennodeClient) RequestRaw(method, path string, params map[string]string, body interface{}) (string, error) { + return c.requestRaw(method, path, params, body) +} + // GetAllPages fetches all pages and merges items into a single result. func (c *GreennodeClient) GetAllPages(path string, pageSize int) (map[string]interface{}, error) { if pageSize == 0 { @@ -223,9 +272,7 @@ func (c *GreennodeClient) requestRaw(method, path string, params map[string]stri return "", fmt.Errorf("failed to create request: %w", err) } - req.Header.Set("Authorization", "Bearer "+token) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", UserAgent) + c.applyHeaders(req, token) if c.debug { fmt.Fprintf(os.Stderr, "[debug] %s %s\n", method, fullURL) @@ -264,9 +311,7 @@ func (c *GreennodeClient) requestRaw(method, path string, params map[string]stri retryBody = bytes.NewReader(jsonBody) } req2, _ := http.NewRequest(method, fullURL, retryBody) - req2.Header.Set("Authorization", "Bearer "+token) - req2.Header.Set("Content-Type", "application/json") - req2.Header.Set("User-Agent", UserAgent) + c.applyHeaders(req2, token) resp2, err := c.httpClient.Do(req2) if err != nil { return "", err diff --git a/go/internal/client/headers_test.go b/go/internal/client/headers_test.go new file mode 100644 index 0000000..683eef3 --- /dev/null +++ b/go/internal/client/headers_test.go @@ -0,0 +1,130 @@ +package client + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/greennodehub/greennode-cli/internal/auth" +) + +func TestSetHeadersAppliesExtraHeaders(t *testing.T) { + var got http.Header + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.Header.Clone() + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + tm := auth.NewMachineTokenProvider("id", "secret", "") + tm.SetToken("test-token", time.Now().Add(1*time.Hour)) + + c := NewGreennodeClient(srv.URL, tm, 5*time.Second, 5*time.Second, false, false). + SetHeaders(map[string]string{"user-type": "IAM_USER"}) + + if _, err := c.Get("/v1/thing", nil); err != nil { + t.Fatalf("Get returned error: %v", err) + } + if v := got.Get("user-type"); v != "IAM_USER" { + t.Errorf("user-type = %q, want IAM_USER", v) + } + // The extras must not displace the built-ins. + if v := got.Get("Authorization"); v != "Bearer test-token" { + t.Errorf("Authorization = %q, want Bearer test-token", v) + } + if v := got.Get("Content-Type"); v != "application/json" { + t.Errorf("Content-Type = %q, want application/json", v) + } +} + +func TestSetHeadersSkipsEmptyValues(t *testing.T) { + // An unset --user-type must send no header at all, so the API applies its own + // ROOT_USER default rather than us pinning a value. + var present bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, present = r.Header["User-Type"] + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + tm := auth.NewMachineTokenProvider("id", "secret", "") + tm.SetToken("test-token", time.Now().Add(1*time.Hour)) + + c := NewGreennodeClient(srv.URL, tm, 5*time.Second, 5*time.Second, false, false). + SetHeaders(map[string]string{"user-type": ""}) + + if _, err := c.Get("/v1/thing", nil); err != nil { + t.Fatalf("Get returned error: %v", err) + } + if present { + t.Error("user-type header was sent despite an empty value") + } +} + +func TestSetHeadersSurviveTokenRefreshRetry(t *testing.T) { + // The 401 path builds a second request from scratch; extras are easy to drop + // there, which would silently change the billing flow on a retried call. + var calls int + var retryHeader string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if calls == 1 { + w.WriteHeader(http.StatusUnauthorized) + return + } + retryHeader = r.Header.Get("user-type") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + fp := &fakeProvider{token: "first", refreshTo: "second"} + c := NewGreennodeClient(srv.URL, fp, 5*time.Second, 5*time.Second, false, false). + SetHeaders(map[string]string{"user-type": "ROOT_USER"}) + + if _, err := c.Get("/v1/thing", nil); err != nil { + t.Fatalf("Get returned error: %v", err) + } + if calls != 2 { + t.Fatalf("server saw %d calls, want 2 (initial 401 + retry)", calls) + } + if retryHeader != "ROOT_USER" { + t.Errorf("user-type on retry = %q, want ROOT_USER", retryHeader) + } +} + +func TestExistingCallersSendNoExtraHeaders(t *testing.T) { + // A client that never calls SetHeaders must send exactly the three built-in + // headers, unchanged — this is what keeps the vdb header seam invisible to + // every other product. + var got http.Header + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.Header.Clone() + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + tm := auth.NewMachineTokenProvider("id", "secret", "") + tm.SetToken("test-token", time.Now().Add(1*time.Hour)) + + c := NewGreennodeClient(srv.URL, tm, 5*time.Second, 5*time.Second, false, false) + if _, err := c.Get("/v1/thing", nil); err != nil { + t.Fatalf("Get returned error: %v", err) + } + + // Accept-Encoding and Host are added by net/http itself, not by us. + for _, h := range []string{"Authorization", "Content-Type", "User-Agent"} { + if got.Get(h) == "" { + t.Errorf("%s header missing", h) + } + got.Del(h) + } + got.Del("Accept-Encoding") + if len(got) != 0 { + t.Errorf("unexpected extra headers on a client without SetHeaders: %v", got) + } +} diff --git a/go/internal/config/config.go b/go/internal/config/config.go index 231ca30..3b627d9 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -15,10 +15,13 @@ var REGIONS = map[string]map[string]string{ "HCM-3": { "vks_endpoint": "https://vks.api.vngcloud.vn", "vserver_endpoint": "https://hcm-3.api.vngcloud.vn/vserver/vserver-gateway", + "vdb_endpoint": "https://vdb-gateway.vngcloud.vn", }, "HAN": { "vks_endpoint": "https://vks-han-1.api.vngcloud.vn", "vserver_endpoint": "https://han-1.api.vngcloud.vn/vserver/vserver-gateway", + // No vdb_endpoint: vdb is not offered in HAN yet. GetEndpoint returns a + // clear "endpoint not found for service 'vdb' in region 'HAN'" error. }, } diff --git a/go/internal/config/endpoint_vdb_test.go b/go/internal/config/endpoint_vdb_test.go new file mode 100644 index 0000000..1d022e7 --- /dev/null +++ b/go/internal/config/endpoint_vdb_test.go @@ -0,0 +1,30 @@ +package config + +import ( + "strings" + "testing" +) + +// vdb is only offered in HCM-3 today. Pin both halves of that: the endpoint +// resolves where the service exists, and fails with a message naming the service +// and region where it does not — so a HAN user gets a real answer instead of a +// confusing auth or 404 error deeper in the stack. +func TestGetEndpointVdb(t *testing.T) { + cfg := &Config{Region: "HCM-3", Regions: REGIONS} + got, err := cfg.GetEndpoint("vdb") + if err != nil { + t.Fatalf("GetEndpoint(vdb) in HCM-3 returned error: %v", err) + } + if got != "https://vdb-gateway.vngcloud.vn" { + t.Errorf("vdb endpoint in HCM-3 = %q, want https://vdb-gateway.vngcloud.vn", got) + } + + cfg = &Config{Region: "HAN", Regions: REGIONS} + _, err = cfg.GetEndpoint("vdb") + if err == nil { + t.Fatal("GetEndpoint(vdb) in HAN succeeded, want an error (vdb is not offered there)") + } + if !strings.Contains(err.Error(), "vdb") || !strings.Contains(err.Error(), "HAN") { + t.Errorf("error = %q, want it to name both the service and the region", err) + } +} diff --git a/go/internal/vdbclient/action.go b/go/internal/vdbclient/action.go new file mode 100644 index 0000000..736ed5c --- /dev/null +++ b/go/internal/vdbclient/action.go @@ -0,0 +1,73 @@ +package vdbclient + +// Resource types accepted by the action and resize endpoints. There is one per +// KIND of resource, and sending the wrong one is a validation error that does not +// name the field — so never hard-code "dbaas" for something that is not an +// instance. +const ( + ResourceTypeInstance = "dbaas" + ResourceTypeBackup = "dbaas-backup" + ResourceTypeBackupStorage = "dbaas-backup-storage" +) + +// ActionBody builds the request for the action endpoints: instance start / stop / +// reboot / detach-replica / delete, and backup-storage delete. +// +// Four things about this shape are easy to get wrong: +// +// - The resource ID goes in the BODY as well as the path, inside a list, under +// the key "instancesId" (not "instanceId") — even when the resource is a backup +// storage rather than an instance. +// - The spec models all of these with one shared request class, so each of them +// documents every action the family knows (start|stop|reboot|detach_replica). +// That is an artifact: send the action that matches the path and nothing else. +// Confirmed with the product team, 2026-08-13. +// - The field is "resType" here but "resourceType" on the resize endpoints — +// see ResizeBody. +// - resourceType is NOT always "dbaas": pass the constant for the resource being +// acted on. +// +// config carries the per-resource options the delete endpoints accept; pass nil +// when there are none. +func ActionBody(resourceType, resourceID, action string, config map[string]interface{}) map[string]interface{} { + return map[string]interface{}{ + "databaseInstances": []interface{}{instanceDetail(resourceID, config)}, + "action": action, + "resType": resourceType, + } +} + +// ResizeBody builds the request for the resize endpoints (instance resize-instance +// / resize-storage, backup-storage resize) and for backup restore, whose action is +// "restore_backup". +// +// Same shape as ActionBody except the resource-type field is spelled +// **resourceType**, not resType. Sending the wrong one is a 400 that reads like a +// validation error on something else. +func ResizeBody(resourceType, resourceID, action string, config map[string]interface{}) map[string]interface{} { + return map[string]interface{}{ + "databaseInstances": []interface{}{instanceDetail(resourceID, config)}, + "action": action, + "resourceType": resourceType, + } +} + +// ResizeConfigBody is ResizeBody for the one endpoint whose detail carries ONLY a +// config: backup restore. RestoreBackupDetail has no instancesId at all — the +// backup being restored is named inside the config, and the config otherwise +// describes the new instance to build. +func ResizeConfigBody(resourceType, action string, config map[string]interface{}) map[string]interface{} { + return map[string]interface{}{ + "databaseInstances": []interface{}{map[string]interface{}{"config": config}}, + "action": action, + "resourceType": resourceType, + } +} + +func instanceDetail(resourceID string, config map[string]interface{}) map[string]interface{} { + detail := map[string]interface{}{"instancesId": resourceID} + if config != nil { + detail["config"] = config + } + return detail +} diff --git a/go/internal/vdbclient/action_test.go b/go/internal/vdbclient/action_test.go new file mode 100644 index 0000000..2ebbcab --- /dev/null +++ b/go/internal/vdbclient/action_test.go @@ -0,0 +1,125 @@ +package vdbclient + +import ( + "bytes" + "strings" + "testing" +) + +func TestActionBody(t *testing.T) { + // The endpoints want the instance ID in the body as well as the path, inside a + // list, under "instancesId". + body := ActionBody(ResourceTypeInstance, "db-1", "reboot", nil) + if body["action"] != "reboot" || body["resType"] != ResourceTypeInstance { + t.Errorf("action body = %v", body) + } + instance := body["databaseInstances"].([]interface{})[0].(map[string]interface{}) + if instance["instancesId"] != "db-1" { + t.Errorf("instancesId = %v", instance["instancesId"]) + } + if _, present := instance["config"]; present { + t.Error("a nil config must not appear in the body") + } + + // Each endpoint accepts only the action matching its path — the shared request + // class documents all of them, which is a spec artifact, not permission. + for _, action := range []string{"start", "stop", "reboot", "detach_replica", "delete"} { + if got := ActionBody(ResourceTypeInstance, "db-1", action, nil)["action"]; got != action { + t.Errorf("action = %v, want %q", got, action) + } + } + + withConfig := ActionBody(ResourceTypeInstance, "db-1", "delete", map[string]interface{}{"createFinalBackup": true}) + instance = withConfig["databaseInstances"].([]interface{})[0].(map[string]interface{}) + config, ok := instance["config"].(map[string]interface{}) + if !ok || config["createFinalBackup"] != true { + t.Errorf("delete config = %v", instance["config"]) + } +} + +// TestResizeBodyUsesResourceType pins the trap: the action endpoints spell the +// field "resType", the resize endpoints spell it "resourceType". +func TestResizeBodyUsesResourceType(t *testing.T) { + body := ResizeBody(ResourceTypeInstance, "db-1", "resize", map[string]interface{}{"packageId": "211"}) + + if body["resourceType"] != ResourceTypeInstance { + t.Errorf("resourceType = %v, want %q", body["resourceType"], ResourceTypeInstance) + } + if _, present := body["resType"]; present { + t.Error("resize must not send resType; that field belongs to the action endpoints") + } + if body["action"] != "resize" { + t.Errorf("action = %v, want resize", body["action"]) + } + instance := body["databaseInstances"].([]interface{})[0].(map[string]interface{}) + if instance["config"].(map[string]interface{})["packageId"] != "211" { + t.Errorf("config = %v", instance["config"]) + } +} + +func TestPreviewMasksSecrets(t *testing.T) { + // --dry-run output gets pasted into tickets; master passwords must not be in it. + var out bytes.Buffer + previewBodyTo(&out, "create", "instance \"db\"", map[string]interface{}{ + "name": "db", + "user": map[string]interface{}{"name": "admin", "password": "s3cret"}, + "replicas": []interface{}{map[string]interface{}{"redisPassword": "s3cret"}}, + }) + + text := out.String() + if strings.Contains(text, "s3cret") { + t.Errorf("preview leaked a secret:\n%s", text) + } + for _, want := range []string{"=== DRY RUN ===", "Would create instance", `"admin"`, `"***"`} { + if !strings.Contains(text, want) { + t.Errorf("preview missing %q:\n%s", want, text) + } + } +} + +func TestMaskSecretsDoesNotMutateTheRealBody(t *testing.T) { + user := map[string]interface{}{"password": "keep-me"} + body := map[string]interface{}{"user": user} + + maskSecrets(body) + + if user["password"] != "keep-me" { + t.Error("maskSecrets edited the body that is about to be sent") + } +} + +// TestResourceTypesAreNotAllDbaas: each kind of resource has its own value, and the +// wrong one is a validation error that does not name the field. +func TestResourceTypesAreNotAllDbaas(t *testing.T) { + if ResourceTypeInstance != "dbaas" || ResourceTypeBackup != "dbaas-backup" || + ResourceTypeBackupStorage != "dbaas-backup-storage" { + t.Errorf("resource types drifted: %q %q %q", + ResourceTypeInstance, ResourceTypeBackup, ResourceTypeBackupStorage) + } + + if got := ActionBody(ResourceTypeBackupStorage, "db-bk-storage-1", "delete", nil)["resType"]; got != ResourceTypeBackupStorage { + t.Errorf("backup-storage delete resType = %v", got) + } + if got := ResizeBody(ResourceTypeBackupStorage, "db-bk-storage-1", "resize", nil)["resourceType"]; got != ResourceTypeBackupStorage { + t.Errorf("backup-storage resize resourceType = %v", got) + } +} + +// TestResizeConfigBodyCarriesNoResourceID: restore is the one endpoint whose detail +// has no instancesId — the backup is named inside the config instead. +func TestResizeConfigBodyCarriesNoResourceID(t *testing.T) { + body := ResizeConfigBody(ResourceTypeBackup, "restore_backup", map[string]interface{}{ + "backupId": "bk-1", "name": "restored", + }) + + if body["action"] != "restore_backup" || body["resourceType"] != ResourceTypeBackup { + t.Errorf("body = %v", body) + } + detail := body["databaseInstances"].([]interface{})[0].(map[string]interface{}) + if _, present := detail["instancesId"]; present { + t.Error("restore must not send instancesId; the detail carries only config") + } + if detail["config"].(map[string]interface{})["backupId"] != "bk-1" { + t.Errorf("config = %v", detail["config"]) + } +} diff --git a/go/internal/vdbclient/client.go b/go/internal/vdbclient/client.go new file mode 100644 index 0000000..51bd23e --- /dev/null +++ b/go/internal/vdbclient/client.go @@ -0,0 +1,132 @@ +// Package vdbclient holds the plumbing shared by every `grn vdb` command group: +// client construction, the vDB response-envelope unwrap, list-query building and +// output helpers. +// +// vDB differs from both vks and vserver in ways that are easy to get wrong — see +// go/cmd/vdb/CLAUDE.md for the full list. The two that shape this package: +// +// - No project ID anywhere in the URL (unlike vserver), so there is no +// ProjectID helper here on purpose. +// - Every response is wrapped in {code, message, data}; list responses nest a +// second data inside that. Unwrap handles both. +package vdbclient + +import ( + "github.com/greennodehub/greennode-cli/internal/cli" + "github.com/greennodehub/greennode-cli/internal/client" + "github.com/spf13/cobra" +) + +// UserTypeHeader is the vDB header selecting the billing flow on endpoints that +// create or resize chargeable resources: ROOT_USER (Checkout) or IAM_USER +// (Auto Payment). The API defaults to ROOT_USER when it is absent. +const UserTypeHeader = "user-type" + +// UserTypeValues are the accepted --user-type values, used for shell completion. +var UserTypeValues = []string{"ROOT_USER", "IAM_USER"} + +// Client is the vdb HTTP client: the shared GreennodeClient with vDB's error +// payload unpacked into every error message (see error.go). It exposes the same +// method set, so commands call it exactly like the shared client. +// +// The wrapper exists so the improvement applies to all vdb requests at one place +// rather than at each of the ~30 call sites, and so the shared client's error +// formatting — which vks and vserver depend on — stays untouched. +type Client struct { + *client.GreennodeClient +} + +// Every method passes the request body to enrich, which uses it to keep any secret the +// body carried out of the error message — the API echoes a rejected password back in +// plain text (verified live on 2026-08-13: "Redis password [hunter2...] contains invalid +// character"), and that message goes to the terminal and into whatever captures it. +func (c *Client) Get(path string, params map[string]string) (interface{}, error) { + result, err := c.GreennodeClient.Get(path, params) + return result, enrich(err, nil) +} + +func (c *Client) Post(path string, body interface{}) (interface{}, error) { + result, err := c.GreennodeClient.Post(path, body) + return result, enrich(err, body) +} + +func (c *Client) Put(path string, body interface{}) (interface{}, error) { + result, err := c.GreennodeClient.Put(path, body) + return result, enrich(err, body) +} + +func (c *Client) Patch(path string, body interface{}) (interface{}, error) { + result, err := c.GreennodeClient.Patch(path, body) + return result, enrich(err, body) +} + +func (c *Client) Delete(path string, params map[string]string) (interface{}, error) { + result, err := c.GreennodeClient.Delete(path, params) + return result, enrich(err, nil) +} + +// DeleteWithBody is needed because vdb has DELETE endpoints that take a JSON array +// body (relational backup and configuration deletes). +func (c *Client) DeleteWithBody(path string, body interface{}) (interface{}, error) { + result, err := c.GreennodeClient.DeleteWithBody(path, body) + return result, enrich(err, body) +} + +// Request is Post/Put with query parameters. The Kafka API passes the arguments of +// several mutating operations in the query string of a PUT that has no body at all +// (?count=&rebalance=, ?size=, ?storageType=, ?enable=), which Put cannot express. +func (c *Client) Request(method, path string, params map[string]string, body interface{}) (interface{}, error) { + result, err := c.GreennodeClient.Request(method, path, params, body) + return result, enrich(err, body) +} + +// Raw is Request without the JSON parse, returning the response body as a string. +// It exists for `kafka user get-creds`, whose success response is a ZIP archive of +// the mTLS credential material — not a JSON document (measured live 2026-10-19; +// before 2026-08-17 the same endpoint answered a bare JSON array of strings, so a +// caller must be ready for either). Errors are enriched exactly as elsewhere. +func (c *Client) Raw(method, path string, params map[string]string) (string, error) { + body, err := c.GreennodeClient.RequestRaw(method, path, params, nil) + return body, enrich(err, nil) +} + +// NoContent sends a request and DISCARDS the response body, reporting only whether +// it succeeded. +// +// Eleven Kafka operations — every mutating one whose result is not a new object — +// declare their 200 response as a bare `string` in the spec, with no schema for +// what that string contains. The product decision is that those carry no +// information: the HTTP status is the whole result. +// +// Live behaviour, verified 2026-08-17 on topic update (200) and topic delete (204): +// the body is EMPTY. Printing that through the normal path would render "{}" where +// every other command prints a resource, which reads like a result and is not one. +// And because the spec promises a string rather than an object, a future +// non-JSON body would make the JSON parse fail on a call that succeeded — so +// skipping the parse is what keeps the outcome tied to the status. +// +// A command using this prints its own confirmation and points at the read command +// that shows the effect. Errors are enriched exactly as elsewhere. +func (c *Client) NoContent(method, path string, params map[string]string, body interface{}) error { + _, err := c.GreennodeClient.RequestRaw(method, path, params, body) + return enrich(err, body) +} + +// BuildClient creates a client for the vdb service. It resolves the endpoint via +// the "vdb_endpoint" REGIONS key and attaches the user-type header from +// --user-type when set. Unlike vserverclient.BuildClient it returns no config — +// vdb needs no project ID. +func BuildClient(cmd *cobra.Command) (*Client, error) { + apiClient, err := cli.NewClient(cmd, "vdb") + if err != nil { + return nil, err + } + + // Empty means "not set": applyHeaders skips empty values, so the API applies + // its own ROOT_USER default rather than us hard-coding it. + if userType, err := cmd.Flags().GetString("user-type"); err == nil && userType != "" { + apiClient.SetHeaders(map[string]string{UserTypeHeader: userType}) + } + + return &Client{GreennodeClient: apiClient}, nil +} diff --git a/go/internal/vdbclient/credential.go b/go/internal/vdbclient/credential.go new file mode 100644 index 0000000..b547a54 --- /dev/null +++ b/go/internal/vdbclient/credential.go @@ -0,0 +1,70 @@ +package vdbclient + +import ( + "fmt" + "strings" +) + +// Master-password rules for vDB, checked before the request goes out so a rejection +// names the user's own flag instead of arriving as a 400 after the secret has crossed +// the network. +// +// Both rules are product rules, stated by the API owner on 2026-08-13: +// +// - Characters: letters, digits and $ ^ _ < > only, in both products. A live probe on +// the MemoryStore API agrees — each of $ ^ _ < > was accepted on its own, while +// - . @ # % * + = ! came back as "Redis password contains invalid character". +// - One undocumented extra, found by that probe: MemoryStore also rejects a password +// containing BOTH < and >, in either order, however far apart — a tag-like sequence +// its sanitiser refuses. Either bracket alone is fine. Enforced for MemoryStore only, +// since only that API was probed for it. +// - Length: 8 to 32 for Relational Database, 16 to 128 for MemoryStore. The +// MemoryStore bound was also confirmed live at both edges (15 and 129 rejected, 16 +// and 128 accepted), and its API states it as "The field Password must be at least +// 16 and max 128 (range_value)". +// +// The two ranges overlap only between 16 and 32, which is worth knowing when reusing one +// $GRN_VDB_MASTER_PASSWORD across products. +// +// PostgreSQL Cluster is covered by neither: it is a separate API surface that has +// already proven to validate differently from relational (it accepts an empty +// configGroupId, which relational rejects), so its password rules stay unverified and +// are left to the API, whose message the error layer surfaces in full. +const ( + minDBPasswordLen = 8 + maxDBPasswordLen = 32 + minRedisPasswordLen = 16 + maxRedisPasswordLen = 128 + passwordExtraChars = "$^_<>" +) + +// ValidateDBPassword checks a Relational Database master password. +func ValidateDBPassword(password, flagName string) error { + return validatePassword(password, flagName, minDBPasswordLen, maxDBPasswordLen, false) +} + +// ValidateRedisPassword checks a MemoryStore master password, whose length bound differs +// from the relational one. +func ValidateRedisPassword(password, flagName string) error { + return validatePassword(password, flagName, minRedisPasswordLen, maxRedisPasswordLen, true) +} + +// rejectBracketPair carries the MemoryStore-only rule; see the note above. +func validatePassword(password, flagName string, min, max int, rejectBracketPair bool) error { + if length := len([]rune(password)); length < min || length > max { + return fmt.Errorf("--%s must be between %d and %d characters, got %d", flagName, min, max, length) + } + if rejectBracketPair && strings.ContainsRune(password, '<') && strings.ContainsRune(password, '>') { + return fmt.Errorf("--%s cannot contain both < and >: the API rejects the pair, though either alone is allowed", flagName) + } + for _, r := range password { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + case strings.ContainsRune(passwordExtraChars, r): + default: + return fmt.Errorf("--%s contains %q: only letters, digits and %s are allowed", + flagName, r, passwordExtraChars) + } + } + return nil +} diff --git a/go/internal/vdbclient/credential_test.go b/go/internal/vdbclient/credential_test.go new file mode 100644 index 0000000..f326529 --- /dev/null +++ b/go/internal/vdbclient/credential_test.go @@ -0,0 +1,74 @@ +package vdbclient + +import "testing" + +// TestPasswordRulesDifferPerProduct pins the two length ranges apart. They overlap only +// between 16 and 32, so a password that suits one product can be invalid for the other — +// which is the trap when $GRN_VDB_MASTER_PASSWORD is reused. +func TestPasswordRulesDifferPerProduct(t *testing.T) { + const ( + eight = "Abcd1234" // valid for relational, too short for Redis + twenty = "Abcd1234Abcd1234Abcd" // valid for both + fortyFour = "Abcd1234Abcd1234Abcd1234Abcd1234Abcd1234Abcd" // too long for relational, fine for Redis + huge = "Abcd1234Abcd1234Abcd1234Abcd1234Abcd1234Abcd1234Abcd1234Abcd1234" + // + "Abcd1234Abcd1234Abcd1234Abcd1234Abcd1234Abcd1234Abcd1234Abcd1234x" // 129 + ) + + for name, tc := range map[string]struct { + password string + db, rd bool // want accepted by relational / by Redis + }{ + "7 characters": {"Abcd123", false, false}, + "8 characters": {eight, true, false}, + "20 characters": {twenty, true, true}, + "44 characters": {fortyFour, false, true}, + "129 characters": {huge, false, false}, + } { + if err := ValidateDBPassword(tc.password, "password"); (err == nil) != tc.db { + t.Errorf("%s: ValidateDBPassword = %v, want accepted=%t", name, err, tc.db) + } + if err := ValidateRedisPassword(tc.password, "redis-password"); (err == nil) != tc.rd { + t.Errorf("%s: ValidateRedisPassword = %v, want accepted=%t", name, err, tc.rd) + } + } +} + +// TestPasswordCharacterSet: letters, digits and $ ^ _ < > are allowed and everything else +// is not. The rejected list is what a live probe actually saw refused. +func TestPasswordCharacterSet(t *testing.T) { + const base = "Abcd1234Abcd1234" // 16, valid for both products + + for _, allowed := range []string{"$", "^", "_", "<", ">"} { + if err := ValidateRedisPassword(base+allowed, "redis-password"); err != nil { + t.Errorf("%q rejected but allowed by the product rule: %v", allowed, err) + } + } + + for _, rejected := range []string{"-", ".", "@", "#", "%", "*", "+", "=", "!", " ", "'", "\\", "ă"} { + err := ValidateRedisPassword(base+rejected, "redis-password") + if err == nil { + t.Errorf("%q accepted, want a rejection", rejected) + continue + } + // The message must name the flag and the offending character, so the user can see + // which of their inputs to fix without echoing the whole secret. + for _, want := range []string{"--redis-password", rejected} { + if !contains(err.Error(), want) { + t.Errorf("error for %q = %q, must mention %q", rejected, err, want) + } + } + } +} + +func contains(haystack, needle string) bool { + return len(needle) == 0 || indexOf(haystack, needle) >= 0 +} + +func indexOf(haystack, needle string) int { + for i := 0; i+len(needle) <= len(haystack); i++ { + if haystack[i:i+len(needle)] == needle { + return i + } + } + return -1 +} diff --git a/go/internal/vdbclient/error.go b/go/internal/vdbclient/error.go new file mode 100644 index 0000000..be4de66 --- /dev/null +++ b/go/internal/vdbclient/error.go @@ -0,0 +1,133 @@ +package vdbclient + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/greennodehub/greennode-cli/internal/client" +) + +// Error is an API error with the vDB error payload unpacked into the message. +// +// Why this exists: on any status >= 400 the shared client builds its message from +// the first recognised field of the body, preferring "message". vDB always sets +// that field — but to a machine code, not a sentence: +// +// {"code":400, +// "message":"in_valid", +// "errors":[{"typeError":"pageSize","fieldError":"invalid", +// "message":"The field pageSize of the request must be greater than 0"}]} +// +// So the user saw "API error (HTTP 400 Bad Request): in_valid" and the sentence +// that actually says what to fix was only visible under --debug. This type merges +// the two, and is applied to every vdb request by Client. +// +// It embeds the shared *client.APIError and unwraps to it, so errors.As still +// reaches StatusCode/Body for callers that branch on them (waiters, retry logic). +type Error struct { + *client.APIError + message string +} + +func (e *Error) Error() string { return e.message } + +// Unwrap exposes the underlying APIError to errors.As / errors.Is. +func (e *Error) Unwrap() error { return e.APIError } + +// vdbErrorBody is the subset of the vDB error payload worth showing. Fields not +// listed here (action, request) come back null in practice. +type vdbErrorBody struct { + Message string `json:"message"` + Errors []struct { + Message string `json:"message"` + TypeError string `json:"typeError"` + FieldError string `json:"fieldError"` + } `json:"errors"` +} + +// enrich upgrades an API error's message with the vDB error details, and removes from it +// any secret the request carried. Anything that is not an *client.APIError, or whose body +// does not carry usable details, is returned untouched unless it needs redacting — +// enrich never hides an error it cannot improve. +// +// requestBody is the body that was sent, and is read only to learn which strings are +// secret; nil for requests that have none. +func enrich(err error, requestBody interface{}) error { + if err == nil { + return nil + } + apiErr, ok := err.(*client.APIError) + if !ok { + return err + } + + secrets := secretValues(requestBody) + + var body vdbErrorBody + if jsonErr := json.Unmarshal([]byte(apiErr.Body), &body); jsonErr != nil { + return redacted(apiErr, apiErr.Error(), secrets) + } + + details := make([]string, 0, len(body.Errors)) + for _, detail := range body.Errors { + text := strings.TrimSpace(detail.Message) + if text == "" { + continue + } + // Name the offending field when the API identifies one: "pageSize" is what + // the user has to change, and typeError is what support will ask for. + if detail.TypeError != "" { + text = fmt.Sprintf("%s (%s)", text, detail.TypeError) + } + details = append(details, text) + } + if len(details) == 0 { + return redacted(apiErr, apiErr.Error(), secrets) + } + + // Keep the original message — it is the machine code the API team greps for — + // and append the human-readable part after it. + return redacted(apiErr, fmt.Sprintf("%s: %s", apiErr.Error(), strings.Join(details, "; ")), secrets) +} + +// redacted returns the error carrying message with every secret masked. When nothing +// changes and the message is the API error's own, the original error is returned so +// callers keep the exact value they would have had. +func redacted(apiErr *client.APIError, message string, secrets []string) error { + for _, secret := range secrets { + message = strings.ReplaceAll(message, secret, "***") + } + if message == apiErr.Error() { + return apiErr + } + return &Error{APIError: apiErr, message: message} +} + +// secretValues collects the values a request body carried under a sensitive key, so they +// can be masked wherever the API repeats them. Short values are skipped: masking a +// two-character password would blank out unrelated text in the message. +func secretValues(v interface{}) []string { + var out []string + collectSecrets(v, &out) + return out +} + +func collectSecrets(v interface{}, out *[]string) { + switch value := v.(type) { + case map[string]interface{}: + for key, item := range value { + if text, isText := item.(string); isText && sensitiveKeys[key] { + if len(text) >= 4 { + *out = append(*out, text) + } + continue + } + collectSecrets(item, out) + } + case []interface{}: + for _, item := range value { + collectSecrets(item, out) + } + } +} diff --git a/go/internal/vdbclient/error_test.go b/go/internal/vdbclient/error_test.go new file mode 100644 index 0000000..ad4caa8 --- /dev/null +++ b/go/internal/vdbclient/error_test.go @@ -0,0 +1,165 @@ +package vdbclient + +import ( + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/greennodehub/greennode-cli/internal/auth" + "github.com/greennodehub/greennode-cli/internal/client" +) + +// testClient builds a vdb client pointed at a test server, with a token already in +// hand so no IAM round trip happens. +func testClient(endpoint string) *Client { + tm := auth.NewMachineTokenProvider("id", "secret", "") + tm.SetToken("test-token", time.Now().Add(1*time.Hour)) + return &Client{GreennodeClient: client.NewGreennodeClient(endpoint, tm, 5*time.Second, 5*time.Second, false, false)} +} + +// TestErrorSurfacesTheUsefulMessage is the regression test for a real complaint: +// vdb answers a bad request with a machine code in "message" and the sentence that +// explains the problem in "errors[].message". The shared client stops at the first +// recognised field, so the user used to see only "in_valid" and had to re-run with +// --debug to learn what was wrong. +func TestErrorSurfacesTheUsefulMessage(t *testing.T) { + body := `{"code":400,"message":"in_valid","errors":[{"action":null,` + + `"fieldError":"invalid","message":"The field pageSize of the request must be greater than 0",` + + `"request":null,"typeError":"pageSize"}]}` + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(body)) + })) + defer srv.Close() + + _, err := testClient(srv.URL).Get("/vdb-relational/v1/database-instances", nil) + if err == nil { + t.Fatal("expected an error for HTTP 400") + } + + message := err.Error() + for _, want := range []string{ + "400", // the status + "in_valid", // the machine code, kept for support + "must be greater than 0", // the part the user needs + "pageSize", // the offending field + } { + if !strings.Contains(message, want) { + t.Errorf("error %q does not mention %q", message, want) + } + } + + // The shared APIError must stay reachable so callers can branch on the status. + var apiErr *client.APIError + if !errors.As(err, &apiErr) { + t.Fatal("errors.As could not reach *client.APIError") + } + if apiErr.StatusCode != http.StatusBadRequest { + t.Errorf("StatusCode = %d, want 400", apiErr.StatusCode) + } + if apiErr.Body != body { + t.Error("APIError.Body no longer carries the raw response") + } +} + +func TestEnrichLeavesOtherErrorsAlone(t *testing.T) { + // Not an API error at all: pass through untouched. + plain := fmt.Errorf("dial tcp: connection refused") + if got := enrich(plain, nil); got != plain { + t.Errorf("enrich rewrote a non-API error: %v", got) + } + if enrich(nil, nil) != nil { + t.Error("enrich(nil) should stay nil") + } + + // An API error whose body has nothing to add must not be replaced, so its + // message keeps whatever the shared formatter produced. These use 400, not 500: + // the shared client retries 5xx three times with backoff, which would make this + // test take half a minute. + for _, body := range []string{ + `{"code":400,"message":"internal"}`, // no errors array + `{"code":400,"errors":[]}`, // empty errors array + `{"code":400,"errors":[{"message":""}]}`, // blank detail + `400 Bad Request`, // not JSON at all + } { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(body)) + })) + + _, err := testClient(srv.URL).Get("/x", nil) + srv.Close() + + if err == nil { + t.Fatalf("body %q: expected an error", body) + } + var enriched *Error + if errors.As(err, &enriched) { + t.Errorf("body %q was wrapped as enriched although it carries no details: %v", body, err) + } + } +} + +// TestErrorJoinsSeveralDetails: a validation failure can name more than one field. +func TestErrorJoinsSeveralDetails(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"code":400,"message":"in_valid","errors":[` + + `{"message":"first problem","typeError":"fieldA"},` + + `{"message":"second problem","typeError":"fieldB"}]}`)) + })) + defer srv.Close() + + _, err := testClient(srv.URL).Get("/x", nil) + if err == nil { + t.Fatal("expected an error") + } + if !strings.Contains(err.Error(), "first problem") || !strings.Contains(err.Error(), "second problem") { + t.Errorf("error %q dropped one of the details", err) + } +} + +// TestErrorRedactsSecretsFromTheMessage guards a live leak: on a rejected password the +// API repeats it verbatim in the error detail ("Redis password [...] contains invalid +// character"), which would otherwise be printed to the terminal. +func TestErrorRedactsSecretsFromTheMessage(t *testing.T) { + const secret = "SuperSecret2026abcd" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"code":400,"message":"in_valid","errors":[{"typeError":"invalid",` + + `"message":"Redis password [` + secret + `] contains invalid character"}]}`)) + })) + defer srv.Close() + + // Nested exactly as a restore body nests it, to prove the walk reaches inside. + body := map[string]interface{}{ + "databaseInstances": []interface{}{ + map[string]interface{}{"config": map[string]interface{}{"redisPassword": secret}}, + }, + } + + _, err := testClient(srv.URL).Put("/x", body) + if err == nil { + t.Fatal("expected an error") + } + if strings.Contains(err.Error(), secret) { + t.Errorf("the password reached the error message: %q", err) + } + // The rest of the sentence must survive, or the user cannot tell what was wrong. + for _, want := range []string{"contains invalid character", "***"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error = %q, must contain %q", err, want) + } + } + + // A request with no secret is unaffected, and stays the same error value. + if _, err := testClient(srv.URL).Get("/x", nil); !strings.Contains(err.Error(), secret) { + t.Errorf("a message with no secret in the request must be left intact: %q", err) + } +} diff --git a/go/internal/vdbclient/get_test.go b/go/internal/vdbclient/get_test.go new file mode 100644 index 0000000..1215358 --- /dev/null +++ b/go/internal/vdbclient/get_test.go @@ -0,0 +1,55 @@ +package vdbclient + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +func TestGetSendsRepeatedQueryKeys(t *testing.T) { + // vdb filters by repeating the status key; the client's map-based Get holds + // one value per key and cannot express that. + var gotQuery url.Values + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.Query() + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"code":200,"message":"ok","data":[]}`)) + })) + defer srv.Close() + + c := testClient(srv.URL) + + q := BuildListQuery(ListOptions{Page: 2, PageSize: 10, Name: "db", Statuses: []string{"ACTIVE", "ERROR"}}) + if _, err := Get(c, "/vdb-relational/v1/database-instances", q); err != nil { + t.Fatalf("Get returned error: %v", err) + } + + if got := gotQuery["status"]; len(got) != 2 || got[0] != "ACTIVE" || got[1] != "ERROR" { + t.Errorf("status = %v, want [ACTIVE ERROR]", got) + } + if got := gotQuery.Get("pageNumber"); got != "2" { + t.Errorf("pageNumber = %q, want 2", got) + } + if got := gotQuery.Get("pageSize"); got != "10" { + t.Errorf("pageSize = %q, want 10", got) + } + if got := gotQuery.Get("name"); got != "db" { + t.Errorf("name = %q, want db", got) + } +} + +func TestAppendQuery(t *testing.T) { + v := url.Values{} + v.Set("b", "2") + + if got := appendQuery("/p?a=1", v); got != "/p?a=1&b=2" { + t.Errorf("with existing query = %q, want /p?a=1&b=2", got) + } + if got := appendQuery("/p", v); got != "/p?b=2" { + t.Errorf("without query = %q, want /p?b=2", got) + } + if got := appendQuery("/p", url.Values{}); got != "/p" { + t.Errorf("with no values = %q, want /p", got) + } +} diff --git a/go/internal/vdbclient/id.go b/go/internal/vdbclient/id.go new file mode 100644 index 0000000..6adfcfd --- /dev/null +++ b/go/internal/vdbclient/id.go @@ -0,0 +1,34 @@ +package vdbclient + +import ( + "fmt" + "strings" + + "github.com/greennodehub/greennode-cli/internal/validator" +) + +// RequireIDWithPrefix validates an ID and additionally enforces the product +// prefix vDB puts on it ("db-" for a Relational Database instance, "pg-" for a +// PostgreSQL Cluster). +// +// The prefix check is a safety measure, not cosmetics: several relational +// endpoints — get-by-id, histories, secrules, reboot, delete — serve BOTH +// products, so a command group that only means to touch one of them must reject +// the other's IDs itself. Without that, `grn vdb postgresql cluster delete +// --cluster-id db-...` would delete a Relational Database instance. +// +// hint is appended to the error to point at the command group that does handle +// the other prefix; pass "" to omit it. +func RequireIDWithPrefix(value, flagName, prefix, hint string) error { + if err := validator.ValidateID(value, flagName); err != nil { + return err + } + if !strings.HasPrefix(value, prefix) { + msg := fmt.Sprintf("invalid %s: '%s' does not start with '%s'", flagName, value, prefix) + if hint != "" { + msg += ". " + hint + } + return fmt.Errorf("%s", msg) + } + return nil +} diff --git a/go/internal/vdbclient/id_test.go b/go/internal/vdbclient/id_test.go new file mode 100644 index 0000000..7e435e8 --- /dev/null +++ b/go/internal/vdbclient/id_test.go @@ -0,0 +1,43 @@ +package vdbclient + +import ( + "strings" + "testing" +) + +func TestRequireIDWithPrefix(t *testing.T) { + const hint = "use 'grn vdb relational instance' instead" + + if err := RequireIDWithPrefix("pg-2e6f2253-9032-466f", "cluster-id", "pg-", hint); err != nil { + t.Errorf("valid ID rejected: %v", err) + } + + // The wrong product's ID is the case this exists for: several relational + // endpoints serve both, so passing a db- ID to a cluster command would act on + // a Relational Database instance. + err := RequireIDWithPrefix("db-66a37ca3-e688", "cluster-id", "pg-", hint) + if err == nil { + t.Fatal("db- ID accepted for a pg- flag") + } + if !strings.Contains(err.Error(), hint) { + t.Errorf("error %q does not carry the hint", err) + } + if !strings.Contains(err.Error(), "cluster-id") { + t.Errorf("error %q does not name the flag", err) + } + + // Invalid characters are still rejected by the underlying validator, before + // any prefix check. + if err := RequireIDWithPrefix("pg-1; rm -rf /", "cluster-id", "pg-", hint); err == nil { + t.Error("ID with unsafe characters accepted") + } + if err := RequireIDWithPrefix("", "cluster-id", "pg-", hint); err == nil { + t.Error("empty ID accepted") + } + + // Without a hint the message must still be complete. + if err := RequireIDWithPrefix("db-1", "cluster-id", "pg-", ""); err == nil || + !strings.Contains(err.Error(), "pg-") { + t.Errorf("error without a hint = %v", err) + } +} diff --git a/go/internal/vdbclient/output.go b/go/internal/vdbclient/output.go new file mode 100644 index 0000000..aec016f --- /dev/null +++ b/go/internal/vdbclient/output.go @@ -0,0 +1,218 @@ +package vdbclient + +import ( + "os" + "strconv" + + "github.com/greennodehub/greennode-cli/internal/config" + "github.com/greennodehub/greennode-cli/internal/formatter" + "github.com/spf13/cobra" +) + +// Unwrap strips the vDB response envelope. +// +// Every wrapped endpoint uses exactly one envelope — all 42 Wrap* schemas in the +// API spec have the properties {code, message, data} and nothing else: +// +// {"code":200,"message":"...","data": } +// +// Unwrap removes only that outer layer, so every field of keeps its +// API name. It deliberately makes no assumption about the payload, because the +// payload shape varies per endpoint (checked against all 128 wrapped operations): +// +// - 76 endpoints: data is a bare array +// - paginated lists: an object holding the items plus pageObject, but under +// TWO different key names — "data" for database-instances, "content" for +// backups, configurations and histories +// - detail endpoints: the resource object itself +// - a few: a plain scalar +// +// The envelope is detected by shape (code AND message AND data present) rather +// than by the "data" key alone, so a payload that merely has a data field of its +// own is not stripped. No non-Wrap schema in the spec carries both code and +// message, so this cannot misfire on a real response. +// +// Kafka is the exception: only 9 of its 36 operations are wrapped, the rest +// return bare objects, bare arrays or plain strings. Unwrap passes those through +// untouched, so it is safe to call — but a kafka command must not ASSUME its +// response was wrapped. +func Unwrap(result interface{}) interface{} { + obj, ok := result.(map[string]interface{}) + if !ok { + return result + } + // Identify the envelope by its shape rather than by code alone: a payload + // that happens to carry a "data" key but no "code"/"message" is not one. + if _, hasCode := obj["code"]; !hasCode { + return result + } + if _, hasMessage := obj["message"]; !hasMessage { + return result + } + data, hasData := obj["data"] + if !hasData || data == nil { + return result + } + return data +} + +// Payload unwraps the envelope and reports whether it actually carried anything. +// +// It exists because **vDB answers "not found" with HTTP 200 and a null payload**: +// asking for a backup that failed to be created returns +// `{"code":200,"message":"success","data":null}`, not a 404 (verified live, +// 2026-08-13). Unwrap alone cannot express that — with a nil data it hands back the +// envelope, so a caller doing Unwrap(...).(map[string]interface{}) gets the envelope +// and happily reads empty fields off it. +// +// Any command that fetches ONE resource and then reads fields from it must go +// through this and report a miss, rather than acting on a phantom. +func Payload(result interface{}) (interface{}, bool) { + obj, ok := result.(map[string]interface{}) + if !ok { + // Not an envelope at all (bare array, scalar): whatever it is, it is the + // payload. + return result, result != nil + } + if _, hasCode := obj["code"]; !hasCode { + return result, true + } + if _, hasMessage := obj["message"]; !hasMessage { + return result, true + } + data, hasData := obj["data"] + if !hasData || data == nil { + return nil, false + } + return data, true +} + +// PayloadObject is Payload for the common case of a single resource object. +func PayloadObject(result interface{}) (map[string]interface{}, bool) { + payload, ok := Payload(result) + if !ok { + return nil, false + } + object, ok := payload.(map[string]interface{}) + return object, ok +} + +// Output unwraps the envelope and prints using the shared --output/--query flags. +func Output(cmd *cobra.Command, data interface{}) error { + return write(cmd, Unwrap(data), nil) +} + +// OutputWithColumns is Output with a fixed column set for table output. vDB list +// items are very wide (a database instance carries ~68 fields, billing metadata +// included), so table output would be unreadable without one. JSON output is +// unaffected and still carries every field. +// +// Use it for LIST responses only. It routes through formatter.extractRows, which +// picks the first slice it finds while ranging over the map — deterministic for a +// list payload (exactly one array: the items) but NOT for a detail payload, where +// a single instance carries four nested arrays (ip, securityGroup, replicas, +// sharedActions) and Go randomizes map iteration order. Detail commands should +// call Output, which renders an object as a key/value table instead. +func OutputWithColumns(cmd *cobra.Command, data interface{}, columns []string) error { + return write(cmd, Unwrap(data), columns) +} + +func write(cmd *cobra.Command, data interface{}, columns []string) error { + output, _ := cmd.Flags().GetString("output") + query, _ := cmd.Flags().GetString("query") + + if output == "" { + profile, _ := cmd.Flags().GetString("profile") + cfg, _ := config.LoadConfig(profile) + if cfg != nil { + output = cfg.Output + } + } + if output == "" { + output = "json" + } + + colorMode, _ := cmd.Flags().GetString("color") + color := formatter.ColorEnabled(colorMode, os.Stdout) + + if output == "table" { + data = plainNumbers(data) + } + if output == "table" && len(columns) > 0 { + return formatter.FormatTableWithColumnsColor(data, columns, query, os.Stdout, color) + } + return formatter.FormatColor(data, output, query, os.Stdout, color) +} + +// plainNumbers returns a copy of data with every float64 rendered as a decimal +// string, for TABLE output only. +// +// Every JSON number decodes to float64, and the table formatter prints cells with +// %v, which switches float64 to exponent form once that is shorter: a backup of +// 5861298 bytes shows up as "5.861298e+06". vDB reports plenty of byte counts and +// IDs in that range. Converting here keeps it to the vdb commands — JSON and text +// output still carry the original numbers, so --query arithmetic and anything +// parsing the output are unaffected. +func plainNumbers(data interface{}) interface{} { + switch value := data.(type) { + case float64: + // 'f' with precision -1 is the shortest form that never uses an exponent: + // 5861298 -> "5861298", 0.06 -> "0.06". + return strconv.FormatFloat(value, 'f', -1, 64) + case map[string]interface{}: + out := make(map[string]interface{}, len(value)) + for k, item := range value { + out[k] = plainNumbers(item) + } + return out + case []interface{}: + out := make([]interface{}, len(value)) + for i, item := range value { + out[i] = plainNumbers(item) + } + return out + default: + return data + } +} + +// ExtractIDValues collects one field from every item of a listing, for shell completion, +// accepting NUMBERS as well as strings. +// +// It exists because cli.ExtractIDs takes only string values, and several vDB ids arrive as +// JSON numbers: a flavor's `id` and a backup package's `packageId` are 179 and 1, not +// "179" and "1". Completion for --package-id was silently empty until this was noticed +// live. Numbers are formatted without an exponent or trailing zeros, so the suggestion is +// exactly what the flag expects. +// +// Duplicates are dropped: one flavor name can appear twice in a zone under different ids, +// and a package listing repeats a package per engine group. +func ExtractIDValues(payload interface{}, field string) []string { + items, ok := payload.([]interface{}) + if !ok { + return nil + } + + var out []string + seen := map[string]bool{} + for _, item := range items { + row, ok := item.(map[string]interface{}) + if !ok { + continue + } + var value string + switch typed := row[field].(type) { + case string: + value = typed + case float64: + value = strconv.FormatFloat(typed, 'f', -1, 64) + default: + continue + } + if value != "" && !seen[value] { + seen[value] = true + out = append(out, value) + } + } + return out +} diff --git a/go/internal/vdbclient/output_test.go b/go/internal/vdbclient/output_test.go new file mode 100644 index 0000000..c768c74 --- /dev/null +++ b/go/internal/vdbclient/output_test.go @@ -0,0 +1,214 @@ +package vdbclient + +import ( + "reflect" + "testing" +) + +// TestPlainNumbers: every JSON number decodes to float64, and the table formatter +// prints cells with %v, which switches to exponent form once that is shorter — +// a 5861298-byte backup rendered as "5.861298e+06". Table output converts first. +func TestPlainNumbers(t *testing.T) { + got := plainNumbers(map[string]interface{}{ + "totalBackupSize": 5861298.0, + "volumeSize": 100.0, + "backupUsage": 0.06, + "name": "keep-me", + "enabled": true, + "missing": nil, + "nested": []interface{}{ + map[string]interface{}{"uncompressedSize": 31677251.0}, + }, + }).(map[string]interface{}) + + for field, want := range map[string]interface{}{ + "totalBackupSize": "5861298", + "volumeSize": "100", + "backupUsage": "0.06", + "name": "keep-me", + "enabled": true, + "missing": nil, + } { + if got[field] != want { + t.Errorf("%s = %#v, want %#v", field, got[field], want) + } + } + + nested := got["nested"].([]interface{})[0].(map[string]interface{}) + if nested["uncompressedSize"] != "31677251" { + t.Errorf("nested number = %#v, want a plain decimal string", nested["uncompressedSize"]) + } +} + +func TestUnwrap(t *testing.T) { + listPayload := map[string]interface{}{ + "projectId": "pro-1", + "data": []interface{}{ + map[string]interface{}{"id": "db-1", "name": "one"}, + }, + "pageObject": map[string]interface{}{"totalPages": 2.0}, + } + + tests := []struct { + name string + in interface{} + want interface{} + }{ + { + // The list envelope nests data inside data; unwrapping the outer + // layer keeps both the items and pageObject. + name: "paginated list envelope", + in: map[string]interface{}{ + "code": 200.0, "message": "Success", "data": listPayload, + }, + want: listPayload, + }, + { + // Backups, configurations and histories key their items "content", + // not "data". Unwrap must not care which — it only strips the outer + // envelope and hands the payload through as-is. + name: "paginated list keyed by content", + in: map[string]interface{}{ + "code": 200.0, "message": "Success", + "data": map[string]interface{}{ + "content": []interface{}{map[string]interface{}{"id": "bkp-1"}}, + "pageObject": map[string]interface{}{"totalPages": 1.0}, + }, + }, + want: map[string]interface{}{ + "content": []interface{}{map[string]interface{}{"id": "bkp-1"}}, + "pageObject": map[string]interface{}{"totalPages": 1.0}, + }, + }, + { + // 76 of the wrapped endpoints put a bare array directly in data. + name: "envelope around a bare array", + in: map[string]interface{}{ + "code": 200.0, "message": "Success", + "data": []interface{}{map[string]interface{}{"id": "sg-1"}}, + }, + want: []interface{}{map[string]interface{}{"id": "sg-1"}}, + }, + { + name: "detail envelope", + in: map[string]interface{}{ + "code": 200.0, "message": "Success", + "data": map[string]interface{}{"id": "db-1"}, + }, + want: map[string]interface{}{"id": "db-1"}, + }, + { + // Kafka returns bare arrays with no envelope — pass through untouched. + name: "bare array is untouched", + in: []interface{}{map[string]interface{}{"id": "kfk-1"}}, + want: []interface{}{map[string]interface{}{"id": "kfk-1"}}, + }, + { + // A payload that merely has a "data" key is not an envelope; without + // code+message we must not strip a real field off the response. + name: "data key without code and message is not an envelope", + in: map[string]interface{}{"data": "keep me", "other": 1.0}, + want: map[string]interface{}{"data": "keep me", "other": 1.0}, + }, + { + name: "envelope with null data is left alone", + in: map[string]interface{}{ + "code": 204.0, "message": "No Content", "data": nil, + }, + want: map[string]interface{}{ + "code": 204.0, "message": "No Content", "data": nil, + }, + }, + { + name: "envelope missing data is left alone", + in: map[string]interface{}{"code": 400.0, "message": "Bad Request"}, + want: map[string]interface{}{"code": 400.0, "message": "Bad Request"}, + }, + { + name: "non-map input is untouched", + in: "plain string", + want: "plain string", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Unwrap(tt.in); !reflect.DeepEqual(got, tt.want) { + t.Errorf("Unwrap() = %#v, want %#v", got, tt.want) + } + }) + } +} + +// TestPayloadDetectsTheNullDataMiss: vDB answers "not found" with HTTP 200 and a +// null payload on some endpoints, so the status code cannot be relied on. Unwrap +// hands back the envelope in that case, which a caller would happily read empty +// fields off — Payload is what makes the miss visible. +func TestPayloadDetectsTheNullDataMiss(t *testing.T) { + // The real response for a backup whose creation failed. + miss := map[string]interface{}{"code": 200.0, "message": "success", "data": nil} + + if _, ok := Payload(miss); ok { + t.Error("Payload reported success for a null data payload") + } + if _, ok := PayloadObject(miss); ok { + t.Error("PayloadObject reported success for a null data payload") + } + // Unwrap on its own cannot tell: it returns the envelope, whose fields read empty. + if unwrapped, ok := Unwrap(miss).(map[string]interface{}); !ok || unwrapped["status"] != nil { + t.Error("assumption changed: Unwrap no longer returns the envelope for null data") + } + + // A real object comes through. + hit := map[string]interface{}{ + "code": 200.0, "message": "ok", + "data": map[string]interface{}{"id": "bk-1", "status": "COMPLETED"}, + } + object, ok := PayloadObject(hit) + if !ok || object["id"] != "bk-1" { + t.Errorf("PayloadObject = %v, %v", object, ok) + } + + // Non-envelope payloads (bare arrays, the kafka style) pass through. + if payload, ok := Payload([]interface{}{"a"}); !ok || len(payload.([]interface{})) != 1 { + t.Errorf("bare array = %v, %v", payload, ok) + } + // An envelope whose data is an empty object is a hit, not a miss: the resource + // exists and simply has no fields set. + empty := map[string]interface{}{"code": 200.0, "message": "ok", "data": map[string]interface{}{}} + if _, ok := PayloadObject(empty); !ok { + t.Error("an empty object payload should count as found") + } +} + +// TestExtractIDValuesTakesNumbers: several vDB ids are JSON numbers — a flavor's id and a +// backup package's packageId — and cli.ExtractIDs skips those, which left --package-id +// completion silently empty until it was tried live. +func TestExtractIDValuesTakesNumbers(t *testing.T) { + payload := []interface{}{ + map[string]interface{}{"id": float64(179)}, + map[string]interface{}{"id": "cfg-1"}, + map[string]interface{}{"id": float64(179)}, // duplicate: one flavor name, two rows + map[string]interface{}{"id": float64(1e6)}, // must not come out as 1e+06 + map[string]interface{}{"id": float64(20.5)}, // fractional values keep their point + map[string]interface{}{"id": nil}, // skipped + map[string]interface{}{"other": "x"}, // field absent + map[string]interface{}{"id": ""}, // empty string is not a suggestion + "not a row", + } + + got := ExtractIDValues(payload, "id") + want := []string{"179", "cfg-1", "1000000", "20.5"} + if len(got) != len(want) { + t.Fatalf("ExtractIDValues = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("ExtractIDValues[%d] = %q, want %q", i, got[i], want[i]) + } + } + + if ExtractIDValues(map[string]interface{}{"id": float64(1)}, "id") != nil { + t.Error("a non-list payload must yield nothing") + } +} diff --git a/go/internal/vdbclient/preview.go b/go/internal/vdbclient/preview.go new file mode 100644 index 0000000..7ffab86 --- /dev/null +++ b/go/internal/vdbclient/preview.go @@ -0,0 +1,67 @@ +package vdbclient + +import ( + "encoding/json" + "fmt" + "io" + "os" + + "github.com/greennodehub/greennode-cli/internal/cli" +) + +// sensitiveKeys never reach the terminal in a preview. vDB carries master +// passwords in create, replica-create and update-setting bodies, and --dry-run +// output is exactly the kind of thing that gets pasted into a ticket. +var sensitiveKeys = map[string]bool{"password": true, "redisPassword": true} + +// PreviewBody prints the request body a command would send, with sensitive values +// masked, followed by the standard dry-run footer. +// +// cli.PrintDryRun covers flat bodies; every mutating vdb body nests (user, +// databases, netIds, databaseInstances[].config), so these are shown as indented +// JSON — which doubles as something the user can replay against the API. +func PreviewBody(verb, target string, body interface{}) { + previewBodyTo(os.Stdout, verb, target, body) + cli.DryRunNotice(verb) +} + +// previewBodyTo writes just the preview (no footer) so tests can capture it. +func previewBodyTo(w io.Writer, verb, target string, body interface{}) { + fmt.Fprintln(w, "=== DRY RUN ===") + if target != "" { + fmt.Fprintf(w, "Would %s %s with:\n", verb, target) + } + + encoded, err := json.MarshalIndent(maskSecrets(body), "", " ") + if err != nil { + // Never block a preview on formatting; fall back to Go's own rendering. + fmt.Fprintf(w, " %v\n", body) + } else { + fmt.Fprintln(w, string(encoded)) + } +} + +// maskSecrets returns a deep copy of v with every sensitive value replaced. It +// copies rather than editing in place so the real request body is untouched. +func maskSecrets(v interface{}) interface{} { + switch value := v.(type) { + case map[string]interface{}: + out := make(map[string]interface{}, len(value)) + for k, item := range value { + if sensitiveKeys[k] { + out[k] = "***" + continue + } + out[k] = maskSecrets(item) + } + return out + case []interface{}: + out := make([]interface{}, len(value)) + for i, item := range value { + out[i] = maskSecrets(item) + } + return out + default: + return v + } +} diff --git a/go/internal/vdbclient/query.go b/go/internal/vdbclient/query.go new file mode 100644 index 0000000..e1a3831 --- /dev/null +++ b/go/internal/vdbclient/query.go @@ -0,0 +1,84 @@ +package vdbclient + +import ( + "net/url" + "strconv" + "strings" +) + +// DefaultPageSize matches the vserver list default so `grn` feels consistent +// across products. +const DefaultPageSize = 50 + +// ListOptions are the query parameters shared by every vDB list endpoint. The +// spec models Name/Statuses as a "filterRequest" object, but the API flattens it +// into plain query params — see BuildListQuery. +type ListOptions struct { + Page int // 1-based, as the user typed it + PageSize int + Name string + Statuses []string +} + +// BuildListQuery renders ListOptions into vDB's query parameters. +// +// Two quirks are encoded here so no command has to remember them: +// +// - pageNumber is 1-BASED (page 1 is the first page). This matches vserver but +// is the opposite of vks, whose pages start at 0 — so --page passes straight +// through with no offset. +// - The spec's "filterRequest" object is sent as flat query params +// (?name=x&status=ACTIVE), not JSON. Multiple statuses repeat the key. +// +// Verified against the live API: requesting pageNumber=1 and pageNumber=2 +// returns distinct items and pageObject.number echoes the requested page, so the +// response counter is 1-based too and can be fed back in. Page against +// pageObject.totalPages / totalElements. +func BuildListQuery(o ListOptions) url.Values { + page := o.Page + if page < 1 { + page = 1 + } + pageSize := o.PageSize + if pageSize < 1 { + pageSize = DefaultPageSize + } + + v := url.Values{} + v.Set("pageNumber", strconv.Itoa(page)) + v.Set("pageSize", strconv.Itoa(pageSize)) + + if o.Name != "" { + v.Set("name", o.Name) + } + for _, s := range o.Statuses { + if s != "" { + v.Add("status", s) + } + } + + return v +} + +// Get issues a GET with multi-valued query parameters. +// +// GreennodeClient.Get takes a map[string]string, which holds one value per key +// and so cannot express vdb's repeated "?status=ACTIVE&status=ERROR". Encoding +// the query onto the path is equivalent — the client only rewrites the query +// string when its params argument is non-empty — and keeps this vdb-specific +// need out of the shared client. +func Get(c *Client, path string, values url.Values) (interface{}, error) { + return c.Get(appendQuery(path, values), nil) +} + +// appendQuery encodes values onto path, preserving any query string already on it. +func appendQuery(path string, values url.Values) string { + if len(values) == 0 { + return path + } + sep := "?" + if strings.Contains(path, "?") { + sep = "&" + } + return path + sep + values.Encode() +} diff --git a/go/internal/vdbclient/query_test.go b/go/internal/vdbclient/query_test.go new file mode 100644 index 0000000..543100f --- /dev/null +++ b/go/internal/vdbclient/query_test.go @@ -0,0 +1,65 @@ +package vdbclient + +import ( + "testing" +) + +func TestBuildListQuery(t *testing.T) { + tests := []struct { + name string + opts ListOptions + want string + }{ + { + name: "defaults when unset", + opts: ListOptions{}, + want: "pageNumber=1&pageSize=50", + }, + { + // pageNumber is 1-based: --page 1 must go out as 1, not 0. A 0 here + // would silently skip the first page on a 1-based API. + name: "page passes through without offset", + opts: ListOptions{Page: 1, PageSize: 20}, + want: "pageNumber=1&pageSize=20", + }, + { + name: "page 3", + opts: ListOptions{Page: 3, PageSize: 20}, + want: "pageNumber=3&pageSize=20", + }, + { + name: "non-positive page and size fall back to defaults", + opts: ListOptions{Page: 0, PageSize: -5}, + want: "pageNumber=1&pageSize=50", + }, + { + // filterRequest is flattened into plain query params, not JSON. + name: "name filter is a flat param", + opts: ListOptions{Page: 1, PageSize: 10, Name: "my-db"}, + want: "name=my-db&pageNumber=1&pageSize=10", + }, + { + name: "multiple statuses repeat the key", + opts: ListOptions{Page: 1, PageSize: 10, Statuses: []string{"ACTIVE", "ERROR"}}, + want: "pageNumber=1&pageSize=10&status=ACTIVE&status=ERROR", + }, + { + name: "empty status entries are dropped", + opts: ListOptions{Page: 1, PageSize: 10, Statuses: []string{"ACTIVE", ""}}, + want: "pageNumber=1&pageSize=10&status=ACTIVE", + }, + { + name: "empty name is omitted entirely", + opts: ListOptions{Page: 2, PageSize: 10, Name: ""}, + want: "pageNumber=2&pageSize=10", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := BuildListQuery(tt.opts).Encode(); got != tt.want { + t.Errorf("BuildListQuery() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/go/internal/vdbclient/secrules.go b/go/internal/vdbclient/secrules.go new file mode 100644 index 0000000..6a33102 --- /dev/null +++ b/go/internal/vdbclient/secrules.go @@ -0,0 +1,183 @@ +package vdbclient + +import ( + "fmt" + "strconv" + "strings" +) + +// Security rules are shared between the relational and memorystore APIs (and so +// between the Relational Database and PostgreSQL Cluster command groups, which both +// use the relational endpoint). Both take the same body: a JSON ARRAY of +// UpdateSecurityGroupRuleDetail, replacing the whole rule set. +// +// Only the default port differs per engine, so it is a parameter. + +// SecurityRuleKeys documents the accepted --rule keys. An unknown key must be an +// error rather than an ignored field: a typo'd "cird=" would otherwise send a rule +// with no network, and since the endpoint REPLACES the rule set, that revokes +// everything. +var SecurityRuleKeys = []string{"cidr", "port", "port-min", "port-max", "id"} + +var securityRuleKeySet = func() map[string]bool { + set := make(map[string]bool, len(SecurityRuleKeys)) + for _, k := range SecurityRuleKeys { + set[k] = true + } + return set +}() + +// ParseSecurityRules turns each `key=value,...` spec into an +// UpdateSecurityGroupRuleDetail. defaultPort is used when a rule names no port — +// 3306 for MySQL/MariaDB, 5432 for PostgreSQL, 6379 for Redis. +func ParseSecurityRules(specs []string, defaultPort int) ([]interface{}, error) { + rules := make([]interface{}, 0, len(specs)) + + for _, spec := range specs { + fields := map[string]string{} + for _, pair := range strings.Split(spec, ",") { + pair = strings.TrimSpace(pair) + if pair == "" { + continue + } + key, value, found := strings.Cut(pair, "=") + key = strings.TrimSpace(strings.ToLower(key)) + if !found { + return nil, fmt.Errorf("invalid --rule %q: %q is not key=value", spec, pair) + } + if !securityRuleKeySet[key] { + return nil, fmt.Errorf("invalid --rule %q: unknown key %q (accepted: %s)", + spec, key, strings.Join(SecurityRuleKeys, ", ")) + } + fields[key] = strings.TrimSpace(value) + } + + cidr := fields["cidr"] + if cidr == "" { + return nil, fmt.Errorf("invalid --rule %q: cidr is required", spec) + } + + portMin, portMax, err := rulePorts(spec, fields, defaultPort) + if err != nil { + return nil, err + } + + rule := map[string]interface{}{ + "portRangeMin": portMin, + "portRangeMax": portMax, + "remoteIpPrefix": cidr, + } + // The API reads a null id as "insert new"; only send one when keeping an + // existing rule. + if id := fields["id"]; id != "" { + rule["id"] = id + } + rules = append(rules, rule) + } + + if len(rules) == 0 { + return nil, fmt.Errorf("no rules given: pass at least one --rule") + } + return rules, nil +} + +func rulePorts(spec string, fields map[string]string, defaultPort int) (int, int, error) { + single, hasSingle := fields["port"] + minValue, hasMin := fields["port-min"] + maxValue, hasMax := fields["port-max"] + + if hasSingle && (hasMin || hasMax) { + return 0, 0, fmt.Errorf("invalid --rule %q: use either port or port-min/port-max, not both", spec) + } + + switch { + case hasSingle: + port, err := parsePort(spec, "port", single) + return port, port, err + case hasMin || hasMax: + if !hasMin || !hasMax { + return 0, 0, fmt.Errorf("invalid --rule %q: port-min and port-max must be given together", spec) + } + low, err := parsePort(spec, "port-min", minValue) + if err != nil { + return 0, 0, err + } + high, err := parsePort(spec, "port-max", maxValue) + if err != nil { + return 0, 0, err + } + if low > high { + return 0, 0, fmt.Errorf("invalid --rule %q: port-min %d is above port-max %d", spec, low, high) + } + return low, high, nil + default: + return defaultPort, defaultPort, nil + } +} + +func parsePort(spec, key, value string) (int, error) { + port, err := strconv.Atoi(value) + if err != nil || port < 1 || port > 65535 { + return 0, fmt.Errorf("invalid --rule %q: %s=%q is not a port number", spec, key, value) + } + return port, nil +} + +// SecurityRulesFrom reshapes a GET /secrules response into the update format, +// keeping each rule's ID so re-sending it preserves the rule instead of recreating +// it. Used to show the current set and to implement "append" semantics on top of an +// endpoint that only supports replace. +func SecurityRulesFrom(result interface{}) []interface{} { + items, ok := Unwrap(result).([]interface{}) + if !ok { + return nil + } + + out := make([]interface{}, 0, len(items)) + for _, item := range items { + row, ok := item.(map[string]interface{}) + if !ok { + continue + } + rule := map[string]interface{}{ + "portRangeMin": row["portRangeMin"], + "portRangeMax": row["portRangeMax"], + "remoteIpPrefix": row["remoteIpPrefix"], + } + if id, _ := row["id"].(string); id != "" { + rule["id"] = id + } + out = append(out, rule) + } + return out +} + +// DescribeSecurityRules renders a rule set as +// "203.0.113.0/24:5432, 10.0.0.0/8:5432-5433" for the confirmation prompt. +func DescribeSecurityRules(rules []interface{}) string { + if len(rules) == 0 { + return "(none)" + } + parts := make([]string, 0, len(rules)) + for _, item := range rules { + rule, ok := item.(map[string]interface{}) + if !ok { + continue + } + low := fmt.Sprintf("%v", rule["portRangeMin"]) + high := fmt.Sprintf("%v", rule["portRangeMax"]) + ports := low + if low != high { + ports = low + "-" + high + } + parts = append(parts, fmt.Sprintf("%v:%s", rule["remoteIpPrefix"], ports)) + } + return strings.Join(parts, ", ") +} + +// SecurityRuleColumns is the table view of a SecurityGroupRuleEntity. status is +// omitted: the API leaves it empty on every rule. +var SecurityRuleColumns = []string{ + "id", "direction", "protocol", "portRangeMin", "portRangeMax", + "remoteIpPrefix", "createdAt", +} diff --git a/go/internal/vdbclient/secrules_test.go b/go/internal/vdbclient/secrules_test.go new file mode 100644 index 0000000..48beee6 --- /dev/null +++ b/go/internal/vdbclient/secrules_test.go @@ -0,0 +1,123 @@ +package vdbclient + +import "testing" + +const testPort = 5432 + +func TestParseSecurityRules(t *testing.T) { + rules, err := ParseSecurityRules([]string{ + "cidr=203.0.113.0/24", + "cidr=10.0.0.0/8,port-min=5432,port-max=5433", + "cidr=192.0.2.1/32,port=6432,id=rule-1", + }, testPort) + if err != nil { + t.Fatalf("ParseSecurityRules: %v", err) + } + if len(rules) != 3 { + t.Fatalf("got %d rules, want 3", len(rules)) + } + + first := rules[0].(map[string]interface{}) + if first["portRangeMin"] != testPort || first["portRangeMax"] != testPort { + t.Errorf("a rule without a port should default to %d, got %v-%v", + testPort, first["portRangeMin"], first["portRangeMax"]) + } + if _, present := first["id"]; present { + t.Error("a new rule must not carry an id; the API reads a null id as 'insert'") + } + if first["remoteIpPrefix"] != "203.0.113.0/24" { + t.Errorf("remoteIpPrefix = %v", first["remoteIpPrefix"]) + } + + second := rules[1].(map[string]interface{}) + if second["portRangeMin"] != 5432 || second["portRangeMax"] != 5433 { + t.Errorf("port range = %v-%v, want 5432-5433", second["portRangeMin"], second["portRangeMax"]) + } + + third := rules[2].(map[string]interface{}) + if third["id"] != "rule-1" || third["portRangeMin"] != 6432 { + t.Errorf("rule with id = %v", third) + } +} + +// TestParseSecurityRulesDefaultPortIsPerEngine: MySQL/MariaDB listen on 3306, +// PostgreSQL on 5432, Redis on 6379, so the default is the caller's to pass. +func TestParseSecurityRulesDefaultPortIsPerEngine(t *testing.T) { + for _, port := range []int{3306, 5432, 6379} { + rules, err := ParseSecurityRules([]string{"cidr=0.0.0.0/0"}, port) + if err != nil { + t.Fatalf("port %d: %v", port, err) + } + if got := rules[0].(map[string]interface{})["portRangeMin"]; got != port { + t.Errorf("default port = %v, want %d", got, port) + } + } +} + +func TestParseSecurityRulesRejectsBadInput(t *testing.T) { + cases := map[string]string{ + "typo'd key would revoke everything": "cird=1.2.3.4/32", + "no cidr": "port=5432", + "not key=value": "cidr", + "port with a range": "cidr=1.2.3.4/32,port=5432,port-min=5432", + "half a range": "cidr=1.2.3.4/32,port-min=5432", + "inverted range": "cidr=1.2.3.4/32,port-min=5433,port-max=5432", + "port is not a number": "cidr=1.2.3.4/32,port=abc", + "port out of range": "cidr=1.2.3.4/32,port=70000", + } + + for name, spec := range cases { + if _, err := ParseSecurityRules([]string{spec}, testPort); err == nil { + t.Errorf("%s: ParseSecurityRules(%q) = nil error, want a rejection", name, spec) + } + } + + if _, err := ParseSecurityRules(nil, testPort); err == nil { + t.Error("no rules accepted; an empty rule set would revoke all access") + } +} + +// TestSecurityRulesFrom: re-sending an existing rule must keep its ID, otherwise +// the replace-semantics endpoint deletes and recreates it. +func TestSecurityRulesFrom(t *testing.T) { + response := map[string]interface{}{ + "code": 200.0, "message": "ok", + "data": []interface{}{ + map[string]interface{}{ + "id": "rule-1", "portRangeMin": 5432.0, "portRangeMax": 5432.0, + "remoteIpPrefix": "0.0.0.0/0", "direction": "ingress", "status": "", + }, + }, + } + + rules := SecurityRulesFrom(response) + if len(rules) != 1 { + t.Fatalf("got %d rules, want 1", len(rules)) + } + rule := rules[0].(map[string]interface{}) + if rule["id"] != "rule-1" { + t.Errorf("id = %v, want it preserved", rule["id"]) + } + // Only the four update fields survive; direction/status are read-only. + if _, present := rule["direction"]; present { + t.Error("direction must not be sent back on an update") + } + + if got := SecurityRulesFrom("not a list"); got != nil { + t.Errorf("SecurityRulesFrom(non-list) = %v, want nil", got) + } +} + +func TestDescribeSecurityRules(t *testing.T) { + rules := []interface{}{ + map[string]interface{}{"remoteIpPrefix": "0.0.0.0/0", "portRangeMin": 5432, "portRangeMax": 5432}, + map[string]interface{}{"remoteIpPrefix": "10.0.0.0/8", "portRangeMin": 5432, "portRangeMax": 5433}, + } + + if got, want := DescribeSecurityRules(rules), "0.0.0.0/0:5432, 10.0.0.0/8:5432-5433"; got != want { + t.Errorf("DescribeSecurityRules = %q, want %q", got, want) + } + if got := DescribeSecurityRules(nil); got != "(none)" { + t.Errorf("empty set = %q, want (none)", got) + } +} diff --git a/mkdocs.yml b/mkdocs.yml index f047b02..e9042c0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -262,6 +262,30 @@ nav: - placement-group: commands/vserver/placement-group.md - Images: - user-image: commands/vserver/user-image.md + - vDB Commands: + - Overview: commands/vdb/index.md + - Relational: + - instance: commands/vdb/relational-instance.md + - catalog: commands/vdb/relational-catalog.md + - backup: commands/vdb/relational-backup.md + - configuration: commands/vdb/relational-configuration.md + - backup-storage: commands/vdb/relational-backup-storage.md + - PostgreSQL Cluster: + - cluster: commands/vdb/postgresql-cluster.md + - catalog: commands/vdb/postgresql-catalog.md + - backup: commands/vdb/postgresql-backup.md + - MemoryStore: + - instance: commands/vdb/memorystore-instance.md + - catalog: commands/vdb/memorystore-catalog.md + - backup: commands/vdb/memorystore-backup.md + - configuration: commands/vdb/memorystore-configuration.md + - backup-storage: commands/vdb/memorystore-backup-storage.md + - Kafka: + - cluster: commands/vdb/kafka-cluster.md + - topic: commands/vdb/kafka-topic.md + - user: commands/vdb/kafka-user.md + - configuration: commands/vdb/kafka-configuration.md + - catalog: commands/vdb/kafka-catalog.md - Development: - Contributing: development/contributing.md - Architecture & Adding a Service: development/architecture.md