Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changes/next-release/bugfix-configure-dkiwga2j.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"type": "bugfix",
"category": "configure",
"description": "Fix panic (nil pointer) when running 'grn configure set/list --profile <name>' for a profile that does not exist yet. LoadConfig now reads the credentials and config files independently, so a profile created via 'configure set' (config file only) loads its region/output/project_id correctly, and 'configure get'/'configure list' on a truly missing profile report a clear 'profile does not exist' error (exit 1, like 'aws configure') instead of crashing"
}
5 changes: 5 additions & 0 deletions .changes/next-release/bugfix-core-qyk3eljj.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"type": "bugfix",
"category": "core",
"description": "Honor --cli-connect-timeout: the TCP connect and TLS handshake are now bounded by the flag (previously it was accepted but ignored, so a slow/unreachable endpoint hung for ~127s regardless). Wired via the HTTP transport's dialer in both VKS and vServer clients"
}
5 changes: 5 additions & 0 deletions .changes/next-release/bugfix-output-m4udwit8.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"type": "bugfix",
"category": "output",
"description": "Reject an invalid --output or --color value (e.g. a typo like 'tabel') with a clear error, a 'maybe you meant' suggestion, and a non-zero exit, instead of silently falling back"
}
5 changes: 5 additions & 0 deletions .changes/next-release/enhancement-core-i13acudc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"type": "enhancement",
"category": "core",
"description": "On error, print a single concise 'Error: ...' line instead of cobra's duplicated error plus a full usage dump (use 'grn <command> --help' for usage)"
}
5 changes: 5 additions & 0 deletions .changes/next-release/enhancement-vks-p7174a2k.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"type": "enhancement",
"category": "vks",
"description": "Document the --unhealthy-range format in config-auto-healing help: expects \"[min-max]\" (e.g. \"[2-5]\")"
}
12 changes: 11 additions & 1 deletion go/cmd/configure/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,17 @@ func runList(cmd *cobra.Command, args []string) {
profile = "default"
}

cfg, _ := config.LoadConfig(profile)
// Report a missing profile like `aws configure list` does, rather than
// printing empty values. LoadConfig only errors when config files exist but
// the profile is in neither — a fresh machine still lists unset defaults.
cfg, err := config.LoadConfig(profile)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if cfg == nil {
cfg = &config.Config{}
}
configDir := config.DefaultConfigDir()
credsFile := filepath.Join(configDir, "credentials")
configFile := filepath.Join(configDir, "config")
Expand Down
13 changes: 8 additions & 5 deletions go/cmd/configure/set.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,33 +28,36 @@ func runSet(cmd *cobra.Command, args []string) {

writer := config.NewConfigFileWriter()

// Load existing config so unrelated fields are preserved on write. For a
// brand-new profile LoadConfig returns (nil, err); fall back to empty
// defaults so the value can still be set instead of panicking.
cfg, err := config.LoadConfig(profile)
if err != nil || cfg == nil {
cfg = &config.Config{}
}

switch key {
case "client_id":
cfg, _ := config.LoadConfig(profile)
if err := writer.WriteCredentials(profile, value, cfg.ClientSecret); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
case "client_secret":
cfg, _ := config.LoadConfig(profile)
if err := writer.WriteCredentials(profile, cfg.ClientID, value); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
case "region":
cfg, _ := config.LoadConfig(profile)
if err := writer.WriteConfig(profile, value, cfg.Output, cfg.ProjectID); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
case "output":
cfg, _ := config.LoadConfig(profile)
if err := writer.WriteConfig(profile, cfg.Region, value, cfg.ProjectID); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
case "project_id":
cfg, _ := config.LoadConfig(profile)
if err := writer.WriteConfig(profile, cfg.Region, cfg.Output, value); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
Expand Down
57 changes: 57 additions & 0 deletions go/cmd/configure/set_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package configure

import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/spf13/cobra"
)

// newConfigureTestCmd wires a root command with the persistent `profile` flag
// (registered on rootCmd in production) so the configure subcommands resolve it
// exactly as they do at runtime.
func newConfigureTestCmd() *cobra.Command {
root := &cobra.Command{Use: "grn"}
root.PersistentFlags().String("profile", "", "")
root.AddCommand(ConfigureCmd)
return root
}

// Regression: `configure set <key> <value> --profile <new>` for a profile that
// does not exist in the credentials file must not panic. LoadConfig returns
// (nil, err) for an unknown profile; set previously dereferenced the nil *Config.
func TestSetRegionOnNonExistentProfile(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)

root := newConfigureTestCmd()
root.SetArgs([]string{"configure", "set", "region", "HCM-3", "--profile", "prod-hcm-qc3"})
if err := root.Execute(); err != nil {
t.Fatalf("set region on new profile failed: %v", err)
}

data, err := os.ReadFile(filepath.Join(home, ".greenode", "config"))
if err != nil {
t.Fatalf("config file not written: %v", err)
}
content := string(data)
if !strings.Contains(content, "prod-hcm-qc3") || !strings.Contains(content, "HCM-3") {
t.Errorf("config missing profile/region; got:\n%s", content)
}
}

