diff --git a/.changes/next-release/feature-vks-03mnp7ol.json b/.changes/next-release/feature-vks-03mnp7ol.json new file mode 100644 index 0000000..6c0fe8f --- /dev/null +++ b/.changes/next-release/feature-vks-03mnp7ol.json @@ -0,0 +1,5 @@ +{ + "type": "feature", + "category": "vks", + "description": "Add kubeconfig commands (generate-kubeconfig, update-kubeconfig) for VKS clusters" +} diff --git a/.changes/next-release/feature-vks-m484unqd.json b/.changes/next-release/feature-vks-m484unqd.json new file mode 100644 index 0000000..8fd5999 --- /dev/null +++ b/.changes/next-release/feature-vks-m484unqd.json @@ -0,0 +1,5 @@ +{ + "type": "feature", + "category": "vks", + "description": "Add list-cluster-versions, get-cluster-events, and get-nodegroup-events commands" +} diff --git a/.changes/next-release/feature-vks-odbam6zb.json b/.changes/next-release/feature-vks-odbam6zb.json new file mode 100644 index 0000000..7029889 --- /dev/null +++ b/.changes/next-release/feature-vks-odbam6zb.json @@ -0,0 +1,5 @@ +{ + "type": "feature", + "category": "vks", + "description": "Add upgrade-nodegroup-version, config-auto-healing, and update-nodegroup-metadata commands" +} diff --git a/CLAUDE.md b/CLAUDE.md index 85202c2..21f5cd2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,15 @@ go/ │ ├── get_nodegroup.go │ ├── create_nodegroup.go │ ├── update_nodegroup.go +│ ├── update_nodegroup_metadata.go # Update labels/tags/taints +│ ├── upgrade_nodegroup_version.go # Upgrade node group k8s version │ ├── delete_nodegroup.go +│ ├── list_cluster_versions.go # Available k8s versions +│ ├── config_auto_healing.go # Configure auto-healing +│ ├── get_cluster_events.go # Cluster events (paginated) +│ ├── get_nodegroup_events.go # Node group events (paginated) +│ ├── generate_kubeconfig.go # Request kubeconfig (async) +│ ├── update_kubeconfig.go # Fetch + merge kubeconfig │ ├── wait_cluster_active.go # Polling waiter │ └── auto_upgrade.go # Set/delete auto-upgrade ├── internal/ @@ -43,6 +51,8 @@ go/ │ ├── auth/token.go # OAuth2 Client Credentials (IAM) │ ├── client/client.go # HTTP client with retry + auto-refresh │ ├── formatter/formatter.go # JSON/Table/Text + JMESPath +│ ├── kubeconfig/ +│ │ └── kubeconfig.go # Merge kubeconfig into ~/.kube/config │ └── validator/validator.go # ID format validation ├── go.mod, go.sum ``` diff --git a/README.md b/README.md index ec39787..a46076c 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,57 @@ To check the version: grn --version ``` +### Available VKS Commands + +**Cluster** + +- `list-clusters` — List all VKS clusters +- `get-cluster` — Get cluster details +- `create-cluster` — Create a new VKS cluster +- `update-cluster` — Update a VKS cluster +- `delete-cluster` — Delete a VKS cluster + +**Node Group** + +- `list-nodegroups` — List node groups for a cluster +- `get-nodegroup` — Get node group details +- `create-nodegroup` — Create a new node group +- `update-nodegroup` — Update a node group +- `update-nodegroup-metadata` — Update labels, tags, and taints of a node group +- `upgrade-nodegroup-version` — Upgrade the Kubernetes version of a node group +- `delete-nodegroup` — Delete a node group + +**Versions** + +- `list-cluster-versions` — List available Kubernetes versions + +**Auto-Upgrade** + +- `set-auto-upgrade-config` — Configure auto-upgrade schedule for a cluster +- `delete-auto-upgrade-config` — Delete auto-upgrade config for a cluster + +**Auto-Healing** + +- `config-auto-healing` — Configure auto-healing for a cluster + +**Events** + +- `get-cluster-events` — Get the list of events for a cluster +- `get-nodegroup-events` — Get the list of events for a node group + +**Kubeconfig** + +- `generate-kubeconfig` — Request generation of a cluster kubeconfig +- `update-kubeconfig` — Fetch and merge the cluster kubeconfig into your kubeconfig file + +**Quota** + +- `get-quota` — Get VKS quota limits and current usage + +**Waiter** + +- `wait-cluster-active` — Wait until a cluster reaches ACTIVE status + ## Getting Help The best way to interact with our team is through GitHub: diff --git a/docs/commands/vks/config-auto-healing.md b/docs/commands/vks/config-auto-healing.md new file mode 100644 index 0000000..9fbb692 --- /dev/null +++ b/docs/commands/vks/config-auto-healing.md @@ -0,0 +1,61 @@ +# config-auto-healing + +## Description + +Configure auto-healing for a VKS cluster. Auto-healing automatically replaces unhealthy nodes to keep the cluster in a working state. + +## Synopsis + +``` +grn vks config-auto-healing + --cluster-id + --enable-auto-healing + [--max-unhealthy ] + [--unhealthy-range ] + [--timeout-unhealthy ] +``` + +## Options + +`--cluster-id` (required) +: The ID of the cluster. + +`--enable-auto-healing` (required) +: Whether to enable auto-healing. Pass `--enable-auto-healing` to enable, or `--enable-auto-healing=false` to disable. + +`--max-unhealthy` (optional) +: Maximum number (or percentage) of unhealthy nodes tolerated, e.g. `30%`. + +`--unhealthy-range` (optional) +: The unhealthy range threshold. + +`--timeout-unhealthy` (optional) +: Time in seconds a node may stay unhealthy before it is replaced. + +## Examples + +Enable auto-healing with default thresholds: + +```bash +grn vks config-auto-healing \ + --cluster-id cls-abc12345-6789-def0-1234-abcdef012345 \ + --enable-auto-healing +``` + +Enable auto-healing with custom thresholds: + +```bash +grn vks config-auto-healing \ + --cluster-id cls-abc12345-6789-def0-1234-abcdef012345 \ + --enable-auto-healing \ + --max-unhealthy 30% \ + --timeout-unhealthy 300 +``` + +Disable auto-healing: + +```bash +grn vks config-auto-healing \ + --cluster-id cls-abc12345-6789-def0-1234-abcdef012345 \ + --enable-auto-healing=false +``` diff --git a/docs/commands/vks/generate-kubeconfig.md b/docs/commands/vks/generate-kubeconfig.md new file mode 100644 index 0000000..4bd40c4 --- /dev/null +++ b/docs/commands/vks/generate-kubeconfig.md @@ -0,0 +1,44 @@ +# generate-kubeconfig + +## Description + +Request the VKS API to generate (or renew) a kubeconfig for a cluster. + +This operation is **asynchronous**: the server accepts the request (HTTP 202) and generates the kubeconfig in the background. Once the kubeconfig becomes `ACTIVE`, run `grn vks update-kubeconfig` to fetch it and merge it into your local kubeconfig file. + +## Synopsis + +``` +grn vks generate-kubeconfig + --cluster-id + [--expiration-days ] +``` + +## Options + +`--cluster-id` (required) +: The ID of the cluster. + +`--expiration-days` (optional) +: Number of days until the generated kubeconfig expires. Default: `30`. + +## Examples + +Request a kubeconfig with the default 30-day expiration: + +```bash +grn vks generate-kubeconfig \ + --cluster-id cls-abc12345-6789-def0-1234-abcdef012345 +``` + +Request a kubeconfig with a custom expiration, then fetch it once it is active: + +```bash +grn vks generate-kubeconfig \ + --cluster-id cls-abc12345-6789-def0-1234-abcdef012345 \ + --expiration-days 90 + +# Once the kubeconfig is ACTIVE: +grn vks update-kubeconfig \ + --cluster-id cls-abc12345-6789-def0-1234-abcdef012345 +``` diff --git a/docs/commands/vks/get-cluster-events.md b/docs/commands/vks/get-cluster-events.md new file mode 100644 index 0000000..ba3797b --- /dev/null +++ b/docs/commands/vks/get-cluster-events.md @@ -0,0 +1,53 @@ +# get-cluster-events + +## Description + +Get the list of events for a VKS cluster. Events can be filtered by action and type, and the results are paginated. + +## Synopsis + +``` +grn vks get-cluster-events + --cluster-id + [--action ] + [--type ] + [--page ] + [--page-size ] +``` + +## Options + +`--cluster-id` (required) +: The ID of the cluster. + +`--action` (optional) +: Filter events by action. + +`--type` (optional) +: Filter events by event type. + +`--page` (optional) +: Page number to retrieve. Pagination is 0-based (page 0 is the first page). Default: `0`. + +`--page-size` (optional) +: Number of events per page. Default: `50`. + +## Examples + +Get the first page of cluster events: + +```bash +grn vks get-cluster-events \ + --cluster-id cls-abc12345-6789-def0-1234-abcdef012345 +``` + +Filter events by action and type with a custom page size: + +```bash +grn vks get-cluster-events \ + --cluster-id cls-abc12345-6789-def0-1234-abcdef012345 \ + --action UPGRADE \ + --type INFO \ + --page 0 \ + --page-size 20 +``` diff --git a/docs/commands/vks/get-nodegroup-events.md b/docs/commands/vks/get-nodegroup-events.md new file mode 100644 index 0000000..547ffd0 --- /dev/null +++ b/docs/commands/vks/get-nodegroup-events.md @@ -0,0 +1,59 @@ +# get-nodegroup-events + +## Description + +Get the list of events for a node group. Events can be filtered by action and type, and the results are paginated. + +## Synopsis + +``` +grn vks get-nodegroup-events + --cluster-id + --nodegroup-id + [--action ] + [--type ] + [--page ] + [--page-size ] +``` + +## Options + +`--cluster-id` (required) +: The ID of the cluster. + +`--nodegroup-id` (required) +: The ID of the node group. + +`--action` (optional) +: Filter events by action. + +`--type` (optional) +: Filter events by event type. + +`--page` (optional) +: Page number to retrieve. Pagination is 0-based (page 0 is the first page). Default: `0`. + +`--page-size` (optional) +: Number of events per page. Default: `50`. + +## Examples + +Get the first page of node group events: + +```bash +grn vks get-nodegroup-events \ + --cluster-id cls-abc12345-6789-def0-1234-abcdef012345 \ + --nodegroup-id ng-abc12345-6789-def0-1234-abcdef012345 +``` + +Filter events by action and type with a custom page size: + +```bash +grn vks get-nodegroup-events \ + --cluster-id cls-abc12345-6789-def0-1234-abcdef012345 \ + --nodegroup-id ng-abc12345-6789-def0-1234-abcdef012345 \ + --action SCALE \ + --type INFO \ + --page 0 \ + --page-size 20 +``` diff --git a/docs/commands/vks/index.md b/docs/commands/vks/index.md index 215e4cf..79e4788 100644 --- a/docs/commands/vks/index.md +++ b/docs/commands/vks/index.md @@ -26,8 +26,16 @@ grn vks [options] | [get-nodegroup](get-nodegroup.md) | Get node group details | | [create-nodegroup](create-nodegroup.md) | Create a new node group | | [update-nodegroup](update-nodegroup.md) | Update a node group | +| [update-nodegroup-metadata](update-nodegroup-metadata.md) | Update labels, tags, and taints of a node group | +| [upgrade-nodegroup-version](upgrade-nodegroup-version.md) | Upgrade the Kubernetes version of a node group | | [delete-nodegroup](delete-nodegroup.md) | Delete a node group | +### Versions + +| Command | Description | +|---------|-------------| +| [list-cluster-versions](list-cluster-versions.md) | List available Kubernetes versions | + ### Auto-Upgrade | Command | Description | @@ -35,6 +43,26 @@ grn vks [options] | [set-auto-upgrade-config](set-auto-upgrade-config.md) | Configure auto-upgrade schedule for a cluster | | [delete-auto-upgrade-config](delete-auto-upgrade-config.md) | Delete auto-upgrade config for a cluster | +### Auto-Healing + +| Command | Description | +|---------|-------------| +| [config-auto-healing](config-auto-healing.md) | Configure auto-healing for a cluster | + +### Events + +| Command | Description | +|---------|-------------| +| [get-cluster-events](get-cluster-events.md) | Get the list of events for a cluster | +| [get-nodegroup-events](get-nodegroup-events.md) | Get the list of events for a node group | + +### Kubeconfig + +| Command | Description | +|---------|-------------| +| [generate-kubeconfig](generate-kubeconfig.md) | Request generation of a cluster kubeconfig | +| [update-kubeconfig](update-kubeconfig.md) | Fetch and merge the cluster kubeconfig into your kubeconfig file | + ### Quota | Command | Description | diff --git a/docs/commands/vks/list-cluster-versions.md b/docs/commands/vks/list-cluster-versions.md new file mode 100644 index 0000000..1c1faa1 --- /dev/null +++ b/docs/commands/vks/list-cluster-versions.md @@ -0,0 +1,23 @@ +# list-cluster-versions + +## Description + +List the Kubernetes versions available for VKS clusters. Use this to discover valid version strings before upgrading a cluster or node group. + +## Synopsis + +``` +grn vks list-cluster-versions +``` + +## Options + +This command takes no options. + +## Examples + +List available Kubernetes versions: + +```bash +grn vks list-cluster-versions +``` diff --git a/docs/commands/vks/update-kubeconfig.md b/docs/commands/vks/update-kubeconfig.md new file mode 100644 index 0000000..dacd9f8 --- /dev/null +++ b/docs/commands/vks/update-kubeconfig.md @@ -0,0 +1,74 @@ +# update-kubeconfig + +## Description + +Fetch the cluster kubeconfig and merge it into your local kubeconfig file, then (by default) set it as the current context. This is similar to `aws eks update-kubeconfig`. + +The kubeconfig must already be `ACTIVE`. If no kubeconfig exists yet (status `NONE`), run `grn vks generate-kubeconfig --cluster-id ` first and wait until it becomes active. + +The target file is resolved in this order: the `--kubeconfig` flag, then the first entry of `$KUBECONFIG`, then `~/.kube/config`. The merged context is named `vks_` by default; override it with `--alias`. + +## Synopsis + +``` +grn vks update-kubeconfig + --cluster-id + [--kubeconfig ] + [--alias ] + [--no-set-context] + [--dry-run] +``` + +## Options + +`--cluster-id` (required) +: The ID of the cluster. + +`--kubeconfig` (optional) +: Path to the kubeconfig file to update. Defaults to `$KUBECONFIG` (first entry) or `~/.kube/config`. + +`--alias` (optional) +: Context name to use for the merged cluster. Default: `vks_`. + +`--no-set-context` (optional) +: Do not set the merged context as the current context. + +`--dry-run` (optional) +: Print what would be written without modifying the kubeconfig file. + +## Examples + +Merge the cluster kubeconfig into the default file and set it as the current context: + +```bash +grn vks update-kubeconfig \ + --cluster-id cls-abc12345-6789-def0-1234-abcdef012345 +``` + +This creates a context named `vks_cls-abc12345-6789-def0-1234-abcdef012345`. + +Use a custom context name and a specific kubeconfig file, without switching the current context: + +```bash +grn vks update-kubeconfig \ + --cluster-id cls-abc12345-6789-def0-1234-abcdef012345 \ + --kubeconfig ./my-kubeconfig.yaml \ + --alias prod-cluster \ + --no-set-context +``` + +Preview the changes without writing the file: + +```bash +grn vks update-kubeconfig \ + --cluster-id cls-abc12345-6789-def0-1234-abcdef012345 \ + --dry-run +``` + +If the kubeconfig does not exist yet, generate it first: + +```bash +grn vks generate-kubeconfig --cluster-id cls-abc12345-6789-def0-1234-abcdef012345 +# Wait until the kubeconfig becomes ACTIVE, then: +grn vks update-kubeconfig --cluster-id cls-abc12345-6789-def0-1234-abcdef012345 +``` diff --git a/docs/commands/vks/update-nodegroup-metadata.md b/docs/commands/vks/update-nodegroup-metadata.md new file mode 100644 index 0000000..7b99a31 --- /dev/null +++ b/docs/commands/vks/update-nodegroup-metadata.md @@ -0,0 +1,57 @@ +# update-nodegroup-metadata + +## Description + +Update the labels, tags, and taints of a node group. At least one of `--labels`, `--tags`, or `--taints` must be provided. + +## Synopsis + +``` +grn vks update-nodegroup-metadata + --cluster-id + --nodegroup-id + [--labels ] + [--tags ] + [--taints ] +``` + +## Options + +`--cluster-id` (required) +: The ID of the cluster. + +`--nodegroup-id` (required) +: The ID of the node group to update. + +`--labels` (optional) +: Comma-separated `key=value` pairs to set as Kubernetes node labels (e.g. `env=prod,tier=app`). + +`--tags` (optional) +: Comma-separated `key=value` pairs to set as tags (e.g. `team=platform,cost-center=123`). + +`--taints` (optional) +: Comma-separated node taints in `key=value:effect` format (e.g. `dedicated=gpu:NoSchedule`). + +At least one of `--labels`, `--tags`, or `--taints` must be provided. + +## Examples + +Update node labels: + +```bash +grn vks update-nodegroup-metadata \ + --cluster-id cls-abc12345-6789-def0-1234-abcdef012345 \ + --nodegroup-id ng-abc12345-6789-def0-1234-abcdef012345 \ + --labels env=prod,tier=app +``` + +Update labels, tags, and taints together: + +```bash +grn vks update-nodegroup-metadata \ + --cluster-id cls-abc12345-6789-def0-1234-abcdef012345 \ + --nodegroup-id ng-abc12345-6789-def0-1234-abcdef012345 \ + --labels tier=gpu \ + --tags team=platform \ + --taints dedicated=gpu:NoSchedule +``` diff --git a/docs/commands/vks/upgrade-nodegroup-version.md b/docs/commands/vks/upgrade-nodegroup-version.md new file mode 100644 index 0000000..e6cf48c --- /dev/null +++ b/docs/commands/vks/upgrade-nodegroup-version.md @@ -0,0 +1,38 @@ +# upgrade-nodegroup-version + +## Description + +Upgrade the Kubernetes version of a node group within a cluster. + +Use `grn vks list-cluster-versions` to find the valid Kubernetes versions before running this command. + +## Synopsis + +``` +grn vks upgrade-nodegroup-version + --cluster-id + --nodegroup-id + --k8s-version +``` + +## Options + +`--cluster-id` (required) +: The ID of the cluster. + +`--nodegroup-id` (required) +: The ID of the node group to upgrade. + +`--k8s-version` (required) +: The target Kubernetes version. Use `grn vks list-cluster-versions` to find valid versions. + +## Examples + +Upgrade a node group to a specific Kubernetes version: + +```bash +grn vks upgrade-nodegroup-version \ + --cluster-id cls-abc12345-6789-def0-1234-abcdef012345 \ + --nodegroup-id ng-abc12345-6789-def0-1234-abcdef012345 \ + --k8s-version v1.29.0 +``` diff --git a/docs/superpowers/plans/2026-06-13-vks-missing-commands.md b/docs/superpowers/plans/2026-06-13-vks-missing-commands.md new file mode 100644 index 0000000..58b9e4e --- /dev/null +++ b/docs/superpowers/plans/2026-06-13-vks-missing-commands.md @@ -0,0 +1,1500 @@ +# VKS Missing Commands Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add 8 new `grn vks` commands (plus a `Patch` client method, a YAML-based kubeconfig package, docs, and changelog) so the CLI covers the kubeconfig, nodegroup version upgrade, auto-healing, metadata, events, and cluster-versions VKS API endpoints. + +**Architecture:** Each command is a cobra `cobra.Command` in `go/cmd/vks/.go`, registered in `go/cmd/vks/vks.go`, reusing the existing `createClient` / `outputResult` / `validator.ValidateID` helpers. Request-body and query-param construction is factored into **pure functions** so they can be unit-tested with `go test` without invoking `os.Exit`. The kubeconfig merge logic lives in a new `internal/kubeconfig` package and is fully unit-tested. + +**Tech Stack:** Go 1.22, cobra, `gopkg.in/yaml.v3` (new dependency), `go test` + `net/http/httptest`. + +--- + +## Conventions (from CLAUDE.md — follow exactly) + +- All code/comments in **English**. +- VKS pagination is **0-based** (page 0 = first page). +- Use `--k8s-version` (NOT `--version`, which conflicts with the global version flag). +- IDs used in URLs must pass `validator.ValidateID()` first. +- **Do NOT auto commit/push.** Each task ends with a build/test verification step; the user commits manually at the end. Commit commands are intentionally omitted. +- Every change adds a changelog fragment via `./scripts/new-change` (Task 12). +- New commands require docs updates (Task 12). +- Build command: `cd go && CGO_ENABLED=0 go build -o /tmp/grn .` +- Test command: `cd go && go test ./...` + +--- + +## File Structure + +**New files:** +- `go/internal/kubeconfig/kubeconfig.go` — load/merge/write kubeconfig (pure, YAML). +- `go/internal/kubeconfig/kubeconfig_test.go` — merge unit tests. +- `go/cmd/vks/list_cluster_versions.go` +- `go/cmd/vks/upgrade_nodegroup_version.go` +- `go/cmd/vks/config_auto_healing.go` +- `go/cmd/vks/update_nodegroup_metadata.go` +- `go/cmd/vks/get_cluster_events.go` +- `go/cmd/vks/get_nodegroup_events.go` +- `go/cmd/vks/generate_kubeconfig.go` +- `go/cmd/vks/update_kubeconfig.go` +- `go/cmd/vks/builders_test.go` — unit tests for pure body/query builders. +- `docs/commands/vks/.md` — 8 reference pages. + +**Modified files:** +- `go/internal/client/client.go` — add `Patch`. +- `go/internal/client/client_test.go` — new, tests `Patch`. +- `go/cmd/vks/helpers.go` — add `buildEventsQuery` helper. +- `go/cmd/vks/vks.go` — register 8 commands. +- `go/go.mod`, `go/go.sum` — add `gopkg.in/yaml.v3`. +- `mkdocs.yml`, `docs/commands/vks/index.md`, `README.md`, `CLAUDE.md` — docs. + +--- + +## Task 1: Add `Patch` method to the HTTP client + +**Files:** +- Modify: `go/internal/client/client.go` (near the existing `Put` method, ~line 80) +- Test: `go/internal/client/client_test.go` (create) + +- [ ] **Step 1: Write the failing test** + +Create `go/internal/client/client_test.go`: + +```go +package client + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/vngcloud/greennode-cli/internal/auth" +) + +func TestPatchSendsPatchMethodAndBody(t *testing.T) { + var gotMethod, gotBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + tm := auth.NewTokenManager("id", "secret") + c := NewGreenodeClient(srv.URL, tm, 5*time.Second, false, false) + + _, err := c.Patch("/v1/thing", map[string]interface{}{"enableAutoHealing": true}) + if err != nil { + t.Fatalf("Patch returned error: %v", err) + } + if gotMethod != http.MethodPatch { + t.Errorf("method = %q, want PATCH", gotMethod) + } + if gotBody != `{"enableAutoHealing":true}` { + t.Errorf("body = %q, want enableAutoHealing payload", gotBody) + } +} +``` + +> Note: `NewGreenodeClient(baseURL, tokenManager, timeout, verifySSL, debug)`. If the token manager performs a network call to fetch a token against `srv.URL`, and the test fails on auth, adjust by pointing the token endpoint at the same test server or skip auth — inspect `internal/auth/token.go` first and mirror however other client behavior is exercised. If auth cannot be stubbed simply, replace this test with a direct test of the method string via a smaller seam, but do NOT delete the Patch coverage. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd go && go test ./internal/client/ -run TestPatch -v` +Expected: FAIL — `c.Patch undefined (type *GreenodeClient has no field or method Patch)`. + +- [ ] **Step 3: Add the `Patch` method** + +In `go/internal/client/client.go`, immediately after the `Put` method: + +```go +// Patch performs a PATCH request with a JSON body. +func (c *GreenodeClient) Patch(path string, body interface{}) (interface{}, error) { + return c.request("PATCH", path, nil, body) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd go && go test ./internal/client/ -run TestPatch -v` +Expected: PASS. + +- [ ] **Step 5: Build** + +Run: `cd go && CGO_ENABLED=0 go build -o /tmp/grn .` +Expected: builds with no errors. + +--- + +## Task 2: Add `buildEventsQuery` pagination helper + +The two events commands share query construction. VKS pagination is 0-based, so we do NOT default page to 1. + +**Files:** +- Modify: `go/cmd/vks/helpers.go` (add function at end) +- Test: `go/cmd/vks/builders_test.go` (create) + +- [ ] **Step 1: Write the failing test** + +Create `go/cmd/vks/builders_test.go`: + +```go +package vks + +import ( + "reflect" + "testing" +) + +func TestBuildEventsQueryOnlyIncludesSetValues(t *testing.T) { + got := buildEventsQuery("CREATE", "", 2, 50, map[string]bool{ + "action": true, "type": false, "page": true, "page-size": true, + }) + want := map[string]string{ + "action": "CREATE", + "page": "2", + "pageSize": "50", + } + if !reflect.DeepEqual(got, want) { + t.Errorf("buildEventsQuery = %#v, want %#v", got, want) + } +} + +func TestBuildEventsQueryEmptyWhenNothingSet(t *testing.T) { + got := buildEventsQuery("", "", 0, 0, map[string]bool{}) + if len(got) != 0 { + t.Errorf("buildEventsQuery = %#v, want empty map", got) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd go && go test ./cmd/vks/ -run TestBuildEventsQuery -v` +Expected: FAIL — `undefined: buildEventsQuery`. + +- [ ] **Step 3: Implement the helper** + +Append to `go/cmd/vks/helpers.go` (it already imports `"fmt"`): + +```go +// buildEventsQuery builds query params for events endpoints, including only +// flags the user explicitly set. `changed` maps flag name -> was it set. +// VKS pagination is 0-based, so page is passed through verbatim. +func buildEventsQuery(action, eventType string, page, pageSize int, changed map[string]bool) map[string]string { + params := map[string]string{} + if changed["action"] && action != "" { + params["action"] = action + } + if changed["type"] && eventType != "" { + params["type"] = eventType + } + if changed["page"] { + params["page"] = fmt.Sprintf("%d", page) + } + if changed["page-size"] { + params["pageSize"] = fmt.Sprintf("%d", pageSize) + } + return params +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd go && go test ./cmd/vks/ -run TestBuildEventsQuery -v` +Expected: PASS. + +--- + +## Task 3: `list-cluster-versions` command + +Simplest command — GET, no params. Establishes the file pattern. + +**Files:** +- Create: `go/cmd/vks/list_cluster_versions.go` +- Modify: `go/cmd/vks/vks.go` + +- [ ] **Step 1: Create the command file** + +`go/cmd/vks/list_cluster_versions.go`: + +```go +package vks + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var listClusterVersionsCmd = &cobra.Command{ + Use: "list-cluster-versions", + Short: "List available Kubernetes versions for VKS clusters", + RunE: runListClusterVersions, +} + +func runListClusterVersions(cmd *cobra.Command, args []string) error { + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Get("/v1/cluster-versions", nil) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + return outputResult(cmd, result) +} +``` + +- [ ] **Step 2: Register in vks.go** + +In `go/cmd/vks/vks.go` `init()`, add a new section before the closing brace: + +```go + // Version & event commands + VksCmd.AddCommand(listClusterVersionsCmd) +``` + +- [ ] **Step 3: Build** + +Run: `cd go && CGO_ENABLED=0 go build -o /tmp/grn .` +Expected: builds clean. + +- [ ] **Step 4: Verify the command is wired** + +Run: `/tmp/grn vks list-cluster-versions --help` +Expected: help text shows the command and the global flags (`--output`, `--region`, etc.). + +--- + +## Task 4: `upgrade-nodegroup-version` command + +POST `/v1/clusters/{c}/node-groups/{ng}/upgrade-version`, body `{"kubernetesVersion": }`. Uses `--k8s-version` per CLAUDE.md. + +**Files:** +- Create: `go/cmd/vks/upgrade_nodegroup_version.go` +- Modify: `go/cmd/vks/vks.go` +- Test: `go/cmd/vks/builders_test.go` + +- [ ] **Step 1: Write the failing test for the body builder** + +Append to `go/cmd/vks/builders_test.go`: + +```go +func TestBuildUpgradeNodegroupBody(t *testing.T) { + got := buildUpgradeNodegroupBody("v1.29.0") + if got["kubernetesVersion"] != "v1.29.0" { + t.Errorf("body = %#v, want kubernetesVersion=v1.29.0", got) + } + if len(got) != 1 { + t.Errorf("body has %d keys, want 1", len(got)) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd go && go test ./cmd/vks/ -run TestBuildUpgradeNodegroupBody -v` +Expected: FAIL — `undefined: buildUpgradeNodegroupBody`. + +- [ ] **Step 3: Create the command file** + +`go/cmd/vks/upgrade_nodegroup_version.go`: + +```go +package vks + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var upgradeNodegroupVersionCmd = &cobra.Command{ + Use: "upgrade-nodegroup-version", + Short: "Upgrade the Kubernetes version of a node group", + RunE: runUpgradeNodegroupVersion, +} + +func init() { + f := upgradeNodegroupVersionCmd.Flags() + f.String("cluster-id", "", "Cluster ID (required)") + f.String("nodegroup-id", "", "Node group ID (required)") + f.String("k8s-version", "", "Target Kubernetes version (required)") + + upgradeNodegroupVersionCmd.MarkFlagRequired("cluster-id") + upgradeNodegroupVersionCmd.MarkFlagRequired("nodegroup-id") + upgradeNodegroupVersionCmd.MarkFlagRequired("k8s-version") +} + +func buildUpgradeNodegroupBody(k8sVersion string) map[string]interface{} { + return map[string]interface{}{"kubernetesVersion": k8sVersion} +} + +func runUpgradeNodegroupVersion(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + nodegroupID, _ := cmd.Flags().GetString("nodegroup-id") + k8sVersion, _ := cmd.Flags().GetString("k8s-version") + + if err := validator.ValidateID(clusterID, "cluster-id"); err != nil { + return err + } + if err := validator.ValidateID(nodegroupID, "nodegroup-id"); err != nil { + return err + } + + body := buildUpgradeNodegroupBody(k8sVersion) + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Post( + fmt.Sprintf("/v1/clusters/%s/node-groups/%s/upgrade-version", clusterID, nodegroupID), body, + ) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + return outputResult(cmd, result) +} +``` + +- [ ] **Step 4: Register in vks.go** + +In the "Version & event commands" section of `vks.go` `init()`: + +```go + VksCmd.AddCommand(upgradeNodegroupVersionCmd) +``` + +- [ ] **Step 5: Run test + build** + +Run: `cd go && go test ./cmd/vks/ -run TestBuildUpgradeNodegroupBody -v && CGO_ENABLED=0 go build -o /tmp/grn .` +Expected: test PASS, build clean. + +- [ ] **Step 6: Verify wiring** + +Run: `/tmp/grn vks upgrade-nodegroup-version --help` +Expected: shows `--cluster-id`, `--nodegroup-id`, `--k8s-version` as required. + +--- + +## Task 5: `config-auto-healing` command + +PATCH `/v1/clusters/{c}/auto-healing-config`. `enableAutoHealing` always sent; other fields only if the flag was changed. + +**Files:** +- Create: `go/cmd/vks/config_auto_healing.go` +- Modify: `go/cmd/vks/vks.go` +- Test: `go/cmd/vks/builders_test.go` + +- [ ] **Step 1: Write the failing test** + +Append to `go/cmd/vks/builders_test.go`: + +```go +func TestBuildAutoHealingBodyOnlyChangedOptionalFields(t *testing.T) { + got := buildAutoHealingBody(true, "30%", "", 600, map[string]bool{ + "max-unhealthy": true, "unhealthy-range": false, "timeout-unhealthy": true, + }) + want := map[string]interface{}{ + "enableAutoHealing": true, + "maxUnhealthy": "30%", + "timeoutUnhealthy": 600, + } + if got["enableAutoHealing"] != want["enableAutoHealing"] || + got["maxUnhealthy"] != want["maxUnhealthy"] || + got["timeoutUnhealthy"] != want["timeoutUnhealthy"] { + t.Errorf("body = %#v, want %#v", got, want) + } + if _, ok := got["unhealthyRange"]; ok { + t.Errorf("unhealthyRange should be absent when flag not set; got %#v", got) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd go && go test ./cmd/vks/ -run TestBuildAutoHealingBody -v` +Expected: FAIL — `undefined: buildAutoHealingBody`. + +- [ ] **Step 3: Create the command file** + +`go/cmd/vks/config_auto_healing.go`: + +```go +package vks + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var configAutoHealingCmd = &cobra.Command{ + Use: "config-auto-healing", + Short: "Configure auto-healing for a VKS cluster", + RunE: runConfigAutoHealing, +} + +func init() { + f := configAutoHealingCmd.Flags() + f.String("cluster-id", "", "Cluster ID (required)") + f.Bool("enable-auto-healing", false, "Enable auto-healing (required)") + f.String("max-unhealthy", "", "Max unhealthy nodes, e.g. \"30%\"") + f.String("unhealthy-range", "", "Unhealthy range") + f.Int("timeout-unhealthy", 0, "Unhealthy timeout in seconds") + + configAutoHealingCmd.MarkFlagRequired("cluster-id") + configAutoHealingCmd.MarkFlagRequired("enable-auto-healing") +} + +func buildAutoHealingBody(enable bool, maxUnhealthy, unhealthyRange string, timeoutUnhealthy int, changed map[string]bool) map[string]interface{} { + body := map[string]interface{}{"enableAutoHealing": enable} + if changed["max-unhealthy"] && maxUnhealthy != "" { + body["maxUnhealthy"] = maxUnhealthy + } + if changed["unhealthy-range"] && unhealthyRange != "" { + body["unhealthyRange"] = unhealthyRange + } + if changed["timeout-unhealthy"] { + body["timeoutUnhealthy"] = timeoutUnhealthy + } + return body +} + +func runConfigAutoHealing(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + enable, _ := cmd.Flags().GetBool("enable-auto-healing") + maxUnhealthy, _ := cmd.Flags().GetString("max-unhealthy") + unhealthyRange, _ := cmd.Flags().GetString("unhealthy-range") + timeoutUnhealthy, _ := cmd.Flags().GetInt("timeout-unhealthy") + + if err := validator.ValidateID(clusterID, "cluster-id"); err != nil { + return err + } + + changed := map[string]bool{ + "max-unhealthy": cmd.Flags().Changed("max-unhealthy"), + "unhealthy-range": cmd.Flags().Changed("unhealthy-range"), + "timeout-unhealthy": cmd.Flags().Changed("timeout-unhealthy"), + } + body := buildAutoHealingBody(enable, maxUnhealthy, unhealthyRange, timeoutUnhealthy, changed) + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Patch( + fmt.Sprintf("/v1/clusters/%s/auto-healing-config", clusterID), body, + ) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + return outputResult(cmd, result) +} +``` + +- [ ] **Step 4: Register in vks.go** + +Add a "Config commands" line near the auto-upgrade section of `vks.go` `init()`: + +```go + VksCmd.AddCommand(configAutoHealingCmd) +``` + +- [ ] **Step 5: Run test + build** + +Run: `cd go && go test ./cmd/vks/ -run TestBuildAutoHealingBody -v && CGO_ENABLED=0 go build -o /tmp/grn .` +Expected: test PASS, build clean. + +- [ ] **Step 6: Verify wiring** + +Run: `/tmp/grn vks config-auto-healing --help` +Expected: shows `--cluster-id`, `--enable-auto-healing` required; optional tuning flags. + +--- + +## Task 6: `update-nodegroup-metadata` command + +PATCH `/v1/clusters/{c}/node-groups/{ng}/metadata`. Body `{labels?, tags?, taints?}`, only the keys whose flags were set. Reuses `parseLabels` and `parseTaints`. + +**Files:** +- Create: `go/cmd/vks/update_nodegroup_metadata.go` +- Modify: `go/cmd/vks/vks.go` +- Test: `go/cmd/vks/builders_test.go` + +- [ ] **Step 1: Write the failing test** + +Append to `go/cmd/vks/builders_test.go`: + +```go +func TestBuildMetadataBodyIncludesOnlyChangedKeys(t *testing.T) { + got := buildMetadataBody("env=prod", "", "dedicated=gpu:NoSchedule", map[string]bool{ + "labels": true, "tags": false, "taints": true, + }) + labels, ok := got["labels"].(map[string]string) + if !ok || labels["env"] != "prod" { + t.Errorf("labels = %#v, want env=prod", got["labels"]) + } + if _, ok := got["tags"]; ok { + t.Errorf("tags should be absent when flag not set; got %#v", got) + } + taints, ok := got["taints"].([]Taint) + if !ok || len(taints) != 1 || taints[0].Key != "dedicated" || taints[0].Effect != "NoSchedule" { + t.Errorf("taints = %#v, want one dedicated=gpu:NoSchedule taint", got["taints"]) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd go && go test ./cmd/vks/ -run TestBuildMetadataBody -v` +Expected: FAIL — `undefined: buildMetadataBody`. + +- [ ] **Step 3: Create the command file** + +`go/cmd/vks/update_nodegroup_metadata.go`: + +```go +package vks + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var updateNodegroupMetadataCmd = &cobra.Command{ + Use: "update-nodegroup-metadata", + Short: "Update labels, tags, and taints of a node group", + RunE: runUpdateNodegroupMetadata, +} + +func init() { + f := updateNodegroupMetadataCmd.Flags() + f.String("cluster-id", "", "Cluster ID (required)") + f.String("nodegroup-id", "", "Node group ID (required)") + f.String("labels", "", "Node labels as key=value pairs (comma-separated)") + f.String("tags", "", "Tags as key=value pairs (comma-separated)") + f.String("taints", "", "Node taints as key=value:effect (comma-separated)") + + updateNodegroupMetadataCmd.MarkFlagRequired("cluster-id") + updateNodegroupMetadataCmd.MarkFlagRequired("nodegroup-id") +} + +func buildMetadataBody(labelsStr, tagsStr, taintsStr string, changed map[string]bool) map[string]interface{} { + body := map[string]interface{}{} + if changed["labels"] { + body["labels"] = parseLabels(labelsStr) + } + if changed["tags"] { + body["tags"] = parseLabels(tagsStr) + } + if changed["taints"] { + body["taints"] = parseTaints(taintsStr) + } + return body +} + +func runUpdateNodegroupMetadata(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + nodegroupID, _ := cmd.Flags().GetString("nodegroup-id") + labelsStr, _ := cmd.Flags().GetString("labels") + tagsStr, _ := cmd.Flags().GetString("tags") + taintsStr, _ := cmd.Flags().GetString("taints") + + if err := validator.ValidateID(clusterID, "cluster-id"); err != nil { + return err + } + if err := validator.ValidateID(nodegroupID, "nodegroup-id"); err != nil { + return err + } + + changed := map[string]bool{ + "labels": cmd.Flags().Changed("labels"), + "tags": cmd.Flags().Changed("tags"), + "taints": cmd.Flags().Changed("taints"), + } + if !changed["labels"] && !changed["tags"] && !changed["taints"] { + return fmt.Errorf("at least one of --labels, --tags, --taints must be provided") + } + body := buildMetadataBody(labelsStr, tagsStr, taintsStr, changed) + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Patch( + fmt.Sprintf("/v1/clusters/%s/node-groups/%s/metadata", clusterID, nodegroupID), body, + ) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + return outputResult(cmd, result) +} +``` + +- [ ] **Step 4: Register in vks.go** + +In the nodegroup section of `vks.go` `init()`: + +```go + VksCmd.AddCommand(updateNodegroupMetadataCmd) +``` + +- [ ] **Step 5: Run test + build** + +Run: `cd go && go test ./cmd/vks/ -run TestBuildMetadataBody -v && CGO_ENABLED=0 go build -o /tmp/grn .` +Expected: test PASS, build clean. + +- [ ] **Step 6: Verify wiring** + +Run: `/tmp/grn vks update-nodegroup-metadata --help` +Expected: shows the five flags. + +--- + +## Task 7: `get-cluster-events` command + +GET `/v1/clusters/{c}/events` with query params via `buildEventsQuery`. + +**Files:** +- Create: `go/cmd/vks/get_cluster_events.go` +- Modify: `go/cmd/vks/vks.go` + +- [ ] **Step 1: Create the command file** + +`go/cmd/vks/get_cluster_events.go`: + +```go +package vks + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var getClusterEventsCmd = &cobra.Command{ + Use: "get-cluster-events", + Short: "Get the list of events for a VKS cluster", + RunE: runGetClusterEvents, +} + +func init() { + f := getClusterEventsCmd.Flags() + f.String("cluster-id", "", "Cluster ID (required)") + f.String("action", "", "Filter by action") + f.String("type", "", "Filter by event type") + f.Int("page", 0, "Page number (0-based)") + f.Int("page-size", 50, "Page size") + + getClusterEventsCmd.MarkFlagRequired("cluster-id") +} + +func runGetClusterEvents(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + action, _ := cmd.Flags().GetString("action") + eventType, _ := cmd.Flags().GetString("type") + page, _ := cmd.Flags().GetInt("page") + pageSize, _ := cmd.Flags().GetInt("page-size") + + if err := validator.ValidateID(clusterID, "cluster-id"); err != nil { + return err + } + + changed := map[string]bool{ + "action": cmd.Flags().Changed("action"), + "type": cmd.Flags().Changed("type"), + "page": cmd.Flags().Changed("page"), + "page-size": cmd.Flags().Changed("page-size"), + } + params := buildEventsQuery(action, eventType, page, pageSize, changed) + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Get( + fmt.Sprintf("/v1/clusters/%s/events", clusterID), params, + ) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + return outputResult(cmd, result) +} +``` + +- [ ] **Step 2: Register in vks.go** + +In the "Version & event commands" section: + +```go + VksCmd.AddCommand(getClusterEventsCmd) +``` + +- [ ] **Step 3: Build + verify** + +Run: `cd go && CGO_ENABLED=0 go build -o /tmp/grn . && /tmp/grn vks get-cluster-events --help` +Expected: build clean; help shows `--cluster-id` required plus filter/paging flags. + +--- + +## Task 8: `get-nodegroup-events` command + +GET `/v1/clusters/{c}/node-groups/{ng}/events`. Same shape as Task 7 plus `--nodegroup-id`. + +**Files:** +- Create: `go/cmd/vks/get_nodegroup_events.go` +- Modify: `go/cmd/vks/vks.go` + +- [ ] **Step 1: Create the command file** + +`go/cmd/vks/get_nodegroup_events.go`: + +```go +package vks + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var getNodegroupEventsCmd = &cobra.Command{ + Use: "get-nodegroup-events", + Short: "Get the list of events for a node group", + RunE: runGetNodegroupEvents, +} + +func init() { + f := getNodegroupEventsCmd.Flags() + f.String("cluster-id", "", "Cluster ID (required)") + f.String("nodegroup-id", "", "Node group ID (required)") + f.String("action", "", "Filter by action") + f.String("type", "", "Filter by event type") + f.Int("page", 0, "Page number (0-based)") + f.Int("page-size", 50, "Page size") + + getNodegroupEventsCmd.MarkFlagRequired("cluster-id") + getNodegroupEventsCmd.MarkFlagRequired("nodegroup-id") +} + +func runGetNodegroupEvents(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + nodegroupID, _ := cmd.Flags().GetString("nodegroup-id") + action, _ := cmd.Flags().GetString("action") + eventType, _ := cmd.Flags().GetString("type") + page, _ := cmd.Flags().GetInt("page") + pageSize, _ := cmd.Flags().GetInt("page-size") + + if err := validator.ValidateID(clusterID, "cluster-id"); err != nil { + return err + } + if err := validator.ValidateID(nodegroupID, "nodegroup-id"); err != nil { + return err + } + + changed := map[string]bool{ + "action": cmd.Flags().Changed("action"), + "type": cmd.Flags().Changed("type"), + "page": cmd.Flags().Changed("page"), + "page-size": cmd.Flags().Changed("page-size"), + } + params := buildEventsQuery(action, eventType, page, pageSize, changed) + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Get( + fmt.Sprintf("/v1/clusters/%s/node-groups/%s/events", clusterID, nodegroupID), params, + ) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + return outputResult(cmd, result) +} +``` + +- [ ] **Step 2: Register in vks.go** + +In the "Version & event commands" section: + +```go + VksCmd.AddCommand(getNodegroupEventsCmd) +``` + +- [ ] **Step 3: Build + verify** + +Run: `cd go && CGO_ENABLED=0 go build -o /tmp/grn . && /tmp/grn vks get-nodegroup-events --help` +Expected: build clean; help shows both IDs required. + +--- + +## Task 9: `internal/kubeconfig` package (load / merge / write) + +Pure YAML logic, fully unit-tested. This is the core of `update-kubeconfig`. + +**Files:** +- Modify: `go/go.mod`, `go/go.sum` (add dependency) +- Create: `go/internal/kubeconfig/kubeconfig.go` +- Test: `go/internal/kubeconfig/kubeconfig_test.go` + +- [ ] **Step 1: Add the YAML dependency** + +Run: `cd go && go get gopkg.in/yaml.v3@v3.0.1` +Expected: `go.mod` now lists `gopkg.in/yaml.v3 v3.0.1`. + +- [ ] **Step 2: Write the failing tests** + +Create `go/internal/kubeconfig/kubeconfig_test.go`: + +```go +package kubeconfig + +import ( + "os" + "path/filepath" + "testing" +) + +const incomingKubeconfig = `apiVersion: v1 +kind: Config +clusters: +- name: vks-cluster + cluster: + server: https://1.2.3.4:6443 + certificate-authority-data: AAAA +contexts: +- name: vks-cluster-ctx + context: + cluster: vks-cluster + user: vks-user +current-context: vks-cluster-ctx +users: +- name: vks-user + user: + token: secret-token +` + +func TestMergeIntoEmptyFileCreatesContext(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "config") + + res, err := Merge(target, incomingKubeconfig, "vks_c-123", true) + if err != nil { + t.Fatalf("Merge error: %v", err) + } + if res.ContextName != "vks_c-123" { + t.Errorf("ContextName = %q, want vks_c-123", res.ContextName) + } + + cfg, err := Load(target) + if err != nil { + t.Fatalf("Load error: %v", err) + } + if cfg.CurrentContext != "vks_c-123" { + t.Errorf("current-context = %q, want vks_c-123", cfg.CurrentContext) + } + if findContext(cfg, "vks_c-123") == nil { + t.Errorf("merged context not found in %#v", cfg.Contexts) + } +} + +func TestMergePreservesExistingContexts(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "config") + existing := `apiVersion: v1 +kind: Config +clusters: +- name: other + cluster: + server: https://9.9.9.9 +contexts: +- name: other-ctx + context: + cluster: other + user: other-user +current-context: other-ctx +users: +- name: other-user + user: + token: other +` + if err := os.WriteFile(target, []byte(existing), 0o600); err != nil { + t.Fatal(err) + } + + if _, err := Merge(target, incomingKubeconfig, "vks_c-123", false); err != nil { + t.Fatalf("Merge error: %v", err) + } + + cfg, _ := Load(target) + if findContext(cfg, "other-ctx") == nil { + t.Errorf("existing context was dropped") + } + if findContext(cfg, "vks_c-123") == nil { + t.Errorf("new context missing") + } + if cfg.CurrentContext != "other-ctx" { + t.Errorf("current-context = %q, want other-ctx (setCurrent=false)", cfg.CurrentContext) + } +} + +func TestMergeOverwritesSameContextName(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "config") + + if _, err := Merge(target, incomingKubeconfig, "vks_c-123", true); err != nil { + t.Fatal(err) + } + if _, err := Merge(target, incomingKubeconfig, "vks_c-123", true); err != nil { + t.Fatal(err) + } + + cfg, _ := Load(target) + count := 0 + for _, c := range cfg.Contexts { + if c.Name == "vks_c-123" { + count++ + } + } + if count != 1 { + t.Errorf("context vks_c-123 appears %d times, want 1", count) + } +} +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `cd go && go test ./internal/kubeconfig/ -v` +Expected: FAIL — `undefined: Merge`, `Load`, `findContext`, etc. + +- [ ] **Step 4: Implement the package** + +Create `go/internal/kubeconfig/kubeconfig.go`: + +```go +// Package kubeconfig loads, merges, and writes Kubernetes kubeconfig files. +package kubeconfig + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// Config models the subset of a kubeconfig we manipulate. Unknown fields are +// preserved via yaml.Node passthrough on the entry "cluster"/"context"/"user". +type Config struct { + APIVersion string `yaml:"apiVersion,omitempty"` + Kind string `yaml:"kind,omitempty"` + Clusters []NamedEntry `yaml:"clusters"` + Contexts []NamedEntry `yaml:"contexts"` + Users []NamedEntry `yaml:"users"` + CurrentContext string `yaml:"current-context,omitempty"` + Preferences map[string]any `yaml:"preferences,omitempty"` +} + +// NamedEntry is a generic {name, } entry. The payload key differs by +// list (cluster/context/user); we keep it as a raw node to avoid lossy parsing. +type NamedEntry struct { + Name string `yaml:"name"` + Cluster *yaml.Node `yaml:"cluster,omitempty"` + Context *contextBody `yaml:"context,omitempty"` + User *yaml.Node `yaml:"user,omitempty"` +} + +type contextBody struct { + Cluster string `yaml:"cluster"` + User string `yaml:"user"` +} + +// MergeResult reports what was applied. +type MergeResult struct { + ContextName string + Path string +} + +// Load reads a kubeconfig from disk. A missing file yields an empty Config. +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return &Config{APIVersion: "v1", Kind: "Config"}, nil + } + if err != nil { + return nil, err + } + var cfg Config + if len(data) > 0 { + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("failed to parse kubeconfig %s: %w", path, err) + } + } + if cfg.APIVersion == "" { + cfg.APIVersion = "v1" + } + if cfg.Kind == "" { + cfg.Kind = "Config" + } + return &cfg, nil +} + +// Write serializes cfg to path with 0600 perms, creating parent dirs (0700). +func Write(path string, cfg *Config) error { + if dir := filepath.Dir(path); dir != "" { + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + } + out, err := yaml.Marshal(cfg) + if err != nil { + return err + } + return os.WriteFile(path, out, 0o600) +} + +// Merge parses incoming (a full kubeconfig YAML string) and grafts its first +// cluster/context/user into the file at target under the name contextName. +// If setCurrent is true, current-context is set to contextName. +func Merge(target, incoming, contextName string, setCurrent bool) (*MergeResult, error) { + var src Config + if err := yaml.Unmarshal([]byte(incoming), &src); err != nil { + return nil, fmt.Errorf("failed to parse incoming kubeconfig: %w", err) + } + if len(src.Clusters) == 0 || len(src.Contexts) == 0 || len(src.Users) == 0 { + return nil, fmt.Errorf("incoming kubeconfig is missing cluster/context/user entries") + } + + dst, err := Load(target) + if err != nil { + return nil, err + } + + clusterName := "cluster_" + contextName + userName := "user_" + contextName + + cluster := src.Clusters[0] + cluster.Name = clusterName + user := src.Users[0] + user.Name = userName + ctx := NamedEntry{ + Name: contextName, + Context: &contextBody{Cluster: clusterName, User: userName}, + } + + dst.Clusters = upsert(dst.Clusters, cluster) + dst.Users = upsert(dst.Users, user) + dst.Contexts = upsert(dst.Contexts, ctx) + if setCurrent { + dst.CurrentContext = contextName + } + + if err := Write(target, dst); err != nil { + return nil, err + } + return &MergeResult{ContextName: contextName, Path: target}, nil +} + +// upsert replaces an entry with the same name or appends it. +func upsert(list []NamedEntry, e NamedEntry) []NamedEntry { + for i := range list { + if list[i].Name == e.Name { + list[i] = e + return list + } + } + return append(list, e) +} + +// findContext is a test/helper accessor. +func findContext(cfg *Config, name string) *NamedEntry { + for i := range cfg.Contexts { + if cfg.Contexts[i].Name == name { + return &cfg.Contexts[i] + } + } + return nil +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd go && go test ./internal/kubeconfig/ -v` +Expected: all three tests PASS. + +> If YAML round-tripping of `cluster`/`user` payloads loses data because the source uses different field layouts, the `*yaml.Node` passthrough preserves them as-is — verify by printing the written file in `TestMergeIntoEmptyFileCreatesContext` if a test fails, and keep the node-based approach. + +- [ ] **Step 6: Tidy modules + build** + +Run: `cd go && go mod tidy && CGO_ENABLED=0 go build -o /tmp/grn .` +Expected: `go.sum` updated, build clean. + +--- + +## Task 10: `generate-kubeconfig` command + +POST `/v1/clusters/{c}/kubeconfig` with `{expirationDays}`. Returns 202 (async); inform the user. + +**Files:** +- Create: `go/cmd/vks/generate_kubeconfig.go` +- Modify: `go/cmd/vks/vks.go` + +- [ ] **Step 1: Create the command file** + +`go/cmd/vks/generate_kubeconfig.go`: + +```go +package vks + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var generateKubeconfigCmd = &cobra.Command{ + Use: "generate-kubeconfig", + Short: "Request generation of a kubeconfig for a VKS cluster", + Long: "Requests the VKS API to generate (or renew) a kubeconfig for the cluster. " + + "This is asynchronous; once the kubeconfig becomes ACTIVE, run 'grn vks update-kubeconfig'.", + RunE: runGenerateKubeconfig, +} + +func init() { + f := generateKubeconfigCmd.Flags() + f.String("cluster-id", "", "Cluster ID (required)") + f.Int("expiration-days", 30, "Number of days until the kubeconfig expires") + + generateKubeconfigCmd.MarkFlagRequired("cluster-id") +} + +func runGenerateKubeconfig(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + expirationDays, _ := cmd.Flags().GetInt("expiration-days") + + if err := validator.ValidateID(clusterID, "cluster-id"); err != nil { + return err + } + + body := map[string]interface{}{"expirationDays": expirationDays} + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + _, err = apiClient.Post( + fmt.Sprintf("/v1/clusters/%s/kubeconfig", clusterID), body, + ) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Kubeconfig generation requested for cluster %s (expires in %d days).\n", clusterID, expirationDays) + fmt.Println("Generation is asynchronous. Once it is ACTIVE, run:") + fmt.Printf(" grn vks update-kubeconfig --cluster-id %s\n", clusterID) + return nil +} +``` + +- [ ] **Step 2: Register in vks.go** + +Add a "Kubeconfig commands" section in `vks.go` `init()`: + +```go + // Kubeconfig commands + VksCmd.AddCommand(generateKubeconfigCmd) +``` + +- [ ] **Step 3: Build + verify** + +Run: `cd go && CGO_ENABLED=0 go build -o /tmp/grn . && /tmp/grn vks generate-kubeconfig --help` +Expected: build clean; help shows `--cluster-id` required, `--expiration-days` default 30. + +--- + +## Task 11: `update-kubeconfig` command + +GET `/v1/clusters/{c}/kubeconfig`, then merge into the target file via the `internal/kubeconfig` package. + +**Files:** +- Create: `go/cmd/vks/update_kubeconfig.go` +- Modify: `go/cmd/vks/vks.go` + +- [ ] **Step 1: Create the command file** + +`go/cmd/vks/update_kubeconfig.go`: + +```go +package vks + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/kubeconfig" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var updateKubeconfigCmd = &cobra.Command{ + Use: "update-kubeconfig", + Short: "Fetch the cluster kubeconfig and merge it into your kubeconfig file", + RunE: runUpdateKubeconfig, +} + +func init() { + f := updateKubeconfigCmd.Flags() + f.String("cluster-id", "", "Cluster ID (required)") + f.String("kubeconfig", "", "Path to kubeconfig file (default: $KUBECONFIG or ~/.kube/config)") + f.String("alias", "", "Context name to use (default: vks_)") + f.Bool("no-set-context", false, "Do not set the merged context as current-context") + f.Bool("dry-run", false, "Print what would be written without modifying the file") + + updateKubeconfigCmd.MarkFlagRequired("cluster-id") +} + +// resolveKubeconfigPath picks the target path: explicit flag, then first entry +// of $KUBECONFIG, then ~/.kube/config. +func resolveKubeconfigPath(flagPath string) (string, error) { + if flagPath != "" { + return flagPath, nil + } + if env := os.Getenv("KUBECONFIG"); env != "" { + return strings.Split(env, string(os.PathListSeparator))[0], nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".kube", "config"), nil +} + +func runUpdateKubeconfig(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + kubeconfigPath, _ := cmd.Flags().GetString("kubeconfig") + alias, _ := cmd.Flags().GetString("alias") + noSetContext, _ := cmd.Flags().GetBool("no-set-context") + dryRun, _ := cmd.Flags().GetBool("dry-run") + + if err := validator.ValidateID(clusterID, "cluster-id"); err != nil { + return err + } + + contextName := alias + if contextName == "" { + contextName = "vks_" + clusterID + } + targetPath, err := resolveKubeconfigPath(kubeconfigPath) + if err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Get(fmt.Sprintf("/v1/clusters/%s/kubeconfig", clusterID), nil) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + resMap, ok := result.(map[string]interface{}) + if !ok { + return fmt.Errorf("unexpected kubeconfig response format") + } + + status, _ := resMap["status"].(string) + switch status { + case "NONE", "": + return fmt.Errorf("no kubeconfig exists for cluster %s. Run 'grn vks generate-kubeconfig --cluster-id %s' first", clusterID, clusterID) + case "CREATING": + return fmt.Errorf("kubeconfig for cluster %s is still being generated; try again shortly", clusterID) + case "ERROR": + return fmt.Errorf("kubeconfig for cluster %s is in ERROR state; re-run 'grn vks generate-kubeconfig'", clusterID) + } + + if warn, _ := resMap["renewalWarning"].(bool); warn { + fmt.Fprintf(os.Stderr, "Warning: this kubeconfig is nearing expiration. Run 'grn vks generate-kubeconfig --cluster-id %s' to renew.\n", clusterID) + } + + rawYAML, _ := resMap["kubeConfig"].(string) + if rawYAML == "" { + return fmt.Errorf("kubeconfig response did not contain kubeconfig data") + } + + if dryRun { + fmt.Printf("=== DRY RUN ===\n") + fmt.Printf("Would merge context %q into %s\n", contextName, targetPath) + if !noSetContext { + fmt.Printf("Would set current-context to %q\n", contextName) + } + return nil + } + + res, err := kubeconfig.Merge(targetPath, rawYAML, contextName, !noSetContext) + if err != nil { + return err + } + + fmt.Printf("Updated context %q in %s\n", res.ContextName, res.Path) + return nil +} +``` + +- [ ] **Step 2: Register in vks.go** + +In the "Kubeconfig commands" section: + +```go + VksCmd.AddCommand(updateKubeconfigCmd) +``` + +- [ ] **Step 3: Build + verify** + +Run: `cd go && CGO_ENABLED=0 go build -o /tmp/grn . && /tmp/grn vks update-kubeconfig --help` +Expected: build clean; help shows `--cluster-id`, `--kubeconfig`, `--alias`, `--no-set-context`, `--dry-run`. + +--- + +## Task 12: Full test suite, docs, and changelog + +**Files:** +- Create: `docs/commands/vks/{upgrade-nodegroup-version,config-auto-healing,update-nodegroup-metadata,get-cluster-events,get-nodegroup-events,list-cluster-versions,generate-kubeconfig,update-kubeconfig}.md` +- Modify: `docs/commands/vks/index.md`, `mkdocs.yml`, `README.md`, `CLAUDE.md` +- Modify: `.changes/next-release/` (via script) + +- [ ] **Step 1: Run the full test suite + build** + +Run: `cd go && go test ./... && CGO_ENABLED=0 go build -o /tmp/grn .` +Expected: all tests PASS, build clean. + +- [ ] **Step 2: Verify all 8 commands appear** + +Run: `/tmp/grn vks --help` +Expected: lists `config-auto-healing`, `generate-kubeconfig`, `get-cluster-events`, `get-nodegroup-events`, `list-cluster-versions`, `update-kubeconfig`, `update-nodegroup-metadata`, `upgrade-nodegroup-version` alongside existing commands. + +- [ ] **Step 3: Write a docs page per command** + +Create one Markdown file per command under `docs/commands/vks/`, following the existing page format (Description / Synopsis / Options / Examples) seen in `docs/commands/vks/delete-auto-upgrade-config.md`. Example for `upgrade-nodegroup-version.md`: + +```markdown +# upgrade-nodegroup-version + +## Description + +Upgrade the Kubernetes version of a node group in a VKS cluster. + +## Synopsis + +``` +grn vks upgrade-nodegroup-version + --cluster-id + --nodegroup-id + --k8s-version +``` + +## Options + +`--cluster-id` (required) +: The ID of the cluster. + +`--nodegroup-id` (required) +: The ID of the node group. + +`--k8s-version` (required) +: The target Kubernetes version. Use `grn vks list-cluster-versions` to see valid versions. + +## Examples + +Upgrade a node group: + +```bash +grn vks upgrade-nodegroup-version --cluster-id k8s-xxxxx --nodegroup-id ng-xxxxx --k8s-version v1.29.0 +``` +``` + +Write the remaining seven pages with their actual flags from Tasks 3–11 (no placeholders — list every flag with its real description). + +- [ ] **Step 4: Update the docs index and nav** + +In `docs/commands/vks/index.md`, add the 8 commands to the command table (match existing rows). In `mkdocs.yml`, add a nav entry for each new page under the VKS section (match existing entries). + +- [ ] **Step 5: Update README.md and CLAUDE.md** + +In `README.md`, add the new commands wherever VKS commands are listed. In `CLAUDE.md`, add the new files to the "Project structure" tree (the 8 command files + `internal/kubeconfig/`). + +- [ ] **Step 6: Add changelog fragments** + +Run (non-interactive): + +```bash +cd /Users/lap16104/Documents/vks/greennode-cli +./scripts/new-change -t feature -c vks -d "Add kubeconfig commands (generate-kubeconfig, update-kubeconfig) for VKS clusters" +./scripts/new-change -t feature -c vks -d "Add upgrade-nodegroup-version, config-auto-healing, and update-nodegroup-metadata commands" +./scripts/new-change -t feature -c vks -d "Add list-cluster-versions, get-cluster-events, and get-nodegroup-events commands" +``` + +Expected: three JSON fragments created under `.changes/next-release/`. + +- [ ] **Step 7: Final verification** + +Run: `cd go && go vet ./... && go test ./... && CGO_ENABLED=0 go build -o /tmp/grn .` +Expected: clean vet, tests PASS, build clean. + +> **Commit:** Per CLAUDE.md, do not auto-commit. Report completion and let the user commit/push (main is protected; work stays on the `feat/vks-missing-commands` branch). + +--- + +## Self-Review Notes + +- **Spec coverage:** All 8 commands from the design spec have tasks (Tasks 3–11), the `Patch` infra (Task 1), the YAML/kubeconfig package (Task 9), the events query helper (Task 2), and docs/changelog (Task 12). ✅ +- **Naming:** `config-auto-healing` used consistently (command, file, builder). `--k8s-version` used for the version flag per CLAUDE.md (spec said `--kubernetes-version`; corrected to repo convention, body field remains `kubernetesVersion`). +- **Type consistency:** `buildEventsQuery`, `buildAutoHealingBody`, `buildMetadataBody`, `buildUpgradeNodegroupBody`, and `kubeconfig.Merge/Load/Write/Config/NamedEntry/MergeResult` signatures match between their definition tasks and their callers. +- **0-based pagination:** events `--page` defaults to 0 and is passed verbatim, per CLAUDE.md. diff --git a/docs/superpowers/specs/2026-06-13-vks-missing-commands-design.md b/docs/superpowers/specs/2026-06-13-vks-missing-commands-design.md new file mode 100644 index 0000000..7b19359 --- /dev/null +++ b/docs/superpowers/specs/2026-06-13-vks-missing-commands-design.md @@ -0,0 +1,147 @@ +# Design: bổ sung command còn thiếu cho `grn vks` + +Ngày: 2026-06-13 + +## Mục tiêu + +Bổ sung các command thiết yếu mà GreenNode CLI (`grn vks`) chưa có nhưng API VKS +(`context/api-docs/vks.json`, OpenAPI 3.0.3) đã hỗ trợ. Phạm vi được chốt dựa +trên đối chiếu trực tiếp với API thật và mức cần thiết khi vận hành, lấy cảm hứng +từ bộ command EKS của aws-cli (đặc biệt `update-kubeconfig`). + +## Phạm vi (8 command) + +| # | Command | HTTP | Endpoint | +|---|---|---|---| +| 1 | `upgrade-nodegroup-version` | POST | `/v1/clusters/{c}/node-groups/{ng}/upgrade-version` | +| 2 | `config-auto-healing` | PATCH | `/v1/clusters/{c}/auto-healing-config` | +| 3 | `update-nodegroup-metadata` | PATCH | `/v1/clusters/{c}/node-groups/{ng}/metadata` | +| 4 | `get-cluster-events` | GET | `/v1/clusters/{c}/events` | +| 5 | `get-nodegroup-events` | GET | `/v1/clusters/{c}/node-groups/{ng}/events` | +| 6 | `list-cluster-versions` | GET | `/v1/cluster-versions` | +| 7 | `generate-kubeconfig` | POST | `/v1/clusters/{c}/kubeconfig` | +| 8 | `update-kubeconfig` | GET | `/v1/clusters/{c}/kubeconfig` (+ merge file) | + +### Ngoài phạm vi (đợt sau) +`list-nodegroup-images`, `list-nodes`, `get-upgrade-insight`, workspace +(get/create/reset-service-account), `register-fleet`/`unregister-fleet`, +`stop-poc`, `acknowledge-warning` đứng riêng (cảnh báo renewal đã được tích hợp +vào `update-kubeconfig`). + +## Thay đổi hạ tầng chung + +### a) Thêm method `Patch` vào client +`internal/client` — hàm `request()` đã nhận `method` string, chỉ cần wrapper: +```go +func (c *GreenodeClient) Patch(path string, body interface{}) (interface{}, error) { + return c.request("PATCH", path, nil, body) +} +``` + +### b) Dependency YAML +Thêm `gopkg.in/yaml.v3` vào `go.mod` (pure Go) phục vụ merge kubeconfig. + +### c) Helper query phân trang +Trong `cmd/vks/helpers.go`, thêm helper gom `action/type/page/page-size` thành +`map[string]string` (chỉ thêm key khi flag được `Changed`), tái dùng cho 2 lệnh +events. + +## Năm command theo pattern CRUD sẵn có + +Mỗi command = 1 file mới trong `cmd/vks/`, đăng ký trong `vks.go`, theo khuôn +`get_nodegroup.go` / `create_nodegroup.go`. Dùng lại `createClient`, +`outputResult`, `validator.ValidateID`, `parseLabels`, `parseTaints`. + +**Nguyên tắc PATCH:** chỉ đưa field vào body khi flag được set +(`cmd.Flags().Changed(name)`), tránh ghi đè nhầm giá trị hiện có. + +### 1. `upgrade-nodegroup-version` — file `upgrade_nodegroup_version.go` +- Flags: `--cluster-id` (req), `--nodegroup-id` (req), `--kubernetes-version` (req). +- Body: `{"kubernetesVersion": }`. +- Validate cluster-id, nodegroup-id qua `ValidateID`. + +### 2. `config-auto-healing` — file `config_auto_healing.go` +- Flags: `--cluster-id` (req), `--enable-auto-healing` (bool, req), + `--max-unhealthy` (string), `--unhealthy-range` (string), + `--timeout-unhealthy` (int). +- Body (chỉ field đã `Changed`, trừ `enableAutoHealing` luôn gửi): + `{enableAutoHealing, maxUnhealthy?, unhealthyRange?, timeoutUnhealthy?}`. +- Method: `Patch`. + +### 3. `update-nodegroup-metadata` — file `update_nodegroup_metadata.go` +- Flags: `--cluster-id` (req), `--nodegroup-id` (req), `--labels`, `--tags`, + `--taints`. +- Body (chỉ field đã `Changed`): `{labels?, tags?, taints?}`. + `labels`/`tags` parse qua `parseLabels`; `taints` qua `parseTaints`. +- Method: `Patch`. + +### 4. `get-cluster-events` — file `get_cluster_events.go` +- Flags: `--cluster-id` (req), `--action`, `--type`, `--page`, `--page-size`. +- GET với query params; trả `{items, total, page, pageSize}` → `outputResult`. + +### 5. `get-nodegroup-events` — file `get_nodegroup_events.go` +- Flags: `--cluster-id` (req), `--nodegroup-id` (req), `--action`, `--type`, + `--page`, `--page-size`. +- Như trên, endpoint nodegroup. + +### 6. `list-cluster-versions` — file `list_cluster_versions.go` +- Flags: chỉ flag chung (không cần cluster-id). +- GET `/v1/cluster-versions` → `outputResult`. Dùng để biết version hợp lệ trước + khi chạy `upgrade-nodegroup-version` / nâng cluster. + +## Kubeconfig (2 command) + +API: `POST /kubeconfig {expirationDays}` **sinh** kubeconfig (trả **202 Accepted**, +async, status `NONE → CREATING → ACTIVE`); `GET /kubeconfig` trả YAML hoàn chỉnh +`{kubeConfig, expirationAt, expirationDays, status, renewalWarning}` với token +nhúng sẵn (khác EKS dùng exec/get-token). + +### 7. `generate-kubeconfig` — file `generate_kubeconfig.go` +- Flags: `--cluster-id` (req), `--expiration-days` (int, default 30). +- POST `/kubeconfig {expirationDays}`. +- Vì trả 202 (async), in thông báo: kubeconfig đang được tạo, chờ status ACTIVE + rồi chạy `update-kubeconfig`. +- (Tùy chọn, có thể đưa đợt sau) `--wait`: poll `GET /kubeconfig` đến khi + `status == ACTIVE`. + +### 8. `update-kubeconfig` — file `update_kubeconfig.go` (merge kiểu EKS) +Luồng: +1. GET `/kubeconfig`. + - `status == NONE` → lỗi, gợi ý chạy `generate-kubeconfig` trước. + - `status == CREATING` → thông báo đang tạo, thử lại sau (không phải lỗi cứng). + - `status == ERROR` → báo lỗi. + - `renewalWarning == true` → in cảnh báo, gợi ý `generate-kubeconfig` để gia hạn + (vẫn tiếp tục merge). +2. Parse field `kubeConfig` (YAML đầy đủ). +3. Merge `clusters[]` / `contexts[]` / `users[]` vào target kubeconfig: + - target = `--kubeconfig` | `$KUBECONFIG` (entry đầu) | `~/.kube/config`. + - context name = `--alias` | `vks_`. + - set `current-context` (trừ khi `--no-set-context`). + - tạo file/dir nếu chưa có; giữ nguyên entry khác. +4. `--dry-run`: in những gì sẽ ghi, không ghi file. + +- Flags: `--cluster-id` (req), `--kubeconfig`, `--alias`, `--no-set-context`, + `--dry-run`. +- Logic merge tách ra package `internal/kubeconfig` (load → merge → write) để test + độc lập, tương tự `awscli/customizations/eks/kubeconfig.py`. + +## Đăng ký command + +Trong `cmd/vks/vks.go`, `AddCommand` cho cả 8 command, gom nhóm theo comment: +cluster ops / nodegroup ops / kubeconfig / versions & events. + +## Testing +- Unit test `internal/kubeconfig`: merge mới, ghi đè cùng context, giữ context + khác, file rỗng/chưa tồn tại, set/không set current-context. +- Unit test build body cho `upgrade-version` / `config-auto-healing` / + `update-nodegroup-metadata` (đặc biệt logic `Changed()` chỉ gửi field đã set). +- Unit test helper query phân trang. +- Theo khuôn test Go sẵn có trong repo. + +## Rủi ro / lưu ý +- Tên `config-auto-healing` lệch quy ước hiện có (`set-auto-upgrade-config`, + `delete-auto-upgrade-config`). Chấp nhận theo yêu cầu; cân nhắc thống nhất sau. +- `generate-kubeconfig` async (202): `update-kubeconfig` ngay sau đó có thể gặp + `CREATING` — đã xử lý bằng thông báo thử lại. +- VKS kubeconfig là token nhúng (không exec) → khi token hết hạn phải + `generate-kubeconfig` lại; không tự refresh như EKS. diff --git a/go/cmd/vks/builders_test.go b/go/cmd/vks/builders_test.go new file mode 100644 index 0000000..fe0a877 --- /dev/null +++ b/go/cmd/vks/builders_test.go @@ -0,0 +1,72 @@ +package vks + +import ( + "reflect" + "testing" +) + +func TestBuildEventsQueryOnlyIncludesSetValues(t *testing.T) { + got := buildEventsQuery("CREATE", "", 2, 50, map[string]bool{ + "action": true, "type": false, "page": true, "page-size": true, + }) + want := map[string]string{ + "action": "CREATE", + "page": "2", + "pageSize": "50", + } + if !reflect.DeepEqual(got, want) { + t.Errorf("buildEventsQuery = %#v, want %#v", got, want) + } +} + +func TestBuildEventsQueryEmptyWhenNothingSet(t *testing.T) { + got := buildEventsQuery("", "", 0, 0, map[string]bool{}) + if len(got) != 0 { + t.Errorf("buildEventsQuery = %#v, want empty map", got) + } +} + +func TestBuildUpgradeNodegroupBody(t *testing.T) { + got := buildUpgradeNodegroupBody("v1.29.0") + if got["kubernetesVersion"] != "v1.29.0" { + t.Errorf("body = %#v, want kubernetesVersion=v1.29.0", got) + } + if len(got) != 1 { + t.Errorf("body has %d keys, want 1", len(got)) + } +} + +func TestBuildAutoHealingBodyOnlyChangedOptionalFields(t *testing.T) { + got := buildAutoHealingBody(true, "30%", "", 600, map[string]bool{ + "max-unhealthy": true, "unhealthy-range": false, "timeout-unhealthy": true, + }) + if got["enableAutoHealing"] != true { + t.Errorf("enableAutoHealing = %#v, want true", got["enableAutoHealing"]) + } + if got["maxUnhealthy"] != "30%" { + t.Errorf("maxUnhealthy = %#v, want 30%%", got["maxUnhealthy"]) + } + if got["timeoutUnhealthy"] != 600 { + t.Errorf("timeoutUnhealthy = %#v, want 600", got["timeoutUnhealthy"]) + } + if _, ok := got["unhealthyRange"]; ok { + t.Errorf("unhealthyRange should be absent when flag not set; got %#v", got) + } +} + +func TestBuildMetadataBodyIncludesOnlyChangedKeys(t *testing.T) { + got := buildMetadataBody("env=prod", "", "dedicated=gpu:NoSchedule", map[string]bool{ + "labels": true, "tags": false, "taints": true, + }) + labels, ok := got["labels"].(map[string]string) + if !ok || labels["env"] != "prod" { + t.Errorf("labels = %#v, want env=prod", got["labels"]) + } + if _, ok := got["tags"]; ok { + t.Errorf("tags should be absent when flag not set; got %#v", got) + } + taints, ok := got["taints"].([]Taint) + if !ok || len(taints) != 1 || taints[0].Key != "dedicated" || taints[0].Effect != "NoSchedule" { + t.Errorf("taints = %#v, want one dedicated=gpu:NoSchedule taint", got["taints"]) + } +} diff --git a/go/cmd/vks/config_auto_healing.go b/go/cmd/vks/config_auto_healing.go new file mode 100644 index 0000000..9d5202d --- /dev/null +++ b/go/cmd/vks/config_auto_healing.go @@ -0,0 +1,75 @@ +package vks + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var configAutoHealingCmd = &cobra.Command{ + Use: "config-auto-healing", + Short: "Configure auto-healing for a VKS cluster", + RunE: runConfigAutoHealing, +} + +func init() { + f := configAutoHealingCmd.Flags() + f.String("cluster-id", "", "Cluster ID (required)") + f.Bool("enable-auto-healing", false, "Enable auto-healing (required)") + f.String("max-unhealthy", "", "Max unhealthy nodes, e.g. \"30%\"") + f.String("unhealthy-range", "", "Unhealthy range") + f.Int("timeout-unhealthy", 0, "Unhealthy timeout in seconds") + + configAutoHealingCmd.MarkFlagRequired("cluster-id") + configAutoHealingCmd.MarkFlagRequired("enable-auto-healing") +} + +func buildAutoHealingBody(enable bool, maxUnhealthy, unhealthyRange string, timeoutUnhealthy int, changed map[string]bool) map[string]interface{} { + body := map[string]interface{}{"enableAutoHealing": enable} + if changed["max-unhealthy"] && maxUnhealthy != "" { + body["maxUnhealthy"] = maxUnhealthy + } + if changed["unhealthy-range"] && unhealthyRange != "" { + body["unhealthyRange"] = unhealthyRange + } + if changed["timeout-unhealthy"] { + body["timeoutUnhealthy"] = timeoutUnhealthy + } + return body +} + +func runConfigAutoHealing(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + enable, _ := cmd.Flags().GetBool("enable-auto-healing") + maxUnhealthy, _ := cmd.Flags().GetString("max-unhealthy") + unhealthyRange, _ := cmd.Flags().GetString("unhealthy-range") + timeoutUnhealthy, _ := cmd.Flags().GetInt("timeout-unhealthy") + + if err := validator.ValidateID(clusterID, "cluster-id"); err != nil { + return err + } + + changed := map[string]bool{ + "max-unhealthy": cmd.Flags().Changed("max-unhealthy"), + "unhealthy-range": cmd.Flags().Changed("unhealthy-range"), + "timeout-unhealthy": cmd.Flags().Changed("timeout-unhealthy"), + } + body := buildAutoHealingBody(enable, maxUnhealthy, unhealthyRange, timeoutUnhealthy, changed) + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Patch( + fmt.Sprintf("/v1/clusters/%s/auto-healing-config", clusterID), body, + ) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + return outputResult(cmd, result) +} diff --git a/go/cmd/vks/generate_kubeconfig.go b/go/cmd/vks/generate_kubeconfig.go new file mode 100644 index 0000000..25ea18e --- /dev/null +++ b/go/cmd/vks/generate_kubeconfig.go @@ -0,0 +1,54 @@ +package vks + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var generateKubeconfigCmd = &cobra.Command{ + Use: "generate-kubeconfig", + Short: "Request generation of a kubeconfig for a VKS cluster", + Long: "Requests the VKS API to generate (or renew) a kubeconfig for the cluster. " + + "This is asynchronous; once the kubeconfig becomes ACTIVE, run 'grn vks update-kubeconfig'.", + RunE: runGenerateKubeconfig, +} + +func init() { + f := generateKubeconfigCmd.Flags() + f.String("cluster-id", "", "Cluster ID (required)") + f.Int("expiration-days", 30, "Number of days until the kubeconfig expires") + + generateKubeconfigCmd.MarkFlagRequired("cluster-id") +} + +func runGenerateKubeconfig(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + expirationDays, _ := cmd.Flags().GetInt("expiration-days") + + if err := validator.ValidateID(clusterID, "cluster-id"); err != nil { + return err + } + + body := map[string]interface{}{"expirationDays": expirationDays} + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + _, err = apiClient.Post( + fmt.Sprintf("/v1/clusters/%s/kubeconfig", clusterID), body, + ) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Kubeconfig generation requested for cluster %s (expires in %d days).\n", clusterID, expirationDays) + fmt.Println("Generation is asynchronous. Once it is ACTIVE, run:") + fmt.Printf(" grn vks update-kubeconfig --cluster-id %s\n", clusterID) + return nil +} diff --git a/go/cmd/vks/get_cluster_events.go b/go/cmd/vks/get_cluster_events.go new file mode 100644 index 0000000..883762d --- /dev/null +++ b/go/cmd/vks/get_cluster_events.go @@ -0,0 +1,61 @@ +package vks + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var getClusterEventsCmd = &cobra.Command{ + Use: "get-cluster-events", + Short: "Get the list of events for a VKS cluster", + RunE: runGetClusterEvents, +} + +func init() { + f := getClusterEventsCmd.Flags() + f.String("cluster-id", "", "Cluster ID (required)") + f.String("action", "", "Filter by action") + f.String("type", "", "Filter by event type") + f.Int("page", 0, "Page number (0-based)") + f.Int("page-size", 50, "Page size") + + getClusterEventsCmd.MarkFlagRequired("cluster-id") +} + +func runGetClusterEvents(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + action, _ := cmd.Flags().GetString("action") + eventType, _ := cmd.Flags().GetString("type") + page, _ := cmd.Flags().GetInt("page") + pageSize, _ := cmd.Flags().GetInt("page-size") + + if err := validator.ValidateID(clusterID, "cluster-id"); err != nil { + return err + } + + changed := map[string]bool{ + "action": cmd.Flags().Changed("action"), + "type": cmd.Flags().Changed("type"), + "page": cmd.Flags().Changed("page"), + "page-size": cmd.Flags().Changed("page-size"), + } + params := buildEventsQuery(action, eventType, page, pageSize, changed) + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Get( + fmt.Sprintf("/v1/clusters/%s/events", clusterID), params, + ) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + return outputResult(cmd, result) +} diff --git a/go/cmd/vks/get_nodegroup_events.go b/go/cmd/vks/get_nodegroup_events.go new file mode 100644 index 0000000..8bff6c5 --- /dev/null +++ b/go/cmd/vks/get_nodegroup_events.go @@ -0,0 +1,67 @@ +package vks + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var getNodegroupEventsCmd = &cobra.Command{ + Use: "get-nodegroup-events", + Short: "Get the list of events for a node group", + RunE: runGetNodegroupEvents, +} + +func init() { + f := getNodegroupEventsCmd.Flags() + f.String("cluster-id", "", "Cluster ID (required)") + f.String("nodegroup-id", "", "Node group ID (required)") + f.String("action", "", "Filter by action") + f.String("type", "", "Filter by event type") + f.Int("page", 0, "Page number (0-based)") + f.Int("page-size", 50, "Page size") + + getNodegroupEventsCmd.MarkFlagRequired("cluster-id") + getNodegroupEventsCmd.MarkFlagRequired("nodegroup-id") +} + +func runGetNodegroupEvents(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + nodegroupID, _ := cmd.Flags().GetString("nodegroup-id") + action, _ := cmd.Flags().GetString("action") + eventType, _ := cmd.Flags().GetString("type") + page, _ := cmd.Flags().GetInt("page") + pageSize, _ := cmd.Flags().GetInt("page-size") + + if err := validator.ValidateID(clusterID, "cluster-id"); err != nil { + return err + } + if err := validator.ValidateID(nodegroupID, "nodegroup-id"); err != nil { + return err + } + + changed := map[string]bool{ + "action": cmd.Flags().Changed("action"), + "type": cmd.Flags().Changed("type"), + "page": cmd.Flags().Changed("page"), + "page-size": cmd.Flags().Changed("page-size"), + } + params := buildEventsQuery(action, eventType, page, pageSize, changed) + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Get( + fmt.Sprintf("/v1/clusters/%s/node-groups/%s/events", clusterID, nodegroupID), params, + ) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + return outputResult(cmd, result) +} diff --git a/go/cmd/vks/helpers.go b/go/cmd/vks/helpers.go index 5394a56..a1103e6 100644 --- a/go/cmd/vks/helpers.go +++ b/go/cmd/vks/helpers.go @@ -134,3 +134,23 @@ func parseCommaSeparated(s string) []string { } return result } + +// buildEventsQuery builds query params for events endpoints, including only +// flags the user explicitly set. `changed` maps flag name -> was it set. +// VKS pagination is 0-based, so page is passed through verbatim. +func buildEventsQuery(action, eventType string, page, pageSize int, changed map[string]bool) map[string]string { + params := map[string]string{} + if changed["action"] && action != "" { + params["action"] = action + } + if changed["type"] && eventType != "" { + params["type"] = eventType + } + if changed["page"] { + params["page"] = fmt.Sprintf("%d", page) + } + if changed["page-size"] { + params["pageSize"] = fmt.Sprintf("%d", pageSize) + } + return params +} diff --git a/go/cmd/vks/list_cluster_versions.go b/go/cmd/vks/list_cluster_versions.go new file mode 100644 index 0000000..44eb8b8 --- /dev/null +++ b/go/cmd/vks/list_cluster_versions.go @@ -0,0 +1,29 @@ +package vks + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var listClusterVersionsCmd = &cobra.Command{ + Use: "list-cluster-versions", + Short: "List available Kubernetes versions for VKS clusters", + RunE: runListClusterVersions, +} + +func runListClusterVersions(cmd *cobra.Command, args []string) error { + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Get("/v1/cluster-versions", nil) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + return outputResult(cmd, result) +} diff --git a/go/cmd/vks/update_kubeconfig.go b/go/cmd/vks/update_kubeconfig.go new file mode 100644 index 0000000..8ed11c6 --- /dev/null +++ b/go/cmd/vks/update_kubeconfig.go @@ -0,0 +1,118 @@ +package vks + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/kubeconfig" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var updateKubeconfigCmd = &cobra.Command{ + Use: "update-kubeconfig", + Short: "Fetch the cluster kubeconfig and merge it into your kubeconfig file", + RunE: runUpdateKubeconfig, +} + +func init() { + f := updateKubeconfigCmd.Flags() + f.String("cluster-id", "", "Cluster ID (required)") + f.String("kubeconfig", "", "Path to kubeconfig file (default: $KUBECONFIG or ~/.kube/config)") + f.String("alias", "", "Context name to use (default: vks_)") + f.Bool("no-set-context", false, "Do not set the merged context as current-context") + f.Bool("dry-run", false, "Print what would be written without modifying the file") + + updateKubeconfigCmd.MarkFlagRequired("cluster-id") +} + +// resolveKubeconfigPath picks the target path: explicit flag, then first entry +// of $KUBECONFIG, then ~/.kube/config. +func resolveKubeconfigPath(flagPath string) (string, error) { + if flagPath != "" { + return flagPath, nil + } + if env := os.Getenv("KUBECONFIG"); env != "" { + return strings.Split(env, string(os.PathListSeparator))[0], nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".kube", "config"), nil +} + +func runUpdateKubeconfig(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + kubeconfigPath, _ := cmd.Flags().GetString("kubeconfig") + alias, _ := cmd.Flags().GetString("alias") + noSetContext, _ := cmd.Flags().GetBool("no-set-context") + dryRun, _ := cmd.Flags().GetBool("dry-run") + + if err := validator.ValidateID(clusterID, "cluster-id"); err != nil { + return err + } + + contextName := alias + if contextName == "" { + contextName = "vks_" + clusterID + } + targetPath, err := resolveKubeconfigPath(kubeconfigPath) + if err != nil { + return err + } + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Get(fmt.Sprintf("/v1/clusters/%s/kubeconfig", clusterID), nil) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + resMap, ok := result.(map[string]interface{}) + if !ok { + return fmt.Errorf("unexpected kubeconfig response format") + } + + status, _ := resMap["status"].(string) + switch status { + case "NONE", "": + return fmt.Errorf("no kubeconfig exists for cluster %s. Run 'grn vks generate-kubeconfig --cluster-id %s' first", clusterID, clusterID) + case "CREATING": + return fmt.Errorf("kubeconfig for cluster %s is still being generated; try again shortly", clusterID) + case "ERROR": + return fmt.Errorf("kubeconfig for cluster %s is in ERROR state; re-run 'grn vks generate-kubeconfig'", clusterID) + } + + if warn, _ := resMap["renewalWarning"].(bool); warn { + fmt.Fprintf(os.Stderr, "Warning: this kubeconfig is nearing expiration. Run 'grn vks generate-kubeconfig --cluster-id %s' to renew.\n", clusterID) + } + + rawYAML, _ := resMap["kubeConfig"].(string) + if rawYAML == "" { + return fmt.Errorf("kubeconfig response did not contain kubeconfig data") + } + + if dryRun { + fmt.Printf("=== DRY RUN ===\n") + fmt.Printf("Would merge context %q into %s\n", contextName, targetPath) + if !noSetContext { + fmt.Printf("Would set current-context to %q\n", contextName) + } + return nil + } + + res, err := kubeconfig.Merge(targetPath, rawYAML, contextName, !noSetContext) + if err != nil { + return err + } + + fmt.Printf("Updated context %q in %s\n", res.ContextName, res.Path) + return nil +} diff --git a/go/cmd/vks/update_nodegroup_metadata.go b/go/cmd/vks/update_nodegroup_metadata.go new file mode 100644 index 0000000..9e561e3 --- /dev/null +++ b/go/cmd/vks/update_nodegroup_metadata.go @@ -0,0 +1,81 @@ +package vks + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var updateNodegroupMetadataCmd = &cobra.Command{ + Use: "update-nodegroup-metadata", + Short: "Update labels, tags, and taints of a node group", + RunE: runUpdateNodegroupMetadata, +} + +func init() { + f := updateNodegroupMetadataCmd.Flags() + f.String("cluster-id", "", "Cluster ID (required)") + f.String("nodegroup-id", "", "Node group ID (required)") + f.String("labels", "", "Node labels as key=value pairs (comma-separated)") + f.String("tags", "", "Tags as key=value pairs (comma-separated)") + f.String("taints", "", "Node taints as key=value:effect (comma-separated)") + + updateNodegroupMetadataCmd.MarkFlagRequired("cluster-id") + updateNodegroupMetadataCmd.MarkFlagRequired("nodegroup-id") +} + +func buildMetadataBody(labelsStr, tagsStr, taintsStr string, changed map[string]bool) map[string]interface{} { + body := map[string]interface{}{} + if changed["labels"] { + body["labels"] = parseLabels(labelsStr) + } + if changed["tags"] { + body["tags"] = parseLabels(tagsStr) + } + if changed["taints"] { + body["taints"] = parseTaints(taintsStr) + } + return body +} + +func runUpdateNodegroupMetadata(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + nodegroupID, _ := cmd.Flags().GetString("nodegroup-id") + labelsStr, _ := cmd.Flags().GetString("labels") + tagsStr, _ := cmd.Flags().GetString("tags") + taintsStr, _ := cmd.Flags().GetString("taints") + + if err := validator.ValidateID(clusterID, "cluster-id"); err != nil { + return err + } + if err := validator.ValidateID(nodegroupID, "nodegroup-id"); err != nil { + return err + } + + changed := map[string]bool{ + "labels": cmd.Flags().Changed("labels"), + "tags": cmd.Flags().Changed("tags"), + "taints": cmd.Flags().Changed("taints"), + } + if !changed["labels"] && !changed["tags"] && !changed["taints"] { + return fmt.Errorf("at least one of --labels, --tags, --taints must be provided") + } + body := buildMetadataBody(labelsStr, tagsStr, taintsStr, changed) + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Patch( + fmt.Sprintf("/v1/clusters/%s/node-groups/%s/metadata", clusterID, nodegroupID), body, + ) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + return outputResult(cmd, result) +} diff --git a/go/cmd/vks/upgrade_nodegroup_version.go b/go/cmd/vks/upgrade_nodegroup_version.go new file mode 100644 index 0000000..7868d95 --- /dev/null +++ b/go/cmd/vks/upgrade_nodegroup_version.go @@ -0,0 +1,60 @@ +package vks + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/validator" +) + +var upgradeNodegroupVersionCmd = &cobra.Command{ + Use: "upgrade-nodegroup-version", + Short: "Upgrade the Kubernetes version of a node group", + RunE: runUpgradeNodegroupVersion, +} + +func init() { + f := upgradeNodegroupVersionCmd.Flags() + f.String("cluster-id", "", "Cluster ID (required)") + f.String("nodegroup-id", "", "Node group ID (required)") + f.String("k8s-version", "", "Target Kubernetes version (required)") + + upgradeNodegroupVersionCmd.MarkFlagRequired("cluster-id") + upgradeNodegroupVersionCmd.MarkFlagRequired("nodegroup-id") + upgradeNodegroupVersionCmd.MarkFlagRequired("k8s-version") +} + +func buildUpgradeNodegroupBody(k8sVersion string) map[string]interface{} { + return map[string]interface{}{"kubernetesVersion": k8sVersion} +} + +func runUpgradeNodegroupVersion(cmd *cobra.Command, args []string) error { + clusterID, _ := cmd.Flags().GetString("cluster-id") + nodegroupID, _ := cmd.Flags().GetString("nodegroup-id") + k8sVersion, _ := cmd.Flags().GetString("k8s-version") + + if err := validator.ValidateID(clusterID, "cluster-id"); err != nil { + return err + } + if err := validator.ValidateID(nodegroupID, "nodegroup-id"); err != nil { + return err + } + + body := buildUpgradeNodegroupBody(k8sVersion) + + apiClient, err := createClient(cmd) + if err != nil { + return err + } + + result, err := apiClient.Post( + fmt.Sprintf("/v1/clusters/%s/node-groups/%s/upgrade-version", clusterID, nodegroupID), body, + ) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + return outputResult(cmd, result) +} diff --git a/go/cmd/vks/vks.go b/go/cmd/vks/vks.go index b0f5101..64268e4 100644 --- a/go/cmd/vks/vks.go +++ b/go/cmd/vks/vks.go @@ -28,6 +28,7 @@ func init() { VksCmd.AddCommand(createNodegroupCmd) VksCmd.AddCommand(updateNodegroupCmd) VksCmd.AddCommand(deleteNodegroupCmd) + VksCmd.AddCommand(updateNodegroupMetadataCmd) // Wait commands VksCmd.AddCommand(waitClusterActiveCmd) @@ -36,6 +37,19 @@ func init() { VksCmd.AddCommand(setAutoUpgradeConfigCmd) VksCmd.AddCommand(deleteAutoUpgradeConfigCmd) + // Auto-healing commands + VksCmd.AddCommand(configAutoHealingCmd) + // Quota commands VksCmd.AddCommand(getQuotaCmd) + + // Version & event commands + VksCmd.AddCommand(listClusterVersionsCmd) + VksCmd.AddCommand(upgradeNodegroupVersionCmd) + VksCmd.AddCommand(getClusterEventsCmd) + VksCmd.AddCommand(getNodegroupEventsCmd) + + // Kubeconfig commands + VksCmd.AddCommand(generateKubeconfigCmd) + VksCmd.AddCommand(updateKubeconfigCmd) } diff --git a/go/go.mod b/go/go.mod index b91346b..5c472c9 100644 --- a/go/go.mod +++ b/go/go.mod @@ -6,6 +6,7 @@ require ( github.com/jmespath/go-jmespath v0.4.0 github.com/spf13/cobra v1.10.2 gopkg.in/ini.v1 v1.67.1 + gopkg.in/yaml.v3 v3.0.1 ) require ( diff --git a/go/go.sum b/go/go.sum index 5469892..a4048df 100644 --- a/go/go.sum +++ b/go/go.sum @@ -25,6 +25,7 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k= gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= diff --git a/go/internal/auth/token.go b/go/internal/auth/token.go index 4f768d5..45ff1d6 100644 --- a/go/internal/auth/token.go +++ b/go/internal/auth/token.go @@ -50,6 +50,15 @@ func (tm *TokenManager) GetToken() (string, error) { 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() diff --git a/go/internal/client/client.go b/go/internal/client/client.go index c0578cb..8c6433e 100644 --- a/go/internal/client/client.go +++ b/go/internal/client/client.go @@ -81,6 +81,11 @@ func (c *GreenodeClient) Put(path string, body interface{}) (interface{}, error) return c.request("PUT", path, nil, body) } +// Patch performs a PATCH request with a JSON body. +func (c *GreenodeClient) Patch(path string, body interface{}) (interface{}, error) { + return c.request("PATCH", path, nil, body) +} + // Delete performs a DELETE request. func (c *GreenodeClient) Delete(path string, params map[string]string) (interface{}, error) { return c.request("DELETE", path, params, nil) diff --git a/go/internal/client/client_test.go b/go/internal/client/client_test.go new file mode 100644 index 0000000..0df21a0 --- /dev/null +++ b/go/internal/client/client_test.go @@ -0,0 +1,41 @@ +package client + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/vngcloud/greennode-cli/internal/auth" +) + +func TestPatchSendsPatchMethodAndBody(t *testing.T) { + var gotMethod, gotBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + tm := auth.NewTokenManager("id", "secret") + // Pre-seed a static token so GetToken never calls the real IAM endpoint. + tm.SetToken("test-token", time.Now().Add(1*time.Hour)) + + c := NewGreenodeClient(srv.URL, tm, 5*time.Second, false, false) + + _, err := c.Patch("/v1/thing", map[string]interface{}{"enableAutoHealing": true}) + if err != nil { + t.Fatalf("Patch returned error: %v", err) + } + if gotMethod != http.MethodPatch { + t.Errorf("method = %q, want PATCH", gotMethod) + } + if gotBody != `{"enableAutoHealing":true}` { + t.Errorf("body = %q, want enableAutoHealing payload", gotBody) + } +} diff --git a/go/internal/kubeconfig/kubeconfig.go b/go/internal/kubeconfig/kubeconfig.go new file mode 100644 index 0000000..c299131 --- /dev/null +++ b/go/internal/kubeconfig/kubeconfig.go @@ -0,0 +1,148 @@ +// Package kubeconfig loads, merges, and writes Kubernetes kubeconfig files. +package kubeconfig + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// Config models the subset of a kubeconfig we manipulate. Cluster/User payloads +// are kept as raw yaml.Node values so unknown fields survive a round-trip. +type Config struct { + APIVersion string `yaml:"apiVersion,omitempty"` + Kind string `yaml:"kind,omitempty"` + Clusters []NamedEntry `yaml:"clusters"` + Contexts []NamedEntry `yaml:"contexts"` + Users []NamedEntry `yaml:"users"` + CurrentContext string `yaml:"current-context,omitempty"` + Preferences map[string]any `yaml:"preferences,omitempty"` +} + +// NamedEntry is a generic {name, } entry. The payload key differs by +// list (cluster/context/user); each is optional so the struct serves all three. +type NamedEntry struct { + Name string `yaml:"name"` + // Cluster and User are value-type yaml.Node (not pointers). yaml.v3 only + // populates a node's content when decoding into an addressable yaml.Node + // value; decoding into a *yaml.Node field leaves the node empty, silently + // dropping the payload. omitempty skips a zero-kind node, so the same + // struct serves cluster, context, and user lists. + Cluster yaml.Node `yaml:"cluster,omitempty"` + Context *contextBody `yaml:"context,omitempty"` + User yaml.Node `yaml:"user,omitempty"` +} + +type contextBody struct { + Cluster string `yaml:"cluster"` + User string `yaml:"user"` +} + +// MergeResult reports what was applied. +type MergeResult struct { + ContextName string + Path string +} + +// Load reads a kubeconfig from disk. A missing file yields an empty Config. +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return &Config{APIVersion: "v1", Kind: "Config"}, nil + } + if err != nil { + return nil, err + } + var cfg Config + if len(data) > 0 { + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("failed to parse kubeconfig %s: %w", path, err) + } + } + if cfg.APIVersion == "" { + cfg.APIVersion = "v1" + } + if cfg.Kind == "" { + cfg.Kind = "Config" + } + return &cfg, nil +} + +// Write serializes cfg to path with 0600 perms, creating parent dirs (0700). +func Write(path string, cfg *Config) error { + if dir := filepath.Dir(path); dir != "" { + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + } + out, err := yaml.Marshal(cfg) + if err != nil { + return err + } + return os.WriteFile(path, out, 0o600) +} + +// Merge parses incoming (a full kubeconfig YAML string) and grafts its first +// cluster/context/user into the file at target under the name contextName. +// If setCurrent is true, current-context is set to contextName. +func Merge(target, incoming, contextName string, setCurrent bool) (*MergeResult, error) { + var src Config + if err := yaml.Unmarshal([]byte(incoming), &src); err != nil { + return nil, fmt.Errorf("failed to parse incoming kubeconfig: %w", err) + } + if len(src.Clusters) == 0 || len(src.Contexts) == 0 || len(src.Users) == 0 { + return nil, fmt.Errorf("incoming kubeconfig is missing cluster/context/user entries") + } + + dst, err := Load(target) + if err != nil { + return nil, err + } + + clusterName := "cluster_" + contextName + userName := "user_" + contextName + + cluster := src.Clusters[0] + cluster.Name = clusterName + user := src.Users[0] + user.Name = userName + ctx := NamedEntry{ + Name: contextName, + Context: &contextBody{Cluster: clusterName, User: userName}, + } + + dst.Clusters = upsert(dst.Clusters, cluster) + dst.Users = upsert(dst.Users, user) + dst.Contexts = upsert(dst.Contexts, ctx) + if setCurrent { + dst.CurrentContext = contextName + } + + if err := Write(target, dst); err != nil { + return nil, err + } + return &MergeResult{ContextName: contextName, Path: target}, nil +} + +// upsert replaces an entry with the same name or appends it. +func upsert(list []NamedEntry, e NamedEntry) []NamedEntry { + for i := range list { + if list[i].Name == e.Name { + list[i] = e + return list + } + } + return append(list, e) +} + +// findContext is a helper/accessor used in tests. +func findContext(cfg *Config, name string) *NamedEntry { + for i := range cfg.Contexts { + if cfg.Contexts[i].Name == name { + return &cfg.Contexts[i] + } + } + return nil +} diff --git a/go/internal/kubeconfig/kubeconfig_test.go b/go/internal/kubeconfig/kubeconfig_test.go new file mode 100644 index 0000000..4876390 --- /dev/null +++ b/go/internal/kubeconfig/kubeconfig_test.go @@ -0,0 +1,113 @@ +package kubeconfig + +import ( + "os" + "path/filepath" + "testing" +) + +const incomingKubeconfig = `apiVersion: v1 +kind: Config +clusters: +- name: vks-cluster + cluster: + server: https://1.2.3.4:6443 + certificate-authority-data: AAAA +contexts: +- name: vks-cluster-ctx + context: + cluster: vks-cluster + user: vks-user +current-context: vks-cluster-ctx +users: +- name: vks-user + user: + token: secret-token +` + +func TestMergeIntoEmptyFileCreatesContext(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "config") + + res, err := Merge(target, incomingKubeconfig, "vks_c-123", true) + if err != nil { + t.Fatalf("Merge error: %v", err) + } + if res.ContextName != "vks_c-123" { + t.Errorf("ContextName = %q, want vks_c-123", res.ContextName) + } + + cfg, err := Load(target) + if err != nil { + t.Fatalf("Load error: %v", err) + } + if cfg.CurrentContext != "vks_c-123" { + t.Errorf("current-context = %q, want vks_c-123", cfg.CurrentContext) + } + if findContext(cfg, "vks_c-123") == nil { + t.Errorf("merged context not found in %#v", cfg.Contexts) + } +} + +func TestMergePreservesExistingContexts(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "config") + existing := `apiVersion: v1 +kind: Config +clusters: +- name: other + cluster: + server: https://9.9.9.9 +contexts: +- name: other-ctx + context: + cluster: other + user: other-user +current-context: other-ctx +users: +- name: other-user + user: + token: other +` + if err := os.WriteFile(target, []byte(existing), 0o600); err != nil { + t.Fatal(err) + } + + if _, err := Merge(target, incomingKubeconfig, "vks_c-123", false); err != nil { + t.Fatalf("Merge error: %v", err) + } + + cfg, _ := Load(target) + if findContext(cfg, "other-ctx") == nil { + t.Errorf("existing context was dropped") + } + if findContext(cfg, "vks_c-123") == nil { + t.Errorf("new context missing") + } + if cfg.CurrentContext != "other-ctx" { + t.Errorf("current-context = %q, want other-ctx (setCurrent=false)", cfg.CurrentContext) + } +} + +func TestMergeOverwritesSameContextName(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "config") + + if _, err := Merge(target, incomingKubeconfig, "vks_c-123", true); err != nil { + t.Fatal(err) + } + if _, err := Merge(target, incomingKubeconfig, "vks_c-123", true); err != nil { + t.Fatal(err) + } + + cfg, _ := Load(target) + count := 0 + for _, c := range cfg.Contexts { + if c.Name == "vks_c-123" { + count++ + } + } + if count != 1 { + t.Errorf("context vks_c-123 appears %d times, want 1", count) + } +} diff --git a/mkdocs.yml b/mkdocs.yml index b320aff..a1f94a9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -51,10 +51,22 @@ nav: - get-nodegroup: commands/vks/get-nodegroup.md - create-nodegroup: commands/vks/create-nodegroup.md - update-nodegroup: commands/vks/update-nodegroup.md + - update-nodegroup-metadata: commands/vks/update-nodegroup-metadata.md + - upgrade-nodegroup-version: commands/vks/upgrade-nodegroup-version.md - delete-nodegroup: commands/vks/delete-nodegroup.md + - Versions: + - list-cluster-versions: commands/vks/list-cluster-versions.md - Auto-Upgrade: - set-auto-upgrade-config: commands/vks/set-auto-upgrade-config.md - delete-auto-upgrade-config: commands/vks/delete-auto-upgrade-config.md + - Auto-Healing: + - config-auto-healing: commands/vks/config-auto-healing.md + - Events: + - get-cluster-events: commands/vks/get-cluster-events.md + - get-nodegroup-events: commands/vks/get-nodegroup-events.md + - Kubeconfig: + - generate-kubeconfig: commands/vks/generate-kubeconfig.md + - update-kubeconfig: commands/vks/update-kubeconfig.md - Quota: - get-quota: commands/vks/get-quota.md - Waiter: