From 56659be4c61bc0c6df57a9fcaf3ba1c8cc9c871d Mon Sep 17 00:00:00 2001 From: nhontt Date: Mon, 7 Sep 2026 16:11:15 +0700 Subject: [PATCH 1/8] init vdb cli --- CLAUDE.md | 80 +- docs/commands/vdb/index.md | 134 ++++ docs/commands/vdb/kafka-catalog.md | 113 +++ docs/commands/vdb/kafka-cluster.md | 280 +++++++ docs/commands/vdb/kafka-configuration.md | 128 ++++ docs/commands/vdb/kafka-topic.md | 105 +++ docs/commands/vdb/kafka-user.md | 149 ++++ .../vdb/memorystore-backup-storage.md | 97 +++ docs/commands/vdb/memorystore-backup.md | 147 ++++ docs/commands/vdb/memorystore-catalog.md | 164 ++++ .../commands/vdb/memorystore-configuration.md | 139 ++++ docs/commands/vdb/memorystore-instance.md | 403 ++++++++++ docs/commands/vdb/postgresql-backup.md | 103 +++ docs/commands/vdb/postgresql-catalog.md | 125 +++ docs/commands/vdb/postgresql-cluster.md | 462 ++++++++++++ .../commands/vdb/relational-backup-storage.md | 104 +++ docs/commands/vdb/relational-backup.md | 175 +++++ docs/commands/vdb/relational-catalog.md | 280 +++++++ docs/commands/vdb/relational-configuration.md | 142 ++++ docs/commands/vdb/relational-instance.md | 678 +++++++++++++++++ go/cmd/register.go | 7 +- go/cmd/register_vdb.go | 11 + go/cmd/vdb/CLAUDE.md | 714 ++++++++++++++++++ go/cmd/vdb/completion_test.go | 89 +++ go/cmd/vdb/kafka/catalog/catalog.go | 153 ++++ go/cmd/vdb/kafka/catalog/completion.go | 133 ++++ go/cmd/vdb/kafka/catalog/list_flavors.go | 64 ++ go/cmd/vdb/kafka/cluster/cluster.go | 52 ++ go/cmd/vdb/kafka/cluster/completion.go | 112 +++ go/cmd/vdb/kafka/cluster/create.go | 166 ++++ go/cmd/vdb/kafka/cluster/helpers.go | 109 +++ go/cmd/vdb/kafka/cluster/lifecycle.go | 293 +++++++ go/cmd/vdb/kafka/cluster/read.go | 188 +++++ go/cmd/vdb/kafka/cluster/secrules.go | 172 +++++ go/cmd/vdb/kafka/cluster/update.go | 240 ++++++ go/cmd/vdb/kafka/configuration/commands.go | 371 +++++++++ go/cmd/vdb/kafka/configuration/completion.go | 95 +++ .../vdb/kafka/configuration/configuration.go | 61 ++ go/cmd/vdb/kafka/kafka.go | 59 ++ go/cmd/vdb/kafka/paths_test.go | 128 ++++ go/cmd/vdb/kafka/topic/completion.go | 95 +++ go/cmd/vdb/kafka/topic/read.go | 88 +++ go/cmd/vdb/kafka/topic/topic.go | 34 + go/cmd/vdb/kafka/topic/write.go | 332 ++++++++ go/cmd/vdb/kafka/user/completion.go | 92 +++ go/cmd/vdb/kafka/user/read.go | 130 ++++ go/cmd/vdb/kafka/user/user.go | 42 ++ go/cmd/vdb/kafka/user/write.go | 328 ++++++++ go/cmd/vdb/memorystore/backup/backup.go | 94 +++ go/cmd/vdb/memorystore/backup/read.go | 126 ++++ go/cmd/vdb/memorystore/backup/write.go | 430 +++++++++++ .../backupstorage/backupstorage.go | 64 ++ .../vdb/memorystore/backupstorage/commands.go | 300 ++++++++ .../memorystore/backupstorage/completion.go | 49 ++ go/cmd/vdb/memorystore/catalog/catalog.go | 183 +++++ go/cmd/vdb/memorystore/catalog/completion.go | 101 +++ .../vdb/memorystore/catalog/list_flavors.go | 63 ++ .../vdb/memorystore/configuration/commands.go | 417 ++++++++++ .../configuration/configuration.go | 76 ++ go/cmd/vdb/memorystore/instance/actions.go | 107 +++ go/cmd/vdb/memorystore/instance/completion.go | 111 +++ go/cmd/vdb/memorystore/instance/create.go | 228 ++++++ .../memorystore/instance/create_replica.go | 216 ++++++ go/cmd/vdb/memorystore/instance/helpers.go | 112 +++ go/cmd/vdb/memorystore/instance/instance.go | 46 ++ .../vdb/memorystore/instance/instance_test.go | 442 +++++++++++ go/cmd/vdb/memorystore/instance/lifecycle.go | 244 ++++++ go/cmd/vdb/memorystore/instance/read.go | 163 ++++ go/cmd/vdb/memorystore/instance/secrules.go | 151 ++++ go/cmd/vdb/memorystore/instance/update.go | 350 +++++++++ go/cmd/vdb/memorystore/memorystore.go | 57 ++ go/cmd/vdb/memorystore/paths_test.go | 70 ++ go/cmd/vdb/postgresql/backup/backup.go | 66 ++ go/cmd/vdb/postgresql/backup/backup_test.go | 65 ++ go/cmd/vdb/postgresql/backup/create.go | 71 ++ go/cmd/vdb/postgresql/backup/get.go | 51 ++ go/cmd/vdb/postgresql/backup/helpers.go | 19 + go/cmd/vdb/postgresql/backup/list.go | 33 + .../postgresql/backup/list_restore_points.go | 49 ++ go/cmd/vdb/postgresql/catalog/catalog.go | 161 ++++ go/cmd/vdb/postgresql/catalog/catalog_test.go | 93 +++ go/cmd/vdb/postgresql/catalog/completion.go | 78 ++ .../postgresql/catalog/list_config_groups.go | 92 +++ go/cmd/vdb/postgresql/cluster/cluster.go | 41 + go/cmd/vdb/postgresql/cluster/cluster_test.go | 434 +++++++++++ go/cmd/vdb/postgresql/cluster/completion.go | 141 ++++ go/cmd/vdb/postgresql/cluster/create.go | 212 ++++++ go/cmd/vdb/postgresql/cluster/delete.go | 107 +++ go/cmd/vdb/postgresql/cluster/get.go | 49 ++ .../vdb/postgresql/cluster/get_volume_used.go | 46 ++ go/cmd/vdb/postgresql/cluster/helpers.go | 74 ++ go/cmd/vdb/postgresql/cluster/list.go | 122 +++ .../vdb/postgresql/cluster/list_histories.go | 60 ++ .../vdb/postgresql/cluster/list_secrules.go | 59 ++ go/cmd/vdb/postgresql/cluster/reboot.go | 65 ++ go/cmd/vdb/postgresql/cluster/resize.go | 152 ++++ .../postgresql/cluster/update_config_group.go | 94 +++ .../vdb/postgresql/cluster/update_secrule.go | 112 +++ .../vdb/postgresql/cluster/update_settings.go | 116 +++ go/cmd/vdb/postgresql/postgresql.go | 37 + go/cmd/vdb/relational/backup/backup.go | 84 +++ go/cmd/vdb/relational/backup/backup_test.go | 287 +++++++ go/cmd/vdb/relational/backup/completion.go | 36 + go/cmd/vdb/relational/backup/create.go | 138 ++++ go/cmd/vdb/relational/backup/delete.go | 99 +++ go/cmd/vdb/relational/backup/fields.go | 86 +++ go/cmd/vdb/relational/backup/get.go | 63 ++ .../vdb/relational/backup/get_free_storage.go | 35 + go/cmd/vdb/relational/backup/list.go | 67 ++ go/cmd/vdb/relational/backup/restore.go | 257 +++++++ .../relational/backupstorage/backupstorage.go | 60 ++ .../backupstorage/backupstorage_test.go | 92 +++ .../relational/backupstorage/completion.go | 49 ++ go/cmd/vdb/relational/backupstorage/read.go | 102 +++ go/cmd/vdb/relational/backupstorage/write.go | 224 ++++++ go/cmd/vdb/relational/catalog/catalog.go | 142 ++++ go/cmd/vdb/relational/catalog/catalog_test.go | 104 +++ go/cmd/vdb/relational/catalog/completion.go | 179 +++++ go/cmd/vdb/relational/catalog/helpers.go | 65 ++ .../relational/catalog/list_config_groups.go | 83 ++ go/cmd/vdb/relational/catalog/list_flavors.go | 66 ++ .../relational/configuration/configuration.go | 78 ++ .../configuration/configuration_test.go | 137 ++++ go/cmd/vdb/relational/configuration/read.go | 143 ++++ go/cmd/vdb/relational/configuration/write.go | 353 +++++++++ go/cmd/vdb/relational/instance/actions.go | 111 +++ go/cmd/vdb/relational/instance/async.go | 69 ++ go/cmd/vdb/relational/instance/completion.go | 143 ++++ .../relational/instance/completion_test.go | 34 + go/cmd/vdb/relational/instance/create.go | 254 +++++++ .../vdb/relational/instance/create_replica.go | 229 ++++++ go/cmd/vdb/relational/instance/delete.go | 107 +++ .../vdb/relational/instance/detach_replica.go | 70 ++ go/cmd/vdb/relational/instance/get.go | 52 ++ go/cmd/vdb/relational/instance/helpers.go | 134 ++++ go/cmd/vdb/relational/instance/instance.go | 47 ++ .../vdb/relational/instance/lifecycle_test.go | 622 +++++++++++++++ go/cmd/vdb/relational/instance/list.go | 61 ++ .../vdb/relational/instance/list_histories.go | 64 ++ .../instance/list_histories_test.go | 32 + .../vdb/relational/instance/list_replicas.go | 48 ++ go/cmd/vdb/relational/instance/list_test.go | 51 ++ .../relational/instance/resize_instance.go | 83 ++ .../vdb/relational/instance/resize_storage.go | 136 ++++ go/cmd/vdb/relational/instance/secrules.go | 168 +++++ .../instance/update_config_group.go | 108 +++ .../relational/instance/update_settings.go | 192 +++++ go/cmd/vdb/relational/relational.go | 30 + go/cmd/vdb/vdb.go | 44 ++ go/internal/auth/token.go | 117 +++ go/internal/client/client.go | 57 +- go/internal/client/headers_test.go | 130 ++++ go/internal/config/config.go | 3 + go/internal/config/endpoint_vdb_test.go | 30 + go/internal/vdbclient/action.go | 73 ++ go/internal/vdbclient/action_test.go | 125 +++ go/internal/vdbclient/client.go | 122 +++ go/internal/vdbclient/credential.go | 70 ++ go/internal/vdbclient/credential_test.go | 74 ++ go/internal/vdbclient/error.go | 133 ++++ go/internal/vdbclient/error_test.go | 165 ++++ go/internal/vdbclient/get_test.go | 55 ++ go/internal/vdbclient/id.go | 34 + go/internal/vdbclient/id_test.go | 43 ++ go/internal/vdbclient/output.go | 218 ++++++ go/internal/vdbclient/output_test.go | 214 ++++++ go/internal/vdbclient/preview.go | 67 ++ go/internal/vdbclient/query.go | 84 +++ go/internal/vdbclient/query_test.go | 65 ++ go/internal/vdbclient/secrules.go | 183 +++++ go/internal/vdbclient/secrules_test.go | 123 +++ mkdocs.yml | 24 + 172 files changed, 23381 insertions(+), 11 deletions(-) create mode 100644 docs/commands/vdb/index.md create mode 100644 docs/commands/vdb/kafka-catalog.md create mode 100644 docs/commands/vdb/kafka-cluster.md create mode 100644 docs/commands/vdb/kafka-configuration.md create mode 100644 docs/commands/vdb/kafka-topic.md create mode 100644 docs/commands/vdb/kafka-user.md create mode 100644 docs/commands/vdb/memorystore-backup-storage.md create mode 100644 docs/commands/vdb/memorystore-backup.md create mode 100644 docs/commands/vdb/memorystore-catalog.md create mode 100644 docs/commands/vdb/memorystore-configuration.md create mode 100644 docs/commands/vdb/memorystore-instance.md create mode 100644 docs/commands/vdb/postgresql-backup.md create mode 100644 docs/commands/vdb/postgresql-catalog.md create mode 100644 docs/commands/vdb/postgresql-cluster.md create mode 100644 docs/commands/vdb/relational-backup-storage.md create mode 100644 docs/commands/vdb/relational-backup.md create mode 100644 docs/commands/vdb/relational-catalog.md create mode 100644 docs/commands/vdb/relational-configuration.md create mode 100644 docs/commands/vdb/relational-instance.md create mode 100644 go/cmd/register_vdb.go create mode 100644 go/cmd/vdb/CLAUDE.md create mode 100644 go/cmd/vdb/completion_test.go create mode 100644 go/cmd/vdb/kafka/catalog/catalog.go create mode 100644 go/cmd/vdb/kafka/catalog/completion.go create mode 100644 go/cmd/vdb/kafka/catalog/list_flavors.go create mode 100644 go/cmd/vdb/kafka/cluster/cluster.go create mode 100644 go/cmd/vdb/kafka/cluster/completion.go create mode 100644 go/cmd/vdb/kafka/cluster/create.go create mode 100644 go/cmd/vdb/kafka/cluster/helpers.go create mode 100644 go/cmd/vdb/kafka/cluster/lifecycle.go create mode 100644 go/cmd/vdb/kafka/cluster/read.go create mode 100644 go/cmd/vdb/kafka/cluster/secrules.go create mode 100644 go/cmd/vdb/kafka/cluster/update.go create mode 100644 go/cmd/vdb/kafka/configuration/commands.go create mode 100644 go/cmd/vdb/kafka/configuration/completion.go create mode 100644 go/cmd/vdb/kafka/configuration/configuration.go create mode 100644 go/cmd/vdb/kafka/kafka.go create mode 100644 go/cmd/vdb/kafka/paths_test.go create mode 100644 go/cmd/vdb/kafka/topic/completion.go create mode 100644 go/cmd/vdb/kafka/topic/read.go create mode 100644 go/cmd/vdb/kafka/topic/topic.go create mode 100644 go/cmd/vdb/kafka/topic/write.go create mode 100644 go/cmd/vdb/kafka/user/completion.go create mode 100644 go/cmd/vdb/kafka/user/read.go create mode 100644 go/cmd/vdb/kafka/user/user.go create mode 100644 go/cmd/vdb/kafka/user/write.go create mode 100644 go/cmd/vdb/memorystore/backup/backup.go create mode 100644 go/cmd/vdb/memorystore/backup/read.go create mode 100644 go/cmd/vdb/memorystore/backup/write.go create mode 100644 go/cmd/vdb/memorystore/backupstorage/backupstorage.go create mode 100644 go/cmd/vdb/memorystore/backupstorage/commands.go create mode 100644 go/cmd/vdb/memorystore/backupstorage/completion.go create mode 100644 go/cmd/vdb/memorystore/catalog/catalog.go create mode 100644 go/cmd/vdb/memorystore/catalog/completion.go create mode 100644 go/cmd/vdb/memorystore/catalog/list_flavors.go create mode 100644 go/cmd/vdb/memorystore/configuration/commands.go create mode 100644 go/cmd/vdb/memorystore/configuration/configuration.go create mode 100644 go/cmd/vdb/memorystore/instance/actions.go create mode 100644 go/cmd/vdb/memorystore/instance/completion.go create mode 100644 go/cmd/vdb/memorystore/instance/create.go create mode 100644 go/cmd/vdb/memorystore/instance/create_replica.go create mode 100644 go/cmd/vdb/memorystore/instance/helpers.go create mode 100644 go/cmd/vdb/memorystore/instance/instance.go create mode 100644 go/cmd/vdb/memorystore/instance/instance_test.go create mode 100644 go/cmd/vdb/memorystore/instance/lifecycle.go create mode 100644 go/cmd/vdb/memorystore/instance/read.go create mode 100644 go/cmd/vdb/memorystore/instance/secrules.go create mode 100644 go/cmd/vdb/memorystore/instance/update.go create mode 100644 go/cmd/vdb/memorystore/memorystore.go create mode 100644 go/cmd/vdb/memorystore/paths_test.go create mode 100644 go/cmd/vdb/postgresql/backup/backup.go create mode 100644 go/cmd/vdb/postgresql/backup/backup_test.go create mode 100644 go/cmd/vdb/postgresql/backup/create.go create mode 100644 go/cmd/vdb/postgresql/backup/get.go create mode 100644 go/cmd/vdb/postgresql/backup/helpers.go create mode 100644 go/cmd/vdb/postgresql/backup/list.go create mode 100644 go/cmd/vdb/postgresql/backup/list_restore_points.go create mode 100644 go/cmd/vdb/postgresql/catalog/catalog.go create mode 100644 go/cmd/vdb/postgresql/catalog/catalog_test.go create mode 100644 go/cmd/vdb/postgresql/catalog/completion.go create mode 100644 go/cmd/vdb/postgresql/catalog/list_config_groups.go create mode 100644 go/cmd/vdb/postgresql/cluster/cluster.go create mode 100644 go/cmd/vdb/postgresql/cluster/cluster_test.go create mode 100644 go/cmd/vdb/postgresql/cluster/completion.go create mode 100644 go/cmd/vdb/postgresql/cluster/create.go create mode 100644 go/cmd/vdb/postgresql/cluster/delete.go create mode 100644 go/cmd/vdb/postgresql/cluster/get.go create mode 100644 go/cmd/vdb/postgresql/cluster/get_volume_used.go create mode 100644 go/cmd/vdb/postgresql/cluster/helpers.go create mode 100644 go/cmd/vdb/postgresql/cluster/list.go create mode 100644 go/cmd/vdb/postgresql/cluster/list_histories.go create mode 100644 go/cmd/vdb/postgresql/cluster/list_secrules.go create mode 100644 go/cmd/vdb/postgresql/cluster/reboot.go create mode 100644 go/cmd/vdb/postgresql/cluster/resize.go create mode 100644 go/cmd/vdb/postgresql/cluster/update_config_group.go create mode 100644 go/cmd/vdb/postgresql/cluster/update_secrule.go create mode 100644 go/cmd/vdb/postgresql/cluster/update_settings.go create mode 100644 go/cmd/vdb/postgresql/postgresql.go create mode 100644 go/cmd/vdb/relational/backup/backup.go create mode 100644 go/cmd/vdb/relational/backup/backup_test.go create mode 100644 go/cmd/vdb/relational/backup/completion.go create mode 100644 go/cmd/vdb/relational/backup/create.go create mode 100644 go/cmd/vdb/relational/backup/delete.go create mode 100644 go/cmd/vdb/relational/backup/fields.go create mode 100644 go/cmd/vdb/relational/backup/get.go create mode 100644 go/cmd/vdb/relational/backup/get_free_storage.go create mode 100644 go/cmd/vdb/relational/backup/list.go create mode 100644 go/cmd/vdb/relational/backup/restore.go create mode 100644 go/cmd/vdb/relational/backupstorage/backupstorage.go create mode 100644 go/cmd/vdb/relational/backupstorage/backupstorage_test.go create mode 100644 go/cmd/vdb/relational/backupstorage/completion.go create mode 100644 go/cmd/vdb/relational/backupstorage/read.go create mode 100644 go/cmd/vdb/relational/backupstorage/write.go create mode 100644 go/cmd/vdb/relational/catalog/catalog.go create mode 100644 go/cmd/vdb/relational/catalog/catalog_test.go create mode 100644 go/cmd/vdb/relational/catalog/completion.go create mode 100644 go/cmd/vdb/relational/catalog/helpers.go create mode 100644 go/cmd/vdb/relational/catalog/list_config_groups.go create mode 100644 go/cmd/vdb/relational/catalog/list_flavors.go create mode 100644 go/cmd/vdb/relational/configuration/configuration.go create mode 100644 go/cmd/vdb/relational/configuration/configuration_test.go create mode 100644 go/cmd/vdb/relational/configuration/read.go create mode 100644 go/cmd/vdb/relational/configuration/write.go create mode 100644 go/cmd/vdb/relational/instance/actions.go create mode 100644 go/cmd/vdb/relational/instance/async.go create mode 100644 go/cmd/vdb/relational/instance/completion.go create mode 100644 go/cmd/vdb/relational/instance/completion_test.go create mode 100644 go/cmd/vdb/relational/instance/create.go create mode 100644 go/cmd/vdb/relational/instance/create_replica.go create mode 100644 go/cmd/vdb/relational/instance/delete.go create mode 100644 go/cmd/vdb/relational/instance/detach_replica.go create mode 100644 go/cmd/vdb/relational/instance/get.go create mode 100644 go/cmd/vdb/relational/instance/helpers.go create mode 100644 go/cmd/vdb/relational/instance/instance.go create mode 100644 go/cmd/vdb/relational/instance/lifecycle_test.go create mode 100644 go/cmd/vdb/relational/instance/list.go create mode 100644 go/cmd/vdb/relational/instance/list_histories.go create mode 100644 go/cmd/vdb/relational/instance/list_histories_test.go create mode 100644 go/cmd/vdb/relational/instance/list_replicas.go create mode 100644 go/cmd/vdb/relational/instance/list_test.go create mode 100644 go/cmd/vdb/relational/instance/resize_instance.go create mode 100644 go/cmd/vdb/relational/instance/resize_storage.go create mode 100644 go/cmd/vdb/relational/instance/secrules.go create mode 100644 go/cmd/vdb/relational/instance/update_config_group.go create mode 100644 go/cmd/vdb/relational/instance/update_settings.go create mode 100644 go/cmd/vdb/relational/relational.go create mode 100644 go/cmd/vdb/vdb.go create mode 100644 go/internal/auth/token.go create mode 100644 go/internal/client/headers_test.go create mode 100644 go/internal/config/endpoint_vdb_test.go create mode 100644 go/internal/vdbclient/action.go create mode 100644 go/internal/vdbclient/action_test.go create mode 100644 go/internal/vdbclient/client.go create mode 100644 go/internal/vdbclient/credential.go create mode 100644 go/internal/vdbclient/credential_test.go create mode 100644 go/internal/vdbclient/error.go create mode 100644 go/internal/vdbclient/error_test.go create mode 100644 go/internal/vdbclient/get_test.go create mode 100644 go/internal/vdbclient/id.go create mode 100644 go/internal/vdbclient/id_test.go create mode 100644 go/internal/vdbclient/output.go create mode 100644 go/internal/vdbclient/output_test.go create mode 100644 go/internal/vdbclient/preview.go create mode 100644 go/internal/vdbclient/query.go create mode 100644 go/internal/vdbclient/query_test.go create mode 100644 go/internal/vdbclient/secrules.go create mode 100644 go/internal/vdbclient/secrules_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 95b1101..cc5f03d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,6 +22,38 @@ go/ │ │ ├── identity.go # identity group (login/workload/outbound-auth) │ │ ├── context.go # context group (switch/current/headers/decorators) │ │ └── helpers.go # mustLoadConfig / newAuthProvider +│ ├── 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 lệnh, 5 noun như relational +│ │ │ │ # nhưng path khác gần hết; không có volume/resize-storage +│ │ │ ├── instance/ # 16 commands (master password thay vì 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 lệnh / 35 endpoint, /vdb-kafka (KHÔNG có /v1) +│ │ │ # không envelope (9/36), không phân trang, không backup +│ │ ├── cluster/ # 14: read + create/delete + resize-brokers/resize-storage/ +│ │ │ # update-volume-type + authentication/config-group/ +│ │ │ # public-access + secrule create/delete (list-secrules +│ │ │ # đọc từ cluster get — API không có endpoint) +│ │ ├── topic/ # list/get/create/update/delete (dưới cluster) +│ │ ├── user/ # + get-creds/generate-creds; quyền theo TÊN topic +│ │ ├── configuration/ # config group CÓ VERSION: create-version thay cho update +│ │ └── catalog/ # flavors/families/codes/volume-types (versions lấy từ +│ │ # /database/configs — endpoint này không có lệnh) │ ├── configure/ │ │ ├── configure.go # Interactive setup │ │ ├── list.go # grn configure list @@ -71,6 +103,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 @@ -94,8 +130,24 @@ go/ ## 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 @@ -116,7 +168,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 @@ -131,6 +201,10 @@ Note: `cmd/agentbase` is gated behind the opt-in `agentbase` build tag (`cmd/register_agentbase.go`), the inverse of the `!vks_only` pattern — it is excluded from default and release builds while still in development. +Products still in development register from their own `cmd/register_.go` +behind `//go:build !vks_only`, so they compile into dev and CI builds but not the +public release binary (`-tags vks_only`). `cmd/vdb` follows this. + ## Security rules - **Credential masking**: `configure list` and `configure get` mask client_id/client_secret (last 4 chars only) @@ -200,6 +274,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..4220a0e --- /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) | In progress | +| [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..bf284c9 --- /dev/null +++ b/docs/commands/vdb/kafka-cluster.md @@ -0,0 +1,280 @@ +# 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. `--network-id` and `--subnet-id` complete from the **relational** catalog — +Kafka has no networks endpoint and both are project-wide. + +Authentication is off unless `--mtls-authen` or `--sasl-authen` is given. A cluster with +neither accepts anyone who can reach its network. + +--- + +## 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..52d425c --- /dev/null +++ b/docs/commands/vdb/kafka-user.md @@ -0,0 +1,149 @@ +# 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) | Show a user's credentials (**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 +``` + +!!! danger "The output is secret" + This returns the credential material itself, as a bare array of strings. Do not + paste it into a ticket or a shared log; redirect it to a file with restrictive + permissions if it has to be kept. + +--- + +## 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 printed 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..b079b11 --- /dev/null +++ b/docs/commands/vdb/postgresql-catalog.md @@ -0,0 +1,125 @@ +# 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 ] +``` + +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. + +```bash +grn vdb postgresql catalog list-flavors --zone-id HCM03-1A --output table +``` + +--- + +## list-volume-types + +``` +grn vdb postgresql catalog list-volume-types [--zone-id ] +``` + +Table columns: `id`, `type`, `name`, `minVolumeSize`, `maxVolumeSize`, `iops`, +`status`, `zoneId`. The `id` (`pgst-...`) is what `--volume-type-id` expects; +`minVolumeSize`/`maxVolumeSize` bound `--volume-size`. + +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..79b43e8 --- /dev/null +++ b/docs/commands/vdb/postgresql-cluster.md @@ -0,0 +1,462 @@ +# 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. + +```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. + +`--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. + +### Examples + +```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 +``` + +--- + +## 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..e2b76f3 --- /dev/null +++ b/go/cmd/vdb/CLAUDE.md @@ -0,0 +1,714 @@ +# 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. +- 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]`. +- **`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`. 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`. + +## 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 is deliberately unchecked: its + surface already validates differently (it accepts `configGroupId: ""`, which relational + rejects) and its limits were never confirmed. +- **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..21ef48a --- /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" + configGroupVersionResourc = "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(configGroupVersionResourc) +} diff --git a/go/cmd/vdb/kafka/cluster/create.go b/go/cmd/vdb/kafka/cluster/create.go new file mode 100644 index 0000000..5dda9c7 --- /dev/null +++ b/go/cmd/vdb/kafka/cluster/create.go @@ -0,0 +1,166 @@ +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" + + "Authentication is off unless --mtls-authen or --sasl-authen is passed. A " + + "cluster with neither is reachable by anyone who can reach its network, so at " + + "least one is normally wanted; users and their per-topic permissions are then " + + "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") + configGroupVersionID, _ := f.GetString("config-group-version-id") + encryptionVolume, _ := f.GetBool("encryption-volume") + + body := map[string]interface{}{ + "name": name, + "kafkaVersion": kafkaVersion, + "serverFlavorId": flavorID, + "kafkaBrokerCount": brokerCount, + "kafkaStorageType": volumeType, + "kafkaStorageSize": volumeSize, + "vserverProjectId": vserverProjectID(cmd), + "networkId": networkID, + "subnetId": subnetID, + "mtlsAuthen": mtls, + "saslAuthen": sasl, + "encryptionVolume": encryptionVolume, + } + // Omitted rather than sent empty: an empty string is a value the API may read as + // "this version", and there is no live evidence it treats "" as "none" here. + if configGroupVersionID != "" { + body["configGroupVersionId"] = configGroupVersionID + } + + 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. +func vserverProjectID(cmd *cobra.Command) string { + if explicit, _ := cmd.Flags().GetString("vserver-project-id"); explicit != "" { + return explicit + } + profile, _ := cmd.Flags().GetString("profile") + cfg, err := config.LoadConfig(profile) + if err != nil || cfg == nil { + return "" + } + return cfg.ProjectID +} 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..1b63493 --- /dev/null +++ b/go/cmd/vdb/kafka/user/read.go @@ -0,0 +1,130 @@ +package user + +import ( + "fmt" + + "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: "Show 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, so this " + + "prints values that must not be pasted into a ticket or a shared log. Redirect " + + "it to a file with restrictive permissions if you need to keep it.\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 + } +} + +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 + } + + result, err := apiClient.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) + } + + // The payload is a bare array of strings with no field names, so there is no + // column set to apply; Output prints it as-is. + return vdbclient.Output(cmd, result) +} 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..50a49c8 --- /dev/null +++ b/go/cmd/vdb/kafka/user/write.go @@ -0,0 +1,328 @@ +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 " + + "printed 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] = cli.ParseCommaSeparated(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] = cli.ParseCommaSeparated(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 +} 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..837e72b --- /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 packages you can +// buy and basePath 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+"/packages", 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..cc49130 --- /dev/null +++ b/go/cmd/vdb/postgresql/catalog/catalog.go @@ -0,0 +1,161 @@ +// 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= + 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'.", + path: flavorsPath, + byZone: 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.", + path: volumeTypesPath, + byZone: 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, 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)") + // 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 + } + 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 +} 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..2db7903 --- /dev/null +++ b/go/cmd/vdb/postgresql/catalog/catalog_test.go @@ -0,0 +1,93 @@ +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) + } + } +} + +// 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..3917f70 --- /dev/null +++ b/go/cmd/vdb/postgresql/cluster/cluster_test.go @@ -0,0 +1,434 @@ +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": ""}}, + } + + 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) + } + } +} + +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..98a992a --- /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.Unwrap(result).(map[string]interface{}) + 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..caa889c --- /dev/null +++ b/go/cmd/vdb/postgresql/cluster/create.go @@ -0,0 +1,212 @@ +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" + + "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 ID(s), comma-separated (required; see 'grn vdb relational 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("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"] + if !cli.Confirm(force, fmt.Sprintf( + "Create PostgreSQL Cluster %q with %v nodes? This places a paid order.", name, nodes)) { + 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) +} + +// 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") + } + + 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..87d1562 --- /dev/null +++ b/go/cmd/vdb/postgresql/cluster/delete.go @@ -0,0 +1,107 @@ +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.Unwrap(result).(map[string]interface{}) + if !ok { + 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..1ee666f --- /dev/null +++ b/go/cmd/vdb/relational/backup/backup_test.go @@ -0,0 +1,287 @@ +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 TestSubnetIDsOnly(t *testing.T) { + got := subnetIDsOnly([]string{"net-1", "sub-1", "", "sub-2", "db-1"}) + if len(got) != 2 || got[0] != "sub-1" || got[1] != "sub-2" { + t.Errorf("subnetIDsOnly = %v, want the two sub- entries", got) + } +} + +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..fdee4d3 --- /dev/null +++ b/go/cmd/vdb/relational/backup/fields.go @@ -0,0 +1,86 @@ +package backup + +import ( + "strings" + + "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 +} + +// subnetIDPrefix is what a restore request needs in netIds. A backup record may +// carry a network ("net-") there instead, which the API rejects. +const subnetIDPrefix = "sub-" + +func subnetIDsOnly(values []string) []string { + out := make([]string, 0, len(values)) + for _, value := range values { + if strings.HasPrefix(value, subnetIDPrefix) { + out = append(out, value) + } + } + return out +} + +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/auth/token.go b/go/internal/auth/token.go new file mode 100644 index 0000000..45ff1d6 --- /dev/null +++ b/go/internal/auth/token.go @@ -0,0 +1,117 @@ +package auth + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +const IAMTokenURL = "https://iamapis.vngcloud.vn/accounts-api/v1/auth/token" + +// tokenResponse matches the IAM API camelCase JSON response. +type tokenResponse struct { + AccessToken string `json:"accessToken"` + ExpiresIn int `json:"expiresIn"` +} + +// TokenManager handles OAuth2 Client Credentials flow with auto-refresh. +type TokenManager struct { + clientID string + clientSecret string + accessToken string + expiresAt time.Time + mu sync.Mutex + httpClient *http.Client +} + +// NewTokenManager creates a new token manager. +func NewTokenManager(clientID, clientSecret string) *TokenManager { + return &TokenManager{ + clientID: clientID, + clientSecret: clientSecret, + httpClient: &http.Client{Timeout: 30 * time.Second}, + } +} + +// GetToken returns a valid access token, fetching a new one if needed. +func (tm *TokenManager) GetToken() (string, error) { + tm.mu.Lock() + defer tm.mu.Unlock() + + if tm.accessToken != "" && time.Now().Before(tm.expiresAt) { + return tm.accessToken, nil + } + return tm.fetchToken() +} + +// SetToken pre-seeds the token manager with a static token and expiry. +// Intended for use in tests only. +func (tm *TokenManager) SetToken(token string, expiresAt time.Time) { + tm.mu.Lock() + defer tm.mu.Unlock() + tm.accessToken = token + tm.expiresAt = expiresAt +} + +// RefreshToken forces a token refresh. +func (tm *TokenManager) RefreshToken() (string, error) { + tm.mu.Lock() + defer tm.mu.Unlock() + + tm.accessToken = "" + tm.expiresAt = time.Time{} + return tm.fetchToken() +} + +func (tm *TokenManager) fetchToken() (string, error) { + credentials := base64.StdEncoding.EncodeToString( + []byte(tm.clientID + ":" + tm.clientSecret), + ) + + data := url.Values{} + data.Set("grantType", "client_credentials") + + req, err := http.NewRequest("POST", IAMTokenURL, strings.NewReader(data.Encode())) + if err != nil { + return "", fmt.Errorf("failed to create token request: %w", err) + } + + req.Header.Set("Authorization", "Basic "+credentials) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := tm.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("IAM authentication request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read token response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("IAM authentication error (HTTP %d): %s", resp.StatusCode, string(body)) + } + + var tokenResp tokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return "", fmt.Errorf("failed to parse token response: %w", err) + } + + tm.accessToken = tokenResp.AccessToken + expiresIn := tokenResp.ExpiresIn + if expiresIn == 0 { + expiresIn = 1800 + } + // Refresh 60 seconds before expiry + tm.expiresAt = time.Now().Add(time.Duration(expiresIn-60) * time.Second) + + return tm.accessToken, nil +} 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 066785d..b8a3691 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..715b2b2 --- /dev/null +++ b/go/internal/vdbclient/client.go @@ -0,0 +1,122 @@ +// 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) +} + +// 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 c08ddc5..9245406 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -229,6 +229,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 From 0f3bf8445a2bc761c25b4c4f32f0a551d93ad9e5 Mon Sep 17 00:00:00 2001 From: nhontt Date: Mon, 14 Sep 2026 15:33:34 +0700 Subject: [PATCH 2/8] merge from main --- .github/workflows/release.yml | 56 +- .github/workflows/run-tests.yml | 46 + .gitignore | 1 - .release-please-manifest.json | 2 +- CHANGELOG.md | 7 + CLAUDE.md | 55 +- README.md | 35 +- RELEASING.md | 15 + .../agent-id-create.md} | 10 +- .../agent-id-delete.md} | 8 +- .../agent-id-get.md} | 8 +- .../agent-id-list.md} | 10 +- .../agent-id-update.md} | 8 +- .../agent-id-use.md} | 6 +- docs/commands/agentbase/access/index.md | 53 + .../outbound-auth-delegated-create.md | 6 +- .../outbound-auth-delegated-delete.md | 6 +- .../outbound-auth-delegated-get-key.md | 8 +- .../outbound-auth-delegated-get.md | 8 +- .../outbound-auth-delegated-list.md | 8 +- .../outbound-auth-oauth2-3lo-token.md | 8 +- .../outbound-auth-oauth2-create.md | 6 +- .../outbound-auth-oauth2-delete.md | 6 +- .../outbound-auth-oauth2-get.md | 8 +- .../outbound-auth-oauth2-list.md | 8 +- .../outbound-auth-oauth2-m2m-token.md | 8 +- .../outbound-auth-oauth2-update.md | 6 +- .../outbound-auth-static-create.md | 8 +- .../outbound-auth-static-delete.md | 6 +- .../outbound-auth-static-get-key.md | 8 +- .../outbound-auth-static-get.md | 8 +- .../outbound-auth-static-list.md | 8 +- .../outbound-auth-static-update.md | 6 +- docs/commands/agentbase/context/current.md | 2 + docs/commands/agentbase/cr/index.md | 2 +- docs/commands/agentbase/deploy/index.md | 2 +- docs/commands/agentbase/gateway/index.md | 2 +- docs/commands/agentbase/identity/index.md | 53 - docs/commands/agentbase/index.md | 64 +- .../{catalog => marketplace}/flavors-list.md | 6 +- .../{catalog => marketplace}/index.md | 8 +- .../openclaw-create.md | 8 +- .../openclaw-delete.md | 6 +- .../{catalog => marketplace}/openclaw-get.md | 6 +- .../{catalog => marketplace}/openclaw-list.md | 6 +- .../openclaw-start.md | 6 +- .../{catalog => marketplace}/openclaw-stop.md | 6 +- .../openclaw-update-version.md | 6 +- .../openclaw-versions-list.md | 6 +- docs/commands/agentbase/policy/decide.md | 2 +- docs/commands/agentbase/policy/index.md | 4 +- docs/configuration.md | 16 +- docs/development/architecture.md | 34 +- docs/index.md | 11 +- docs/installation.md | 79 +- docs/usage/global-options.md | 6 +- go/cmd/agentbase/{identity.go => access.go} | 14 +- go/cmd/agentbase/agentbase.go | 9 +- go/cmd/agentbase/agentbase_test.go | 68 +- go/cmd/agentbase/catalog.go | 15 +- go/cmd/agentbase/cr.go | 2 +- go/cmd/agentbase/gateway.go | 2 +- go/cmd/agentbase/helpers.go | 103 +- go/cmd/agentbase/helpers_test.go | 98 + go/cmd/agentbase/memory.go | 5 +- go/cmd/agentbase/policy.go | 8 +- go/cmd/agentbase/runtime.go | 4 +- go/cmd/configure/list.go | 6 +- go/cmd/configure/set.go | 2 +- go/cmd/configure/set_test.go | 2 +- go/cmd/root.go | 2 +- go/internal/agentbase/catalog/models.go | 2 +- go/internal/agentbase/client/client.go | 77 +- go/internal/agentbase/client/client_test.go | 103 +- go/internal/auth/login_token.go | 15 +- go/internal/auth/login_token_test.go | 41 + go/internal/config/config.go | 4 +- go/internal/config/loadconfig_test.go | 29 +- go/internal/config/writer.go | 19 +- go/internal/config/writer_agent_test.go | 6 +- go/internal/config/writer_credentials_test.go | 64 + go/internal/config/writer_login_test.go | 4 +- mkdocs.yml | 74 +- scripts/install | 48 - scripts/install.cmd | 96 + scripts/install.ps1 | 119 + scripts/install.sh | 100 + scripts/make-bundle | 58 - scripts/uninstall.cmd | 50 + scripts/uninstall.ps1 | 41 + scripts/uninstall.sh | 75 + site/404.html | 6970 ++++++++++++ site/assets/images/favicon.png | Bin 0 -> 1870 bytes .../assets/javascripts/bundle.d7400e89.min.js | 16 + .../javascripts/bundle.d7400e89.min.js.map | 7 + .../javascripts/lunr/min/lunr.ar.min.js | 1 + .../javascripts/lunr/min/lunr.da.min.js | 18 + .../javascripts/lunr/min/lunr.de.min.js | 18 + .../javascripts/lunr/min/lunr.du.min.js | 18 + .../javascripts/lunr/min/lunr.el.min.js | 1 + .../javascripts/lunr/min/lunr.es.min.js | 18 + .../javascripts/lunr/min/lunr.fi.min.js | 18 + .../javascripts/lunr/min/lunr.fr.min.js | 18 + .../javascripts/lunr/min/lunr.he.min.js | 1 + .../javascripts/lunr/min/lunr.hi.min.js | 1 + .../javascripts/lunr/min/lunr.hu.min.js | 18 + .../javascripts/lunr/min/lunr.hy.min.js | 1 + .../javascripts/lunr/min/lunr.it.min.js | 18 + .../javascripts/lunr/min/lunr.ja.min.js | 1 + .../javascripts/lunr/min/lunr.jp.min.js | 1 + .../javascripts/lunr/min/lunr.kn.min.js | 1 + .../javascripts/lunr/min/lunr.ko.min.js | 1 + .../javascripts/lunr/min/lunr.multi.min.js | 1 + .../javascripts/lunr/min/lunr.nl.min.js | 18 + .../javascripts/lunr/min/lunr.no.min.js | 18 + .../javascripts/lunr/min/lunr.pt.min.js | 18 + .../javascripts/lunr/min/lunr.ro.min.js | 18 + .../javascripts/lunr/min/lunr.ru.min.js | 18 + .../javascripts/lunr/min/lunr.sa.min.js | 1 + .../lunr/min/lunr.stemmer.support.min.js | 1 + .../javascripts/lunr/min/lunr.sv.min.js | 18 + .../javascripts/lunr/min/lunr.ta.min.js | 1 + .../javascripts/lunr/min/lunr.te.min.js | 1 + .../javascripts/lunr/min/lunr.th.min.js | 1 + .../javascripts/lunr/min/lunr.tr.min.js | 18 + .../javascripts/lunr/min/lunr.vi.min.js | 1 + .../javascripts/lunr/min/lunr.zh.min.js | 1 + site/assets/javascripts/lunr/tinyseg.js | 206 + site/assets/javascripts/lunr/wordcut.js | 6708 ++++++++++++ .../workers/search.2c215733.min.js | 42 + .../workers/search.2c215733.min.js.map | 7 + site/assets/logo.png | Bin 0 -> 214404 bytes site/assets/stylesheets/main.ec1eaa64.min.css | 1 + .../stylesheets/main.ec1eaa64.min.css.map | 1 + .../stylesheets/palette.ab4e12ef.min.css | 1 + .../stylesheets/palette.ab4e12ef.min.css.map | 1 + site/changelog/index.html | 7011 ++++++++++++ .../access/agent-id-create/index.html | 7228 +++++++++++++ .../access/agent-id-delete/index.html | 7264 +++++++++++++ .../agentbase/access/agent-id-get/index.html | 7189 +++++++++++++ .../agentbase/access/agent-id-list/index.html | 7205 +++++++++++++ .../access/agent-id-update/index.html | 7205 +++++++++++++ .../agentbase/access/agent-id-use/index.html | 7186 +++++++++++++ site/commands/agentbase/access/index.html | 7323 +++++++++++++ .../outbound-auth-delegated-create/index.html | 7194 +++++++++++++ .../outbound-auth-delegated-delete/index.html | 7186 +++++++++++++ .../index.html | 7260 +++++++++++++ .../outbound-auth-delegated-get/index.html | 7189 +++++++++++++ .../outbound-auth-delegated-list/index.html | 7202 +++++++++++++ .../outbound-auth-oauth2-3lo-token/index.html | 7277 +++++++++++++ .../outbound-auth-oauth2-create/index.html | 7226 +++++++++++++ .../outbound-auth-oauth2-delete/index.html | 7186 +++++++++++++ .../outbound-auth-oauth2-get/index.html | 7189 +++++++++++++ .../outbound-auth-oauth2-list/index.html | 7202 +++++++++++++ .../outbound-auth-oauth2-m2m-token/index.html | 7229 +++++++++++++ .../outbound-auth-oauth2-update/index.html | 7214 +++++++++++++ .../outbound-auth-static-create/index.html | 7205 +++++++++++++ .../outbound-auth-static-delete/index.html | 7186 +++++++++++++ .../outbound-auth-static-get-key/index.html | 7218 +++++++++++++ .../outbound-auth-static-get/index.html | 7189 +++++++++++++ .../outbound-auth-static-list/index.html | 7202 +++++++++++++ .../outbound-auth-static-update/index.html | 7192 +++++++++++++ .../agentbase/context/current/index.html | 7187 +++++++++++++ .../agentbase/context/decorators/index.html | 7186 +++++++++++++ .../agentbase/context/headers/index.html | 7186 +++++++++++++ site/commands/agentbase/context/index.html | 7105 ++++++++++++ site/commands/agentbase/cr/index.html | 7196 +++++++++++++ .../cr/registry-credential-get/index.html | 7191 +++++++++++++ .../index.html | 7187 +++++++++++++ .../cr/repository-artifact-delete/index.html | 7203 +++++++++++++ .../cr/repository-artifact-list/index.html | 7214 +++++++++++++ .../agentbase/cr/repository-get/index.html | 7190 +++++++++++++ .../cr/repository-image-delete/index.html | 7194 +++++++++++++ .../cr/repository-image-list/index.html | 7211 +++++++++++++ .../agentbase/deploy/destroy/index.html | 7216 +++++++++++++ .../agentbase/deploy/generate/index.html | 7190 +++++++++++++ site/commands/agentbase/deploy/index.html | 7167 +++++++++++++ .../agentbase/deploy/status/index.html | 7196 +++++++++++++ site/commands/agentbase/deploy/up/index.html | 7326 +++++++++++++ .../gateway/access-logs-list/index.html | 7224 +++++++++++++ .../gateway/access-logs-stats/index.html | 7220 +++++++++++++ .../agentbase/gateway/create/index.html | 7373 +++++++++++++ .../agentbase/gateway/delete/index.html | 7219 +++++++++++++ .../agentbase/gateway/flavors-list/index.html | 7193 +++++++++++++ .../agentbase/gateway/generate/index.html | 7199 +++++++++++++ .../commands/agentbase/gateway/get/index.html | 7219 +++++++++++++ .../inbound-auth-jwt-idp-app-clear/index.html | 7211 +++++++++++++ .../inbound-auth-jwt-idp-app-set/index.html | 7219 +++++++++++++ site/commands/agentbase/gateway/index.html | 7213 +++++++++++++ .../agentbase/gateway/list/index.html | 7205 +++++++++++++ .../private-network-routes-get/index.html | 7212 +++++++++++++ .../private-network-routes-set/index.html | 7219 +++++++++++++ .../gateway/service-account-repair/index.html | 7213 +++++++++++++ .../agentbase/gateway/update/index.html | 7277 +++++++++++++ .../agentbase/gateway/wait/index.html | 7260 +++++++++++++ site/commands/agentbase/index.html | 7497 +++++++++++++ .../marketplace/flavors-list/index.html | 7194 +++++++++++++ .../commands/agentbase/marketplace/index.html | 7185 +++++++++++++ .../marketplace/openclaw-create/index.html | 7224 +++++++++++++ .../marketplace/openclaw-delete/index.html | 7215 +++++++++++++ .../marketplace/openclaw-get/index.html | 7215 +++++++++++++ .../marketplace/openclaw-list/index.html | 7200 +++++++++++++ .../marketplace/openclaw-start/index.html | 7215 +++++++++++++ .../marketplace/openclaw-stop/index.html | 7215 +++++++++++++ .../openclaw-update-version/index.html | 7220 +++++++++++++ .../openclaw-versions-list/index.html | 7188 +++++++++++++ .../agentbase/memory/actor-list/index.html | 7215 +++++++++++++ .../agentbase/memory/create/index.html | 7259 +++++++++++++ .../agentbase/memory/delete/index.html | 7190 +++++++++++++ .../agentbase/memory/event-create/index.html | 7223 +++++++++++++ .../agentbase/memory/event-delete/index.html | 7213 +++++++++++++ .../agentbase/memory/event-list/index.html | 7222 +++++++++++++ .../agentbase/memory/generate/index.html | 7190 +++++++++++++ site/commands/agentbase/memory/get/index.html | 7190 +++++++++++++ site/commands/agentbase/memory/index.html | 7171 +++++++++++++ .../commands/agentbase/memory/list/index.html | 7205 +++++++++++++ .../agentbase/memory/record-delete/index.html | 7213 +++++++++++++ .../record-generate-from-content/index.html | 7221 +++++++++++++ .../record-generate-from-session/index.html | 7216 +++++++++++++ .../agentbase/memory/record-insert/index.html | 7214 +++++++++++++ .../agentbase/memory/record-list/index.html | 7213 +++++++++++++ .../agentbase/memory/search/index.html | 7228 +++++++++++++ .../agentbase/memory/session-list/index.html | 7215 +++++++++++++ .../agentbase/memory/strategy-list/index.html | 7212 +++++++++++++ .../policy/condition-operators/index.html | 7192 +++++++++++++ .../agentbase/policy/decide/index.html | 7250 +++++++++++++ .../agentbase/policy/group-create/index.html | 7217 +++++++++++++ .../agentbase/policy/group-delete/index.html | 7191 +++++++++++++ .../policy/group-generate/index.html | 7192 +++++++++++++ .../agentbase/policy/group-get/index.html | 7191 +++++++++++++ .../agentbase/policy/group-list/index.html | 7216 +++++++++++++ .../policy/group-policy-create/index.html | 7262 +++++++++++++ .../policy/group-policy-delete/index.html | 7191 +++++++++++++ .../policy/group-policy-generate/index.html | 7195 +++++++++++++ .../policy/group-policy-get/index.html | 7193 +++++++++++++ .../policy/group-policy-list/index.html | 7220 +++++++++++++ .../policy/group-policy-update/index.html | 7258 +++++++++++++ .../agentbase/policy/group-update/index.html | 7214 +++++++++++++ site/commands/agentbase/policy/index.html | 7256 +++++++++++++ .../agentbase/runtime/create/index.html | 7321 +++++++++++++ .../agentbase/runtime/delete/index.html | 7193 +++++++++++++ .../runtime/endpoint-create/index.html | 7212 +++++++++++++ .../runtime/endpoint-delete/index.html | 7213 +++++++++++++ .../runtime/endpoint-events/index.html | 7212 +++++++++++++ .../runtime/endpoint-list/index.html | 7215 +++++++++++++ .../runtime/endpoint-logs/index.html | 7220 +++++++++++++ .../runtime/endpoint-metrics/index.html | 7216 +++++++++++++ .../runtime/endpoint-start/index.html | 7211 +++++++++++++ .../runtime/endpoint-stop/index.html | 7211 +++++++++++++ .../runtime/endpoint-update/index.html | 7214 +++++++++++++ .../agentbase/runtime/generate/index.html | 7193 +++++++++++++ .../commands/agentbase/runtime/get/index.html | 7192 +++++++++++++ site/commands/agentbase/runtime/index.html | 7175 +++++++++++++ .../agentbase/runtime/list/index.html | 7206 +++++++++++++ .../agentbase/runtime/logs/index.html | 7219 +++++++++++++ .../runtime/reset-service-account/index.html | 7213 +++++++++++++ .../agentbase/runtime/trace-get/index.html | 7214 +++++++++++++ .../agentbase/runtime/trace-search/index.html | 7213 +++++++++++++ .../runtime/trace-tag-values/index.html | 7191 +++++++++++++ .../agentbase/runtime/update/index.html | 7310 +++++++++++++ .../agentbase/runtime/versions/index.html | 7215 +++++++++++++ .../agentbase/runtime/wait/index.html | 7213 +++++++++++++ .../vks/config-auto-healing/index.html | 7250 +++++++++++++ .../vks/config-auto-upgrade/index.html | 7227 +++++++++++++ site/commands/vks/create-cluster/index.html | 7366 +++++++++++++ site/commands/vks/create-nodegroup/index.html | 7391 +++++++++++++ .../vks/delete-auto-upgrade-config/index.html | 7212 +++++++++++++ site/commands/vks/delete-cluster/index.html | 7211 +++++++++++++ site/commands/vks/delete-nodegroup/index.html | 7235 +++++++++++++ .../vks/generate-kubeconfig/index.html | 7216 +++++++++++++ .../vks/get-cluster-events/index.html | 7225 +++++++++++++ site/commands/vks/get-cluster/index.html | 7192 +++++++++++++ site/commands/vks/get-nodegroup/index.html | 7205 +++++++++++++ site/commands/vks/get-quota/index.html | 7194 +++++++++++++ site/commands/vks/index.html | 7480 +++++++++++++ .../vks/list-cluster-versions/index.html | 7183 +++++++++++++ site/commands/vks/list-clusters/index.html | 7211 +++++++++++++ site/commands/vks/list-nodegroups/index.html | 7220 +++++++++++++ site/commands/vks/list-nodes/index.html | 7213 +++++++++++++ site/commands/vks/update-cluster/index.html | 7238 +++++++++++++ .../commands/vks/update-kubeconfig/index.html | 7235 +++++++++++++ .../vks/update-nodegroup-metadata/index.html | 7242 +++++++++++++ site/commands/vks/update-nodegroup/index.html | 7272 +++++++++++++ .../vks/upgrade-nodegroup-version/index.html | 7217 +++++++++++++ site/commands/vks/wait/index.html | 7599 +++++++++++++ site/commands/vserver/dhcp/index.html | 7839 ++++++++++++++ site/commands/vserver/flavor/index.html | 7450 +++++++++++++ site/commands/vserver/floating-ip/index.html | 7343 +++++++++++++ site/commands/vserver/image/index.html | 7231 +++++++++++++ site/commands/vserver/index.html | 7261 +++++++++++++ .../vserver/network-interface/index.html | 7728 +++++++++++++ .../vserver/placement-group/index.html | 7726 +++++++++++++ site/commands/vserver/secgroup/index.html | 8117 ++++++++++++++ site/commands/vserver/server/index.html | 9537 +++++++++++++++++ site/commands/vserver/sshkey/index.html | 7592 +++++++++++++ site/commands/vserver/subnet/index.html | 7600 +++++++++++++ site/commands/vserver/user-image/index.html | 7469 +++++++++++++ site/commands/vserver/volume-type/index.html | 7223 +++++++++++++ site/commands/vserver/volume/index.html | 7751 ++++++++++++++ site/commands/vserver/vpc/index.html | 7594 +++++++++++++ site/configuration/index.html | 7598 +++++++++++++ site/development/architecture/index.html | 7342 +++++++++++++ site/development/contributing/index.html | 7182 +++++++++++++ site/development/release/index.html | 7218 +++++++++++++ site/index.html | 7156 +++++++++++++ site/installation/index.html | 7263 +++++++++++++ site/search/search_index.json | 6789 ++++++++++++ site/sitemap.xml | 715 ++ site/sitemap.xml.gz | Bin 0 -> 1348 bytes .../index.html | 8073 ++++++++++++++ .../index.html | 7449 +++++++++++++ site/usage/dry-run/index.html | 7240 +++++++++++++ site/usage/getting-started/index.html | 7162 +++++++++++++ site/usage/global-options/index.html | 7203 +++++++++++++ site/usage/output/index.html | 7218 +++++++++++++ site/usage/pagination/index.html | 7120 ++++++++++++ site/usage/shell-completion/index.html | 7240 +++++++++++++ tests/install.test.cmd | 64 + tests/install.test.ps1 | 164 + tests/install.test.sh | 108 + tests/release.test.sh | 43 + tests/uninstall.test.cmd | 68 + tests/uninstall.test.ps1 | 74 + tests/uninstall.test.sh | 70 + 324 files changed, 1317914 insertions(+), 529 deletions(-) create mode 100644 RELEASING.md rename docs/commands/agentbase/{identity/workload-create.md => access/agent-id-create.md} (89%) rename docs/commands/agentbase/{identity/workload-delete.md => access/agent-id-delete.md} (73%) rename docs/commands/agentbase/{identity/workload-get.md => access/agent-id-get.md} (78%) rename docs/commands/agentbase/{identity/workload-list.md => access/agent-id-list.md} (81%) rename docs/commands/agentbase/{identity/workload-update.md => access/agent-id-update.md} (84%) rename docs/commands/agentbase/{identity/workload-use.md => access/agent-id-use.md} (85%) create mode 100644 docs/commands/agentbase/access/index.md rename docs/commands/agentbase/{identity => access}/outbound-auth-delegated-create.md (83%) rename docs/commands/agentbase/{identity => access}/outbound-auth-delegated-delete.md (76%) rename docs/commands/agentbase/{identity => access}/outbound-auth-delegated-get-key.md (89%) rename docs/commands/agentbase/{identity => access}/outbound-auth-delegated-get.md (71%) rename docs/commands/agentbase/{identity => access}/outbound-auth-delegated-list.md (78%) rename docs/commands/agentbase/{identity => access}/outbound-auth-oauth2-3lo-token.md (92%) rename docs/commands/agentbase/{identity => access}/outbound-auth-oauth2-create.md (93%) rename docs/commands/agentbase/{identity => access}/outbound-auth-oauth2-delete.md (77%) rename docs/commands/agentbase/{identity => access}/outbound-auth-oauth2-get.md (72%) rename docs/commands/agentbase/{identity => access}/outbound-auth-oauth2-list.md (79%) rename docs/commands/agentbase/{identity => access}/outbound-auth-oauth2-m2m-token.md (85%) rename docs/commands/agentbase/{identity => access}/outbound-auth-oauth2-update.md (90%) rename docs/commands/agentbase/{identity => access}/outbound-auth-static-create.md (84%) rename docs/commands/agentbase/{identity => access}/outbound-auth-static-delete.md (77%) rename docs/commands/agentbase/{identity => access}/outbound-auth-static-get-key.md (77%) rename docs/commands/agentbase/{identity => access}/outbound-auth-static-get.md (72%) rename docs/commands/agentbase/{identity => access}/outbound-auth-static-list.md (79%) rename docs/commands/agentbase/{identity => access}/outbound-auth-static-update.md (82%) delete mode 100644 docs/commands/agentbase/identity/index.md rename docs/commands/agentbase/{catalog => marketplace}/flavors-list.md (87%) rename docs/commands/agentbase/{catalog => marketplace}/index.md (87%) rename docs/commands/agentbase/{catalog => marketplace}/openclaw-create.md (86%) rename docs/commands/agentbase/{catalog => marketplace}/openclaw-delete.md (85%) rename docs/commands/agentbase/{catalog => marketplace}/openclaw-get.md (85%) rename docs/commands/agentbase/{catalog => marketplace}/openclaw-list.md (86%) rename docs/commands/agentbase/{catalog => marketplace}/openclaw-start.md (84%) rename docs/commands/agentbase/{catalog => marketplace}/openclaw-stop.md (84%) rename docs/commands/agentbase/{catalog => marketplace}/openclaw-update-version.md (81%) rename docs/commands/agentbase/{catalog => marketplace}/openclaw-versions-list.md (82%) rename go/cmd/agentbase/{identity.go => access.go} (98%) create mode 100644 go/internal/config/writer_credentials_test.go delete mode 100755 scripts/install create mode 100644 scripts/install.cmd create mode 100644 scripts/install.ps1 create mode 100755 scripts/install.sh delete mode 100755 scripts/make-bundle create mode 100644 scripts/uninstall.cmd create mode 100644 scripts/uninstall.ps1 create mode 100755 scripts/uninstall.sh create mode 100644 site/404.html create mode 100644 site/assets/images/favicon.png create mode 100644 site/assets/javascripts/bundle.d7400e89.min.js create mode 100644 site/assets/javascripts/bundle.d7400e89.min.js.map create mode 100644 site/assets/javascripts/lunr/min/lunr.ar.min.js create mode 100644 site/assets/javascripts/lunr/min/lunr.da.min.js create mode 100644 site/assets/javascripts/lunr/min/lunr.de.min.js create mode 100644 site/assets/javascripts/lunr/min/lunr.du.min.js create mode 100644 site/assets/javascripts/lunr/min/lunr.el.min.js create mode 100644 site/assets/javascripts/lunr/min/lunr.es.min.js create mode 100644 site/assets/javascripts/lunr/min/lunr.fi.min.js create mode 100644 site/assets/javascripts/lunr/min/lunr.fr.min.js create mode 100644 site/assets/javascripts/lunr/min/lunr.he.min.js create mode 100644 site/assets/javascripts/lunr/min/lunr.hi.min.js create mode 100644 site/assets/javascripts/lunr/min/lunr.hu.min.js create mode 100644 site/assets/javascripts/lunr/min/lunr.hy.min.js create mode 100644 site/assets/javascripts/lunr/min/lunr.it.min.js create mode 100644 site/assets/javascripts/lunr/min/lunr.ja.min.js create mode 100644 site/assets/javascripts/lunr/min/lunr.jp.min.js create mode 100644 site/assets/javascripts/lunr/min/lunr.kn.min.js create mode 100644 site/assets/javascripts/lunr/min/lunr.ko.min.js create mode 100644 site/assets/javascripts/lunr/min/lunr.multi.min.js create mode 100644 site/assets/javascripts/lunr/min/lunr.nl.min.js create mode 100644 site/assets/javascripts/lunr/min/lunr.no.min.js create mode 100644 site/assets/javascripts/lunr/min/lunr.pt.min.js create mode 100644 site/assets/javascripts/lunr/min/lunr.ro.min.js create mode 100644 site/assets/javascripts/lunr/min/lunr.ru.min.js create mode 100644 site/assets/javascripts/lunr/min/lunr.sa.min.js create mode 100644 site/assets/javascripts/lunr/min/lunr.stemmer.support.min.js create mode 100644 site/assets/javascripts/lunr/min/lunr.sv.min.js create mode 100644 site/assets/javascripts/lunr/min/lunr.ta.min.js create mode 100644 site/assets/javascripts/lunr/min/lunr.te.min.js create mode 100644 site/assets/javascripts/lunr/min/lunr.th.min.js create mode 100644 site/assets/javascripts/lunr/min/lunr.tr.min.js create mode 100644 site/assets/javascripts/lunr/min/lunr.vi.min.js create mode 100644 site/assets/javascripts/lunr/min/lunr.zh.min.js create mode 100644 site/assets/javascripts/lunr/tinyseg.js create mode 100644 site/assets/javascripts/lunr/wordcut.js create mode 100644 site/assets/javascripts/workers/search.2c215733.min.js create mode 100644 site/assets/javascripts/workers/search.2c215733.min.js.map create mode 100644 site/assets/logo.png create mode 100644 site/assets/stylesheets/main.ec1eaa64.min.css create mode 100644 site/assets/stylesheets/main.ec1eaa64.min.css.map create mode 100644 site/assets/stylesheets/palette.ab4e12ef.min.css create mode 100644 site/assets/stylesheets/palette.ab4e12ef.min.css.map create mode 100644 site/changelog/index.html create mode 100644 site/commands/agentbase/access/agent-id-create/index.html create mode 100644 site/commands/agentbase/access/agent-id-delete/index.html create mode 100644 site/commands/agentbase/access/agent-id-get/index.html create mode 100644 site/commands/agentbase/access/agent-id-list/index.html create mode 100644 site/commands/agentbase/access/agent-id-update/index.html create mode 100644 site/commands/agentbase/access/agent-id-use/index.html create mode 100644 site/commands/agentbase/access/index.html create mode 100644 site/commands/agentbase/access/outbound-auth-delegated-create/index.html create mode 100644 site/commands/agentbase/access/outbound-auth-delegated-delete/index.html create mode 100644 site/commands/agentbase/access/outbound-auth-delegated-get-key/index.html create mode 100644 site/commands/agentbase/access/outbound-auth-delegated-get/index.html create mode 100644 site/commands/agentbase/access/outbound-auth-delegated-list/index.html create mode 100644 site/commands/agentbase/access/outbound-auth-oauth2-3lo-token/index.html create mode 100644 site/commands/agentbase/access/outbound-auth-oauth2-create/index.html create mode 100644 site/commands/agentbase/access/outbound-auth-oauth2-delete/index.html create mode 100644 site/commands/agentbase/access/outbound-auth-oauth2-get/index.html create mode 100644 site/commands/agentbase/access/outbound-auth-oauth2-list/index.html create mode 100644 site/commands/agentbase/access/outbound-auth-oauth2-m2m-token/index.html create mode 100644 site/commands/agentbase/access/outbound-auth-oauth2-update/index.html create mode 100644 site/commands/agentbase/access/outbound-auth-static-create/index.html create mode 100644 site/commands/agentbase/access/outbound-auth-static-delete/index.html create mode 100644 site/commands/agentbase/access/outbound-auth-static-get-key/index.html create mode 100644 site/commands/agentbase/access/outbound-auth-static-get/index.html create mode 100644 site/commands/agentbase/access/outbound-auth-static-list/index.html create mode 100644 site/commands/agentbase/access/outbound-auth-static-update/index.html create mode 100644 site/commands/agentbase/context/current/index.html create mode 100644 site/commands/agentbase/context/decorators/index.html create mode 100644 site/commands/agentbase/context/headers/index.html create mode 100644 site/commands/agentbase/context/index.html create mode 100644 site/commands/agentbase/cr/index.html create mode 100644 site/commands/agentbase/cr/registry-credential-get/index.html create mode 100644 site/commands/agentbase/cr/registry-credential-reset-secret/index.html create mode 100644 site/commands/agentbase/cr/repository-artifact-delete/index.html create mode 100644 site/commands/agentbase/cr/repository-artifact-list/index.html create mode 100644 site/commands/agentbase/cr/repository-get/index.html create mode 100644 site/commands/agentbase/cr/repository-image-delete/index.html create mode 100644 site/commands/agentbase/cr/repository-image-list/index.html create mode 100644 site/commands/agentbase/deploy/destroy/index.html create mode 100644 site/commands/agentbase/deploy/generate/index.html create mode 100644 site/commands/agentbase/deploy/index.html create mode 100644 site/commands/agentbase/deploy/status/index.html create mode 100644 site/commands/agentbase/deploy/up/index.html create mode 100644 site/commands/agentbase/gateway/access-logs-list/index.html create mode 100644 site/commands/agentbase/gateway/access-logs-stats/index.html create mode 100644 site/commands/agentbase/gateway/create/index.html create mode 100644 site/commands/agentbase/gateway/delete/index.html create mode 100644 site/commands/agentbase/gateway/flavors-list/index.html create mode 100644 site/commands/agentbase/gateway/generate/index.html create mode 100644 site/commands/agentbase/gateway/get/index.html create mode 100644 site/commands/agentbase/gateway/inbound-auth-jwt-idp-app-clear/index.html create mode 100644 site/commands/agentbase/gateway/inbound-auth-jwt-idp-app-set/index.html create mode 100644 site/commands/agentbase/gateway/index.html create mode 100644 site/commands/agentbase/gateway/list/index.html create mode 100644 site/commands/agentbase/gateway/private-network-routes-get/index.html create mode 100644 site/commands/agentbase/gateway/private-network-routes-set/index.html create mode 100644 site/commands/agentbase/gateway/service-account-repair/index.html create mode 100644 site/commands/agentbase/gateway/update/index.html create mode 100644 site/commands/agentbase/gateway/wait/index.html create mode 100644 site/commands/agentbase/index.html create mode 100644 site/commands/agentbase/marketplace/flavors-list/index.html create mode 100644 site/commands/agentbase/marketplace/index.html create mode 100644 site/commands/agentbase/marketplace/openclaw-create/index.html create mode 100644 site/commands/agentbase/marketplace/openclaw-delete/index.html create mode 100644 site/commands/agentbase/marketplace/openclaw-get/index.html create mode 100644 site/commands/agentbase/marketplace/openclaw-list/index.html create mode 100644 site/commands/agentbase/marketplace/openclaw-start/index.html create mode 100644 site/commands/agentbase/marketplace/openclaw-stop/index.html create mode 100644 site/commands/agentbase/marketplace/openclaw-update-version/index.html create mode 100644 site/commands/agentbase/marketplace/openclaw-versions-list/index.html create mode 100644 site/commands/agentbase/memory/actor-list/index.html create mode 100644 site/commands/agentbase/memory/create/index.html create mode 100644 site/commands/agentbase/memory/delete/index.html create mode 100644 site/commands/agentbase/memory/event-create/index.html create mode 100644 site/commands/agentbase/memory/event-delete/index.html create mode 100644 site/commands/agentbase/memory/event-list/index.html create mode 100644 site/commands/agentbase/memory/generate/index.html create mode 100644 site/commands/agentbase/memory/get/index.html create mode 100644 site/commands/agentbase/memory/index.html create mode 100644 site/commands/agentbase/memory/list/index.html create mode 100644 site/commands/agentbase/memory/record-delete/index.html create mode 100644 site/commands/agentbase/memory/record-generate-from-content/index.html create mode 100644 site/commands/agentbase/memory/record-generate-from-session/index.html create mode 100644 site/commands/agentbase/memory/record-insert/index.html create mode 100644 site/commands/agentbase/memory/record-list/index.html create mode 100644 site/commands/agentbase/memory/search/index.html create mode 100644 site/commands/agentbase/memory/session-list/index.html create mode 100644 site/commands/agentbase/memory/strategy-list/index.html create mode 100644 site/commands/agentbase/policy/condition-operators/index.html create mode 100644 site/commands/agentbase/policy/decide/index.html create mode 100644 site/commands/agentbase/policy/group-create/index.html create mode 100644 site/commands/agentbase/policy/group-delete/index.html create mode 100644 site/commands/agentbase/policy/group-generate/index.html create mode 100644 site/commands/agentbase/policy/group-get/index.html create mode 100644 site/commands/agentbase/policy/group-list/index.html create mode 100644 site/commands/agentbase/policy/group-policy-create/index.html create mode 100644 site/commands/agentbase/policy/group-policy-delete/index.html create mode 100644 site/commands/agentbase/policy/group-policy-generate/index.html create mode 100644 site/commands/agentbase/policy/group-policy-get/index.html create mode 100644 site/commands/agentbase/policy/group-policy-list/index.html create mode 100644 site/commands/agentbase/policy/group-policy-update/index.html create mode 100644 site/commands/agentbase/policy/group-update/index.html create mode 100644 site/commands/agentbase/policy/index.html create mode 100644 site/commands/agentbase/runtime/create/index.html create mode 100644 site/commands/agentbase/runtime/delete/index.html create mode 100644 site/commands/agentbase/runtime/endpoint-create/index.html create mode 100644 site/commands/agentbase/runtime/endpoint-delete/index.html create mode 100644 site/commands/agentbase/runtime/endpoint-events/index.html create mode 100644 site/commands/agentbase/runtime/endpoint-list/index.html create mode 100644 site/commands/agentbase/runtime/endpoint-logs/index.html create mode 100644 site/commands/agentbase/runtime/endpoint-metrics/index.html create mode 100644 site/commands/agentbase/runtime/endpoint-start/index.html create mode 100644 site/commands/agentbase/runtime/endpoint-stop/index.html create mode 100644 site/commands/agentbase/runtime/endpoint-update/index.html create mode 100644 site/commands/agentbase/runtime/generate/index.html create mode 100644 site/commands/agentbase/runtime/get/index.html create mode 100644 site/commands/agentbase/runtime/index.html create mode 100644 site/commands/agentbase/runtime/list/index.html create mode 100644 site/commands/agentbase/runtime/logs/index.html create mode 100644 site/commands/agentbase/runtime/reset-service-account/index.html create mode 100644 site/commands/agentbase/runtime/trace-get/index.html create mode 100644 site/commands/agentbase/runtime/trace-search/index.html create mode 100644 site/commands/agentbase/runtime/trace-tag-values/index.html create mode 100644 site/commands/agentbase/runtime/update/index.html create mode 100644 site/commands/agentbase/runtime/versions/index.html create mode 100644 site/commands/agentbase/runtime/wait/index.html create mode 100644 site/commands/vks/config-auto-healing/index.html create mode 100644 site/commands/vks/config-auto-upgrade/index.html create mode 100644 site/commands/vks/create-cluster/index.html create mode 100644 site/commands/vks/create-nodegroup/index.html create mode 100644 site/commands/vks/delete-auto-upgrade-config/index.html create mode 100644 site/commands/vks/delete-cluster/index.html create mode 100644 site/commands/vks/delete-nodegroup/index.html create mode 100644 site/commands/vks/generate-kubeconfig/index.html create mode 100644 site/commands/vks/get-cluster-events/index.html create mode 100644 site/commands/vks/get-cluster/index.html create mode 100644 site/commands/vks/get-nodegroup/index.html create mode 100644 site/commands/vks/get-quota/index.html create mode 100644 site/commands/vks/index.html create mode 100644 site/commands/vks/list-cluster-versions/index.html create mode 100644 site/commands/vks/list-clusters/index.html create mode 100644 site/commands/vks/list-nodegroups/index.html create mode 100644 site/commands/vks/list-nodes/index.html create mode 100644 site/commands/vks/update-cluster/index.html create mode 100644 site/commands/vks/update-kubeconfig/index.html create mode 100644 site/commands/vks/update-nodegroup-metadata/index.html create mode 100644 site/commands/vks/update-nodegroup/index.html create mode 100644 site/commands/vks/upgrade-nodegroup-version/index.html create mode 100644 site/commands/vks/wait/index.html create mode 100644 site/commands/vserver/dhcp/index.html create mode 100644 site/commands/vserver/flavor/index.html create mode 100644 site/commands/vserver/floating-ip/index.html create mode 100644 site/commands/vserver/image/index.html create mode 100644 site/commands/vserver/index.html create mode 100644 site/commands/vserver/network-interface/index.html create mode 100644 site/commands/vserver/placement-group/index.html create mode 100644 site/commands/vserver/secgroup/index.html create mode 100644 site/commands/vserver/server/index.html create mode 100644 site/commands/vserver/sshkey/index.html create mode 100644 site/commands/vserver/subnet/index.html create mode 100644 site/commands/vserver/user-image/index.html create mode 100644 site/commands/vserver/volume-type/index.html create mode 100644 site/commands/vserver/volume/index.html create mode 100644 site/commands/vserver/vpc/index.html create mode 100644 site/configuration/index.html create mode 100644 site/development/architecture/index.html create mode 100644 site/development/contributing/index.html create mode 100644 site/development/release/index.html create mode 100644 site/index.html create mode 100644 site/installation/index.html create mode 100644 site/search/search_index.json create mode 100644 site/sitemap.xml create mode 100644 site/sitemap.xml.gz create mode 100644 site/superpowers/plans/2026-08-18-agentbase-command-rename/index.html create mode 100644 site/superpowers/specs/2026-08-18-agentbase-command-rename-design/index.html create mode 100644 site/usage/dry-run/index.html create mode 100644 site/usage/getting-started/index.html create mode 100644 site/usage/global-options/index.html create mode 100644 site/usage/output/index.html create mode 100644 site/usage/pagination/index.html create mode 100644 site/usage/shell-completion/index.html create mode 100644 tests/install.test.cmd create mode 100644 tests/install.test.ps1 create mode 100644 tests/install.test.sh create mode 100755 tests/release.test.sh create mode 100644 tests/uninstall.test.cmd create mode 100644 tests/uninstall.test.ps1 create mode 100644 tests/uninstall.test.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b31ca53..67a5e76 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,6 +43,9 @@ jobs: - goos: windows goarch: amd64 suffix: windows-amd64.exe + - goos: windows + goarch: arm64 + suffix: windows-arm64.exe steps: - uses: actions/checkout@v4 @@ -62,11 +65,42 @@ jobs: # vks_only build tag until it is ready to release. run: go build -tags vks_only -o grn-${{ matrix.suffix }} . + - name: Version the binary filename + shell: bash + working-directory: go + env: + SUFFIX: ${{ matrix.suffix }} + CALL_TAG: ${{ inputs.tag }} # workflow_call (release-please) — e.g. v1.12.0 + REF_NAME: ${{ github.ref_name }} # tag push (v1.12.0) or branch (main) + DISPATCH_VER: ${{ inputs.version }} # workflow_dispatch — e.g. 1.12.0 (no v) + run: | + set -euo pipefail + # Precedence mirrors the release job's TAG resolution + workflow_dispatch. + if [ -n "$CALL_TAG" ]; then + VER="${CALL_TAG#v}" + elif [[ "$REF_NAME" == v[0-9]* ]]; then + VER="${REF_NAME#v}" + elif [ -n "$DISPATCH_VER" ]; then + VER="${DISPATCH_VER#v}" + else + echo "WARN: no version source; falling back to 'dev'" >&2 + VER="dev" + fi + BASE="${SUFFIX%.exe}" + EXT="${SUFFIX#"$BASE"}" # ".exe" for windows, "" otherwise + mv "grn-${SUFFIX}" "grn-${BASE}-v${VER}${EXT}" + - name: Upload artifact uses: actions/upload-artifact@v4 with: name: grn-${{ matrix.suffix }} - path: go/grn-${{ matrix.suffix }} + # Glob the versioned binary produced by the rename step. Each build + # leg runs on its own runner, so go/grn-*-v* matches exactly that + # leg's single file (grn--v[.exe]) and mirrors the release + # job's grn-*-v* checksum glob. (A literal grn-${{ matrix.suffix }}-v* + # would fail for the windows rows, whose suffix includes .exe but + # whose versioned name puts .exe at the end.) + path: go/grn-*-v* release: needs: build @@ -78,18 +112,26 @@ jobs: path: dist/ merge-multiple: true - - name: Attach binaries to the release + - name: Generate SHA256SUMS + shell: bash + run: | + cd dist + # Glob only the versioned binaries (grn-*-v*), one sha256 line each. + sha256sum grn-*-v* > SHA256SUMS + echo "Generated SHA256SUMS:" + cat SHA256SUMS + + - name: Attach binaries + SHA256SUMS to the release env: GH_TOKEN: ${{ github.token }} - # tag comes from the workflow_call input; otherwise the pushed tag ref. + # TAG resolution quirk on workflow_dispatch (yields the branch) is + # pre-existing and intentionally preserved here. TAG: ${{ inputs.tag || github.ref_name }} REPO: ${{ github.repository }} run: | set -euo pipefail if gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then - # Release already exists (e.g. created by release-please) — attach binaries. - gh release upload "$TAG" dist/grn-* --clobber --repo "$REPO" + gh release upload "$TAG" dist/grn-* dist/SHA256SUMS --clobber --repo "$REPO" else - # Manual tag push / dispatch — create the release with generated notes. - gh release create "$TAG" dist/grn-* --generate-notes --repo "$REPO" + gh release create "$TAG" dist/grn-* dist/SHA256SUMS --generate-notes --repo "$REPO" fi diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 4837086..eac5dd9 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -60,3 +60,49 @@ jobs: # aborts such binaries ("missing LC_UUID load command"). CGO is enabled # so the system cc (present on both ubuntu and macos runners) links. run: CGO_ENABLED=1 go test -ldflags='-linkmode=external' ./... -v + + installer-tests: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + steps: + - uses: actions/checkout@v4 + - name: Install shellcheck + if: matrix.os == 'ubuntu-latest' + run: sudo apt-get update && sudo apt-get install -y shellcheck + - name: Install shellcheck (macOS) + if: matrix.os == 'macos-latest' + run: brew install shellcheck + - name: shellcheck install.sh + run: shellcheck scripts/install.sh + - name: shellcheck uninstall.sh + run: shellcheck scripts/uninstall.sh + - name: Release-contract test (SHA256SUMS shape) + run: bash tests/release.test.sh + - name: install.sh hermetic test + run: bash tests/install.test.sh + - name: uninstall.sh hermetic test + run: bash tests/uninstall.test.sh + + installer-tests-windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python (for http.server test fixtures) + uses: actions/setup-python@v5 + with: + python-version: '3.x' + - name: install.ps1 smoke test + shell: powershell + run: powershell -ExecutionPolicy Bypass -File tests\install.test.ps1 + - name: install.cmd smoke test + shell: cmd + run: tests\install.test.cmd + - name: uninstall.ps1 smoke test + shell: powershell + run: powershell -ExecutionPolicy Bypass -File tests\uninstall.test.ps1 + - name: uninstall.cmd smoke test + shell: cmd + run: tests\uninstall.test.cmd diff --git a/.gitignore b/.gitignore index 040e376..803e60e 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,3 @@ build/ # Internal design docs (local only, not for public repo) docs/superpowers/ -site/ diff --git a/.release-please-manifest.json b/.release-please-manifest.json index a09efe7..23860f9 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.12.0" + ".": "1.13.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 90676c4..e336faf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.13.0](https://github.com/GreenNodeHub/greennode-cli/compare/v1.12.0...v1.13.0) (2026-09-04) + + +### Features + +* **enhance:** fix login and env vars, add installation by scripts ([#17](https://github.com/GreenNodeHub/greennode-cli/issues/17)) ([bb4805c](https://github.com/GreenNodeHub/greennode-cli/commit/bb4805c0b007aad98f2c11b8092cd5db2dd5728b)) + ## [1.12.0](https://github.com/GreenNodeHub/greennode-cli/compare/v1.11.0...v1.12.0) (2026-08-05) diff --git a/CLAUDE.md b/CLAUDE.md index cc5f03d..79a794e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ go/ │ ├── register.go # Blank-imports all product packages (triggers init()) │ ├── agentbase/ # grn agentbase (default-on; -tags agentbase gate dropped at GA) │ │ ├── agentbase.go # AgentbaseCmd subcommand root (self-registers) -│ │ ├── identity.go # identity group (login/workload/outbound-auth) +│ │ ├── access.go # access group (agent-id/outbound-auth) │ │ ├── context.go # context group (switch/current/headers/decorators) │ │ └── helpers.go # mustLoadConfig / newAuthProvider │ ├── vdb/ # grn vdb (behind !vks_only tag, in development) @@ -109,14 +109,23 @@ go/ │ │ └── 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 +│ │ ├── client/ # bearer-token HTTP client (401 refresh + retry-once, mirrors internal/client) │ │ ├── config/ # ./.greennode.json loader -│ │ ├── identity/ # identity API client + models +│ │ ├── identity/ # access (agent identities) API client + models │ │ ├── cliinput/ # interactive prompts │ │ ├── jsonslice/ # typed JSON slice helper │ │ └── output/ # table/json/id + color + banner │ └── validator/validator.go # ID format validation ├── go.mod, go.sum + +scripts/ +├── install.sh # one-liner installer (macOS / Linux) +├── install.ps1 # one-liner installer (Windows PowerShell) +├── install.cmd # one-liner installer (Windows pure-CMD, PowerShell-less) +├── uninstall.sh # one-liner uninstaller (macOS / Linux) +├── uninstall.ps1 # one-liner uninstaller (Windows PowerShell) +├── uninstall.cmd # one-liner uninstaller (Windows pure-CMD) +└── new-product # scaffold a new product command ``` ## Code conventions @@ -197,18 +206,10 @@ completers with `grn __complete …`. 5. Add `_endpoint` for each region in `internal/config/config.go` REGIONS 6. root.go needs no change — it mounts everything in the registry -Note: `cmd/agentbase` is gated behind the opt-in `agentbase` build tag -(`cmd/register_agentbase.go`), the inverse of the `!vks_only` pattern — it is -excluded from default and release builds while still in development. - -Products still in development register from their own `cmd/register_.go` -behind `//go:build !vks_only`, so they compile into dev and CI builds but not the -public release binary (`-tags vks_only`). `cmd/vdb` follows this. - ## Security rules - **Credential masking**: `configure list` and `configure get` mask client_id/client_secret (last 4 chars only) -- **Credential env vars supported**: `GRN_ACCESS_KEY_ID`/`GRN_SECRET_ACCESS_KEY` override credentials file (highest priority) +- **Credential env vars supported**: `GRN_CLIENT_ID`/`GRN_CLIENT_SECRET` override credentials file (highest priority) - **Input validation**: All cluster-id/nodegroup-id validated via `validator.ValidateID()` before URLs - **SSL default on**: `--no-verify-ssl` prints warning to stderr - **Tokens in memory only**: Never written to disk or logged @@ -229,6 +230,28 @@ GOOS=darwin GOARCH=arm64 go build -o grn-darwin-arm64 . GOOS=windows GOARCH=amd64 go build -o grn-windows-amd64.exe . ``` +## Installation one-liners + +- **macOS / Linux:** `curl -fsSL https://raw.githubusercontent.com/GreenNodeHub/greennode-cli/main/scripts/install.sh | bash` +- **Windows (PowerShell):** `irm https://raw.githubusercontent.com/GreenNodeHub/greennode-cli/main/scripts/install.ps1 | iex` +- **Windows (CMD, no PowerShell):** `curl -fsSL https://raw.githubusercontent.com/GreenNodeHub/greennode-cli/main/scripts/install.cmd -o install.cmd && install.cmd && del install.cmd` + +Installers resolve the latest release tag via GitHub's `/releases/latest` 302, download the versioned binary (`grn--v`) + a GNU-format `SHA256SUMS`, verify SHA-256, and install user-locally (`~/.local/bin/grn` / `%LOCALAPPDATA%\greennode\bin\grn.exe`, no sudo). Honors `GRN_INSTALL_BASE_URL` (self-host/mirror) and `GRN_INSTALL_TAG` (pin a version; used by tests). + +### Uninstall one-liners + +- **macOS / Linux:** `curl -fsSL https://raw.githubusercontent.com/GreenNodeHub/greennode-cli/main/scripts/uninstall.sh | bash` +- **Windows (PowerShell):** `irm https://raw.githubusercontent.com/GreenNodeHub/greennode-cli/main/scripts/uninstall.ps1 | iex` +- **Windows (CMD, no PowerShell):** `curl -fsSL https://raw.githubusercontent.com/GreenNodeHub/greennode-cli/main/scripts/uninstall.cmd -o uninstall.cmd && uninstall.cmd && del uninstall.cmd` + +Removes the binary + PATH entry the installer added. Pass `--purge` (`-Purge` in PowerShell) to also remove config + credentials (`~/.greennode/` / `%USERPROFILE%\.greennode\`). + +## Release artifacts + +Each release (tag `vX.Y.Z`) attaches: +- 6 platform binaries: `grn-{linux,linux,darwin,darwin,windows,windows}-{amd64,arm64,amd64,arm64,amd64,arm64}-v[.exe]` +- `SHA256SUMS`: GNU `sha256sum` format (one line per binary), generated by `release.yml`'s `Generate SHA256SUMS` step. + ## Git workflow - **Do not auto commit/push** — only change source code, user will ask for commit/push @@ -238,6 +261,8 @@ GOOS=windows GOARCH=amd64 go build -o grn-windows-amd64.exe . - **PR titles are Conventional Commits** (`feat:`/`fix:`/`feat!:`) — PRs are squash-merged, so the title becomes the release commit release-please reads - **Release**: merge the `chore: release main` PR → tags `vX.Y.Z` + GitHub Release + binaries +- **Release binaries are version-named** (`grn--vX.Y.Z[.exe]`) and accompanied by a `SHA256SUMS` file (GNU format) — the `scripts/install.*` installers resolve the latest tag, download the versioned binary + checksums, and verify SHA-256 before installing. +- **Pre-release smoke checklist:** see `RELEASING.md` (run the one-liners against the real tagged release before announcing). ## Documentation update rule @@ -281,3 +306,9 @@ Code without docs is not done. | `internal/client/client.go` | HTTP client with retry (3x backoff) + 401 refresh | | `internal/formatter/formatter.go` | JSON/Table/Text + JMESPath | | `internal/validator/validator.go` | ID format validation | +| `scripts/install.sh` | One-liner installer: macOS/Linux (`curl \| bash`) | +| `scripts/install.ps1` | One-liner installer: Windows PowerShell (`irm \| iex`) | +| `scripts/install.cmd` | One-liner installer: Windows pure-CMD fallback | +| `scripts/uninstall.sh` | One-liner uninstaller: macOS/Linux | +| `scripts/uninstall.ps1` | One-liner uninstaller: Windows PowerShell | +| `scripts/uninstall.cmd` | One-liner uninstaller: Windows pure-CMD fallback | diff --git a/README.md b/README.md index 9dd5996..724d534 100644 --- a/README.md +++ b/README.md @@ -14,33 +14,40 @@ The GreenNode CLI (`grn`) is a unified tool to manage your GreenNode services fr ### Installation -Download the latest binary for your platform from [GitHub Releases](https://github.com/GreenNodeHub/greennode-cli/releases): - **macOS / Linux:** - ```bash -# Download (replace OS and ARCH as needed) -curl -L -o grn https://github.com/GreenNodeHub/greennode-cli/releases/latest/download/grn-darwin-arm64 -chmod +x grn -sudo mv grn /usr/local/bin/ +curl -fsSL https://raw.githubusercontent.com/GreenNodeHub/greennode-cli/main/scripts/install.sh | bash ``` -**Or build from source:** +**Windows (PowerShell):** +```powershell +irm https://raw.githubusercontent.com/GreenNodeHub/greennode-cli/main/scripts/install.ps1 | iex +``` + +**Windows (CMD — for environments without PowerShell):** +```cmd +curl -fsSL https://raw.githubusercontent.com/GreenNodeHub/greennode-cli/main/scripts/install.cmd -o install.cmd && install.cmd && del install.cmd +``` +**Build from source** (fallback): ```bash git clone https://github.com/GreenNodeHub/greennode-cli.git cd greennode-cli/go go build -o grn . -sudo mv grn /usr/local/bin/ +# place grn on your PATH ``` -**Verify installation:** - +Verify: ```bash grn --version -# grn-cli/0.1.0 Go/1.22.2 darwin/arm64 ``` +**Uninstall (macOS / Linux):** +```bash +curl -fsSL https://raw.githubusercontent.com/GreenNodeHub/greennode-cli/main/scripts/uninstall.sh | bash +``` +Windows + the `--purge` option (also removes `~/.greennode` config + credentials) are documented at https://greennodehub.github.io/greennode-cli/installation/#uninstall. + ### Configuration Before using the GreenNode CLI, you need to configure your credentials. There are three ways: @@ -48,8 +55,8 @@ Before using the GreenNode CLI, you need to configure your credentials. There ar **Method 1: Environment variables** ```bash -export GRN_ACCESS_KEY_ID=your-client-id -export GRN_SECRET_ACCESS_KEY=your-client-secret +export GRN_CLIENT_ID=your-client-id +export GRN_CLIENT_SECRET=your-client-secret export GRN_DEFAULT_REGION=HCM-3 export GRN_DEFAULT_PROJECT_ID=pro-xxxxxxxx # optional ``` diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..1b964d4 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,15 @@ +# Releasing GreenNode CLI + +Releases are automated by release-please (merge the `chore: release main` PR → tags `vX.Y.Z` → `release.yml` builds + attaches versioned binaries + `SHA256SUMS`). This checklist covers the **manual pre-release smoke** that the automated hermetic tests cannot run: the real one-liners against a real tagged release. + +After a release tag `vX.Y.Z` exists (with binaries + `SHA256SUMS` attached), run each one-liner on a real machine: + +- [ ] **macOS (arm64):** `curl -fsSL https://raw.githubusercontent.com/GreenNodeHub/greennode-cli/main/scripts/install.sh | bash` → `grn --version` prints `X.Y.Z`. +- [ ] **macOS (x86_64, or under Rosetta):** same one-liner → `grn --version` correct. +- [ ] **Linux (amd64):** same one-liner → `grn --version` correct. +- [ ] **Linux (arm64):** same one-liner → `grn --version` correct. +- [ ] **Windows (PowerShell, amd64):** `irm https://raw.githubusercontent.com/GreenNodeHub/greennode-cli/main/scripts/install.ps1 | iex` → `grn --version` correct. +- [ ] **Windows (CMD, amd64, PowerShell-blocked box if available):** `curl -fsSL https://raw.githubusercontent.com/GreenNodeHub/greennode-cli/main/scripts/install.cmd -o install.cmd && install.cmd && del install.cmd` → `grn --version` correct. +- [ ] **Windows (arm64, if hardware available):** `install.ps1` → `grn --version` correct. + +If a one-liner fails: check the release has 6 binaries + `SHA256SUMS`, the versioned filenames match `grn--vX.Y.Z[.exe]`, and the `SHA256SUMS` line for the platform is present. Do not announce the release until all passing one-liners pass. diff --git a/docs/commands/agentbase/identity/workload-create.md b/docs/commands/agentbase/access/agent-id-create.md similarity index 89% rename from docs/commands/agentbase/identity/workload-create.md rename to docs/commands/agentbase/access/agent-id-create.md index 5f2f31d..b7b6093 100644 --- a/docs/commands/agentbase/identity/workload-create.md +++ b/docs/commands/agentbase/access/agent-id-create.md @@ -1,4 +1,4 @@ -# identity workload create +# access agent-id create Create a new agent identity. @@ -13,7 +13,7 @@ external services. The name must be 3-50 characters and match the pattern ## Synopsis ```text -grn agentbase identity workload create +grn agentbase access agent-id create --name [--set-current] [--description ] @@ -65,13 +65,13 @@ All `grn agentbase` commands accept: Create an agent identity: ```bash -grn agentbase identity workload create --name my-agent +grn agentbase access agent-id create --name my-agent ``` Create one with a description, allowed return URLs, and set it as current: ```bash -grn agentbase identity workload create \ +grn agentbase access agent-id create \ --name my-agent \ --description "Production agent" \ --allowed-return-url https://app.example.com/callback \ @@ -81,5 +81,5 @@ grn agentbase identity workload create \ Create interactively (prompts for the name): ```bash -grn agentbase identity workload create --interactive +grn agentbase access agent-id create --interactive ``` diff --git a/docs/commands/agentbase/identity/workload-delete.md b/docs/commands/agentbase/access/agent-id-delete.md similarity index 73% rename from docs/commands/agentbase/identity/workload-delete.md rename to docs/commands/agentbase/access/agent-id-delete.md index 7ff6522..ac77da0 100644 --- a/docs/commands/agentbase/identity/workload-delete.md +++ b/docs/commands/agentbase/access/agent-id-delete.md @@ -1,15 +1,15 @@ -# identity workload delete +# access agent-id delete Delete an agent identity. ## Description -Delete an agent identity by name. The identity will be soft-deleted. +Delete an agent identity by name. ## Synopsis ```text -grn agentbase identity workload delete +grn agentbase access agent-id delete ``` ## Options @@ -29,5 +29,5 @@ All `grn agentbase` commands accept: Delete an identity: ```bash -grn agentbase identity workload delete my-agent +grn agentbase access agent-id delete my-agent ``` diff --git a/docs/commands/agentbase/identity/workload-get.md b/docs/commands/agentbase/access/agent-id-get.md similarity index 78% rename from docs/commands/agentbase/identity/workload-get.md rename to docs/commands/agentbase/access/agent-id-get.md index 0b0df5c..c5c5d29 100644 --- a/docs/commands/agentbase/identity/workload-get.md +++ b/docs/commands/agentbase/access/agent-id-get.md @@ -1,4 +1,4 @@ -# identity workload get +# access agent-id get Get an agent identity by name. @@ -9,7 +9,7 @@ Retrieve a specific agent identity by its name. ## Synopsis ```text -grn agentbase identity workload get +grn agentbase access agent-id get ``` ## Options @@ -29,11 +29,11 @@ All `grn agentbase` commands accept: Get an identity: ```bash -grn agentbase identity workload get my-agent +grn agentbase access agent-id get my-agent ``` View the full record as JSON: ```bash -grn agentbase identity workload get my-agent -o json +grn agentbase access agent-id get my-agent -o json ``` diff --git a/docs/commands/agentbase/identity/workload-list.md b/docs/commands/agentbase/access/agent-id-list.md similarity index 81% rename from docs/commands/agentbase/identity/workload-list.md rename to docs/commands/agentbase/access/agent-id-list.md index dbae0ed..5d5347c 100644 --- a/docs/commands/agentbase/identity/workload-list.md +++ b/docs/commands/agentbase/access/agent-id-list.md @@ -1,4 +1,4 @@ -# identity workload list +# access agent-id list List agent identities. @@ -9,7 +9,7 @@ Retrieve a paginated list of all agent identities owned by the authenticated use ## Synopsis ```text -grn agentbase identity workload list +grn agentbase access agent-id list [--page ] [--size ] ``` @@ -43,17 +43,17 @@ All `grn agentbase` commands accept: List identities (default page/size): ```bash -grn agentbase identity workload list +grn agentbase access agent-id list ``` List the second page with 50 per page: ```bash -grn agentbase identity workload list --page 2 --size 50 +grn agentbase access agent-id list --page 2 --size 50 ``` Emit IDs only (scripting): ```bash -grn agentbase identity workload list -o id +grn agentbase access agent-id list -o id ``` diff --git a/docs/commands/agentbase/identity/workload-update.md b/docs/commands/agentbase/access/agent-id-update.md similarity index 84% rename from docs/commands/agentbase/identity/workload-update.md rename to docs/commands/agentbase/access/agent-id-update.md index f464544..6202c49 100644 --- a/docs/commands/agentbase/identity/workload-update.md +++ b/docs/commands/agentbase/access/agent-id-update.md @@ -1,4 +1,4 @@ -# identity workload update +# access agent-id update Update an agent identity. @@ -9,7 +9,7 @@ Update an existing agent identity. Only description and allowed return URLs can ## Synopsis ```text -grn agentbase identity workload update +grn agentbase access agent-id update [--description ] [--allowed-return-url ] ``` @@ -44,13 +44,13 @@ All `grn agentbase` commands accept: Update the description: ```bash -grn agentbase identity workload update my-agent --description "Updated description" +grn agentbase access agent-id update my-agent --description "Updated description" ``` Replace the allowed return URLs: ```bash -grn agentbase identity workload update my-agent \ +grn agentbase access agent-id update my-agent \ --allowed-return-url https://app.example.com/callback \ --allowed-return-url https://app.example.com/alt ``` diff --git a/docs/commands/agentbase/identity/workload-use.md b/docs/commands/agentbase/access/agent-id-use.md similarity index 85% rename from docs/commands/agentbase/identity/workload-use.md rename to docs/commands/agentbase/access/agent-id-use.md index 487331b..999eb27 100644 --- a/docs/commands/agentbase/identity/workload-use.md +++ b/docs/commands/agentbase/access/agent-id-use.md @@ -1,4 +1,4 @@ -# identity workload use +# access agent-id use Set the current agent identity. @@ -9,7 +9,7 @@ Set the given agent identity name as the current identity in the shared `~/.gree ## Synopsis ```text -grn agentbase identity workload use +grn agentbase access agent-id use ``` ## Options @@ -29,5 +29,5 @@ All `grn agentbase` commands accept: Set the current agent identity: ```bash -grn agentbase identity workload use my-agent +grn agentbase access agent-id use my-agent ``` diff --git a/docs/commands/agentbase/access/index.md b/docs/commands/agentbase/access/index.md new file mode 100644 index 0000000..e266ac2 --- /dev/null +++ b/docs/commands/agentbase/access/index.md @@ -0,0 +1,53 @@ +# access + +Manage authentication and agent identities. + +```bash +grn agentbase access [options] +``` + +## Available commands + +### Agent ID + +| Command | Description | +|---------|-------------| +| [agent-id create](agent-id-create.md) | Create a new agent identity | +| [agent-id list](agent-id-list.md) | List agent identities | +| [agent-id get](agent-id-get.md) | Get an agent identity by name | +| [agent-id update](agent-id-update.md) | Update an agent identity | +| [agent-id use](agent-id-use.md) | Set the current agent identity | +| [agent-id delete](agent-id-delete.md) | Delete an agent identity | + +### Outbound Auth — Static / API key + +| Command | Description | +|---------|-------------| +| [outbound-auth static create](outbound-auth-static-create.md) | Create a static API key provider | +| [outbound-auth static list](outbound-auth-static-list.md) | List static API key providers | +| [outbound-auth static get](outbound-auth-static-get.md) | Get a static API key provider | +| [outbound-auth static update](outbound-auth-static-update.md) | Update a static API key provider | +| [outbound-auth static delete](outbound-auth-static-delete.md) | Delete a static API key provider | +| [outbound-auth static get-key](outbound-auth-static-get-key.md) | Get the API key for an agent identity | + +### Outbound Auth — Delegated + +| Command | Description | +|---------|-------------| +| [outbound-auth delegated create](outbound-auth-delegated-create.md) | Create a delegated API key provider | +| [outbound-auth delegated list](outbound-auth-delegated-list.md) | List delegated API key providers | +| [outbound-auth delegated get](outbound-auth-delegated-get.md) | Get a delegated API key provider | +| [outbound-auth delegated delete](outbound-auth-delegated-delete.md) | Delete a delegated API key provider | +| [outbound-auth delegated get-key](outbound-auth-delegated-get-key.md) | Obtain a delegated API key for an agent identity | + +### Outbound Auth — OAuth2 + +| Command | Description | +|---------|-------------| +| [outbound-auth oauth2 create](outbound-auth-oauth2-create.md) | Create an OAuth2 provider | +| [outbound-auth oauth2 list](outbound-auth-oauth2-list.md) | List OAuth2 providers | +| [outbound-auth oauth2 get](outbound-auth-oauth2-get.md) | Get an OAuth2 provider | +| [outbound-auth oauth2 update](outbound-auth-oauth2-update.md) | Update an OAuth2 provider | +| [outbound-auth oauth2 delete](outbound-auth-oauth2-delete.md) | Delete an OAuth2 provider | +| [outbound-auth oauth2 m2m-token](outbound-auth-oauth2-m2m-token.md) | Get an M2M OAuth2 token | +| [outbound-auth oauth2 3lo-token](outbound-auth-oauth2-3lo-token.md) | Get a 3-legged OAuth2 token | diff --git a/docs/commands/agentbase/identity/outbound-auth-delegated-create.md b/docs/commands/agentbase/access/outbound-auth-delegated-create.md similarity index 83% rename from docs/commands/agentbase/identity/outbound-auth-delegated-create.md rename to docs/commands/agentbase/access/outbound-auth-delegated-create.md index 7045294..4e8a3d4 100644 --- a/docs/commands/agentbase/identity/outbound-auth-delegated-create.md +++ b/docs/commands/agentbase/access/outbound-auth-delegated-create.md @@ -1,4 +1,4 @@ -# identity outbound-auth delegated create +# access outbound-auth delegated create Create a delegated API key provider. @@ -10,7 +10,7 @@ the pattern `^[a-zA-Z0-9_-]+$`. ## Synopsis ```text -grn agentbase identity outbound-auth delegated create +grn agentbase access outbound-auth delegated create --name ``` @@ -37,5 +37,5 @@ All `grn agentbase` commands accept: Create a delegated API key provider: ```bash -grn agentbase identity outbound-auth delegated create --name my-delegated-provider +grn agentbase access outbound-auth delegated create --name my-delegated-provider ``` diff --git a/docs/commands/agentbase/identity/outbound-auth-delegated-delete.md b/docs/commands/agentbase/access/outbound-auth-delegated-delete.md similarity index 76% rename from docs/commands/agentbase/identity/outbound-auth-delegated-delete.md rename to docs/commands/agentbase/access/outbound-auth-delegated-delete.md index 444ef5e..8141af1 100644 --- a/docs/commands/agentbase/identity/outbound-auth-delegated-delete.md +++ b/docs/commands/agentbase/access/outbound-auth-delegated-delete.md @@ -1,4 +1,4 @@ -# identity outbound-auth delegated delete +# access outbound-auth delegated delete Delete a delegated API key provider. @@ -9,7 +9,7 @@ Delete a delegated API key provider by name. ## Synopsis ```text -grn agentbase identity outbound-auth delegated delete +grn agentbase access outbound-auth delegated delete ``` ## Options @@ -29,5 +29,5 @@ All `grn agentbase` commands accept: Delete a delegated API key provider: ```bash -grn agentbase identity outbound-auth delegated delete my-delegated-provider +grn agentbase access outbound-auth delegated delete my-delegated-provider ``` diff --git a/docs/commands/agentbase/identity/outbound-auth-delegated-get-key.md b/docs/commands/agentbase/access/outbound-auth-delegated-get-key.md similarity index 89% rename from docs/commands/agentbase/identity/outbound-auth-delegated-get-key.md rename to docs/commands/agentbase/access/outbound-auth-delegated-get-key.md index 37a133b..3ee6bb4 100644 --- a/docs/commands/agentbase/identity/outbound-auth-delegated-get-key.md +++ b/docs/commands/agentbase/access/outbound-auth-delegated-get-key.md @@ -1,4 +1,4 @@ -# identity outbound-auth delegated get-key +# access outbound-auth delegated get-key Obtain a delegated API key for an agent identity. @@ -15,7 +15,7 @@ The response may include the API key value and an authorization URL. Use `-o jso ## Synopsis ```text -grn agentbase identity outbound-auth delegated get-key +grn agentbase access outbound-auth delegated get-key --agent-user-id --return-url [--custom-state ] @@ -76,7 +76,7 @@ All `grn agentbase` commands accept: Obtain a delegated API key: ```bash -grn agentbase identity outbound-auth delegated get-key \ +grn agentbase access outbound-auth delegated get-key \ my-delegated-provider my-agent \ --agent-user-id user-123 \ --return-url https://app.example.com/callback @@ -85,7 +85,7 @@ grn agentbase identity outbound-auth delegated get-key \ Force a fresh delegation and reveal the full response as JSON (prints secret values): ```bash -grn agentbase identity outbound-auth delegated get-key \ +grn agentbase access outbound-auth delegated get-key \ my-delegated-provider my-agent \ --agent-user-id user-123 \ --return-url https://app.example.com/callback \ diff --git a/docs/commands/agentbase/identity/outbound-auth-delegated-get.md b/docs/commands/agentbase/access/outbound-auth-delegated-get.md similarity index 71% rename from docs/commands/agentbase/identity/outbound-auth-delegated-get.md rename to docs/commands/agentbase/access/outbound-auth-delegated-get.md index 93aa7ca..d1747b4 100644 --- a/docs/commands/agentbase/identity/outbound-auth-delegated-get.md +++ b/docs/commands/agentbase/access/outbound-auth-delegated-get.md @@ -1,4 +1,4 @@ -# identity outbound-auth delegated get +# access outbound-auth delegated get Get a delegated API key provider. @@ -9,7 +9,7 @@ Retrieve a delegated API key provider by name. ## Synopsis ```text -grn agentbase identity outbound-auth delegated get +grn agentbase access outbound-auth delegated get ``` ## Options @@ -29,11 +29,11 @@ All `grn agentbase` commands accept: Get a delegated API key provider: ```bash -grn agentbase identity outbound-auth delegated get my-delegated-provider +grn agentbase access outbound-auth delegated get my-delegated-provider ``` View the full record as JSON: ```bash -grn agentbase identity outbound-auth delegated get my-delegated-provider -o json +grn agentbase access outbound-auth delegated get my-delegated-provider -o json ``` diff --git a/docs/commands/agentbase/identity/outbound-auth-delegated-list.md b/docs/commands/agentbase/access/outbound-auth-delegated-list.md similarity index 78% rename from docs/commands/agentbase/identity/outbound-auth-delegated-list.md rename to docs/commands/agentbase/access/outbound-auth-delegated-list.md index 1b05978..536982b 100644 --- a/docs/commands/agentbase/identity/outbound-auth-delegated-list.md +++ b/docs/commands/agentbase/access/outbound-auth-delegated-list.md @@ -1,4 +1,4 @@ -# identity outbound-auth delegated list +# access outbound-auth delegated list List delegated API key providers. @@ -9,7 +9,7 @@ Retrieve a paginated list of delegated API key providers. ## Synopsis ```text -grn agentbase identity outbound-auth delegated list +grn agentbase access outbound-auth delegated list [--page ] [--size ] ``` @@ -43,11 +43,11 @@ All `grn agentbase` commands accept: List delegated API key providers: ```bash -grn agentbase identity outbound-auth delegated list +grn agentbase access outbound-auth delegated list ``` List the second page with 50 per page: ```bash -grn agentbase identity outbound-auth delegated list --page 2 --size 50 +grn agentbase access outbound-auth delegated list --page 2 --size 50 ``` diff --git a/docs/commands/agentbase/identity/outbound-auth-oauth2-3lo-token.md b/docs/commands/agentbase/access/outbound-auth-oauth2-3lo-token.md similarity index 92% rename from docs/commands/agentbase/identity/outbound-auth-oauth2-3lo-token.md rename to docs/commands/agentbase/access/outbound-auth-oauth2-3lo-token.md index 55d55c6..e3750d5 100644 --- a/docs/commands/agentbase/identity/outbound-auth-oauth2-3lo-token.md +++ b/docs/commands/agentbase/access/outbound-auth-oauth2-3lo-token.md @@ -1,4 +1,4 @@ -# identity outbound-auth oauth2 3lo-token +# access outbound-auth oauth2 3lo-token Get a 3-legged OAuth2 token. @@ -15,7 +15,7 @@ The response may include the access token and an authorization URL. Use `-o json ## Synopsis ```text -grn agentbase identity outbound-auth oauth2 3lo-token +grn agentbase access outbound-auth oauth2 3lo-token --agent-user-id --return-url --scope @@ -93,7 +93,7 @@ All `grn agentbase` commands accept: Get a 3-legged token with scopes (table view): ```bash -grn agentbase identity outbound-auth oauth2 3lo-token \ +grn agentbase access outbound-auth oauth2 3lo-token \ my-oauth2-provider my-agent \ --agent-user-id user-123 \ --return-url https://app.example.com/callback \ @@ -103,7 +103,7 @@ grn agentbase identity outbound-auth oauth2 3lo-token \ Pass custom parameters as JSON and reveal the full response (prints secret values): ```bash -grn agentbase identity outbound-auth oauth2 3lo-token \ +grn agentbase access outbound-auth oauth2 3lo-token \ my-oauth2-provider my-agent \ --agent-user-id user-123 \ --return-url https://app.example.com/callback \ diff --git a/docs/commands/agentbase/identity/outbound-auth-oauth2-create.md b/docs/commands/agentbase/access/outbound-auth-oauth2-create.md similarity index 93% rename from docs/commands/agentbase/identity/outbound-auth-oauth2-create.md rename to docs/commands/agentbase/access/outbound-auth-oauth2-create.md index 9d7ba06..b42f92c 100644 --- a/docs/commands/agentbase/identity/outbound-auth-oauth2-create.md +++ b/docs/commands/agentbase/access/outbound-auth-oauth2-create.md @@ -1,4 +1,4 @@ -# identity outbound-auth oauth2 create +# access outbound-auth oauth2 create Create an OAuth2 provider. @@ -11,7 +11,7 @@ authorization-url, and token-url are required. ## Synopsis ```text -grn agentbase identity outbound-auth oauth2 create +grn agentbase access outbound-auth oauth2 create --name --client-id --client-secret @@ -66,7 +66,7 @@ All `grn agentbase` commands accept: Create an OAuth2 provider: ```bash -grn agentbase identity outbound-auth oauth2 create \ +grn agentbase access outbound-auth oauth2 create \ --name my-oauth2-provider \ --client-id abc123 \ --client-secret s3cr3t \ diff --git a/docs/commands/agentbase/identity/outbound-auth-oauth2-delete.md b/docs/commands/agentbase/access/outbound-auth-oauth2-delete.md similarity index 77% rename from docs/commands/agentbase/identity/outbound-auth-oauth2-delete.md rename to docs/commands/agentbase/access/outbound-auth-oauth2-delete.md index a17636c..b403f3e 100644 --- a/docs/commands/agentbase/identity/outbound-auth-oauth2-delete.md +++ b/docs/commands/agentbase/access/outbound-auth-oauth2-delete.md @@ -1,4 +1,4 @@ -# identity outbound-auth oauth2 delete +# access outbound-auth oauth2 delete Delete an OAuth2 provider. @@ -9,7 +9,7 @@ Delete an OAuth2 provider by name. ## Synopsis ```text -grn agentbase identity outbound-auth oauth2 delete +grn agentbase access outbound-auth oauth2 delete ``` ## Options @@ -29,5 +29,5 @@ All `grn agentbase` commands accept: Delete an OAuth2 provider: ```bash -grn agentbase identity outbound-auth oauth2 delete my-oauth2-provider +grn agentbase access outbound-auth oauth2 delete my-oauth2-provider ``` diff --git a/docs/commands/agentbase/identity/outbound-auth-oauth2-get.md b/docs/commands/agentbase/access/outbound-auth-oauth2-get.md similarity index 72% rename from docs/commands/agentbase/identity/outbound-auth-oauth2-get.md rename to docs/commands/agentbase/access/outbound-auth-oauth2-get.md index 77249b4..8d1ce22 100644 --- a/docs/commands/agentbase/identity/outbound-auth-oauth2-get.md +++ b/docs/commands/agentbase/access/outbound-auth-oauth2-get.md @@ -1,4 +1,4 @@ -# identity outbound-auth oauth2 get +# access outbound-auth oauth2 get Get an OAuth2 provider. @@ -9,7 +9,7 @@ Retrieve an OAuth2 provider by name. ## Synopsis ```text -grn agentbase identity outbound-auth oauth2 get +grn agentbase access outbound-auth oauth2 get ``` ## Options @@ -29,11 +29,11 @@ All `grn agentbase` commands accept: Get an OAuth2 provider: ```bash -grn agentbase identity outbound-auth oauth2 get my-oauth2-provider +grn agentbase access outbound-auth oauth2 get my-oauth2-provider ``` View the full record as JSON: ```bash -grn agentbase identity outbound-auth oauth2 get my-oauth2-provider -o json +grn agentbase access outbound-auth oauth2 get my-oauth2-provider -o json ``` diff --git a/docs/commands/agentbase/identity/outbound-auth-oauth2-list.md b/docs/commands/agentbase/access/outbound-auth-oauth2-list.md similarity index 79% rename from docs/commands/agentbase/identity/outbound-auth-oauth2-list.md rename to docs/commands/agentbase/access/outbound-auth-oauth2-list.md index ea48e7c..c489be8 100644 --- a/docs/commands/agentbase/identity/outbound-auth-oauth2-list.md +++ b/docs/commands/agentbase/access/outbound-auth-oauth2-list.md @@ -1,4 +1,4 @@ -# identity outbound-auth oauth2 list +# access outbound-auth oauth2 list List OAuth2 providers. @@ -9,7 +9,7 @@ Retrieve a paginated list of OAuth2 providers. ## Synopsis ```text -grn agentbase identity outbound-auth oauth2 list +grn agentbase access outbound-auth oauth2 list [--page ] [--size ] ``` @@ -43,11 +43,11 @@ All `grn agentbase` commands accept: List OAuth2 providers: ```bash -grn agentbase identity outbound-auth oauth2 list +grn agentbase access outbound-auth oauth2 list ``` List the second page with 50 per page: ```bash -grn agentbase identity outbound-auth oauth2 list --page 2 --size 50 +grn agentbase access outbound-auth oauth2 list --page 2 --size 50 ``` diff --git a/docs/commands/agentbase/identity/outbound-auth-oauth2-m2m-token.md b/docs/commands/agentbase/access/outbound-auth-oauth2-m2m-token.md similarity index 85% rename from docs/commands/agentbase/identity/outbound-auth-oauth2-m2m-token.md rename to docs/commands/agentbase/access/outbound-auth-oauth2-m2m-token.md index 0f1513a..8e98226 100644 --- a/docs/commands/agentbase/identity/outbound-auth-oauth2-m2m-token.md +++ b/docs/commands/agentbase/access/outbound-auth-oauth2-m2m-token.md @@ -1,4 +1,4 @@ -# identity outbound-auth oauth2 m2m-token +# access outbound-auth oauth2 m2m-token Get an M2M OAuth2 token. @@ -12,7 +12,7 @@ value; the default `table` output is for human inspection. ## Synopsis ```text -grn agentbase identity outbound-auth oauth2 m2m-token +grn agentbase access outbound-auth oauth2 m2m-token --scope ``` @@ -43,7 +43,7 @@ All `grn agentbase` commands accept: Get an M2M token with scopes (table view): ```bash -grn agentbase identity outbound-auth oauth2 m2m-token \ +grn agentbase access outbound-auth oauth2 m2m-token \ my-oauth2-provider my-agent \ --scope read --scope write ``` @@ -51,7 +51,7 @@ grn agentbase identity outbound-auth oauth2 m2m-token \ Reveal the full access token value (use with care — this prints the secret): ```bash -grn agentbase identity outbound-auth oauth2 m2m-token \ +grn agentbase access outbound-auth oauth2 m2m-token \ my-oauth2-provider my-agent \ --scope read \ -o json diff --git a/docs/commands/agentbase/identity/outbound-auth-oauth2-update.md b/docs/commands/agentbase/access/outbound-auth-oauth2-update.md similarity index 90% rename from docs/commands/agentbase/identity/outbound-auth-oauth2-update.md rename to docs/commands/agentbase/access/outbound-auth-oauth2-update.md index d1d7495..f743fcc 100644 --- a/docs/commands/agentbase/identity/outbound-auth-oauth2-update.md +++ b/docs/commands/agentbase/access/outbound-auth-oauth2-update.md @@ -1,4 +1,4 @@ -# identity outbound-auth oauth2 update +# access outbound-auth oauth2 update Update an OAuth2 provider. @@ -10,7 +10,7 @@ authorization-url, and token-url are required. ## Synopsis ```text -grn agentbase identity outbound-auth oauth2 update +grn agentbase access outbound-auth oauth2 update --client-id --client-secret --authorization-url @@ -56,7 +56,7 @@ All `grn agentbase` commands accept: Rotate all four endpoints/credentials on an existing provider: ```bash -grn agentbase identity outbound-auth oauth2 update my-oauth2-provider \ +grn agentbase access outbound-auth oauth2 update my-oauth2-provider \ --client-id abc123 \ --client-secret new-s3cr3t \ --authorization-url https://idp.example.com/oauth2/authorize \ diff --git a/docs/commands/agentbase/identity/outbound-auth-static-create.md b/docs/commands/agentbase/access/outbound-auth-static-create.md similarity index 84% rename from docs/commands/agentbase/identity/outbound-auth-static-create.md rename to docs/commands/agentbase/access/outbound-auth-static-create.md index a1a939f..d0dcf16 100644 --- a/docs/commands/agentbase/identity/outbound-auth-static-create.md +++ b/docs/commands/agentbase/access/outbound-auth-static-create.md @@ -1,4 +1,4 @@ -# identity outbound-auth static create +# access outbound-auth static create Create a static API key provider. @@ -10,7 +10,7 @@ the pattern `^[a-zA-Z0-9_-]+$`. Both name and API key are required. ## Synopsis ```text -grn agentbase identity outbound-auth static create +grn agentbase access outbound-auth static create --name --apikey ``` @@ -44,7 +44,7 @@ All `grn agentbase` commands accept: Create a static API key provider: ```bash -grn agentbase identity outbound-auth static create \ +grn agentbase access outbound-auth static create \ --name my-apikey-provider \ --apikey sk-xxxxxxxxxxxx ``` @@ -52,5 +52,5 @@ grn agentbase identity outbound-auth static create \ Create interactively (the API key is read without echoing): ```bash -grn agentbase identity outbound-auth static create --name my-apikey-provider --interactive +grn agentbase access outbound-auth static create --name my-apikey-provider --interactive ``` diff --git a/docs/commands/agentbase/identity/outbound-auth-static-delete.md b/docs/commands/agentbase/access/outbound-auth-static-delete.md similarity index 77% rename from docs/commands/agentbase/identity/outbound-auth-static-delete.md rename to docs/commands/agentbase/access/outbound-auth-static-delete.md index 52ef07d..1bcbf67 100644 --- a/docs/commands/agentbase/identity/outbound-auth-static-delete.md +++ b/docs/commands/agentbase/access/outbound-auth-static-delete.md @@ -1,4 +1,4 @@ -# identity outbound-auth static delete +# access outbound-auth static delete Delete a static API key provider. @@ -9,7 +9,7 @@ Delete a static API key provider by name. ## Synopsis ```text -grn agentbase identity outbound-auth static delete +grn agentbase access outbound-auth static delete ``` ## Options @@ -29,5 +29,5 @@ All `grn agentbase` commands accept: Delete a static API key provider: ```bash -grn agentbase identity outbound-auth static delete my-apikey-provider +grn agentbase access outbound-auth static delete my-apikey-provider ``` diff --git a/docs/commands/agentbase/identity/outbound-auth-static-get-key.md b/docs/commands/agentbase/access/outbound-auth-static-get-key.md similarity index 77% rename from docs/commands/agentbase/identity/outbound-auth-static-get-key.md rename to docs/commands/agentbase/access/outbound-auth-static-get-key.md index 4e6409c..013ee26 100644 --- a/docs/commands/agentbase/identity/outbound-auth-static-get-key.md +++ b/docs/commands/agentbase/access/outbound-auth-static-get-key.md @@ -1,4 +1,4 @@ -# identity outbound-auth static get-key +# access outbound-auth static get-key Get the API key for an agent identity. @@ -12,7 +12,7 @@ value; the default `table` output is for human inspection. ## Synopsis ```text -grn agentbase identity outbound-auth static get-key +grn agentbase access outbound-auth static get-key ``` ## Arguments @@ -37,11 +37,11 @@ All `grn agentbase` commands accept: Get the API key assigned to an identity (table view): ```bash -grn agentbase identity outbound-auth static get-key my-apikey-provider my-agent +grn agentbase access outbound-auth static get-key my-apikey-provider my-agent ``` Reveal the full API key value (use with care — this prints the secret): ```bash -grn agentbase identity outbound-auth static get-key my-apikey-provider my-agent -o json +grn agentbase access outbound-auth static get-key my-apikey-provider my-agent -o json ``` diff --git a/docs/commands/agentbase/identity/outbound-auth-static-get.md b/docs/commands/agentbase/access/outbound-auth-static-get.md similarity index 72% rename from docs/commands/agentbase/identity/outbound-auth-static-get.md rename to docs/commands/agentbase/access/outbound-auth-static-get.md index 2736644..026abfb 100644 --- a/docs/commands/agentbase/identity/outbound-auth-static-get.md +++ b/docs/commands/agentbase/access/outbound-auth-static-get.md @@ -1,4 +1,4 @@ -# identity outbound-auth static get +# access outbound-auth static get Get a static API key provider. @@ -9,7 +9,7 @@ Retrieve a static API key provider by name. ## Synopsis ```text -grn agentbase identity outbound-auth static get +grn agentbase access outbound-auth static get ``` ## Options @@ -29,11 +29,11 @@ All `grn agentbase` commands accept: Get a static API key provider: ```bash -grn agentbase identity outbound-auth static get my-apikey-provider +grn agentbase access outbound-auth static get my-apikey-provider ``` View the full record as JSON: ```bash -grn agentbase identity outbound-auth static get my-apikey-provider -o json +grn agentbase access outbound-auth static get my-apikey-provider -o json ``` diff --git a/docs/commands/agentbase/identity/outbound-auth-static-list.md b/docs/commands/agentbase/access/outbound-auth-static-list.md similarity index 79% rename from docs/commands/agentbase/identity/outbound-auth-static-list.md rename to docs/commands/agentbase/access/outbound-auth-static-list.md index 7aad4c9..994b636 100644 --- a/docs/commands/agentbase/identity/outbound-auth-static-list.md +++ b/docs/commands/agentbase/access/outbound-auth-static-list.md @@ -1,4 +1,4 @@ -# identity outbound-auth static list +# access outbound-auth static list List static API key providers. @@ -9,7 +9,7 @@ Retrieve a paginated list of static API key providers. ## Synopsis ```text -grn agentbase identity outbound-auth static list +grn agentbase access outbound-auth static list [--page ] [--size ] ``` @@ -43,11 +43,11 @@ All `grn agentbase` commands accept: List static API key providers: ```bash -grn agentbase identity outbound-auth static list +grn agentbase access outbound-auth static list ``` List the second page with 50 per page: ```bash -grn agentbase identity outbound-auth static list --page 2 --size 50 +grn agentbase access outbound-auth static list --page 2 --size 50 ``` diff --git a/docs/commands/agentbase/identity/outbound-auth-static-update.md b/docs/commands/agentbase/access/outbound-auth-static-update.md similarity index 82% rename from docs/commands/agentbase/identity/outbound-auth-static-update.md rename to docs/commands/agentbase/access/outbound-auth-static-update.md index 04fa87e..934350c 100644 --- a/docs/commands/agentbase/identity/outbound-auth-static-update.md +++ b/docs/commands/agentbase/access/outbound-auth-static-update.md @@ -1,4 +1,4 @@ -# identity outbound-auth static update +# access outbound-auth static update Update a static API key provider. @@ -9,7 +9,7 @@ Update the API key value of an existing static API key provider. ## Synopsis ```text -grn agentbase identity outbound-auth static update +grn agentbase access outbound-auth static update --apikey ``` @@ -34,6 +34,6 @@ All `grn agentbase` commands accept: Rotate the API key on an existing provider: ```bash -grn agentbase identity outbound-auth static update my-apikey-provider \ +grn agentbase access outbound-auth static update my-apikey-provider \ --apikey sk-newvalue ``` diff --git a/docs/commands/agentbase/context/current.md b/docs/commands/agentbase/context/current.md index db97abf..11a1ea2 100644 --- a/docs/commands/agentbase/context/current.md +++ b/docs/commands/agentbase/context/current.md @@ -8,6 +8,8 @@ Display the currently active environment (resolved from the profile's `iam_env`) The environment selects the agentbase API endpoints AND the IAM v2 token URL; it is stored as `iam_env` in the shared `~/.greennode` profile (default `prod`), the same selector vks/vserver use. Switch it with `grn configure set iam_env ` (machine) or `grn login --iam-env ` (user). +When `--endpoint-url` is set, the six service endpoints (Identity/Runtime/Memory/Gateway/Policy/CR) reflect the override — host swapped, each service's path kept. The `OAuth2 Token` URL is shown unchanged: it is resolved from `iam_env` by the auth provider, not overridden by `--endpoint-url`. + ## Synopsis ```text diff --git a/docs/commands/agentbase/cr/index.md b/docs/commands/agentbase/cr/index.md index bb08d96..5c58fa5 100644 --- a/docs/commands/agentbase/cr/index.md +++ b/docs/commands/agentbase/cr/index.md @@ -1,6 +1,6 @@ # cr -Manage the agentbase container registry (agent-core-container-registry). +Manage the agentbase container registry. ```bash grn agentbase cr [options] diff --git a/docs/commands/agentbase/deploy/index.md b/docs/commands/agentbase/deploy/index.md index 8da6a83..c88486f 100644 --- a/docs/commands/agentbase/deploy/index.md +++ b/docs/commands/agentbase/deploy/index.md @@ -1,6 +1,6 @@ # deploy -Deploy and manage an agent as one unit. `deploy` is a **client-side orchestrator** — it has no backend of its own. It composes the `identity`, `memory`, `runtime`, and `cr` clients into a single lifecycle. +Deploy and manage an agent as one unit. `deploy` is a **client-side orchestrator** — it has no backend of its own. It composes the `access`, `memory`, `runtime`, and `cr` clients into a single lifecycle. An **agent** is the set of resources that share a name (the join key): an identity (always), an optional memory container (stateless agents omit it), and a runtime (the container that runs the agent code). There is no cross-service foreign key — the name is what ties them together. The agent code is a container image; push it to your vCR repo and reference it in the manifest. `imageAuth: auto` resolves the pull credentials from your auto-provisioned robot account. diff --git a/docs/commands/agentbase/gateway/index.md b/docs/commands/agentbase/gateway/index.md index 6708500..a849424 100644 --- a/docs/commands/agentbase/gateway/index.md +++ b/docs/commands/agentbase/gateway/index.md @@ -1,6 +1,6 @@ # gateway -Manage MCP gateways (the agent-core-gateway service). +Manage MCP gateways (the gateway service). ```bash grn agentbase gateway [options] diff --git a/docs/commands/agentbase/identity/index.md b/docs/commands/agentbase/identity/index.md deleted file mode 100644 index cb2e801..0000000 --- a/docs/commands/agentbase/identity/index.md +++ /dev/null @@ -1,53 +0,0 @@ -# identity - -Manage authentication and agent identities. - -```bash -grn agentbase identity [options] -``` - -## Available commands - -### Workload - -| Command | Description | -|---------|-------------| -| [workload-create](workload-create.md) | Create a new agent identity | -| [workload-list](workload-list.md) | List agent identities | -| [workload-get](workload-get.md) | Get an agent identity by name | -| [workload-update](workload-update.md) | Update an agent identity | -| [workload-use](workload-use.md) | Set the current agent identity | -| [workload-delete](workload-delete.md) | Delete an agent identity | - -### Outbound Auth — Static / API key - -| Command | Description | -|---------|-------------| -| [outbound-auth-static-create](outbound-auth-static-create.md) | Create a static API key provider | -| [outbound-auth-static-list](outbound-auth-static-list.md) | List static API key providers | -| [outbound-auth-static-get](outbound-auth-static-get.md) | Get a static API key provider | -| [outbound-auth-static-update](outbound-auth-static-update.md) | Update a static API key provider | -| [outbound-auth-static-delete](outbound-auth-static-delete.md) | Delete a static API key provider | -| [outbound-auth-static-get-key](outbound-auth-static-get-key.md) | Get the API key for an agent identity | - -### Outbound Auth — Delegated - -| Command | Description | -|---------|-------------| -| [outbound-auth-delegated-create](outbound-auth-delegated-create.md) | Create a delegated API key provider | -| [outbound-auth-delegated-list](outbound-auth-delegated-list.md) | List delegated API key providers | -| [outbound-auth-delegated-get](outbound-auth-delegated-get.md) | Get a delegated API key provider | -| [outbound-auth-delegated-delete](outbound-auth-delegated-delete.md) | Delete a delegated API key provider | -| [outbound-auth-delegated-get-key](outbound-auth-delegated-get-key.md) | Obtain a delegated API key for an agent identity | - -### Outbound Auth — OAuth2 - -| Command | Description | -|---------|-------------| -| [outbound-auth-oauth2-create](outbound-auth-oauth2-create.md) | Create an OAuth2 provider | -| [outbound-auth-oauth2-list](outbound-auth-oauth2-list.md) | List OAuth2 providers | -| [outbound-auth-oauth2-get](outbound-auth-oauth2-get.md) | Get an OAuth2 provider | -| [outbound-auth-oauth2-update](outbound-auth-oauth2-update.md) | Update an OAuth2 provider | -| [outbound-auth-oauth2-delete](outbound-auth-oauth2-delete.md) | Delete an OAuth2 provider | -| [outbound-auth-oauth2-m2m-token](outbound-auth-oauth2-m2m-token.md) | Get an M2M OAuth2 token | -| [outbound-auth-oauth2-3lo-token](outbound-auth-oauth2-3lo-token.md) | Get a 3-legged OAuth2 token | diff --git a/docs/commands/agentbase/index.md b/docs/commands/agentbase/index.md index 2fa4c43..b06156f 100644 --- a/docs/commands/agentbase/index.md +++ b/docs/commands/agentbase/index.md @@ -11,24 +11,28 @@ grn agentbase --help ## Overview -The AgentBase platform consists of **6 backend services** behind -`agentbase.api.vngcloud.vn` (prod) / `agentbase.api-dev.vngcloud.tech` (dev), -plus a client-side orchestrator, `deploy`: +> **Breaking change:** `grn agentbase identity` → `grn agentbase access`; +> `identity workload` → `access agent-id`; `grn agentbase catalog` → `grn agentbase +> marketplace`. Old names are removed (no aliases). Update any scripts. -| Command group | Backend service | Role | -|---|---|---| -| `identity` | agent-core-identity | Manage agent digital identities + outbound auth (OAuth2/API key) | -| `gateway` | agent-core-gateway | Create/manage MCP gateways (provision VKS runtime, DNS, load balancer, quota) | -| `runtime` | agent-core-runtime | Deploy the container that runs the agent code (image/command/args/env/autoscaling) | -| `catalog` | agent-core-runtime | Browse the catalog: runtime flavors, OpenClaw versions + workspaces | -| `memory` | agent-core-memory | Long-term memory for agents (Mem0 vector store, semantic search) | -| `policy` | agent-core-policy | Cedar policy engine (policy-groups + policies + decision) | -| `cr` | agent-core-container-registry | Wrapper for VNG Cloud Container Registry (vCR): auto-provisioned repo + robot account | -| `deploy` | *(no backend)* | Orchestrator composing identity + memory + runtime (+ cr) | -| `context` | — | Show the active environment + endpoints | +The AgentBase platform is served behind `agentbase.api.vngcloud.vn` (prod) / +`agentbase.api-dev.vngcloud.tech` (dev), plus a client-side orchestrator, +`deploy`: + +| Command group | Role | +|---|---| +| `access` | Manage agent digital identities + outbound auth (OAuth2/API key) | +| `gateway` | Create/manage MCP gateways (provision VKS runtime, DNS, load balancer, quota) | +| `runtime` | Deploy the container that runs the agent code (image/command/args/env/autoscaling) | +| `marketplace` | Browse the marketplace: runtime flavors, OpenClaw versions + workspaces | +| `memory` | Long-term memory for agents (Mem0 vector store, semantic search) | +| `policy` | Cedar policy engine (policy-groups + policies + decision) | +| `cr` | Wrapper for VNG Cloud Container Registry (vCR): auto-provisioned repo + robot account | +| `deploy` | Orchestrator composing access + memory + runtime (+ cr) | +| `context` | Show the active environment + endpoints | An **agent** is the set of resources that share a **name** (the join key): an -identity (always present), an optional memory container (omit for a stateless +agent identity (always present), an optional memory container (omit for a stateless agent), and a runtime (the container that runs the agent code). There is no cross-service foreign key; `deploy` uses the name as the join. @@ -44,7 +48,7 @@ modes: ```bash grn configure --profile default -# Enter client_id (GRN_ACCESS_KEY_ID) and client_secret (GRN_SECRET_ACCESS_KEY) +# Enter client_id (GRN_CLIENT_ID) and client_secret (GRN_CLIENT_SECRET) ``` The CLI uses `clientcredentials` to mint an IAM v2 token. The `client_secret` is @@ -81,6 +85,21 @@ grn agentbase context current > environments, you must `grn login --iam-env ` again. In machine mode, > `iam_env` can be switched freely. +### Overriding endpoints (`--endpoint-url`) + +The global `--endpoint-url` flag repoints every agentbase service endpoint +(identity/runtime/memory/gateway/policy/cr) at a different host, preserving +each service's path segment so routing still works — e.g. +`--endpoint-url https://agentbase-staging.example.com` sends identity calls to +`…/identity`, runtime to `…/runtime`, and so on. The IAM token URL +(`OAuth2 Token` in `context current`) is **not** overridden; it stays on +`iam_env`. The same endpoint-safety policy as vks applies (trusted-domain / TLS +check, `--allow-untrusted-endpoint` to force). + +```bash +grn --endpoint-url https://agentbase-staging.example.com agentbase access agent-id list +``` + --- ## Command reference @@ -91,14 +110,14 @@ All commands accept the common flags `-o json|table|id` (output format) and | Command group | Description | |---|---| | [context](context/index.md) | Show the active environment + standard headers/decorators | -| [identity](identity/index.md) | Workload identities + outbound auth (OAuth2 / static API key / delegated) | +| [access](access/index.md) | Agent identities + outbound auth (OAuth2 / static API key / delegated) | | [gateway](gateway/index.md) | Create/manage MCP gateways (async FSM; use `wait` to converge) | | [runtime](runtime/index.md) | Deploy the agent container (image/command/args/env/autoscaling) | | [memory](memory/index.md) | Long-term memory container (Mem0 vector store, semantic search) | -| [catalog](catalog/index.md) | Runtime placement flavors + OpenClaw versions/workspaces | +| [marketplace](marketplace/index.md) | Runtime placement flavors + OpenClaw versions/workspaces | | [policy](policy/index.md) | Cedar policy engine (policy-groups + policies + decision) | | [cr](cr/index.md) | VNG Cloud Container Registry wrapper (auto-provisioned repo + robot account) | -| [deploy](deploy/index.md) | Client-side orchestrator (identity + memory + runtime + cr) | +| [deploy](deploy/index.md) | Client-side orchestrator (access + memory + runtime + cr) | --- @@ -112,6 +131,11 @@ Every command takes `-o` (`--output`): | `json` | Raw JSON — secrets revealed (e.g. to pipe into `docker login`) | | `id` | Print only the ID (for scripting) | +When `-o`/`--output` is not passed, the format falls back to the `output` key +in `~/.greennode/config` (the same fallback vks/vserver use), then to `table`. +So `grn configure set output json` governs `grn agentbase` too. (`context current` +uses a fixed table layout and is not affected by the output format.) + --- ## Paging envelope reference @@ -125,7 +149,7 @@ Each service uses a different pagination shape — a common source of confusion: | memory | `{listData, page, pageSize, totalPage, totalItem}` | `size` | | policy | `{content, page, pageSize, totalPage, totalItem}` | `page_size` (snake) | | cr | `{data, page, pageSize, totalItem, totalPage}` | `size` | -| identity | `{content, page, size, totalElements, totalPages, ...}` | `size` | +| access | `{content, page, size, totalElements, totalPages, ...}` | `size` | --- diff --git a/docs/commands/agentbase/catalog/flavors-list.md b/docs/commands/agentbase/marketplace/flavors-list.md similarity index 87% rename from docs/commands/agentbase/catalog/flavors-list.md rename to docs/commands/agentbase/marketplace/flavors-list.md index e5a7e1a..f6d9ff5 100644 --- a/docs/commands/agentbase/catalog/flavors-list.md +++ b/docs/commands/agentbase/marketplace/flavors-list.md @@ -1,4 +1,4 @@ -# catalog flavors list +# marketplace flavors list List runtime placement flavors. @@ -11,7 +11,7 @@ runtime. Filters are optional; omit them to list every flavor. ## Synopsis ```text -grn agentbase catalog flavors list +grn agentbase marketplace flavors list [--resource-type ] ``` @@ -28,7 +28,7 @@ Filter by supported resource type. List every flavor: ```bash -grn agentbase catalog flavors list +grn agentbase marketplace flavors list ``` ## Global options diff --git a/docs/commands/agentbase/catalog/index.md b/docs/commands/agentbase/marketplace/index.md similarity index 87% rename from docs/commands/agentbase/catalog/index.md rename to docs/commands/agentbase/marketplace/index.md index c63e15d..20da649 100644 --- a/docs/commands/agentbase/catalog/index.md +++ b/docs/commands/agentbase/marketplace/index.md @@ -1,14 +1,14 @@ -# catalog +# marketplace -Browse the GreenNode catalog served by the agent-core-runtime service. +Browse the GreenNode catalog, which shares the runtime service's backend. ## Description -The `catalog` group fronts the catalog endpoints under `/v1/` (flavors, +The `marketplace` group fronts the catalog endpoints under `/v1/` (flavors, openclaw versions, and the openclaw workspace registry). It uses the same base URL as the runtime service. These operators are part of the agentbase surface but were previously unwired in the CLI; they are now available under -`grn agentbase catalog`. +`grn agentbase marketplace`. ## Subcommands diff --git a/docs/commands/agentbase/catalog/openclaw-create.md b/docs/commands/agentbase/marketplace/openclaw-create.md similarity index 86% rename from docs/commands/agentbase/catalog/openclaw-create.md rename to docs/commands/agentbase/marketplace/openclaw-create.md index 9a0ddb8..6fd27f0 100644 --- a/docs/commands/agentbase/catalog/openclaw-create.md +++ b/docs/commands/agentbase/marketplace/openclaw-create.md @@ -1,4 +1,4 @@ -# catalog openclaw create +# marketplace openclaw create Create an OpenClaw workspace. @@ -13,7 +13,7 @@ simple `--name/--version-id/--flavor-id` path, author a spec file with ## Synopsis ```text -grn agentbase catalog openclaw create +grn agentbase marketplace openclaw create [--name ] [--version-id ] [--flavor-id ] @@ -59,13 +59,13 @@ Apply a spec file (authoritative when set). Create from flags: ```bash -grn agentbase catalog openclaw create --name my-claw --version-id v1 --flavor-id f1 +grn agentbase marketplace openclaw create --name my-claw --version-id v1 --flavor-id f1 ``` Create from a spec file: ```bash -grn agentbase catalog openclaw create --file openclaw.yaml +grn agentbase marketplace openclaw create --file openclaw.yaml ``` ## Global options diff --git a/docs/commands/agentbase/catalog/openclaw-delete.md b/docs/commands/agentbase/marketplace/openclaw-delete.md similarity index 85% rename from docs/commands/agentbase/catalog/openclaw-delete.md rename to docs/commands/agentbase/marketplace/openclaw-delete.md index 704b0f7..22edd2f 100644 --- a/docs/commands/agentbase/catalog/openclaw-delete.md +++ b/docs/commands/agentbase/marketplace/openclaw-delete.md @@ -1,4 +1,4 @@ -# catalog openclaw delete +# marketplace openclaw delete Delete an OpenClaw workspace. @@ -10,7 +10,7 @@ returns 200 with no body; the command prints the deleted id. ## Synopsis ```text -grn agentbase catalog openclaw delete +grn agentbase marketplace openclaw delete ``` ## Arguments @@ -28,7 +28,7 @@ This command takes no command-specific options. ## Examples ```bash -grn agentbase catalog openclaw delete oc-123 +grn agentbase marketplace openclaw delete oc-123 ``` ## Global options diff --git a/docs/commands/agentbase/catalog/openclaw-get.md b/docs/commands/agentbase/marketplace/openclaw-get.md similarity index 85% rename from docs/commands/agentbase/catalog/openclaw-get.md rename to docs/commands/agentbase/marketplace/openclaw-get.md index 49a39c4..174ba76 100644 --- a/docs/commands/agentbase/catalog/openclaw-get.md +++ b/docs/commands/agentbase/marketplace/openclaw-get.md @@ -1,4 +1,4 @@ -# catalog openclaw get +# marketplace openclaw get Show an OpenClaw workspace. @@ -10,7 +10,7 @@ rendered as a detail table (or JSON/id per the global output format). ## Synopsis ```text -grn agentbase catalog openclaw get +grn agentbase marketplace openclaw get ``` ## Arguments @@ -28,7 +28,7 @@ This command takes no command-specific options. ## Examples ```bash -grn agentbase catalog openclaw get oc-123 -o json +grn agentbase marketplace openclaw get oc-123 -o json ``` ## Global options diff --git a/docs/commands/agentbase/catalog/openclaw-list.md b/docs/commands/agentbase/marketplace/openclaw-list.md similarity index 86% rename from docs/commands/agentbase/catalog/openclaw-list.md rename to docs/commands/agentbase/marketplace/openclaw-list.md index 8e94018..4e51576 100644 --- a/docs/commands/agentbase/catalog/openclaw-list.md +++ b/docs/commands/agentbase/marketplace/openclaw-list.md @@ -1,4 +1,4 @@ -# catalog openclaw list +# marketplace openclaw list List OpenClaw workspaces. @@ -10,7 +10,7 @@ view shows the workspace id, name, version, status, and timestamps. ## Synopsis ```text -grn agentbase catalog openclaw list +grn agentbase marketplace openclaw list [--page ] [--size ] ``` @@ -34,7 +34,7 @@ Page size. ## Examples ```bash -grn agentbase catalog openclaw list -o json +grn agentbase marketplace openclaw list -o json ``` ## Global options diff --git a/docs/commands/agentbase/catalog/openclaw-start.md b/docs/commands/agentbase/marketplace/openclaw-start.md similarity index 84% rename from docs/commands/agentbase/catalog/openclaw-start.md rename to docs/commands/agentbase/marketplace/openclaw-start.md index e7faa7c..81bee44 100644 --- a/docs/commands/agentbase/catalog/openclaw-start.md +++ b/docs/commands/agentbase/marketplace/openclaw-start.md @@ -1,4 +1,4 @@ -# catalog openclaw start +# marketplace openclaw start Start an OpenClaw workspace. @@ -10,7 +10,7 @@ Start an OpenClaw workspace by id (POST `/v1/openclaws/{id}/start`). No body; ## Synopsis ```text -grn agentbase catalog openclaw start +grn agentbase marketplace openclaw start ``` ## Arguments @@ -28,7 +28,7 @@ This command takes no command-specific options. ## Examples ```bash -grn agentbase catalog openclaw start oc-123 +grn agentbase marketplace openclaw start oc-123 ``` ## Global options diff --git a/docs/commands/agentbase/catalog/openclaw-stop.md b/docs/commands/agentbase/marketplace/openclaw-stop.md similarity index 84% rename from docs/commands/agentbase/catalog/openclaw-stop.md rename to docs/commands/agentbase/marketplace/openclaw-stop.md index fea9de0..4856b1e 100644 --- a/docs/commands/agentbase/catalog/openclaw-stop.md +++ b/docs/commands/agentbase/marketplace/openclaw-stop.md @@ -1,4 +1,4 @@ -# catalog openclaw stop +# marketplace openclaw stop Stop an OpenClaw workspace. @@ -10,7 +10,7 @@ Stop an OpenClaw workspace by id (POST `/v1/openclaws/{id}/stop`). No body; ## Synopsis ```text -grn agentbase catalog openclaw stop +grn agentbase marketplace openclaw stop ``` ## Arguments @@ -28,7 +28,7 @@ This command takes no command-specific options. ## Examples ```bash -grn agentbase catalog openclaw stop oc-123 +grn agentbase marketplace openclaw stop oc-123 ``` ## Global options diff --git a/docs/commands/agentbase/catalog/openclaw-update-version.md b/docs/commands/agentbase/marketplace/openclaw-update-version.md similarity index 81% rename from docs/commands/agentbase/catalog/openclaw-update-version.md rename to docs/commands/agentbase/marketplace/openclaw-update-version.md index 4ee257f..92c9de8 100644 --- a/docs/commands/agentbase/catalog/openclaw-update-version.md +++ b/docs/commands/agentbase/marketplace/openclaw-update-version.md @@ -1,4 +1,4 @@ -# catalog openclaw update-version +# marketplace openclaw update-version Roll an OpenClaw workspace to a version. @@ -11,7 +11,7 @@ variant; this PUT is canonical and the PATCH QC row maps to it. ## Synopsis ```text -grn agentbase catalog openclaw update-version --version-id +grn agentbase marketplace openclaw update-version --version-id ``` ## Arguments @@ -33,7 +33,7 @@ Target version id (required). ## Examples ```bash -grn agentbase catalog openclaw update-version oc-123 --version-id v2 +grn agentbase marketplace openclaw update-version oc-123 --version-id v2 ``` ## Global options diff --git a/docs/commands/agentbase/catalog/openclaw-versions-list.md b/docs/commands/agentbase/marketplace/openclaw-versions-list.md similarity index 82% rename from docs/commands/agentbase/catalog/openclaw-versions-list.md rename to docs/commands/agentbase/marketplace/openclaw-versions-list.md index e92a955..d6beecb 100644 --- a/docs/commands/agentbase/catalog/openclaw-versions-list.md +++ b/docs/commands/agentbase/marketplace/openclaw-versions-list.md @@ -1,4 +1,4 @@ -# catalog openclaw-versions list +# marketplace openclaw-versions list List OpenClaw versions. @@ -11,7 +11,7 @@ it is the default deployment. ## Synopsis ```text -grn agentbase catalog openclaw-versions list +grn agentbase marketplace openclaw-versions list ``` ## Options @@ -21,7 +21,7 @@ This command takes no command-specific options. ## Examples ```bash -grn agentbase catalog openclaw-versions list +grn agentbase marketplace openclaw-versions list ``` ## Global options diff --git a/docs/commands/agentbase/policy/decide.md b/docs/commands/agentbase/policy/decide.md index c20d4e0..26d01b0 100644 --- a/docs/commands/agentbase/policy/decide.md +++ b/docs/commands/agentbase/policy/decide.md @@ -4,7 +4,7 @@ Probe an authorization decision for a gateway target. ## Description -Probe an authorization decision — the **internal** route that agent-core-gateway +Probe an authorization decision — the **internal** route that the gateway service calls per inbound request. It always returns a decision: `allow`, or `deny` with a `reason` (HTTP 200 in both cases). diff --git a/docs/commands/agentbase/policy/index.md b/docs/commands/agentbase/policy/index.md index 085263d..e37b2f3 100644 --- a/docs/commands/agentbase/policy/index.md +++ b/docs/commands/agentbase/policy/index.md @@ -1,11 +1,11 @@ # policy -Manage Cedar authorization policy groups, policies, and decisions (the agent-core-policy service). +Manage Cedar authorization policy groups, policies, and decisions (the policy service). A "policy group" is a container of policies owned by a user (max 20/user); each group holds "policies" — individual permit/forbid rules (max 10/group) compiled to Cedar at write time. A gateway binds a group via its `policyGroupId`, and its -enforcement asks agent-core-policy for an allow/deny decision per inbound request. +enforcement asks the policy service for an allow/deny decision per inbound request. Policy resources are **synchronous** (no `WAITING_*` FSM, so there is no `wait`). There are two nested resources: **policy groups** and **policies within a group**. diff --git a/docs/configuration.md b/docs/configuration.md index 5c9b99c..eba06b7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -48,6 +48,12 @@ The profile's `auth_mode` decides which credential is used at runtime: | `user` | PKCE refresh token | `grn login` | | `machine` (default) | Client ID / secret | `grn configure` | +Switching back: running `grn configure` (or `grn configure set client_id` / +`client_secret`) after `grn login` repoints the profile at machine credentials — +it sets `auth_mode=machine` and drops the cached `refresh_token` / +`token_expires_at` so the stale login token is not used. `iam_env` is preserved +(it selects the environment for both modes). + `grn login` mints a short-lived access token and persists only the **refresh token** (plus `auth_mode` and `iam_env`) to the profile's credentials file (`0600`). The access token is held in memory for the process only — it is never @@ -87,15 +93,15 @@ left intact. Credentials are resolved in the following order (highest to lowest priority): -1. **Environment variables**: `GRN_ACCESS_KEY_ID`, `GRN_SECRET_ACCESS_KEY` +1. **Environment variables**: `GRN_CLIENT_ID`, `GRN_CLIENT_SECRET` 2. **Shared credentials file**: `~/.greennode/credentials` ## Environment variables | Variable | Description | |----------|-------------| -| `GRN_ACCESS_KEY_ID` | Client ID (overrides credentials file) | -| `GRN_SECRET_ACCESS_KEY` | Client Secret (overrides credentials file) | +| `GRN_CLIENT_ID` | Client ID (overrides credentials file) | +| `GRN_CLIENT_SECRET` | Client Secret (overrides credentials file) | | `GRN_DEFAULT_REGION` | Default region | | `GRN_DEFAULT_PROJECT_ID` | Project ID (GreenNode project UUID) | | `GRN_PROFILE` | Profile name (default: "default") | @@ -107,8 +113,8 @@ Environment variables take priority over config file values. ```bash # Set credentials via environment variables -export GRN_ACCESS_KEY_ID=your-client-id -export GRN_SECRET_ACCESS_KEY=your-client-secret +export GRN_CLIENT_ID=your-client-id +export GRN_CLIENT_SECRET=your-client-secret export GRN_DEFAULT_REGION=HCM-3 # Commands will use env var credentials automatically diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 78d1f15..6ce7bc5 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -28,7 +28,7 @@ go/ └── validator/ # ID validation ``` -**Rule of thumb:** product-specific code lives in `cmd//`; anything shared +**Rule of thumb:** product-specific code lives in a per-service command package; anything shared goes in `internal/cli` (or another `internal/*` package). A product package must not import another product package. @@ -36,13 +36,13 @@ not import another product package. A new product (e.g. `vserver`) is mounted without touching `root.go`: -1. **Create `cmd//`** with a parent `cobra.Command`: +1. **Create the service's command package** with a parent `cobra.Command`: ```go package vserver import ( - "github.com/greennodehub/greennode-cli/internal/cli" + "…/internal/cli" // shared CLI infra (NewClient, Output, RegisterService) "github.com/spf13/cobra" ) @@ -62,9 +62,9 @@ A new product (e.g. `vserver`) is mounted without touching `root.go`: ```go import ( - _ "github.com/greennodehub/greennode-cli/cmd/vks" - _ "github.com/greennodehub/greennode-cli/internal/resources/vserver" - _ "github.com/greennodehub/greennode-cli/cmd/vserver" // add this line + _ "…/cmd/vks" + _ "…/resources/vserver" // cross-service completion providers + _ "…/cmd/vserver" // add this line ) ``` @@ -81,14 +81,12 @@ A new product (e.g. `vserver`) is mounted without touching `root.go`: ## Self-contained subcommand: `agentbase` -`cmd/agentbase/` is a self-contained service stack that is compiled into the -default `grn` binary and the public release build (`-tags vks_only`) — it was -previously gated behind the `-tags agentbase` opt-in tag while under development -(see `cmd/register_agentbase.go`). Unlike `vks`/`vserver`, it does not reuse the -shared `internal/cli` infrastructure: it ships its own v2 OAuth2 -client-credentials auth (`internal/agentbase/auth/`), its own -`./.greennode.json` config loader (`internal/agentbase/config/`), and its own -HTTP client + output helpers. `AgentbaseCmd` self-registers via +The agentbase subcommand package is a self-contained service stack that is +compiled into the default `grn` binary and all release builds. Unlike +`vks`/`vserver`, it does not reuse the shared `internal/cli` infrastructure: it +ships its own v2 OAuth2 client-credentials auth, its own `./.greennode.json` +config loader, and its own HTTP client + output helpers. `AgentbaseCmd` +self-registers via `cli.RegisterService(...)` in `init()`, so it appears under `grn` with no further wiring. Its public command-reference pages live under `docs/commands/agentbase/*` and are published in the `mkdocs.yml` nav. @@ -135,13 +133,13 @@ Static command/flag completion is automatic (`grn completion `). For flag - Cross-service resource: a consumer uses `cli.ResourceCompletion(":")`; the owning service registers the provider with `cli.RegisterResourceCompleter(":", ...)`. See - `internal/resources/vserver/` for the pattern. + the vserver resource-completer package for the pattern. ## Ownership -`.github/CODEOWNERS` routes review by path: `internal/`, `cmd/root.go`, -`cmd/register.go` and `cmd/configure/` are platform-owned; `cmd//` is owned -by its product team. Add a CODEOWNERS line for a new service directory. +Review ownership is routed by path: platform-owned code (shared infrastructure, +the root command, and `configure`) is distinguished from product-team-owned code +(a service's command package). Add an ownership entry for a new service directory. ## Running tests diff --git a/docs/index.md b/docs/index.md index 5767b2f..149dd25 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,12 +6,15 @@ The GreenNode CLI (`grn`) is a unified tool to manage your GreenNode services fr ## Quick Start +Install with one command (macOS / Linux): + ```bash -# Download binary (macOS Apple Silicon example) -curl -L -o grn https://github.com/GreenNodeHub/greennode-cli/releases/latest/download/grn-darwin-arm64 -chmod +x grn -sudo mv grn /usr/local/bin/ +curl -fsSL https://raw.githubusercontent.com/GreenNodeHub/greennode-cli/main/scripts/install.sh | bash +``` +See [Installation](installation.md) for Windows and build-from-source. + +```bash # Configure credentials grn configure diff --git a/docs/installation.md b/docs/installation.md index 1445775..a6655d6 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,56 +1,71 @@ # Installation -## Download binary - -Download the latest binary for your platform from [GitHub Releases](https://github.com/GreenNodeHub/greennode-cli/releases): - -### macOS +The GreenNode CLI (`grn`) is a single binary with zero dependencies. Install it with the one-liner for your platform below. +**macOS / Linux:** ```bash -# Apple Silicon (M1/M2/M3) -curl -L -o grn https://github.com/GreenNodeHub/greennode-cli/releases/latest/download/grn-darwin-arm64 - -# Intel -curl -L -o grn https://github.com/GreenNodeHub/greennode-cli/releases/latest/download/grn-darwin-amd64 +curl -fsSL https://raw.githubusercontent.com/GreenNodeHub/greennode-cli/main/scripts/install.sh | bash +``` -chmod +x grn -sudo mv grn /usr/local/bin/ +**Windows (PowerShell):** +```powershell +irm https://raw.githubusercontent.com/GreenNodeHub/greennode-cli/main/scripts/install.ps1 | iex ``` -### Linux +**Windows (CMD — for environments without PowerShell):** +```cmd +curl -fsSL https://raw.githubusercontent.com/GreenNodeHub/greennode-cli/main/scripts/install.cmd -o install.cmd && install.cmd && del install.cmd +``` +**Build from source** (fallback): ```bash -# x86_64 -curl -L -o grn https://github.com/GreenNodeHub/greennode-cli/releases/latest/download/grn-linux-amd64 - -# ARM64 -curl -L -o grn https://github.com/GreenNodeHub/greennode-cli/releases/latest/download/grn-linux-arm64 +git clone https://github.com/GreenNodeHub/greennode-cli.git +cd greennode-cli/go +go build -o grn . +# place grn on your PATH +``` -chmod +x grn -sudo mv grn /usr/local/bin/ +Verify: +```bash +grn --version ``` -### Windows +## Uninstall -Download `grn-windows-amd64.exe` from [GitHub Releases](https://github.com/GreenNodeHub/greennode-cli/releases) and add to your PATH. +Remove the binary + PATH entry with the one-liner for your platform: -## Build from source +**macOS / Linux:** +```bash +curl -fsSL https://raw.githubusercontent.com/GreenNodeHub/greennode-cli/main/scripts/uninstall.sh | bash +``` -Requires [Go 1.22+](https://go.dev/dl/): +**Windows (PowerShell):** +```powershell +irm https://raw.githubusercontent.com/GreenNodeHub/greennode-cli/main/scripts/uninstall.ps1 | iex +``` -```bash -git clone https://github.com/GreenNodeHub/greennode-cli.git -cd greennode-cli/go -go build -o grn . -sudo mv grn /usr/local/bin/ +**Windows (CMD — for environments without PowerShell):** +```cmd +curl -fsSL https://raw.githubusercontent.com/GreenNodeHub/greennode-cli/main/scripts/uninstall.cmd -o uninstall.cmd && uninstall.cmd && del uninstall.cmd ``` -## Verify installation +This removes: +- **macOS / Linux:** `~/.local/lib/greennode/`, the `~/.local/bin/grn` symlink, and the PATH line from your shell rc. +- **Windows:** `%LOCALAPPDATA%\greennode\` and its entry in the User PATH. + +To also remove config + credentials (`~/.greennode/` on macOS/Linux, `%USERPROFILE%\.greennode\` on Windows), pass `--purge` (`-Purge` in PowerShell): ```bash -grn --version -# grn-cli/1.5.1 Go/1.22.12 darwin/arm64 +curl -fsSL https://raw.githubusercontent.com/GreenNodeHub/greennode-cli/main/scripts/uninstall.sh | bash -s -- --purge ``` +```powershell +& ([scriptblock]::Create((irm https://raw.githubusercontent.com/GreenNodeHub/greennode-cli/main/scripts/uninstall.ps1))) -Purge +``` +```cmd +curl -fsSL https://raw.githubusercontent.com/GreenNodeHub/greennode-cli/main/scripts/uninstall.cmd -o uninstall.cmd && uninstall.cmd --purge && del uninstall.cmd +``` + +Restart your shell (or open a new terminal) for PATH changes to take effect. ## Next steps diff --git a/docs/usage/global-options.md b/docs/usage/global-options.md index 4c927b0..1117add 100644 --- a/docs/usage/global-options.md +++ b/docs/usage/global-options.md @@ -8,7 +8,7 @@ grn [global-options] [command-options] ## Environment variables -Global options can also be set via environment variables. See the [Configuration Guide](../configuration.md#environment-variables) for the full list, including `GRN_ACCESS_KEY_ID` and `GRN_SECRET_ACCESS_KEY` for credential overrides. +Global options can also be set via environment variables. See the [Configuration Guide](../configuration.md#environment-variables) for the full list, including `GRN_CLIENT_ID` and `GRN_CLIENT_SECRET` for credential overrides. ## Options @@ -38,6 +38,10 @@ grn --region HAN vks list-clusters # Custom endpoint (for local testing) grn --endpoint-url http://localhost:8080 vks list-clusters +# Custom agentbase endpoint (swaps every agentbase service endpoint host, +# keeping each service's path: identity→…/identity, runtime→…/runtime, …) +grn --endpoint-url https://agentbase-staging.example.com agentbase access agent-id list + # Disable SSL (dev only) grn --no-verify-ssl vks list-clusters # Warning: SSL certificate verification is disabled. This is insecure and should only be used for testing. diff --git a/go/cmd/agentbase/identity.go b/go/cmd/agentbase/access.go similarity index 98% rename from go/cmd/agentbase/identity.go rename to go/cmd/agentbase/access.go index 7967479..b2d31eb 100644 --- a/go/cmd/agentbase/identity.go +++ b/go/cmd/agentbase/access.go @@ -17,10 +17,10 @@ import ( coreconfig "github.com/greennodehub/greennode-cli/internal/config" ) -// --- root identity command --- +// --- root access command (was identity; renamed to match the portal) --- var identityCmd = &cobra.Command{ - Use: "identity", + Use: "access", Short: "Manage authentication and agent identities", Long: `Manage agent identities and configure outbound authentication providers. @@ -36,12 +36,12 @@ token source agentbase uses, exactly like vks/vserver.`, // auth_mode, iam_env, client_id, refresh_token and — now — agent_identity. The // current environment + resolved endpoints live at 'grn agentbase context current'. -// --- identity workload --- +// --- access agent-id (was workload; renamed to match the portal) --- var workloadCmd = &cobra.Command{ - Use: "workload", - Short: "Manage agent workload identities", - Long: `Create, list, get, update, and delete agent workload identities used to represent digital identities for agents accessing external services.`, + Use: "agent-id", + Short: "Manage agent identities", + Long: `Create, list, get, update, and delete agent identities used to represent digital identities for agents accessing external services.`, } var ( @@ -256,7 +256,7 @@ var workloadUseCmd = &cobra.Command{ var workloadDeleteCmd = &cobra.Command{ Use: "delete ", Short: "Delete an agent identity", - Long: `Delete an agent identity by name. The identity will be soft-deleted.`, + Long: `Delete an agent identity by name.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() diff --git a/go/cmd/agentbase/agentbase.go b/go/cmd/agentbase/agentbase.go index f741dd3..abf2943 100644 --- a/go/cmd/agentbase/agentbase.go +++ b/go/cmd/agentbase/agentbase.go @@ -48,10 +48,15 @@ agentbase shares the ~/.greennode profile with the rest of the CLI. Configure machine credentials with 'grn configure' (or log in as a user with 'grn login'); select dev/prod with 'grn configure set iam_env ' (machine) or 'grn login --iam-env ' (user); set the current agent with 'grn agentbase -identity workload use '. Run 'grn agentbase context current' to see the +access agent-id use '. Run 'grn agentbase context current' to see the active environment and endpoints.`, PersistentPreRun: func(cmd *cobra.Command, args []string) { - output.SetFormat(output.ParseFormat(outputFormat)) + // Honor ~/.greennode/config's output key as the default format when + // --output/-o is not passed explicitly, matching cli.Output's fallback + // for vks/vserver. agentbase keeps its own --output/-o flag (shorthand + // + table default), so effectiveOutputFormat is the bridge that makes + // the shared config govern agentbase like the rest of the CLI. + output.SetFormat(output.ParseFormat(effectiveOutputFormat(cmd, outputFormat))) cliinput.SetInteractive(interactiveMode) }, } diff --git a/go/cmd/agentbase/agentbase_test.go b/go/cmd/agentbase/agentbase_test.go index 0c54d53..a45d130 100644 --- a/go/cmd/agentbase/agentbase_test.go +++ b/go/cmd/agentbase/agentbase_test.go @@ -77,39 +77,40 @@ func TestAgentbaseCmd_PersistentFlags(t *testing.T) { } } -// TestAgentbaseCmd_HasIdentitySubtree verifies the identity group and its -// workload CRUD subtree mounted under `grn agentbase`. identity login/logout +// TestAgentbaseCmd_HasAccessSubtree verifies the access group and its +// agent-id CRUD subtree mounted under `grn agentbase`. access login/logout // were removed when agentbase unified onto `grn configure`/`grn login`/`grn logout`; -// identity whoami/config were removed to defer config display to 'grn configure'. -func TestAgentbaseCmd_HasIdentitySubtree(t *testing.T) { - identityCmd, _, err := AgentbaseCmd.Find([]string{"identity"}) +// access whoami/config were removed to defer config display to 'grn configure'. +// (Renamed from `identity`/`workload` to match the portal.) +func TestAgentbaseCmd_HasAccessSubtree(t *testing.T) { + accessCmd, _, err := AgentbaseCmd.Find([]string{"access"}) if err != nil { - t.Fatalf("agentbase has no 'identity' subcommand: %v", err) + t.Fatalf("agentbase has no 'access' subcommand: %v", err) } - for _, want := range []string{"workload", "outbound-auth"} { - if _, _, err := identityCmd.Find([]string{want}); err != nil { - t.Errorf("identity missing subcommand %q: %v", want, err) + for _, want := range []string{"agent-id", "outbound-auth"} { + if _, _, err := accessCmd.Find([]string{want}); err != nil { + t.Errorf("access missing subcommand %q: %v", want, err) } } for _, gone := range []string{"login", "logout", "whoami", "config"} { found := false - for _, c := range identityCmd.Commands() { + for _, c := range accessCmd.Commands() { if c.Name() == gone { found = true break } } if found { - t.Errorf("identity should no longer have %q (defer to grn configure/grn login/grn logout)", gone) + t.Errorf("access should no longer have %q (defer to grn configure/grn login/grn logout)", gone) } } - workloadCmd, _, err := identityCmd.Find([]string{"workload"}) + agentIDCmd, _, err := accessCmd.Find([]string{"agent-id"}) if err != nil { - t.Fatalf("identity has no 'workload' subcommand: %v", err) + t.Fatalf("access has no 'agent-id' subcommand: %v", err) } for _, want := range []string{"create", "list", "get", "update", "use", "delete"} { - if _, _, err := workloadCmd.Find([]string{want}); err != nil { - t.Errorf("workload missing subcommand %q: %v", want, err) + if _, _, err := agentIDCmd.Find([]string{want}); err != nil { + t.Errorf("agent-id missing subcommand %q: %v", want, err) } } } @@ -128,8 +129,43 @@ func TestAgentbaseCmd_HasGatewaySubtree(t *testing.T) { } } +// TestAgentbaseCmd_HasMarketplaceSubtree verifies the marketplace group +// (renamed from `catalog`) and its children are mounted under `grn agentbase`. +func TestAgentbaseCmd_HasMarketplaceSubtree(t *testing.T) { + mktCmd, _, err := AgentbaseCmd.Find([]string{"marketplace"}) + if err != nil { + t.Fatalf("agentbase has no 'marketplace' subcommand: %v", err) + } + for _, want := range []string{"flavors", "openclaw-versions", "openclaw"} { + if _, _, err := mktCmd.Find([]string{want}); err != nil { + t.Errorf("marketplace missing subcommand %q: %v", want, err) + } + } +} + +// TestOldCommandNamesAbsent locks in the hard cut of the agentbase rename: +// identity→access, catalog→marketplace, workload→agent-id. Old names must be +// absent (no aliases were kept). Uses subCmdExists (Commands() iteration) +// because cobra's Find treats unknown trailing args as positionals and does not +// reliably signal absence (see the subCmdExists comment above). +func TestOldCommandNamesAbsent(t *testing.T) { + if subCmdExists(AgentbaseCmd, "identity") { + t.Error("agentbase should not have 'identity' (renamed to 'access')") + } + if subCmdExists(AgentbaseCmd, "catalog") { + t.Error("agentbase should not have 'catalog' (renamed to 'marketplace')") + } + accessCmd, _, err := AgentbaseCmd.Find([]string{"access"}) + if err != nil { + t.Fatalf("agentbase has no 'access' subcommand: %v", err) + } + if subCmdExists(accessCmd, "workload") { + t.Error("access should not have 'workload' (renamed to 'agent-id')") + } +} + // TestJoinStrings_jsonsliceArray ports the agentbase helper test for joinStrings -// (defined in identity.go). +// (defined in access.go). func TestJoinStrings_jsonsliceArray(t *testing.T) { if got := joinStrings(jsonslice.Array[string]{"a", "b"}, ", "); got != "a, b" { t.Errorf("got %q", got) diff --git a/go/cmd/agentbase/catalog.go b/go/cmd/agentbase/catalog.go index cad9743..3887603 100644 --- a/go/cmd/agentbase/catalog.go +++ b/go/cmd/agentbase/catalog.go @@ -13,14 +13,13 @@ import ( "github.com/greennodehub/greennode-cli/internal/agentbase/output" ) -// catalogCmd groups the catalog commands. The catalog surface (compute flavors, -// openclaw versions, and openclaw CRUD/lifecycle) is served by the -// agent-core-runtime service under its /v1 prefix, so the client uses the same -// base URL (ab.endpoints.Runtime) as the runtime client. +// catalogCmd groups the marketplace commands (was `catalog`; renamed to match +// the portal). The marketplace surface (compute flavors, openclaw versions, and +// openclaw CRUD/lifecycle) shares the runtime client's base URL. var catalogCmd = &cobra.Command{ - Use: "catalog", - Short: "Browse the catalog (flavors, openclaw versions, openclaws)", - Long: `Browse and manage the agent-core-runtime catalog. + Use: "marketplace", + Short: "Browse the marketplace (flavors, openclaw versions, openclaws)", + Long: `Browse and manage the marketplace catalog. Two groups: @@ -31,7 +30,7 @@ Two groups: openclaw — OpenClaw instances: list/get/create/delete + start/stop and switch version. -Catalog ops share the ~/.greennode profile and the runtime endpoint.`, +Marketplace ops share the ~/.greennode profile and the runtime endpoint.`, } // catalogFlavorsCmd groups the flavor commands. diff --git a/go/cmd/agentbase/cr.go b/go/cmd/agentbase/cr.go index 6983c87..c534d98 100644 --- a/go/cmd/agentbase/cr.go +++ b/go/cmd/agentbase/cr.go @@ -20,7 +20,7 @@ import ( var crCmd = &cobra.Command{ Use: "cr", Short: "Manage the container registry (repository, images, artifacts, robot credential)", - Long: `Manage the agentbase container registry (agent-core-container-registry). + Long: `Manage the agentbase container registry. A user's repository and robot account are auto-provisioned on first read, so there is no 'create' — they appear when first fetched. You can list/delete diff --git a/go/cmd/agentbase/gateway.go b/go/cmd/agentbase/gateway.go index 883293d..dea5cd7 100644 --- a/go/cmd/agentbase/gateway.go +++ b/go/cmd/agentbase/gateway.go @@ -26,7 +26,7 @@ import ( var gatewayCmd = &cobra.Command{ Use: "gateway", Short: "Manage MCP gateways", - Long: `Create and manage MCP gateways (the agent-core-gateway service). + Long: `Create and manage MCP gateways (the gateway service). A gateway is created asynchronously: create/update/delete return the resource in a WAITING_* state and converge to ACTIVE (or DELETED). Use 'wait' to block diff --git a/go/cmd/agentbase/helpers.go b/go/cmd/agentbase/helpers.go index f79116f..64b591d 100644 --- a/go/cmd/agentbase/helpers.go +++ b/go/cmd/agentbase/helpers.go @@ -2,6 +2,7 @@ package agentbase import ( "fmt" + "net/url" "os" "github.com/spf13/cobra" @@ -61,6 +62,12 @@ func envFromIamEnv(iamEnv string) agentbaseconfig.Env { // mustLoadConfig behavior for display commands); credential/token errors are // NOT fatal here — they surface from newAuthProvider/newIdentityClient so RunE // can return them. +// +// The root --endpoint-url override is honored here, the same flag vks/vserver +// consume via cli.NewClient: when set, cli.CheckEndpoint enforces the endpoint +// safety policy (trusted-host/TLS/allow-untrusted) and overrideEndpointHosts +// repoints the agentbase service endpoints at it. Without this, agentbase alone +// ignored --endpoint-url and always used the iam_env-derived endpoints. func mustLoadAgentbaseCtx(cmd *cobra.Command) *agentbaseCtx { shared, err := coreconfig.LoadConfig(resolveProfile(cmd)) if err != nil { @@ -68,11 +75,105 @@ func mustLoadAgentbaseCtx(cmd *cobra.Command) *agentbaseCtx { os.Exit(1) } env := envFromIamEnv(shared.IamEnv) + endpoints := agentbaseconfig.EndpointsForEnv(env) + + if endpointURL := flagString(cmd, "endpoint-url"); endpointURL != "" { + if err := cli.CheckEndpoint(endpointURL, flagBool(cmd, "no-verify-ssl"), flagBool(cmd, "allow-untrusted-endpoint")); err != nil { + fmt.Fprintln(os.Stderr, "Error:", err) + os.Exit(1) + } + if err := overrideEndpointHosts(&endpoints, endpointURL); err != nil { + fmt.Fprintln(os.Stderr, "Error:", err) + os.Exit(1) + } + } + return &agentbaseCtx{ shared: shared, env: env, - endpoints: agentbaseconfig.EndpointsForEnv(env), + endpoints: endpoints, + } +} + +// flagString/flagBool are nil-safe persistent-flag readers. agentbase's helpers +// accept a zero *cobra.Command from white-box tests (see resolveProfile's nil +// guard); these keep that contract for the endpoint-url/no-verify-ssl flags. +func flagString(cmd *cobra.Command, name string) string { + if cmd != nil { + if v, err := cmd.Flags().GetString(name); err == nil { + return v + } + } + return "" +} + +func flagBool(cmd *cobra.Command, name string) bool { + if cmd != nil { + if v, err := cmd.Flags().GetBool(name); err == nil { + return v + } + } + return false +} + +// resolveOutputFormat picks the effective agentbase output format: an explicit +// --output/-o wins; otherwise the profile's config-file output key; otherwise +// flagValue (the flag's own default, "table"). This is the pure core of +// effectiveOutputFormat — kept IO-free so it is unit-testable without a +// ~/.greennode on disk. agentbase keeps its own --output/-o flag (shorthand + +// table default) rather than inheriting the root --output, so this fallback is +// what makes ~/.greennode/config's output key govern agentbase like the rest of +// the CLI (cli.Output applies the same fallback for vks/vserver). +func resolveOutputFormat(cmd *cobra.Command, flagValue, cfgOutput string) string { + if cmd != nil && cmd.Flags().Changed("output") { + return flagValue + } + if cfgOutput != "" { + return cfgOutput + } + return flagValue +} + +// effectiveOutputFormat is the PersistentPreRun wiring for resolveOutputFormat: +// it loads the resolved profile's config-file output key (missing config is +// non-fatal here — the command's RunE will surface a real LoadConfig error via +// mustLoadAgentbaseCtx) and delegates to resolveOutputFormat. +func effectiveOutputFormat(cmd *cobra.Command, flagValue string) string { + cfgOutput := "" + if cfg, err := coreconfig.LoadConfig(resolveProfile(cmd)); err == nil { + cfgOutput = cfg.Output + } + return resolveOutputFormat(cmd, flagValue, cfgOutput) +} + +// overrideEndpointHosts repoints every agentbase service endpoint at +// endpointURL by swapping its scheme+host[:port], preserving the per-service +// path (e.g. /identity, /runtime, /agent-core-runtime). This lets +// --endpoint-url point agentbase at a different deployment while keeping +// service routing intact — a bare host ("https://staging.example.com") reaches +// every service at its real path, and even a full service URL pasted from +// `agentbase context current` works (its path is ignored, only the host is +// swapped in). OAuth2Token is untouched: it is the IAM token URL the auth +// provider resolves from iam_env, not a service call, so overriding it would +// lie in `agentbase context current` about where tokens are minted. +func overrideEndpointHosts(eps *agentbaseconfig.Endpoints, endpointURL string) error { + override, err := url.Parse(endpointURL) + if err != nil { + return fmt.Errorf("invalid --endpoint-url %q: %w", endpointURL, err) + } + if override.Scheme == "" || override.Host == "" { + return fmt.Errorf("invalid --endpoint-url %q: want scheme://host[:port]", endpointURL) + } + for _, p := range []*string{&eps.Identity, &eps.Runtime, &eps.Memory, &eps.Gateway, &eps.Policy, &eps.Cr} { + u, err := url.Parse(*p) + if err != nil { + return fmt.Errorf("invalid agentbase endpoint %q: %w", *p, err) + } + u.Scheme = override.Scheme + u.Host = override.Host + *p = u.String() } + return nil } // newAuthProvider is the single shared auth selector for agentbase — identical diff --git a/go/cmd/agentbase/helpers_test.go b/go/cmd/agentbase/helpers_test.go index cf3b4fa..6de57c5 100644 --- a/go/cmd/agentbase/helpers_test.go +++ b/go/cmd/agentbase/helpers_test.go @@ -4,6 +4,8 @@ import ( "strings" "testing" + "github.com/spf13/cobra" + agentbaseconfig "github.com/greennodehub/greennode-cli/internal/agentbase/config" "github.com/greennodehub/greennode-cli/internal/auth" coreconfig "github.com/greennodehub/greennode-cli/internal/config" @@ -83,3 +85,99 @@ func TestNewAuthProvider_MachineMissingCreds_Errors(t *testing.T) { t.Errorf("err=%v, want guidance mentioning `grn configure`", err) } } + +// resolveOutputFormat is the Fix A core: an explicit --output/-o wins, else the +// config-file output key, else the flag default. Pure (no ~/.greennode IO) so it +// is unit-testable without touching disk; effectiveOutputFormat is the thin +// wiring that loads the config key and calls this. + +func TestResolveOutputFormat(t *testing.T) { + t.Parallel() + newCmd := func(setExplicit bool) *cobra.Command { + c := &cobra.Command{} + c.Flags().StringP("output", "o", "table", "") + if setExplicit { + _ = c.Flags().Set("output", "id") + } + return c + } + cases := []struct { + name string + cmd *cobra.Command + flagValue string + cfgOutput string + want string + }{ + {"explicit flag wins over config", newCmd(true), "id", "json", "id"}, + {"config fallback when flag unchanged", newCmd(false), "table", "json", "json"}, + {"flag default when config empty", newCmd(false), "table", "", "table"}, + {"nil cmd falls back to config", nil, "table", "json", "json"}, + {"nil cmd empty config uses flag default", nil, "table", "", "table"}, + } + for _, tc := range cases { + if got := resolveOutputFormat(tc.cmd, tc.flagValue, tc.cfgOutput); got != tc.want { + t.Errorf("%s: got %q, want %q", tc.name, got, tc.want) + } + } +} + +// overrideEndpointHosts is the Fix B core: --endpoint-url swaps scheme+host of +// every agentbase service endpoint, preserving each service's path so routing +// still works against a real deployment. OAuth2Token (the IAM token URL) is +// untouched. Pure, so unit-testable without a profile on disk. + +func TestOverrideEndpointHosts(t *testing.T) { + t.Parallel() + prod := agentbaseconfig.EndpointsForEnv(agentbaseconfig.EnvProd) + + // Bare-host swap: every service keeps its path, host becomes the override. + eps := prod + if err := overrideEndpointHosts(&eps, "https://staging.example.com"); err != nil { + t.Fatalf("override: %v", err) + } + if eps.Identity != "https://staging.example.com/identity" { + t.Errorf("Identity=%q, want https://staging.example.com/identity", eps.Identity) + } + if eps.Runtime != "https://staging.example.com/runtime" { + t.Errorf("Runtime=%q, want https://staging.example.com/runtime", eps.Runtime) + } + if eps.OAuth2Token != prod.OAuth2Token { + t.Errorf("OAuth2Token=%q, want %q (IAM token URL must be untouched)", eps.OAuth2Token, prod.OAuth2Token) + } + + // Full-URL override (e.g. pasted from `agentbase context current`): the + // override's own path is ignored — only scheme+host are swapped in — so + // Runtime still gets /runtime, not /identity. + eps2 := prod + if err := overrideEndpointHosts(&eps2, "https://staging.example.com/identity"); err != nil { + t.Fatalf("override: %v", err) + } + if eps2.Identity != "https://staging.example.com/identity" { + t.Errorf("Identity=%q, want https://staging.example.com/identity", eps2.Identity) + } + if eps2.Runtime != "https://staging.example.com/runtime" { + t.Errorf("Runtime=%q, want https://staging.example.com/runtime (override path ignored)", eps2.Runtime) + } + + // Dev split-host topology: services live on different hosts with different + // path segments; the swap repoints every host while keeping each path. + dev := agentbaseconfig.EndpointsForEnv(agentbaseconfig.EnvDev) + if err := overrideEndpointHosts(&dev, "https://proxy.example.com"); err != nil { + t.Fatalf("override: %v", err) + } + if dev.Identity != "https://proxy.example.com/identity" { + t.Errorf("dev Identity=%q, want https://proxy.example.com/identity", dev.Identity) + } + if dev.Runtime != "https://proxy.example.com/agent-core-runtime" { + t.Errorf("dev Runtime=%q, want https://proxy.example.com/agent-core-runtime", dev.Runtime) + } + + // Invalid override (missing scheme/host) is rejected before any endpoint is + // mutated, so a bad --endpoint-url never leaves a half-overwritten ctx. + bad := prod + if err := overrideEndpointHosts(&bad, "not-a-url"); err == nil { + t.Errorf("expected error for invalid --endpoint-url, got nil") + } else if bad.Identity != prod.Identity { + t.Errorf("endpoints mutated on error: Identity=%q, want %q", bad.Identity, prod.Identity) + } +} diff --git a/go/cmd/agentbase/memory.go b/go/cmd/agentbase/memory.go index b9ab37d..95f4773 100644 --- a/go/cmd/agentbase/memory.go +++ b/go/cmd/agentbase/memory.go @@ -22,12 +22,11 @@ import ( var memoryCmd = &cobra.Command{ Use: "memory", Short: "Manage agent memories", - Long: `Create and manage agent memories (the agent-core-memory service). + Long: `Create and manage agent memories (the memory service). A memory is a container holding an agent's long-term facts (memory records, backed by an external Mem0 vector store) and short-term conversation events. -Memories are created synchronously (no WAITING_* state) and soft-deleted -(ACTIVE → DELETED). +Memories are created synchronously (no WAITING_* state) (ACTIVE → DELETED). The signature command is 'search' — semantic search over a memory's long-term facts: diff --git a/go/cmd/agentbase/policy.go b/go/cmd/agentbase/policy.go index ba57827..0697604 100644 --- a/go/cmd/agentbase/policy.go +++ b/go/cmd/agentbase/policy.go @@ -20,15 +20,15 @@ import ( var policyCmd = &cobra.Command{ Use: "policy", Short: "Manage authorization policy groups, policies, and decisions", - Long: `Manage Cedar authorization policies (the agent-core-policy service). + Long: `Manage Cedar authorization policies (the policy service). A "policy group" is a container of policies owned by a user (max 20/user). Each group holds "policies" — individual permit/forbid rules (max 10/group) compiled to Cedar. A gateway binds a group via its policyGroupId; the gateway's policy -enforcement then asks agent-core-policy for an allow/denied decision per inbound +enforcement then asks the policy service for an allow/denied decision per inbound request. -The decision route is internal (called by agent-core-gateway), but is exposed +The decision route is internal (called by the gateway service), but is exposed here as a probe: grn agentbase policy group create --name my-group @@ -603,7 +603,7 @@ var policyDecideFile string var policyDecideCmd = &cobra.Command{ Use: "decide ", Short: "Probe an authorization decision for a gateway target", - Long: `Probe an authorization decision (the internal route agent-core-gateway calls + Long: `Probe an authorization decision (the internal route the gateway service calls per inbound request). Always returns a decision: allow, or deny with a reason. The minimal flag path covers the common probe (policy group + user + action diff --git a/go/cmd/agentbase/runtime.go b/go/cmd/agentbase/runtime.go index 62c9a03..191857c 100644 --- a/go/cmd/agentbase/runtime.go +++ b/go/cmd/agentbase/runtime.go @@ -28,7 +28,7 @@ import ( var runtimeCmd = &cobra.Command{ Use: "runtime", Short: "Manage agent runtimes", - Long: `Create and manage agent runtimes (the agent-core-runtime service). + Long: `Create and manage agent runtimes (the runtime service). An agent runtime is a deployable container defined by its image, command, args, environment, autoscaling, and flavor. Runtimes converge asynchronously: @@ -594,7 +594,7 @@ var runtimeEndpointCmd = &cobra.Command{Use: "endpoint", Short: "Manage a runtim var runtimeTraceCmd = &cobra.Command{ Use: "trace", Short: "Query the tracing backend (raw passthrough)", - Long: `Query the agent-core-runtime tracing backend. + Long: `Query the runtime tracing backend. These are Google-AIP custom verbs that forward arbitrary query params to the tracing backend and return that backend's raw JSON. Use --param key=value diff --git a/go/cmd/configure/list.go b/go/cmd/configure/list.go index 4029c38..be158e4 100644 --- a/go/cmd/configure/list.go +++ b/go/cmd/configure/list.go @@ -63,7 +63,7 @@ func runList(cmd *cobra.Command, args []string) { resolveCredEntryPlain("iam_env", cfg.IamEnv, credsFile), resolveCredEntryPlain("token_expires_at", tokenExpiresAtStr(cfg.TokenExpiresAt), credsFile), // agent_identity: the agentbase current-agent selection, persisted by - // 'grn agentbase identity workload use|create --set-current'. Non-secret. + // 'grn agentbase access agent-id use|create --set-current'. Non-secret. resolveCredEntryPlain("agent_identity", cfg.AgentIdentity, credsFile), } @@ -96,8 +96,8 @@ func resolveCredEntry(name, value, credsFile string) configEntry { // Check if value came from env var envMap := map[string]string{ - "client_id": "GRN_ACCESS_KEY_ID", - "client_secret": "GRN_SECRET_ACCESS_KEY", + "client_id": "GRN_CLIENT_ID", + "client_secret": "GRN_CLIENT_SECRET", } if envVar, ok := envMap[name]; ok { if os.Getenv(envVar) != "" { diff --git a/go/cmd/configure/set.go b/go/cmd/configure/set.go index 2ebf7ea..93ee7f4 100644 --- a/go/cmd/configure/set.go +++ b/go/cmd/configure/set.go @@ -82,7 +82,7 @@ func runSet(cmd *cobra.Command, args []string) { os.Exit(1) } // agent_identity: the agentbase current-agent selection. Normally set via - // 'grn agentbase identity workload use|create --set-current', but exposed here + // 'grn agentbase access agent-id use|create --set-current', but exposed here // so it is settable / clearable like any other profile key. case "agent_identity": if err := writer.WriteAgentIdentity(profile, value); err != nil { diff --git a/go/cmd/configure/set_test.go b/go/cmd/configure/set_test.go index 86b6ccf..1a38635 100644 --- a/go/cmd/configure/set_test.go +++ b/go/cmd/configure/set_test.go @@ -100,7 +100,7 @@ func TestSetIamEnv_MachineProfileWrites(t *testing.T) { } // `configure set agent_identity ` persists the agentbase current-agent -// selection (the same key `agentbase identity workload use` writes) and is +// selection (the same key `agentbase access agent-id use` writes) and is // readable back through `configure get` — proving the deferral is non-lossy. func TestSetAgentIdentity_WritesAndReadsBack(t *testing.T) { home := t.TempDir() diff --git a/go/cmd/root.go b/go/cmd/root.go index cbca6e5..8ce3a1a 100644 --- a/go/cmd/root.go +++ b/go/cmd/root.go @@ -13,7 +13,7 @@ import ( "github.com/spf13/cobra" ) -const cliVersion = "1.12.0" // x-release-please-version +const cliVersion = "1.13.0" // x-release-please-version // Global flags var ( diff --git a/go/internal/agentbase/catalog/models.go b/go/internal/agentbase/catalog/models.go index b422c5c..9c84562 100644 --- a/go/internal/agentbase/catalog/models.go +++ b/go/internal/agentbase/catalog/models.go @@ -1,5 +1,5 @@ // Package catalog is the typed client for the catalog surface of the -// agent-core-runtime service (`grn agentbase catalog`). The catalog group lives +// agent-core-runtime service (`grn agentbase marketplace`). The catalog group lives // under the runtime service's /v1 prefix (/v1/flavors, /v1/openclaw-versions, // /v1/openclaws) and shares the runtime base URL + paging envelope // ({listData, page, pageSize, totalPage, totalItem}), so the client is built diff --git a/go/internal/agentbase/client/client.go b/go/internal/agentbase/client/client.go index ed5eddf..d4d4521 100644 --- a/go/internal/agentbase/client/client.go +++ b/go/internal/agentbase/client/client.go @@ -78,30 +78,17 @@ func (c *Client) doReq(ctx context.Context, method, path string, query url.Value fullURL += "?" + query.Encode() } - var bodyReader io.Reader + var data []byte if body != nil { - data, err := json.Marshal(body) + data, err = json.Marshal(body) if err != nil { return fmt.Errorf("failed to marshal request body: %w", err) } - bodyReader = bytes.NewReader(data) } - req, err := http.NewRequestWithContext(ctx, method, fullURL, bodyReader) + req, err := c.buildRequest(ctx, method, fullURL, data, headers, token) if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Authorization", "Bearer "+token) - if body != nil { - req.Header.Set("Content-Type", "application/json") - } - req.Header.Set("Accept", "application/json") - for k, v := range headers { - if k == "Authorization" || k == "Content-Type" || k == "Accept" { - continue - } - req.Header.Set(k, v) + return err } resp, err := c.httpClient.Do(req) @@ -115,6 +102,34 @@ func (c *Client) doReq(ctx context.Context, method, path string, query url.Value return fmt.Errorf("failed to read response body: %w", err) } + // 401 — force-refresh the token and retry once with the new bearer, mirroring + // internal/client.GreennodeClient so a mid-session access-token expiry (or + // server-side revocation) self-heals instead of surfacing as a hard APIError. + // The proactive GetToken above + the provider's pre-expiry skew handle the + // common case; this is the reactive backstop. A refresh failure, a transport + // error on the retry, or a second 401 is terminal — no further refresh. + if resp.StatusCode == http.StatusUnauthorized { + token, err = c.auth.RefreshToken() + if err != nil { + return err + } + req2, err := c.buildRequest(ctx, method, fullURL, data, headers, token) + if err != nil { + return err + } + resp2, err := c.httpClient.Do(req2) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + resp2Body, err := io.ReadAll(resp2.Body) + resp2.Body.Close() + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + resp = resp2 + respBody = resp2Body + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { return &APIError{StatusCode: resp.StatusCode, Body: string(respBody)} } @@ -128,6 +143,34 @@ func (c *Client) doReq(ctx context.Context, method, path string, query url.Value return nil } +// buildRequest assembles the authenticated *http.Request for a given bearer +// token. Shared by doReq's initial attempt and its 401 retry so the retry +// applies an identical header set — Authorization swapped for the fresh token, +// Content-Type/Accept/extra headers preserved. data is the already-marshaled +// JSON body (nil for bodyless requests); nil data means no Content-Type. +func (c *Client) buildRequest(ctx context.Context, method, fullURL string, data []byte, headers map[string]string, token string) (*http.Request, error) { + var bodyReader io.Reader + if data != nil { + bodyReader = bytes.NewReader(data) + } + req, err := http.NewRequestWithContext(ctx, method, fullURL, bodyReader) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + if data != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Accept", "application/json") + for k, v := range headers { + if k == "Authorization" || k == "Content-Type" || k == "Accept" { + continue + } + req.Header.Set(k, v) + } + return req, nil +} + // Get performs a GET request. func (c *Client) Get(ctx context.Context, path string, query url.Values, out interface{}) error { return c.Do(ctx, http.MethodGet, path, query, nil, out) diff --git a/go/internal/agentbase/client/client_test.go b/go/internal/agentbase/client/client_test.go index e5e8263..6182163 100644 --- a/go/internal/agentbase/client/client_test.go +++ b/go/internal/agentbase/client/client_test.go @@ -16,9 +16,12 @@ import ( // proves client.Do drives the Bearer header from any TokenProvider // implementor, not just the concrete auth providers. type fakeTokenProvider struct { - token string - err error - getCalls atomic.Int32 + token string + refreshToken string // returned by RefreshToken; falls back to token if empty + err error + refreshErr error + getCalls atomic.Int32 + refreshCalls atomic.Int32 } func (f *fakeTokenProvider) GetToken() (string, error) { @@ -27,7 +30,11 @@ func (f *fakeTokenProvider) GetToken() (string, error) { } func (f *fakeTokenProvider) RefreshToken() (string, error) { - return f.token, f.err + f.refreshCalls.Add(1) + if f.refreshToken != "" { + return f.refreshToken, f.refreshErr + } + return f.token, f.refreshErr } type stubResponse struct { @@ -258,3 +265,91 @@ func TestDo_UnchangedByHeaderSeam(t *testing.T) { t.Fatal(err) } } + +// TestDo_401RefreshRetriesOnce: a 401 triggers RefreshToken + a single retry +// with the new bearer, mirroring internal/client.GreennodeClient so a mid-session +// access-token expiry self-heals. The initial request (Bearer stale-token) gets +// 401; the retry (Bearer refreshed-token) gets 200. RefreshToken is called once; +// upstream sees exactly two requests. +func TestDo_401RefreshRetriesOnce(t *testing.T) { + var ( + gotAuths []string + reqCount atomic.Int32 + ) + fp := &fakeTokenProvider{token: "stale-token", refreshToken: "refreshed-token"} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuths = append(gotAuths, r.Header.Get("Authorization")) + reqCount.Add(1) + if r.Header.Get("Authorization") == "Bearer stale-token" { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(stubResponse{Message: "ok"}) + })) + t.Cleanup(srv.Close) + c := New(srv.URL, fp) + + var out stubResponse + if err := c.Get(context.Background(), "/test", nil, &out); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Message != "ok" { + t.Errorf("expected ok, got %s", out.Message) + } + if got := reqCount.Load(); got != 2 { + t.Errorf("upstream requests=%d, want 2 (initial 401 + retry)", got) + } + if got := fp.refreshCalls.Load(); got != 1 { + t.Errorf("RefreshToken calls=%d, want 1", got) + } + if len(gotAuths) != 2 || gotAuths[0] != "Bearer stale-token" || gotAuths[1] != "Bearer refreshed-token" { + t.Errorf("auth sequence=%v, want [Bearer stale-token, Bearer refreshed-token]", gotAuths) + } +} + +// TestDo_401RefreshFailsSurfacesError: if RefreshToken errors (e.g. refresh +// token revoked), the retry is abandoned and the error surfaces — no silent +// fallback, no upstream retry. +func TestDo_401RefreshFailsSurfacesError(t *testing.T) { + refreshErr := errors.New("login token expired or revoked — run `grn login`") + fp := &fakeTokenProvider{token: "stale-token", refreshErr: refreshErr} + var reqCount atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reqCount.Add(1) + w.WriteHeader(http.StatusUnauthorized) + })) + t.Cleanup(srv.Close) + c := New(srv.URL, fp) + + err := c.Get(context.Background(), "/test", nil, nil) + if !errors.Is(err, refreshErr) { + t.Errorf("err=%v, want refreshErr", err) + } + if got := reqCount.Load(); got != 1 { + t.Errorf("upstream requests=%d, want 1 (no retry after refresh failure)", got) + } +} + +// TestDo_401RetryStill401IsTerminal: a second 401 (after the one retry) is a +// hard APIError — the client does not refresh again. +func TestDo_401RetryStill401IsTerminal(t *testing.T) { + fp := &fakeTokenProvider{token: "stale-token", refreshToken: "refreshed-token"} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + t.Cleanup(srv.Close) + c := New(srv.URL, fp) + + err := c.Get(context.Background(), "/test", nil, nil) + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("err=%v, want *APIError", err) + } + if apiErr.StatusCode != http.StatusUnauthorized { + t.Errorf("status=%d, want 401", apiErr.StatusCode) + } + if got := fp.refreshCalls.Load(); got != 1 { + t.Errorf("RefreshToken calls=%d, want 1 (no second refresh on retry-401)", got) + } +} diff --git a/go/internal/auth/login_token.go b/go/internal/auth/login_token.go index af96d50..ffaf1b4 100644 --- a/go/internal/auth/login_token.go +++ b/go/internal/auth/login_token.go @@ -110,11 +110,16 @@ func (p *LoginTokenProvider) refresh() (string, error) { ClientSecret: p.clientSecret, Scope: "openid", }) - if err != nil { - return "", fmt.Errorf("%w: %v", ErrLoginTokenRefreshFailed, err) - } - if errResp != nil { - return "", fmt.Errorf("%w: iam status=%d body=%s", ErrLoginTokenRefreshFailed, errResp.Status, string(errResp.RawBody)) + if err != nil || errResp != nil { + // Refresh failed — transport error (err) or a non-2xx IAM response + // (errResp). Do not expose IAM's status code or raw body to the user: the + // actionable fix is the same in every case (re-login), and the raw envelope + // is noise that reads like a server incident when it's typically a token + // problem (IAM currently 500s with {"errors":[]} for an expired/revoked + // refresh token instead of a proper 400 invalid_grant). Return the sentinel + // alone so the surfaced error is just "login token expired or revoked — + // run `grn login`" (callers wrap it as "authentication failed: ..."). + return "", ErrLoginTokenRefreshFailed } tok, err := login.DecodeTokenBody(resp.Raw) if err != nil { diff --git a/go/internal/auth/login_token_test.go b/go/internal/auth/login_token_test.go index dca3110..bba1b50 100644 --- a/go/internal/auth/login_token_test.go +++ b/go/internal/auth/login_token_test.go @@ -172,6 +172,47 @@ func TestLoginTokenProvider_Non2xx_HardErrors(t *testing.T) { } } +// TestLoginTokenProvider_Non2xx_DoesNotExposeIAMDetails: a refresh-grant +// failure (here the 500 {"errors":[]} IAM returns for an expired/revoked RT) +// must surface as just the re-login guidance — no status code, no raw body — +// so the user-facing error isn't noise that looks like a server incident. +func TestLoginTokenProvider_Non2xx_DoesNotExposeIAMDetails(t *testing.T) { + t.Parallel() + srv, _ := newTokenServer(t, []string{`{"errors":[]}`}, http.StatusInternalServerError) + + p := NewLoginTokenProvider("rt-orig", "cid", "", srv.URL, nil) + _, err := p.GetToken() + if !errors.Is(err, ErrLoginTokenRefreshFailed) { + t.Errorf("err=%v, want ErrLoginTokenRefreshFailed", err) + } + for _, leak := range []string{"500", "errors", "status"} { + if strings.Contains(err.Error(), leak) { + t.Errorf("err=%q leaks IAM detail %q; want just the re-login guidance", err.Error(), leak) + } + } +} + +// TestLoginTokenProvider_TransportError_JustRequestsLogin: a transport-level +// refresh failure (IAM unreachable) also surfaces as just the re-login +// guidance — no connection/dial details leaked. +func TestLoginTokenProvider_TransportError_JustRequestsLogin(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + addr := srv.URL + srv.Close() // tear down → the next dial is refused + + p := NewLoginTokenProvider("rt-orig", "cid", "", addr, nil) + _, err := p.GetToken() + if !errors.Is(err, ErrLoginTokenRefreshFailed) { + t.Errorf("err=%v, want ErrLoginTokenRefreshFailed", err) + } + for _, leak := range []string{"connect", "refused", "dial", addr} { + if strings.Contains(err.Error(), leak) { + t.Errorf("err=%q leaks transport detail %q; want just the re-login guidance", err.Error(), leak) + } + } +} + func TestLoginTokenProvider_RefreshRequestShape(t *testing.T) { t.Parallel() body := `{"access_token":"at-1","token_type":"Bearer","refresh_token":"rt-orig","expires_in":3600}` diff --git a/go/internal/config/config.go b/go/internal/config/config.go index b8a3691..3b627d9 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -115,10 +115,10 @@ func LoadConfig(profile string) (*Config, error) { anyFileExists := false // Load credentials — env vars override file - if v := os.Getenv("GRN_ACCESS_KEY_ID"); v != "" { + if v := os.Getenv("GRN_CLIENT_ID"); v != "" { cfg.ClientID = v } - if v := os.Getenv("GRN_SECRET_ACCESS_KEY"); v != "" { + if v := os.Getenv("GRN_CLIENT_SECRET"); v != "" { cfg.ClientSecret = v } if cfg.ClientID != "" && cfg.ClientSecret != "" { diff --git a/go/internal/config/loadconfig_test.go b/go/internal/config/loadconfig_test.go index 7a350f0..f6846c3 100644 --- a/go/internal/config/loadconfig_test.go +++ b/go/internal/config/loadconfig_test.go @@ -13,7 +13,7 @@ func isolateConfigEnv(t *testing.T) string { home := t.TempDir() t.Setenv("HOME", home) for _, k := range []string{ - "GRN_PROFILE", "GRN_ACCESS_KEY_ID", "GRN_SECRET_ACCESS_KEY", + "GRN_PROFILE", "GRN_CLIENT_ID", "GRN_CLIENT_SECRET", "GRN_DEFAULT_REGION", "GRN_DEFAULT_PROJECT_ID", } { t.Setenv(k, "") @@ -91,6 +91,29 @@ func TestLoadConfigProfileInCredsOnly(t *testing.T) { } } +// Credential env vars GRN_CLIENT_ID / GRN_CLIENT_SECRET override the credentials +// file. Pins the renamed env var names (formerly GRN_ACCESS_KEY_ID / +// GRN_SECRET_ACCESS_KEY) so a drift back to the old names fails this test. +func TestLoadConfigEnvVarsOverrideFile(t *testing.T) { + dir := isolateConfigEnv(t) + writeFile(t, filepath.Join(dir, "credentials"), + "[default]\nclient_id = AKIA-file\nclient_secret = secret-file\n") + + t.Setenv("GRN_CLIENT_ID", "AKIA-env") + t.Setenv("GRN_CLIENT_SECRET", "secret-env") + + cfg, err := LoadConfig("default") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.ClientID != "AKIA-env" { + t.Errorf("client_id = %q, want AKIA-env (GRN_CLIENT_ID must override file)", cfg.ClientID) + } + if cfg.ClientSecret != "secret-env" { + t.Errorf("client_secret = %q, want secret-env (GRN_CLIENT_SECRET must override file)", cfg.ClientSecret) + } +} + // Fresh machine with no config files: not an error (preserves first-run UX); // returns a populated default cfg. func TestLoadConfigNoFiles(t *testing.T) { @@ -113,7 +136,7 @@ func isolateLegacyConfigEnv(t *testing.T) string { home := t.TempDir() t.Setenv("HOME", home) for _, k := range []string{ - "GRN_PROFILE", "GRN_ACCESS_KEY_ID", "GRN_SECRET_ACCESS_KEY", + "GRN_PROFILE", "GRN_CLIENT_ID", "GRN_CLIENT_SECRET", "GRN_DEFAULT_REGION", "GRN_DEFAULT_PROJECT_ID", } { t.Setenv(k, "") @@ -155,7 +178,7 @@ func TestLoadConfigPrefersNewDirOverLegacy(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) for _, k := range []string{ - "GRN_PROFILE", "GRN_ACCESS_KEY_ID", "GRN_SECRET_ACCESS_KEY", + "GRN_PROFILE", "GRN_CLIENT_ID", "GRN_CLIENT_SECRET", "GRN_DEFAULT_REGION", "GRN_DEFAULT_PROJECT_ID", } { t.Setenv(k, "") diff --git a/go/internal/config/writer.go b/go/internal/config/writer.go index e3edcfc..8d0dc75 100644 --- a/go/internal/config/writer.go +++ b/go/internal/config/writer.go @@ -24,7 +24,15 @@ func (w *ConfigFileWriter) ensureDir() error { return os.MkdirAll(w.configDir, 0700) } -// WriteCredentials writes client_id and client_secret for the given profile. +// WriteCredentials writes client_id and client_secret for the given profile and +// switches the profile to machine auth mode (auth_mode=machine), clearing any +// prior PKCE login token (refresh_token, token_expires_at). This is the +// symmetric inverse of WriteLoginToken (which sets auth_mode=user + a +// refresh_token): running `grn configure` or `grn configure set +// client_id/client_secret` after `grn login` repoints the profile at machine +// credentials — without this, the stale auth_mode=user left NewTokenProvider +// selecting LoginTokenProvider and ignoring the just-configured machine creds. +// iam_env is preserved (it selects the environment for both auth modes). func (w *ConfigFileWriter) WriteCredentials(profile, clientID, clientSecret string) error { if err := w.ensureDir(); err != nil { return fmt.Errorf("failed to create config directory: %w", err) @@ -42,6 +50,13 @@ func (w *ConfigFileWriter) WriteCredentials(profile, clientID, clientSecret stri } section.Key("client_id").SetValue(clientID) section.Key("client_secret").SetValue(clientSecret) + section.Key("auth_mode").SetValue("machine") + // Drop any prior PKCE login token — it is not active under machine mode. + // iam_env is intentionally preserved: it selects the environment for both + // auth modes (machine client_credentials + user refresh both resolve the + // IAM token URL from it). + section.DeleteKey("refresh_token") + section.DeleteKey("token_expires_at") return w.save(cfg, filePath) } @@ -162,7 +177,7 @@ func (w *ConfigFileWriter) ClearLoginToken(profile string) error { // write: loadOrCreate preserves every other key/section, NewSection is // idempotent (returns the existing section without wiping its keys), and save // is the same atomic 0600 rename used by WriteLoginToken. An empty name clears -// the key (explicit unset) rather than being a no-op, so `workload use` can +// the key (explicit unset) rather than being a no-op, so `agent-id use` can // distinguish "select none" from "no change". func (w *ConfigFileWriter) WriteAgentIdentity(profile, name string) error { if err := w.ensureDir(); err != nil { diff --git a/go/internal/config/writer_agent_test.go b/go/internal/config/writer_agent_test.go index 65129f1..a895660 100644 --- a/go/internal/config/writer_agent_test.go +++ b/go/internal/config/writer_agent_test.go @@ -57,7 +57,7 @@ func TestWriteAgentIdentity_PerProfileIsolation(t *testing.T) { } } -// An empty name CLEARS the key (explicit unset), not a no-op — so `workload use` +// An empty name CLEARS the key (explicit unset), not a no-op — so `agent-id use` // can distinguish "select none" from "no change". func TestWriteAgentIdentity_EmptyClearsKey(t *testing.T) { w := newHomeWriter(t) @@ -120,7 +120,7 @@ func TestWriteIamEnv_OnMachineProfile(t *testing.T) { func TestLoadConfig_ReadsAgentIdentity(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) - for _, k := range []string{"GRN_PROFILE", "GRN_ACCESS_KEY_ID", "GRN_SECRET_ACCESS_KEY", "GRN_DEFAULT_REGION", "GRN_DEFAULT_PROJECT_ID"} { + for _, k := range []string{"GRN_PROFILE", "GRN_CLIENT_ID", "GRN_CLIENT_SECRET", "GRN_DEFAULT_REGION", "GRN_DEFAULT_PROJECT_ID"} { t.Setenv(k, "") } dir := filepath.Join(home, ".greennode") @@ -148,7 +148,7 @@ func TestLoadConfig_ReadsAgentIdentity(t *testing.T) { func TestLoadConfig_MissingAgentIdentityIsEmpty(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) - for _, k := range []string{"GRN_PROFILE", "GRN_ACCESS_KEY_ID", "GRN_SECRET_ACCESS_KEY", "GRN_DEFAULT_REGION", "GRN_DEFAULT_PROJECT_ID"} { + for _, k := range []string{"GRN_PROFILE", "GRN_CLIENT_ID", "GRN_CLIENT_SECRET", "GRN_DEFAULT_REGION", "GRN_DEFAULT_PROJECT_ID"} { t.Setenv(k, "") } dir := filepath.Join(home, ".greennode") diff --git a/go/internal/config/writer_credentials_test.go b/go/internal/config/writer_credentials_test.go new file mode 100644 index 0000000..36c4406 --- /dev/null +++ b/go/internal/config/writer_credentials_test.go @@ -0,0 +1,64 @@ +package config + +import ( + "testing" + "time" +) + +// WriteCredentials switches the profile to machine auth mode and drops any prior +// PKCE login token — the symmetric inverse of WriteLoginToken. This is the fix +// for `grn login` then `grn configure`: without it the stale auth_mode=user left +// NewTokenProvider selecting LoginTokenProvider and ignoring the just-configured +// machine creds. +func TestWriteCredentials_SwitchesToMachineModeAndClearsLoginToken(t *testing.T) { + w := newHomeWriter(t) + // Start from a logged-in profile (auth_mode=user + refresh token + iam_env). + exp := time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC) + if err := w.WriteLoginToken("default", "rt-orig", exp, "user", "dev"); err != nil { + t.Fatalf("seed WriteLoginToken: %v", err) + } + + // User runs `grn configure` (or `configure set client_id/secret`) — repoints + // the profile at machine credentials. + if err := w.WriteCredentials("default", "cid", "cs"); err != nil { + t.Fatalf("WriteCredentials: %v", err) + } + + s := loadCredsFile(t).Section("default") + if got := s.Key("auth_mode").String(); got != "machine" { + t.Errorf("auth_mode=%q, want machine (configure must switch off user mode)", got) + } + if got := s.Key("client_id").String(); got != "cid" { + t.Errorf("client_id=%q, want cid", got) + } + if got := s.Key("client_secret").String(); got != "cs" { + t.Errorf("client_secret=%q, want cs", got) + } + // The prior PKCE login token must be gone — it is not active under machine mode. + if got := s.Key("refresh_token").String(); got != "" { + t.Errorf("refresh_token=%q after WriteCredentials, want empty (machine mode drops it)", got) + } + if got := s.Key("token_expires_at").String(); got != "" { + t.Errorf("token_expires_at=%q after WriteCredentials, want empty", got) + } + // iam_env is preserved: it selects the environment for both auth modes. + if got := s.Key("iam_env").String(); got != "dev" { + t.Errorf("iam_env=%q, want dev (preserved across auth-mode switch)", got) + } +} + +// WriteCredentials on a fresh profile (no prior login) just sets machine mode — +// there is nothing to clear, and it must not error. +func TestWriteCredentials_FreshProfileSetsMachineMode(t *testing.T) { + w := newHomeWriter(t) + if err := w.WriteCredentials("default", "cid", "cs"); err != nil { + t.Fatalf("WriteCredentials: %v", err) + } + s := loadCredsFile(t).Section("default") + if got := s.Key("auth_mode").String(); got != "machine" { + t.Errorf("auth_mode=%q, want machine", got) + } + if got := s.Key("client_id").String(); got != "cid" { + t.Errorf("client_id=%q, want cid", got) + } +} diff --git a/go/internal/config/writer_login_test.go b/go/internal/config/writer_login_test.go index eb6ac9b..754b256 100644 --- a/go/internal/config/writer_login_test.go +++ b/go/internal/config/writer_login_test.go @@ -205,7 +205,7 @@ func TestClearLoginToken_RemovesLegacyLoginClientID(t *testing.T) { func TestLoadConfig_ReadsLoginKeys(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) - for _, k := range []string{"GRN_PROFILE", "GRN_ACCESS_KEY_ID", "GRN_SECRET_ACCESS_KEY", "GRN_DEFAULT_REGION", "GRN_DEFAULT_PROJECT_ID"} { + for _, k := range []string{"GRN_PROFILE", "GRN_CLIENT_ID", "GRN_CLIENT_SECRET", "GRN_DEFAULT_REGION", "GRN_DEFAULT_PROJECT_ID"} { t.Setenv(k, "") } dir := filepath.Join(home, ".greennode") @@ -247,7 +247,7 @@ func TestLoadConfig_ReadsLoginKeys(t *testing.T) { func TestLoadConfig_LoginOnlyProfileExists(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) - for _, k := range []string{"GRN_PROFILE", "GRN_ACCESS_KEY_ID", "GRN_SECRET_ACCESS_KEY", "GRN_DEFAULT_REGION", "GRN_DEFAULT_PROJECT_ID"} { + for _, k := range []string{"GRN_PROFILE", "GRN_CLIENT_ID", "GRN_CLIENT_SECRET", "GRN_DEFAULT_REGION", "GRN_DEFAULT_PROJECT_ID"} { t.Setenv(k, "") } dir := filepath.Join(home, ".greennode") diff --git a/mkdocs.yml b/mkdocs.yml index 9245406..b2cbbdc 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -79,32 +79,32 @@ nav: - current: commands/agentbase/context/current.md - headers: commands/agentbase/context/headers.md - decorators: commands/agentbase/context/decorators.md - - Identity: - - Overview: commands/agentbase/identity/index.md - - workload create: commands/agentbase/identity/workload-create.md - - workload list: commands/agentbase/identity/workload-list.md - - workload get: commands/agentbase/identity/workload-get.md - - workload update: commands/agentbase/identity/workload-update.md - - workload use: commands/agentbase/identity/workload-use.md - - workload delete: commands/agentbase/identity/workload-delete.md - - outbound-auth static create: commands/agentbase/identity/outbound-auth-static-create.md - - outbound-auth static list: commands/agentbase/identity/outbound-auth-static-list.md - - outbound-auth static get: commands/agentbase/identity/outbound-auth-static-get.md - - outbound-auth static update: commands/agentbase/identity/outbound-auth-static-update.md - - outbound-auth static delete: commands/agentbase/identity/outbound-auth-static-delete.md - - outbound-auth static get-key: commands/agentbase/identity/outbound-auth-static-get-key.md - - outbound-auth delegated create: commands/agentbase/identity/outbound-auth-delegated-create.md - - outbound-auth delegated list: commands/agentbase/identity/outbound-auth-delegated-list.md - - outbound-auth delegated get: commands/agentbase/identity/outbound-auth-delegated-get.md - - outbound-auth delegated delete: commands/agentbase/identity/outbound-auth-delegated-delete.md - - outbound-auth delegated get-key: commands/agentbase/identity/outbound-auth-delegated-get-key.md - - outbound-auth oauth2 create: commands/agentbase/identity/outbound-auth-oauth2-create.md - - outbound-auth oauth2 list: commands/agentbase/identity/outbound-auth-oauth2-list.md - - outbound-auth oauth2 get: commands/agentbase/identity/outbound-auth-oauth2-get.md - - outbound-auth oauth2 update: commands/agentbase/identity/outbound-auth-oauth2-update.md - - outbound-auth oauth2 delete: commands/agentbase/identity/outbound-auth-oauth2-delete.md - - outbound-auth oauth2 m2m-token: commands/agentbase/identity/outbound-auth-oauth2-m2m-token.md - - outbound-auth oauth2 3lo-token: commands/agentbase/identity/outbound-auth-oauth2-3lo-token.md + - Access: + - Overview: commands/agentbase/access/index.md + - agent-id create: commands/agentbase/access/agent-id-create.md + - agent-id list: commands/agentbase/access/agent-id-list.md + - agent-id get: commands/agentbase/access/agent-id-get.md + - agent-id update: commands/agentbase/access/agent-id-update.md + - agent-id use: commands/agentbase/access/agent-id-use.md + - agent-id delete: commands/agentbase/access/agent-id-delete.md + - outbound-auth static create: commands/agentbase/access/outbound-auth-static-create.md + - outbound-auth static list: commands/agentbase/access/outbound-auth-static-list.md + - outbound-auth static get: commands/agentbase/access/outbound-auth-static-get.md + - outbound-auth static update: commands/agentbase/access/outbound-auth-static-update.md + - outbound-auth static delete: commands/agentbase/access/outbound-auth-static-delete.md + - outbound-auth static get-key: commands/agentbase/access/outbound-auth-static-get-key.md + - outbound-auth delegated create: commands/agentbase/access/outbound-auth-delegated-create.md + - outbound-auth delegated list: commands/agentbase/access/outbound-auth-delegated-list.md + - outbound-auth delegated get: commands/agentbase/access/outbound-auth-delegated-get.md + - outbound-auth delegated delete: commands/agentbase/access/outbound-auth-delegated-delete.md + - outbound-auth delegated get-key: commands/agentbase/access/outbound-auth-delegated-get-key.md + - outbound-auth oauth2 create: commands/agentbase/access/outbound-auth-oauth2-create.md + - outbound-auth oauth2 list: commands/agentbase/access/outbound-auth-oauth2-list.md + - outbound-auth oauth2 get: commands/agentbase/access/outbound-auth-oauth2-get.md + - outbound-auth oauth2 update: commands/agentbase/access/outbound-auth-oauth2-update.md + - outbound-auth oauth2 delete: commands/agentbase/access/outbound-auth-oauth2-delete.md + - outbound-auth oauth2 m2m-token: commands/agentbase/access/outbound-auth-oauth2-m2m-token.md + - outbound-auth oauth2 3lo-token: commands/agentbase/access/outbound-auth-oauth2-3lo-token.md - Gateway: - Overview: commands/agentbase/gateway/index.md - create: commands/agentbase/gateway/create.md @@ -165,17 +165,17 @@ nav: - record insert: commands/agentbase/memory/record-insert.md - record generate-from-session: commands/agentbase/memory/record-generate-from-session.md - record generate-from-content: commands/agentbase/memory/record-generate-from-content.md - - Catalog: - - Overview: commands/agentbase/catalog/index.md - - flavors list: commands/agentbase/catalog/flavors-list.md - - openclaw-versions list: commands/agentbase/catalog/openclaw-versions-list.md - - openclaw list: commands/agentbase/catalog/openclaw-list.md - - openclaw create: commands/agentbase/catalog/openclaw-create.md - - openclaw get: commands/agentbase/catalog/openclaw-get.md - - openclaw delete: commands/agentbase/catalog/openclaw-delete.md - - openclaw start: commands/agentbase/catalog/openclaw-start.md - - openclaw stop: commands/agentbase/catalog/openclaw-stop.md - - openclaw update-version: commands/agentbase/catalog/openclaw-update-version.md + - Marketplace: + - Overview: commands/agentbase/marketplace/index.md + - flavors list: commands/agentbase/marketplace/flavors-list.md + - openclaw-versions list: commands/agentbase/marketplace/openclaw-versions-list.md + - openclaw list: commands/agentbase/marketplace/openclaw-list.md + - openclaw create: commands/agentbase/marketplace/openclaw-create.md + - openclaw get: commands/agentbase/marketplace/openclaw-get.md + - openclaw delete: commands/agentbase/marketplace/openclaw-delete.md + - openclaw start: commands/agentbase/marketplace/openclaw-start.md + - openclaw stop: commands/agentbase/marketplace/openclaw-stop.md + - openclaw update-version: commands/agentbase/marketplace/openclaw-update-version.md - Policy: - Overview: commands/agentbase/policy/index.md - group create: commands/agentbase/policy/group-create.md diff --git a/scripts/install b/scripts/install deleted file mode 100755 index 809952f..0000000 --- a/scripts/install +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env bash -# Standalone installer for GreenNode CLI -# Usage: ./scripts/install [--install-dir ] [--bin-dir ] - -set -e - -INSTALL_DIR="${HOME}/.local/lib/greennode" -BIN_DIR="${HOME}/.local/bin" - -while [ $# -gt 0 ]; do - case "$1" in - --install-dir) - INSTALL_DIR="$2" - shift 2 - ;; - --bin-dir) - BIN_DIR="$2" - shift 2 - ;; - *) - echo "Unknown option: $1" - exit 1 - ;; - esac -done - -echo "Installing GreenNode CLI..." -echo " Install dir: ${INSTALL_DIR}" -echo " Bin dir: ${BIN_DIR}" - -# Create virtualenv -python3 -m venv "${INSTALL_DIR}" - -# Install grncli into the virtualenv -"${INSTALL_DIR}/bin/pip" install --upgrade pip -"${INSTALL_DIR}/bin/pip" install . - -# Create symlink in bin dir -mkdir -p "${BIN_DIR}" -ln -sf "${INSTALL_DIR}/bin/grn" "${BIN_DIR}/grn" - -echo "" -echo "GreenNode CLI installed successfully!" -echo "" -echo "Make sure ${BIN_DIR} is in your PATH:" -echo " export PATH=\"${BIN_DIR}:\$PATH\"" -echo "" -echo "Run 'grn configure' to set up credentials." diff --git a/scripts/install.cmd b/scripts/install.cmd new file mode 100644 index 0000000..bdacca0 --- /dev/null +++ b/scripts/install.cmd @@ -0,0 +1,96 @@ +@echo off +REM GreenNode CLI installer (Windows pure-CMD) — for environments without PowerShell. +REM Usage: curl -fsSL https://raw.githubusercontent.com/.../scripts/install.cmd -o install.cmd && install.cmd && del install.cmd +setlocal enableextensions enabledelayedexpansion +set "EXITCODE=0" + +set "BASE=%GRN_INSTALL_BASE_URL%" +if "%BASE%"=="" set "BASE=https://github.com/GreenNodeHub/greennode-cli" + +REM --- Step 1: arch detect + 32-bit reject --- +set "PLATFORM=windows-amd64" +if /i "%PROCESSOR_ARCHITECTURE%"=="ARM64" set "PLATFORM=windows-arm64" +if /i "%PROCESSOR_ARCHITECTURE%"=="x86" goto :err_32bit + +REM --- Step 3: resolve tag (GRN_INSTALL_TAG skips) --- +if not "%GRN_INSTALL_TAG%"=="" ( + set "VTAG=%GRN_INSTALL_TAG%" + goto :tag_done +) +set "REDIRECT=" +for /f "delims=" %%i in ('curl.exe -fsSL -o NUL -w "%%{url_effective}" "%BASE%/releases/latest" 2^>^&1') do set "REDIRECT=%%i" +if "%REDIRECT%"=="" goto :err_resolve +REM Extract the part after /tag/ (gives v1.12.0). +set "VTAG=%REDIRECT:*/tag/=%" +:tag_done +REM Validate VTAG looks like vX.Y.Z. +echo !VTAG!| findstr /r "^v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*" >nul || goto :err_resolve +set "TAG=!VTAG:~1!" + +REM --- Step 4: fetch SHA256SUMS + expected hash --- +set "TMP=%TEMP%\grn-cmd-%RANDOM%" +mkdir "%TMP%" 2>nul +curl.exe -fsSL "%BASE%/releases/download/!VTAG!/SHA256SUMS" -o "%TMP%\SHA256SUMS" || goto :err_dl_sums +set "EXPECTED=" +for /f "tokens=1" %%h in ('findstr /c:"grn-!PLATFORM!-!VTAG!.exe" "%TMP%\SHA256SUMS"') do set "EXPECTED=%%h" +if "%EXPECTED%"=="" goto :err_no_platform + +REM --- Step 5: download binary (+ corporate-proxy cert-revocation retry) --- +curl.exe -fsSL "%BASE%/releases/download/!VTAG!/grn-!PLATFORM!-!VTAG!.exe" -o "%TMP%\grn-installer.exe" 2>"%TMP%\dlerr.txt" +if errorlevel 1 ( + findstr /c:"0x80092012" /c:"0x80092013" "%TMP%\dlerr.txt" >nul 2>&1 && ( + curl.exe -fsSL --ssl-revoke-best-effort "%BASE%/releases/download/!VTAG!/grn-!PLATFORM!-!VTAG!.exe" -o "%TMP%\grn-installer.exe" || goto :err_dl_bin + ) || goto :err_dl_bin +) + +REM --- Step 6: verify (certutil; parse line 2) --- +certutil -hashfile "%TMP%\grn-installer.exe" SHA256 > "%TMP%\hashout.txt" +set "ACTUAL=" +set /a _n=0 +for /f "delims=" %%L in (%TMP%\hashout.txt) do ( + set /a _n+=1 + if !_n! equ 2 set "ACTUAL=%%L" +) +REM certutil emits uppercase hex; lowercase-compare by uppercasing EXPECTED. +if /i not "!ACTUAL!"=="!EXPECTED!" ( del "%TMP%\grn-installer.exe" >nul 2>&1 & goto :err_checksum ) + +REM --- Step 7: install + PATH (registry; robust vs setx truncation) --- +set "INSTALL_DIR=%LOCALAPPDATA%\greennode\bin" +mkdir "%INSTALL_DIR%" 2>nul +move /y "%TMP%\grn-installer.exe" "%INSTALL_DIR%\grn.exe" >nul || goto :err_move + +REM Read HKCU\Environment\PATH, append INSTALL_DIR if absent, write back REG_EXPAND_SZ. +set "USERPATH=" +for /f "tokens=2,*" %%A in ('reg query "HKCU\Environment" /v PATH 2^>nul ^| findstr /i "PATH"') do set "USERPATH=%%B" +echo !USERPATH!| findstr /i /c:"%INSTALL_DIR%" >nul 2>&1 +if errorlevel 1 ( + if defined USERPATH ( set "NEWPATH=!USERPATH!;%INSTALL_DIR%" ) else ( set "NEWPATH=%INSTALL_DIR%" ) + reg add "HKCU\Environment" /v PATH /t REG_EXPAND_SZ /d "!NEWPATH!" /f >nul +) +REM Refresh current session. +set "PATH=%PATH%;%INSTALL_DIR%" + +echo Installed grn !TAG! -^> %INSTALL_DIR%\grn.exe +echo Open a new terminal for PATH to take effect (or run: grn --version) +goto :cleanup + +:err_32bit +echo Error: GreenNode CLI requires 64-bit Windows. & goto :cleanup_err +:err_resolve +echo Error: could not resolve latest version tag from %BASE%/releases/latest. Set GRN_INSTALL_TAG=vX.Y.Z. & goto :cleanup_err +:err_dl_sums +echo Error: failed to download SHA256SUMS for !VTAG!. & goto :cleanup_err +:err_no_platform +echo Error: no checksum for !PLATFORM! in SHA256SUMS -- release may not ship this platform. & goto :cleanup_err +:err_dl_bin +echo Error: failed to download binary. & goto :cleanup_err +:err_checksum +echo Error: checksum mismatch -- download may be corrupt or tampered. & goto :cleanup_err +:err_move +echo Error: could not install grn.exe (is grn currently running? close it and retry). & goto :cleanup_err + +:cleanup_err +set "EXITCODE=1" +:cleanup +rd /s /q "%TMP%" >nul 2>&1 +endlocal & exit /b %EXITCODE% diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 0000000..f3b01b8 --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,119 @@ +# GreenNode CLI installer (Windows PowerShell). +# Usage: irm https://raw.githubusercontent.com/GreenNodeHub/greennode-cli/main/scripts/install.ps1 | iex +param( + # Allow pinning a version instead of resolving latest (also used by tests). + [string]$Tag +) +# Strict mode OFF by intent. Under Set-StrictMode -Version Latest, Windows +# PowerShell 5.1 (the irm|iex target) false-positives on $bin at Move-Item +# (Step 7): $bin is assigned at Step 5 and already consumed by Get-FileHash at +# Step 6 with no error, yet Move-Item -Path $bin throws "VariableIsUndefined", +# after which Move-Item prompts for the now-"missing" mandatory -Path and hangs +# ~2 min on a TTY-less CI stdin. Observed on Windows Server 2025 CI (PR #17). +# $ErrorActionPreference = "Stop" below still turns cmdlet errors into +# terminating errors, so fail-fast behavior is preserved without the quirk. +Set-StrictMode -Off +$ErrorActionPreference = "Stop" +$ProgressPreference = 'SilentlyContinue' + +$BASE = $env:GRN_INSTALL_BASE_URL +if (-not $BASE) { $BASE = "https://github.com/GreenNodeHub/greennode-cli" } +if (-not $Tag) { $Tag = $env:GRN_INSTALL_TAG } + +# --- Step 1: 64-bit guard --- +if (-not [Environment]::Is64BitProcess) { + [Console]::Error.WriteLine("install.ps1: GreenNode CLI requires 64-bit Windows."); exit 1 +} + +# --- Step 2: arch detect --- +if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { $platform = "windows-arm64" } +else { $platform = "windows-amd64" } + +# --- Step 3: resolve tag --- +if ($Tag) { + $vtag = $Tag + if ($vtag -notlike "v*") { $vtag = "v$vtag" } +} else { + # GitHub 302 /releases/latest -> /releases/tag/vX.Y.Z. Capture the Location. + # -UseBasicParsing: PS 5.1 otherwise uses the IE COM engine, which is absent on + # Server Core / IE-less Windows (a common CLI-install target) and would throw + # "Internet Explorer engine is not available" on this HTML release page. + $resp = Invoke-WebRequest -Uri "$BASE/releases/latest" -MaximumRedirection 0 -UseBasicParsing -ErrorAction SilentlyContinue + if ($resp.StatusCode -eq 302 -and $resp.Headers.Location) { + $loc = [string]$resp.Headers.Location + $vtag = ($loc -split '/')[-1] # last segment is v1.12.0 + } else { + # Fallback: follow the redirect and read the effective URL. + $eff = Invoke-WebRequest -Uri "$BASE/releases/latest" -MaximumRedirection 5 -UseBasicParsing + $vtag = ($eff.BaseResponse.ResponseUri.AbsolutePath -split '/')[-1] + } +} +if ($vtag -notmatch '^v\d+\.\d+\.\d+') { + [Console]::Error.WriteLine("install.ps1: Could not resolve a version tag from $BASE/releases/latest (got '$vtag'). Set -Tag or GRN_INSTALL_TAG."); exit 1 +} +$tag = $vtag.TrimStart('v') + +# --- Step 4: fetch SHA256SUMS + expected hash --- +$dl = Join-Path $env:TEMP ("grn-install-" + [guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Force -Path $dl | Out-Null +$sums = Join-Path $dl "SHA256SUMS" +# NOTE: no try/catch here. Under $ErrorActionPreference = "Stop", a try/catch +# around Invoke-WebRequest -OutFile caused Windows PowerShell 5.1 to skip every +# subsequent statement through the next block boundary (observed on Server 2025 +# CI, PR #17): $expected/$bin/$actual were never assigned, no catch fired, no +# error printed. -ErrorAction Stop makes IWR throw a terminating error on +# failure (clean exit with its native message) without the skip side effect. +Invoke-WebRequest -Uri "$BASE/releases/download/$vtag/SHA256SUMS" -UseBasicParsing -OutFile $sums -TimeoutSec 60 -ErrorAction Stop +if ($env:GRN_INSTALL_DEBUG) { [Console]::Error.WriteLine("DBG L62 after SHA256SUMS dl: sums=[$sums]") } +# Avoid `Select-Object -First 1` under Stop: it throws a terminating +# StopUpstreamCommandsException in PS 5.1. Index the array instead. +$line = @(Get-Content $sums | Select-String -Pattern "grn-$platform-$vtag.exe`$")[0] +if (-not $line) { + [Console]::Error.WriteLine("install.ps1: No checksum line for $platform in SHA256SUMS; the release may not ship this platform."); exit 1 +} +$expected = ($line.ToString() -split '\s+')[0].ToLower() + +# --- Step 5: download binary --- +$bin = Join-Path $dl "grn.exe" +if ($env:GRN_INSTALL_DEBUG) { [Console]::Error.WriteLine("DBG L70 after set: bin=[$bin] dl=[$dl]") } +Invoke-WebRequest -Uri "$BASE/releases/download/$vtag/grn-$platform-$vtag.exe" -UseBasicParsing -OutFile $bin -TimeoutSec 60 -ErrorAction Stop + +# --- Step 6: verify --- +$actual = (Get-FileHash -Path $bin -Algorithm SHA256).Hash.ToLower() +if ($env:GRN_INSTALL_DEBUG) { [Console]::Error.WriteLine("DBG L76 after hash: bin=[$bin] actual=[$actual] expected=[$expected]") } +if ($actual -ne $expected) { + Remove-Item -Force $bin -ErrorAction SilentlyContinue + [Console]::Error.WriteLine("install.ps1: Checksum mismatch (expected $expected, got $actual); the download may be corrupt or tampered."); exit 1 +} + +# --- Step 7: install + PATH --- +$installDir = Join-Path $env:LOCALAPPDATA "greennode\bin" +New-Item -ItemType Directory -Force -Path $installDir | Out-Null +$dest = Join-Path $installDir "grn.exe" +if ($env:GRN_INSTALL_DEBUG) { [Console]::Error.WriteLine("DBG L86 before move: bin=[$bin] dl=[$dl] dest=[$dest] installDir=[$installDir] expected=[$expected] actual=[$actual]") } +# Defensive: on Windows PowerShell 5.1 (Server 2025 CI), $bin has been observed +# $null here despite being set at line 70 and consumed by Get-FileHash at line +# 76, with no statement between modifying it. Re-derive from $dl (unchanged +# since line 57) so a transient null doesn't break the install. +if (-not $bin) { + [Console]::Error.WriteLine("install.ps1: WARNING: `$bin was null at Move-Item; re-deriving from dl=[$dl]") + $bin = Join-Path $dl "grn.exe" +} +if (-not (Test-Path -LiteralPath $bin)) { + [Console]::Error.WriteLine("install.ps1: ERROR: binary not found at [$bin] (dl=[$dl])") + exit 1 +} +Move-Item -Path $bin -Destination $dest -Force + +# Add to User PATH (idempotent). +$userPath = [Environment]::GetEnvironmentVariable("PATH", "User") +if ($userPath -and ($userPath -notlike "*$installDir*")) { + [Environment]::SetEnvironmentVariable("PATH", "$userPath;$installDir", "User") +} elseif (-not $userPath) { + [Environment]::SetEnvironmentVariable("PATH", $installDir, "User") +} +# Refresh current session. +$env:PATH = "$env:PATH;$installDir" + +Write-Output "Installed grn $tag -> $dest" +Write-Output "Open a new terminal for PATH to take effect (or run: grn --version)" diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..8c68a16 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# GreenNode CLI installer (macOS / Linux). +# Usage: curl -fsSL https://raw.githubusercontent.com/GreenNodeHub/greennode-cli/main/scripts/install.sh | bash +# +# Honors: +# GRN_INSTALL_BASE_URL (default https://github.com/GreenNodeHub/greennode-cli) +# GRN_INSTALL_TAG (default: resolve latest via /releases/latest 302) +# GRN_INSTALL_ALLOW_SUDO (set to 1 to allow running under sudo from a regular user) +set -euo pipefail + +BASE="${GRN_INSTALL_BASE_URL:-https://github.com/GreenNodeHub/greennode-cli}" + +die() { echo "Error: $*" >&2; exit 1; } + +# --- Step 1: sudo guard --- +if [ "$(id -u)" -eq 0 ] && [ -n "${SUDO_USER:-}" ] && [ "${GRN_INSTALL_ALLOW_SUDO:-0}" != "1" ]; then + die "running under sudo would install into /root — set GRN_INSTALL_ALLOW_SUDO=1 to allow, or run without sudo." +fi + +# --- Step 2: platform detect --- +OS="$(uname -s)" +ARCH="$(uname -m)" +case "$ARCH" in + arm64|aarch64) ARCH=arm64 ;; + x86_64|amd64) ARCH=amd64 ;; + *) die "unsupported arch: $ARCH" ;; +esac +case "$OS" in + Darwin) + # Rosetta-2 translation: flip amd64 -> arm64 under translation. + if [ "$ARCH" = "amd64" ] && [ "$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)" = "1" ]; then + ARCH=arm64 + fi + PLATFORM="darwin-${ARCH}" + ;; + Linux) PLATFORM="linux-${ARCH}" ;; + *) die "unsupported OS: $OS" ;; +esac +# NOTE: no musl detection — binaries are static (CGO_ENABLED=0). + +# --- Step 3: resolve latest tag (unless GRN_INSTALL_TAG set) --- +if [ -n "${GRN_INSTALL_TAG:-}" ]; then + VTAG="$GRN_INSTALL_TAG" +else + REDIRECT="$(curl -fsSL -o /dev/null -w '%{url_effective}' "${BASE}/releases/latest")" + VTAG="${REDIRECT##*/tag/}" # .../releases/tag/v1.12.0 -> v1.12.0 +fi +case "$VTAG" in + v[0-9]*) ;; # ok + *) die "could not resolve a version tag from ${BASE}/releases/latest (got '${VTAG}') — set GRN_INSTALL_TAG manually." ;; +esac +TAG="${VTAG#v}" + +# --- Step 4: fetch SHA256SUMS + extract expected hash for our platform --- +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +SUMS="$TMP/SHA256SUMS" +curl -fsSL "${BASE}/releases/download/${VTAG}/SHA256SUMS" -o "$SUMS" \ + || die "could not fetch SHA256SUMS for ${VTAG} from ${BASE} (check GRN_INSTALL_BASE_URL / GRN_INSTALL_TAG)." +# Match the exact platform binary line; -v${TAG} suffix anchors so amd64 != arm64. +EXPECTED="$(grep -E "grn-${PLATFORM}-${VTAG}(\.exe)?\$" "$SUMS" | awk '{print $1}' | head -1)" +[ -n "$EXPECTED" ] || die "no checksum line for ${PLATFORM} in SHA256SUMS — the release may not ship this platform." +# Sanity: it's 64 hex. +echo "$EXPECTED" | grep -qE '^[0-9a-f]{64}$' || die "malformed checksum for ${PLATFORM}: ${EXPECTED}" + +# --- Step 5: download binary --- +BIN="$TMP/grn" +curl -fsSL "${BASE}/releases/download/${VTAG}/grn-${PLATFORM}-${VTAG}" -o "$BIN" \ + || die "could not fetch grn-${PLATFORM}-${VTAG} from ${BASE} (the release may not ship this platform; check GRN_INSTALL_BASE_URL / GRN_INSTALL_TAG)." + +# --- Step 6: verify --- +if command -v sha256sum >/dev/null 2>&1; then HASHER=sha256sum; else HASHER="shasum -a 256"; fi +ACTUAL="$($HASHER "$BIN" | awk '{print $1}')" +[ "$ACTUAL" = "$EXPECTED" ] || { rm -f "$BIN"; die "checksum mismatch (expected ${EXPECTED}, got ${ACTUAL}) — the download may be corrupt or tampered."; } + +# --- Step 7: install + PATH --- +INSTALL_DIR="$HOME/.local/lib/greennode" +BIN_DIR="$HOME/.local/bin" +mkdir -p "$INSTALL_DIR" "$BIN_DIR" +mv "$BIN" "$INSTALL_DIR/grn" +chmod +x "$INSTALL_DIR/grn" +ln -sf "$INSTALL_DIR/grn" "$BIN_DIR/grn" + +# Idempotent rc-PATH append: pick the right rc, grep-before-append. +rc_for_path() { + case "$OS" in + Darwin) for f in "$HOME/.zshrc" "$HOME/.zprofile"; do [ -f "$f" ] && echo "$f" && return; done; echo "$HOME/.zshrc" ;; + Linux) for f in "$HOME/.bashrc" "$HOME/.bash_profile" "$HOME/.profile"; do [ -f "$f" ] && echo "$f" && return; done; echo "$HOME/.bashrc" ;; + esac +} +RC="$(rc_for_path)" +# shellcheck disable=SC2016 # single quotes are intentional: this is a literal +# line to WRITE into the rc file so $HOME/$PATH expand at shell startup, not now. +PATH_LINE='export PATH="$HOME/.local/bin:$PATH"' +if ! grep -qF "$PATH_LINE" "$RC" 2>/dev/null; then + printf '\n# Added by grn installer\n%s\n' "$PATH_LINE" >> "$RC" +fi + +echo "Installed grn ${TAG} → ${BIN_DIR}/grn" +echo "Restart your shell, or run: export PATH=\"$HOME/.local/bin:\$PATH\"" diff --git a/scripts/make-bundle b/scripts/make-bundle deleted file mode 100755 index 9d1af3c..0000000 --- a/scripts/make-bundle +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env bash -# Create a self-contained bundle for offline installation -# Usage: ./scripts/make-bundle -# Output: dist/grncli-bundle.zip - -set -e - -BUNDLE_DIR="dist/grncli-bundle" - -echo "Creating GreenNode CLI bundle..." - -# Clean -rm -rf "${BUNDLE_DIR}" dist/grncli-bundle.zip - -# Build wheel -python setup.py bdist_wheel - -# Download all dependencies -mkdir -p "${BUNDLE_DIR}/packages" -pip download -r requirements.txt -d "${BUNDLE_DIR}/packages" -cp dist/*.whl "${BUNDLE_DIR}/packages/" - -# Copy install script -cp scripts/install "${BUNDLE_DIR}/install" -chmod +x "${BUNDLE_DIR}/install" - -# Create offline installer -cat > "${BUNDLE_DIR}/install-offline" << 'INSTALLER' -#!/usr/bin/env bash -# Offline installer for GreenNode CLI -set -e - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -INSTALL_DIR="${HOME}/.local/lib/greennode" -BIN_DIR="${HOME}/.local/bin" - -echo "Installing GreenNode CLI (offline)..." - -python3 -m venv "${INSTALL_DIR}" -"${INSTALL_DIR}/bin/pip" install --upgrade pip -"${INSTALL_DIR}/bin/pip" install --no-index --find-links "${SCRIPT_DIR}/packages" grncli - -mkdir -p "${BIN_DIR}" -ln -sf "${INSTALL_DIR}/bin/grn" "${BIN_DIR}/grn" - -echo "" -echo "GreenNode CLI installed successfully!" -echo "Make sure ${BIN_DIR} is in your PATH." -echo "Run 'grn configure' to set up credentials." -INSTALLER -chmod +x "${BUNDLE_DIR}/install-offline" - -# Zip it -cd dist && zip -r grncli-bundle.zip grncli-bundle/ -cd .. - -echo "Bundle created: dist/grncli-bundle.zip" -echo "To install offline: unzip grncli-bundle.zip && cd grncli-bundle && ./install-offline" diff --git a/scripts/uninstall.cmd b/scripts/uninstall.cmd new file mode 100644 index 0000000..3a95cdb --- /dev/null +++ b/scripts/uninstall.cmd @@ -0,0 +1,50 @@ +@echo off +REM GreenNode CLI uninstaller (Windows pure-CMD) — for environments without PowerShell. +REM Removes what scripts/install.cmd put down: %LOCALAPPDATA%\greennode\ and its PATH entry. +REM Usage: curl -fsSL https://raw.githubusercontent.com/.../scripts/uninstall.cmd -o uninstall.cmd && uninstall.cmd && del uninstall.cmd +REM uninstall.cmd --purge (also remove %USERPROFILE%\.greennode config + credentials) +setlocal enableextensions enabledelayedexpansion +set "EXITCODE=0" + +set "PURGE=0" +if /i "%~1"=="--purge" set "PURGE=1" + +REM --- Step 1: remove binary dir --- +set "INSTALL_DIR=%LOCALAPPDATA%\greennode" +if exist "%INSTALL_DIR%" rd /s /q "%INSTALL_DIR%" 2>nul + +REM --- Step 2: remove %INSTALL_DIR%\bin from User PATH (registry read-modify-write) --- +set "BIN_DIR=%INSTALL_DIR%\bin" +set "USERPATH=" +for /f "tokens=2,*" %%A in ('reg query "HKCU\Environment" /v PATH 2^>nul ^| findstr /i "PATH"') do set "USERPATH=%%B" +if not defined USERPATH goto :purge +REM Rebuild PATH excluding BIN_DIR (case-insensitive). Flat label flow (no nested +REM labels inside parens — batch can't call a label from within a parenthesized block). +set "NEWPATH=" +set "P=!USERPATH!" +:split +for /f "delims=;" %%X in ("!P!") do set "SEG=%%X" +if not "!SEG!"=="" if /i not "!SEG!"=="!BIN_DIR!" ( + if defined NEWPATH ( set "NEWPATH=!NEWPATH!;!SEG!" ) else ( set "NEWPATH=!SEG!" ) +) +set "TAIL=!P:*;=!" +if "!TAIL!"=="!P!" goto :writepath +set "P=!TAIL!" +goto :split +:writepath +if defined NEWPATH ( + reg add "HKCU\Environment" /v PATH /t REG_EXPAND_SZ /d "!NEWPATH!" /f >nul +) else ( + reg delete "HKCU\Environment" /v PATH /f >nul 2>&1 +) + +:purge +if not "%PURGE%"=="1" goto :done +if exist "%USERPROFILE%\.greennode" rd /s /q "%USERPROFILE%\.greennode" 2>nul +if exist "%USERPROFILE%\.greenode" rd /s /q "%USERPROFILE%\.greenode" 2>nul + +:done +echo Uninstalled grn +if "%PURGE%"=="1" echo Also removed %USERPROFILE%\.greennode (config + credentials) +echo Open a new terminal for PATH changes to take effect. +endlocal & exit /b %EXITCODE% diff --git a/scripts/uninstall.ps1 b/scripts/uninstall.ps1 new file mode 100644 index 0000000..425679f --- /dev/null +++ b/scripts/uninstall.ps1 @@ -0,0 +1,41 @@ +# GreenNode CLI uninstaller (Windows PowerShell). +# Removes what scripts/install.ps1 put down: the %LOCALAPPDATA%\greennode binary +# dir and its entry in the User PATH. +# +# Usage: irm https://raw.githubusercontent.com/GreenNodeHub/greennode-cli/main/scripts/uninstall.ps1 | iex +# With -Purge (also remove %USERPROFILE%\.greennode config + credentials): +# & ([scriptblock]::Create((irm https://raw.githubusercontent.com/GreenNodeHub/greennode-cli/main/scripts/uninstall.ps1))) -Purge +param( + [switch]$Purge +) +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +$ProgressPreference = 'SilentlyContinue' + +$installDir = Join-Path $env:LOCALAPPDATA "greennode" + +# --- Step 1: remove binary dir --- +if (Test-Path -LiteralPath $installDir) { + Remove-Item -Recurse -Force -LiteralPath $installDir +} + +# --- Step 2: remove from User PATH (idempotent) --- +$binInPath = Join-Path $installDir "bin" +$userPath = [Environment]::GetEnvironmentVariable("PATH", "User") +if ($userPath) { + # Drop empty segments + any segment matching binInPath (case-insensitive). + $segments = $userPath -split ';' | Where-Object { $_ -and ($_.Trim() -ine $binInPath) } + [Environment]::SetEnvironmentVariable("PATH", ($segments -join ';'), "User") +} + +# --- Step 3: -Purge config + credentials --- +if ($Purge) { + foreach ($name in @(".greennode", ".greenode")) { # current + legacy pre-rename dir + $d = Join-Path $env:USERPROFILE $name + if (Test-Path -LiteralPath $d) { Remove-Item -Recurse -Force -LiteralPath $d } + } +} + +Write-Output "Uninstalled grn" +if ($Purge) { Write-Output "Also removed $env:USERPROFILE\.greennode (config + credentials)" } +Write-Output "Open a new terminal for PATH changes to take effect." diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh new file mode 100755 index 0000000..4ebefde --- /dev/null +++ b/scripts/uninstall.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# GreenNode CLI uninstaller (macOS / Linux). +# Removes what scripts/install.sh put down: the ~/.local/lib/greennode binary +# dir, the ~/.local/bin/grn symlink, and the PATH line from your shell rc. +# +# Usage: curl -fsSL https://raw.githubusercontent.com/GreenNodeHub/greennode-cli/main/scripts/uninstall.sh | bash +# bash uninstall.sh --purge # also remove ~/.greennode config + credentials +# +# Honors: +# GRN_INSTALL_ALLOW_SUDO (set to 1 to allow running under sudo from a regular user) +set -euo pipefail + +PURGE=0 +for arg in "$@"; do + case "$arg" in + --purge) PURGE=1 ;; + -h|--help) + sed -n '2,9p' "$0" + exit 0 + ;; + *) echo "Error: unknown arg: $arg (try --help)" >&2; exit 2 ;; + esac +done + +die() { echo "Error: $*" >&2; exit 1; } + +# --- Step 1: sudo guard (same as install.sh) --- +if [ "$(id -u)" -eq 0 ] && [ -n "${SUDO_USER:-}" ] && [ "${GRN_INSTALL_ALLOW_SUDO:-0}" != "1" ]; then + die "running under sudo would remove from /root — set GRN_INSTALL_ALLOW_SUDO=1 to allow, or run without sudo." +fi + +# --- Step 2: platform detect (need OS for rc selection) --- +OS="$(uname -s)" +case "$OS" in + Darwin|Linux) ;; + *) die "unsupported OS: $OS (this uninstaller is for macOS / Linux)" ;; +esac + +# --- Step 3: remove binary dir + symlink --- +INSTALL_DIR="$HOME/.local/lib/greennode" +BIN_DIR="$HOME/.local/bin" +rm -f "$BIN_DIR/grn" 2>/dev/null || true +rm -rf "$INSTALL_DIR" 2>/dev/null || true + +# --- Step 4: remove PATH line from rc (idempotent) --- +rc_for_path() { + case "$OS" in + Darwin) for f in "$HOME/.zshrc" "$HOME/.zprofile"; do [ -f "$f" ] && echo "$f" && return; done; echo "$HOME/.zshrc" ;; + Linux) for f in "$HOME/.bashrc" "$HOME/.bash_profile" "$HOME/.profile"; do [ -f "$f" ] && echo "$f" && return; done; echo "$HOME/.bashrc" ;; + esac +} +RC="$(rc_for_path)" +# shellcheck disable=SC2016 # single quotes are intentional: this is the literal +# line install.sh writes, so we match it byte-for-byte (expands at shell startup). +PATH_LINE='export PATH="$HOME/.local/bin:$PATH"' +if [ -f "$RC" ] && grep -qF "$PATH_LINE" "$RC" 2>/dev/null; then + # Remove the PATH line + its "# Added by grn installer" comment. A leading + # blank line from the installer's printf is left behind (harmless). grep -v + # outputs every non-matching line; mktemp+mv (not sed -i) for BSD/macOS. + tmp_rc="$(mktemp)" + grep -vF -e "$PATH_LINE" -e "# Added by grn installer" "$RC" > "$tmp_rc" || true + mv "$tmp_rc" "$RC" +fi + +# --- Step 5: --purge config + credentials --- +if [ "$PURGE" -eq 1 ]; then + rm -rf "$HOME/.greennode" 2>/dev/null || true + rm -rf "$HOME/.greenode" 2>/dev/null || true # legacy pre-rename config dir +fi + +echo "Uninstalled grn" +if [ "$PURGE" -eq 1 ]; then + echo "Also removed ~/.greennode/ (config + credentials)" +fi +echo "Restart your shell, or open a new terminal, for PATH changes to take effect." diff --git a/site/404.html b/site/404.html new file mode 100644 index 0000000..ad7746c --- /dev/null +++ b/site/404.html @@ -0,0 +1,6970 @@ + + + + + + + + + + + + + + + + + + + + + + + + Green Node CLI + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ +
+ + + + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+ +
+ +

404 - Not found

+ +
+
+ + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/site/assets/images/favicon.png b/site/assets/images/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..1cf13b9f9d978896599290a74f77d5dbe7d1655c GIT binary patch literal 1870 zcmV-U2eJ5xP)Gc)JR9QMau)O=X#!i9;T z37kk-upj^(fsR36MHs_+1RCI)NNu9}lD0S{B^g8PN?Ww(5|~L#Ng*g{WsqleV}|#l zz8@ri&cTzw_h33bHI+12+kK6WN$h#n5cD8OQt`5kw6p~9H3()bUQ8OS4Q4HTQ=1Ol z_JAocz`fLbT2^{`8n~UAo=#AUOf=SOq4pYkt;XbC&f#7lb$*7=$na!mWCQ`dBQsO0 zLFBSPj*N?#u5&pf2t4XjEGH|=pPQ8xh7tpx;US5Cx_Ju;!O`ya-yF`)b%TEt5>eP1ZX~}sjjA%FJF?h7cX8=b!DZl<6%Cv z*G0uvvU+vmnpLZ2paivG-(cd*y3$hCIcsZcYOGh{$&)A6*XX&kXZd3G8m)G$Zz-LV z^GF3VAW^Mdv!)4OM8EgqRiz~*Cji;uzl2uC9^=8I84vNp;ltJ|q-*uQwGp2ma6cY7 z;`%`!9UXO@fr&Ebapfs34OmS9^u6$)bJxrucutf>`dKPKT%%*d3XlFVKunp9 zasduxjrjs>f8V=D|J=XNZp;_Zy^WgQ$9WDjgY=z@stwiEBm9u5*|34&1Na8BMjjgf3+SHcr`5~>oz1Y?SW^=K z^bTyO6>Gar#P_W2gEMwq)ot3; zREHn~U&Dp0l6YT0&k-wLwYjb?5zGK`W6S2v+K>AM(95m2C20L|3m~rN8dprPr@t)5lsk9Hu*W z?pS990s;Ez=+Rj{x7p``4>+c0G5^pYnB1^!TL=(?HLHZ+HicG{~4F1d^5Awl_2!1jICM-!9eoLhbbT^;yHcefyTAaqRcY zmuctDopPT!%k+}x%lZRKnzykr2}}XfG_ne?nRQO~?%hkzo;@RN{P6o`&mMUWBYMTe z6i8ChtjX&gXl`nvrU>jah)2iNM%JdjqoaeaU%yVn!^70x-flljp6Q5tK}5}&X8&&G zX3fpb3E(!rH=zVI_9Gjl45w@{(ITqngWFe7@9{mX;tO25Z_8 zQHEpI+FkTU#4xu>RkN>b3Tnc3UpWzPXWm#o55GKF09j^Mh~)K7{QqbO_~(@CVq! zS<8954|P8mXN2MRs86xZ&Q4EfM@JB94b=(YGuk)s&^jiSF=t3*oNK3`rD{H`yQ?d; ztE=laAUoZx5?RC8*WKOj`%LXEkgDd>&^Q4M^z`%u0rg-It=hLCVsq!Z%^6eB-OvOT zFZ28TN&cRmgU}Elrnk43)!>Z1FCPL2K$7}gwzIc48NX}#!A1BpJP?#v5wkNprhV** z?Cpalt1oH&{r!o3eSKc&ap)iz2BTn_VV`4>9M^b3;(YY}4>#ML6{~(4mH+?%07*qo IM6N<$f(jP3KmY&$ literal 0 HcmV?d00001 diff --git a/site/assets/javascripts/bundle.d7400e89.min.js b/site/assets/javascripts/bundle.d7400e89.min.js new file mode 100644 index 0000000..6308ac3 --- /dev/null +++ b/site/assets/javascripts/bundle.d7400e89.min.js @@ -0,0 +1,16 @@ +"use strict";(()=>{var Zi=Object.create;var _r=Object.defineProperty;var ea=Object.getOwnPropertyDescriptor;var ta=Object.getOwnPropertyNames,Bt=Object.getOwnPropertySymbols,ra=Object.getPrototypeOf,Ar=Object.prototype.hasOwnProperty,bo=Object.prototype.propertyIsEnumerable;var ho=(e,t,r)=>t in e?_r(e,t,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[t]=r,P=(e,t)=>{for(var r in t||(t={}))Ar.call(t,r)&&ho(e,r,t[r]);if(Bt)for(var r of Bt(t))bo.call(t,r)&&ho(e,r,t[r]);return e};var vo=(e,t)=>{var r={};for(var o in e)Ar.call(e,o)&&t.indexOf(o)<0&&(r[o]=e[o]);if(e!=null&&Bt)for(var o of Bt(e))t.indexOf(o)<0&&bo.call(e,o)&&(r[o]=e[o]);return r};var Cr=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports);var oa=(e,t,r,o)=>{if(t&&typeof t=="object"||typeof t=="function")for(let n of ta(t))!Ar.call(e,n)&&n!==r&&_r(e,n,{get:()=>t[n],enumerable:!(o=ea(t,n))||o.enumerable});return e};var $t=(e,t,r)=>(r=e!=null?Zi(ra(e)):{},oa(t||!e||!e.__esModule?_r(r,"default",{value:e,enumerable:!0}):r,e));var go=(e,t,r)=>new Promise((o,n)=>{var i=c=>{try{a(r.next(c))}catch(p){n(p)}},s=c=>{try{a(r.throw(c))}catch(p){n(p)}},a=c=>c.done?o(c.value):Promise.resolve(c.value).then(i,s);a((r=r.apply(e,t)).next())});var xo=Cr((kr,yo)=>{(function(e,t){typeof kr=="object"&&typeof yo!="undefined"?t():typeof define=="function"&&define.amd?define(t):t()})(kr,(function(){"use strict";function e(r){var o=!0,n=!1,i=null,s={text:!0,search:!0,url:!0,tel:!0,email:!0,password:!0,number:!0,date:!0,month:!0,week:!0,time:!0,datetime:!0,"datetime-local":!0};function a(k){return!!(k&&k!==document&&k.nodeName!=="HTML"&&k.nodeName!=="BODY"&&"classList"in k&&"contains"in k.classList)}function c(k){var ut=k.type,je=k.tagName;return!!(je==="INPUT"&&s[ut]&&!k.readOnly||je==="TEXTAREA"&&!k.readOnly||k.isContentEditable)}function p(k){k.classList.contains("focus-visible")||(k.classList.add("focus-visible"),k.setAttribute("data-focus-visible-added",""))}function l(k){k.hasAttribute("data-focus-visible-added")&&(k.classList.remove("focus-visible"),k.removeAttribute("data-focus-visible-added"))}function f(k){k.metaKey||k.altKey||k.ctrlKey||(a(r.activeElement)&&p(r.activeElement),o=!0)}function u(k){o=!1}function d(k){a(k.target)&&(o||c(k.target))&&p(k.target)}function v(k){a(k.target)&&(k.target.classList.contains("focus-visible")||k.target.hasAttribute("data-focus-visible-added"))&&(n=!0,window.clearTimeout(i),i=window.setTimeout(function(){n=!1},100),l(k.target))}function S(k){document.visibilityState==="hidden"&&(n&&(o=!0),X())}function X(){document.addEventListener("mousemove",ee),document.addEventListener("mousedown",ee),document.addEventListener("mouseup",ee),document.addEventListener("pointermove",ee),document.addEventListener("pointerdown",ee),document.addEventListener("pointerup",ee),document.addEventListener("touchmove",ee),document.addEventListener("touchstart",ee),document.addEventListener("touchend",ee)}function re(){document.removeEventListener("mousemove",ee),document.removeEventListener("mousedown",ee),document.removeEventListener("mouseup",ee),document.removeEventListener("pointermove",ee),document.removeEventListener("pointerdown",ee),document.removeEventListener("pointerup",ee),document.removeEventListener("touchmove",ee),document.removeEventListener("touchstart",ee),document.removeEventListener("touchend",ee)}function ee(k){k.target.nodeName&&k.target.nodeName.toLowerCase()==="html"||(o=!1,re())}document.addEventListener("keydown",f,!0),document.addEventListener("mousedown",u,!0),document.addEventListener("pointerdown",u,!0),document.addEventListener("touchstart",u,!0),document.addEventListener("visibilitychange",S,!0),X(),r.addEventListener("focus",d,!0),r.addEventListener("blur",v,!0),r.nodeType===Node.DOCUMENT_FRAGMENT_NODE&&r.host?r.host.setAttribute("data-js-focus-visible",""):r.nodeType===Node.DOCUMENT_NODE&&(document.documentElement.classList.add("js-focus-visible"),document.documentElement.setAttribute("data-js-focus-visible",""))}if(typeof window!="undefined"&&typeof document!="undefined"){window.applyFocusVisiblePolyfill=e;var t;try{t=new CustomEvent("focus-visible-polyfill-ready")}catch(r){t=document.createEvent("CustomEvent"),t.initCustomEvent("focus-visible-polyfill-ready",!1,!1,{})}window.dispatchEvent(t)}typeof document!="undefined"&&e(document)}))});var ro=Cr((jy,Rn)=>{"use strict";/*! + * escape-html + * Copyright(c) 2012-2013 TJ Holowaychuk + * Copyright(c) 2015 Andreas Lubbe + * Copyright(c) 2015 Tiancheng "Timothy" Gu + * MIT Licensed + */var qa=/["'&<>]/;Rn.exports=Ka;function Ka(e){var t=""+e,r=qa.exec(t);if(!r)return t;var o,n="",i=0,s=0;for(i=r.index;i{/*! + * clipboard.js v2.0.11 + * https://clipboardjs.com/ + * + * Licensed MIT © Zeno Rocha + */(function(t,r){typeof Nt=="object"&&typeof io=="object"?io.exports=r():typeof define=="function"&&define.amd?define([],r):typeof Nt=="object"?Nt.ClipboardJS=r():t.ClipboardJS=r()})(Nt,function(){return(function(){var e={686:(function(o,n,i){"use strict";i.d(n,{default:function(){return Xi}});var s=i(279),a=i.n(s),c=i(370),p=i.n(c),l=i(817),f=i.n(l);function u(q){try{return document.execCommand(q)}catch(C){return!1}}var d=function(C){var _=f()(C);return u("cut"),_},v=d;function S(q){var C=document.documentElement.getAttribute("dir")==="rtl",_=document.createElement("textarea");_.style.fontSize="12pt",_.style.border="0",_.style.padding="0",_.style.margin="0",_.style.position="absolute",_.style[C?"right":"left"]="-9999px";var D=window.pageYOffset||document.documentElement.scrollTop;return _.style.top="".concat(D,"px"),_.setAttribute("readonly",""),_.value=q,_}var X=function(C,_){var D=S(C);_.container.appendChild(D);var N=f()(D);return u("copy"),D.remove(),N},re=function(C){var _=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{container:document.body},D="";return typeof C=="string"?D=X(C,_):C instanceof HTMLInputElement&&!["text","search","url","tel","password"].includes(C==null?void 0:C.type)?D=X(C.value,_):(D=f()(C),u("copy")),D},ee=re;function k(q){"@babel/helpers - typeof";return typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?k=function(_){return typeof _}:k=function(_){return _&&typeof Symbol=="function"&&_.constructor===Symbol&&_!==Symbol.prototype?"symbol":typeof _},k(q)}var ut=function(){var C=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{},_=C.action,D=_===void 0?"copy":_,N=C.container,G=C.target,We=C.text;if(D!=="copy"&&D!=="cut")throw new Error('Invalid "action" value, use either "copy" or "cut"');if(G!==void 0)if(G&&k(G)==="object"&&G.nodeType===1){if(D==="copy"&&G.hasAttribute("disabled"))throw new Error('Invalid "target" attribute. Please use "readonly" instead of "disabled" attribute');if(D==="cut"&&(G.hasAttribute("readonly")||G.hasAttribute("disabled")))throw new Error(`Invalid "target" attribute. You can't cut text from elements with "readonly" or "disabled" attributes`)}else throw new Error('Invalid "target" value, use a valid Element');if(We)return ee(We,{container:N});if(G)return D==="cut"?v(G):ee(G,{container:N})},je=ut;function R(q){"@babel/helpers - typeof";return typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?R=function(_){return typeof _}:R=function(_){return _&&typeof Symbol=="function"&&_.constructor===Symbol&&_!==Symbol.prototype?"symbol":typeof _},R(q)}function se(q,C){if(!(q instanceof C))throw new TypeError("Cannot call a class as a function")}function ce(q,C){for(var _=0;_0&&arguments[0]!==void 0?arguments[0]:{};this.action=typeof N.action=="function"?N.action:this.defaultAction,this.target=typeof N.target=="function"?N.target:this.defaultTarget,this.text=typeof N.text=="function"?N.text:this.defaultText,this.container=R(N.container)==="object"?N.container:document.body}},{key:"listenClick",value:function(N){var G=this;this.listener=p()(N,"click",function(We){return G.onClick(We)})}},{key:"onClick",value:function(N){var G=N.delegateTarget||N.currentTarget,We=this.action(G)||"copy",Yt=je({action:We,container:this.container,target:this.target(G),text:this.text(G)});this.emit(Yt?"success":"error",{action:We,text:Yt,trigger:G,clearSelection:function(){G&&G.focus(),window.getSelection().removeAllRanges()}})}},{key:"defaultAction",value:function(N){return Mr("action",N)}},{key:"defaultTarget",value:function(N){var G=Mr("target",N);if(G)return document.querySelector(G)}},{key:"defaultText",value:function(N){return Mr("text",N)}},{key:"destroy",value:function(){this.listener.destroy()}}],[{key:"copy",value:function(N){var G=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{container:document.body};return ee(N,G)}},{key:"cut",value:function(N){return v(N)}},{key:"isSupported",value:function(){var N=arguments.length>0&&arguments[0]!==void 0?arguments[0]:["copy","cut"],G=typeof N=="string"?[N]:N,We=!!document.queryCommandSupported;return G.forEach(function(Yt){We=We&&!!document.queryCommandSupported(Yt)}),We}}]),_})(a()),Xi=Ji}),828:(function(o){var n=9;if(typeof Element!="undefined"&&!Element.prototype.matches){var i=Element.prototype;i.matches=i.matchesSelector||i.mozMatchesSelector||i.msMatchesSelector||i.oMatchesSelector||i.webkitMatchesSelector}function s(a,c){for(;a&&a.nodeType!==n;){if(typeof a.matches=="function"&&a.matches(c))return a;a=a.parentNode}}o.exports=s}),438:(function(o,n,i){var s=i(828);function a(l,f,u,d,v){var S=p.apply(this,arguments);return l.addEventListener(u,S,v),{destroy:function(){l.removeEventListener(u,S,v)}}}function c(l,f,u,d,v){return typeof l.addEventListener=="function"?a.apply(null,arguments):typeof u=="function"?a.bind(null,document).apply(null,arguments):(typeof l=="string"&&(l=document.querySelectorAll(l)),Array.prototype.map.call(l,function(S){return a(S,f,u,d,v)}))}function p(l,f,u,d){return function(v){v.delegateTarget=s(v.target,f),v.delegateTarget&&d.call(l,v)}}o.exports=c}),879:(function(o,n){n.node=function(i){return i!==void 0&&i instanceof HTMLElement&&i.nodeType===1},n.nodeList=function(i){var s=Object.prototype.toString.call(i);return i!==void 0&&(s==="[object NodeList]"||s==="[object HTMLCollection]")&&"length"in i&&(i.length===0||n.node(i[0]))},n.string=function(i){return typeof i=="string"||i instanceof String},n.fn=function(i){var s=Object.prototype.toString.call(i);return s==="[object Function]"}}),370:(function(o,n,i){var s=i(879),a=i(438);function c(u,d,v){if(!u&&!d&&!v)throw new Error("Missing required arguments");if(!s.string(d))throw new TypeError("Second argument must be a String");if(!s.fn(v))throw new TypeError("Third argument must be a Function");if(s.node(u))return p(u,d,v);if(s.nodeList(u))return l(u,d,v);if(s.string(u))return f(u,d,v);throw new TypeError("First argument must be a String, HTMLElement, HTMLCollection, or NodeList")}function p(u,d,v){return u.addEventListener(d,v),{destroy:function(){u.removeEventListener(d,v)}}}function l(u,d,v){return Array.prototype.forEach.call(u,function(S){S.addEventListener(d,v)}),{destroy:function(){Array.prototype.forEach.call(u,function(S){S.removeEventListener(d,v)})}}}function f(u,d,v){return a(document.body,u,d,v)}o.exports=c}),817:(function(o){function n(i){var s;if(i.nodeName==="SELECT")i.focus(),s=i.value;else if(i.nodeName==="INPUT"||i.nodeName==="TEXTAREA"){var a=i.hasAttribute("readonly");a||i.setAttribute("readonly",""),i.select(),i.setSelectionRange(0,i.value.length),a||i.removeAttribute("readonly"),s=i.value}else{i.hasAttribute("contenteditable")&&i.focus();var c=window.getSelection(),p=document.createRange();p.selectNodeContents(i),c.removeAllRanges(),c.addRange(p),s=c.toString()}return s}o.exports=n}),279:(function(o){function n(){}n.prototype={on:function(i,s,a){var c=this.e||(this.e={});return(c[i]||(c[i]=[])).push({fn:s,ctx:a}),this},once:function(i,s,a){var c=this;function p(){c.off(i,p),s.apply(a,arguments)}return p._=s,this.on(i,p,a)},emit:function(i){var s=[].slice.call(arguments,1),a=((this.e||(this.e={}))[i]||[]).slice(),c=0,p=a.length;for(c;c0&&i[i.length-1])&&(p[0]===6||p[0]===2)){r=0;continue}if(p[0]===3&&(!i||p[1]>i[0]&&p[1]=e.length&&(e=void 0),{value:e&&e[o++],done:!e}}};throw new TypeError(t?"Object is not iterable.":"Symbol.iterator is not defined.")}function K(e,t){var r=typeof Symbol=="function"&&e[Symbol.iterator];if(!r)return e;var o=r.call(e),n,i=[],s;try{for(;(t===void 0||t-- >0)&&!(n=o.next()).done;)i.push(n.value)}catch(a){s={error:a}}finally{try{n&&!n.done&&(r=o.return)&&r.call(o)}finally{if(s)throw s.error}}return i}function B(e,t,r){if(r||arguments.length===2)for(var o=0,n=t.length,i;o1||c(d,S)})},v&&(n[d]=v(n[d])))}function c(d,v){try{p(o[d](v))}catch(S){u(i[0][3],S)}}function p(d){d.value instanceof dt?Promise.resolve(d.value.v).then(l,f):u(i[0][2],d)}function l(d){c("next",d)}function f(d){c("throw",d)}function u(d,v){d(v),i.shift(),i.length&&c(i[0][0],i[0][1])}}function To(e){if(!Symbol.asyncIterator)throw new TypeError("Symbol.asyncIterator is not defined.");var t=e[Symbol.asyncIterator],r;return t?t.call(e):(e=typeof Oe=="function"?Oe(e):e[Symbol.iterator](),r={},o("next"),o("throw"),o("return"),r[Symbol.asyncIterator]=function(){return this},r);function o(i){r[i]=e[i]&&function(s){return new Promise(function(a,c){s=e[i](s),n(a,c,s.done,s.value)})}}function n(i,s,a,c){Promise.resolve(c).then(function(p){i({value:p,done:a})},s)}}function I(e){return typeof e=="function"}function yt(e){var t=function(o){Error.call(o),o.stack=new Error().stack},r=e(t);return r.prototype=Object.create(Error.prototype),r.prototype.constructor=r,r}var Jt=yt(function(e){return function(r){e(this),this.message=r?r.length+` errors occurred during unsubscription: +`+r.map(function(o,n){return n+1+") "+o.toString()}).join(` + `):"",this.name="UnsubscriptionError",this.errors=r}});function Ze(e,t){if(e){var r=e.indexOf(t);0<=r&&e.splice(r,1)}}var qe=(function(){function e(t){this.initialTeardown=t,this.closed=!1,this._parentage=null,this._finalizers=null}return e.prototype.unsubscribe=function(){var t,r,o,n,i;if(!this.closed){this.closed=!0;var s=this._parentage;if(s)if(this._parentage=null,Array.isArray(s))try{for(var a=Oe(s),c=a.next();!c.done;c=a.next()){var p=c.value;p.remove(this)}}catch(S){t={error:S}}finally{try{c&&!c.done&&(r=a.return)&&r.call(a)}finally{if(t)throw t.error}}else s.remove(this);var l=this.initialTeardown;if(I(l))try{l()}catch(S){i=S instanceof Jt?S.errors:[S]}var f=this._finalizers;if(f){this._finalizers=null;try{for(var u=Oe(f),d=u.next();!d.done;d=u.next()){var v=d.value;try{So(v)}catch(S){i=i!=null?i:[],S instanceof Jt?i=B(B([],K(i)),K(S.errors)):i.push(S)}}}catch(S){o={error:S}}finally{try{d&&!d.done&&(n=u.return)&&n.call(u)}finally{if(o)throw o.error}}}if(i)throw new Jt(i)}},e.prototype.add=function(t){var r;if(t&&t!==this)if(this.closed)So(t);else{if(t instanceof e){if(t.closed||t._hasParent(this))return;t._addParent(this)}(this._finalizers=(r=this._finalizers)!==null&&r!==void 0?r:[]).push(t)}},e.prototype._hasParent=function(t){var r=this._parentage;return r===t||Array.isArray(r)&&r.includes(t)},e.prototype._addParent=function(t){var r=this._parentage;this._parentage=Array.isArray(r)?(r.push(t),r):r?[r,t]:t},e.prototype._removeParent=function(t){var r=this._parentage;r===t?this._parentage=null:Array.isArray(r)&&Ze(r,t)},e.prototype.remove=function(t){var r=this._finalizers;r&&Ze(r,t),t instanceof e&&t._removeParent(this)},e.EMPTY=(function(){var t=new e;return t.closed=!0,t})(),e})();var $r=qe.EMPTY;function Xt(e){return e instanceof qe||e&&"closed"in e&&I(e.remove)&&I(e.add)&&I(e.unsubscribe)}function So(e){I(e)?e():e.unsubscribe()}var De={onUnhandledError:null,onStoppedNotification:null,Promise:void 0,useDeprecatedSynchronousErrorHandling:!1,useDeprecatedNextContext:!1};var xt={setTimeout:function(e,t){for(var r=[],o=2;o0},enumerable:!1,configurable:!0}),t.prototype._trySubscribe=function(r){return this._throwIfClosed(),e.prototype._trySubscribe.call(this,r)},t.prototype._subscribe=function(r){return this._throwIfClosed(),this._checkFinalizedStatuses(r),this._innerSubscribe(r)},t.prototype._innerSubscribe=function(r){var o=this,n=this,i=n.hasError,s=n.isStopped,a=n.observers;return i||s?$r:(this.currentObservers=null,a.push(r),new qe(function(){o.currentObservers=null,Ze(a,r)}))},t.prototype._checkFinalizedStatuses=function(r){var o=this,n=o.hasError,i=o.thrownError,s=o.isStopped;n?r.error(i):s&&r.complete()},t.prototype.asObservable=function(){var r=new F;return r.source=this,r},t.create=function(r,o){return new Ho(r,o)},t})(F);var Ho=(function(e){ie(t,e);function t(r,o){var n=e.call(this)||this;return n.destination=r,n.source=o,n}return t.prototype.next=function(r){var o,n;(n=(o=this.destination)===null||o===void 0?void 0:o.next)===null||n===void 0||n.call(o,r)},t.prototype.error=function(r){var o,n;(n=(o=this.destination)===null||o===void 0?void 0:o.error)===null||n===void 0||n.call(o,r)},t.prototype.complete=function(){var r,o;(o=(r=this.destination)===null||r===void 0?void 0:r.complete)===null||o===void 0||o.call(r)},t.prototype._subscribe=function(r){var o,n;return(n=(o=this.source)===null||o===void 0?void 0:o.subscribe(r))!==null&&n!==void 0?n:$r},t})(T);var jr=(function(e){ie(t,e);function t(r){var o=e.call(this)||this;return o._value=r,o}return Object.defineProperty(t.prototype,"value",{get:function(){return this.getValue()},enumerable:!1,configurable:!0}),t.prototype._subscribe=function(r){var o=e.prototype._subscribe.call(this,r);return!o.closed&&r.next(this._value),o},t.prototype.getValue=function(){var r=this,o=r.hasError,n=r.thrownError,i=r._value;if(o)throw n;return this._throwIfClosed(),i},t.prototype.next=function(r){e.prototype.next.call(this,this._value=r)},t})(T);var Rt={now:function(){return(Rt.delegate||Date).now()},delegate:void 0};var It=(function(e){ie(t,e);function t(r,o,n){r===void 0&&(r=1/0),o===void 0&&(o=1/0),n===void 0&&(n=Rt);var i=e.call(this)||this;return i._bufferSize=r,i._windowTime=o,i._timestampProvider=n,i._buffer=[],i._infiniteTimeWindow=!0,i._infiniteTimeWindow=o===1/0,i._bufferSize=Math.max(1,r),i._windowTime=Math.max(1,o),i}return t.prototype.next=function(r){var o=this,n=o.isStopped,i=o._buffer,s=o._infiniteTimeWindow,a=o._timestampProvider,c=o._windowTime;n||(i.push(r),!s&&i.push(a.now()+c)),this._trimBuffer(),e.prototype.next.call(this,r)},t.prototype._subscribe=function(r){this._throwIfClosed(),this._trimBuffer();for(var o=this._innerSubscribe(r),n=this,i=n._infiniteTimeWindow,s=n._buffer,a=s.slice(),c=0;c0?e.prototype.schedule.call(this,r,o):(this.delay=o,this.state=r,this.scheduler.flush(this),this)},t.prototype.execute=function(r,o){return o>0||this.closed?e.prototype.execute.call(this,r,o):this._execute(r,o)},t.prototype.requestAsyncId=function(r,o,n){return n===void 0&&(n=0),n!=null&&n>0||n==null&&this.delay>0?e.prototype.requestAsyncId.call(this,r,o,n):(r.flush(this),0)},t})(St);var Ro=(function(e){ie(t,e);function t(){return e!==null&&e.apply(this,arguments)||this}return t})(Ot);var Dr=new Ro(Po);var Io=(function(e){ie(t,e);function t(r,o){var n=e.call(this,r,o)||this;return n.scheduler=r,n.work=o,n}return t.prototype.requestAsyncId=function(r,o,n){return n===void 0&&(n=0),n!==null&&n>0?e.prototype.requestAsyncId.call(this,r,o,n):(r.actions.push(this),r._scheduled||(r._scheduled=Tt.requestAnimationFrame(function(){return r.flush(void 0)})))},t.prototype.recycleAsyncId=function(r,o,n){var i;if(n===void 0&&(n=0),n!=null?n>0:this.delay>0)return e.prototype.recycleAsyncId.call(this,r,o,n);var s=r.actions;o!=null&&o===r._scheduled&&((i=s[s.length-1])===null||i===void 0?void 0:i.id)!==o&&(Tt.cancelAnimationFrame(o),r._scheduled=void 0)},t})(St);var Fo=(function(e){ie(t,e);function t(){return e!==null&&e.apply(this,arguments)||this}return t.prototype.flush=function(r){this._active=!0;var o;r?o=r.id:(o=this._scheduled,this._scheduled=void 0);var n=this.actions,i;r=r||n.shift();do if(i=r.execute(r.state,r.delay))break;while((r=n[0])&&r.id===o&&n.shift());if(this._active=!1,i){for(;(r=n[0])&&r.id===o&&n.shift();)r.unsubscribe();throw i}},t})(Ot);var ye=new Fo(Io);var y=new F(function(e){return e.complete()});function tr(e){return e&&I(e.schedule)}function Vr(e){return e[e.length-1]}function pt(e){return I(Vr(e))?e.pop():void 0}function Fe(e){return tr(Vr(e))?e.pop():void 0}function rr(e,t){return typeof Vr(e)=="number"?e.pop():t}var Lt=(function(e){return e&&typeof e.length=="number"&&typeof e!="function"});function or(e){return I(e==null?void 0:e.then)}function nr(e){return I(e[wt])}function ir(e){return Symbol.asyncIterator&&I(e==null?void 0:e[Symbol.asyncIterator])}function ar(e){return new TypeError("You provided "+(e!==null&&typeof e=="object"?"an invalid object":"'"+e+"'")+" where a stream was expected. You can provide an Observable, Promise, ReadableStream, Array, AsyncIterable, or Iterable.")}function fa(){return typeof Symbol!="function"||!Symbol.iterator?"@@iterator":Symbol.iterator}var sr=fa();function cr(e){return I(e==null?void 0:e[sr])}function pr(e){return wo(this,arguments,function(){var r,o,n,i;return Gt(this,function(s){switch(s.label){case 0:r=e.getReader(),s.label=1;case 1:s.trys.push([1,,9,10]),s.label=2;case 2:return[4,dt(r.read())];case 3:return o=s.sent(),n=o.value,i=o.done,i?[4,dt(void 0)]:[3,5];case 4:return[2,s.sent()];case 5:return[4,dt(n)];case 6:return[4,s.sent()];case 7:return s.sent(),[3,2];case 8:return[3,10];case 9:return r.releaseLock(),[7];case 10:return[2]}})})}function lr(e){return I(e==null?void 0:e.getReader)}function U(e){if(e instanceof F)return e;if(e!=null){if(nr(e))return ua(e);if(Lt(e))return da(e);if(or(e))return ha(e);if(ir(e))return jo(e);if(cr(e))return ba(e);if(lr(e))return va(e)}throw ar(e)}function ua(e){return new F(function(t){var r=e[wt]();if(I(r.subscribe))return r.subscribe(t);throw new TypeError("Provided object does not correctly implement Symbol.observable")})}function da(e){return new F(function(t){for(var r=0;r=2;return function(o){return o.pipe(e?g(function(n,i){return e(n,i,o)}):be,Ee(1),r?Qe(t):tn(function(){return new fr}))}}function Yr(e){return e<=0?function(){return y}:E(function(t,r){var o=[];t.subscribe(w(r,function(n){o.push(n),e=2,!0))}function le(e){e===void 0&&(e={});var t=e.connector,r=t===void 0?function(){return new T}:t,o=e.resetOnError,n=o===void 0?!0:o,i=e.resetOnComplete,s=i===void 0?!0:i,a=e.resetOnRefCountZero,c=a===void 0?!0:a;return function(p){var l,f,u,d=0,v=!1,S=!1,X=function(){f==null||f.unsubscribe(),f=void 0},re=function(){X(),l=u=void 0,v=S=!1},ee=function(){var k=l;re(),k==null||k.unsubscribe()};return E(function(k,ut){d++,!S&&!v&&X();var je=u=u!=null?u:r();ut.add(function(){d--,d===0&&!S&&!v&&(f=Br(ee,c))}),je.subscribe(ut),!l&&d>0&&(l=new bt({next:function(R){return je.next(R)},error:function(R){S=!0,X(),f=Br(re,n,R),je.error(R)},complete:function(){v=!0,X(),f=Br(re,s),je.complete()}}),U(k).subscribe(l))})(p)}}function Br(e,t){for(var r=[],o=2;oe.next(document)),e}function M(e,t=document){return Array.from(t.querySelectorAll(e))}function j(e,t=document){let r=ue(e,t);if(typeof r=="undefined")throw new ReferenceError(`Missing element: expected "${e}" to be present`);return r}function ue(e,t=document){return t.querySelector(e)||void 0}function Ne(){var e,t,r,o;return(o=(r=(t=(e=document.activeElement)==null?void 0:e.shadowRoot)==null?void 0:t.activeElement)!=null?r:document.activeElement)!=null?o:void 0}var Ra=L(h(document.body,"focusin"),h(document.body,"focusout")).pipe(Ae(1),Q(void 0),m(()=>Ne()||document.body),Z(1));function Ye(e){return Ra.pipe(m(t=>e.contains(t)),Y())}function it(e,t){return H(()=>L(h(e,"mouseenter").pipe(m(()=>!0)),h(e,"mouseleave").pipe(m(()=>!1))).pipe(t?jt(r=>He(+!r*t)):be,Q(e.matches(":hover"))))}function sn(e,t){if(typeof t=="string"||typeof t=="number")e.innerHTML+=t.toString();else if(t instanceof Node)e.appendChild(t);else if(Array.isArray(t))for(let r of t)sn(e,r)}function x(e,t,...r){let o=document.createElement(e);if(t)for(let n of Object.keys(t))typeof t[n]!="undefined"&&(typeof t[n]!="boolean"?o.setAttribute(n,t[n]):o.setAttribute(n,""));for(let n of r)sn(o,n);return o}function br(e){if(e>999){let t=+((e-950)%1e3>99);return`${((e+1e-6)/1e3).toFixed(t)}k`}else return e.toString()}function _t(e){let t=x("script",{src:e});return H(()=>(document.head.appendChild(t),L(h(t,"load"),h(t,"error").pipe(b(()=>Nr(()=>new ReferenceError(`Invalid script: ${e}`))))).pipe(m(()=>{}),A(()=>document.head.removeChild(t)),Ee(1))))}var cn=new T,Ia=H(()=>typeof ResizeObserver=="undefined"?_t("https://unpkg.com/resize-observer-polyfill"):$(void 0)).pipe(m(()=>new ResizeObserver(e=>e.forEach(t=>cn.next(t)))),b(e=>L(tt,$(e)).pipe(A(()=>e.disconnect()))),Z(1));function de(e){return{width:e.offsetWidth,height:e.offsetHeight}}function Le(e){let t=e;for(;t.clientWidth===0&&t.parentElement;)t=t.parentElement;return Ia.pipe(O(r=>r.observe(t)),b(r=>cn.pipe(g(o=>o.target===t),A(()=>r.unobserve(t)))),m(()=>de(e)),Q(de(e)))}function At(e){return{width:e.scrollWidth,height:e.scrollHeight}}function vr(e){let t=e.parentElement;for(;t&&(e.scrollWidth<=t.scrollWidth&&e.scrollHeight<=t.scrollHeight);)t=(e=t).parentElement;return t?e:void 0}function pn(e){let t=[],r=e.parentElement;for(;r;)(e.clientWidth>r.clientWidth||e.clientHeight>r.clientHeight)&&t.push(r),r=(e=r).parentElement;return t.length===0&&t.push(document.documentElement),t}function Be(e){return{x:e.offsetLeft,y:e.offsetTop}}function ln(e){let t=e.getBoundingClientRect();return{x:t.x+window.scrollX,y:t.y+window.scrollY}}function mn(e){return L(h(window,"load"),h(window,"resize")).pipe($e(0,ye),m(()=>Be(e)),Q(Be(e)))}function gr(e){return{x:e.scrollLeft,y:e.scrollTop}}function Ge(e){return L(h(e,"scroll"),h(window,"scroll"),h(window,"resize")).pipe($e(0,ye),m(()=>gr(e)),Q(gr(e)))}var fn=new T,Fa=H(()=>$(new IntersectionObserver(e=>{for(let t of e)fn.next(t)},{threshold:0}))).pipe(b(e=>L(tt,$(e)).pipe(A(()=>e.disconnect()))),Z(1));function mt(e){return Fa.pipe(O(t=>t.observe(e)),b(t=>fn.pipe(g(({target:r})=>r===e),A(()=>t.unobserve(e)),m(({isIntersecting:r})=>r))))}function un(e,t=16){return Ge(e).pipe(m(({y:r})=>{let o=de(e),n=At(e);return r>=n.height-o.height-t}),Y())}var yr={drawer:j("[data-md-toggle=drawer]"),search:j("[data-md-toggle=search]")};function dn(e){return yr[e].checked}function at(e,t){yr[e].checked!==t&&yr[e].click()}function Je(e){let t=yr[e];return h(t,"change").pipe(m(()=>t.checked),Q(t.checked))}function ja(e,t){switch(e.constructor){case HTMLInputElement:return e.type==="radio"?/^Arrow/.test(t):!0;case HTMLSelectElement:case HTMLTextAreaElement:return!0;default:return e.isContentEditable}}function Ua(){return L(h(window,"compositionstart").pipe(m(()=>!0)),h(window,"compositionend").pipe(m(()=>!1))).pipe(Q(!1))}function hn(){let e=h(window,"keydown").pipe(g(t=>!(t.metaKey||t.ctrlKey)),m(t=>({mode:dn("search")?"search":"global",type:t.key,claim(){t.preventDefault(),t.stopPropagation()}})),g(({mode:t,type:r})=>{if(t==="global"){let o=Ne();if(typeof o!="undefined")return!ja(o,r)}return!0}),le());return Ua().pipe(b(t=>t?y:e))}function we(){return new URL(location.href)}function st(e,t=!1){if(V("navigation.instant")&&!t){let r=x("a",{href:e.href});document.body.appendChild(r),r.click(),r.remove()}else location.href=e.href}function bn(){return new T}function vn(){return location.hash.slice(1)}function gn(e){let t=x("a",{href:e});t.addEventListener("click",r=>r.stopPropagation()),t.click()}function Zr(e){return L(h(window,"hashchange"),e).pipe(m(vn),Q(vn()),g(t=>t.length>0),Z(1))}function yn(e){return Zr(e).pipe(m(t=>ue(`[id="${t}"]`)),g(t=>typeof t!="undefined"))}function Wt(e){let t=matchMedia(e);return ur(r=>t.addListener(()=>r(t.matches))).pipe(Q(t.matches))}function xn(){let e=matchMedia("print");return L(h(window,"beforeprint").pipe(m(()=>!0)),h(window,"afterprint").pipe(m(()=>!1))).pipe(Q(e.matches))}function eo(e,t){return e.pipe(b(r=>r?t():y))}function to(e,t){return new F(r=>{let o=new XMLHttpRequest;return o.open("GET",`${e}`),o.responseType="blob",o.addEventListener("load",()=>{o.status>=200&&o.status<300?(r.next(o.response),r.complete()):r.error(new Error(o.statusText))}),o.addEventListener("error",()=>{r.error(new Error("Network error"))}),o.addEventListener("abort",()=>{r.complete()}),typeof(t==null?void 0:t.progress$)!="undefined"&&(o.addEventListener("progress",n=>{var i;if(n.lengthComputable)t.progress$.next(n.loaded/n.total*100);else{let s=(i=o.getResponseHeader("Content-Length"))!=null?i:0;t.progress$.next(n.loaded/+s*100)}}),t.progress$.next(5)),o.send(),()=>o.abort()})}function ze(e,t){return to(e,t).pipe(b(r=>r.text()),m(r=>JSON.parse(r)),Z(1))}function xr(e,t){let r=new DOMParser;return to(e,t).pipe(b(o=>o.text()),m(o=>r.parseFromString(o,"text/html")),Z(1))}function En(e,t){let r=new DOMParser;return to(e,t).pipe(b(o=>o.text()),m(o=>r.parseFromString(o,"text/xml")),Z(1))}function wn(){return{x:Math.max(0,scrollX),y:Math.max(0,scrollY)}}function Tn(){return L(h(window,"scroll",{passive:!0}),h(window,"resize",{passive:!0})).pipe(m(wn),Q(wn()))}function Sn(){return{width:innerWidth,height:innerHeight}}function On(){return h(window,"resize",{passive:!0}).pipe(m(Sn),Q(Sn()))}function Ln(){return z([Tn(),On()]).pipe(m(([e,t])=>({offset:e,size:t})),Z(1))}function Er(e,{viewport$:t,header$:r}){let o=t.pipe(ne("size")),n=z([o,r]).pipe(m(()=>Be(e)));return z([r,t,n]).pipe(m(([{height:i},{offset:s,size:a},{x:c,y:p}])=>({offset:{x:s.x-c,y:s.y-p+i},size:a})))}function Wa(e){return h(e,"message",t=>t.data)}function Da(e){let t=new T;return t.subscribe(r=>e.postMessage(r)),t}function Mn(e,t=new Worker(e)){let r=Wa(t),o=Da(t),n=new T;n.subscribe(o);let i=o.pipe(oe(),ae(!0));return n.pipe(oe(),Ve(r.pipe(W(i))),le())}var Va=j("#__config"),Ct=JSON.parse(Va.textContent);Ct.base=`${new URL(Ct.base,we())}`;function Te(){return Ct}function V(e){return Ct.features.includes(e)}function Me(e,t){return typeof t!="undefined"?Ct.translations[e].replace("#",t.toString()):Ct.translations[e]}function Ce(e,t=document){return j(`[data-md-component=${e}]`,t)}function me(e,t=document){return M(`[data-md-component=${e}]`,t)}function Na(e){let t=j(".md-typeset > :first-child",e);return h(t,"click",{once:!0}).pipe(m(()=>j(".md-typeset",e)),m(r=>({hash:__md_hash(r.innerHTML)})))}function _n(e){if(!V("announce.dismiss")||!e.childElementCount)return y;if(!e.hidden){let t=j(".md-typeset",e);__md_hash(t.innerHTML)===__md_get("__announce")&&(e.hidden=!0)}return H(()=>{let t=new T;return t.subscribe(({hash:r})=>{e.hidden=!0,__md_set("__announce",r)}),Na(e).pipe(O(r=>t.next(r)),A(()=>t.complete()),m(r=>P({ref:e},r)))})}function za(e,{target$:t}){return t.pipe(m(r=>({hidden:r!==e})))}function An(e,t){let r=new T;return r.subscribe(({hidden:o})=>{e.hidden=o}),za(e,t).pipe(O(o=>r.next(o)),A(()=>r.complete()),m(o=>P({ref:e},o)))}function Dt(e,t){return t==="inline"?x("div",{class:"md-tooltip md-tooltip--inline",id:e,role:"tooltip"},x("div",{class:"md-tooltip__inner md-typeset"})):x("div",{class:"md-tooltip",id:e,role:"tooltip"},x("div",{class:"md-tooltip__inner md-typeset"}))}function wr(...e){return x("div",{class:"md-tooltip2",role:"dialog"},x("div",{class:"md-tooltip2__inner md-typeset"},e))}function Cn(...e){return x("div",{class:"md-tooltip2",role:"tooltip"},x("div",{class:"md-tooltip2__inner md-typeset"},e))}function kn(e,t){if(t=t?`${t}_annotation_${e}`:void 0,t){let r=t?`#${t}`:void 0;return x("aside",{class:"md-annotation",tabIndex:0},Dt(t),x("a",{href:r,class:"md-annotation__index",tabIndex:-1},x("span",{"data-md-annotation-id":e})))}else return x("aside",{class:"md-annotation",tabIndex:0},Dt(t),x("span",{class:"md-annotation__index",tabIndex:-1},x("span",{"data-md-annotation-id":e})))}function Hn(e){return x("button",{class:"md-code__button",title:Me("clipboard.copy"),"data-clipboard-target":`#${e} > code`,"data-md-type":"copy"})}function $n(){return x("button",{class:"md-code__button",title:"Toggle line selection","data-md-type":"select"})}function Pn(){return x("nav",{class:"md-code__nav"})}var In=$t(ro());function oo(e,t){let r=t&2,o=t&1,n=Object.keys(e.terms).filter(c=>!e.terms[c]).reduce((c,p)=>[...c,x("del",null,(0,In.default)(p))," "],[]).slice(0,-1),i=Te(),s=new URL(e.location,i.base);V("search.highlight")&&s.searchParams.set("h",Object.entries(e.terms).filter(([,c])=>c).reduce((c,[p])=>`${c} ${p}`.trim(),""));let{tags:a}=Te();return x("a",{href:`${s}`,class:"md-search-result__link",tabIndex:-1},x("article",{class:"md-search-result__article md-typeset","data-md-score":e.score.toFixed(2)},r>0&&x("div",{class:"md-search-result__icon md-icon"}),r>0&&x("h1",null,e.title),r<=0&&x("h2",null,e.title),o>0&&e.text.length>0&&e.text,e.tags&&x("nav",{class:"md-tags"},e.tags.map(c=>{let p=a?c in a?`md-tag-icon md-tag--${a[c]}`:"md-tag-icon":"";return x("span",{class:`md-tag ${p}`},c)})),o>0&&n.length>0&&x("p",{class:"md-search-result__terms"},Me("search.result.term.missing"),": ",...n)))}function Fn(e){let t=e[0].score,r=[...e],o=Te(),n=r.findIndex(l=>!`${new URL(l.location,o.base)}`.includes("#")),[i]=r.splice(n,1),s=r.findIndex(l=>l.scoreoo(l,1)),...c.length?[x("details",{class:"md-search-result__more"},x("summary",{tabIndex:-1},x("div",null,c.length>0&&c.length===1?Me("search.result.more.one"):Me("search.result.more.other",c.length))),...c.map(l=>oo(l,1)))]:[]];return x("li",{class:"md-search-result__item"},p)}function jn(e){return x("ul",{class:"md-source__facts"},Object.entries(e).map(([t,r])=>x("li",{class:`md-source__fact md-source__fact--${t}`},typeof r=="number"?br(r):r)))}function no(e){let t=`tabbed-control tabbed-control--${e}`;return x("div",{class:t,hidden:!0},x("button",{class:"tabbed-button",tabIndex:-1,"aria-hidden":"true"}))}function Un(e){return x("div",{class:"md-typeset__scrollwrap"},x("div",{class:"md-typeset__table"},e))}function Qa(e){var o;let t=Te(),r=new URL(`../${e.version}/`,t.base);return x("li",{class:"md-version__item"},x("a",{href:`${r}`,class:"md-version__link"},e.title,((o=t.version)==null?void 0:o.alias)&&e.aliases.length>0&&x("span",{class:"md-version__alias"},e.aliases[0])))}function Wn(e,t){var o;let r=Te();return e=e.filter(n=>{var i;return!((i=n.properties)!=null&&i.hidden)}),x("div",{class:"md-version"},x("button",{class:"md-version__current","aria-label":Me("select.version")},t.title,((o=r.version)==null?void 0:o.alias)&&t.aliases.length>0&&x("span",{class:"md-version__alias"},t.aliases[0])),x("ul",{class:"md-version__list"},e.map(Qa)))}var Ya=0;function Ba(e,t=250){let r=z([Ye(e),it(e,t)]).pipe(m(([n,i])=>n||i),Y()),o=H(()=>pn(e)).pipe(J(Ge),gt(1),Pe(r),m(()=>ln(e)));return r.pipe(Re(n=>n),b(()=>z([r,o])),m(([n,i])=>({active:n,offset:i})),le())}function Vt(e,t,r=250){let{content$:o,viewport$:n}=t,i=`__tooltip2_${Ya++}`;return H(()=>{let s=new T,a=new jr(!1);s.pipe(oe(),ae(!1)).subscribe(a);let c=a.pipe(jt(l=>He(+!l*250,Dr)),Y(),b(l=>l?o:y),O(l=>l.id=i),le());z([s.pipe(m(({active:l})=>l)),c.pipe(b(l=>it(l,250)),Q(!1))]).pipe(m(l=>l.some(f=>f))).subscribe(a);let p=a.pipe(g(l=>l),te(c,n),m(([l,f,{size:u}])=>{let d=e.getBoundingClientRect(),v=d.width/2;if(f.role==="tooltip")return{x:v,y:8+d.height};if(d.y>=u.height/2){let{height:S}=de(f);return{x:v,y:-16-S}}else return{x:v,y:16+d.height}}));return z([c,s,p]).subscribe(([l,{offset:f},u])=>{l.style.setProperty("--md-tooltip-host-x",`${f.x}px`),l.style.setProperty("--md-tooltip-host-y",`${f.y}px`),l.style.setProperty("--md-tooltip-x",`${u.x}px`),l.style.setProperty("--md-tooltip-y",`${u.y}px`),l.classList.toggle("md-tooltip2--top",u.y<0),l.classList.toggle("md-tooltip2--bottom",u.y>=0)}),a.pipe(g(l=>l),te(c,(l,f)=>f),g(l=>l.role==="tooltip")).subscribe(l=>{let f=de(j(":scope > *",l));l.style.setProperty("--md-tooltip-width",`${f.width}px`),l.style.setProperty("--md-tooltip-tail","0px")}),a.pipe(Y(),xe(ye),te(c)).subscribe(([l,f])=>{f.classList.toggle("md-tooltip2--active",l)}),z([a.pipe(g(l=>l)),c]).subscribe(([l,f])=>{f.role==="dialog"?(e.setAttribute("aria-controls",i),e.setAttribute("aria-haspopup","dialog")):e.setAttribute("aria-describedby",i)}),a.pipe(g(l=>!l)).subscribe(()=>{e.removeAttribute("aria-controls"),e.removeAttribute("aria-describedby"),e.removeAttribute("aria-haspopup")}),Ba(e,r).pipe(O(l=>s.next(l)),A(()=>s.complete()),m(l=>P({ref:e},l)))})}function Xe(e,{viewport$:t},r=document.body){return Vt(e,{content$:new F(o=>{let n=e.title,i=Cn(n);return o.next(i),e.removeAttribute("title"),r.append(i),()=>{i.remove(),e.setAttribute("title",n)}}),viewport$:t},0)}function Ga(e,t){let r=H(()=>z([mn(e),Ge(t)])).pipe(m(([{x:o,y:n},i])=>{let{width:s,height:a}=de(e);return{x:o-i.x+s/2,y:n-i.y+a/2}}));return Ye(e).pipe(b(o=>r.pipe(m(n=>({active:o,offset:n})),Ee(+!o||1/0))))}function Dn(e,t,{target$:r}){let[o,n]=Array.from(e.children);return H(()=>{let i=new T,s=i.pipe(oe(),ae(!0));return i.subscribe({next({offset:a}){e.style.setProperty("--md-tooltip-x",`${a.x}px`),e.style.setProperty("--md-tooltip-y",`${a.y}px`)},complete(){e.style.removeProperty("--md-tooltip-x"),e.style.removeProperty("--md-tooltip-y")}}),mt(e).pipe(W(s)).subscribe(a=>{e.toggleAttribute("data-md-visible",a)}),L(i.pipe(g(({active:a})=>a)),i.pipe(Ae(250),g(({active:a})=>!a))).subscribe({next({active:a}){a?e.prepend(o):o.remove()},complete(){e.prepend(o)}}),i.pipe($e(16,ye)).subscribe(({active:a})=>{o.classList.toggle("md-tooltip--active",a)}),i.pipe(gt(125,ye),g(()=>!!e.offsetParent),m(()=>e.offsetParent.getBoundingClientRect()),m(({x:a})=>a)).subscribe({next(a){a?e.style.setProperty("--md-tooltip-0",`${-a}px`):e.style.removeProperty("--md-tooltip-0")},complete(){e.style.removeProperty("--md-tooltip-0")}}),h(n,"click").pipe(W(s),g(a=>!(a.metaKey||a.ctrlKey))).subscribe(a=>{a.stopPropagation(),a.preventDefault()}),h(n,"mousedown").pipe(W(s),te(i)).subscribe(([a,{active:c}])=>{var p;if(a.button!==0||a.metaKey||a.ctrlKey)a.preventDefault();else if(c){a.preventDefault();let l=e.parentElement.closest(".md-annotation");l instanceof HTMLElement?l.focus():(p=Ne())==null||p.blur()}}),r.pipe(W(s),g(a=>a===o),nt(125)).subscribe(()=>e.focus()),Ga(e,t).pipe(O(a=>i.next(a)),A(()=>i.complete()),m(a=>P({ref:e},a)))})}function Ja(e){let t=Te();if(e.tagName!=="CODE")return[e];let r=[".c",".c1",".cm"];if(t.annotate&&typeof t.annotate=="object"){let o=e.closest("[class|=language]");if(o)for(let n of Array.from(o.classList)){if(!n.startsWith("language-"))continue;let[,i]=n.split("-");i in t.annotate&&r.push(...t.annotate[i])}}return M(r.join(", "),e)}function Xa(e){let t=[];for(let r of Ja(e)){let o=[],n=document.createNodeIterator(r,NodeFilter.SHOW_TEXT);for(let i=n.nextNode();i;i=n.nextNode())o.push(i);for(let i of o){let s;for(;s=/(\(\d+\))(!)?/.exec(i.textContent);){let[,a,c]=s;if(typeof c=="undefined"){let p=i.splitText(s.index);i=p.splitText(a.length),t.push(p)}else{i.textContent=a,t.push(i);break}}}}return t}function Vn(e,t){t.append(...Array.from(e.childNodes))}function Tr(e,t,{target$:r,print$:o}){let n=t.closest("[id]"),i=n==null?void 0:n.id,s=new Map;for(let a of Xa(t)){let[,c]=a.textContent.match(/\((\d+)\)/);ue(`:scope > li:nth-child(${c})`,e)&&(s.set(c,kn(c,i)),a.replaceWith(s.get(c)))}return s.size===0?y:H(()=>{let a=new T,c=a.pipe(oe(),ae(!0)),p=[];for(let[l,f]of s)p.push([j(".md-typeset",f),j(`:scope > li:nth-child(${l})`,e)]);return o.pipe(W(c)).subscribe(l=>{e.hidden=!l,e.classList.toggle("md-annotation-list",l);for(let[f,u]of p)l?Vn(f,u):Vn(u,f)}),L(...[...s].map(([,l])=>Dn(l,t,{target$:r}))).pipe(A(()=>a.complete()),le())})}function Nn(e){if(e.nextElementSibling){let t=e.nextElementSibling;if(t.tagName==="OL")return t;if(t.tagName==="P"&&!t.children.length)return Nn(t)}}function zn(e,t){return H(()=>{let r=Nn(e);return typeof r!="undefined"?Tr(r,e,t):y})}var Kn=$t(ao());var Za=0,qn=L(h(window,"keydown").pipe(m(()=>!0)),L(h(window,"keyup"),h(window,"contextmenu")).pipe(m(()=>!1))).pipe(Q(!1),Z(1));function Qn(e){if(e.nextElementSibling){let t=e.nextElementSibling;if(t.tagName==="OL")return t;if(t.tagName==="P"&&!t.children.length)return Qn(t)}}function es(e){return Le(e).pipe(m(({width:t})=>({scrollable:At(e).width>t})),ne("scrollable"))}function Yn(e,t){let{matches:r}=matchMedia("(hover)"),o=H(()=>{let n=new T,i=n.pipe(Yr(1));n.subscribe(({scrollable:d})=>{d&&r?e.setAttribute("tabindex","0"):e.removeAttribute("tabindex")});let s=[],a=e.closest("pre"),c=a.closest("[id]"),p=c?c.id:Za++;a.id=`__code_${p}`;let l=[],f=e.closest(".highlight");if(f instanceof HTMLElement){let d=Qn(f);if(typeof d!="undefined"&&(f.classList.contains("annotate")||V("content.code.annotate"))){let v=Tr(d,e,t);l.push(Le(f).pipe(W(i),m(({width:S,height:X})=>S&&X),Y(),b(S=>S?v:y)))}}let u=M(":scope > span[id]",e);if(u.length&&(e.classList.add("md-code__content"),e.closest(".select")||V("content.code.select")&&!e.closest(".no-select"))){let d=+u[0].id.split("-").pop(),v=$n();s.push(v),V("content.tooltips")&&l.push(Xe(v,{viewport$}));let S=h(v,"click").pipe(Ut(R=>!R,!1),O(()=>v.blur()),le());S.subscribe(R=>{v.classList.toggle("md-code__button--active",R)});let X=fe(u).pipe(J(R=>it(R).pipe(m(se=>[R,se]))));S.pipe(b(R=>R?X:y)).subscribe(([R,se])=>{let ce=ue(".hll.select",R);if(ce&&!se)ce.replaceWith(...Array.from(ce.childNodes));else if(!ce&&se){let he=document.createElement("span");he.className="hll select",he.append(...Array.from(R.childNodes).slice(1)),R.append(he)}});let re=fe(u).pipe(J(R=>h(R,"mousedown").pipe(O(se=>se.preventDefault()),m(()=>R)))),ee=S.pipe(b(R=>R?re:y),te(qn),m(([R,se])=>{var he;let ce=u.indexOf(R)+d;if(se===!1)return[ce,ce];{let Se=M(".hll",e).map(Ue=>u.indexOf(Ue.parentElement)+d);return(he=window.getSelection())==null||he.removeAllRanges(),[Math.min(ce,...Se),Math.max(ce,...Se)]}})),k=Zr(y).pipe(g(R=>R.startsWith(`__codelineno-${p}-`)));k.subscribe(R=>{let[,,se]=R.split("-"),ce=se.split(":").map(Se=>+Se-d+1);ce.length===1&&ce.push(ce[0]);for(let Se of M(".hll:not(.select)",e))Se.replaceWith(...Array.from(Se.childNodes));let he=u.slice(ce[0]-1,ce[1]);for(let Se of he){let Ue=document.createElement("span");Ue.className="hll",Ue.append(...Array.from(Se.childNodes).slice(1)),Se.append(Ue)}}),k.pipe(Ee(1),xe(pe)).subscribe(R=>{if(R.includes(":")){let se=document.getElementById(R.split(":")[0]);se&&setTimeout(()=>{let ce=se,he=-64;for(;ce!==document.body;)he+=ce.offsetTop,ce=ce.offsetParent;window.scrollTo({top:he})},1)}});let je=fe(M('a[href^="#__codelineno"]',f)).pipe(J(R=>h(R,"click").pipe(O(se=>se.preventDefault()),m(()=>R)))).pipe(W(i),te(qn),m(([R,se])=>{let he=+j(`[id="${R.hash.slice(1)}"]`).parentElement.id.split("-").pop();if(se===!1)return[he,he];{let Se=M(".hll",e).map(Ue=>+Ue.parentElement.id.split("-").pop());return[Math.min(he,...Se),Math.max(he,...Se)]}}));L(ee,je).subscribe(R=>{let se=`#__codelineno-${p}-`;R[0]===R[1]?se+=R[0]:se+=`${R[0]}:${R[1]}`,history.replaceState({},"",se),window.dispatchEvent(new HashChangeEvent("hashchange",{newURL:window.location.origin+window.location.pathname+se,oldURL:window.location.href}))})}if(Kn.default.isSupported()&&(e.closest(".copy")||V("content.code.copy")&&!e.closest(".no-copy"))){let d=Hn(a.id);s.push(d),V("content.tooltips")&&l.push(Xe(d,{viewport$}))}if(s.length){let d=Pn();d.append(...s),a.insertBefore(d,e)}return es(e).pipe(O(d=>n.next(d)),A(()=>n.complete()),m(d=>P({ref:e},d)),Ve(L(...l).pipe(W(i))))});return V("content.lazy")?mt(e).pipe(g(n=>n),Ee(1),b(()=>o)):o}function ts(e,{target$:t,print$:r}){let o=!0;return L(t.pipe(m(n=>n.closest("details:not([open])")),g(n=>e===n),m(()=>({action:"open",reveal:!0}))),r.pipe(g(n=>n||!o),O(()=>o=e.open),m(n=>({action:n?"open":"close"}))))}function Bn(e,t){return H(()=>{let r=new T;return r.subscribe(({action:o,reveal:n})=>{e.toggleAttribute("open",o==="open"),n&&e.scrollIntoView()}),ts(e,t).pipe(O(o=>r.next(o)),A(()=>r.complete()),m(o=>P({ref:e},o)))})}var Gn=0;function rs(e){let t=document.createElement("h3");t.innerHTML=e.innerHTML;let r=[t],o=e.nextElementSibling;for(;o&&!(o instanceof HTMLHeadingElement);)r.push(o),o=o.nextElementSibling;return r}function os(e,t){for(let r of M("[href], [src]",e))for(let o of["href","src"]){let n=r.getAttribute(o);if(n&&!/^(?:[a-z]+:)?\/\//i.test(n)){r[o]=new URL(r.getAttribute(o),t).toString();break}}for(let r of M("[name^=__], [for]",e))for(let o of["id","for","name"]){let n=r.getAttribute(o);n&&r.setAttribute(o,`${n}$preview_${Gn}`)}return Gn++,$(e)}function Jn(e,t){let{sitemap$:r}=t;if(!(e instanceof HTMLAnchorElement))return y;if(!(V("navigation.instant.preview")||e.hasAttribute("data-preview")))return y;e.removeAttribute("title");let o=z([Ye(e),it(e)]).pipe(m(([i,s])=>i||s),Y(),g(i=>i));return rt([r,o]).pipe(b(([i])=>{let s=new URL(e.href);return s.search=s.hash="",i.has(`${s}`)?$(s):y}),b(i=>xr(i).pipe(b(s=>os(s,i)))),b(i=>{let s=e.hash?`article [id="${e.hash.slice(1)}"]`:"article h1",a=ue(s,i);return typeof a=="undefined"?y:$(rs(a))})).pipe(b(i=>{let s=new F(a=>{let c=wr(...i);return a.next(c),document.body.append(c),()=>c.remove()});return Vt(e,P({content$:s},t))}))}var Xn=".node circle,.node ellipse,.node path,.node polygon,.node rect{fill:var(--md-mermaid-node-bg-color);stroke:var(--md-mermaid-node-fg-color)}marker{fill:var(--md-mermaid-edge-color)!important}.edgeLabel .label rect{fill:#0000}.flowchartTitleText{fill:var(--md-mermaid-label-fg-color)}.label{color:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}.label foreignObject{line-height:normal;overflow:visible}.label div .edgeLabel{color:var(--md-mermaid-label-fg-color)}.edgeLabel,.edgeLabel p,.label div .edgeLabel{background-color:var(--md-mermaid-label-bg-color)}.edgeLabel,.edgeLabel p{fill:var(--md-mermaid-label-bg-color);color:var(--md-mermaid-edge-color)}.edgePath .path,.flowchart-link{stroke:var(--md-mermaid-edge-color)}.edgePath .arrowheadPath{fill:var(--md-mermaid-edge-color);stroke:none}.cluster rect{fill:var(--md-default-fg-color--lightest);stroke:var(--md-default-fg-color--lighter)}.cluster span{color:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}g #flowchart-circleEnd,g #flowchart-circleStart,g #flowchart-crossEnd,g #flowchart-crossStart,g #flowchart-pointEnd,g #flowchart-pointStart{stroke:none}.classDiagramTitleText{fill:var(--md-mermaid-label-fg-color)}g.classGroup line,g.classGroup rect{fill:var(--md-mermaid-node-bg-color);stroke:var(--md-mermaid-node-fg-color)}g.classGroup text{fill:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}.classLabel .box{fill:var(--md-mermaid-label-bg-color);background-color:var(--md-mermaid-label-bg-color);opacity:1}.classLabel .label{fill:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}.node .divider{stroke:var(--md-mermaid-node-fg-color)}.relation{stroke:var(--md-mermaid-edge-color)}.cardinality{fill:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}.cardinality text{fill:inherit!important}defs marker.marker.composition.class path,defs marker.marker.dependency.class path,defs marker.marker.extension.class path{fill:var(--md-mermaid-edge-color)!important;stroke:var(--md-mermaid-edge-color)!important}defs marker.marker.aggregation.class path{fill:var(--md-mermaid-label-bg-color)!important;stroke:var(--md-mermaid-edge-color)!important}.statediagramTitleText{fill:var(--md-mermaid-label-fg-color)}g.stateGroup rect{fill:var(--md-mermaid-node-bg-color);stroke:var(--md-mermaid-node-fg-color)}g.stateGroup .state-title{fill:var(--md-mermaid-label-fg-color)!important;font-family:var(--md-mermaid-font-family)}g.stateGroup .composit{fill:var(--md-mermaid-label-bg-color)}.nodeLabel,.nodeLabel p{color:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}a .nodeLabel{text-decoration:underline}.node circle.state-end,.node circle.state-start,.start-state{fill:var(--md-mermaid-edge-color);stroke:none}.end-state-inner,.end-state-outer{fill:var(--md-mermaid-edge-color)}.end-state-inner,.node circle.state-end{stroke:var(--md-mermaid-label-bg-color)}.transition{stroke:var(--md-mermaid-edge-color)}[id^=state-fork] rect,[id^=state-join] rect{fill:var(--md-mermaid-edge-color)!important;stroke:none!important}.statediagram-cluster.statediagram-cluster .inner{fill:var(--md-default-bg-color)}.statediagram-cluster rect{fill:var(--md-mermaid-node-bg-color);stroke:var(--md-mermaid-node-fg-color)}.statediagram-state rect.divider{fill:var(--md-default-fg-color--lightest);stroke:var(--md-default-fg-color--lighter)}defs #statediagram-barbEnd{stroke:var(--md-mermaid-edge-color)}[id^=entity] path,[id^=entity] rect{fill:var(--md-default-bg-color)}.relationshipLine{stroke:var(--md-mermaid-edge-color)}defs .marker.oneOrMore.er *,defs .marker.onlyOne.er *,defs .marker.zeroOrMore.er *,defs .marker.zeroOrOne.er *{stroke:var(--md-mermaid-edge-color)!important}text:not([class]):last-child{fill:var(--md-mermaid-label-fg-color)}.actor{fill:var(--md-mermaid-sequence-actor-bg-color);stroke:var(--md-mermaid-sequence-actor-border-color)}text.actor>tspan{fill:var(--md-mermaid-sequence-actor-fg-color);font-family:var(--md-mermaid-font-family)}line{stroke:var(--md-mermaid-sequence-actor-line-color)}.actor-man circle,.actor-man line{fill:var(--md-mermaid-sequence-actorman-bg-color);stroke:var(--md-mermaid-sequence-actorman-line-color)}.messageLine0,.messageLine1{stroke:var(--md-mermaid-sequence-message-line-color)}.note{fill:var(--md-mermaid-sequence-note-bg-color);stroke:var(--md-mermaid-sequence-note-border-color)}.loopText,.loopText>tspan,.messageText,.noteText>tspan{stroke:none;font-family:var(--md-mermaid-font-family)!important}.messageText{fill:var(--md-mermaid-sequence-message-fg-color)}.loopText,.loopText>tspan{fill:var(--md-mermaid-sequence-loop-fg-color)}.noteText>tspan{fill:var(--md-mermaid-sequence-note-fg-color)}#arrowhead path{fill:var(--md-mermaid-sequence-message-line-color);stroke:none}.loopLine{fill:var(--md-mermaid-sequence-loop-bg-color);stroke:var(--md-mermaid-sequence-loop-border-color)}.labelBox{fill:var(--md-mermaid-sequence-label-bg-color);stroke:none}.labelText,.labelText>span{fill:var(--md-mermaid-sequence-label-fg-color);font-family:var(--md-mermaid-font-family)}.sequenceNumber{fill:var(--md-mermaid-sequence-number-fg-color)}rect.rect{fill:var(--md-mermaid-sequence-box-bg-color);stroke:none}rect.rect+text.text{fill:var(--md-mermaid-sequence-box-fg-color)}defs #sequencenumber{fill:var(--md-mermaid-sequence-number-bg-color)!important}";var so,is=0;function as(){return typeof mermaid=="undefined"||mermaid instanceof Element?_t("https://unpkg.com/mermaid@11/dist/mermaid.min.js"):$(void 0)}function Zn(e){return e.classList.remove("mermaid"),so||(so=as().pipe(O(()=>mermaid.initialize({startOnLoad:!1,themeCSS:Xn,sequence:{actorFontSize:"16px",messageFontSize:"16px",noteFontSize:"16px"}})),m(()=>{}),Z(1))),so.subscribe(()=>go(null,null,function*(){e.classList.add("mermaid");let t=`__mermaid_${is++}`,r=x("div",{class:"mermaid"}),o=e.textContent,{svg:n,fn:i}=yield mermaid.render(t,o),s=r.attachShadow({mode:"closed"});s.innerHTML=n,e.replaceWith(r),i==null||i(s)})),so.pipe(m(()=>({ref:e})))}var ei=x("table");function ti(e){return e.replaceWith(ei),ei.replaceWith(Un(e)),$({ref:e})}function ss(e){let t=e.find(r=>r.checked)||e[0];return L(...e.map(r=>h(r,"change").pipe(m(()=>j(`label[for="${r.id}"]`))))).pipe(Q(j(`label[for="${t.id}"]`)),m(r=>({active:r})))}function ri(e,{viewport$:t,target$:r}){let o=j(".tabbed-labels",e),n=M(":scope > input",e),i=no("prev");e.append(i);let s=no("next");return e.append(s),H(()=>{let a=new T,c=a.pipe(oe(),ae(!0));z([a,Le(e),mt(e)]).pipe(W(c),$e(1,ye)).subscribe({next([{active:p},l]){let f=Be(p),{width:u}=de(p);e.style.setProperty("--md-indicator-x",`${f.x}px`),e.style.setProperty("--md-indicator-width",`${u}px`);let d=gr(o);(f.xd.x+l.width)&&o.scrollTo({left:Math.max(0,f.x-16),behavior:"smooth"})},complete(){e.style.removeProperty("--md-indicator-x"),e.style.removeProperty("--md-indicator-width")}}),z([Ge(o),Le(o)]).pipe(W(c)).subscribe(([p,l])=>{let f=At(o);i.hidden=p.x<16,s.hidden=p.x>f.width-l.width-16}),L(h(i,"click").pipe(m(()=>-1)),h(s,"click").pipe(m(()=>1))).pipe(W(c)).subscribe(p=>{let{width:l}=de(o);o.scrollBy({left:l*p,behavior:"smooth"})}),r.pipe(W(c),g(p=>n.includes(p))).subscribe(p=>p.click()),o.classList.add("tabbed-labels--linked");for(let p of n){let l=j(`label[for="${p.id}"]`);l.replaceChildren(x("a",{href:`#${l.htmlFor}`,tabIndex:-1},...Array.from(l.childNodes))),h(l.firstElementChild,"click").pipe(W(c),g(f=>!(f.metaKey||f.ctrlKey)),O(f=>{f.preventDefault(),f.stopPropagation()})).subscribe(()=>{history.replaceState({},"",`#${l.htmlFor}`),l.click()})}return V("content.tabs.link")&&a.pipe(Ie(1),te(t)).subscribe(([{active:p},{offset:l}])=>{let f=p.innerText.trim();if(p.hasAttribute("data-md-switching"))p.removeAttribute("data-md-switching");else{let u=e.offsetTop-l.y;for(let v of M("[data-tabs]"))for(let S of M(":scope > input",v)){let X=j(`label[for="${S.id}"]`);if(X!==p&&X.innerText.trim()===f){X.setAttribute("data-md-switching",""),S.click();break}}window.scrollTo({top:e.offsetTop-u});let d=__md_get("__tabs")||[];__md_set("__tabs",[...new Set([f,...d])])}}),a.pipe(W(c)).subscribe(()=>{for(let p of M("audio, video",e))p.offsetWidth&&p.autoplay?p.play().catch(()=>{}):p.pause()}),ss(n).pipe(O(p=>a.next(p)),A(()=>a.complete()),m(p=>P({ref:e},p)))}).pipe(et(pe))}function oi(e,t){let{viewport$:r,target$:o,print$:n}=t;return L(...M(".annotate:not(.highlight)",e).map(i=>zn(i,{target$:o,print$:n})),...M("pre:not(.mermaid) > code",e).map(i=>Yn(i,{target$:o,print$:n})),...M("a",e).map(i=>Jn(i,t)),...M("pre.mermaid",e).map(i=>Zn(i)),...M("table:not([class])",e).map(i=>ti(i)),...M("details",e).map(i=>Bn(i,{target$:o,print$:n})),...M("[data-tabs]",e).map(i=>ri(i,{viewport$:r,target$:o})),...M("[title]:not([data-preview])",e).filter(()=>V("content.tooltips")).map(i=>Xe(i,{viewport$:r})),...M(".footnote-ref",e).filter(()=>V("content.footnote.tooltips")).map(i=>Vt(i,{content$:new F(s=>{let a=new URL(i.href).hash.slice(1),c=Array.from(document.getElementById(a).cloneNode(!0).children),p=wr(...c);return s.next(p),document.body.append(p),()=>p.remove()}),viewport$:r})))}function cs(e,{alert$:t}){return t.pipe(b(r=>L($(!0),$(!1).pipe(nt(2e3))).pipe(m(o=>({message:r,active:o})))))}function ni(e,t){let r=j(".md-typeset",e);return H(()=>{let o=new T;return o.subscribe(({message:n,active:i})=>{e.classList.toggle("md-dialog--active",i),r.textContent=n}),cs(e,t).pipe(O(n=>o.next(n)),A(()=>o.complete()),m(n=>P({ref:e},n)))})}var ps=0;function ls(e,t){document.body.append(e);let{width:r}=de(e);e.style.setProperty("--md-tooltip-width",`${r}px`),e.remove();let o=vr(t),n=typeof o!="undefined"?Ge(o):$({x:0,y:0}),i=L(Ye(t),it(t)).pipe(Y());return z([i,n]).pipe(m(([s,a])=>{let{x:c,y:p}=Be(t),l=de(t),f=t.closest("table");return f&&t.parentElement&&(c+=f.offsetLeft+t.parentElement.offsetLeft,p+=f.offsetTop+t.parentElement.offsetTop),{active:s,offset:{x:c-a.x+l.width/2-r/2,y:p-a.y+l.height+8}}}))}function ii(e){let t=e.title;if(!t.length)return y;let r=`__tooltip_${ps++}`,o=Dt(r,"inline"),n=j(".md-typeset",o);return n.innerHTML=t,H(()=>{let i=new T;return i.subscribe({next({offset:s}){o.style.setProperty("--md-tooltip-x",`${s.x}px`),o.style.setProperty("--md-tooltip-y",`${s.y}px`)},complete(){o.style.removeProperty("--md-tooltip-x"),o.style.removeProperty("--md-tooltip-y")}}),L(i.pipe(g(({active:s})=>s)),i.pipe(Ae(250),g(({active:s})=>!s))).subscribe({next({active:s}){s?(e.insertAdjacentElement("afterend",o),e.setAttribute("aria-describedby",r),e.removeAttribute("title")):(o.remove(),e.removeAttribute("aria-describedby"),e.setAttribute("title",t))},complete(){o.remove(),e.removeAttribute("aria-describedby"),e.setAttribute("title",t)}}),i.pipe($e(16,ye)).subscribe(({active:s})=>{o.classList.toggle("md-tooltip--active",s)}),i.pipe(gt(125,ye),g(()=>!!e.offsetParent),m(()=>e.offsetParent.getBoundingClientRect()),m(({x:s})=>s)).subscribe({next(s){s?o.style.setProperty("--md-tooltip-0",`${-s}px`):o.style.removeProperty("--md-tooltip-0")},complete(){o.style.removeProperty("--md-tooltip-0")}}),ls(o,e).pipe(O(s=>i.next(s)),A(()=>i.complete()),m(s=>P({ref:e},s)))}).pipe(et(pe))}function ms({viewport$:e}){if(!V("header.autohide"))return $(!1);let t=e.pipe(m(({offset:{y:n}})=>n),ot(2,1),m(([n,i])=>[nMath.abs(i-n.y)>100),m(([,[n]])=>n),Y()),o=Je("search");return z([e,o]).pipe(m(([{offset:n},i])=>n.y>400&&!i),Y(),b(n=>n?r:$(!1)),Q(!1))}function ai(e,t){return H(()=>z([Le(e),ms(t)])).pipe(m(([{height:r},o])=>({height:r,hidden:o})),Y((r,o)=>r.height===o.height&&r.hidden===o.hidden),Z(1))}function si(e,{header$:t,main$:r}){return H(()=>{let o=new T,n=o.pipe(oe(),ae(!0));o.pipe(ne("active"),Pe(t)).subscribe(([{active:s},{hidden:a}])=>{e.classList.toggle("md-header--shadow",s&&!a),e.hidden=a});let i=fe(M("[title]",e)).pipe(g(()=>V("content.tooltips")),J(s=>ii(s)));return r.subscribe(o),t.pipe(W(n),m(s=>P({ref:e},s)),Ve(i.pipe(W(n))))})}function fs(e,{viewport$:t,header$:r}){return Er(e,{viewport$:t,header$:r}).pipe(m(({offset:{y:o}})=>{let{height:n}=de(e);return{active:n>0&&o>=n}}),ne("active"))}function ci(e,t){return H(()=>{let r=new T;r.subscribe({next({active:n}){e.classList.toggle("md-header__title--active",n)},complete(){e.classList.remove("md-header__title--active")}});let o=ue(".md-content h1");return typeof o=="undefined"?y:fs(o,t).pipe(O(n=>r.next(n)),A(()=>r.complete()),m(n=>P({ref:e},n)))})}function pi(e,{viewport$:t,header$:r}){let o=r.pipe(m(({height:i})=>i),Y()),n=o.pipe(b(()=>Le(e).pipe(m(({height:i})=>({top:e.offsetTop,bottom:e.offsetTop+i})),ne("bottom"))));return z([o,n,t]).pipe(m(([i,{top:s,bottom:a},{offset:{y:c},size:{height:p}}])=>(p=Math.max(0,p-Math.max(0,s-c,i)-Math.max(0,p+c-a)),{offset:s-i,height:p,active:s-i<=c})),Y((i,s)=>i.offset===s.offset&&i.height===s.height&&i.active===s.active))}function us(e){let t=__md_get("__palette")||{index:e.findIndex(o=>matchMedia(o.getAttribute("data-md-color-media")).matches)},r=Math.max(0,Math.min(t.index,e.length-1));return $(...e).pipe(J(o=>h(o,"change").pipe(m(()=>o))),Q(e[r]),m(o=>({index:e.indexOf(o),color:{media:o.getAttribute("data-md-color-media"),scheme:o.getAttribute("data-md-color-scheme"),primary:o.getAttribute("data-md-color-primary"),accent:o.getAttribute("data-md-color-accent")}})),Z(1))}function li(e){let t=M("input",e),r=x("meta",{name:"theme-color"});document.head.appendChild(r);let o=x("meta",{name:"color-scheme"});document.head.appendChild(o);let n=Wt("(prefers-color-scheme: light)");return H(()=>{let i=new T;return i.subscribe(s=>{if(document.body.setAttribute("data-md-color-switching",""),s.color.media==="(prefers-color-scheme)"){let a=matchMedia("(prefers-color-scheme: light)"),c=document.querySelector(a.matches?"[data-md-color-media='(prefers-color-scheme: light)']":"[data-md-color-media='(prefers-color-scheme: dark)']");s.color.scheme=c.getAttribute("data-md-color-scheme"),s.color.primary=c.getAttribute("data-md-color-primary"),s.color.accent=c.getAttribute("data-md-color-accent")}for(let[a,c]of Object.entries(s.color))document.body.setAttribute(`data-md-color-${a}`,c);for(let a=0;as.key==="Enter"),te(i,(s,a)=>a)).subscribe(({index:s})=>{s=(s+1)%t.length,t[s].click(),t[s].focus()}),i.pipe(m(()=>{let s=Ce("header"),a=window.getComputedStyle(s);return o.content=a.colorScheme,a.backgroundColor.match(/\d+/g).map(c=>(+c).toString(16).padStart(2,"0")).join("")})).subscribe(s=>r.content=`#${s}`),i.pipe(xe(pe)).subscribe(()=>{document.body.removeAttribute("data-md-color-switching")}),us(t).pipe(W(n.pipe(Ie(1))),vt(),O(s=>i.next(s)),A(()=>i.complete()),m(s=>P({ref:e},s)))})}function mi(e,{progress$:t}){return H(()=>{let r=new T;return r.subscribe(({value:o})=>{e.style.setProperty("--md-progress-value",`${o}`)}),t.pipe(O(o=>r.next({value:o})),A(()=>r.complete()),m(o=>({ref:e,value:o})))})}function fi(e,t){return e.protocol=t.protocol,e.hostname=t.hostname,e}function ds(e,t){let r=new Map;for(let o of M("url",e)){let n=j("loc",o),i=[fi(new URL(n.textContent),t)];r.set(`${i[0]}`,i);for(let s of M("[rel=alternate]",o)){let a=s.getAttribute("href");a!=null&&i.push(fi(new URL(a),t))}}return r}function kt(e){return En(new URL("sitemap.xml",e)).pipe(m(t=>ds(t,new URL(e))),ve(()=>$(new Map)),le())}function ui({document$:e}){let t=new Map;e.pipe(b(()=>M("link[rel=alternate]")),m(r=>new URL(r.href)),g(r=>!t.has(r.toString())),J(r=>kt(r).pipe(m(o=>[r,o]),ve(()=>y)))).subscribe(([r,o])=>{t.set(r.toString().replace(/\/$/,""),o)}),h(document.body,"click").pipe(g(r=>!r.metaKey&&!r.ctrlKey),b(r=>{if(r.target instanceof Element){let o=r.target.closest("a");if(o&&!o.target){let n=[...t].find(([f])=>o.href.startsWith(`${f}/`));if(typeof n=="undefined")return y;let[i,s]=n,a=we();if(a.href.startsWith(i))return y;let c=Te(),p=a.href.replace(c.base,"");p=`${i}/${p}`;let l=s.has(p.split("#")[0])?new URL(p,c.base):new URL(i);return r.preventDefault(),$(l)}}return y})).subscribe(r=>st(r,!0))}var co=$t(ao());function hs(e){e.setAttribute("data-md-copying","");let t=e.closest("[data-copy]"),r=t?t.getAttribute("data-copy"):e.innerText;return e.removeAttribute("data-md-copying"),r.trimEnd()}function di({alert$:e}){co.default.isSupported()&&new F(t=>{new co.default("[data-clipboard-target], [data-clipboard-text]",{text:r=>r.getAttribute("data-clipboard-text")||hs(j(r.getAttribute("data-clipboard-target")))}).on("success",r=>t.next(r))}).pipe(O(t=>{t.trigger.focus()}),m(()=>Me("clipboard.copied"))).subscribe(e)}function hi(e,t){if(!(e.target instanceof Element))return y;let r=e.target.closest("a");if(r===null)return y;if(r.target||e.metaKey||e.ctrlKey)return y;let o=new URL(r.href);return o.search=o.hash="",t.has(`${o}`)?(e.preventDefault(),$(r)):y}function bi(e){let t=new Map;for(let r of M(":scope > *",e.head))t.set(r.outerHTML,r);return t}function vi(e){for(let t of M("[href], [src]",e))for(let r of["href","src"]){let o=t.getAttribute(r);if(o&&!/^(?:[a-z]+:)?\/\//i.test(o)){t[r]=t[r];break}}return $(e)}function bs(e){for(let o of["[data-md-component=announce]","[data-md-component=container]","[data-md-component=header-topic]","[data-md-component=outdated]","[data-md-component=logo]","[data-md-component=skip]",...V("navigation.tabs.sticky")?["[data-md-component=tabs]"]:[]]){let n=ue(o),i=ue(o,e);typeof n!="undefined"&&typeof i!="undefined"&&n.replaceWith(i)}let t=bi(document);for(let[o,n]of bi(e))t.has(o)?t.delete(o):document.head.appendChild(n);for(let o of t.values()){let n=o.getAttribute("name");n!=="theme-color"&&n!=="color-scheme"&&o.remove()}let r=Ce("container");return Ke(M("script",r)).pipe(b(o=>{let n=e.createElement("script");if(o.src){for(let i of o.getAttributeNames())n.setAttribute(i,o.getAttribute(i));return o.replaceWith(n),new F(i=>{n.onload=()=>i.complete()})}else return n.textContent=o.textContent,o.replaceWith(n),y}),oe(),ae(document))}function gi({sitemap$:e,location$:t,viewport$:r,progress$:o}){if(location.protocol==="file:")return y;$(document).subscribe(vi);let n=h(document.body,"click").pipe(Pe(e),b(([a,c])=>hi(a,c)),m(({href:a})=>new URL(a)),le()),i=h(window,"popstate").pipe(m(we),le());n.pipe(te(r)).subscribe(([a,{offset:c}])=>{history.replaceState(c,""),history.pushState(null,"",a)}),L(n,i).subscribe(t);let s=t.pipe(ne("pathname"),b(a=>xr(a,{progress$:o}).pipe(ve(()=>(st(a,!0),y)))),b(vi),b(bs),le());return L(s.pipe(te(t,(a,c)=>c)),s.pipe(b(()=>t),ne("hash")),t.pipe(Y((a,c)=>a.pathname===c.pathname&&a.hash===c.hash),b(()=>n),O(()=>history.back()))).subscribe(a=>{var c,p;history.state!==null||!a.hash?window.scrollTo(0,(p=(c=history.state)==null?void 0:c.y)!=null?p:0):(history.scrollRestoration="auto",gn(a.hash),history.scrollRestoration="manual")}),t.subscribe(()=>{history.scrollRestoration="manual"}),h(window,"beforeunload").subscribe(()=>{history.scrollRestoration="auto"}),r.pipe(ne("offset"),Ae(100)).subscribe(({offset:a})=>{history.replaceState(a,"")}),V("navigation.instant.prefetch")&&L(h(document.body,"mousemove"),h(document.body,"focusin")).pipe(Pe(e),b(([a,c])=>hi(a,c)),Ae(25),Qr(({href:a})=>a),hr(a=>{let c=document.createElement("link");return c.rel="prefetch",c.href=a.toString(),document.head.appendChild(c),h(c,"load").pipe(m(()=>c),Ee(1))})).subscribe(a=>a.remove()),s}var yi=$t(ro());function xi(e){let t=e.separator.split("|").map(n=>n.replace(/(\(\?[!=<][^)]+\))/g,"").length===0?"\uFFFD":n).join("|"),r=new RegExp(t,"img"),o=(n,i,s)=>`${i}${s}`;return n=>{n=n.replace(/[\s*+\-:~^]+/g," ").replace(/&/g,"&").trim();let i=new RegExp(`(^|${e.separator}|)(${n.replace(/[|\\{}()[\]^$+*?.-]/g,"\\$&").replace(r,"|")})`,"img");return s=>(0,yi.default)(s).replace(i,o).replace(/<\/mark>(\s+)]*>/img,"$1")}}function zt(e){return e.type===1}function Sr(e){return e.type===3}function Ei(e,t){let r=Mn(e);return L($(location.protocol!=="file:"),Je("search")).pipe(Re(o=>o),b(()=>t)).subscribe(({config:o,docs:n})=>r.next({type:0,data:{config:o,docs:n,options:{suggest:V("search.suggest")}}})),r}function wi(e){var l;let{selectedVersionSitemap:t,selectedVersionBaseURL:r,currentLocation:o,currentBaseURL:n}=e,i=(l=po(n))==null?void 0:l.pathname;if(i===void 0)return;let s=ys(o.pathname,i);if(s===void 0)return;let a=Es(t.keys());if(!t.has(a))return;let c=po(s,a);if(!c||!t.has(c.href))return;let p=po(s,r);if(p)return p.hash=o.hash,p.search=o.search,p}function po(e,t){try{return new URL(e,t)}catch(r){return}}function ys(e,t){if(e.startsWith(t))return e.slice(t.length)}function xs(e,t){let r=Math.min(e.length,t.length),o;for(o=0;oy)),o=r.pipe(m(n=>{let[,i]=t.base.match(/([^/]+)\/?$/);return n.find(({version:s,aliases:a})=>s===i||a.includes(i))||n[0]}));r.pipe(m(n=>new Map(n.map(i=>[`${new URL(`../${i.version}/`,t.base)}`,i]))),b(n=>h(document.body,"click").pipe(g(i=>!i.metaKey&&!i.ctrlKey),te(o),b(([i,s])=>{if(i.target instanceof Element){let a=i.target.closest("a");if(a&&!a.target&&n.has(a.href)){let c=a.href;return!i.target.closest(".md-version")&&n.get(c)===s?y:(i.preventDefault(),$(new URL(c)))}}return y}),b(i=>kt(i).pipe(m(s=>{var a;return(a=wi({selectedVersionSitemap:s,selectedVersionBaseURL:i,currentLocation:we(),currentBaseURL:t.base}))!=null?a:i})))))).subscribe(n=>st(n,!0)),z([r,o]).subscribe(([n,i])=>{j(".md-header__topic").appendChild(Wn(n,i))}),e.pipe(b(()=>o)).subscribe(n=>{var a;let i=new URL(t.base),s=__md_get("__outdated",sessionStorage,i);if(s===null){s=!0;let c=((a=t.version)==null?void 0:a.default)||"latest";Array.isArray(c)||(c=[c]);e:for(let p of c)for(let l of n.aliases.concat(n.version))if(new RegExp(p,"i").test(l)){s=!1;break e}__md_set("__outdated",s,sessionStorage,i)}if(s)for(let c of me("outdated"))c.hidden=!1})}function ws(e,{worker$:t}){let{searchParams:r}=we();r.has("q")&&(at("search",!0),e.value=r.get("q"),e.focus(),Je("search").pipe(Re(i=>!i)).subscribe(()=>{let i=we();i.searchParams.delete("q"),history.replaceState({},"",`${i}`)}));let o=Ye(e),n=L(t.pipe(Re(zt)),h(e,"keyup"),o).pipe(m(()=>e.value),Y());return z([n,o]).pipe(m(([i,s])=>({value:i,focus:s})),Z(1))}function Si(e,{worker$:t}){let r=new T,o=r.pipe(oe(),ae(!0));z([t.pipe(Re(zt)),r],(i,s)=>s).pipe(ne("value")).subscribe(({value:i})=>t.next({type:2,data:i})),r.pipe(ne("focus")).subscribe(({focus:i})=>{i&&at("search",i)}),h(e.form,"reset").pipe(W(o)).subscribe(()=>e.focus());let n=j("header [for=__search]");return h(n,"click").subscribe(()=>e.focus()),ws(e,{worker$:t}).pipe(O(i=>r.next(i)),A(()=>r.complete()),m(i=>P({ref:e},i)),Z(1))}function Oi(e,{worker$:t,query$:r}){let o=new T,n=un(e.parentElement).pipe(g(Boolean)),i=e.parentElement,s=j(":scope > :first-child",e),a=j(":scope > :last-child",e);Je("search").subscribe(l=>{a.setAttribute("role",l?"list":"presentation"),a.hidden=!l}),o.pipe(te(r),Gr(t.pipe(Re(zt)))).subscribe(([{items:l},{value:f}])=>{switch(l.length){case 0:s.textContent=f.length?Me("search.result.none"):Me("search.result.placeholder");break;case 1:s.textContent=Me("search.result.one");break;default:let u=br(l.length);s.textContent=Me("search.result.other",u)}});let c=o.pipe(O(()=>a.innerHTML=""),b(({items:l})=>L($(...l.slice(0,10)),$(...l.slice(10)).pipe(ot(4),Xr(n),b(([f])=>f)))),m(Fn),le());return c.subscribe(l=>a.appendChild(l)),c.pipe(J(l=>{let f=ue("details",l);return typeof f=="undefined"?y:h(f,"toggle").pipe(W(o),m(()=>f))})).subscribe(l=>{l.open===!1&&l.offsetTop<=i.scrollTop&&i.scrollTo({top:l.offsetTop})}),t.pipe(g(Sr),m(({data:l})=>l)).pipe(O(l=>o.next(l)),A(()=>o.complete()),m(l=>P({ref:e},l)))}function Ts(e,{query$:t}){return t.pipe(m(({value:r})=>{let o=we();return o.hash="",r=r.replace(/\s+/g,"+").replace(/&/g,"%26").replace(/=/g,"%3D"),o.search=`q=${r}`,{url:o}}))}function Li(e,t){let r=new T,o=r.pipe(oe(),ae(!0));return r.subscribe(({url:n})=>{e.setAttribute("data-clipboard-text",e.href),e.href=`${n}`}),h(e,"click").pipe(W(o)).subscribe(n=>n.preventDefault()),Ts(e,t).pipe(O(n=>r.next(n)),A(()=>r.complete()),m(n=>P({ref:e},n)))}function Mi(e,{worker$:t,keyboard$:r}){let o=new T,n=Ce("search-query"),i=L(h(n,"keydown"),h(n,"focus")).pipe(xe(pe),m(()=>n.value),Y());return o.pipe(Pe(i),m(([{suggest:a},c])=>{let p=c.split(/([\s-]+)/);if(a!=null&&a.length&&p[p.length-1]){let l=a[a.length-1];l.startsWith(p[p.length-1])&&(p[p.length-1]=l)}else p.length=0;return p})).subscribe(a=>e.textContent=a.join("")),r.pipe(g(({mode:a})=>a==="search")).subscribe(a=>{a.type==="ArrowRight"&&e.innerText.length&&n.selectionStart===n.value.length&&(n.value=e.innerText)}),t.pipe(g(Sr),m(({data:a})=>a)).pipe(O(a=>o.next(a)),A(()=>o.complete()),m(()=>({ref:e})))}function _i(e,{index$:t,keyboard$:r}){let o=Te();try{let n=Ei(o.search,t),i=Ce("search-query",e),s=Ce("search-result",e);h(e,"click").pipe(g(({target:c})=>c instanceof Element&&!!c.closest("a"))).subscribe(()=>at("search",!1)),r.pipe(g(({mode:c})=>c==="search")).subscribe(c=>{let p=Ne();switch(c.type){case"Enter":if(p===i){let l=new Map;for(let f of M(":first-child [href]",s)){let u=f.firstElementChild;l.set(f,parseFloat(u.getAttribute("data-md-score")))}if(l.size){let[[f]]=[...l].sort(([,u],[,d])=>d-u);f.click()}c.claim()}break;case"Escape":case"Tab":at("search",!1),i.blur();break;case"ArrowUp":case"ArrowDown":if(typeof p=="undefined")i.focus();else{let l=[i,...M(":not(details) > [href], summary, details[open] [href]",s)],f=Math.max(0,(Math.max(0,l.indexOf(p))+l.length+(c.type==="ArrowUp"?-1:1))%l.length);l[f].focus()}c.claim();break;default:i!==Ne()&&i.focus()}}),r.pipe(g(({mode:c})=>c==="global")).subscribe(c=>{switch(c.type){case"f":case"s":case"/":i.focus(),i.select(),c.claim();break}});let a=Si(i,{worker$:n});return L(a,Oi(s,{worker$:n,query$:a})).pipe(Ve(...me("search-share",e).map(c=>Li(c,{query$:a})),...me("search-suggest",e).map(c=>Mi(c,{worker$:n,keyboard$:r}))))}catch(n){return e.hidden=!0,tt}}function Ai(e,{index$:t,location$:r}){return z([t,r.pipe(Q(we()),g(o=>!!o.searchParams.get("h")))]).pipe(m(([o,n])=>xi(o.config)(n.searchParams.get("h"))),m(o=>{var s;let n=new Map,i=document.createNodeIterator(e,NodeFilter.SHOW_TEXT);for(let a=i.nextNode();a;a=i.nextNode())if((s=a.parentElement)!=null&&s.offsetHeight){let c=a.textContent,p=o(c);p.length>c.length&&n.set(a,p)}for(let[a,c]of n){let{childNodes:p}=x("span",null,c);a.replaceWith(...Array.from(p))}return{ref:e,nodes:n}}))}function Ss(e,{viewport$:t,main$:r}){let o=e.closest(".md-grid"),n=o.offsetTop-o.parentElement.offsetTop;return z([r,t]).pipe(m(([{offset:i,height:s},{offset:{y:a}}])=>(s=s+Math.min(n,Math.max(0,a-i))-n,{height:s,locked:a>=i+n})),Y((i,s)=>i.height===s.height&&i.locked===s.locked))}function lo(e,o){var n=o,{header$:t}=n,r=vo(n,["header$"]);let i=j(".md-sidebar__scrollwrap",e),{y:s}=Be(i);return H(()=>{let a=new T,c=a.pipe(oe(),ae(!0)),p=a.pipe($e(0,ye));return p.pipe(te(t)).subscribe({next([{height:l},{height:f}]){i.style.height=`${l-2*s}px`,e.style.top=`${f}px`},complete(){i.style.height="",e.style.top=""}}),p.pipe(Re()).subscribe(()=>{for(let l of M(".md-nav__link--active[href]",e)){if(!l.clientHeight)continue;let f=l.closest(".md-sidebar__scrollwrap");if(typeof f!="undefined"){let u=l.offsetTop-f.offsetTop,{height:d}=de(f);f.scrollTo({top:u-d/2})}}}),fe(M("label[tabindex]",e)).pipe(J(l=>h(l,"click").pipe(xe(pe),m(()=>l),W(c)))).subscribe(l=>{let f=j(`[id="${l.htmlFor}"]`);j(`[aria-labelledby="${l.id}"]`).setAttribute("aria-expanded",`${f.checked}`)}),V("content.tooltips")&&fe(M("abbr[title]",e)).pipe(J(l=>Xe(l,{viewport$})),W(c)).subscribe(),Ss(e,r).pipe(O(l=>a.next(l)),A(()=>a.complete()),m(l=>P({ref:e},l)))})}function Ci(e,t){if(typeof t!="undefined"){let r=`https://api.github.com/repos/${e}/${t}`;return rt(ze(`${r}/releases/latest`).pipe(ve(()=>y),m(o=>({version:o.tag_name})),Qe({})),ze(r).pipe(ve(()=>y),m(o=>({stars:o.stargazers_count,forks:o.forks_count})),Qe({}))).pipe(m(([o,n])=>P(P({},o),n)))}else{let r=`https://api.github.com/users/${e}`;return ze(r).pipe(m(o=>({repositories:o.public_repos})),Qe({}))}}function ki(e,t){let r=`https://${e}/api/v4/projects/${encodeURIComponent(t)}`;return rt(ze(`${r}/releases/permalink/latest`).pipe(ve(()=>y),m(({tag_name:o})=>({version:o})),Qe({})),ze(r).pipe(ve(()=>y),m(({star_count:o,forks_count:n})=>({stars:o,forks:n})),Qe({}))).pipe(m(([o,n])=>P(P({},o),n)))}function Hi(e){let t=e.match(/^.+github\.com\/([^/]+)\/?([^/]+)?/i);if(t){let[,r,o]=t;return Ci(r,o)}if(t=e.match(/^.+?([^/]*gitlab[^/]+)\/(.+?)\/?$/i),t){let[,r,o]=t;return ki(r,o)}return y}var Os;function Ls(e){return Os||(Os=H(()=>{let t=__md_get("__source",sessionStorage);if(t)return $(t);if(me("consent").length){let o=__md_get("__consent");if(!(o&&o.github))return y}return Hi(e.href).pipe(O(o=>__md_set("__source",o,sessionStorage)))}).pipe(ve(()=>y),g(t=>Object.keys(t).length>0),m(t=>({facts:t})),Z(1)))}function $i(e){let t=j(":scope > :last-child",e);return H(()=>{let r=new T;return r.subscribe(({facts:o})=>{t.appendChild(jn(o)),t.classList.add("md-source__repository--active")}),Ls(e).pipe(O(o=>r.next(o)),A(()=>r.complete()),m(o=>P({ref:e},o)))})}function Ms(e,{viewport$:t,header$:r}){return Le(document.body).pipe(b(()=>Er(e,{header$:r,viewport$:t})),m(({offset:{y:o}})=>({hidden:o>=10})),ne("hidden"))}function Pi(e,t){return H(()=>{let r=new T;return r.subscribe({next({hidden:o}){e.hidden=o},complete(){e.hidden=!1}}),(V("navigation.tabs.sticky")?$({hidden:!1}):Ms(e,t)).pipe(O(o=>r.next(o)),A(()=>r.complete()),m(o=>P({ref:e},o)))})}function _s(e,{viewport$:t,header$:r}){let o=new Map,n=M(".md-nav__link",e);for(let a of n){let c=decodeURIComponent(a.hash.substring(1)),p=ue(`[id="${c}"]`);typeof p!="undefined"&&o.set(a,p)}let i=r.pipe(ne("height"),m(({height:a})=>{let c=Ce("main"),p=j(":scope > :first-child",c);return a+.8*(p.offsetTop-c.offsetTop)}),le());return Le(document.body).pipe(ne("height"),b(a=>H(()=>{let c=[];return $([...o].reduce((p,[l,f])=>{for(;c.length&&o.get(c[c.length-1]).tagName>=f.tagName;)c.pop();let u=f.offsetTop;for(;!u&&f.parentElement;)f=f.parentElement,u=f.offsetTop;let d=f.offsetParent;for(;d;d=d.offsetParent)u+=d.offsetTop;return p.set([...c=[...c,l]].reverse(),u)},new Map))}).pipe(m(c=>new Map([...c].sort(([,p],[,l])=>p-l))),Pe(i),b(([c,p])=>t.pipe(Ut(([l,f],{offset:{y:u},size:d})=>{let v=u+d.height>=Math.floor(a.height);for(;f.length;){let[,S]=f[0];if(S-p=u&&!v)f=[l.pop(),...f];else break}return[l,f]},[[],[...c]]),Y((l,f)=>l[0]===f[0]&&l[1]===f[1])))))).pipe(m(([a,c])=>({prev:a.map(([p])=>p),next:c.map(([p])=>p)})),Q({prev:[],next:[]}),ot(2,1),m(([a,c])=>a.prev.length{let i=new T,s=i.pipe(oe(),ae(!0));if(i.subscribe(({prev:a,next:c})=>{for(let[p]of c)p.classList.remove("md-nav__link--passed"),p.classList.remove("md-nav__link--active");for(let[p,[l]]of a.entries())l.classList.add("md-nav__link--passed"),l.classList.toggle("md-nav__link--active",p===a.length-1)}),V("toc.follow")){let a=L(t.pipe(Ae(1),m(()=>{})),t.pipe(Ae(250),m(()=>"smooth")));i.pipe(g(({prev:c})=>c.length>0),Pe(o.pipe(xe(pe))),te(a)).subscribe(([[{prev:c}],p])=>{let[l]=c[c.length-1];if(l.offsetHeight){let f=vr(l);if(typeof f!="undefined"){let u=l.offsetTop-f.offsetTop,{height:d}=de(f);f.scrollTo({top:u-d/2,behavior:p})}}})}return V("navigation.tracking")&&t.pipe(W(s),ne("offset"),Ae(250),Ie(1),W(n.pipe(Ie(1))),vt({delay:250}),te(i)).subscribe(([,{prev:a}])=>{let c=we(),p=a[a.length-1];if(p&&p.length){let[l]=p,{hash:f}=new URL(l.href);c.hash!==f&&(c.hash=f,history.replaceState({},"",`${c}`))}else c.hash="",history.replaceState({},"",`${c}`)}),_s(e,{viewport$:t,header$:r}).pipe(O(a=>i.next(a)),A(()=>i.complete()),m(a=>P({ref:e},a)))})}function As(e,{viewport$:t,main$:r,target$:o}){let n=t.pipe(m(({offset:{y:s}})=>s),ot(2,1),m(([s,a])=>s>a&&a>0),Y()),i=r.pipe(m(({active:s})=>s));return z([i,n]).pipe(m(([s,a])=>!(s&&a)),Y(),W(o.pipe(Ie(1))),ae(!0),vt({delay:250}),m(s=>({hidden:s})))}function Ii(e,{viewport$:t,header$:r,main$:o,target$:n}){let i=new T,s=i.pipe(oe(),ae(!0));return i.subscribe({next({hidden:a}){e.hidden=a,a?(e.setAttribute("tabindex","-1"),e.blur()):e.removeAttribute("tabindex")},complete(){e.style.top="",e.hidden=!0,e.removeAttribute("tabindex")}}),r.pipe(W(s),ne("height")).subscribe(({height:a})=>{e.style.top=`${a+16}px`}),h(e,"click").subscribe(a=>{a.preventDefault(),window.scrollTo({top:0})}),As(e,{viewport$:t,main$:o,target$:n}).pipe(O(a=>i.next(a)),A(()=>i.complete()),m(a=>P({ref:e},a)))}function Fi({document$:e,viewport$:t}){e.pipe(b(()=>M(".md-ellipsis")),J(r=>mt(r).pipe(W(e.pipe(Ie(1))),g(o=>o),m(()=>r),Ee(1))),g(r=>r.offsetWidth{let o=r.innerText,n=r.closest("a")||r;return n.title=o,V("content.tooltips")?Xe(n,{viewport$:t}).pipe(W(e.pipe(Ie(1))),A(()=>n.removeAttribute("title"))):y})).subscribe(),V("content.tooltips")&&e.pipe(b(()=>M(".md-status")),J(r=>Xe(r,{viewport$:t}))).subscribe()}function ji({document$:e,tablet$:t}){e.pipe(b(()=>M(".md-toggle--indeterminate")),O(r=>{r.indeterminate=!0,r.checked=!1}),J(r=>h(r,"change").pipe(Jr(()=>r.classList.contains("md-toggle--indeterminate")),m(()=>r))),te(t)).subscribe(([r,o])=>{r.classList.remove("md-toggle--indeterminate"),o&&(r.checked=!1)})}function Cs(){return/(iPad|iPhone|iPod)/.test(navigator.userAgent)}function Ui({document$:e}){e.pipe(b(()=>M("[data-md-scrollfix]")),O(t=>t.removeAttribute("data-md-scrollfix")),g(Cs),J(t=>h(t,"touchstart").pipe(m(()=>t)))).subscribe(t=>{let r=t.scrollTop;r===0?t.scrollTop=1:r+t.offsetHeight===t.scrollHeight&&(t.scrollTop=r-1)})}function Wi({viewport$:e,tablet$:t}){z([Je("search"),t]).pipe(m(([r,o])=>r&&!o),b(r=>$(r).pipe(nt(r?400:100))),te(e)).subscribe(([r,{offset:{y:o}}])=>{if(r)document.body.setAttribute("data-md-scrolllock",""),document.body.style.top=`-${o}px`;else{let n=-1*parseInt(document.body.style.top,10);document.body.removeAttribute("data-md-scrolllock"),document.body.style.top="",n&&window.scrollTo(0,n)}})}Object.entries||(Object.entries=function(e){let t=[];for(let r of Object.keys(e))t.push([r,e[r]]);return t});Object.values||(Object.values=function(e){let t=[];for(let r of Object.keys(e))t.push(e[r]);return t});typeof Element!="undefined"&&(Element.prototype.scrollTo||(Element.prototype.scrollTo=function(e,t){typeof e=="object"?(this.scrollLeft=e.left,this.scrollTop=e.top):(this.scrollLeft=e,this.scrollTop=t)}),Element.prototype.replaceWith||(Element.prototype.replaceWith=function(...e){let t=this.parentNode;if(t){e.length===0&&t.removeChild(this);for(let r=e.length-1;r>=0;r--){let o=e[r];typeof o=="string"?o=document.createTextNode(o):o.parentNode&&o.parentNode.removeChild(o),r?t.insertBefore(this.previousSibling,o):t.replaceChild(o,this)}}}));function ks(){return location.protocol==="file:"?_t(`${new URL("search/search_index.js",Or.base)}`).pipe(m(()=>__index),Z(1)):ze(new URL("search/search_index.json",Or.base))}document.documentElement.classList.remove("no-js");document.documentElement.classList.add("js");var ct=an(),Kt=bn(),Ht=yn(Kt),mo=hn(),ke=Ln(),Lr=Wt("(min-width: 60em)"),Vi=Wt("(min-width: 76.25em)"),Ni=xn(),Or=Te(),zi=document.forms.namedItem("search")?ks():tt,fo=new T;di({alert$:fo});ui({document$:ct});var uo=new T,qi=kt(Or.base);V("navigation.instant")&&gi({sitemap$:qi,location$:Kt,viewport$:ke,progress$:uo}).subscribe(ct);var Di;((Di=Or.version)==null?void 0:Di.provider)==="mike"&&Ti({document$:ct});L(Kt,Ht).pipe(nt(125)).subscribe(()=>{at("drawer",!1),at("search",!1)});mo.pipe(g(({mode:e})=>e==="global")).subscribe(e=>{switch(e.type){case"p":case",":let t=ue("link[rel=prev]");typeof t!="undefined"&&st(t);break;case"n":case".":let r=ue("link[rel=next]");typeof r!="undefined"&&st(r);break;case"Enter":let o=Ne();o instanceof HTMLLabelElement&&o.click()}});Fi({viewport$:ke,document$:ct});ji({document$:ct,tablet$:Lr});Ui({document$:ct});Wi({viewport$:ke,tablet$:Lr});var ft=ai(Ce("header"),{viewport$:ke}),qt=ct.pipe(m(()=>Ce("main")),b(e=>pi(e,{viewport$:ke,header$:ft})),Z(1)),Hs=L(...me("consent").map(e=>An(e,{target$:Ht})),...me("dialog").map(e=>ni(e,{alert$:fo})),...me("palette").map(e=>li(e)),...me("progress").map(e=>mi(e,{progress$:uo})),...me("search").map(e=>_i(e,{index$:zi,keyboard$:mo})),...me("source").map(e=>$i(e))),$s=H(()=>L(...me("announce").map(e=>_n(e)),...me("content").map(e=>oi(e,{sitemap$:qi,viewport$:ke,target$:Ht,print$:Ni})),...me("content").map(e=>V("search.highlight")?Ai(e,{index$:zi,location$:Kt}):y),...me("header").map(e=>si(e,{viewport$:ke,header$:ft,main$:qt})),...me("header-title").map(e=>ci(e,{viewport$:ke,header$:ft})),...me("sidebar").map(e=>e.getAttribute("data-md-type")==="navigation"?eo(Vi,()=>lo(e,{viewport$:ke,header$:ft,main$:qt})):eo(Lr,()=>lo(e,{viewport$:ke,header$:ft,main$:qt}))),...me("tabs").map(e=>Pi(e,{viewport$:ke,header$:ft})),...me("toc").map(e=>Ri(e,{viewport$:ke,header$:ft,main$:qt,target$:Ht})),...me("top").map(e=>Ii(e,{viewport$:ke,header$:ft,main$:qt,target$:Ht})))),Ki=ct.pipe(b(()=>$s),Ve(Hs),Z(1));Ki.subscribe();window.document$=ct;window.location$=Kt;window.target$=Ht;window.keyboard$=mo;window.viewport$=ke;window.tablet$=Lr;window.screen$=Vi;window.print$=Ni;window.alert$=fo;window.progress$=uo;window.component$=Ki;})(); +//# sourceMappingURL=bundle.d7400e89.min.js.map + diff --git a/site/assets/javascripts/bundle.d7400e89.min.js.map b/site/assets/javascripts/bundle.d7400e89.min.js.map new file mode 100644 index 0000000..bd88dc9 --- /dev/null +++ b/site/assets/javascripts/bundle.d7400e89.min.js.map @@ -0,0 +1,7 @@ +{ + "version": 3, + "sources": ["node_modules/focus-visible/dist/focus-visible.js", "node_modules/escape-html/index.js", "node_modules/clipboard/dist/clipboard.js", "src/templates/assets/javascripts/bundle.ts", "node_modules/tslib/tslib.es6.mjs", "node_modules/rxjs/src/internal/util/isFunction.ts", "node_modules/rxjs/src/internal/util/createErrorClass.ts", "node_modules/rxjs/src/internal/util/UnsubscriptionError.ts", "node_modules/rxjs/src/internal/util/arrRemove.ts", "node_modules/rxjs/src/internal/Subscription.ts", "node_modules/rxjs/src/internal/config.ts", "node_modules/rxjs/src/internal/scheduler/timeoutProvider.ts", "node_modules/rxjs/src/internal/util/reportUnhandledError.ts", "node_modules/rxjs/src/internal/util/noop.ts", "node_modules/rxjs/src/internal/NotificationFactories.ts", "node_modules/rxjs/src/internal/util/errorContext.ts", "node_modules/rxjs/src/internal/Subscriber.ts", "node_modules/rxjs/src/internal/symbol/observable.ts", "node_modules/rxjs/src/internal/util/identity.ts", "node_modules/rxjs/src/internal/util/pipe.ts", "node_modules/rxjs/src/internal/Observable.ts", "node_modules/rxjs/src/internal/util/lift.ts", "node_modules/rxjs/src/internal/operators/OperatorSubscriber.ts", "node_modules/rxjs/src/internal/scheduler/animationFrameProvider.ts", "node_modules/rxjs/src/internal/util/ObjectUnsubscribedError.ts", "node_modules/rxjs/src/internal/Subject.ts", "node_modules/rxjs/src/internal/BehaviorSubject.ts", "node_modules/rxjs/src/internal/scheduler/dateTimestampProvider.ts", "node_modules/rxjs/src/internal/ReplaySubject.ts", "node_modules/rxjs/src/internal/scheduler/Action.ts", "node_modules/rxjs/src/internal/scheduler/intervalProvider.ts", "node_modules/rxjs/src/internal/scheduler/AsyncAction.ts", "node_modules/rxjs/src/internal/Scheduler.ts", "node_modules/rxjs/src/internal/scheduler/AsyncScheduler.ts", "node_modules/rxjs/src/internal/scheduler/async.ts", "node_modules/rxjs/src/internal/scheduler/QueueAction.ts", "node_modules/rxjs/src/internal/scheduler/QueueScheduler.ts", "node_modules/rxjs/src/internal/scheduler/queue.ts", "node_modules/rxjs/src/internal/scheduler/AnimationFrameAction.ts", "node_modules/rxjs/src/internal/scheduler/AnimationFrameScheduler.ts", "node_modules/rxjs/src/internal/scheduler/animationFrame.ts", "node_modules/rxjs/src/internal/observable/empty.ts", "node_modules/rxjs/src/internal/util/isScheduler.ts", "node_modules/rxjs/src/internal/util/args.ts", "node_modules/rxjs/src/internal/util/isArrayLike.ts", "node_modules/rxjs/src/internal/util/isPromise.ts", "node_modules/rxjs/src/internal/util/isInteropObservable.ts", "node_modules/rxjs/src/internal/util/isAsyncIterable.ts", "node_modules/rxjs/src/internal/util/throwUnobservableError.ts", "node_modules/rxjs/src/internal/symbol/iterator.ts", "node_modules/rxjs/src/internal/util/isIterable.ts", "node_modules/rxjs/src/internal/util/isReadableStreamLike.ts", "node_modules/rxjs/src/internal/observable/innerFrom.ts", "node_modules/rxjs/src/internal/util/executeSchedule.ts", "node_modules/rxjs/src/internal/operators/observeOn.ts", "node_modules/rxjs/src/internal/operators/subscribeOn.ts", "node_modules/rxjs/src/internal/scheduled/scheduleObservable.ts", "node_modules/rxjs/src/internal/scheduled/schedulePromise.ts", "node_modules/rxjs/src/internal/scheduled/scheduleArray.ts", "node_modules/rxjs/src/internal/scheduled/scheduleIterable.ts", "node_modules/rxjs/src/internal/scheduled/scheduleAsyncIterable.ts", "node_modules/rxjs/src/internal/scheduled/scheduleReadableStreamLike.ts", "node_modules/rxjs/src/internal/scheduled/scheduled.ts", "node_modules/rxjs/src/internal/observable/from.ts", "node_modules/rxjs/src/internal/observable/of.ts", "node_modules/rxjs/src/internal/observable/throwError.ts", "node_modules/rxjs/src/internal/util/EmptyError.ts", "node_modules/rxjs/src/internal/util/isDate.ts", "node_modules/rxjs/src/internal/operators/map.ts", "node_modules/rxjs/src/internal/util/mapOneOrManyArgs.ts", "node_modules/rxjs/src/internal/util/argsArgArrayOrObject.ts", "node_modules/rxjs/src/internal/util/createObject.ts", "node_modules/rxjs/src/internal/observable/combineLatest.ts", "node_modules/rxjs/src/internal/operators/mergeInternals.ts", "node_modules/rxjs/src/internal/operators/mergeMap.ts", "node_modules/rxjs/src/internal/operators/mergeAll.ts", "node_modules/rxjs/src/internal/operators/concatAll.ts", "node_modules/rxjs/src/internal/observable/concat.ts", "node_modules/rxjs/src/internal/observable/defer.ts", "node_modules/rxjs/src/internal/observable/fromEvent.ts", "node_modules/rxjs/src/internal/observable/fromEventPattern.ts", "node_modules/rxjs/src/internal/observable/timer.ts", "node_modules/rxjs/src/internal/observable/merge.ts", "node_modules/rxjs/src/internal/observable/never.ts", "node_modules/rxjs/src/internal/util/argsOrArgArray.ts", "node_modules/rxjs/src/internal/operators/filter.ts", "node_modules/rxjs/src/internal/observable/zip.ts", "node_modules/rxjs/src/internal/operators/audit.ts", "node_modules/rxjs/src/internal/operators/auditTime.ts", "node_modules/rxjs/src/internal/operators/bufferCount.ts", "node_modules/rxjs/src/internal/operators/catchError.ts", "node_modules/rxjs/src/internal/operators/scanInternals.ts", "node_modules/rxjs/src/internal/operators/combineLatest.ts", "node_modules/rxjs/src/internal/operators/combineLatestWith.ts", "node_modules/rxjs/src/internal/operators/debounce.ts", "node_modules/rxjs/src/internal/operators/debounceTime.ts", "node_modules/rxjs/src/internal/operators/defaultIfEmpty.ts", "node_modules/rxjs/src/internal/operators/take.ts", "node_modules/rxjs/src/internal/operators/ignoreElements.ts", "node_modules/rxjs/src/internal/operators/mapTo.ts", "node_modules/rxjs/src/internal/operators/delayWhen.ts", "node_modules/rxjs/src/internal/operators/delay.ts", "node_modules/rxjs/src/internal/operators/distinct.ts", "node_modules/rxjs/src/internal/operators/distinctUntilChanged.ts", "node_modules/rxjs/src/internal/operators/distinctUntilKeyChanged.ts", "node_modules/rxjs/src/internal/operators/throwIfEmpty.ts", "node_modules/rxjs/src/internal/operators/endWith.ts", "node_modules/rxjs/src/internal/operators/exhaustMap.ts", "node_modules/rxjs/src/internal/operators/finalize.ts", "node_modules/rxjs/src/internal/operators/first.ts", "node_modules/rxjs/src/internal/operators/takeLast.ts", "node_modules/rxjs/src/internal/operators/merge.ts", "node_modules/rxjs/src/internal/operators/mergeWith.ts", "node_modules/rxjs/src/internal/operators/repeat.ts", "node_modules/rxjs/src/internal/operators/scan.ts", "node_modules/rxjs/src/internal/operators/share.ts", "node_modules/rxjs/src/internal/operators/shareReplay.ts", "node_modules/rxjs/src/internal/operators/skip.ts", "node_modules/rxjs/src/internal/operators/skipUntil.ts", "node_modules/rxjs/src/internal/operators/startWith.ts", "node_modules/rxjs/src/internal/operators/switchMap.ts", "node_modules/rxjs/src/internal/operators/takeUntil.ts", "node_modules/rxjs/src/internal/operators/takeWhile.ts", "node_modules/rxjs/src/internal/operators/tap.ts", "node_modules/rxjs/src/internal/operators/throttle.ts", "node_modules/rxjs/src/internal/operators/throttleTime.ts", "node_modules/rxjs/src/internal/operators/withLatestFrom.ts", "node_modules/rxjs/src/internal/operators/zip.ts", "node_modules/rxjs/src/internal/operators/zipWith.ts", "src/templates/assets/javascripts/browser/document/index.ts", "src/templates/assets/javascripts/browser/element/_/index.ts", "src/templates/assets/javascripts/browser/element/focus/index.ts", "src/templates/assets/javascripts/browser/element/hover/index.ts", "src/templates/assets/javascripts/utilities/h/index.ts", "src/templates/assets/javascripts/utilities/round/index.ts", "src/templates/assets/javascripts/browser/script/index.ts", "src/templates/assets/javascripts/browser/element/size/_/index.ts", "src/templates/assets/javascripts/browser/element/size/content/index.ts", "src/templates/assets/javascripts/browser/element/offset/_/index.ts", "src/templates/assets/javascripts/browser/element/offset/content/index.ts", "src/templates/assets/javascripts/browser/element/visibility/index.ts", "src/templates/assets/javascripts/browser/toggle/index.ts", "src/templates/assets/javascripts/browser/keyboard/index.ts", "src/templates/assets/javascripts/browser/location/_/index.ts", "src/templates/assets/javascripts/browser/location/hash/index.ts", "src/templates/assets/javascripts/browser/media/index.ts", "src/templates/assets/javascripts/browser/request/index.ts", "src/templates/assets/javascripts/browser/viewport/offset/index.ts", "src/templates/assets/javascripts/browser/viewport/size/index.ts", "src/templates/assets/javascripts/browser/viewport/_/index.ts", "src/templates/assets/javascripts/browser/viewport/at/index.ts", "src/templates/assets/javascripts/browser/worker/index.ts", "src/templates/assets/javascripts/_/index.ts", "src/templates/assets/javascripts/components/_/index.ts", "src/templates/assets/javascripts/components/announce/index.ts", "src/templates/assets/javascripts/components/consent/index.ts", "src/templates/assets/javascripts/templates/tooltip/index.tsx", "src/templates/assets/javascripts/templates/annotation/index.tsx", "src/templates/assets/javascripts/templates/clipboard/index.tsx", "src/templates/assets/javascripts/templates/search/index.tsx", "src/templates/assets/javascripts/templates/source/index.tsx", "src/templates/assets/javascripts/templates/tabbed/index.tsx", "src/templates/assets/javascripts/templates/table/index.tsx", "src/templates/assets/javascripts/templates/version/index.tsx", "src/templates/assets/javascripts/components/tooltip2/index.ts", "src/templates/assets/javascripts/components/content/annotation/_/index.ts", "src/templates/assets/javascripts/components/content/annotation/list/index.ts", "src/templates/assets/javascripts/components/content/annotation/block/index.ts", "src/templates/assets/javascripts/components/content/code/_/index.ts", "src/templates/assets/javascripts/components/content/details/index.ts", "src/templates/assets/javascripts/components/content/link/index.ts", "src/templates/assets/javascripts/components/content/mermaid/index.css", "src/templates/assets/javascripts/components/content/mermaid/index.ts", "src/templates/assets/javascripts/components/content/table/index.ts", "src/templates/assets/javascripts/components/content/tabs/index.ts", "src/templates/assets/javascripts/components/content/_/index.ts", "src/templates/assets/javascripts/components/dialog/index.ts", "src/templates/assets/javascripts/components/tooltip/index.ts", "src/templates/assets/javascripts/components/header/_/index.ts", "src/templates/assets/javascripts/components/header/title/index.ts", "src/templates/assets/javascripts/components/main/index.ts", "src/templates/assets/javascripts/components/palette/index.ts", "src/templates/assets/javascripts/components/progress/index.ts", "src/templates/assets/javascripts/integrations/sitemap/index.ts", "src/templates/assets/javascripts/integrations/alternate/index.ts", "src/templates/assets/javascripts/integrations/clipboard/index.ts", "src/templates/assets/javascripts/integrations/instant/index.ts", "src/templates/assets/javascripts/integrations/search/highlighter/index.ts", "src/templates/assets/javascripts/integrations/search/worker/message/index.ts", "src/templates/assets/javascripts/integrations/search/worker/_/index.ts", "src/templates/assets/javascripts/integrations/version/findurl/index.ts", "src/templates/assets/javascripts/integrations/version/index.ts", "src/templates/assets/javascripts/components/search/query/index.ts", "src/templates/assets/javascripts/components/search/result/index.ts", "src/templates/assets/javascripts/components/search/share/index.ts", "src/templates/assets/javascripts/components/search/suggest/index.ts", "src/templates/assets/javascripts/components/search/_/index.ts", "src/templates/assets/javascripts/components/search/highlight/index.ts", "src/templates/assets/javascripts/components/sidebar/index.ts", "src/templates/assets/javascripts/components/source/facts/github/index.ts", "src/templates/assets/javascripts/components/source/facts/gitlab/index.ts", "src/templates/assets/javascripts/components/source/facts/_/index.ts", "src/templates/assets/javascripts/components/source/_/index.ts", "src/templates/assets/javascripts/components/tabs/index.ts", "src/templates/assets/javascripts/components/toc/index.ts", "src/templates/assets/javascripts/components/top/index.ts", "src/templates/assets/javascripts/patches/ellipsis/index.ts", "src/templates/assets/javascripts/patches/indeterminate/index.ts", "src/templates/assets/javascripts/patches/scrollfix/index.ts", "src/templates/assets/javascripts/patches/scrolllock/index.ts", "src/templates/assets/javascripts/polyfills/index.ts"], + "sourcesContent": ["(function (global, factory) {\n typeof exports === 'object' && typeof module !== 'undefined' ? factory() :\n typeof define === 'function' && define.amd ? define(factory) :\n (factory());\n}(this, (function () { 'use strict';\n\n /**\n * Applies the :focus-visible polyfill at the given scope.\n * A scope in this case is either the top-level Document or a Shadow Root.\n *\n * @param {(Document|ShadowRoot)} scope\n * @see https://github.com/WICG/focus-visible\n */\n function applyFocusVisiblePolyfill(scope) {\n var hadKeyboardEvent = true;\n var hadFocusVisibleRecently = false;\n var hadFocusVisibleRecentlyTimeout = null;\n\n var inputTypesAllowlist = {\n text: true,\n search: true,\n url: true,\n tel: true,\n email: true,\n password: true,\n number: true,\n date: true,\n month: true,\n week: true,\n time: true,\n datetime: true,\n 'datetime-local': true\n };\n\n /**\n * Helper function for legacy browsers and iframes which sometimes focus\n * elements like document, body, and non-interactive SVG.\n * @param {Element} el\n */\n function isValidFocusTarget(el) {\n if (\n el &&\n el !== document &&\n el.nodeName !== 'HTML' &&\n el.nodeName !== 'BODY' &&\n 'classList' in el &&\n 'contains' in el.classList\n ) {\n return true;\n }\n return false;\n }\n\n /**\n * Computes whether the given element should automatically trigger the\n * `focus-visible` class being added, i.e. whether it should always match\n * `:focus-visible` when focused.\n * @param {Element} el\n * @return {boolean}\n */\n function focusTriggersKeyboardModality(el) {\n var type = el.type;\n var tagName = el.tagName;\n\n if (tagName === 'INPUT' && inputTypesAllowlist[type] && !el.readOnly) {\n return true;\n }\n\n if (tagName === 'TEXTAREA' && !el.readOnly) {\n return true;\n }\n\n if (el.isContentEditable) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Add the `focus-visible` class to the given element if it was not added by\n * the author.\n * @param {Element} el\n */\n function addFocusVisibleClass(el) {\n if (el.classList.contains('focus-visible')) {\n return;\n }\n el.classList.add('focus-visible');\n el.setAttribute('data-focus-visible-added', '');\n }\n\n /**\n * Remove the `focus-visible` class from the given element if it was not\n * originally added by the author.\n * @param {Element} el\n */\n function removeFocusVisibleClass(el) {\n if (!el.hasAttribute('data-focus-visible-added')) {\n return;\n }\n el.classList.remove('focus-visible');\n el.removeAttribute('data-focus-visible-added');\n }\n\n /**\n * If the most recent user interaction was via the keyboard;\n * and the key press did not include a meta, alt/option, or control key;\n * then the modality is keyboard. Otherwise, the modality is not keyboard.\n * Apply `focus-visible` to any current active element and keep track\n * of our keyboard modality state with `hadKeyboardEvent`.\n * @param {KeyboardEvent} e\n */\n function onKeyDown(e) {\n if (e.metaKey || e.altKey || e.ctrlKey) {\n return;\n }\n\n if (isValidFocusTarget(scope.activeElement)) {\n addFocusVisibleClass(scope.activeElement);\n }\n\n hadKeyboardEvent = true;\n }\n\n /**\n * If at any point a user clicks with a pointing device, ensure that we change\n * the modality away from keyboard.\n * This avoids the situation where a user presses a key on an already focused\n * element, and then clicks on a different element, focusing it with a\n * pointing device, while we still think we're in keyboard modality.\n * @param {Event} e\n */\n function onPointerDown(e) {\n hadKeyboardEvent = false;\n }\n\n /**\n * On `focus`, add the `focus-visible` class to the target if:\n * - the target received focus as a result of keyboard navigation, or\n * - the event target is an element that will likely require interaction\n * via the keyboard (e.g. a text box)\n * @param {Event} e\n */\n function onFocus(e) {\n // Prevent IE from focusing the document or HTML element.\n if (!isValidFocusTarget(e.target)) {\n return;\n }\n\n if (hadKeyboardEvent || focusTriggersKeyboardModality(e.target)) {\n addFocusVisibleClass(e.target);\n }\n }\n\n /**\n * On `blur`, remove the `focus-visible` class from the target.\n * @param {Event} e\n */\n function onBlur(e) {\n if (!isValidFocusTarget(e.target)) {\n return;\n }\n\n if (\n e.target.classList.contains('focus-visible') ||\n e.target.hasAttribute('data-focus-visible-added')\n ) {\n // To detect a tab/window switch, we look for a blur event followed\n // rapidly by a visibility change.\n // If we don't see a visibility change within 100ms, it's probably a\n // regular focus change.\n hadFocusVisibleRecently = true;\n window.clearTimeout(hadFocusVisibleRecentlyTimeout);\n hadFocusVisibleRecentlyTimeout = window.setTimeout(function() {\n hadFocusVisibleRecently = false;\n }, 100);\n removeFocusVisibleClass(e.target);\n }\n }\n\n /**\n * If the user changes tabs, keep track of whether or not the previously\n * focused element had .focus-visible.\n * @param {Event} e\n */\n function onVisibilityChange(e) {\n if (document.visibilityState === 'hidden') {\n // If the tab becomes active again, the browser will handle calling focus\n // on the element (Safari actually calls it twice).\n // If this tab change caused a blur on an element with focus-visible,\n // re-apply the class when the user switches back to the tab.\n if (hadFocusVisibleRecently) {\n hadKeyboardEvent = true;\n }\n addInitialPointerMoveListeners();\n }\n }\n\n /**\n * Add a group of listeners to detect usage of any pointing devices.\n * These listeners will be added when the polyfill first loads, and anytime\n * the window is blurred, so that they are active when the window regains\n * focus.\n */\n function addInitialPointerMoveListeners() {\n document.addEventListener('mousemove', onInitialPointerMove);\n document.addEventListener('mousedown', onInitialPointerMove);\n document.addEventListener('mouseup', onInitialPointerMove);\n document.addEventListener('pointermove', onInitialPointerMove);\n document.addEventListener('pointerdown', onInitialPointerMove);\n document.addEventListener('pointerup', onInitialPointerMove);\n document.addEventListener('touchmove', onInitialPointerMove);\n document.addEventListener('touchstart', onInitialPointerMove);\n document.addEventListener('touchend', onInitialPointerMove);\n }\n\n function removeInitialPointerMoveListeners() {\n document.removeEventListener('mousemove', onInitialPointerMove);\n document.removeEventListener('mousedown', onInitialPointerMove);\n document.removeEventListener('mouseup', onInitialPointerMove);\n document.removeEventListener('pointermove', onInitialPointerMove);\n document.removeEventListener('pointerdown', onInitialPointerMove);\n document.removeEventListener('pointerup', onInitialPointerMove);\n document.removeEventListener('touchmove', onInitialPointerMove);\n document.removeEventListener('touchstart', onInitialPointerMove);\n document.removeEventListener('touchend', onInitialPointerMove);\n }\n\n /**\n * When the polfyill first loads, assume the user is in keyboard modality.\n * If any event is received from a pointing device (e.g. mouse, pointer,\n * touch), turn off keyboard modality.\n * This accounts for situations where focus enters the page from the URL bar.\n * @param {Event} e\n */\n function onInitialPointerMove(e) {\n // Work around a Safari quirk that fires a mousemove on whenever the\n // window blurs, even if you're tabbing out of the page. \u00AF\\_(\u30C4)_/\u00AF\n if (e.target.nodeName && e.target.nodeName.toLowerCase() === 'html') {\n return;\n }\n\n hadKeyboardEvent = false;\n removeInitialPointerMoveListeners();\n }\n\n // For some kinds of state, we are interested in changes at the global scope\n // only. For example, global pointer input, global key presses and global\n // visibility change should affect the state at every scope:\n document.addEventListener('keydown', onKeyDown, true);\n document.addEventListener('mousedown', onPointerDown, true);\n document.addEventListener('pointerdown', onPointerDown, true);\n document.addEventListener('touchstart', onPointerDown, true);\n document.addEventListener('visibilitychange', onVisibilityChange, true);\n\n addInitialPointerMoveListeners();\n\n // For focus and blur, we specifically care about state changes in the local\n // scope. This is because focus / blur events that originate from within a\n // shadow root are not re-dispatched from the host element if it was already\n // the active element in its own scope:\n scope.addEventListener('focus', onFocus, true);\n scope.addEventListener('blur', onBlur, true);\n\n // We detect that a node is a ShadowRoot by ensuring that it is a\n // DocumentFragment and also has a host property. This check covers native\n // implementation and polyfill implementation transparently. If we only cared\n // about the native implementation, we could just check if the scope was\n // an instance of a ShadowRoot.\n if (scope.nodeType === Node.DOCUMENT_FRAGMENT_NODE && scope.host) {\n // Since a ShadowRoot is a special kind of DocumentFragment, it does not\n // have a root element to add a class to. So, we add this attribute to the\n // host element instead:\n scope.host.setAttribute('data-js-focus-visible', '');\n } else if (scope.nodeType === Node.DOCUMENT_NODE) {\n document.documentElement.classList.add('js-focus-visible');\n document.documentElement.setAttribute('data-js-focus-visible', '');\n }\n }\n\n // It is important to wrap all references to global window and document in\n // these checks to support server-side rendering use cases\n // @see https://github.com/WICG/focus-visible/issues/199\n if (typeof window !== 'undefined' && typeof document !== 'undefined') {\n // Make the polyfill helper globally available. This can be used as a signal\n // to interested libraries that wish to coordinate with the polyfill for e.g.,\n // applying the polyfill to a shadow root:\n window.applyFocusVisiblePolyfill = applyFocusVisiblePolyfill;\n\n // Notify interested libraries of the polyfill's presence, in case the\n // polyfill was loaded lazily:\n var event;\n\n try {\n event = new CustomEvent('focus-visible-polyfill-ready');\n } catch (error) {\n // IE11 does not support using CustomEvent as a constructor directly:\n event = document.createEvent('CustomEvent');\n event.initCustomEvent('focus-visible-polyfill-ready', false, false, {});\n }\n\n window.dispatchEvent(event);\n }\n\n if (typeof document !== 'undefined') {\n // Apply the polyfill to the global document, so that no JavaScript\n // coordination is required to use the polyfill in the top-level document:\n applyFocusVisiblePolyfill(document);\n }\n\n})));\n", "/*!\n * escape-html\n * Copyright(c) 2012-2013 TJ Holowaychuk\n * Copyright(c) 2015 Andreas Lubbe\n * Copyright(c) 2015 Tiancheng \"Timothy\" Gu\n * MIT Licensed\n */\n\n'use strict';\n\n/**\n * Module variables.\n * @private\n */\n\nvar matchHtmlRegExp = /[\"'&<>]/;\n\n/**\n * Module exports.\n * @public\n */\n\nmodule.exports = escapeHtml;\n\n/**\n * Escape special characters in the given string of html.\n *\n * @param {string} string The string to escape for inserting into HTML\n * @return {string}\n * @public\n */\n\nfunction escapeHtml(string) {\n var str = '' + string;\n var match = matchHtmlRegExp.exec(str);\n\n if (!match) {\n return str;\n }\n\n var escape;\n var html = '';\n var index = 0;\n var lastIndex = 0;\n\n for (index = match.index; index < str.length; index++) {\n switch (str.charCodeAt(index)) {\n case 34: // \"\n escape = '"';\n break;\n case 38: // &\n escape = '&';\n break;\n case 39: // '\n escape = ''';\n break;\n case 60: // <\n escape = '<';\n break;\n case 62: // >\n escape = '>';\n break;\n default:\n continue;\n }\n\n if (lastIndex !== index) {\n html += str.substring(lastIndex, index);\n }\n\n lastIndex = index + 1;\n html += escape;\n }\n\n return lastIndex !== index\n ? html + str.substring(lastIndex, index)\n : html;\n}\n", "/*!\n * clipboard.js v2.0.11\n * https://clipboardjs.com/\n *\n * Licensed MIT \u00A9 Zeno Rocha\n */\n(function webpackUniversalModuleDefinition(root, factory) {\n\tif(typeof exports === 'object' && typeof module === 'object')\n\t\tmodule.exports = factory();\n\telse if(typeof define === 'function' && define.amd)\n\t\tdefine([], factory);\n\telse if(typeof exports === 'object')\n\t\texports[\"ClipboardJS\"] = factory();\n\telse\n\t\troot[\"ClipboardJS\"] = factory();\n})(this, function() {\nreturn /******/ (function() { // webpackBootstrap\n/******/ \tvar __webpack_modules__ = ({\n\n/***/ 686:\n/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) {\n\n\"use strict\";\n\n// EXPORTS\n__webpack_require__.d(__webpack_exports__, {\n \"default\": function() { return /* binding */ clipboard; }\n});\n\n// EXTERNAL MODULE: ./node_modules/tiny-emitter/index.js\nvar tiny_emitter = __webpack_require__(279);\nvar tiny_emitter_default = /*#__PURE__*/__webpack_require__.n(tiny_emitter);\n// EXTERNAL MODULE: ./node_modules/good-listener/src/listen.js\nvar listen = __webpack_require__(370);\nvar listen_default = /*#__PURE__*/__webpack_require__.n(listen);\n// EXTERNAL MODULE: ./node_modules/select/src/select.js\nvar src_select = __webpack_require__(817);\nvar select_default = /*#__PURE__*/__webpack_require__.n(src_select);\n;// CONCATENATED MODULE: ./src/common/command.js\n/**\n * Executes a given operation type.\n * @param {String} type\n * @return {Boolean}\n */\nfunction command(type) {\n try {\n return document.execCommand(type);\n } catch (err) {\n return false;\n }\n}\n;// CONCATENATED MODULE: ./src/actions/cut.js\n\n\n/**\n * Cut action wrapper.\n * @param {String|HTMLElement} target\n * @return {String}\n */\n\nvar ClipboardActionCut = function ClipboardActionCut(target) {\n var selectedText = select_default()(target);\n command('cut');\n return selectedText;\n};\n\n/* harmony default export */ var actions_cut = (ClipboardActionCut);\n;// CONCATENATED MODULE: ./src/common/create-fake-element.js\n/**\n * Creates a fake textarea element with a value.\n * @param {String} value\n * @return {HTMLElement}\n */\nfunction createFakeElement(value) {\n var isRTL = document.documentElement.getAttribute('dir') === 'rtl';\n var fakeElement = document.createElement('textarea'); // Prevent zooming on iOS\n\n fakeElement.style.fontSize = '12pt'; // Reset box model\n\n fakeElement.style.border = '0';\n fakeElement.style.padding = '0';\n fakeElement.style.margin = '0'; // Move element out of screen horizontally\n\n fakeElement.style.position = 'absolute';\n fakeElement.style[isRTL ? 'right' : 'left'] = '-9999px'; // Move element to the same position vertically\n\n var yPosition = window.pageYOffset || document.documentElement.scrollTop;\n fakeElement.style.top = \"\".concat(yPosition, \"px\");\n fakeElement.setAttribute('readonly', '');\n fakeElement.value = value;\n return fakeElement;\n}\n;// CONCATENATED MODULE: ./src/actions/copy.js\n\n\n\n/**\n * Create fake copy action wrapper using a fake element.\n * @param {String} target\n * @param {Object} options\n * @return {String}\n */\n\nvar fakeCopyAction = function fakeCopyAction(value, options) {\n var fakeElement = createFakeElement(value);\n options.container.appendChild(fakeElement);\n var selectedText = select_default()(fakeElement);\n command('copy');\n fakeElement.remove();\n return selectedText;\n};\n/**\n * Copy action wrapper.\n * @param {String|HTMLElement} target\n * @param {Object} options\n * @return {String}\n */\n\n\nvar ClipboardActionCopy = function ClipboardActionCopy(target) {\n var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {\n container: document.body\n };\n var selectedText = '';\n\n if (typeof target === 'string') {\n selectedText = fakeCopyAction(target, options);\n } else if (target instanceof HTMLInputElement && !['text', 'search', 'url', 'tel', 'password'].includes(target === null || target === void 0 ? void 0 : target.type)) {\n // If input type doesn't support `setSelectionRange`. Simulate it. https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/setSelectionRange\n selectedText = fakeCopyAction(target.value, options);\n } else {\n selectedText = select_default()(target);\n command('copy');\n }\n\n return selectedText;\n};\n\n/* harmony default export */ var actions_copy = (ClipboardActionCopy);\n;// CONCATENATED MODULE: ./src/actions/default.js\nfunction _typeof(obj) { \"@babel/helpers - typeof\"; if (typeof Symbol === \"function\" && typeof Symbol.iterator === \"symbol\") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === \"function\" && obj.constructor === Symbol && obj !== Symbol.prototype ? \"symbol\" : typeof obj; }; } return _typeof(obj); }\n\n\n\n/**\n * Inner function which performs selection from either `text` or `target`\n * properties and then executes copy or cut operations.\n * @param {Object} options\n */\n\nvar ClipboardActionDefault = function ClipboardActionDefault() {\n var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};\n // Defines base properties passed from constructor.\n var _options$action = options.action,\n action = _options$action === void 0 ? 'copy' : _options$action,\n container = options.container,\n target = options.target,\n text = options.text; // Sets the `action` to be performed which can be either 'copy' or 'cut'.\n\n if (action !== 'copy' && action !== 'cut') {\n throw new Error('Invalid \"action\" value, use either \"copy\" or \"cut\"');\n } // Sets the `target` property using an element that will be have its content copied.\n\n\n if (target !== undefined) {\n if (target && _typeof(target) === 'object' && target.nodeType === 1) {\n if (action === 'copy' && target.hasAttribute('disabled')) {\n throw new Error('Invalid \"target\" attribute. Please use \"readonly\" instead of \"disabled\" attribute');\n }\n\n if (action === 'cut' && (target.hasAttribute('readonly') || target.hasAttribute('disabled'))) {\n throw new Error('Invalid \"target\" attribute. You can\\'t cut text from elements with \"readonly\" or \"disabled\" attributes');\n }\n } else {\n throw new Error('Invalid \"target\" value, use a valid Element');\n }\n } // Define selection strategy based on `text` property.\n\n\n if (text) {\n return actions_copy(text, {\n container: container\n });\n } // Defines which selection strategy based on `target` property.\n\n\n if (target) {\n return action === 'cut' ? actions_cut(target) : actions_copy(target, {\n container: container\n });\n }\n};\n\n/* harmony default export */ var actions_default = (ClipboardActionDefault);\n;// CONCATENATED MODULE: ./src/clipboard.js\nfunction clipboard_typeof(obj) { \"@babel/helpers - typeof\"; if (typeof Symbol === \"function\" && typeof Symbol.iterator === \"symbol\") { clipboard_typeof = function _typeof(obj) { return typeof obj; }; } else { clipboard_typeof = function _typeof(obj) { return obj && typeof Symbol === \"function\" && obj.constructor === Symbol && obj !== Symbol.prototype ? \"symbol\" : typeof obj; }; } return clipboard_typeof(obj); }\n\nfunction _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError(\"Cannot call a class as a function\"); } }\n\nfunction _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if (\"value\" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }\n\nfunction _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; }\n\nfunction _inherits(subClass, superClass) { if (typeof superClass !== \"function\" && superClass !== null) { throw new TypeError(\"Super expression must either be null or a function\"); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, writable: true, configurable: true } }); if (superClass) _setPrototypeOf(subClass, superClass); }\n\nfunction _setPrototypeOf(o, p) { _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) { o.__proto__ = p; return o; }; return _setPrototypeOf(o, p); }\n\nfunction _createSuper(Derived) { var hasNativeReflectConstruct = _isNativeReflectConstruct(); return function _createSuperInternal() { var Super = _getPrototypeOf(Derived), result; if (hasNativeReflectConstruct) { var NewTarget = _getPrototypeOf(this).constructor; result = Reflect.construct(Super, arguments, NewTarget); } else { result = Super.apply(this, arguments); } return _possibleConstructorReturn(this, result); }; }\n\nfunction _possibleConstructorReturn(self, call) { if (call && (clipboard_typeof(call) === \"object\" || typeof call === \"function\")) { return call; } return _assertThisInitialized(self); }\n\nfunction _assertThisInitialized(self) { if (self === void 0) { throw new ReferenceError(\"this hasn't been initialised - super() hasn't been called\"); } return self; }\n\nfunction _isNativeReflectConstruct() { if (typeof Reflect === \"undefined\" || !Reflect.construct) return false; if (Reflect.construct.sham) return false; if (typeof Proxy === \"function\") return true; try { Date.prototype.toString.call(Reflect.construct(Date, [], function () {})); return true; } catch (e) { return false; } }\n\nfunction _getPrototypeOf(o) { _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf : function _getPrototypeOf(o) { return o.__proto__ || Object.getPrototypeOf(o); }; return _getPrototypeOf(o); }\n\n\n\n\n\n\n/**\n * Helper function to retrieve attribute value.\n * @param {String} suffix\n * @param {Element} element\n */\n\nfunction getAttributeValue(suffix, element) {\n var attribute = \"data-clipboard-\".concat(suffix);\n\n if (!element.hasAttribute(attribute)) {\n return;\n }\n\n return element.getAttribute(attribute);\n}\n/**\n * Base class which takes one or more elements, adds event listeners to them,\n * and instantiates a new `ClipboardAction` on each click.\n */\n\n\nvar Clipboard = /*#__PURE__*/function (_Emitter) {\n _inherits(Clipboard, _Emitter);\n\n var _super = _createSuper(Clipboard);\n\n /**\n * @param {String|HTMLElement|HTMLCollection|NodeList} trigger\n * @param {Object} options\n */\n function Clipboard(trigger, options) {\n var _this;\n\n _classCallCheck(this, Clipboard);\n\n _this = _super.call(this);\n\n _this.resolveOptions(options);\n\n _this.listenClick(trigger);\n\n return _this;\n }\n /**\n * Defines if attributes would be resolved using internal setter functions\n * or custom functions that were passed in the constructor.\n * @param {Object} options\n */\n\n\n _createClass(Clipboard, [{\n key: \"resolveOptions\",\n value: function resolveOptions() {\n var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};\n this.action = typeof options.action === 'function' ? options.action : this.defaultAction;\n this.target = typeof options.target === 'function' ? options.target : this.defaultTarget;\n this.text = typeof options.text === 'function' ? options.text : this.defaultText;\n this.container = clipboard_typeof(options.container) === 'object' ? options.container : document.body;\n }\n /**\n * Adds a click event listener to the passed trigger.\n * @param {String|HTMLElement|HTMLCollection|NodeList} trigger\n */\n\n }, {\n key: \"listenClick\",\n value: function listenClick(trigger) {\n var _this2 = this;\n\n this.listener = listen_default()(trigger, 'click', function (e) {\n return _this2.onClick(e);\n });\n }\n /**\n * Defines a new `ClipboardAction` on each click event.\n * @param {Event} e\n */\n\n }, {\n key: \"onClick\",\n value: function onClick(e) {\n var trigger = e.delegateTarget || e.currentTarget;\n var action = this.action(trigger) || 'copy';\n var text = actions_default({\n action: action,\n container: this.container,\n target: this.target(trigger),\n text: this.text(trigger)\n }); // Fires an event based on the copy operation result.\n\n this.emit(text ? 'success' : 'error', {\n action: action,\n text: text,\n trigger: trigger,\n clearSelection: function clearSelection() {\n if (trigger) {\n trigger.focus();\n }\n\n window.getSelection().removeAllRanges();\n }\n });\n }\n /**\n * Default `action` lookup function.\n * @param {Element} trigger\n */\n\n }, {\n key: \"defaultAction\",\n value: function defaultAction(trigger) {\n return getAttributeValue('action', trigger);\n }\n /**\n * Default `target` lookup function.\n * @param {Element} trigger\n */\n\n }, {\n key: \"defaultTarget\",\n value: function defaultTarget(trigger) {\n var selector = getAttributeValue('target', trigger);\n\n if (selector) {\n return document.querySelector(selector);\n }\n }\n /**\n * Allow fire programmatically a copy action\n * @param {String|HTMLElement} target\n * @param {Object} options\n * @returns Text copied.\n */\n\n }, {\n key: \"defaultText\",\n\n /**\n * Default `text` lookup function.\n * @param {Element} trigger\n */\n value: function defaultText(trigger) {\n return getAttributeValue('text', trigger);\n }\n /**\n * Destroy lifecycle.\n */\n\n }, {\n key: \"destroy\",\n value: function destroy() {\n this.listener.destroy();\n }\n }], [{\n key: \"copy\",\n value: function copy(target) {\n var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {\n container: document.body\n };\n return actions_copy(target, options);\n }\n /**\n * Allow fire programmatically a cut action\n * @param {String|HTMLElement} target\n * @returns Text cutted.\n */\n\n }, {\n key: \"cut\",\n value: function cut(target) {\n return actions_cut(target);\n }\n /**\n * Returns the support of the given action, or all actions if no action is\n * given.\n * @param {String} [action]\n */\n\n }, {\n key: \"isSupported\",\n value: function isSupported() {\n var action = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ['copy', 'cut'];\n var actions = typeof action === 'string' ? [action] : action;\n var support = !!document.queryCommandSupported;\n actions.forEach(function (action) {\n support = support && !!document.queryCommandSupported(action);\n });\n return support;\n }\n }]);\n\n return Clipboard;\n}((tiny_emitter_default()));\n\n/* harmony default export */ var clipboard = (Clipboard);\n\n/***/ }),\n\n/***/ 828:\n/***/ (function(module) {\n\nvar DOCUMENT_NODE_TYPE = 9;\n\n/**\n * A polyfill for Element.matches()\n */\nif (typeof Element !== 'undefined' && !Element.prototype.matches) {\n var proto = Element.prototype;\n\n proto.matches = proto.matchesSelector ||\n proto.mozMatchesSelector ||\n proto.msMatchesSelector ||\n proto.oMatchesSelector ||\n proto.webkitMatchesSelector;\n}\n\n/**\n * Finds the closest parent that matches a selector.\n *\n * @param {Element} element\n * @param {String} selector\n * @return {Function}\n */\nfunction closest (element, selector) {\n while (element && element.nodeType !== DOCUMENT_NODE_TYPE) {\n if (typeof element.matches === 'function' &&\n element.matches(selector)) {\n return element;\n }\n element = element.parentNode;\n }\n}\n\nmodule.exports = closest;\n\n\n/***/ }),\n\n/***/ 438:\n/***/ (function(module, __unused_webpack_exports, __webpack_require__) {\n\nvar closest = __webpack_require__(828);\n\n/**\n * Delegates event to a selector.\n *\n * @param {Element} element\n * @param {String} selector\n * @param {String} type\n * @param {Function} callback\n * @param {Boolean} useCapture\n * @return {Object}\n */\nfunction _delegate(element, selector, type, callback, useCapture) {\n var listenerFn = listener.apply(this, arguments);\n\n element.addEventListener(type, listenerFn, useCapture);\n\n return {\n destroy: function() {\n element.removeEventListener(type, listenerFn, useCapture);\n }\n }\n}\n\n/**\n * Delegates event to a selector.\n *\n * @param {Element|String|Array} [elements]\n * @param {String} selector\n * @param {String} type\n * @param {Function} callback\n * @param {Boolean} useCapture\n * @return {Object}\n */\nfunction delegate(elements, selector, type, callback, useCapture) {\n // Handle the regular Element usage\n if (typeof elements.addEventListener === 'function') {\n return _delegate.apply(null, arguments);\n }\n\n // Handle Element-less usage, it defaults to global delegation\n if (typeof type === 'function') {\n // Use `document` as the first parameter, then apply arguments\n // This is a short way to .unshift `arguments` without running into deoptimizations\n return _delegate.bind(null, document).apply(null, arguments);\n }\n\n // Handle Selector-based usage\n if (typeof elements === 'string') {\n elements = document.querySelectorAll(elements);\n }\n\n // Handle Array-like based usage\n return Array.prototype.map.call(elements, function (element) {\n return _delegate(element, selector, type, callback, useCapture);\n });\n}\n\n/**\n * Finds closest match and invokes callback.\n *\n * @param {Element} element\n * @param {String} selector\n * @param {String} type\n * @param {Function} callback\n * @return {Function}\n */\nfunction listener(element, selector, type, callback) {\n return function(e) {\n e.delegateTarget = closest(e.target, selector);\n\n if (e.delegateTarget) {\n callback.call(element, e);\n }\n }\n}\n\nmodule.exports = delegate;\n\n\n/***/ }),\n\n/***/ 879:\n/***/ (function(__unused_webpack_module, exports) {\n\n/**\n * Check if argument is a HTML element.\n *\n * @param {Object} value\n * @return {Boolean}\n */\nexports.node = function(value) {\n return value !== undefined\n && value instanceof HTMLElement\n && value.nodeType === 1;\n};\n\n/**\n * Check if argument is a list of HTML elements.\n *\n * @param {Object} value\n * @return {Boolean}\n */\nexports.nodeList = function(value) {\n var type = Object.prototype.toString.call(value);\n\n return value !== undefined\n && (type === '[object NodeList]' || type === '[object HTMLCollection]')\n && ('length' in value)\n && (value.length === 0 || exports.node(value[0]));\n};\n\n/**\n * Check if argument is a string.\n *\n * @param {Object} value\n * @return {Boolean}\n */\nexports.string = function(value) {\n return typeof value === 'string'\n || value instanceof String;\n};\n\n/**\n * Check if argument is a function.\n *\n * @param {Object} value\n * @return {Boolean}\n */\nexports.fn = function(value) {\n var type = Object.prototype.toString.call(value);\n\n return type === '[object Function]';\n};\n\n\n/***/ }),\n\n/***/ 370:\n/***/ (function(module, __unused_webpack_exports, __webpack_require__) {\n\nvar is = __webpack_require__(879);\nvar delegate = __webpack_require__(438);\n\n/**\n * Validates all params and calls the right\n * listener function based on its target type.\n *\n * @param {String|HTMLElement|HTMLCollection|NodeList} target\n * @param {String} type\n * @param {Function} callback\n * @return {Object}\n */\nfunction listen(target, type, callback) {\n if (!target && !type && !callback) {\n throw new Error('Missing required arguments');\n }\n\n if (!is.string(type)) {\n throw new TypeError('Second argument must be a String');\n }\n\n if (!is.fn(callback)) {\n throw new TypeError('Third argument must be a Function');\n }\n\n if (is.node(target)) {\n return listenNode(target, type, callback);\n }\n else if (is.nodeList(target)) {\n return listenNodeList(target, type, callback);\n }\n else if (is.string(target)) {\n return listenSelector(target, type, callback);\n }\n else {\n throw new TypeError('First argument must be a String, HTMLElement, HTMLCollection, or NodeList');\n }\n}\n\n/**\n * Adds an event listener to a HTML element\n * and returns a remove listener function.\n *\n * @param {HTMLElement} node\n * @param {String} type\n * @param {Function} callback\n * @return {Object}\n */\nfunction listenNode(node, type, callback) {\n node.addEventListener(type, callback);\n\n return {\n destroy: function() {\n node.removeEventListener(type, callback);\n }\n }\n}\n\n/**\n * Add an event listener to a list of HTML elements\n * and returns a remove listener function.\n *\n * @param {NodeList|HTMLCollection} nodeList\n * @param {String} type\n * @param {Function} callback\n * @return {Object}\n */\nfunction listenNodeList(nodeList, type, callback) {\n Array.prototype.forEach.call(nodeList, function(node) {\n node.addEventListener(type, callback);\n });\n\n return {\n destroy: function() {\n Array.prototype.forEach.call(nodeList, function(node) {\n node.removeEventListener(type, callback);\n });\n }\n }\n}\n\n/**\n * Add an event listener to a selector\n * and returns a remove listener function.\n *\n * @param {String} selector\n * @param {String} type\n * @param {Function} callback\n * @return {Object}\n */\nfunction listenSelector(selector, type, callback) {\n return delegate(document.body, selector, type, callback);\n}\n\nmodule.exports = listen;\n\n\n/***/ }),\n\n/***/ 817:\n/***/ (function(module) {\n\nfunction select(element) {\n var selectedText;\n\n if (element.nodeName === 'SELECT') {\n element.focus();\n\n selectedText = element.value;\n }\n else if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') {\n var isReadOnly = element.hasAttribute('readonly');\n\n if (!isReadOnly) {\n element.setAttribute('readonly', '');\n }\n\n element.select();\n element.setSelectionRange(0, element.value.length);\n\n if (!isReadOnly) {\n element.removeAttribute('readonly');\n }\n\n selectedText = element.value;\n }\n else {\n if (element.hasAttribute('contenteditable')) {\n element.focus();\n }\n\n var selection = window.getSelection();\n var range = document.createRange();\n\n range.selectNodeContents(element);\n selection.removeAllRanges();\n selection.addRange(range);\n\n selectedText = selection.toString();\n }\n\n return selectedText;\n}\n\nmodule.exports = select;\n\n\n/***/ }),\n\n/***/ 279:\n/***/ (function(module) {\n\nfunction E () {\n // Keep this empty so it's easier to inherit from\n // (via https://github.com/lipsmack from https://github.com/scottcorgan/tiny-emitter/issues/3)\n}\n\nE.prototype = {\n on: function (name, callback, ctx) {\n var e = this.e || (this.e = {});\n\n (e[name] || (e[name] = [])).push({\n fn: callback,\n ctx: ctx\n });\n\n return this;\n },\n\n once: function (name, callback, ctx) {\n var self = this;\n function listener () {\n self.off(name, listener);\n callback.apply(ctx, arguments);\n };\n\n listener._ = callback\n return this.on(name, listener, ctx);\n },\n\n emit: function (name) {\n var data = [].slice.call(arguments, 1);\n var evtArr = ((this.e || (this.e = {}))[name] || []).slice();\n var i = 0;\n var len = evtArr.length;\n\n for (i; i < len; i++) {\n evtArr[i].fn.apply(evtArr[i].ctx, data);\n }\n\n return this;\n },\n\n off: function (name, callback) {\n var e = this.e || (this.e = {});\n var evts = e[name];\n var liveEvents = [];\n\n if (evts && callback) {\n for (var i = 0, len = evts.length; i < len; i++) {\n if (evts[i].fn !== callback && evts[i].fn._ !== callback)\n liveEvents.push(evts[i]);\n }\n }\n\n // Remove event from queue to prevent memory leak\n // Suggested by https://github.com/lazd\n // Ref: https://github.com/scottcorgan/tiny-emitter/commit/c6ebfaa9bc973b33d110a84a307742b7cf94c953#commitcomment-5024910\n\n (liveEvents.length)\n ? e[name] = liveEvents\n : delete e[name];\n\n return this;\n }\n};\n\nmodule.exports = E;\nmodule.exports.TinyEmitter = E;\n\n\n/***/ })\n\n/******/ \t});\n/************************************************************************/\n/******/ \t// The module cache\n/******/ \tvar __webpack_module_cache__ = {};\n/******/ \t\n/******/ \t// The require function\n/******/ \tfunction __webpack_require__(moduleId) {\n/******/ \t\t// Check if module is in cache\n/******/ \t\tif(__webpack_module_cache__[moduleId]) {\n/******/ \t\t\treturn __webpack_module_cache__[moduleId].exports;\n/******/ \t\t}\n/******/ \t\t// Create a new module (and put it into the cache)\n/******/ \t\tvar module = __webpack_module_cache__[moduleId] = {\n/******/ \t\t\t// no module.id needed\n/******/ \t\t\t// no module.loaded needed\n/******/ \t\t\texports: {}\n/******/ \t\t};\n/******/ \t\n/******/ \t\t// Execute the module function\n/******/ \t\t__webpack_modules__[moduleId](module, module.exports, __webpack_require__);\n/******/ \t\n/******/ \t\t// Return the exports of the module\n/******/ \t\treturn module.exports;\n/******/ \t}\n/******/ \t\n/************************************************************************/\n/******/ \t/* webpack/runtime/compat get default export */\n/******/ \t!function() {\n/******/ \t\t// getDefaultExport function for compatibility with non-harmony modules\n/******/ \t\t__webpack_require__.n = function(module) {\n/******/ \t\t\tvar getter = module && module.__esModule ?\n/******/ \t\t\t\tfunction() { return module['default']; } :\n/******/ \t\t\t\tfunction() { return module; };\n/******/ \t\t\t__webpack_require__.d(getter, { a: getter });\n/******/ \t\t\treturn getter;\n/******/ \t\t};\n/******/ \t}();\n/******/ \t\n/******/ \t/* webpack/runtime/define property getters */\n/******/ \t!function() {\n/******/ \t\t// define getter functions for harmony exports\n/******/ \t\t__webpack_require__.d = function(exports, definition) {\n/******/ \t\t\tfor(var key in definition) {\n/******/ \t\t\t\tif(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {\n/******/ \t\t\t\t\tObject.defineProperty(exports, key, { enumerable: true, get: definition[key] });\n/******/ \t\t\t\t}\n/******/ \t\t\t}\n/******/ \t\t};\n/******/ \t}();\n/******/ \t\n/******/ \t/* webpack/runtime/hasOwnProperty shorthand */\n/******/ \t!function() {\n/******/ \t\t__webpack_require__.o = function(obj, prop) { return Object.prototype.hasOwnProperty.call(obj, prop); }\n/******/ \t}();\n/******/ \t\n/************************************************************************/\n/******/ \t// module exports must be returned from runtime so entry inlining is disabled\n/******/ \t// startup\n/******/ \t// Load entry module and return exports\n/******/ \treturn __webpack_require__(686);\n/******/ })()\n.default;\n});", "/*\n * Copyright (c) 2016-2025 Martin Donath \n *\n * Permission is hereby granted, free of charge, to any person obtaining a copy\n * of this software and associated documentation files (the \"Software\"), to\n * deal in the Software without restriction, including without limitation the\n * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or\n * sell copies of the Software, and to permit persons to whom the Software is\n * furnished to do so, subject to the following conditions:\n *\n * The above copyright notice and this permission notice shall be included in\n * all copies or substantial portions of the Software.\n *\n * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE\n * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\n * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS\n * IN THE SOFTWARE.\n */\n\nimport \"focus-visible\"\n\nimport {\n EMPTY,\n NEVER,\n Observable,\n Subject,\n defer,\n delay,\n filter,\n map,\n merge,\n mergeWith,\n shareReplay,\n switchMap\n} from \"rxjs\"\n\nimport { configuration, feature } from \"./_\"\nimport {\n at,\n getActiveElement,\n getOptionalElement,\n requestJSON,\n setLocation,\n setToggle,\n watchDocument,\n watchKeyboard,\n watchLocation,\n watchLocationTarget,\n watchMedia,\n watchPrint,\n watchScript,\n watchViewport\n} from \"./browser\"\nimport {\n getComponentElement,\n getComponentElements,\n mountAnnounce,\n mountBackToTop,\n mountConsent,\n mountContent,\n mountDialog,\n mountHeader,\n mountHeaderTitle,\n mountPalette,\n mountProgress,\n mountSearch,\n mountSearchHiglight,\n mountSidebar,\n mountSource,\n mountTableOfContents,\n mountTabs,\n watchHeader,\n watchMain\n} from \"./components\"\nimport {\n SearchIndex,\n fetchSitemap,\n setupAlternate,\n setupClipboardJS,\n setupInstantNavigation,\n setupVersionSelector\n} from \"./integrations\"\nimport {\n patchEllipsis,\n patchIndeterminate,\n patchScrollfix,\n patchScrolllock\n} from \"./patches\"\nimport \"./polyfills\"\n\n/* ----------------------------------------------------------------------------\n * Functions - @todo refactor\n * ------------------------------------------------------------------------- */\n\n/**\n * Fetch search index\n *\n * @returns Search index observable\n */\nfunction fetchSearchIndex(): Observable {\n if (location.protocol === \"file:\") {\n return watchScript(\n `${new URL(\"search/search_index.js\", config.base)}`\n )\n .pipe(\n // @ts-ignore - @todo fix typings\n map(() => __index),\n shareReplay(1)\n )\n } else {\n return requestJSON(\n new URL(\"search/search_index.json\", config.base)\n )\n }\n}\n\n/* ----------------------------------------------------------------------------\n * Application\n * ------------------------------------------------------------------------- */\n\n/* Yay, JavaScript is available */\ndocument.documentElement.classList.remove(\"no-js\")\ndocument.documentElement.classList.add(\"js\")\n\n/* Set up navigation observables and subjects */\nconst document$ = watchDocument()\nconst location$ = watchLocation()\nconst target$ = watchLocationTarget(location$)\nconst keyboard$ = watchKeyboard()\n\n/* Set up media observables */\nconst viewport$ = watchViewport()\nconst tablet$ = watchMedia(\"(min-width: 60em)\")\nconst screen$ = watchMedia(\"(min-width: 76.25em)\")\nconst print$ = watchPrint()\n\n/* Retrieve search index, if search is enabled */\nconst config = configuration()\nconst index$ = document.forms.namedItem(\"search\")\n ? fetchSearchIndex()\n : NEVER\n\n/* Set up Clipboard.js integration */\nconst alert$ = new Subject()\nsetupClipboardJS({ alert$ })\n\n/* Set up language selector */\nsetupAlternate({ document$ })\n\n/* Set up progress indicator */\nconst progress$ = new Subject()\n\n/* Set up sitemap for instant navigation and previews */\nconst sitemap$ = fetchSitemap(config.base)\n\n/* Set up instant navigation, if enabled */\nif (feature(\"navigation.instant\"))\n setupInstantNavigation({ sitemap$, location$, viewport$, progress$ })\n .subscribe(document$)\n\n/* Set up version selector */\nif (config.version?.provider === \"mike\")\n setupVersionSelector({ document$ })\n\n/* Always close drawer and search on navigation */\nmerge(location$, target$)\n .pipe(\n delay(125)\n )\n .subscribe(() => {\n setToggle(\"drawer\", false)\n setToggle(\"search\", false)\n })\n\n/* Set up global keyboard handlers */\nkeyboard$\n .pipe(\n filter(({ mode }) => mode === \"global\")\n )\n .subscribe(key => {\n switch (key.type) {\n\n /* Go to previous page */\n case \"p\":\n case \",\":\n const prev = getOptionalElement(\"link[rel=prev]\")\n if (typeof prev !== \"undefined\")\n setLocation(prev)\n break\n\n /* Go to next page */\n case \"n\":\n case \".\":\n const next = getOptionalElement(\"link[rel=next]\")\n if (typeof next !== \"undefined\")\n setLocation(next)\n break\n\n /* Expand navigation, see https://bit.ly/3ZjG5io */\n case \"Enter\":\n const active = getActiveElement()\n if (active instanceof HTMLLabelElement)\n active.click()\n }\n })\n\n/* Set up patches */\npatchEllipsis({ viewport$, document$ })\npatchIndeterminate({ document$, tablet$ })\npatchScrollfix({ document$ })\npatchScrolllock({ viewport$, tablet$ })\n\n/* Set up header and main area observable */\nconst header$ = watchHeader(getComponentElement(\"header\"), { viewport$ })\nconst main$ = document$\n .pipe(\n map(() => getComponentElement(\"main\")),\n switchMap(el => watchMain(el, { viewport$, header$ })),\n shareReplay(1)\n )\n\n/* Set up control component observables */\nconst control$ = merge(\n\n /* Consent */\n ...getComponentElements(\"consent\")\n .map(el => mountConsent(el, { target$ })),\n\n /* Dialog */\n ...getComponentElements(\"dialog\")\n .map(el => mountDialog(el, { alert$ })),\n\n /* Color palette */\n ...getComponentElements(\"palette\")\n .map(el => mountPalette(el)),\n\n /* Progress bar */\n ...getComponentElements(\"progress\")\n .map(el => mountProgress(el, { progress$ })),\n\n /* Search */\n ...getComponentElements(\"search\")\n .map(el => mountSearch(el, { index$, keyboard$ })),\n\n /* Repository information */\n ...getComponentElements(\"source\")\n .map(el => mountSource(el))\n)\n\n/* Set up content component observables */\nconst content$ = defer(() => merge(\n\n /* Announcement bar */\n ...getComponentElements(\"announce\")\n .map(el => mountAnnounce(el)),\n\n /* Content */\n ...getComponentElements(\"content\")\n .map(el => mountContent(el, { sitemap$, viewport$, target$, print$ })),\n\n /* Search highlighting */\n ...getComponentElements(\"content\")\n .map(el => feature(\"search.highlight\")\n ? mountSearchHiglight(el, { index$, location$ })\n : EMPTY\n ),\n\n /* Header */\n ...getComponentElements(\"header\")\n .map(el => mountHeader(el, { viewport$, header$, main$ })),\n\n /* Header title */\n ...getComponentElements(\"header-title\")\n .map(el => mountHeaderTitle(el, { viewport$, header$ })),\n\n /* Sidebar */\n ...getComponentElements(\"sidebar\")\n .map(el => el.getAttribute(\"data-md-type\") === \"navigation\"\n ? at(screen$, () => mountSidebar(el, { viewport$, header$, main$ }))\n : at(tablet$, () => mountSidebar(el, { viewport$, header$, main$ }))\n ),\n\n /* Navigation tabs */\n ...getComponentElements(\"tabs\")\n .map(el => mountTabs(el, { viewport$, header$ })),\n\n /* Table of contents */\n ...getComponentElements(\"toc\")\n .map(el => mountTableOfContents(el, {\n viewport$, header$, main$, target$\n })),\n\n /* Back-to-top button */\n ...getComponentElements(\"top\")\n .map(el => mountBackToTop(el, { viewport$, header$, main$, target$ }))\n))\n\n/* Set up component observables */\nconst component$ = document$\n .pipe(\n switchMap(() => content$),\n mergeWith(control$),\n shareReplay(1)\n )\n\n/* Subscribe to all components */\ncomponent$.subscribe()\n\n/* ----------------------------------------------------------------------------\n * Exports\n * ------------------------------------------------------------------------- */\n\nwindow.document$ = document$ /* Document observable */\nwindow.location$ = location$ /* Location subject */\nwindow.target$ = target$ /* Location target observable */\nwindow.keyboard$ = keyboard$ /* Keyboard observable */\nwindow.viewport$ = viewport$ /* Viewport observable */\nwindow.tablet$ = tablet$ /* Media tablet observable */\nwindow.screen$ = screen$ /* Media screen observable */\nwindow.print$ = print$ /* Media print observable */\nwindow.alert$ = alert$ /* Alert subject */\nwindow.progress$ = progress$ /* Progress indicator subject */\nwindow.component$ = component$ /* Component observable */\n", "/******************************************************************************\nCopyright (c) Microsoft Corporation.\n\nPermission to use, copy, modify, and/or distribute this software for any\npurpose with or without fee is hereby granted.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH\nREGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY\nAND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,\nINDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM\nLOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR\nOTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR\nPERFORMANCE OF THIS SOFTWARE.\n***************************************************************************** */\n/* global Reflect, Promise, SuppressedError, Symbol, Iterator */\n\nvar extendStatics = function(d, b) {\n extendStatics = Object.setPrototypeOf ||\n ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||\n function (d, b) { for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p]; };\n return extendStatics(d, b);\n};\n\nexport function __extends(d, b) {\n if (typeof b !== \"function\" && b !== null)\n throw new TypeError(\"Class extends value \" + String(b) + \" is not a constructor or null\");\n extendStatics(d, b);\n function __() { this.constructor = d; }\n d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());\n}\n\nexport var __assign = function() {\n __assign = Object.assign || function __assign(t) {\n for (var s, i = 1, n = arguments.length; i < n; i++) {\n s = arguments[i];\n for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p];\n }\n return t;\n }\n return __assign.apply(this, arguments);\n}\n\nexport function __rest(s, e) {\n var t = {};\n for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)\n t[p] = s[p];\n if (s != null && typeof Object.getOwnPropertySymbols === \"function\")\n for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {\n if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))\n t[p[i]] = s[p[i]];\n }\n return t;\n}\n\nexport function __decorate(decorators, target, key, desc) {\n var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;\n if (typeof Reflect === \"object\" && typeof Reflect.decorate === \"function\") r = Reflect.decorate(decorators, target, key, desc);\n else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;\n return c > 3 && r && Object.defineProperty(target, key, r), r;\n}\n\nexport function __param(paramIndex, decorator) {\n return function (target, key) { decorator(target, key, paramIndex); }\n}\n\nexport function __esDecorate(ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {\n function accept(f) { if (f !== void 0 && typeof f !== \"function\") throw new TypeError(\"Function expected\"); return f; }\n var kind = contextIn.kind, key = kind === \"getter\" ? \"get\" : kind === \"setter\" ? \"set\" : \"value\";\n var target = !descriptorIn && ctor ? contextIn[\"static\"] ? ctor : ctor.prototype : null;\n var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});\n var _, done = false;\n for (var i = decorators.length - 1; i >= 0; i--) {\n var context = {};\n for (var p in contextIn) context[p] = p === \"access\" ? {} : contextIn[p];\n for (var p in contextIn.access) context.access[p] = contextIn.access[p];\n context.addInitializer = function (f) { if (done) throw new TypeError(\"Cannot add initializers after decoration has completed\"); extraInitializers.push(accept(f || null)); };\n var result = (0, decorators[i])(kind === \"accessor\" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);\n if (kind === \"accessor\") {\n if (result === void 0) continue;\n if (result === null || typeof result !== \"object\") throw new TypeError(\"Object expected\");\n if (_ = accept(result.get)) descriptor.get = _;\n if (_ = accept(result.set)) descriptor.set = _;\n if (_ = accept(result.init)) initializers.unshift(_);\n }\n else if (_ = accept(result)) {\n if (kind === \"field\") initializers.unshift(_);\n else descriptor[key] = _;\n }\n }\n if (target) Object.defineProperty(target, contextIn.name, descriptor);\n done = true;\n};\n\nexport function __runInitializers(thisArg, initializers, value) {\n var useValue = arguments.length > 2;\n for (var i = 0; i < initializers.length; i++) {\n value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);\n }\n return useValue ? value : void 0;\n};\n\nexport function __propKey(x) {\n return typeof x === \"symbol\" ? x : \"\".concat(x);\n};\n\nexport function __setFunctionName(f, name, prefix) {\n if (typeof name === \"symbol\") name = name.description ? \"[\".concat(name.description, \"]\") : \"\";\n return Object.defineProperty(f, \"name\", { configurable: true, value: prefix ? \"\".concat(prefix, \" \", name) : name });\n};\n\nexport function __metadata(metadataKey, metadataValue) {\n if (typeof Reflect === \"object\" && typeof Reflect.metadata === \"function\") return Reflect.metadata(metadataKey, metadataValue);\n}\n\nexport function __awaiter(thisArg, _arguments, P, generator) {\n function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }\n return new (P || (P = Promise))(function (resolve, reject) {\n function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }\n function rejected(value) { try { step(generator[\"throw\"](value)); } catch (e) { reject(e); } }\n function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }\n step((generator = generator.apply(thisArg, _arguments || [])).next());\n });\n}\n\nexport function __generator(thisArg, body) {\n var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === \"function\" ? Iterator : Object).prototype);\n return g.next = verb(0), g[\"throw\"] = verb(1), g[\"return\"] = verb(2), typeof Symbol === \"function\" && (g[Symbol.iterator] = function() { return this; }), g;\n function verb(n) { return function (v) { return step([n, v]); }; }\n function step(op) {\n if (f) throw new TypeError(\"Generator is already executing.\");\n while (g && (g = 0, op[0] && (_ = 0)), _) try {\n if (f = 1, y && (t = op[0] & 2 ? y[\"return\"] : op[0] ? y[\"throw\"] || ((t = y[\"return\"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;\n if (y = 0, t) op = [op[0] & 2, t.value];\n switch (op[0]) {\n case 0: case 1: t = op; break;\n case 4: _.label++; return { value: op[1], done: false };\n case 5: _.label++; y = op[1]; op = [0]; continue;\n case 7: op = _.ops.pop(); _.trys.pop(); continue;\n default:\n if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }\n if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }\n if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }\n if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }\n if (t[2]) _.ops.pop();\n _.trys.pop(); continue;\n }\n op = body.call(thisArg, _);\n } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }\n if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };\n }\n}\n\nexport var __createBinding = Object.create ? (function(o, m, k, k2) {\n if (k2 === undefined) k2 = k;\n var desc = Object.getOwnPropertyDescriptor(m, k);\n if (!desc || (\"get\" in desc ? !m.__esModule : desc.writable || desc.configurable)) {\n desc = { enumerable: true, get: function() { return m[k]; } };\n }\n Object.defineProperty(o, k2, desc);\n}) : (function(o, m, k, k2) {\n if (k2 === undefined) k2 = k;\n o[k2] = m[k];\n});\n\nexport function __exportStar(m, o) {\n for (var p in m) if (p !== \"default\" && !Object.prototype.hasOwnProperty.call(o, p)) __createBinding(o, m, p);\n}\n\nexport function __values(o) {\n var s = typeof Symbol === \"function\" && Symbol.iterator, m = s && o[s], i = 0;\n if (m) return m.call(o);\n if (o && typeof o.length === \"number\") return {\n next: function () {\n if (o && i >= o.length) o = void 0;\n return { value: o && o[i++], done: !o };\n }\n };\n throw new TypeError(s ? \"Object is not iterable.\" : \"Symbol.iterator is not defined.\");\n}\n\nexport function __read(o, n) {\n var m = typeof Symbol === \"function\" && o[Symbol.iterator];\n if (!m) return o;\n var i = m.call(o), r, ar = [], e;\n try {\n while ((n === void 0 || n-- > 0) && !(r = i.next()).done) ar.push(r.value);\n }\n catch (error) { e = { error: error }; }\n finally {\n try {\n if (r && !r.done && (m = i[\"return\"])) m.call(i);\n }\n finally { if (e) throw e.error; }\n }\n return ar;\n}\n\n/** @deprecated */\nexport function __spread() {\n for (var ar = [], i = 0; i < arguments.length; i++)\n ar = ar.concat(__read(arguments[i]));\n return ar;\n}\n\n/** @deprecated */\nexport function __spreadArrays() {\n for (var s = 0, i = 0, il = arguments.length; i < il; i++) s += arguments[i].length;\n for (var r = Array(s), k = 0, i = 0; i < il; i++)\n for (var a = arguments[i], j = 0, jl = a.length; j < jl; j++, k++)\n r[k] = a[j];\n return r;\n}\n\nexport function __spreadArray(to, from, pack) {\n if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) {\n if (ar || !(i in from)) {\n if (!ar) ar = Array.prototype.slice.call(from, 0, i);\n ar[i] = from[i];\n }\n }\n return to.concat(ar || Array.prototype.slice.call(from));\n}\n\nexport function __await(v) {\n return this instanceof __await ? (this.v = v, this) : new __await(v);\n}\n\nexport function __asyncGenerator(thisArg, _arguments, generator) {\n if (!Symbol.asyncIterator) throw new TypeError(\"Symbol.asyncIterator is not defined.\");\n var g = generator.apply(thisArg, _arguments || []), i, q = [];\n return i = Object.create((typeof AsyncIterator === \"function\" ? AsyncIterator : Object).prototype), verb(\"next\"), verb(\"throw\"), verb(\"return\", awaitReturn), i[Symbol.asyncIterator] = function () { return this; }, i;\n function awaitReturn(f) { return function (v) { return Promise.resolve(v).then(f, reject); }; }\n function verb(n, f) { if (g[n]) { i[n] = function (v) { return new Promise(function (a, b) { q.push([n, v, a, b]) > 1 || resume(n, v); }); }; if (f) i[n] = f(i[n]); } }\n function resume(n, v) { try { step(g[n](v)); } catch (e) { settle(q[0][3], e); } }\n function step(r) { r.value instanceof __await ? Promise.resolve(r.value.v).then(fulfill, reject) : settle(q[0][2], r); }\n function fulfill(value) { resume(\"next\", value); }\n function reject(value) { resume(\"throw\", value); }\n function settle(f, v) { if (f(v), q.shift(), q.length) resume(q[0][0], q[0][1]); }\n}\n\nexport function __asyncDelegator(o) {\n var i, p;\n return i = {}, verb(\"next\"), verb(\"throw\", function (e) { throw e; }), verb(\"return\"), i[Symbol.iterator] = function () { return this; }, i;\n function verb(n, f) { i[n] = o[n] ? function (v) { return (p = !p) ? { value: __await(o[n](v)), done: false } : f ? f(v) : v; } : f; }\n}\n\nexport function __asyncValues(o) {\n if (!Symbol.asyncIterator) throw new TypeError(\"Symbol.asyncIterator is not defined.\");\n var m = o[Symbol.asyncIterator], i;\n return m ? m.call(o) : (o = typeof __values === \"function\" ? __values(o) : o[Symbol.iterator](), i = {}, verb(\"next\"), verb(\"throw\"), verb(\"return\"), i[Symbol.asyncIterator] = function () { return this; }, i);\n function verb(n) { i[n] = o[n] && function (v) { return new Promise(function (resolve, reject) { v = o[n](v), settle(resolve, reject, v.done, v.value); }); }; }\n function settle(resolve, reject, d, v) { Promise.resolve(v).then(function(v) { resolve({ value: v, done: d }); }, reject); }\n}\n\nexport function __makeTemplateObject(cooked, raw) {\n if (Object.defineProperty) { Object.defineProperty(cooked, \"raw\", { value: raw }); } else { cooked.raw = raw; }\n return cooked;\n};\n\nvar __setModuleDefault = Object.create ? (function(o, v) {\n Object.defineProperty(o, \"default\", { enumerable: true, value: v });\n}) : function(o, v) {\n o[\"default\"] = v;\n};\n\nexport function __importStar(mod) {\n if (mod && mod.__esModule) return mod;\n var result = {};\n if (mod != null) for (var k in mod) if (k !== \"default\" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);\n __setModuleDefault(result, mod);\n return result;\n}\n\nexport function __importDefault(mod) {\n return (mod && mod.__esModule) ? mod : { default: mod };\n}\n\nexport function __classPrivateFieldGet(receiver, state, kind, f) {\n if (kind === \"a\" && !f) throw new TypeError(\"Private accessor was defined without a getter\");\n if (typeof state === \"function\" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError(\"Cannot read private member from an object whose class did not declare it\");\n return kind === \"m\" ? f : kind === \"a\" ? f.call(receiver) : f ? f.value : state.get(receiver);\n}\n\nexport function __classPrivateFieldSet(receiver, state, value, kind, f) {\n if (kind === \"m\") throw new TypeError(\"Private method is not writable\");\n if (kind === \"a\" && !f) throw new TypeError(\"Private accessor was defined without a setter\");\n if (typeof state === \"function\" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError(\"Cannot write private member to an object whose class did not declare it\");\n return (kind === \"a\" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;\n}\n\nexport function __classPrivateFieldIn(state, receiver) {\n if (receiver === null || (typeof receiver !== \"object\" && typeof receiver !== \"function\")) throw new TypeError(\"Cannot use 'in' operator on non-object\");\n return typeof state === \"function\" ? receiver === state : state.has(receiver);\n}\n\nexport function __addDisposableResource(env, value, async) {\n if (value !== null && value !== void 0) {\n if (typeof value !== \"object\" && typeof value !== \"function\") throw new TypeError(\"Object expected.\");\n var dispose, inner;\n if (async) {\n if (!Symbol.asyncDispose) throw new TypeError(\"Symbol.asyncDispose is not defined.\");\n dispose = value[Symbol.asyncDispose];\n }\n if (dispose === void 0) {\n if (!Symbol.dispose) throw new TypeError(\"Symbol.dispose is not defined.\");\n dispose = value[Symbol.dispose];\n if (async) inner = dispose;\n }\n if (typeof dispose !== \"function\") throw new TypeError(\"Object not disposable.\");\n if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };\n env.stack.push({ value: value, dispose: dispose, async: async });\n }\n else if (async) {\n env.stack.push({ async: true });\n }\n return value;\n}\n\nvar _SuppressedError = typeof SuppressedError === \"function\" ? SuppressedError : function (error, suppressed, message) {\n var e = new Error(message);\n return e.name = \"SuppressedError\", e.error = error, e.suppressed = suppressed, e;\n};\n\nexport function __disposeResources(env) {\n function fail(e) {\n env.error = env.hasError ? new _SuppressedError(e, env.error, \"An error was suppressed during disposal.\") : e;\n env.hasError = true;\n }\n var r, s = 0;\n function next() {\n while (r = env.stack.pop()) {\n try {\n if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);\n if (r.dispose) {\n var result = r.dispose.call(r.value);\n if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });\n }\n else s |= 1;\n }\n catch (e) {\n fail(e);\n }\n }\n if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();\n if (env.hasError) throw env.error;\n }\n return next();\n}\n\nexport default {\n __extends,\n __assign,\n __rest,\n __decorate,\n __param,\n __metadata,\n __awaiter,\n __generator,\n __createBinding,\n __exportStar,\n __values,\n __read,\n __spread,\n __spreadArrays,\n __spreadArray,\n __await,\n __asyncGenerator,\n __asyncDelegator,\n __asyncValues,\n __makeTemplateObject,\n __importStar,\n __importDefault,\n __classPrivateFieldGet,\n __classPrivateFieldSet,\n __classPrivateFieldIn,\n __addDisposableResource,\n __disposeResources,\n};\n", "/**\n * Returns true if the object is a function.\n * @param value The value to check\n */\nexport function isFunction(value: any): value is (...args: any[]) => any {\n return typeof value === 'function';\n}\n", "/**\n * Used to create Error subclasses until the community moves away from ES5.\n *\n * This is because compiling from TypeScript down to ES5 has issues with subclassing Errors\n * as well as other built-in types: https://github.com/Microsoft/TypeScript/issues/12123\n *\n * @param createImpl A factory function to create the actual constructor implementation. The returned\n * function should be a named function that calls `_super` internally.\n */\nexport function createErrorClass(createImpl: (_super: any) => any): T {\n const _super = (instance: any) => {\n Error.call(instance);\n instance.stack = new Error().stack;\n };\n\n const ctorFunc = createImpl(_super);\n ctorFunc.prototype = Object.create(Error.prototype);\n ctorFunc.prototype.constructor = ctorFunc;\n return ctorFunc;\n}\n", "import { createErrorClass } from './createErrorClass';\n\nexport interface UnsubscriptionError extends Error {\n readonly errors: any[];\n}\n\nexport interface UnsubscriptionErrorCtor {\n /**\n * @deprecated Internal implementation detail. Do not construct error instances.\n * Cannot be tagged as internal: https://github.com/ReactiveX/rxjs/issues/6269\n */\n new (errors: any[]): UnsubscriptionError;\n}\n\n/**\n * An error thrown when one or more errors have occurred during the\n * `unsubscribe` of a {@link Subscription}.\n */\nexport const UnsubscriptionError: UnsubscriptionErrorCtor = createErrorClass(\n (_super) =>\n function UnsubscriptionErrorImpl(this: any, errors: (Error | string)[]) {\n _super(this);\n this.message = errors\n ? `${errors.length} errors occurred during unsubscription:\n${errors.map((err, i) => `${i + 1}) ${err.toString()}`).join('\\n ')}`\n : '';\n this.name = 'UnsubscriptionError';\n this.errors = errors;\n }\n);\n", "/**\n * Removes an item from an array, mutating it.\n * @param arr The array to remove the item from\n * @param item The item to remove\n */\nexport function arrRemove(arr: T[] | undefined | null, item: T) {\n if (arr) {\n const index = arr.indexOf(item);\n 0 <= index && arr.splice(index, 1);\n }\n}\n", "import { isFunction } from './util/isFunction';\nimport { UnsubscriptionError } from './util/UnsubscriptionError';\nimport { SubscriptionLike, TeardownLogic, Unsubscribable } from './types';\nimport { arrRemove } from './util/arrRemove';\n\n/**\n * Represents a disposable resource, such as the execution of an Observable. A\n * Subscription has one important method, `unsubscribe`, that takes no argument\n * and just disposes the resource held by the subscription.\n *\n * Additionally, subscriptions may be grouped together through the `add()`\n * method, which will attach a child Subscription to the current Subscription.\n * When a Subscription is unsubscribed, all its children (and its grandchildren)\n * will be unsubscribed as well.\n */\nexport class Subscription implements SubscriptionLike {\n public static EMPTY = (() => {\n const empty = new Subscription();\n empty.closed = true;\n return empty;\n })();\n\n /**\n * A flag to indicate whether this Subscription has already been unsubscribed.\n */\n public closed = false;\n\n private _parentage: Subscription[] | Subscription | null = null;\n\n /**\n * The list of registered finalizers to execute upon unsubscription. Adding and removing from this\n * list occurs in the {@link #add} and {@link #remove} methods.\n */\n private _finalizers: Exclude[] | null = null;\n\n /**\n * @param initialTeardown A function executed first as part of the finalization\n * process that is kicked off when {@link #unsubscribe} is called.\n */\n constructor(private initialTeardown?: () => void) {}\n\n /**\n * Disposes the resources held by the subscription. May, for instance, cancel\n * an ongoing Observable execution or cancel any other type of work that\n * started when the Subscription was created.\n */\n unsubscribe(): void {\n let errors: any[] | undefined;\n\n if (!this.closed) {\n this.closed = true;\n\n // Remove this from it's parents.\n const { _parentage } = this;\n if (_parentage) {\n this._parentage = null;\n if (Array.isArray(_parentage)) {\n for (const parent of _parentage) {\n parent.remove(this);\n }\n } else {\n _parentage.remove(this);\n }\n }\n\n const { initialTeardown: initialFinalizer } = this;\n if (isFunction(initialFinalizer)) {\n try {\n initialFinalizer();\n } catch (e) {\n errors = e instanceof UnsubscriptionError ? e.errors : [e];\n }\n }\n\n const { _finalizers } = this;\n if (_finalizers) {\n this._finalizers = null;\n for (const finalizer of _finalizers) {\n try {\n execFinalizer(finalizer);\n } catch (err) {\n errors = errors ?? [];\n if (err instanceof UnsubscriptionError) {\n errors = [...errors, ...err.errors];\n } else {\n errors.push(err);\n }\n }\n }\n }\n\n if (errors) {\n throw new UnsubscriptionError(errors);\n }\n }\n }\n\n /**\n * Adds a finalizer to this subscription, so that finalization will be unsubscribed/called\n * when this subscription is unsubscribed. If this subscription is already {@link #closed},\n * because it has already been unsubscribed, then whatever finalizer is passed to it\n * will automatically be executed (unless the finalizer itself is also a closed subscription).\n *\n * Closed Subscriptions cannot be added as finalizers to any subscription. Adding a closed\n * subscription to a any subscription will result in no operation. (A noop).\n *\n * Adding a subscription to itself, or adding `null` or `undefined` will not perform any\n * operation at all. (A noop).\n *\n * `Subscription` instances that are added to this instance will automatically remove themselves\n * if they are unsubscribed. Functions and {@link Unsubscribable} objects that you wish to remove\n * will need to be removed manually with {@link #remove}\n *\n * @param teardown The finalization logic to add to this subscription.\n */\n add(teardown: TeardownLogic): void {\n // Only add the finalizer if it's not undefined\n // and don't add a subscription to itself.\n if (teardown && teardown !== this) {\n if (this.closed) {\n // If this subscription is already closed,\n // execute whatever finalizer is handed to it automatically.\n execFinalizer(teardown);\n } else {\n if (teardown instanceof Subscription) {\n // We don't add closed subscriptions, and we don't add the same subscription\n // twice. Subscription unsubscribe is idempotent.\n if (teardown.closed || teardown._hasParent(this)) {\n return;\n }\n teardown._addParent(this);\n }\n (this._finalizers = this._finalizers ?? []).push(teardown);\n }\n }\n }\n\n /**\n * Checks to see if a this subscription already has a particular parent.\n * This will signal that this subscription has already been added to the parent in question.\n * @param parent the parent to check for\n */\n private _hasParent(parent: Subscription) {\n const { _parentage } = this;\n return _parentage === parent || (Array.isArray(_parentage) && _parentage.includes(parent));\n }\n\n /**\n * Adds a parent to this subscription so it can be removed from the parent if it\n * unsubscribes on it's own.\n *\n * NOTE: THIS ASSUMES THAT {@link _hasParent} HAS ALREADY BEEN CHECKED.\n * @param parent The parent subscription to add\n */\n private _addParent(parent: Subscription) {\n const { _parentage } = this;\n this._parentage = Array.isArray(_parentage) ? (_parentage.push(parent), _parentage) : _parentage ? [_parentage, parent] : parent;\n }\n\n /**\n * Called on a child when it is removed via {@link #remove}.\n * @param parent The parent to remove\n */\n private _removeParent(parent: Subscription) {\n const { _parentage } = this;\n if (_parentage === parent) {\n this._parentage = null;\n } else if (Array.isArray(_parentage)) {\n arrRemove(_parentage, parent);\n }\n }\n\n /**\n * Removes a finalizer from this subscription that was previously added with the {@link #add} method.\n *\n * Note that `Subscription` instances, when unsubscribed, will automatically remove themselves\n * from every other `Subscription` they have been added to. This means that using the `remove` method\n * is not a common thing and should be used thoughtfully.\n *\n * If you add the same finalizer instance of a function or an unsubscribable object to a `Subscription` instance\n * more than once, you will need to call `remove` the same number of times to remove all instances.\n *\n * All finalizer instances are removed to free up memory upon unsubscription.\n *\n * @param teardown The finalizer to remove from this subscription\n */\n remove(teardown: Exclude): void {\n const { _finalizers } = this;\n _finalizers && arrRemove(_finalizers, teardown);\n\n if (teardown instanceof Subscription) {\n teardown._removeParent(this);\n }\n }\n}\n\nexport const EMPTY_SUBSCRIPTION = Subscription.EMPTY;\n\nexport function isSubscription(value: any): value is Subscription {\n return (\n value instanceof Subscription ||\n (value && 'closed' in value && isFunction(value.remove) && isFunction(value.add) && isFunction(value.unsubscribe))\n );\n}\n\nfunction execFinalizer(finalizer: Unsubscribable | (() => void)) {\n if (isFunction(finalizer)) {\n finalizer();\n } else {\n finalizer.unsubscribe();\n }\n}\n", "import { Subscriber } from './Subscriber';\nimport { ObservableNotification } from './types';\n\n/**\n * The {@link GlobalConfig} object for RxJS. It is used to configure things\n * like how to react on unhandled errors.\n */\nexport const config: GlobalConfig = {\n onUnhandledError: null,\n onStoppedNotification: null,\n Promise: undefined,\n useDeprecatedSynchronousErrorHandling: false,\n useDeprecatedNextContext: false,\n};\n\n/**\n * The global configuration object for RxJS, used to configure things\n * like how to react on unhandled errors. Accessible via {@link config}\n * object.\n */\nexport interface GlobalConfig {\n /**\n * A registration point for unhandled errors from RxJS. These are errors that\n * cannot were not handled by consuming code in the usual subscription path. For\n * example, if you have this configured, and you subscribe to an observable without\n * providing an error handler, errors from that subscription will end up here. This\n * will _always_ be called asynchronously on another job in the runtime. This is because\n * we do not want errors thrown in this user-configured handler to interfere with the\n * behavior of the library.\n */\n onUnhandledError: ((err: any) => void) | null;\n\n /**\n * A registration point for notifications that cannot be sent to subscribers because they\n * have completed, errored or have been explicitly unsubscribed. By default, next, complete\n * and error notifications sent to stopped subscribers are noops. However, sometimes callers\n * might want a different behavior. For example, with sources that attempt to report errors\n * to stopped subscribers, a caller can configure RxJS to throw an unhandled error instead.\n * This will _always_ be called asynchronously on another job in the runtime. This is because\n * we do not want errors thrown in this user-configured handler to interfere with the\n * behavior of the library.\n */\n onStoppedNotification: ((notification: ObservableNotification, subscriber: Subscriber) => void) | null;\n\n /**\n * The promise constructor used by default for {@link Observable#toPromise toPromise} and {@link Observable#forEach forEach}\n * methods.\n *\n * @deprecated As of version 8, RxJS will no longer support this sort of injection of a\n * Promise constructor. If you need a Promise implementation other than native promises,\n * please polyfill/patch Promise as you see appropriate. Will be removed in v8.\n */\n Promise?: PromiseConstructorLike;\n\n /**\n * If true, turns on synchronous error rethrowing, which is a deprecated behavior\n * in v6 and higher. This behavior enables bad patterns like wrapping a subscribe\n * call in a try/catch block. It also enables producer interference, a nasty bug\n * where a multicast can be broken for all observers by a downstream consumer with\n * an unhandled error. DO NOT USE THIS FLAG UNLESS IT'S NEEDED TO BUY TIME\n * FOR MIGRATION REASONS.\n *\n * @deprecated As of version 8, RxJS will no longer support synchronous throwing\n * of unhandled errors. All errors will be thrown on a separate call stack to prevent bad\n * behaviors described above. Will be removed in v8.\n */\n useDeprecatedSynchronousErrorHandling: boolean;\n\n /**\n * If true, enables an as-of-yet undocumented feature from v5: The ability to access\n * `unsubscribe()` via `this` context in `next` functions created in observers passed\n * to `subscribe`.\n *\n * This is being removed because the performance was severely problematic, and it could also cause\n * issues when types other than POJOs are passed to subscribe as subscribers, as they will likely have\n * their `this` context overwritten.\n *\n * @deprecated As of version 8, RxJS will no longer support altering the\n * context of next functions provided as part of an observer to Subscribe. Instead,\n * you will have access to a subscription or a signal or token that will allow you to do things like\n * unsubscribe and test closed status. Will be removed in v8.\n */\n useDeprecatedNextContext: boolean;\n}\n", "import type { TimerHandle } from './timerHandle';\ntype SetTimeoutFunction = (handler: () => void, timeout?: number, ...args: any[]) => TimerHandle;\ntype ClearTimeoutFunction = (handle: TimerHandle) => void;\n\ninterface TimeoutProvider {\n setTimeout: SetTimeoutFunction;\n clearTimeout: ClearTimeoutFunction;\n delegate:\n | {\n setTimeout: SetTimeoutFunction;\n clearTimeout: ClearTimeoutFunction;\n }\n | undefined;\n}\n\nexport const timeoutProvider: TimeoutProvider = {\n // When accessing the delegate, use the variable rather than `this` so that\n // the functions can be called without being bound to the provider.\n setTimeout(handler: () => void, timeout?: number, ...args) {\n const { delegate } = timeoutProvider;\n if (delegate?.setTimeout) {\n return delegate.setTimeout(handler, timeout, ...args);\n }\n return setTimeout(handler, timeout, ...args);\n },\n clearTimeout(handle) {\n const { delegate } = timeoutProvider;\n return (delegate?.clearTimeout || clearTimeout)(handle as any);\n },\n delegate: undefined,\n};\n", "import { config } from '../config';\nimport { timeoutProvider } from '../scheduler/timeoutProvider';\n\n/**\n * Handles an error on another job either with the user-configured {@link onUnhandledError},\n * or by throwing it on that new job so it can be picked up by `window.onerror`, `process.on('error')`, etc.\n *\n * This should be called whenever there is an error that is out-of-band with the subscription\n * or when an error hits a terminal boundary of the subscription and no error handler was provided.\n *\n * @param err the error to report\n */\nexport function reportUnhandledError(err: any) {\n timeoutProvider.setTimeout(() => {\n const { onUnhandledError } = config;\n if (onUnhandledError) {\n // Execute the user-configured error handler.\n onUnhandledError(err);\n } else {\n // Throw so it is picked up by the runtime's uncaught error mechanism.\n throw err;\n }\n });\n}\n", "/* tslint:disable:no-empty */\nexport function noop() { }\n", "import { CompleteNotification, NextNotification, ErrorNotification } from './types';\n\n/**\n * A completion object optimized for memory use and created to be the\n * same \"shape\" as other notifications in v8.\n * @internal\n */\nexport const COMPLETE_NOTIFICATION = (() => createNotification('C', undefined, undefined) as CompleteNotification)();\n\n/**\n * Internal use only. Creates an optimized error notification that is the same \"shape\"\n * as other notifications.\n * @internal\n */\nexport function errorNotification(error: any): ErrorNotification {\n return createNotification('E', undefined, error) as any;\n}\n\n/**\n * Internal use only. Creates an optimized next notification that is the same \"shape\"\n * as other notifications.\n * @internal\n */\nexport function nextNotification(value: T) {\n return createNotification('N', value, undefined) as NextNotification;\n}\n\n/**\n * Ensures that all notifications created internally have the same \"shape\" in v8.\n *\n * TODO: This is only exported to support a crazy legacy test in `groupBy`.\n * @internal\n */\nexport function createNotification(kind: 'N' | 'E' | 'C', value: any, error: any) {\n return {\n kind,\n value,\n error,\n };\n}\n", "import { config } from '../config';\n\nlet context: { errorThrown: boolean; error: any } | null = null;\n\n/**\n * Handles dealing with errors for super-gross mode. Creates a context, in which\n * any synchronously thrown errors will be passed to {@link captureError}. Which\n * will record the error such that it will be rethrown after the call back is complete.\n * TODO: Remove in v8\n * @param cb An immediately executed function.\n */\nexport function errorContext(cb: () => void) {\n if (config.useDeprecatedSynchronousErrorHandling) {\n const isRoot = !context;\n if (isRoot) {\n context = { errorThrown: false, error: null };\n }\n cb();\n if (isRoot) {\n const { errorThrown, error } = context!;\n context = null;\n if (errorThrown) {\n throw error;\n }\n }\n } else {\n // This is the general non-deprecated path for everyone that\n // isn't crazy enough to use super-gross mode (useDeprecatedSynchronousErrorHandling)\n cb();\n }\n}\n\n/**\n * Captures errors only in super-gross mode.\n * @param err the error to capture\n */\nexport function captureError(err: any) {\n if (config.useDeprecatedSynchronousErrorHandling && context) {\n context.errorThrown = true;\n context.error = err;\n }\n}\n", "import { isFunction } from './util/isFunction';\nimport { Observer, ObservableNotification } from './types';\nimport { isSubscription, Subscription } from './Subscription';\nimport { config } from './config';\nimport { reportUnhandledError } from './util/reportUnhandledError';\nimport { noop } from './util/noop';\nimport { nextNotification, errorNotification, COMPLETE_NOTIFICATION } from './NotificationFactories';\nimport { timeoutProvider } from './scheduler/timeoutProvider';\nimport { captureError } from './util/errorContext';\n\n/**\n * Implements the {@link Observer} interface and extends the\n * {@link Subscription} class. While the {@link Observer} is the public API for\n * consuming the values of an {@link Observable}, all Observers get converted to\n * a Subscriber, in order to provide Subscription-like capabilities such as\n * `unsubscribe`. Subscriber is a common type in RxJS, and crucial for\n * implementing operators, but it is rarely used as a public API.\n */\nexport class Subscriber extends Subscription implements Observer {\n /**\n * A static factory for a Subscriber, given a (potentially partial) definition\n * of an Observer.\n * @param next The `next` callback of an Observer.\n * @param error The `error` callback of an\n * Observer.\n * @param complete The `complete` callback of an\n * Observer.\n * @return A Subscriber wrapping the (partially defined)\n * Observer represented by the given arguments.\n * @deprecated Do not use. Will be removed in v8. There is no replacement for this\n * method, and there is no reason to be creating instances of `Subscriber` directly.\n * If you have a specific use case, please file an issue.\n */\n static create(next?: (x?: T) => void, error?: (e?: any) => void, complete?: () => void): Subscriber {\n return new SafeSubscriber(next, error, complete);\n }\n\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n protected isStopped: boolean = false;\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n protected destination: Subscriber | Observer; // this `any` is the escape hatch to erase extra type param (e.g. R)\n\n /**\n * @deprecated Internal implementation detail, do not use directly. Will be made internal in v8.\n * There is no reason to directly create an instance of Subscriber. This type is exported for typings reasons.\n */\n constructor(destination?: Subscriber | Observer) {\n super();\n if (destination) {\n this.destination = destination;\n // Automatically chain subscriptions together here.\n // if destination is a Subscription, then it is a Subscriber.\n if (isSubscription(destination)) {\n destination.add(this);\n }\n } else {\n this.destination = EMPTY_OBSERVER;\n }\n }\n\n /**\n * The {@link Observer} callback to receive notifications of type `next` from\n * the Observable, with a value. The Observable may call this method 0 or more\n * times.\n * @param value The `next` value.\n */\n next(value: T): void {\n if (this.isStopped) {\n handleStoppedNotification(nextNotification(value), this);\n } else {\n this._next(value!);\n }\n }\n\n /**\n * The {@link Observer} callback to receive notifications of type `error` from\n * the Observable, with an attached `Error`. Notifies the Observer that\n * the Observable has experienced an error condition.\n * @param err The `error` exception.\n */\n error(err?: any): void {\n if (this.isStopped) {\n handleStoppedNotification(errorNotification(err), this);\n } else {\n this.isStopped = true;\n this._error(err);\n }\n }\n\n /**\n * The {@link Observer} callback to receive a valueless notification of type\n * `complete` from the Observable. Notifies the Observer that the Observable\n * has finished sending push-based notifications.\n */\n complete(): void {\n if (this.isStopped) {\n handleStoppedNotification(COMPLETE_NOTIFICATION, this);\n } else {\n this.isStopped = true;\n this._complete();\n }\n }\n\n unsubscribe(): void {\n if (!this.closed) {\n this.isStopped = true;\n super.unsubscribe();\n this.destination = null!;\n }\n }\n\n protected _next(value: T): void {\n this.destination.next(value);\n }\n\n protected _error(err: any): void {\n try {\n this.destination.error(err);\n } finally {\n this.unsubscribe();\n }\n }\n\n protected _complete(): void {\n try {\n this.destination.complete();\n } finally {\n this.unsubscribe();\n }\n }\n}\n\n/**\n * This bind is captured here because we want to be able to have\n * compatibility with monoid libraries that tend to use a method named\n * `bind`. In particular, a library called Monio requires this.\n */\nconst _bind = Function.prototype.bind;\n\nfunction bind any>(fn: Fn, thisArg: any): Fn {\n return _bind.call(fn, thisArg);\n}\n\n/**\n * Internal optimization only, DO NOT EXPOSE.\n * @internal\n */\nclass ConsumerObserver implements Observer {\n constructor(private partialObserver: Partial>) {}\n\n next(value: T): void {\n const { partialObserver } = this;\n if (partialObserver.next) {\n try {\n partialObserver.next(value);\n } catch (error) {\n handleUnhandledError(error);\n }\n }\n }\n\n error(err: any): void {\n const { partialObserver } = this;\n if (partialObserver.error) {\n try {\n partialObserver.error(err);\n } catch (error) {\n handleUnhandledError(error);\n }\n } else {\n handleUnhandledError(err);\n }\n }\n\n complete(): void {\n const { partialObserver } = this;\n if (partialObserver.complete) {\n try {\n partialObserver.complete();\n } catch (error) {\n handleUnhandledError(error);\n }\n }\n }\n}\n\nexport class SafeSubscriber extends Subscriber {\n constructor(\n observerOrNext?: Partial> | ((value: T) => void) | null,\n error?: ((e?: any) => void) | null,\n complete?: (() => void) | null\n ) {\n super();\n\n let partialObserver: Partial>;\n if (isFunction(observerOrNext) || !observerOrNext) {\n // The first argument is a function, not an observer. The next\n // two arguments *could* be observers, or they could be empty.\n partialObserver = {\n next: (observerOrNext ?? undefined) as ((value: T) => void) | undefined,\n error: error ?? undefined,\n complete: complete ?? undefined,\n };\n } else {\n // The first argument is a partial observer.\n let context: any;\n if (this && config.useDeprecatedNextContext) {\n // This is a deprecated path that made `this.unsubscribe()` available in\n // next handler functions passed to subscribe. This only exists behind a flag\n // now, as it is *very* slow.\n context = Object.create(observerOrNext);\n context.unsubscribe = () => this.unsubscribe();\n partialObserver = {\n next: observerOrNext.next && bind(observerOrNext.next, context),\n error: observerOrNext.error && bind(observerOrNext.error, context),\n complete: observerOrNext.complete && bind(observerOrNext.complete, context),\n };\n } else {\n // The \"normal\" path. Just use the partial observer directly.\n partialObserver = observerOrNext;\n }\n }\n\n // Wrap the partial observer to ensure it's a full observer, and\n // make sure proper error handling is accounted for.\n this.destination = new ConsumerObserver(partialObserver);\n }\n}\n\nfunction handleUnhandledError(error: any) {\n if (config.useDeprecatedSynchronousErrorHandling) {\n captureError(error);\n } else {\n // Ideal path, we report this as an unhandled error,\n // which is thrown on a new call stack.\n reportUnhandledError(error);\n }\n}\n\n/**\n * An error handler used when no error handler was supplied\n * to the SafeSubscriber -- meaning no error handler was supplied\n * do the `subscribe` call on our observable.\n * @param err The error to handle\n */\nfunction defaultErrorHandler(err: any) {\n throw err;\n}\n\n/**\n * A handler for notifications that cannot be sent to a stopped subscriber.\n * @param notification The notification being sent.\n * @param subscriber The stopped subscriber.\n */\nfunction handleStoppedNotification(notification: ObservableNotification, subscriber: Subscriber) {\n const { onStoppedNotification } = config;\n onStoppedNotification && timeoutProvider.setTimeout(() => onStoppedNotification(notification, subscriber));\n}\n\n/**\n * The observer used as a stub for subscriptions where the user did not\n * pass any arguments to `subscribe`. Comes with the default error handling\n * behavior.\n */\nexport const EMPTY_OBSERVER: Readonly> & { closed: true } = {\n closed: true,\n next: noop,\n error: defaultErrorHandler,\n complete: noop,\n};\n", "/**\n * Symbol.observable or a string \"@@observable\". Used for interop\n *\n * @deprecated We will no longer be exporting this symbol in upcoming versions of RxJS.\n * Instead polyfill and use Symbol.observable directly *or* use https://www.npmjs.com/package/symbol-observable\n */\nexport const observable: string | symbol = (() => (typeof Symbol === 'function' && Symbol.observable) || '@@observable')();\n", "/**\n * This function takes one parameter and just returns it. Simply put,\n * this is like `(x: T): T => x`.\n *\n * ## Examples\n *\n * This is useful in some cases when using things like `mergeMap`\n *\n * ```ts\n * import { interval, take, map, range, mergeMap, identity } from 'rxjs';\n *\n * const source$ = interval(1000).pipe(take(5));\n *\n * const result$ = source$.pipe(\n * map(i => range(i)),\n * mergeMap(identity) // same as mergeMap(x => x)\n * );\n *\n * result$.subscribe({\n * next: console.log\n * });\n * ```\n *\n * Or when you want to selectively apply an operator\n *\n * ```ts\n * import { interval, take, identity } from 'rxjs';\n *\n * const shouldLimit = () => Math.random() < 0.5;\n *\n * const source$ = interval(1000);\n *\n * const result$ = source$.pipe(shouldLimit() ? take(5) : identity);\n *\n * result$.subscribe({\n * next: console.log\n * });\n * ```\n *\n * @param x Any value that is returned by this function\n * @returns The value passed as the first parameter to this function\n */\nexport function identity(x: T): T {\n return x;\n}\n", "import { identity } from './identity';\nimport { UnaryFunction } from '../types';\n\nexport function pipe(): typeof identity;\nexport function pipe(fn1: UnaryFunction): UnaryFunction;\nexport function pipe(fn1: UnaryFunction, fn2: UnaryFunction): UnaryFunction;\nexport function pipe(fn1: UnaryFunction, fn2: UnaryFunction, fn3: UnaryFunction): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction\n): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction,\n fn5: UnaryFunction\n): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction,\n fn5: UnaryFunction,\n fn6: UnaryFunction\n): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction,\n fn5: UnaryFunction,\n fn6: UnaryFunction,\n fn7: UnaryFunction\n): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction,\n fn5: UnaryFunction,\n fn6: UnaryFunction,\n fn7: UnaryFunction,\n fn8: UnaryFunction\n): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction,\n fn5: UnaryFunction,\n fn6: UnaryFunction,\n fn7: UnaryFunction,\n fn8: UnaryFunction,\n fn9: UnaryFunction\n): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction,\n fn5: UnaryFunction,\n fn6: UnaryFunction,\n fn7: UnaryFunction,\n fn8: UnaryFunction,\n fn9: UnaryFunction,\n ...fns: UnaryFunction[]\n): UnaryFunction;\n\n/**\n * pipe() can be called on one or more functions, each of which can take one argument (\"UnaryFunction\")\n * and uses it to return a value.\n * It returns a function that takes one argument, passes it to the first UnaryFunction, and then\n * passes the result to the next one, passes that result to the next one, and so on. \n */\nexport function pipe(...fns: Array>): UnaryFunction {\n return pipeFromArray(fns);\n}\n\n/** @internal */\nexport function pipeFromArray(fns: Array>): UnaryFunction {\n if (fns.length === 0) {\n return identity as UnaryFunction;\n }\n\n if (fns.length === 1) {\n return fns[0];\n }\n\n return function piped(input: T): R {\n return fns.reduce((prev: any, fn: UnaryFunction) => fn(prev), input as any);\n };\n}\n", "import { Operator } from './Operator';\nimport { SafeSubscriber, Subscriber } from './Subscriber';\nimport { isSubscription, Subscription } from './Subscription';\nimport { TeardownLogic, OperatorFunction, Subscribable, Observer } from './types';\nimport { observable as Symbol_observable } from './symbol/observable';\nimport { pipeFromArray } from './util/pipe';\nimport { config } from './config';\nimport { isFunction } from './util/isFunction';\nimport { errorContext } from './util/errorContext';\n\n/**\n * A representation of any set of values over any amount of time. This is the most basic building block\n * of RxJS.\n */\nexport class Observable implements Subscribable {\n /**\n * @deprecated Internal implementation detail, do not use directly. Will be made internal in v8.\n */\n source: Observable | undefined;\n\n /**\n * @deprecated Internal implementation detail, do not use directly. Will be made internal in v8.\n */\n operator: Operator | undefined;\n\n /**\n * @param subscribe The function that is called when the Observable is\n * initially subscribed to. This function is given a Subscriber, to which new values\n * can be `next`ed, or an `error` method can be called to raise an error, or\n * `complete` can be called to notify of a successful completion.\n */\n constructor(subscribe?: (this: Observable, subscriber: Subscriber) => TeardownLogic) {\n if (subscribe) {\n this._subscribe = subscribe;\n }\n }\n\n // HACK: Since TypeScript inherits static properties too, we have to\n // fight against TypeScript here so Subject can have a different static create signature\n /**\n * Creates a new Observable by calling the Observable constructor\n * @param subscribe the subscriber function to be passed to the Observable constructor\n * @return A new observable.\n * @deprecated Use `new Observable()` instead. Will be removed in v8.\n */\n static create: (...args: any[]) => any = (subscribe?: (subscriber: Subscriber) => TeardownLogic) => {\n return new Observable(subscribe);\n };\n\n /**\n * Creates a new Observable, with this Observable instance as the source, and the passed\n * operator defined as the new observable's operator.\n * @param operator the operator defining the operation to take on the observable\n * @return A new observable with the Operator applied.\n * @deprecated Internal implementation detail, do not use directly. Will be made internal in v8.\n * If you have implemented an operator using `lift`, it is recommended that you create an\n * operator by simply returning `new Observable()` directly. See \"Creating new operators from\n * scratch\" section here: https://rxjs.dev/guide/operators\n */\n lift(operator?: Operator): Observable {\n const observable = new Observable();\n observable.source = this;\n observable.operator = operator;\n return observable;\n }\n\n subscribe(observerOrNext?: Partial> | ((value: T) => void)): Subscription;\n /** @deprecated Instead of passing separate callback arguments, use an observer argument. Signatures taking separate callback arguments will be removed in v8. Details: https://rxjs.dev/deprecations/subscribe-arguments */\n subscribe(next?: ((value: T) => void) | null, error?: ((error: any) => void) | null, complete?: (() => void) | null): Subscription;\n /**\n * Invokes an execution of an Observable and registers Observer handlers for notifications it will emit.\n *\n * Use it when you have all these Observables, but still nothing is happening.\n *\n * `subscribe` is not a regular operator, but a method that calls Observable's internal `subscribe` function. It\n * might be for example a function that you passed to Observable's constructor, but most of the time it is\n * a library implementation, which defines what will be emitted by an Observable, and when it be will emitted. This means\n * that calling `subscribe` is actually the moment when Observable starts its work, not when it is created, as it is often\n * the thought.\n *\n * Apart from starting the execution of an Observable, this method allows you to listen for values\n * that an Observable emits, as well as for when it completes or errors. You can achieve this in two\n * of the following ways.\n *\n * The first way is creating an object that implements {@link Observer} interface. It should have methods\n * defined by that interface, but note that it should be just a regular JavaScript object, which you can create\n * yourself in any way you want (ES6 class, classic function constructor, object literal etc.). In particular, do\n * not attempt to use any RxJS implementation details to create Observers - you don't need them. Remember also\n * that your object does not have to implement all methods. If you find yourself creating a method that doesn't\n * do anything, you can simply omit it. Note however, if the `error` method is not provided and an error happens,\n * it will be thrown asynchronously. Errors thrown asynchronously cannot be caught using `try`/`catch`. Instead,\n * use the {@link onUnhandledError} configuration option or use a runtime handler (like `window.onerror` or\n * `process.on('error)`) to be notified of unhandled errors. Because of this, it's recommended that you provide\n * an `error` method to avoid missing thrown errors.\n *\n * The second way is to give up on Observer object altogether and simply provide callback functions in place of its methods.\n * This means you can provide three functions as arguments to `subscribe`, where the first function is equivalent\n * of a `next` method, the second of an `error` method and the third of a `complete` method. Just as in case of an Observer,\n * if you do not need to listen for something, you can omit a function by passing `undefined` or `null`,\n * since `subscribe` recognizes these functions by where they were placed in function call. When it comes\n * to the `error` function, as with an Observer, if not provided, errors emitted by an Observable will be thrown asynchronously.\n *\n * You can, however, subscribe with no parameters at all. This may be the case where you're not interested in terminal events\n * and you also handled emissions internally by using operators (e.g. using `tap`).\n *\n * Whichever style of calling `subscribe` you use, in both cases it returns a Subscription object.\n * This object allows you to call `unsubscribe` on it, which in turn will stop the work that an Observable does and will clean\n * up all resources that an Observable used. Note that cancelling a subscription will not call `complete` callback\n * provided to `subscribe` function, which is reserved for a regular completion signal that comes from an Observable.\n *\n * Remember that callbacks provided to `subscribe` are not guaranteed to be called asynchronously.\n * It is an Observable itself that decides when these functions will be called. For example {@link of}\n * by default emits all its values synchronously. Always check documentation for how given Observable\n * will behave when subscribed and if its default behavior can be modified with a `scheduler`.\n *\n * #### Examples\n *\n * Subscribe with an {@link guide/observer Observer}\n *\n * ```ts\n * import { of } from 'rxjs';\n *\n * const sumObserver = {\n * sum: 0,\n * next(value) {\n * console.log('Adding: ' + value);\n * this.sum = this.sum + value;\n * },\n * error() {\n * // We actually could just remove this method,\n * // since we do not really care about errors right now.\n * },\n * complete() {\n * console.log('Sum equals: ' + this.sum);\n * }\n * };\n *\n * of(1, 2, 3) // Synchronously emits 1, 2, 3 and then completes.\n * .subscribe(sumObserver);\n *\n * // Logs:\n * // 'Adding: 1'\n * // 'Adding: 2'\n * // 'Adding: 3'\n * // 'Sum equals: 6'\n * ```\n *\n * Subscribe with functions ({@link deprecations/subscribe-arguments deprecated})\n *\n * ```ts\n * import { of } from 'rxjs'\n *\n * let sum = 0;\n *\n * of(1, 2, 3).subscribe(\n * value => {\n * console.log('Adding: ' + value);\n * sum = sum + value;\n * },\n * undefined,\n * () => console.log('Sum equals: ' + sum)\n * );\n *\n * // Logs:\n * // 'Adding: 1'\n * // 'Adding: 2'\n * // 'Adding: 3'\n * // 'Sum equals: 6'\n * ```\n *\n * Cancel a subscription\n *\n * ```ts\n * import { interval } from 'rxjs';\n *\n * const subscription = interval(1000).subscribe({\n * next(num) {\n * console.log(num)\n * },\n * complete() {\n * // Will not be called, even when cancelling subscription.\n * console.log('completed!');\n * }\n * });\n *\n * setTimeout(() => {\n * subscription.unsubscribe();\n * console.log('unsubscribed!');\n * }, 2500);\n *\n * // Logs:\n * // 0 after 1s\n * // 1 after 2s\n * // 'unsubscribed!' after 2.5s\n * ```\n *\n * @param observerOrNext Either an {@link Observer} with some or all callback methods,\n * or the `next` handler that is called for each value emitted from the subscribed Observable.\n * @param error A handler for a terminal event resulting from an error. If no error handler is provided,\n * the error will be thrown asynchronously as unhandled.\n * @param complete A handler for a terminal event resulting from successful completion.\n * @return A subscription reference to the registered handlers.\n */\n subscribe(\n observerOrNext?: Partial> | ((value: T) => void) | null,\n error?: ((error: any) => void) | null,\n complete?: (() => void) | null\n ): Subscription {\n const subscriber = isSubscriber(observerOrNext) ? observerOrNext : new SafeSubscriber(observerOrNext, error, complete);\n\n errorContext(() => {\n const { operator, source } = this;\n subscriber.add(\n operator\n ? // We're dealing with a subscription in the\n // operator chain to one of our lifted operators.\n operator.call(subscriber, source)\n : source\n ? // If `source` has a value, but `operator` does not, something that\n // had intimate knowledge of our API, like our `Subject`, must have\n // set it. We're going to just call `_subscribe` directly.\n this._subscribe(subscriber)\n : // In all other cases, we're likely wrapping a user-provided initializer\n // function, so we need to catch errors and handle them appropriately.\n this._trySubscribe(subscriber)\n );\n });\n\n return subscriber;\n }\n\n /** @internal */\n protected _trySubscribe(sink: Subscriber): TeardownLogic {\n try {\n return this._subscribe(sink);\n } catch (err) {\n // We don't need to return anything in this case,\n // because it's just going to try to `add()` to a subscription\n // above.\n sink.error(err);\n }\n }\n\n /**\n * Used as a NON-CANCELLABLE means of subscribing to an observable, for use with\n * APIs that expect promises, like `async/await`. You cannot unsubscribe from this.\n *\n * **WARNING**: Only use this with observables you *know* will complete. If the source\n * observable does not complete, you will end up with a promise that is hung up, and\n * potentially all of the state of an async function hanging out in memory. To avoid\n * this situation, look into adding something like {@link timeout}, {@link take},\n * {@link takeWhile}, or {@link takeUntil} amongst others.\n *\n * #### Example\n *\n * ```ts\n * import { interval, take } from 'rxjs';\n *\n * const source$ = interval(1000).pipe(take(4));\n *\n * async function getTotal() {\n * let total = 0;\n *\n * await source$.forEach(value => {\n * total += value;\n * console.log('observable -> ' + value);\n * });\n *\n * return total;\n * }\n *\n * getTotal().then(\n * total => console.log('Total: ' + total)\n * );\n *\n * // Expected:\n * // 'observable -> 0'\n * // 'observable -> 1'\n * // 'observable -> 2'\n * // 'observable -> 3'\n * // 'Total: 6'\n * ```\n *\n * @param next A handler for each value emitted by the observable.\n * @return A promise that either resolves on observable completion or\n * rejects with the handled error.\n */\n forEach(next: (value: T) => void): Promise;\n\n /**\n * @param next a handler for each value emitted by the observable\n * @param promiseCtor a constructor function used to instantiate the Promise\n * @return a promise that either resolves on observable completion or\n * rejects with the handled error\n * @deprecated Passing a Promise constructor will no longer be available\n * in upcoming versions of RxJS. This is because it adds weight to the library, for very\n * little benefit. If you need this functionality, it is recommended that you either\n * polyfill Promise, or you create an adapter to convert the returned native promise\n * to whatever promise implementation you wanted. Will be removed in v8.\n */\n forEach(next: (value: T) => void, promiseCtor: PromiseConstructorLike): Promise;\n\n forEach(next: (value: T) => void, promiseCtor?: PromiseConstructorLike): Promise {\n promiseCtor = getPromiseCtor(promiseCtor);\n\n return new promiseCtor((resolve, reject) => {\n const subscriber = new SafeSubscriber({\n next: (value) => {\n try {\n next(value);\n } catch (err) {\n reject(err);\n subscriber.unsubscribe();\n }\n },\n error: reject,\n complete: resolve,\n });\n this.subscribe(subscriber);\n }) as Promise;\n }\n\n /** @internal */\n protected _subscribe(subscriber: Subscriber): TeardownLogic {\n return this.source?.subscribe(subscriber);\n }\n\n /**\n * An interop point defined by the es7-observable spec https://github.com/zenparsing/es-observable\n * @return This instance of the observable.\n */\n [Symbol_observable]() {\n return this;\n }\n\n /* tslint:disable:max-line-length */\n pipe(): Observable;\n pipe(op1: OperatorFunction): Observable;\n pipe(op1: OperatorFunction, op2: OperatorFunction): Observable;\n pipe(op1: OperatorFunction, op2: OperatorFunction, op3: OperatorFunction): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction\n ): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction,\n op5: OperatorFunction\n ): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction,\n op5: OperatorFunction,\n op6: OperatorFunction\n ): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction,\n op5: OperatorFunction,\n op6: OperatorFunction,\n op7: OperatorFunction\n ): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction,\n op5: OperatorFunction,\n op6: OperatorFunction,\n op7: OperatorFunction,\n op8: OperatorFunction\n ): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction,\n op5: OperatorFunction,\n op6: OperatorFunction,\n op7: OperatorFunction,\n op8: OperatorFunction,\n op9: OperatorFunction\n ): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction,\n op5: OperatorFunction,\n op6: OperatorFunction,\n op7: OperatorFunction,\n op8: OperatorFunction,\n op9: OperatorFunction,\n ...operations: OperatorFunction[]\n ): Observable;\n /* tslint:enable:max-line-length */\n\n /**\n * Used to stitch together functional operators into a chain.\n *\n * ## Example\n *\n * ```ts\n * import { interval, filter, map, scan } from 'rxjs';\n *\n * interval(1000)\n * .pipe(\n * filter(x => x % 2 === 0),\n * map(x => x + x),\n * scan((acc, x) => acc + x)\n * )\n * .subscribe(x => console.log(x));\n * ```\n *\n * @return The Observable result of all the operators having been called\n * in the order they were passed in.\n */\n pipe(...operations: OperatorFunction[]): Observable {\n return pipeFromArray(operations)(this);\n }\n\n /* tslint:disable:max-line-length */\n /** @deprecated Replaced with {@link firstValueFrom} and {@link lastValueFrom}. Will be removed in v8. Details: https://rxjs.dev/deprecations/to-promise */\n toPromise(): Promise;\n /** @deprecated Replaced with {@link firstValueFrom} and {@link lastValueFrom}. Will be removed in v8. Details: https://rxjs.dev/deprecations/to-promise */\n toPromise(PromiseCtor: typeof Promise): Promise;\n /** @deprecated Replaced with {@link firstValueFrom} and {@link lastValueFrom}. Will be removed in v8. Details: https://rxjs.dev/deprecations/to-promise */\n toPromise(PromiseCtor: PromiseConstructorLike): Promise;\n /* tslint:enable:max-line-length */\n\n /**\n * Subscribe to this Observable and get a Promise resolving on\n * `complete` with the last emission (if any).\n *\n * **WARNING**: Only use this with observables you *know* will complete. If the source\n * observable does not complete, you will end up with a promise that is hung up, and\n * potentially all of the state of an async function hanging out in memory. To avoid\n * this situation, look into adding something like {@link timeout}, {@link take},\n * {@link takeWhile}, or {@link takeUntil} amongst others.\n *\n * @param [promiseCtor] a constructor function used to instantiate\n * the Promise\n * @return A Promise that resolves with the last value emit, or\n * rejects on an error. If there were no emissions, Promise\n * resolves with undefined.\n * @deprecated Replaced with {@link firstValueFrom} and {@link lastValueFrom}. Will be removed in v8. Details: https://rxjs.dev/deprecations/to-promise\n */\n toPromise(promiseCtor?: PromiseConstructorLike): Promise {\n promiseCtor = getPromiseCtor(promiseCtor);\n\n return new promiseCtor((resolve, reject) => {\n let value: T | undefined;\n this.subscribe(\n (x: T) => (value = x),\n (err: any) => reject(err),\n () => resolve(value)\n );\n }) as Promise;\n }\n}\n\n/**\n * Decides between a passed promise constructor from consuming code,\n * A default configured promise constructor, and the native promise\n * constructor and returns it. If nothing can be found, it will throw\n * an error.\n * @param promiseCtor The optional promise constructor to passed by consuming code\n */\nfunction getPromiseCtor(promiseCtor: PromiseConstructorLike | undefined) {\n return promiseCtor ?? config.Promise ?? Promise;\n}\n\nfunction isObserver(value: any): value is Observer {\n return value && isFunction(value.next) && isFunction(value.error) && isFunction(value.complete);\n}\n\nfunction isSubscriber(value: any): value is Subscriber {\n return (value && value instanceof Subscriber) || (isObserver(value) && isSubscription(value));\n}\n", "import { Observable } from '../Observable';\nimport { Subscriber } from '../Subscriber';\nimport { OperatorFunction } from '../types';\nimport { isFunction } from './isFunction';\n\n/**\n * Used to determine if an object is an Observable with a lift function.\n */\nexport function hasLift(source: any): source is { lift: InstanceType['lift'] } {\n return isFunction(source?.lift);\n}\n\n/**\n * Creates an `OperatorFunction`. Used to define operators throughout the library in a concise way.\n * @param init The logic to connect the liftedSource to the subscriber at the moment of subscription.\n */\nexport function operate(\n init: (liftedSource: Observable, subscriber: Subscriber) => (() => void) | void\n): OperatorFunction {\n return (source: Observable) => {\n if (hasLift(source)) {\n return source.lift(function (this: Subscriber, liftedSource: Observable) {\n try {\n return init(liftedSource, this);\n } catch (err) {\n this.error(err);\n }\n });\n }\n throw new TypeError('Unable to lift unknown Observable type');\n };\n}\n", "import { Subscriber } from '../Subscriber';\n\n/**\n * Creates an instance of an `OperatorSubscriber`.\n * @param destination The downstream subscriber.\n * @param onNext Handles next values, only called if this subscriber is not stopped or closed. Any\n * error that occurs in this function is caught and sent to the `error` method of this subscriber.\n * @param onError Handles errors from the subscription, any errors that occur in this handler are caught\n * and send to the `destination` error handler.\n * @param onComplete Handles completion notification from the subscription. Any errors that occur in\n * this handler are sent to the `destination` error handler.\n * @param onFinalize Additional teardown logic here. This will only be called on teardown if the\n * subscriber itself is not already closed. This is called after all other teardown logic is executed.\n */\nexport function createOperatorSubscriber(\n destination: Subscriber,\n onNext?: (value: T) => void,\n onComplete?: () => void,\n onError?: (err: any) => void,\n onFinalize?: () => void\n): Subscriber {\n return new OperatorSubscriber(destination, onNext, onComplete, onError, onFinalize);\n}\n\n/**\n * A generic helper for allowing operators to be created with a Subscriber and\n * use closures to capture necessary state from the operator function itself.\n */\nexport class OperatorSubscriber extends Subscriber {\n /**\n * Creates an instance of an `OperatorSubscriber`.\n * @param destination The downstream subscriber.\n * @param onNext Handles next values, only called if this subscriber is not stopped or closed. Any\n * error that occurs in this function is caught and sent to the `error` method of this subscriber.\n * @param onError Handles errors from the subscription, any errors that occur in this handler are caught\n * and send to the `destination` error handler.\n * @param onComplete Handles completion notification from the subscription. Any errors that occur in\n * this handler are sent to the `destination` error handler.\n * @param onFinalize Additional finalization logic here. This will only be called on finalization if the\n * subscriber itself is not already closed. This is called after all other finalization logic is executed.\n * @param shouldUnsubscribe An optional check to see if an unsubscribe call should truly unsubscribe.\n * NOTE: This currently **ONLY** exists to support the strange behavior of {@link groupBy}, where unsubscription\n * to the resulting observable does not actually disconnect from the source if there are active subscriptions\n * to any grouped observable. (DO NOT EXPOSE OR USE EXTERNALLY!!!)\n */\n constructor(\n destination: Subscriber,\n onNext?: (value: T) => void,\n onComplete?: () => void,\n onError?: (err: any) => void,\n private onFinalize?: () => void,\n private shouldUnsubscribe?: () => boolean\n ) {\n // It's important - for performance reasons - that all of this class's\n // members are initialized and that they are always initialized in the same\n // order. This will ensure that all OperatorSubscriber instances have the\n // same hidden class in V8. This, in turn, will help keep the number of\n // hidden classes involved in property accesses within the base class as\n // low as possible. If the number of hidden classes involved exceeds four,\n // the property accesses will become megamorphic and performance penalties\n // will be incurred - i.e. inline caches won't be used.\n //\n // The reasons for ensuring all instances have the same hidden class are\n // further discussed in this blog post from Benedikt Meurer:\n // https://benediktmeurer.de/2018/03/23/impact-of-polymorphism-on-component-based-frameworks-like-react/\n super(destination);\n this._next = onNext\n ? function (this: OperatorSubscriber, value: T) {\n try {\n onNext(value);\n } catch (err) {\n destination.error(err);\n }\n }\n : super._next;\n this._error = onError\n ? function (this: OperatorSubscriber, err: any) {\n try {\n onError(err);\n } catch (err) {\n // Send any errors that occur down stream.\n destination.error(err);\n } finally {\n // Ensure finalization.\n this.unsubscribe();\n }\n }\n : super._error;\n this._complete = onComplete\n ? function (this: OperatorSubscriber) {\n try {\n onComplete();\n } catch (err) {\n // Send any errors that occur down stream.\n destination.error(err);\n } finally {\n // Ensure finalization.\n this.unsubscribe();\n }\n }\n : super._complete;\n }\n\n unsubscribe() {\n if (!this.shouldUnsubscribe || this.shouldUnsubscribe()) {\n const { closed } = this;\n super.unsubscribe();\n // Execute additional teardown if we have any and we didn't already do so.\n !closed && this.onFinalize?.();\n }\n }\n}\n", "import { Subscription } from '../Subscription';\n\ninterface AnimationFrameProvider {\n schedule(callback: FrameRequestCallback): Subscription;\n requestAnimationFrame: typeof requestAnimationFrame;\n cancelAnimationFrame: typeof cancelAnimationFrame;\n delegate:\n | {\n requestAnimationFrame: typeof requestAnimationFrame;\n cancelAnimationFrame: typeof cancelAnimationFrame;\n }\n | undefined;\n}\n\nexport const animationFrameProvider: AnimationFrameProvider = {\n // When accessing the delegate, use the variable rather than `this` so that\n // the functions can be called without being bound to the provider.\n schedule(callback) {\n let request = requestAnimationFrame;\n let cancel: typeof cancelAnimationFrame | undefined = cancelAnimationFrame;\n const { delegate } = animationFrameProvider;\n if (delegate) {\n request = delegate.requestAnimationFrame;\n cancel = delegate.cancelAnimationFrame;\n }\n const handle = request((timestamp) => {\n // Clear the cancel function. The request has been fulfilled, so\n // attempting to cancel the request upon unsubscription would be\n // pointless.\n cancel = undefined;\n callback(timestamp);\n });\n return new Subscription(() => cancel?.(handle));\n },\n requestAnimationFrame(...args) {\n const { delegate } = animationFrameProvider;\n return (delegate?.requestAnimationFrame || requestAnimationFrame)(...args);\n },\n cancelAnimationFrame(...args) {\n const { delegate } = animationFrameProvider;\n return (delegate?.cancelAnimationFrame || cancelAnimationFrame)(...args);\n },\n delegate: undefined,\n};\n", "import { createErrorClass } from './createErrorClass';\n\nexport interface ObjectUnsubscribedError extends Error {}\n\nexport interface ObjectUnsubscribedErrorCtor {\n /**\n * @deprecated Internal implementation detail. Do not construct error instances.\n * Cannot be tagged as internal: https://github.com/ReactiveX/rxjs/issues/6269\n */\n new (): ObjectUnsubscribedError;\n}\n\n/**\n * An error thrown when an action is invalid because the object has been\n * unsubscribed.\n *\n * @see {@link Subject}\n * @see {@link BehaviorSubject}\n *\n * @class ObjectUnsubscribedError\n */\nexport const ObjectUnsubscribedError: ObjectUnsubscribedErrorCtor = createErrorClass(\n (_super) =>\n function ObjectUnsubscribedErrorImpl(this: any) {\n _super(this);\n this.name = 'ObjectUnsubscribedError';\n this.message = 'object unsubscribed';\n }\n);\n", "import { Operator } from './Operator';\nimport { Observable } from './Observable';\nimport { Subscriber } from './Subscriber';\nimport { Subscription, EMPTY_SUBSCRIPTION } from './Subscription';\nimport { Observer, SubscriptionLike, TeardownLogic } from './types';\nimport { ObjectUnsubscribedError } from './util/ObjectUnsubscribedError';\nimport { arrRemove } from './util/arrRemove';\nimport { errorContext } from './util/errorContext';\n\n/**\n * A Subject is a special type of Observable that allows values to be\n * multicasted to many Observers. Subjects are like EventEmitters.\n *\n * Every Subject is an Observable and an Observer. You can subscribe to a\n * Subject, and you can call next to feed values as well as error and complete.\n */\nexport class Subject extends Observable implements SubscriptionLike {\n closed = false;\n\n private currentObservers: Observer[] | null = null;\n\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n observers: Observer[] = [];\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n isStopped = false;\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n hasError = false;\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n thrownError: any = null;\n\n /**\n * Creates a \"subject\" by basically gluing an observer to an observable.\n *\n * @deprecated Recommended you do not use. Will be removed at some point in the future. Plans for replacement still under discussion.\n */\n static create: (...args: any[]) => any = (destination: Observer, source: Observable): AnonymousSubject => {\n return new AnonymousSubject(destination, source);\n };\n\n constructor() {\n // NOTE: This must be here to obscure Observable's constructor.\n super();\n }\n\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n lift(operator: Operator): Observable {\n const subject = new AnonymousSubject(this, this);\n subject.operator = operator as any;\n return subject as any;\n }\n\n /** @internal */\n protected _throwIfClosed() {\n if (this.closed) {\n throw new ObjectUnsubscribedError();\n }\n }\n\n next(value: T) {\n errorContext(() => {\n this._throwIfClosed();\n if (!this.isStopped) {\n if (!this.currentObservers) {\n this.currentObservers = Array.from(this.observers);\n }\n for (const observer of this.currentObservers) {\n observer.next(value);\n }\n }\n });\n }\n\n error(err: any) {\n errorContext(() => {\n this._throwIfClosed();\n if (!this.isStopped) {\n this.hasError = this.isStopped = true;\n this.thrownError = err;\n const { observers } = this;\n while (observers.length) {\n observers.shift()!.error(err);\n }\n }\n });\n }\n\n complete() {\n errorContext(() => {\n this._throwIfClosed();\n if (!this.isStopped) {\n this.isStopped = true;\n const { observers } = this;\n while (observers.length) {\n observers.shift()!.complete();\n }\n }\n });\n }\n\n unsubscribe() {\n this.isStopped = this.closed = true;\n this.observers = this.currentObservers = null!;\n }\n\n get observed() {\n return this.observers?.length > 0;\n }\n\n /** @internal */\n protected _trySubscribe(subscriber: Subscriber): TeardownLogic {\n this._throwIfClosed();\n return super._trySubscribe(subscriber);\n }\n\n /** @internal */\n protected _subscribe(subscriber: Subscriber): Subscription {\n this._throwIfClosed();\n this._checkFinalizedStatuses(subscriber);\n return this._innerSubscribe(subscriber);\n }\n\n /** @internal */\n protected _innerSubscribe(subscriber: Subscriber) {\n const { hasError, isStopped, observers } = this;\n if (hasError || isStopped) {\n return EMPTY_SUBSCRIPTION;\n }\n this.currentObservers = null;\n observers.push(subscriber);\n return new Subscription(() => {\n this.currentObservers = null;\n arrRemove(observers, subscriber);\n });\n }\n\n /** @internal */\n protected _checkFinalizedStatuses(subscriber: Subscriber) {\n const { hasError, thrownError, isStopped } = this;\n if (hasError) {\n subscriber.error(thrownError);\n } else if (isStopped) {\n subscriber.complete();\n }\n }\n\n /**\n * Creates a new Observable with this Subject as the source. You can do this\n * to create custom Observer-side logic of the Subject and conceal it from\n * code that uses the Observable.\n * @return Observable that this Subject casts to.\n */\n asObservable(): Observable {\n const observable: any = new Observable();\n observable.source = this;\n return observable;\n }\n}\n\nexport class AnonymousSubject extends Subject {\n constructor(\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n public destination?: Observer,\n source?: Observable\n ) {\n super();\n this.source = source;\n }\n\n next(value: T) {\n this.destination?.next?.(value);\n }\n\n error(err: any) {\n this.destination?.error?.(err);\n }\n\n complete() {\n this.destination?.complete?.();\n }\n\n /** @internal */\n protected _subscribe(subscriber: Subscriber): Subscription {\n return this.source?.subscribe(subscriber) ?? EMPTY_SUBSCRIPTION;\n }\n}\n", "import { Subject } from './Subject';\nimport { Subscriber } from './Subscriber';\nimport { Subscription } from './Subscription';\n\n/**\n * A variant of Subject that requires an initial value and emits its current\n * value whenever it is subscribed to.\n */\nexport class BehaviorSubject extends Subject {\n constructor(private _value: T) {\n super();\n }\n\n get value(): T {\n return this.getValue();\n }\n\n /** @internal */\n protected _subscribe(subscriber: Subscriber): Subscription {\n const subscription = super._subscribe(subscriber);\n !subscription.closed && subscriber.next(this._value);\n return subscription;\n }\n\n getValue(): T {\n const { hasError, thrownError, _value } = this;\n if (hasError) {\n throw thrownError;\n }\n this._throwIfClosed();\n return _value;\n }\n\n next(value: T): void {\n super.next((this._value = value));\n }\n}\n", "import { TimestampProvider } from '../types';\n\ninterface DateTimestampProvider extends TimestampProvider {\n delegate: TimestampProvider | undefined;\n}\n\nexport const dateTimestampProvider: DateTimestampProvider = {\n now() {\n // Use the variable rather than `this` so that the function can be called\n // without being bound to the provider.\n return (dateTimestampProvider.delegate || Date).now();\n },\n delegate: undefined,\n};\n", "import { Subject } from './Subject';\nimport { TimestampProvider } from './types';\nimport { Subscriber } from './Subscriber';\nimport { Subscription } from './Subscription';\nimport { dateTimestampProvider } from './scheduler/dateTimestampProvider';\n\n/**\n * A variant of {@link Subject} that \"replays\" old values to new subscribers by emitting them when they first subscribe.\n *\n * `ReplaySubject` has an internal buffer that will store a specified number of values that it has observed. Like `Subject`,\n * `ReplaySubject` \"observes\" values by having them passed to its `next` method. When it observes a value, it will store that\n * value for a time determined by the configuration of the `ReplaySubject`, as passed to its constructor.\n *\n * When a new subscriber subscribes to the `ReplaySubject` instance, it will synchronously emit all values in its buffer in\n * a First-In-First-Out (FIFO) manner. The `ReplaySubject` will also complete, if it has observed completion; and it will\n * error if it has observed an error.\n *\n * There are two main configuration items to be concerned with:\n *\n * 1. `bufferSize` - This will determine how many items are stored in the buffer, defaults to infinite.\n * 2. `windowTime` - The amount of time to hold a value in the buffer before removing it from the buffer.\n *\n * Both configurations may exist simultaneously. So if you would like to buffer a maximum of 3 values, as long as the values\n * are less than 2 seconds old, you could do so with a `new ReplaySubject(3, 2000)`.\n *\n * ### Differences with BehaviorSubject\n *\n * `BehaviorSubject` is similar to `new ReplaySubject(1)`, with a couple of exceptions:\n *\n * 1. `BehaviorSubject` comes \"primed\" with a single value upon construction.\n * 2. `ReplaySubject` will replay values, even after observing an error, where `BehaviorSubject` will not.\n *\n * @see {@link Subject}\n * @see {@link BehaviorSubject}\n * @see {@link shareReplay}\n */\nexport class ReplaySubject extends Subject {\n private _buffer: (T | number)[] = [];\n private _infiniteTimeWindow = true;\n\n /**\n * @param _bufferSize The size of the buffer to replay on subscription\n * @param _windowTime The amount of time the buffered items will stay buffered\n * @param _timestampProvider An object with a `now()` method that provides the current timestamp. This is used to\n * calculate the amount of time something has been buffered.\n */\n constructor(\n private _bufferSize = Infinity,\n private _windowTime = Infinity,\n private _timestampProvider: TimestampProvider = dateTimestampProvider\n ) {\n super();\n this._infiniteTimeWindow = _windowTime === Infinity;\n this._bufferSize = Math.max(1, _bufferSize);\n this._windowTime = Math.max(1, _windowTime);\n }\n\n next(value: T): void {\n const { isStopped, _buffer, _infiniteTimeWindow, _timestampProvider, _windowTime } = this;\n if (!isStopped) {\n _buffer.push(value);\n !_infiniteTimeWindow && _buffer.push(_timestampProvider.now() + _windowTime);\n }\n this._trimBuffer();\n super.next(value);\n }\n\n /** @internal */\n protected _subscribe(subscriber: Subscriber): Subscription {\n this._throwIfClosed();\n this._trimBuffer();\n\n const subscription = this._innerSubscribe(subscriber);\n\n const { _infiniteTimeWindow, _buffer } = this;\n // We use a copy here, so reentrant code does not mutate our array while we're\n // emitting it to a new subscriber.\n const copy = _buffer.slice();\n for (let i = 0; i < copy.length && !subscriber.closed; i += _infiniteTimeWindow ? 1 : 2) {\n subscriber.next(copy[i] as T);\n }\n\n this._checkFinalizedStatuses(subscriber);\n\n return subscription;\n }\n\n private _trimBuffer() {\n const { _bufferSize, _timestampProvider, _buffer, _infiniteTimeWindow } = this;\n // If we don't have an infinite buffer size, and we're over the length,\n // use splice to truncate the old buffer values off. Note that we have to\n // double the size for instances where we're not using an infinite time window\n // because we're storing the values and the timestamps in the same array.\n const adjustedBufferSize = (_infiniteTimeWindow ? 1 : 2) * _bufferSize;\n _bufferSize < Infinity && adjustedBufferSize < _buffer.length && _buffer.splice(0, _buffer.length - adjustedBufferSize);\n\n // Now, if we're not in an infinite time window, remove all values where the time is\n // older than what is allowed.\n if (!_infiniteTimeWindow) {\n const now = _timestampProvider.now();\n let last = 0;\n // Search the array for the first timestamp that isn't expired and\n // truncate the buffer up to that point.\n for (let i = 1; i < _buffer.length && (_buffer[i] as number) <= now; i += 2) {\n last = i;\n }\n last && _buffer.splice(0, last + 1);\n }\n }\n}\n", "import { Scheduler } from '../Scheduler';\nimport { Subscription } from '../Subscription';\nimport { SchedulerAction } from '../types';\n\n/**\n * A unit of work to be executed in a `scheduler`. An action is typically\n * created from within a {@link SchedulerLike} and an RxJS user does not need to concern\n * themselves about creating and manipulating an Action.\n *\n * ```ts\n * class Action extends Subscription {\n * new (scheduler: Scheduler, work: (state?: T) => void);\n * schedule(state?: T, delay: number = 0): Subscription;\n * }\n * ```\n */\nexport class Action extends Subscription {\n constructor(scheduler: Scheduler, work: (this: SchedulerAction, state?: T) => void) {\n super();\n }\n /**\n * Schedules this action on its parent {@link SchedulerLike} for execution. May be passed\n * some context object, `state`. May happen at some point in the future,\n * according to the `delay` parameter, if specified.\n * @param state Some contextual data that the `work` function uses when called by the\n * Scheduler.\n * @param delay Time to wait before executing the work, where the time unit is implicit\n * and defined by the Scheduler.\n * @return A subscription in order to be able to unsubscribe the scheduled work.\n */\n public schedule(state?: T, delay: number = 0): Subscription {\n return this;\n }\n}\n", "import type { TimerHandle } from './timerHandle';\ntype SetIntervalFunction = (handler: () => void, timeout?: number, ...args: any[]) => TimerHandle;\ntype ClearIntervalFunction = (handle: TimerHandle) => void;\n\ninterface IntervalProvider {\n setInterval: SetIntervalFunction;\n clearInterval: ClearIntervalFunction;\n delegate:\n | {\n setInterval: SetIntervalFunction;\n clearInterval: ClearIntervalFunction;\n }\n | undefined;\n}\n\nexport const intervalProvider: IntervalProvider = {\n // When accessing the delegate, use the variable rather than `this` so that\n // the functions can be called without being bound to the provider.\n setInterval(handler: () => void, timeout?: number, ...args) {\n const { delegate } = intervalProvider;\n if (delegate?.setInterval) {\n return delegate.setInterval(handler, timeout, ...args);\n }\n return setInterval(handler, timeout, ...args);\n },\n clearInterval(handle) {\n const { delegate } = intervalProvider;\n return (delegate?.clearInterval || clearInterval)(handle as any);\n },\n delegate: undefined,\n};\n", "import { Action } from './Action';\nimport { SchedulerAction } from '../types';\nimport { Subscription } from '../Subscription';\nimport { AsyncScheduler } from './AsyncScheduler';\nimport { intervalProvider } from './intervalProvider';\nimport { arrRemove } from '../util/arrRemove';\nimport { TimerHandle } from './timerHandle';\n\nexport class AsyncAction extends Action {\n public id: TimerHandle | undefined;\n public state?: T;\n // @ts-ignore: Property has no initializer and is not definitely assigned\n public delay: number;\n protected pending: boolean = false;\n\n constructor(protected scheduler: AsyncScheduler, protected work: (this: SchedulerAction, state?: T) => void) {\n super(scheduler, work);\n }\n\n public schedule(state?: T, delay: number = 0): Subscription {\n if (this.closed) {\n return this;\n }\n\n // Always replace the current state with the new state.\n this.state = state;\n\n const id = this.id;\n const scheduler = this.scheduler;\n\n //\n // Important implementation note:\n //\n // Actions only execute once by default, unless rescheduled from within the\n // scheduled callback. This allows us to implement single and repeat\n // actions via the same code path, without adding API surface area, as well\n // as mimic traditional recursion but across asynchronous boundaries.\n //\n // However, JS runtimes and timers distinguish between intervals achieved by\n // serial `setTimeout` calls vs. a single `setInterval` call. An interval of\n // serial `setTimeout` calls can be individually delayed, which delays\n // scheduling the next `setTimeout`, and so on. `setInterval` attempts to\n // guarantee the interval callback will be invoked more precisely to the\n // interval period, regardless of load.\n //\n // Therefore, we use `setInterval` to schedule single and repeat actions.\n // If the action reschedules itself with the same delay, the interval is not\n // canceled. If the action doesn't reschedule, or reschedules with a\n // different delay, the interval will be canceled after scheduled callback\n // execution.\n //\n if (id != null) {\n this.id = this.recycleAsyncId(scheduler, id, delay);\n }\n\n // Set the pending flag indicating that this action has been scheduled, or\n // has recursively rescheduled itself.\n this.pending = true;\n\n this.delay = delay;\n // If this action has already an async Id, don't request a new one.\n this.id = this.id ?? this.requestAsyncId(scheduler, this.id, delay);\n\n return this;\n }\n\n protected requestAsyncId(scheduler: AsyncScheduler, _id?: TimerHandle, delay: number = 0): TimerHandle {\n return intervalProvider.setInterval(scheduler.flush.bind(scheduler, this), delay);\n }\n\n protected recycleAsyncId(_scheduler: AsyncScheduler, id?: TimerHandle, delay: number | null = 0): TimerHandle | undefined {\n // If this action is rescheduled with the same delay time, don't clear the interval id.\n if (delay != null && this.delay === delay && this.pending === false) {\n return id;\n }\n // Otherwise, if the action's delay time is different from the current delay,\n // or the action has been rescheduled before it's executed, clear the interval id\n if (id != null) {\n intervalProvider.clearInterval(id);\n }\n\n return undefined;\n }\n\n /**\n * Immediately executes this action and the `work` it contains.\n */\n public execute(state: T, delay: number): any {\n if (this.closed) {\n return new Error('executing a cancelled action');\n }\n\n this.pending = false;\n const error = this._execute(state, delay);\n if (error) {\n return error;\n } else if (this.pending === false && this.id != null) {\n // Dequeue if the action didn't reschedule itself. Don't call\n // unsubscribe(), because the action could reschedule later.\n // For example:\n // ```\n // scheduler.schedule(function doWork(counter) {\n // /* ... I'm a busy worker bee ... */\n // var originalAction = this;\n // /* wait 100ms before rescheduling the action */\n // setTimeout(function () {\n // originalAction.schedule(counter + 1);\n // }, 100);\n // }, 1000);\n // ```\n this.id = this.recycleAsyncId(this.scheduler, this.id, null);\n }\n }\n\n protected _execute(state: T, _delay: number): any {\n let errored: boolean = false;\n let errorValue: any;\n try {\n this.work(state);\n } catch (e) {\n errored = true;\n // HACK: Since code elsewhere is relying on the \"truthiness\" of the\n // return here, we can't have it return \"\" or 0 or false.\n // TODO: Clean this up when we refactor schedulers mid-version-8 or so.\n errorValue = e ? e : new Error('Scheduled action threw falsy error');\n }\n if (errored) {\n this.unsubscribe();\n return errorValue;\n }\n }\n\n unsubscribe() {\n if (!this.closed) {\n const { id, scheduler } = this;\n const { actions } = scheduler;\n\n this.work = this.state = this.scheduler = null!;\n this.pending = false;\n\n arrRemove(actions, this);\n if (id != null) {\n this.id = this.recycleAsyncId(scheduler, id, null);\n }\n\n this.delay = null!;\n super.unsubscribe();\n }\n }\n}\n", "import { Action } from './scheduler/Action';\nimport { Subscription } from './Subscription';\nimport { SchedulerLike, SchedulerAction } from './types';\nimport { dateTimestampProvider } from './scheduler/dateTimestampProvider';\n\n/**\n * An execution context and a data structure to order tasks and schedule their\n * execution. Provides a notion of (potentially virtual) time, through the\n * `now()` getter method.\n *\n * Each unit of work in a Scheduler is called an `Action`.\n *\n * ```ts\n * class Scheduler {\n * now(): number;\n * schedule(work, delay?, state?): Subscription;\n * }\n * ```\n *\n * @deprecated Scheduler is an internal implementation detail of RxJS, and\n * should not be used directly. Rather, create your own class and implement\n * {@link SchedulerLike}. Will be made internal in v8.\n */\nexport class Scheduler implements SchedulerLike {\n public static now: () => number = dateTimestampProvider.now;\n\n constructor(private schedulerActionCtor: typeof Action, now: () => number = Scheduler.now) {\n this.now = now;\n }\n\n /**\n * A getter method that returns a number representing the current time\n * (at the time this function was called) according to the scheduler's own\n * internal clock.\n * @return A number that represents the current time. May or may not\n * have a relation to wall-clock time. May or may not refer to a time unit\n * (e.g. milliseconds).\n */\n public now: () => number;\n\n /**\n * Schedules a function, `work`, for execution. May happen at some point in\n * the future, according to the `delay` parameter, if specified. May be passed\n * some context object, `state`, which will be passed to the `work` function.\n *\n * The given arguments will be processed an stored as an Action object in a\n * queue of actions.\n *\n * @param work A function representing a task, or some unit of work to be\n * executed by the Scheduler.\n * @param delay Time to wait before executing the work, where the time unit is\n * implicit and defined by the Scheduler itself.\n * @param state Some contextual data that the `work` function uses when called\n * by the Scheduler.\n * @return A subscription in order to be able to unsubscribe the scheduled work.\n */\n public schedule(work: (this: SchedulerAction, state?: T) => void, delay: number = 0, state?: T): Subscription {\n return new this.schedulerActionCtor(this, work).schedule(state, delay);\n }\n}\n", "import { Scheduler } from '../Scheduler';\nimport { Action } from './Action';\nimport { AsyncAction } from './AsyncAction';\nimport { TimerHandle } from './timerHandle';\n\nexport class AsyncScheduler extends Scheduler {\n public actions: Array> = [];\n /**\n * A flag to indicate whether the Scheduler is currently executing a batch of\n * queued actions.\n * @internal\n */\n public _active: boolean = false;\n /**\n * An internal ID used to track the latest asynchronous task such as those\n * coming from `setTimeout`, `setInterval`, `requestAnimationFrame`, and\n * others.\n * @internal\n */\n public _scheduled: TimerHandle | undefined;\n\n constructor(SchedulerAction: typeof Action, now: () => number = Scheduler.now) {\n super(SchedulerAction, now);\n }\n\n public flush(action: AsyncAction): void {\n const { actions } = this;\n\n if (this._active) {\n actions.push(action);\n return;\n }\n\n let error: any;\n this._active = true;\n\n do {\n if ((error = action.execute(action.state, action.delay))) {\n break;\n }\n } while ((action = actions.shift()!)); // exhaust the scheduler queue\n\n this._active = false;\n\n if (error) {\n while ((action = actions.shift()!)) {\n action.unsubscribe();\n }\n throw error;\n }\n }\n}\n", "import { AsyncAction } from './AsyncAction';\nimport { AsyncScheduler } from './AsyncScheduler';\n\n/**\n *\n * Async Scheduler\n *\n * Schedule task as if you used setTimeout(task, duration)\n *\n * `async` scheduler schedules tasks asynchronously, by putting them on the JavaScript\n * event loop queue. It is best used to delay tasks in time or to schedule tasks repeating\n * in intervals.\n *\n * If you just want to \"defer\" task, that is to perform it right after currently\n * executing synchronous code ends (commonly achieved by `setTimeout(deferredTask, 0)`),\n * better choice will be the {@link asapScheduler} scheduler.\n *\n * ## Examples\n * Use async scheduler to delay task\n * ```ts\n * import { asyncScheduler } from 'rxjs';\n *\n * const task = () => console.log('it works!');\n *\n * asyncScheduler.schedule(task, 2000);\n *\n * // After 2 seconds logs:\n * // \"it works!\"\n * ```\n *\n * Use async scheduler to repeat task in intervals\n * ```ts\n * import { asyncScheduler } from 'rxjs';\n *\n * function task(state) {\n * console.log(state);\n * this.schedule(state + 1, 1000); // `this` references currently executing Action,\n * // which we reschedule with new state and delay\n * }\n *\n * asyncScheduler.schedule(task, 3000, 0);\n *\n * // Logs:\n * // 0 after 3s\n * // 1 after 4s\n * // 2 after 5s\n * // 3 after 6s\n * ```\n */\n\nexport const asyncScheduler = new AsyncScheduler(AsyncAction);\n\n/**\n * @deprecated Renamed to {@link asyncScheduler}. Will be removed in v8.\n */\nexport const async = asyncScheduler;\n", "import { AsyncAction } from './AsyncAction';\nimport { Subscription } from '../Subscription';\nimport { QueueScheduler } from './QueueScheduler';\nimport { SchedulerAction } from '../types';\nimport { TimerHandle } from './timerHandle';\n\nexport class QueueAction extends AsyncAction {\n constructor(protected scheduler: QueueScheduler, protected work: (this: SchedulerAction, state?: T) => void) {\n super(scheduler, work);\n }\n\n public schedule(state?: T, delay: number = 0): Subscription {\n if (delay > 0) {\n return super.schedule(state, delay);\n }\n this.delay = delay;\n this.state = state;\n this.scheduler.flush(this);\n return this;\n }\n\n public execute(state: T, delay: number): any {\n return delay > 0 || this.closed ? super.execute(state, delay) : this._execute(state, delay);\n }\n\n protected requestAsyncId(scheduler: QueueScheduler, id?: TimerHandle, delay: number = 0): TimerHandle {\n // If delay exists and is greater than 0, or if the delay is null (the\n // action wasn't rescheduled) but was originally scheduled as an async\n // action, then recycle as an async action.\n\n if ((delay != null && delay > 0) || (delay == null && this.delay > 0)) {\n return super.requestAsyncId(scheduler, id, delay);\n }\n\n // Otherwise flush the scheduler starting with this action.\n scheduler.flush(this);\n\n // HACK: In the past, this was returning `void`. However, `void` isn't a valid\n // `TimerHandle`, and generally the return value here isn't really used. So the\n // compromise is to return `0` which is both \"falsy\" and a valid `TimerHandle`,\n // as opposed to refactoring every other instanceo of `requestAsyncId`.\n return 0;\n }\n}\n", "import { AsyncScheduler } from './AsyncScheduler';\n\nexport class QueueScheduler extends AsyncScheduler {\n}\n", "import { QueueAction } from './QueueAction';\nimport { QueueScheduler } from './QueueScheduler';\n\n/**\n *\n * Queue Scheduler\n *\n * Put every next task on a queue, instead of executing it immediately\n *\n * `queue` scheduler, when used with delay, behaves the same as {@link asyncScheduler} scheduler.\n *\n * When used without delay, it schedules given task synchronously - executes it right when\n * it is scheduled. However when called recursively, that is when inside the scheduled task,\n * another task is scheduled with queue scheduler, instead of executing immediately as well,\n * that task will be put on a queue and wait for current one to finish.\n *\n * This means that when you execute task with `queue` scheduler, you are sure it will end\n * before any other task scheduled with that scheduler will start.\n *\n * ## Examples\n * Schedule recursively first, then do something\n * ```ts\n * import { queueScheduler } from 'rxjs';\n *\n * queueScheduler.schedule(() => {\n * queueScheduler.schedule(() => console.log('second')); // will not happen now, but will be put on a queue\n *\n * console.log('first');\n * });\n *\n * // Logs:\n * // \"first\"\n * // \"second\"\n * ```\n *\n * Reschedule itself recursively\n * ```ts\n * import { queueScheduler } from 'rxjs';\n *\n * queueScheduler.schedule(function(state) {\n * if (state !== 0) {\n * console.log('before', state);\n * this.schedule(state - 1); // `this` references currently executing Action,\n * // which we reschedule with new state\n * console.log('after', state);\n * }\n * }, 0, 3);\n *\n * // In scheduler that runs recursively, you would expect:\n * // \"before\", 3\n * // \"before\", 2\n * // \"before\", 1\n * // \"after\", 1\n * // \"after\", 2\n * // \"after\", 3\n *\n * // But with queue it logs:\n * // \"before\", 3\n * // \"after\", 3\n * // \"before\", 2\n * // \"after\", 2\n * // \"before\", 1\n * // \"after\", 1\n * ```\n */\n\nexport const queueScheduler = new QueueScheduler(QueueAction);\n\n/**\n * @deprecated Renamed to {@link queueScheduler}. Will be removed in v8.\n */\nexport const queue = queueScheduler;\n", "import { AsyncAction } from './AsyncAction';\nimport { AnimationFrameScheduler } from './AnimationFrameScheduler';\nimport { SchedulerAction } from '../types';\nimport { animationFrameProvider } from './animationFrameProvider';\nimport { TimerHandle } from './timerHandle';\n\nexport class AnimationFrameAction extends AsyncAction {\n constructor(protected scheduler: AnimationFrameScheduler, protected work: (this: SchedulerAction, state?: T) => void) {\n super(scheduler, work);\n }\n\n protected requestAsyncId(scheduler: AnimationFrameScheduler, id?: TimerHandle, delay: number = 0): TimerHandle {\n // If delay is greater than 0, request as an async action.\n if (delay !== null && delay > 0) {\n return super.requestAsyncId(scheduler, id, delay);\n }\n // Push the action to the end of the scheduler queue.\n scheduler.actions.push(this);\n // If an animation frame has already been requested, don't request another\n // one. If an animation frame hasn't been requested yet, request one. Return\n // the current animation frame request id.\n return scheduler._scheduled || (scheduler._scheduled = animationFrameProvider.requestAnimationFrame(() => scheduler.flush(undefined)));\n }\n\n protected recycleAsyncId(scheduler: AnimationFrameScheduler, id?: TimerHandle, delay: number = 0): TimerHandle | undefined {\n // If delay exists and is greater than 0, or if the delay is null (the\n // action wasn't rescheduled) but was originally scheduled as an async\n // action, then recycle as an async action.\n if (delay != null ? delay > 0 : this.delay > 0) {\n return super.recycleAsyncId(scheduler, id, delay);\n }\n // If the scheduler queue has no remaining actions with the same async id,\n // cancel the requested animation frame and set the scheduled flag to\n // undefined so the next AnimationFrameAction will request its own.\n const { actions } = scheduler;\n if (id != null && id === scheduler._scheduled && actions[actions.length - 1]?.id !== id) {\n animationFrameProvider.cancelAnimationFrame(id as number);\n scheduler._scheduled = undefined;\n }\n // Return undefined so the action knows to request a new async id if it's rescheduled.\n return undefined;\n }\n}\n", "import { AsyncAction } from './AsyncAction';\nimport { AsyncScheduler } from './AsyncScheduler';\n\nexport class AnimationFrameScheduler extends AsyncScheduler {\n public flush(action?: AsyncAction): void {\n this._active = true;\n // The async id that effects a call to flush is stored in _scheduled.\n // Before executing an action, it's necessary to check the action's async\n // id to determine whether it's supposed to be executed in the current\n // flush.\n // Previous implementations of this method used a count to determine this,\n // but that was unsound, as actions that are unsubscribed - i.e. cancelled -\n // are removed from the actions array and that can shift actions that are\n // scheduled to be executed in a subsequent flush into positions at which\n // they are executed within the current flush.\n let flushId;\n if (action) {\n flushId = action.id;\n } else {\n flushId = this._scheduled;\n this._scheduled = undefined;\n }\n\n const { actions } = this;\n let error: any;\n action = action || actions.shift()!;\n\n do {\n if ((error = action.execute(action.state, action.delay))) {\n break;\n }\n } while ((action = actions[0]) && action.id === flushId && actions.shift());\n\n this._active = false;\n\n if (error) {\n while ((action = actions[0]) && action.id === flushId && actions.shift()) {\n action.unsubscribe();\n }\n throw error;\n }\n }\n}\n", "import { AnimationFrameAction } from './AnimationFrameAction';\nimport { AnimationFrameScheduler } from './AnimationFrameScheduler';\n\n/**\n *\n * Animation Frame Scheduler\n *\n * Perform task when `window.requestAnimationFrame` would fire\n *\n * When `animationFrame` scheduler is used with delay, it will fall back to {@link asyncScheduler} scheduler\n * behaviour.\n *\n * Without delay, `animationFrame` scheduler can be used to create smooth browser animations.\n * It makes sure scheduled task will happen just before next browser content repaint,\n * thus performing animations as efficiently as possible.\n *\n * ## Example\n * Schedule div height animation\n * ```ts\n * // html:
\n * import { animationFrameScheduler } from 'rxjs';\n *\n * const div = document.querySelector('div');\n *\n * animationFrameScheduler.schedule(function(height) {\n * div.style.height = height + \"px\";\n *\n * this.schedule(height + 1); // `this` references currently executing Action,\n * // which we reschedule with new state\n * }, 0, 0);\n *\n * // You will see a div element growing in height\n * ```\n */\n\nexport const animationFrameScheduler = new AnimationFrameScheduler(AnimationFrameAction);\n\n/**\n * @deprecated Renamed to {@link animationFrameScheduler}. Will be removed in v8.\n */\nexport const animationFrame = animationFrameScheduler;\n", "import { Observable } from '../Observable';\nimport { SchedulerLike } from '../types';\n\n/**\n * A simple Observable that emits no items to the Observer and immediately\n * emits a complete notification.\n *\n * Just emits 'complete', and nothing else.\n *\n * ![](empty.png)\n *\n * A simple Observable that only emits the complete notification. It can be used\n * for composing with other Observables, such as in a {@link mergeMap}.\n *\n * ## Examples\n *\n * Log complete notification\n *\n * ```ts\n * import { EMPTY } from 'rxjs';\n *\n * EMPTY.subscribe({\n * next: () => console.log('Next'),\n * complete: () => console.log('Complete!')\n * });\n *\n * // Outputs\n * // Complete!\n * ```\n *\n * Emit the number 7, then complete\n *\n * ```ts\n * import { EMPTY, startWith } from 'rxjs';\n *\n * const result = EMPTY.pipe(startWith(7));\n * result.subscribe(x => console.log(x));\n *\n * // Outputs\n * // 7\n * ```\n *\n * Map and flatten only odd numbers to the sequence `'a'`, `'b'`, `'c'`\n *\n * ```ts\n * import { interval, mergeMap, of, EMPTY } from 'rxjs';\n *\n * const interval$ = interval(1000);\n * const result = interval$.pipe(\n * mergeMap(x => x % 2 === 1 ? of('a', 'b', 'c') : EMPTY),\n * );\n * result.subscribe(x => console.log(x));\n *\n * // Results in the following to the console:\n * // x is equal to the count on the interval, e.g. (0, 1, 2, 3, ...)\n * // x will occur every 1000ms\n * // if x % 2 is equal to 1, print a, b, c (each on its own)\n * // if x % 2 is not equal to 1, nothing will be output\n * ```\n *\n * @see {@link Observable}\n * @see {@link NEVER}\n * @see {@link of}\n * @see {@link throwError}\n */\nexport const EMPTY = new Observable((subscriber) => subscriber.complete());\n\n/**\n * @param scheduler A {@link SchedulerLike} to use for scheduling\n * the emission of the complete notification.\n * @deprecated Replaced with the {@link EMPTY} constant or {@link scheduled} (e.g. `scheduled([], scheduler)`). Will be removed in v8.\n */\nexport function empty(scheduler?: SchedulerLike) {\n return scheduler ? emptyScheduled(scheduler) : EMPTY;\n}\n\nfunction emptyScheduled(scheduler: SchedulerLike) {\n return new Observable((subscriber) => scheduler.schedule(() => subscriber.complete()));\n}\n", "import { SchedulerLike } from '../types';\nimport { isFunction } from './isFunction';\n\nexport function isScheduler(value: any): value is SchedulerLike {\n return value && isFunction(value.schedule);\n}\n", "import { SchedulerLike } from '../types';\nimport { isFunction } from './isFunction';\nimport { isScheduler } from './isScheduler';\n\nfunction last(arr: T[]): T | undefined {\n return arr[arr.length - 1];\n}\n\nexport function popResultSelector(args: any[]): ((...args: unknown[]) => unknown) | undefined {\n return isFunction(last(args)) ? args.pop() : undefined;\n}\n\nexport function popScheduler(args: any[]): SchedulerLike | undefined {\n return isScheduler(last(args)) ? args.pop() : undefined;\n}\n\nexport function popNumber(args: any[], defaultValue: number): number {\n return typeof last(args) === 'number' ? args.pop()! : defaultValue;\n}\n", "export const isArrayLike = ((x: any): x is ArrayLike => x && typeof x.length === 'number' && typeof x !== 'function');", "import { isFunction } from \"./isFunction\";\n\n/**\n * Tests to see if the object is \"thennable\".\n * @param value the object to test\n */\nexport function isPromise(value: any): value is PromiseLike {\n return isFunction(value?.then);\n}\n", "import { InteropObservable } from '../types';\nimport { observable as Symbol_observable } from '../symbol/observable';\nimport { isFunction } from './isFunction';\n\n/** Identifies an input as being Observable (but not necessary an Rx Observable) */\nexport function isInteropObservable(input: any): input is InteropObservable {\n return isFunction(input[Symbol_observable]);\n}\n", "import { isFunction } from './isFunction';\n\nexport function isAsyncIterable(obj: any): obj is AsyncIterable {\n return Symbol.asyncIterator && isFunction(obj?.[Symbol.asyncIterator]);\n}\n", "/**\n * Creates the TypeError to throw if an invalid object is passed to `from` or `scheduled`.\n * @param input The object that was passed.\n */\nexport function createInvalidObservableTypeError(input: any) {\n // TODO: We should create error codes that can be looked up, so this can be less verbose.\n return new TypeError(\n `You provided ${\n input !== null && typeof input === 'object' ? 'an invalid object' : `'${input}'`\n } where a stream was expected. You can provide an Observable, Promise, ReadableStream, Array, AsyncIterable, or Iterable.`\n );\n}\n", "export function getSymbolIterator(): symbol {\n if (typeof Symbol !== 'function' || !Symbol.iterator) {\n return '@@iterator' as any;\n }\n\n return Symbol.iterator;\n}\n\nexport const iterator = getSymbolIterator();\n", "import { iterator as Symbol_iterator } from '../symbol/iterator';\nimport { isFunction } from './isFunction';\n\n/** Identifies an input as being an Iterable */\nexport function isIterable(input: any): input is Iterable {\n return isFunction(input?.[Symbol_iterator]);\n}\n", "import { ReadableStreamLike } from '../types';\nimport { isFunction } from './isFunction';\n\nexport async function* readableStreamLikeToAsyncGenerator(readableStream: ReadableStreamLike): AsyncGenerator {\n const reader = readableStream.getReader();\n try {\n while (true) {\n const { value, done } = await reader.read();\n if (done) {\n return;\n }\n yield value!;\n }\n } finally {\n reader.releaseLock();\n }\n}\n\nexport function isReadableStreamLike(obj: any): obj is ReadableStreamLike {\n // We don't want to use instanceof checks because they would return\n // false for instances from another Realm, like an