// On a fresh machine (no config files at all) `configure list` must not panic
// and renders unset defaults — matching `aws configure list`. (A profile that
// is missing while config files DO exist exits non-zero via os.Exit, which is
// covered by the binary-level checks rather than here.)
func TestListOnFreshMachineNoFiles(t *testing.T) {
t.Setenv("HOME", t.TempDir())

root := newConfigureTestCmd()
root.SetArgs([]string{"configure", "list", "--profile", "ghost"})
if err := root.Execute(); err != nil {
t.Fatalf("list on fresh machine failed: %v", err)
}
}
11 changes: 10 additions & 1 deletion go/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ var rootCmd = &cobra.Command{
Use: "grn",
Short: "GreenNode CLI - unified command-line tool for GreenNode (VNG Cloud) services",
Version: fmt.Sprintf("%s Go/%s %s/%s", cliVersion, runtime.Version()[2:], runtime.GOOS, runtime.GOARCH),
// Print a single clean "Error: ..." line on failure (done in Execute) rather
// than cobra's error plus a full usage dump.
SilenceErrors: true,
SilenceUsage: true,
Long: `GreenNode CLI (grn) is a unified command-line tool for managing
GreenNode (VNG Cloud) services including VKS (VNG Kubernetes Service).

Expand All @@ -39,6 +43,11 @@ To get started, run:

For help on any command:
grn <command> --help`,
// Validate global flags up front so an invalid --output fails fast with a
// suggestion, rather than silently falling back to JSON.
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
return validateGlobalFlags(cmd)
},
Run: func(cmd *cobra.Command, args []string) {
cmd.Help()
},
Expand Down Expand Up @@ -72,7 +81,7 @@ func init() {
// Execute runs the root command.
func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
100 changes: 100 additions & 0 deletions go/cmd/validate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package cmd

import (
"fmt"
"slices"
"strings"

"github.com/spf13/cobra"
)

var (
validOutputFormats = []string{"json", "text", "table"}
validColorModes = []string{"on", "off", "auto"}
)

// validateGlobalFlags rejects invalid values for global flags before any work
// runs, mirroring `aws`/`gcloud` which fail fast on an unknown value instead of
// silently falling back. Only validates flags the user explicitly set.
func validateGlobalFlags(cmd *cobra.Command) error {
checks := []struct {
flag string
label string
valid []string
}{
{"output", "output format", validOutputFormats},
{"color", "color mode", validColorModes},
}
for _, c := range checks {
if !cmd.Flags().Changed(c.flag) {
continue
}
v, _ := cmd.Flags().GetString(c.flag)
if err := validateChoice(c.label, v, c.valid); err != nil {
return err
}
}
return nil
}

// validateOutputFormat returns an error when value is a non-empty,
// unrecognized output format.
func validateOutputFormat(value string) error {
return validateChoice("output format", value, validOutputFormats)
}

// validateChoice returns an error when value is non-empty and not one of valid.
// The message echoes the bad value, suggests the closest valid option when one
// is near, and always lists the valid options. label names the thing being
// validated (e.g. "output format").
func validateChoice(label, value string, valid []string) error {
if value == "" {
return nil
}
if slices.Contains(valid, value) {
return nil
}
msg := fmt.Sprintf("invalid %s: '%s'", label, value)
if s := suggestClosest(value, valid); s != "" {
msg += fmt.Sprintf(", maybe you meant '%s'", s)
}
msg += fmt.Sprintf(" (valid values: %s)", strings.Join(valid, ", "))
return fmt.Errorf("%s", msg)
}

// suggestClosest returns the option nearest to value by Levenshtein distance,
// or "" when the nearest is too far to be a likely typo (distance > 2).
func suggestClosest(value string, options []string) string {
best := ""
bestDist := 1 << 30
for _, opt := range options {
if d := levenshtein(value, opt); d < bestDist {
bestDist, best = d, opt
}
}
if bestDist > 2 {
return ""
}
return best
}

func levenshtein(a, b string) int {
ra, rb := []rune(a), []rune(b)
prev := make([]int, len(rb)+1)
curr := make([]int, len(rb)+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= len(ra); i++ {
curr[0] = i
for j := 1; j <= len(rb); j++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
curr[j] = min(prev[j]+1, curr[j-1]+1, prev[j-1]+cost)
}
prev, curr = curr, prev
}
return prev[len(rb)]
}
76 changes: 76 additions & 0 deletions go/cmd/validate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package cmd

import (
"strings"
"testing"
)

func TestValidateOutputFormatValid(t *testing.T) {
for _, v := range []string{"json", "text", "table"} {
if err := validateOutputFormat(v); err != nil {
t.Errorf("%q should be valid, got %v", v, err)
}
}
}

func TestValidateOutputFormatEmptyIsValid(t *testing.T) {
// Empty means the flag was not provided; default resolution handles it.
if err := validateOutputFormat(""); err != nil {
t.Errorf("empty should be valid, got %v", err)
}
}

func TestValidateOutputFormatTypoSuggests(t *testing.T) {
err := validateOutputFormat("tabel")
if err == nil {
t.Fatal("typo 'tabel' should be rejected")
}
msg := err.Error()
if !strings.Contains(msg, "tabel") {
t.Errorf("error should echo the bad value, got %q", msg)
}
if !strings.Contains(msg, "table") {
t.Errorf("error should suggest 'table' (did you mean), got %q", msg)
}
}

func TestValidateOutputFormatUnknownNoSuggestion(t *testing.T) {
err := validateOutputFormat("xml")
if err == nil {
t.Fatal("'xml' should be rejected")
}
// Far from any valid value: no bogus suggestion, but valid list is shown.
if !strings.Contains(err.Error(), "json") {
t.Errorf("error should list valid formats, got %q", err.Error())
}
}

func TestValidateChoiceColor(t *testing.T) {
for _, v := range []string{"on", "off", "auto", ""} {
if err := validateChoice("color mode", v, validColorModes); err != nil {
t.Errorf("%q should be valid, got %v", v, err)
}
}
err := validateChoice("color mode", "atuo", validColorModes)
if err == nil {
t.Fatal("typo 'atuo' should be rejected")
}
if !strings.Contains(err.Error(), "auto") {
t.Errorf("error should suggest 'auto', got %q", err.Error())
}
}

func TestSuggestClosest(t *testing.T) {
opts := []string{"json", "text", "table"}
cases := map[string]string{
"tabel": "table",
"jsno": "json",
"txt": "text",
"xml": "", // too far -> no suggestion
}
for in, want := range cases {
if got := suggestClosest(in, opts); got != want {
t.Errorf("suggestClosest(%q) = %q, want %q", in, got, want)
}
}
}
2 changes: 1 addition & 1 deletion go/cmd/vks/config_auto_healing.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ func init() {
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.String("unhealthy-range", "", "Unhealthy node count range as \"[min-max]\", e.g. \"[2-5]\"")
f.Int("timeout-unhealthy", 0, "Unhealthy timeout in seconds")

configAutoHealingCmd.MarkFlagRequired("cluster-id")
Expand Down
6 changes: 4 additions & 2 deletions go/internal/cli/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ func NewClient(cmd *cobra.Command, serviceName string) (*client.GreenodeClient,
endpointURL, _ := cmd.Flags().GetString("endpoint-url")
noVerifySSL, _ := cmd.Flags().GetBool("no-verify-ssl")
debug, _ := cmd.Flags().GetBool("debug")
connectTimeout, _ := cmd.Flags().GetInt("cli-connect-timeout")
readTimeout, _ := cmd.Flags().GetInt("cli-read-timeout")

cfg, err := config.LoadConfig(profile)
Expand Down Expand Up @@ -52,7 +53,8 @@ func NewClient(cmd *cobra.Command, serviceName string) (*client.GreenodeClient,
}

tokenManager := auth.NewTokenManager(cfg.ClientID, cfg.ClientSecret)
timeout := time.Duration(readTimeout) * time.Second
connect := time.Duration(connectTimeout) * time.Second
read := time.Duration(readTimeout) * time.Second

return client.NewGreenodeClient(baseURL, tokenManager, timeout, !noVerifySSL, debug), nil
return client.NewGreenodeClient(baseURL, tokenManager, connect, read, !noVerifySSL, debug), nil
}
Loading
Loading