diff --git a/cmd/internal/platform/config.go b/cmd/internal/platform/config.go index 4570bbef29..7c6a7da8f0 100644 --- a/cmd/internal/platform/config.go +++ b/cmd/internal/platform/config.go @@ -103,6 +103,7 @@ type GlobalFlag struct { PrivateKey string BaseURL string Timeout int + WaitTimeout int // 同步轮询总超时(秒),0 表示用内置默认(600s) MaxRetryTimes int } diff --git a/cmd/product_registration_test.go b/cmd/product_registration_test.go index f10fef9365..27fb0c3211 100644 --- a/cmd/product_registration_test.go +++ b/cmd/product_registration_test.go @@ -52,6 +52,7 @@ func TestRegisteredProductsUseCommandDirectoryProducts(t *testing.T) { {product: "ulb", commands: []string{"ulb"}}, {product: "umodelverse", commands: []string{"umodelverse"}}, {product: "uphost", commands: []string{"uphost"}}, + {product: "utidb", commands: []string{"utidb"}}, {product: "vpc", commands: []string{"vpc"}}, } { assertProductCommands(t, byName, tt.product, tt.commands) diff --git a/cmd/products.gen.go b/cmd/products.gen.go index 8a02dabd04..ce9f68beeb 100644 --- a/cmd/products.gen.go +++ b/cmd/products.gen.go @@ -10,20 +10,26 @@ import ( "github.com/ucloud/ucloud-cli/products/image" "github.com/ucloud/ucloud-cli/products/memcache" "github.com/ucloud/ucloud-cli/products/mysql" + "github.com/ucloud/ucloud-cli/products/nlb" "github.com/ucloud/ucloud-cli/products/pathx" "github.com/ucloud/ucloud-cli/products/redis" "github.com/ucloud/ucloud-cli/products/sharedbw" "github.com/ucloud/ucloud-cli/products/subnet" + "github.com/ucloud/ucloud-cli/products/uclickhouse" + "github.com/ucloud/ucloud-cli/products/uddos" "github.com/ucloud/ucloud-cli/products/udisk" "github.com/ucloud/ucloud-cli/products/udpn" "github.com/ucloud/ucloud-cli/products/ufs" "github.com/ucloud/ucloud-cli/products/uhost" + "github.com/ucloud/ucloud-cli/products/ukafka" "github.com/ucloud/ucloud-cli/products/ulb" "github.com/ucloud/ucloud-cli/products/umodelverse" + "github.com/ucloud/ucloud-cli/products/umongodb" "github.com/ucloud/ucloud-cli/products/upfs" "github.com/ucloud/ucloud-cli/products/uphost" "github.com/ucloud/ucloud-cli/products/urocketmq" "github.com/ucloud/ucloud-cli/products/usnap" + "github.com/ucloud/ucloud-cli/products/utidb" "github.com/ucloud/ucloud-cli/products/vpc" ) @@ -37,20 +43,26 @@ func registeredProducts() []cli.Product { image.New(), memcache.New(), mysql.New(), + nlb.New(), pathx.New(), redis.New(), sharedbw.New(), subnet.New(), + uclickhouse.New(), + uddos.New(), udisk.New(), udpn.New(), ufs.New(), uhost.New(), + ukafka.New(), ulb.New(), umodelverse.New(), + umongodb.New(), upfs.New(), uphost.New(), urocketmq.New(), usnap.New(), + utidb.New(), vpc.New(), } } diff --git a/cmd/root.go b/cmd/root.go index 8b92d4eba0..40bd350a94 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -20,6 +20,7 @@ import ( "os" "strconv" "strings" + "time" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -190,6 +191,7 @@ func applyGlobalOverrideFlags(root *cobra.Command) { c.PersistentFlags().StringVar(&global.PrivateKey, "private-key", global.PrivateKey, "Set private-key to override the private-key in local config file") c.PersistentFlags().StringVar(&global.BaseURL, "base-url", "", "Set base-url to override the base-url in local config file") c.PersistentFlags().IntVar(&global.Timeout, "timeout-sec", 0, "Set timeout-sec to override the timeout-sec in local config file") + c.PersistentFlags().IntVar(&global.WaitTimeout, "wait-timeout-sec", 0, "Set the total timeout in seconds for synchronous wait/poll (e.g. cluster/host create). 0 uses the built-in default (600s)") c.PersistentFlags().IntVar(&global.MaxRetryTimes, "max-retry-times", -1, "Set max-retry-times to override the max-retry-times in local config file") } } @@ -241,6 +243,9 @@ func Execute() { } } platform.InitConfig() + if global.WaitTimeout > 0 { + cli.SetUserPollTimeout(time.Duration(global.WaitTimeout) * time.Second) + } setActiveRuntimeFromBaseGlobals() mode := os.Getenv("UCLOUD_CLI_DEBUG") if mode == "on" || global.Debug { @@ -328,9 +333,40 @@ func init() { } } } + if sec, found, err := parseWaitTimeoutSec(os.Args); found { + if err != nil { + fmt.Printf("parse wait-timeout-sec failed: %v\n", err) + } else { + global.WaitTimeout = sec + } + } cobra.EnableCommandSorting = false } +// parseWaitTimeoutSec scans args for --wait-timeout-sec in either +// `--wait-timeout-sec N` or `--wait-timeout-sec=N` form. found=true when +// the flag is present with a value; err is set when that value isn't an int. +func parseWaitTimeoutSec(args []string) (sec int, found bool, err error) { + const flag = "--wait-timeout-sec" + for idx, arg := range args { + if arg == flag { + if len(args) > idx+1 && args[idx+1] != "" { + sec, err = strconv.Atoi(args[idx+1]) + return sec, true, err + } + continue + } + if val, ok := strings.CutPrefix(arg, flag+"="); ok { + if val == "" { + continue + } + sec, err = strconv.Atoi(val) + return sec, true, err + } + } + return 0, false, nil +} + func resetHelpFunc(cmd *cobra.Command) { for _, a := range os.Args { if a == "-h" { diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 0000000000..d3884ba294 --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,83 @@ +package cmd + +import "testing" + +// TestParseWaitTimeoutSec covers the --wait-timeout-sec startup pre-scan +// helper, which must accept both the space form (--wait-timeout-sec 1800) +// and the equals form (--wait-timeout-sec=1800). See issue #119: the equals +// form was previously silently ignored because init()'s manual os.Args scan +// only matched arg == "--wait-timeout-sec" exactly. +func TestParseWaitTimeoutSec(t *testing.T) { + tests := []struct { + name string + args []string + wantSec int + wantFound bool + wantErr bool + }{ + { + name: "space form", + args: []string{"ucloud", "uhost", "--wait-timeout-sec", "1800"}, + wantSec: 1800, + wantFound: true, + wantErr: false, + }, + { + name: "equals form", + args: []string{"ucloud", "uhost", "--wait-timeout-sec=1800"}, + wantSec: 1800, + wantFound: true, + wantErr: false, + }, + { + name: "absent", + args: []string{"ucloud", "uhost"}, + wantSec: 0, + wantFound: false, + wantErr: false, + }, + { + name: "equals with bad int", + args: []string{"ucloud", "--wait-timeout-sec=abc"}, + wantSec: 0, + wantFound: true, + wantErr: true, + }, + { + name: "space with bad int", + args: []string{"ucloud", "--wait-timeout-sec", "abc"}, + wantSec: 0, + wantFound: true, + wantErr: true, + }, + { + name: "trailing flag no value", + args: []string{"ucloud", "--wait-timeout-sec"}, + wantSec: 0, + wantFound: false, + wantErr: false, + }, + { + name: "empty equals", + args: []string{"ucloud", "--wait-timeout-sec="}, + wantSec: 0, + wantFound: false, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sec, found, err := parseWaitTimeoutSec(tt.args) + if sec != tt.wantSec { + t.Errorf("sec = %d, want %d", sec, tt.wantSec) + } + if found != tt.wantFound { + t.Errorf("found = %v, want %v", found, tt.wantFound) + } + if (err != nil) != tt.wantErr { + t.Errorf("err = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/go.mod b/go.mod index cbdd673b5f..28bb7f119c 100644 --- a/go.mod +++ b/go.mod @@ -10,8 +10,7 @@ require ( github.com/sirupsen/logrus v1.3.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 - github.com/ucloud/ucloud-sdk-go v0.22.89 - golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 + github.com/ucloud/ucloud-sdk-go v0.22.90 gopkg.in/yaml.v2 v2.2.2 ) @@ -25,5 +24,6 @@ require ( github.com/russross/blackfriday/v2 v2.1.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 // indirect + golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 // indirect gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect ) diff --git a/go.sum b/go.sum index 15710e52f7..02ec0d7689 100644 --- a/go.sum +++ b/go.sum @@ -38,8 +38,8 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/ucloud/ucloud-sdk-go v0.22.89 h1:eCvCtkan5KIzzBv+QJgEQojCbwwS+hVtOq52hfGnWtg= -github.com/ucloud/ucloud-sdk-go v0.22.89/go.mod h1:dyLmFHmUfgb4RZKYQP9IArlvQ2pxzFthfhwxRzOEPIw= +github.com/ucloud/ucloud-sdk-go v0.22.90 h1:OYYPYlWZFQ5DB15akEkrd/ty28zkd6Als7PQ6shywzk= +github.com/ucloud/ucloud-sdk-go v0.22.90/go.mod h1:dyLmFHmUfgb4RZKYQP9IArlvQ2pxzFthfhwxRzOEPIw= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= diff --git a/pkg/cli/context.go b/pkg/cli/context.go index 09834a58a8..9735fc2c24 100644 --- a/pkg/cli/context.go +++ b/pkg/cli/context.go @@ -55,7 +55,7 @@ type Context struct { logWarn func(io.Writer, ...string) logError func(io.Writer, ...string) logFilePath func() string - newPoller func(func(string, *request.CommonBase) (interface{}, error), io.Writer) Poller + newPoller func(func(string, *request.CommonBase) (interface{}, error), io.Writer, ...PollerOption) Poller // errCount tallies HandleError calls this invocation so the host (cmd) can // set a non-zero exit code when any product error occurred (aws/gcloud @@ -88,7 +88,7 @@ type Deps struct { LogWarn func(io.Writer, ...string) LogError func(io.Writer, ...string) LogFilePath func() string - NewPoller func(func(string, *request.CommonBase) (interface{}, error), io.Writer) Poller + NewPoller func(func(string, *request.CommonBase) (interface{}, error), io.Writer, ...PollerOption) Poller } // NewContext constructs a Context from the provided Deps. diff --git a/pkg/cli/forward.go b/pkg/cli/forward.go index 1cef34995e..23551fb87a 100644 --- a/pkg/cli/forward.go +++ b/pkg/cli/forward.go @@ -177,14 +177,14 @@ func (c *Context) LogFilePath() string { return c.logFilePath() } func (c *Context) PickResourceID(s string) string { return PickResourceID(s) } // Poller returns a platform poller bound to ctx's writer. -func (c *Context) Poller(describeFunc func(string, *request.CommonBase) (interface{}, error)) Poller { - return c.newPoller(describeFunc, c.out) +func (c *Context) Poller(describeFunc func(string, *request.CommonBase) (interface{}, error), opts ...PollerOption) Poller { + return c.newPoller(describeFunc, c.out, opts...) } // PollerTo wraps base.NewSpoller bound to an explicit writer, so callers can // route progress narration to stderr (e.g. in json/yaml mode) while keeping // machine output on stdout. Products cannot import base directly, so this // exposes the writer-parameterized poller through the Context. -func (c *Context) PollerTo(w io.Writer, describeFunc func(string, *request.CommonBase) (interface{}, error)) Poller { - return c.newPoller(describeFunc, w) +func (c *Context) PollerTo(w io.Writer, describeFunc func(string, *request.CommonBase) (interface{}, error), opts ...PollerOption) Poller { + return c.newPoller(describeFunc, w, opts...) } diff --git a/pkg/cli/forward_test.go b/pkg/cli/forward_test.go index c68d4c655e..93f59d5a10 100644 --- a/pkg/cli/forward_test.go +++ b/pkg/cli/forward_test.go @@ -77,8 +77,8 @@ func TestContextBindCommonParams(t *testing.T) { func TestContextPollerToReturnsProductCompatiblePoller(t *testing.T) { ctx := cli.NewContext(cli.Deps{ - NewPoller: func(describe func(string, *request.CommonBase) (interface{}, error), out io.Writer) cli.Poller { - return cli.NewPoller(describe, out) + NewPoller: func(describe func(string, *request.CommonBase) (interface{}, error), out io.Writer, opts ...cli.PollerOption) cli.Poller { + return cli.NewPoller(describe, out, opts...) }, }) diff --git a/pkg/cli/poller.go b/pkg/cli/poller.go index 5f2640f26d..8a9b9fa13e 100644 --- a/pkg/cli/poller.go +++ b/pkg/cli/poller.go @@ -25,19 +25,64 @@ type Poller interface { } type poller struct { - describe func(string, *request.CommonBase) (interface{}, error) - out io.Writer - stateFields []string - timeout time.Duration + describe func(string, *request.CommonBase) (interface{}, error) + out io.Writer + stateFields []string + commandTimeout time.Duration + timeout time.Duration } -func NewPoller(describe func(string, *request.CommonBase) (interface{}, error), out io.Writer) Poller { - return &poller{ +// builtinPollTimeout 是同步轮询的内置兜底总超时。 +const builtinPollTimeout = 10 * time.Minute + +// userPollTimeout 是用户经 --wait-timeout-sec 指定的轮询超时(cmd 层启动时注入)。 +// 0 表示用户未指定。它是全局最高优先级,覆盖任何命令自设的默认。 +var userPollTimeout time.Duration + +// SetUserPollTimeout 设置用户层轮询超时(cmd 层接 --wait-timeout-sec)。 +// 非正值被忽略:SDK 的 waiter 在 Timeout==0 时直接报错(errTimeoutConf), +// 故此时保留其余层级,不将其置 0。 +func SetUserPollTimeout(d time.Duration) { + if d > 0 { + userPollTimeout = d + } +} + +// effectivePollTimeout 按 用户 > 命令自设 > 内置 的优先级裁决最终超时。 +func effectivePollTimeout(commandTimeout time.Duration) time.Duration { + if userPollTimeout > 0 { + return userPollTimeout + } + if commandTimeout > 0 { + return commandTimeout + } + return builtinPollTimeout +} + +// PollerOption 定制单个 poller 的创建。 +type PollerOption func(*poller) + +// WithTimeout 让单个命令声明自己的默认轮询超时。非正值被忽略。 +// 优先级低于用户 --wait-timeout-sec、高于内置默认(见 effectivePollTimeout)。 +func WithTimeout(d time.Duration) PollerOption { + return func(p *poller) { + if d > 0 { + p.commandTimeout = d + } + } +} + +func NewPoller(describe func(string, *request.CommonBase) (interface{}, error), out io.Writer, opts ...PollerOption) Poller { + p := &poller{ describe: describe, out: out, stateFields: []string{"State", "Status"}, - timeout: 10 * time.Minute, } + for _, o := range opts { + o(p) + } + p.timeout = effectivePollTimeout(p.commandTimeout) + return p } func (p *poller) Spoll(resourceID, pollText string, targetStates []string) { diff --git a/pkg/cli/poller_test.go b/pkg/cli/poller_test.go index c69803a3a1..b0e599533b 100644 --- a/pkg/cli/poller_test.go +++ b/pkg/cli/poller_test.go @@ -4,6 +4,7 @@ import ( "bytes" "strings" "testing" + "time" "github.com/ucloud/ucloud-sdk-go/ucloud/request" @@ -71,3 +72,86 @@ func TestPollerSspollNonTTYDone(t *testing.T) { } } } + +// pollNoop 是仅用于构造 poller 的空 describe 函数。 +func pollNoop(string, *request.CommonBase) (interface{}, error) { return nil, nil } + +func TestNewPollerDefaultTimeout(t *testing.T) { + p, ok := NewPoller(pollNoop, &bytes.Buffer{}).(*poller) + if !ok { + t.Fatalf("NewPoller did not return *poller") + } + if p.timeout != 10*time.Minute { + t.Errorf("default poll timeout = %v, want 10m", p.timeout) + } +} + +func TestSetUserPollTimeoutOverride(t *testing.T) { + defer func() { userPollTimeout = 0 }() // 直接复位;SetUserPollTimeout(0) 会被守卫忽略 + SetUserPollTimeout(30 * time.Minute) + p := NewPoller(pollNoop, &bytes.Buffer{}).(*poller) + if p.timeout != 30*time.Minute { + t.Errorf("after SetUserPollTimeout(30m), timeout = %v, want 30m", p.timeout) + } +} + +func TestSetUserPollTimeoutIgnoresNonPositive(t *testing.T) { + defer func() { userPollTimeout = 0 }() + SetUserPollTimeout(30 * time.Minute) + SetUserPollTimeout(0) // 忽略 + SetUserPollTimeout(-5 * time.Minute) // 忽略 + p := NewPoller(pollNoop, &bytes.Buffer{}).(*poller) + if p.timeout != 30*time.Minute { + t.Errorf("non-positive SetUserPollTimeout must be ignored, timeout = %v, want 30m", p.timeout) + } +} + +func TestEffectivePollTimeoutPriority(t *testing.T) { + defer func() { userPollTimeout = 0 }() + + // 都未设 → builtin + userPollTimeout = 0 + if got := effectivePollTimeout(0); got != 10*time.Minute { + t.Errorf("no user, no command: got %v, want 10m", got) + } + // 仅命令自设 → command + userPollTimeout = 0 + if got := effectivePollTimeout(20 * time.Minute); got != 20*time.Minute { + t.Errorf("command only: got %v, want 20m", got) + } + // 用户已设 → user 覆盖命令 + userPollTimeout = 15 * time.Minute + if got := effectivePollTimeout(20 * time.Minute); got != 15*time.Minute { + t.Errorf("user overrides command: got %v, want 15m", got) + } +} + +func TestWithTimeoutSetsCommandTimeout(t *testing.T) { + defer func() { userPollTimeout = 0 }() + userPollTimeout = 0 + p := NewPoller(pollNoop, &bytes.Buffer{}, WithTimeout(30*time.Minute)).(*poller) + if p.commandTimeout != 30*time.Minute { + t.Errorf("commandTimeout = %v, want 30m", p.commandTimeout) + } + if p.timeout != 30*time.Minute { + t.Errorf("effective timeout with command option = %v, want 30m", p.timeout) + } +} + +func TestWithTimeoutIgnoresNonPositive(t *testing.T) { + defer func() { userPollTimeout = 0 }() + userPollTimeout = 0 + p := NewPoller(pollNoop, &bytes.Buffer{}, WithTimeout(0), WithTimeout(-5*time.Minute)).(*poller) + if p.timeout != 10*time.Minute { + t.Errorf("non-positive WithTimeout must be ignored, timeout = %v, want builtin 10m", p.timeout) + } +} + +func TestUserFlagOverridesCommandOption(t *testing.T) { + defer func() { userPollTimeout = 0 }() + SetUserPollTimeout(20 * time.Minute) + p := NewPoller(pollNoop, &bytes.Buffer{}, WithTimeout(30*time.Minute)).(*poller) + if p.timeout != 20*time.Minute { + t.Errorf("user flag must override command option: timeout = %v, want 20m", p.timeout) + } +} diff --git a/pkg/cli/progress.go b/pkg/cli/progress.go index acc711823b..346d2d741b 100644 --- a/pkg/cli/progress.go +++ b/pkg/cli/progress.go @@ -50,8 +50,8 @@ func (p *Progress) Refresh(text string) { ui.NewRefresh(p.out).Do(text) } // Sspoll runs the concurrent poller into block, bound to the progress writer. func (p *Progress) Sspoll(describe func(string, *request.CommonBase) (interface{}, error), - resourceID, text string, targetStates []string, block *Block, common *request.CommonBase) { - NewPoller(describe, p.out).Sspoll(resourceID, text, targetStates, block, common) + resourceID, text string, targetStates []string, block *Block, common *request.CommonBase, opts ...PollerOption) { + NewPoller(describe, p.out, opts...).Sspoll(resourceID, text, targetStates, block, common) } // ConcurrentAction runs actionFunc over reqs with bounded concurrency (limit), diff --git a/products/css/internal/css/create.go b/products/css/internal/css/create.go index 72ad537961..95fff18e84 100644 --- a/products/css/internal/css/create.go +++ b/products/css/internal/css/create.go @@ -48,7 +48,7 @@ func newCreate(ctx *cli.Context) *cobra.Command { flags := cmd.Flags() flags.SortFlags = false req.InstanceName = flags.String("name", "", "Required. Instance name") - req.AppVersion = flags.String("app-version", "", "Required. Application version, e.g. 7.10.0") + req.AppVersion = flags.String("app-version", "", "Required. Application version, e.g. 7.10.2") req.AppName = flags.String("app-name", "elasticsearch", "Optional. Application name, default elasticsearch") req.NodeConf = flags.String("node-conf", "", "Required. Node configuration identifier") req.NodeDiskConf = flags.String("node-disk-conf", "CLOUD_RSSD", "Required. Node disk type") diff --git a/products/css/internal/css/nodeconf.go b/products/css/internal/css/nodeconf.go index e1b5f1f8f7..127fb047b9 100644 --- a/products/css/internal/css/nodeconf.go +++ b/products/css/internal/css/nodeconf.go @@ -40,7 +40,7 @@ func newNodeConf(ctx *cli.Context) *cobra.Command { } flags := cmd.Flags() flags.SortFlags = false - req.AppVersion = flags.String("app-version", "", "Required. Application version, e.g. elasticsearch-7.10.0") + req.AppVersion = flags.String("app-version", "", "Required. Application version, e.g. 7.10.2") req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") diff --git a/products/memcache/internal/memcache/cmd.go b/products/memcache/internal/memcache/cmd.go index dd4e53b1ac..569a0edfe5 100644 --- a/products/memcache/internal/memcache/cmd.go +++ b/products/memcache/internal/memcache/cmd.go @@ -17,5 +17,6 @@ func NewCommand(ctx *cli.Context) *cobra.Command { cmd.AddCommand(newCreate(ctx)) cmd.AddCommand(newDelete(ctx)) cmd.AddCommand(newRestart(ctx)) + cmd.AddCommand(newResize(ctx)) return cmd } diff --git a/products/memcache/internal/memcache/resize.go b/products/memcache/internal/memcache/resize.go new file mode 100644 index 0000000000..e888e7ac9b --- /dev/null +++ b/products/memcache/internal/memcache/resize.go @@ -0,0 +1,82 @@ +package memcache + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/umem" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newResize returns ucloud memcache resize. +func newResize(ctx *cli.Context) *cobra.Command { + idNames := make([]string, 0) + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewResizeUMemcacheGroupRequest() + cmd := &cobra.Command{ + Use: "resize", + Short: "Resize memcache instances", + Long: "Resize memcache instances", + Run: func(c *cobra.Command, args []string) { + reqs := make([]request.Common, len(idNames)) + for idx, idname := range idNames { + id := ctx.PickResourceID(idname) + next := *req + next.GroupId = &id + reqs[idx] = &next + } + prog := ctx.NewProgress() + if len(reqs) > 5 { + prog.Disable() + } + ctx.ConcurrentAction(reqs, 10, resize(ctx, client, prog)) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&idNames, "umem-id", nil, "Required. Resource ID of memcache to resize") + req.Size = flags.Int("size-gb", 0, "Required. Target memory size in GB. Accept values:1,2,4,8,16,32") + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + command.SetCompletion(cmd, "umem-id", func() []string { + return getIDList(ctx, *req.ProjectId, *req.Region) + }) + command.SetFlagValues(cmd, "size-gb", "1", "2", "4", "8", "16", "32") + + cmd.MarkFlagRequired("umem-id") + cmd.MarkFlagRequired("size-gb") + return cmd +} + +func resize(ctx *cli.Context, client *umem.UMemClient, prog *cli.Progress) func(request.Common) (bool, []string) { + return func(creq request.Common) (bool, []string) { + req := creq.(*umem.ResizeUMemcacheGroupRequest) + block := prog.NewBlock() + logs := []string{} + _, err := client.ResizeUMemcacheGroup(req) + if err != nil { + msg := fmt.Sprintf("resize memcache[%s] failed: %s", *req.GroupId, cli.ParseError(err)) + block.Append(cli.ParseError(err)) + logs = append(logs, msg) + return false, logs + } + text := fmt.Sprintf("memcache[%s] is resizing", *req.GroupId) + ret := ctx.PollerTo(ctx.ProgressWriter(), describeByID(ctx)).Sspoll(*req.GroupId, text, []string{UMEM_RUNNING, UMEM_FAIL}, block, nil) + if ret.Err != nil { + block.Append(cli.ParseError(ret.Err)) + logs = append(logs, ret.Err.Error()) + } + if ret.Timeout { + logs = append(logs, fmt.Sprintf("poll memcache[%s] timeout", *req.GroupId)) + } + return ret.Done, logs + } +} diff --git a/products/memcache/product.yaml b/products/memcache/product.yaml index d8dc016041..b1a5cc3514 100644 --- a/products/memcache/product.yaml +++ b/products/memcache/product.yaml @@ -1,6 +1,6 @@ name: memcache owners: - - Episkey-G + - ucloud-umem-qingpfang commands: - memcache enabled: true diff --git a/products/memcache/testdata/cmdtree.golden b/products/memcache/testdata/cmdtree.golden index 6c01f04634..6ed98b2b99 100644 --- a/products/memcache/testdata/cmdtree.golden +++ b/products/memcache/testdata/cmdtree.golden @@ -22,6 +22,12 @@ ucloud memcache list use=list short=List memcache instances flag=region short= default= required= flag=umem-id short= default= required= flag=zone short= default= required= +ucloud memcache resize use=resize short=Resize memcache instances + flag=project-id short= default= required= + flag=region short= default= required= + flag=size-gb short= default=0 required=true + flag=umem-id short= default=[] required=true + flag=zone short= default= required= ucloud memcache restart use=restart short=Restart memcache instances flag=project-id short= default= required= flag=region short= default= required= diff --git a/products/memcache/testdata/completion.golden b/products/memcache/testdata/completion.golden index 781cd1f7bf..044307428e 100644 --- a/products/memcache/testdata/completion.golden +++ b/products/memcache/testdata/completion.golden @@ -12,6 +12,11 @@ ucloud memcache delete zone dynamic ucloud memcache list project-id dynamic ucloud memcache list region dynamic ucloud memcache list zone dynamic +ucloud memcache resize project-id dynamic +ucloud memcache resize region dynamic +ucloud memcache resize size-gb static 1,16,2,32,4,8 +ucloud memcache resize umem-id dynamic +ucloud memcache resize zone dynamic ucloud memcache restart project-id dynamic ucloud memcache restart region dynamic ucloud memcache restart umem-id dynamic diff --git a/products/nlb/internal/nlb/cmd.go b/products/nlb/internal/nlb/cmd.go new file mode 100644 index 0000000000..06fc8227e2 --- /dev/null +++ b/products/nlb/internal/nlb/cmd.go @@ -0,0 +1,28 @@ +package nlb + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand assembles the `nlb` command tree. This aggregator only constructs +// the top-level command and AddCommand's one constructor per verb / sub-group +// (§2.2 file-layout convention): no business logic lives here. +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "nlb", + Short: "List and manipulate NLB (Network Load Balancer) instances", + Long: "List and manipulate NLB (Network Load Balancer) instances", + } + + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newDescribe(ctx)) + cmd.AddCommand(newCreate(ctx)) + cmd.AddCommand(newUpdate(ctx)) + cmd.AddCommand(newDelete(ctx)) + cmd.AddCommand(newListener(ctx)) + cmd.AddCommand(newTarget(ctx)) + + return cmd +} diff --git a/products/nlb/internal/nlb/completion.go b/products/nlb/internal/nlb/completion.go new file mode 100644 index 0000000000..b959c16a5a --- /dev/null +++ b/products/nlb/internal/nlb/completion.go @@ -0,0 +1,157 @@ +package nlb + +import ( + "fmt" + + nlbsdk "github.com/ucloud/ucloud-sdk-go/services/nlb" + "github.com/ucloud/ucloud-sdk-go/services/vpc" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// productName is the single source of truth for the product's command name and +// its resource-id flag (`--nlb-id`). +const productName = "nlb" + +// resourceIDFlag is the NLB instance resource-id flag, named after the product. +const resourceIDFlag = productName + "-id" // "nlb-id" + +// getAllNLB returns every NLB instance in the active region/project, paging +// through the DescribeNetworkLoadBalancers result set. +func getAllNLB(ctx *cli.Context, project, region string) ([]nlbsdk.NetworkLoadBalancer, error) { + client := cli.NewServiceClient(ctx, nlbsdk.NewClient) + req := client.NewDescribeNetworkLoadBalancersRequest() + req.ProjectId = sdk.String(cli.PickResourceID(project)) + req.Region = sdk.String(region) + + list := []nlbsdk.NetworkLoadBalancer{} + for offset, limit := 0, 100; ; offset += limit { + req.Offset = sdk.Int(offset) + req.Limit = sdk.Int(limit) + resp, err := client.DescribeNetworkLoadBalancers(req) + if err != nil { + return nil, err + } + list = append(list, resp.NLBs...) + if offset+limit >= resp.TotalCount { + break + } + } + return list, nil +} + +// getAllNLBIDNames returns "nlbId/name" completion candidates for --nlb-id. +func getAllNLBIDNames(ctx *cli.Context, project, region string) []string { + list, err := getAllNLB(ctx, project, region) + if err != nil { + return nil + } + idNames := make([]string, 0, len(list)) + for _, n := range list { + idNames = append(idNames, fmt.Sprintf("%s/%s", n.NLBId, n.Name)) + } + return idNames +} + +// getAllListeners returns the listeners of a given NLB instance. +func getAllListeners(ctx *cli.Context, nlbID, project, region string) ([]nlbsdk.Listener, error) { + if nlbID == "" { + return nil, fmt.Errorf("nlb-id can't be empty") + } + client := cli.NewServiceClient(ctx, nlbsdk.NewClient) + req := client.NewDescribeNLBListenersRequest() + req.ProjectId = sdk.String(cli.PickResourceID(project)) + req.Region = sdk.String(region) + req.NLBId = sdk.String(cli.PickResourceID(nlbID)) + resp, err := client.DescribeNLBListeners(req) + if err != nil { + return nil, err + } + return resp.Listeners, nil +} + +// getAllListenerIDNames returns "listenerId/name" completion candidates. +func getAllListenerIDNames(ctx *cli.Context, nlbID, project, region string) []string { + listeners, err := getAllListeners(ctx, nlbID, project, region) + if err != nil { + return nil + } + idNames := make([]string, 0, len(listeners)) + for _, l := range listeners { + idNames = append(idNames, fmt.Sprintf("%s/%s", l.ListenerId, l.Name)) + } + return idNames +} + +// getAllTargetIDNames returns the target ids attached to a listener, in +// "targetId/resource" form for --target-id completion. +func getAllTargetIDNames(ctx *cli.Context, nlbID, listenerID, project, region string) []string { + listeners, err := getAllListeners(ctx, nlbID, project, region) + if err != nil { + return nil + } + wantListener := cli.PickResourceID(listenerID) + idNames := []string{} + for _, l := range listeners { + if wantListener != "" && l.ListenerId != wantListener { + continue + } + for _, t := range l.Targets { + label := t.ResourceId + if label == "" { + label = t.ResourceIP + } + idNames = append(idNames, fmt.Sprintf("%s/%s", t.Id, label)) + } + } + return idNames +} + +// getAllVPCIDNames returns "vpcId/name" candidates via the VPC SDK service +// package. Cross-product completion uses the peer SDK directly, never imports +// products/vpc (§8, check-product rule 1). +func getAllVPCIDNames(ctx *cli.Context, project, region string) []string { + client := cli.NewServiceClient(ctx, vpc.NewClient) + req := client.NewDescribeVPCRequest() + req.ProjectId = sdk.String(cli.PickResourceID(project)) + req.Region = sdk.String(region) + resp, err := client.DescribeVPC(req) + if err != nil { + return nil + } + idNames := make([]string, 0, len(resp.DataSet)) + for _, v := range resp.DataSet { + idNames = append(idNames, fmt.Sprintf("%s/%s", v.VPCId, v.Name)) + } + return idNames +} + +// getAllSubnetIDNames returns "subnetId/name" candidates, optionally scoped to +// a VPC, via the VPC SDK service package. +func getAllSubnetIDNames(ctx *cli.Context, vpcID, project, region string) []string { + client := cli.NewServiceClient(ctx, vpc.NewClient) + req := client.NewDescribeSubnetRequest() + req.ProjectId = sdk.String(cli.PickResourceID(project)) + req.Region = sdk.String(region) + if vpcID != "" { + req.VPCId = sdk.String(cli.PickResourceID(vpcID)) + } + resp, err := client.DescribeSubnet(req) + if err != nil { + return nil + } + idNames := make([]string, 0, len(resp.DataSet)) + for _, s := range resp.DataSet { + idNames = append(idNames, fmt.Sprintf("%s/%s", s.SubnetId, s.SubnetName)) + } + return idNames +} + +// derefStr safely dereferences a *string bound by a flag. +func derefStr(p *string) string { + if p == nil { + return "" + } + return *p +} diff --git a/products/nlb/internal/nlb/create.go b/products/nlb/internal/nlb/create.go new file mode 100644 index 0000000000..6811a56565 --- /dev/null +++ b/products/nlb/internal/nlb/create.go @@ -0,0 +1,73 @@ +package nlb + +import ( + "fmt" + + "github.com/spf13/cobra" + + nlbsdk "github.com/ucloud/ucloud-sdk-go/services/nlb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newCreate implements `nlb create`. +func newCreate(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, nlbsdk.NewClient) + req := client.NewCreateNetworkLoadBalancerRequest() + + var couponID string + + cmd := &cobra.Command{ + Use: "create", + Short: "Create an NLB instance", + Long: "Create an NLB (Network Load Balancer) instance in the specified VPC and subnet.", + Run: func(c *cobra.Command, args []string) { + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + req.VPCId = sdk.String(ctx.PickResourceID(*req.VPCId)) + req.SubnetId = sdk.String(ctx.PickResourceID(*req.SubnetId)) + if c.Flags().Changed("coupon-id") { + req.CouponId = &couponID + } + + resp, err := client.CreateNetworkLoadBalancer(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "nlb[%s] created\n", resp.NLBId) + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.NLBId, Action: "create", Status: "Created"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + req.VPCId = flags.String("vpc-id", "", "Required. Resource ID of the VPC the NLB belongs to. See 'ucloud vpc list'.") + req.SubnetId = flags.String("subnet-id", "", "Required. Resource ID of the subnet the NLB belongs to. See 'ucloud subnet list'.") + req.Name = flags.String("name", "", "Optional. NLB instance name, 1-255 chars.") + req.IPVersion = flags.String("ip-version", "IPv4", "Optional. IP protocol version: IPv4/IPv6/DualStack.") + req.ChargeType = flags.String("charge-type", "Dynamic", "Optional. Charge type: Dynamic (by hour), Month, Year.") + req.Quantity = flags.Int("quantity", 1, "Optional. Purchase duration. For Month with value 0 means until end of month.") + req.Tag = flags.String("group", "Default", "Optional. Business group.") + req.Remark = flags.String("remark", "", "Optional. Remark of the NLB instance.") + flags.StringVar(&couponID, "coupon-id", "", "Optional. Coupon ID.") + + command.SetFlagValues(cmd, "ip-version", "IPv4", "IPv6", "DualStack") + command.SetFlagValues(cmd, "charge-type", "Dynamic", "Month", "Year") + command.SetCompletion(cmd, "vpc-id", func() []string { + return getAllVPCIDNames(ctx, derefStr(req.ProjectId), derefStr(req.Region)) + }) + command.SetCompletion(cmd, "subnet-id", func() []string { + return getAllSubnetIDNames(ctx, derefStr(req.VPCId), derefStr(req.ProjectId), derefStr(req.Region)) + }) + + cmd.MarkFlagRequired("vpc-id") + cmd.MarkFlagRequired("subnet-id") + + return cmd +} diff --git a/products/nlb/internal/nlb/delete.go b/products/nlb/internal/nlb/delete.go new file mode 100644 index 0000000000..d819a53bda --- /dev/null +++ b/products/nlb/internal/nlb/delete.go @@ -0,0 +1,68 @@ +package nlb + +import ( + "fmt" + + "github.com/spf13/cobra" + + nlbsdk "github.com/ucloud/ucloud-sdk-go/services/nlb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newDelete implements `nlb delete`. +func newDelete(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, nlbsdk.NewClient) + req := client.NewDeleteNetworkLoadBalancerRequest() + + var idNames []string + var yes bool + + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete NLB instances by resource ID", + Long: "Delete one or more NLB instances by resource ID.", + Run: func(c *cobra.Command, args []string) { + ok, err := ctx.Confirm(yes, "Are you sure you want to delete the NLB instance(s)?") + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + results := []cli.OpResultRow{} + for _, idName := range idNames { + id := ctx.PickResourceID(idName) + req.NLBId = sdk.String(id) + if _, err := client.DeleteNetworkLoadBalancer(req); err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(ctx.ProgressWriter(), "nlb[%s] deleted\n", id) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "delete", Status: "Deleted"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + flags.StringSliceVar(&idNames, resourceIDFlag, nil, "Required. Resource ID(s) of the NLB instances to delete.") + flags.BoolVarP(&yes, "yes", "y", false, "Optional. Skip the confirmation prompt.") + req.ReleaseEIP = flags.Bool("release-eip", false, "Optional. Release the EIP bound to the NLB when deleting.") + + cmd.MarkFlagRequired(resourceIDFlag) + command.SetCompletion(cmd, resourceIDFlag, func() []string { + return getAllNLBIDNames(ctx, derefStr(req.ProjectId), derefStr(req.Region)) + }) + + return cmd +} diff --git a/products/nlb/internal/nlb/describe.go b/products/nlb/internal/nlb/describe.go new file mode 100644 index 0000000000..a5a4ea31a6 --- /dev/null +++ b/products/nlb/internal/nlb/describe.go @@ -0,0 +1,152 @@ +package nlb + +import ( + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + + nlbsdk "github.com/ucloud/ucloud-sdk-go/services/nlb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newDescribe implements `nlb describe`. +func newDescribe(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, nlbsdk.NewClient) + req := client.NewDescribeNetworkLoadBalancersRequest() + + var nlbID string + + cmd := &cobra.Command{ + Use: "describe", + Short: "Show details of one NLB instance", + Long: "Show the full attribute/value detail of a single NLB instance, along with its listeners and each listener's backend targets.", + Run: func(c *cobra.Command, args []string) { + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + id := ctx.PickResourceID(nlbID) + req.NLBIds = []string{id} + req.ShowDetail = sdk.Bool(true) + + resp, err := client.DescribeNetworkLoadBalancers(req) + if err != nil { + ctx.HandleError(err) + return + } + if len(resp.NLBs) == 0 { + ctx.HandleError(fmt.Errorf("NLB instance %q not found", id)) + return + } + n := resp.NLBs[0] + + ips := make([]string, 0, len(n.IPInfos)) + for _, ip := range n.IPInfos { + direction := "Forward" + if ip.Type == 2 { + direction = "Backward" + } + ips = append(ips, fmt.Sprintf("%s(%s,%s)", ip.IP, ip.AddressType, direction)) + } + + rows := []cli.DescribeRow{ + {Attribute: "ResourceID", Content: n.NLBId}, + {Attribute: "Name", Content: n.Name}, + {Attribute: "Status", Content: n.Status}, + {Attribute: "VPC", Content: n.VPCId}, + {Attribute: "Subnet", Content: n.SubnetId}, + {Attribute: "IPVersion", Content: n.IPVersion}, + {Attribute: "IP", Content: strings.Join(ips, ",")}, + {Attribute: "ForwardingMode", Content: n.ForwardingMode}, + {Attribute: "ChargeType", Content: n.ChargeType}, + {Attribute: "AutoRenew", Content: strconv.FormatBool(n.AutoRenewEnabled)}, + {Attribute: "PurchaseValue", Content: common.FormatDate(n.PurchaseValue)}, + {Attribute: "Group", Content: n.Tag}, + {Attribute: "Remark", Content: n.Remark}, + {Attribute: "ListenerCount", Content: fmt.Sprintf("%d", len(n.Listeners))}, + {Attribute: "CreationTime", Content: common.FormatDate(n.CreateTime)}, + } + printDescribe(ctx, rows) + + if len(n.Listeners) > 0 { + fmt.Fprintln(ctx.ProgressWriter(), "\nListeners:") + details := make([]ListenerDetailRow, 0, len(n.Listeners)) + for _, l := range n.Listeners { + details = append(details, ListenerDetailRow{ + ListenerID: l.ListenerId, + Name: l.Name, + Protocol: l.Protocol, + PortRange: fmt.Sprintf("%d-%d", l.StartPort, l.EndPort), + Scheduler: l.Scheduler, + ForwardSrcIPMethod: l.ForwardSrcIPMethod, + StickinessTimeout: l.StickinessTimeout, + State: l.State, + HealthCheckType: l.HealthCheckConfig.Type, + HealthCheckPort: l.HealthCheckConfig.Port, + HealthCheckReqMsg: l.HealthCheckConfig.ReqMsg, + HealthCheckResMsg: l.HealthCheckConfig.ResMsg, + TargetCount: len(l.Targets), + }) + } + ctx.PrintList(details) + + for _, l := range n.Listeners { + fmt.Fprintf(ctx.ProgressWriter(), "\nTargets of %s(%s):\n", l.ListenerId, l.Name) + targets := make([]TargetRow, 0, len(l.Targets)) + for _, t := range l.Targets { + resourceID := t.ResourceId + if resourceID == "" { + resourceID = t.ResourceIP // IP-type targets carry the address here + } + targets = append(targets, TargetRow{ + TargetID: t.Id, + Name: t.ResourceName, + ResourceType: t.ResourceType, + ResourceID: resourceID, + Port: t.Port, + Weight: t.Weight, + Enabled: t.Enabled, + State: t.State, + }) + } + ctx.PrintList(targets) + } + } + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVar(&nlbID, resourceIDFlag, "", "Required. Resource ID of the NLB instance to describe.") + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired(resourceIDFlag) + command.SetCompletion(cmd, resourceIDFlag, func() []string { + return getAllNLBIDNames(ctx, derefStr(req.ProjectId), derefStr(req.Region)) + }) + + return cmd +} + +// printDescribe renders describe rows without column headers in table mode, +// printing each attribute/content pair as an aligned key-value row. +func printDescribe(ctx *cli.Context, rows []cli.DescribeRow) { + if ctx.Format() != cli.OutputTable { + ctx.PrintList(rows) + return + } + maxWidth := 0 + for _, r := range rows { + if len(r.Attribute) > maxWidth { + maxWidth = len(r.Attribute) + } + } + for _, r := range rows { + fmt.Fprintf(ctx.Out(), "%-*s %s\n", maxWidth, r.Attribute, r.Content) + } +} diff --git a/products/nlb/internal/nlb/list.go b/products/nlb/internal/nlb/list.go new file mode 100644 index 0000000000..f1ac03f268 --- /dev/null +++ b/products/nlb/internal/nlb/list.go @@ -0,0 +1,81 @@ +package nlb + +import ( + "github.com/spf13/cobra" + + nlbsdk "github.com/ucloud/ucloud-sdk-go/services/nlb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newList implements `nlb list`. +func newList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, nlbsdk.NewClient) + req := client.NewDescribeNetworkLoadBalancersRequest() + + var nlbID string + + cmd := &cobra.Command{ + Use: "list", + Short: "List NLB instances", + Long: "List NLB instances in the active region/project.", + Run: func(c *cobra.Command, args []string) { + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + if id := ctx.PickResourceID(nlbID); id != "" { + req.NLBIds = []string{id} + } + req.VPCId = sdk.String(ctx.PickResourceID(*req.VPCId)) + req.SubnetId = sdk.String(ctx.PickResourceID(*req.SubnetId)) + + resp, err := client.DescribeNetworkLoadBalancers(req) + if err != nil { + ctx.HandleError(err) + return + } + rows := make([]NLBRow, 0, len(resp.NLBs)) + for _, n := range resp.NLBs { + rows = append(rows, NLBRow{ + ResourceID: n.NLBId, + Name: n.Name, + Status: n.Status, + VPC: n.VPCId, + Subnet: n.SubnetId, + IPVersion: n.IPVersion, + ForwardingMode: n.ForwardingMode, + AutoRenewEnabled: n.AutoRenewEnabled, + PurchaseValue: common.FormatDate(n.PurchaseValue), + Group: n.Tag, + CreationTime: common.FormatDate(n.CreateTime), + }) + } + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + flags.StringVar(&nlbID, resourceIDFlag, "", "Optional. List only the specified NLB instance.") + req.VPCId = flags.String("vpc-id", "", "Optional. List only NLB instances in the specified VPC.") + req.SubnetId = flags.String("subnet-id", "", "Optional. List only NLB instances in the specified subnet.") + req.Offset = flags.Int("offset", 0, "Optional. Offset.") + req.Limit = flags.Int("limit", 100, "Optional. Limit.") + + command.SetCompletion(cmd, resourceIDFlag, func() []string { + return getAllNLBIDNames(ctx, derefStr(req.ProjectId), derefStr(req.Region)) + }) + command.SetCompletion(cmd, "vpc-id", func() []string { + return getAllVPCIDNames(ctx, derefStr(req.ProjectId), derefStr(req.Region)) + }) + command.SetCompletion(cmd, "subnet-id", func() []string { + return getAllSubnetIDNames(ctx, derefStr(req.VPCId), derefStr(req.ProjectId), derefStr(req.Region)) + }) + + return cmd +} diff --git a/products/nlb/internal/nlb/listener.go b/products/nlb/internal/nlb/listener.go new file mode 100644 index 0000000000..2c9a5ca69f --- /dev/null +++ b/products/nlb/internal/nlb/listener.go @@ -0,0 +1,21 @@ +package nlb + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newListener assembles the `nlb listener` sub-tree. +func newListener(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "listener", + Short: "List and manipulate NLB listeners", + Long: "List and manipulate NLB listeners", + } + cmd.AddCommand(newListenerList(ctx)) + cmd.AddCommand(newListenerCreate(ctx)) + cmd.AddCommand(newListenerUpdate(ctx)) + cmd.AddCommand(newListenerDelete(ctx)) + return cmd +} diff --git a/products/nlb/internal/nlb/listener_create.go b/products/nlb/internal/nlb/listener_create.go new file mode 100644 index 0000000000..9a8f8cbe02 --- /dev/null +++ b/products/nlb/internal/nlb/listener_create.go @@ -0,0 +1,87 @@ +package nlb + +import ( + "fmt" + + "github.com/spf13/cobra" + + nlbsdk "github.com/ucloud/ucloud-sdk-go/services/nlb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newListenerCreate implements `nlb listener create`. +func newListenerCreate(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, nlbsdk.NewClient) + req := client.NewCreateNLBListenerRequest() + + var nlbID string + var healthCheckPort int + var healthCheckType, healthCheckReqMsg, healthCheckResMsg string + + cmd := &cobra.Command{ + Use: "create", + Short: "Create an NLB listener", + Long: "Create a listener on the specified NLB instance.", + Run: func(c *cobra.Command, args []string) { + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + req.NLBId = sdk.String(ctx.PickResourceID(nlbID)) + hc := &nlbsdk.CreateNLBListenerParamHealthCheckConfig{ + Enabled: sdk.Bool(true), + Type: sdk.String(healthCheckType), + Port: sdk.Int(healthCheckPort), + } + if healthCheckReqMsg != "" { + hc.ReqMsg = sdk.String(healthCheckReqMsg) + } + if healthCheckResMsg != "" { + hc.ResMsg = sdk.String(healthCheckResMsg) + } + req.HealthCheckConfig = hc + + resp, err := client.CreateNLBListener(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "nlb-listener[%s] created\n", resp.ListenerId) + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.ListenerId, Action: "create-listener", Status: "Created"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + flags.StringVar(&nlbID, resourceIDFlag, "", "Required. Resource ID of the NLB instance to add the listener to.") + req.Protocol = flags.String("protocol", "", "Required. Listen protocol: TCP/UDP.") + req.Name = flags.String("name", "", "Optional. Listener name, 1-255 chars.") + req.Scheduler = flags.String("scheduler", "RoundRobin", "Optional. Load balancing algorithm: RoundRobin/SourceHash/LeastConn/WeightLeastConn/WeightRoundRobin.") + req.StartPort = flags.Int("start-port", 1, "Required. Start port of the listen port range.") + req.EndPort = flags.Int("end-port", 65535, "Required. End port of the listen port range.") + req.StickinessTimeout = flags.Int("stickiness-timeout", 0, "Optional. Session stickiness timeout in seconds, [60-900], 0 disables it.") + req.ForwardSrcIPMethod = flags.String("forward-src-ip-method", "", "Optional. Source IP passthrough method: \"\"/None/Toa/ProxyProto.") + flags.IntVar(&healthCheckPort, "health-check-port", 0, "Optional. Health check probe port, [1-65535] (0 allowed for Ping).") + flags.StringVar(&healthCheckType, "health-check-type", "Port", "Optional. Health check method: Port/UDP/Ping.") + flags.StringVar(&healthCheckReqMsg, "health-check-req-msg", "", "Optional. UDP health check request string.") + flags.StringVar(&healthCheckResMsg, "health-check-res-msg", "", "Optional. UDP health check expected response string.") + + command.SetFlagValues(cmd, "protocol", "TCP", "UDP") + command.SetFlagValues(cmd, "scheduler", "RoundRobin", "SourceHash", "LeastConn", "WeightLeastConn", "WeightRoundRobin") + command.SetFlagValues(cmd, "forward-src-ip-method", "", "None", "Toa", "ProxyProto") + command.SetFlagValues(cmd, "health-check-type", "Port", "UDP", "Ping") + command.SetCompletion(cmd, resourceIDFlag, func() []string { + return getAllNLBIDNames(ctx, derefStr(req.ProjectId), derefStr(req.Region)) + }) + + cmd.MarkFlagRequired(resourceIDFlag) + cmd.MarkFlagRequired("protocol") + cmd.MarkFlagRequired("start-port") + cmd.MarkFlagRequired("end-port") + + return cmd +} diff --git a/products/nlb/internal/nlb/listener_delete.go b/products/nlb/internal/nlb/listener_delete.go new file mode 100644 index 0000000000..ca6434f2f6 --- /dev/null +++ b/products/nlb/internal/nlb/listener_delete.go @@ -0,0 +1,74 @@ +package nlb + +import ( + "fmt" + + "github.com/spf13/cobra" + + nlbsdk "github.com/ucloud/ucloud-sdk-go/services/nlb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newListenerDelete implements `nlb listener delete`. +func newListenerDelete(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, nlbsdk.NewClient) + req := client.NewDeleteNLBListenerRequest() + + var nlbID string + var listenerIDs []string + var yes bool + + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete NLB listeners by resource ID", + Long: "Delete one or more listeners of an NLB instance.", + Run: func(c *cobra.Command, args []string) { + ok, err := ctx.Confirm(yes, "Are you sure you want to delete the listener(s)?") + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + req.NLBId = sdk.String(ctx.PickResourceID(nlbID)) + results := []cli.OpResultRow{} + for _, idName := range listenerIDs { + id := ctx.PickResourceID(idName) + req.ListenerId = sdk.String(id) + if _, err := client.DeleteNLBListener(req); err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(ctx.ProgressWriter(), "nlb-listener[%s] deleted\n", id) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "delete-listener", Status: "Deleted"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + flags.StringVar(&nlbID, resourceIDFlag, "", "Required. Resource ID of the NLB instance.") + flags.StringSliceVar(&listenerIDs, "listener-id", nil, "Required. Resource ID(s) of the listeners to delete.") + flags.BoolVarP(&yes, "yes", "y", false, "Optional. Skip the confirmation prompt.") + + cmd.MarkFlagRequired(resourceIDFlag) + cmd.MarkFlagRequired("listener-id") + command.SetCompletion(cmd, resourceIDFlag, func() []string { + return getAllNLBIDNames(ctx, derefStr(req.ProjectId), derefStr(req.Region)) + }) + command.SetCompletion(cmd, "listener-id", func() []string { + return getAllListenerIDNames(ctx, nlbID, derefStr(req.ProjectId), derefStr(req.Region)) + }) + + return cmd +} diff --git a/products/nlb/internal/nlb/listener_list.go b/products/nlb/internal/nlb/listener_list.go new file mode 100644 index 0000000000..3743cbe09f --- /dev/null +++ b/products/nlb/internal/nlb/listener_list.go @@ -0,0 +1,100 @@ +package nlb + +import ( + "fmt" + + "github.com/spf13/cobra" + + nlbsdk "github.com/ucloud/ucloud-sdk-go/services/nlb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newListenerList implements `nlb listener list`. +func newListenerList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, nlbsdk.NewClient) + req := client.NewDescribeNLBListenersRequest() + + var nlbID, listenerID string + + cmd := &cobra.Command{ + Use: "list", + Short: "List listeners of an NLB instance", + Long: "List the listeners of the specified NLB instance, along with each listener's backend targets.", + Run: func(c *cobra.Command, args []string) { + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + req.NLBId = sdk.String(ctx.PickResourceID(nlbID)) + if id := ctx.PickResourceID(listenerID); id != "" { + req.ListenerId = sdk.String(id) + } + resp, err := client.DescribeNLBListeners(req) + if err != nil { + ctx.HandleError(err) + return + } + rows := make([]ListenerRow, 0, len(resp.Listeners)) + for _, l := range resp.Listeners { + rows = append(rows, ListenerRow{ + ListenerID: l.ListenerId, + Name: l.Name, + Protocol: l.Protocol, + Scheduler: l.Scheduler, + PortRange: fmt.Sprintf("%d-%d", l.StartPort, l.EndPort), + ForwardSrcIPMethod: l.ForwardSrcIPMethod, + State: l.State, + StickinessTimeout: l.StickinessTimeout, + HealthCheckType: l.HealthCheckConfig.Type, + HealthCheckPort: l.HealthCheckConfig.Port, + HealthCheckReqMsg: l.HealthCheckConfig.ReqMsg, + HealthCheckResMsg: l.HealthCheckConfig.ResMsg, + }) + } + ctx.PrintList(rows) + + for _, l := range resp.Listeners { + fmt.Fprintf(ctx.ProgressWriter(), "\nTargets of %s(%s):\n", l.ListenerId, l.Name) + targets := make([]TargetRow, 0, len(l.Targets)) + for _, t := range l.Targets { + resourceID := t.ResourceId + if resourceID == "" { + resourceID = t.ResourceIP // IP-type targets carry the address here + } + targets = append(targets, TargetRow{ + TargetID: t.Id, + Name: t.ResourceName, + ResourceType: t.ResourceType, + ResourceID: resourceID, + Port: t.Port, + Weight: t.Weight, + Enabled: t.Enabled, + State: t.State, + }) + } + ctx.PrintList(targets) + } + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + flags.StringVar(&nlbID, resourceIDFlag, "", "Required. Resource ID of the NLB instance.") + flags.StringVar(&listenerID, "listener-id", "", "Optional. List only the specified listener.") + req.Offset = flags.Int("offset", 0, "Optional. Offset.") + req.Limit = flags.Int("limit", 100, "Optional. Limit.") + + cmd.MarkFlagRequired(resourceIDFlag) + command.SetCompletion(cmd, resourceIDFlag, func() []string { + return getAllNLBIDNames(ctx, derefStr(req.ProjectId), derefStr(req.Region)) + }) + command.SetCompletion(cmd, "listener-id", func() []string { + return getAllListenerIDNames(ctx, nlbID, derefStr(req.ProjectId), derefStr(req.Region)) + }) + + return cmd +} diff --git a/products/nlb/internal/nlb/listener_update.go b/products/nlb/internal/nlb/listener_update.go new file mode 100644 index 0000000000..4840753f2f --- /dev/null +++ b/products/nlb/internal/nlb/listener_update.go @@ -0,0 +1,123 @@ +package nlb + +import ( + "fmt" + + "github.com/spf13/cobra" + + nlbsdk "github.com/ucloud/ucloud-sdk-go/services/nlb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newListenerUpdate implements `nlb listener update`. +func newListenerUpdate(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, nlbsdk.NewClient) + req := client.NewUpdateNLBListenerAttributeRequest() + + var nlbID, listenerID string + var name, remark, scheduler string + var forwardSrcIPMethod, healthCheckType string + var healthCheckReqMsg, healthCheckResMsg string + var startPort, endPort, healthCheckPort int + + cmd := &cobra.Command{ + Use: "update", + Short: "Update an NLB listener", + Long: "Update attributes of an NLB listener.", + Run: func(c *cobra.Command, args []string) { + flags := c.Flags() + changedHealthCheckType := flags.Changed("health-check-type") + changedHealthCheckPort := flags.Changed("health-check-port") + changedHealthCheckReqMsg := flags.Changed("health-check-req-msg") + changedHealthCheckResMsg := flags.Changed("health-check-res-msg") + changed := name != "" || remark != "" || scheduler != "" || + flags.Changed("forward-src-ip-method") || flags.Changed("start-port") || flags.Changed("end-port") || + changedHealthCheckType || changedHealthCheckPort || + changedHealthCheckReqMsg || changedHealthCheckResMsg + if !changed { + ctx.HandleError(fmt.Errorf("nothing to update: set at least one of --name/--remark/--scheduler/--forward-src-ip-method/--start-port/--end-port/--health-check-type/--health-check-port/--health-check-req-msg/--health-check-res-msg")) + return + } + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + req.NLBId = sdk.String(ctx.PickResourceID(nlbID)) + req.ListenerId = sdk.String(ctx.PickResourceID(listenerID)) + if name != "" { + req.Name = &name + } + if remark != "" { + req.Remark = &remark + } + if scheduler != "" { + req.Scheduler = &scheduler + } + if flags.Changed("forward-src-ip-method") { + req.ForwardSrcIPMethod = &forwardSrcIPMethod + } + if flags.Changed("start-port") { + req.StartPort = sdk.Int(startPort) + } + if flags.Changed("end-port") { + req.EndPort = sdk.Int(endPort) + } + if changedHealthCheckType || changedHealthCheckPort || changedHealthCheckReqMsg || changedHealthCheckResMsg { + hc := &nlbsdk.UpdateNLBListenerAttributeParamHealthCheckConfig{} + if changedHealthCheckType { + hc.Type = &healthCheckType + } + if changedHealthCheckPort { + hc.Port = sdk.Int(healthCheckPort) + } + if changedHealthCheckReqMsg { + hc.ReqMsg = &healthCheckReqMsg + } + if changedHealthCheckResMsg { + hc.ResMsg = &healthCheckResMsg + } + req.HealthCheckConfig = hc + } + if _, err := client.UpdateNLBListenerAttribute(req); err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "nlb-listener[%s] updated\n", *req.ListenerId) + ctx.EmitResult(cli.OpResultRow{ResourceID: *req.ListenerId, Action: "update-listener", Status: "Updated"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + flags.StringVar(&nlbID, resourceIDFlag, "", "Required. Resource ID of the NLB instance.") + flags.StringVar(&listenerID, "listener-id", "", "Required. Resource ID of the listener to update.") + flags.StringVar(&name, "name", "", "Optional. New listener name.") + flags.StringVar(&remark, "remark", "", "Optional. New remark.") + flags.StringVar(&scheduler, "scheduler", "", "Optional. New load balancing algorithm: RoundRobin/SourceHash/LeastConn/WeightLeastConn/WeightRoundRobin.") + flags.StringVar(&forwardSrcIPMethod, "forward-src-ip-method", "", "Optional. Source IP passthrough method: \"\"/None/Toa/ProxyProto.") + flags.IntVar(&startPort, "start-port", 1, "Optional. New start port of the listen port range (full-port-range listeners only).") + flags.IntVar(&endPort, "end-port", 65535, "Optional. New end port of the listen port range (full-port-range listeners only).") + flags.StringVar(&healthCheckType, "health-check-type", "", "Optional. Health check type: Port/UDP/Ping.") + flags.IntVar(&healthCheckPort, "health-check-port", 0, "Optional. Health check probe port, [1-65535] (0 allowed for Ping).") + flags.StringVar(&healthCheckReqMsg, "health-check-req-msg", "", "Optional. UDP health check request string.") + flags.StringVar(&healthCheckResMsg, "health-check-res-msg", "", "Optional. UDP health check expected response string.") + + command.SetFlagValues(cmd, "scheduler", "RoundRobin", "SourceHash", "LeastConn", "WeightLeastConn", "WeightRoundRobin") + command.SetFlagValues(cmd, "forward-src-ip-method", "", "None", "Toa", "ProxyProto") + command.SetFlagValues(cmd, "health-check-type", "Port", "UDP", "Ping") + command.SetCompletion(cmd, resourceIDFlag, func() []string { + return getAllNLBIDNames(ctx, derefStr(req.ProjectId), derefStr(req.Region)) + }) + command.SetCompletion(cmd, "listener-id", func() []string { + return getAllListenerIDNames(ctx, nlbID, derefStr(req.ProjectId), derefStr(req.Region)) + }) + + cmd.MarkFlagRequired(resourceIDFlag) + cmd.MarkFlagRequired("listener-id") + + return cmd +} diff --git a/products/nlb/internal/nlb/rows.go b/products/nlb/internal/nlb/rows.go new file mode 100644 index 0000000000..a2a9f2e20e --- /dev/null +++ b/products/nlb/internal/nlb/rows.go @@ -0,0 +1,71 @@ +package nlb + +// NLBRow is the output row for `nlb list`. Field declaration order is the +// table column order. +type NLBRow struct { + ResourceID string + Name string + Status string + VPC string + Subnet string + IPVersion string + ForwardingMode string + AutoRenewEnabled bool + PurchaseValue string + Group string + CreationTime string +} + +// ListenerRow is the output row for `nlb listener list`. +type ListenerRow struct { + ListenerID string + Name string + Protocol string + Scheduler string + PortRange string + ForwardSrcIPMethod string + State string + StickinessTimeout int + HealthCheckType string + HealthCheckPort int + HealthCheckReqMsg string + HealthCheckResMsg string +} + +// ListenerDetailRow is the output row for the "Listeners" detail table +// appended under `nlb describe`. It surfaces the per-listener fields the +// summary "ListenerCount" attribute row can't. HealthCheckType/HealthCheckPort +// give a health-check summary; TargetCount is an at-a-glance count, with the +// full per-target breakdown following right after in the "Targets of ..." +// tables (see TargetRow) — keeping this row itself flat/one-level-deep. +type ListenerDetailRow struct { + ListenerID string + Name string + Protocol string + PortRange string + Scheduler string + ForwardSrcIPMethod string + StickinessTimeout int + State string + HealthCheckType string + HealthCheckPort int + HealthCheckReqMsg string + HealthCheckResMsg string + TargetCount int +} + +// TargetRow is the output row for the per-listener "Targets of ..." tables +// appended under `nlb listener list` and `nlb describe` (Target sits one +// level below Listener — NLB → Listener → Target — so it is surfaced there, +// grouped by listener, rather than via a standalone `nlb target list` +// command). +type TargetRow struct { + TargetID string + Name string + ResourceType string + ResourceID string + Port int + Weight int + Enabled bool + State string +} diff --git a/products/nlb/internal/nlb/target.go b/products/nlb/internal/nlb/target.go new file mode 100644 index 0000000000..9c7a709e5d --- /dev/null +++ b/products/nlb/internal/nlb/target.go @@ -0,0 +1,21 @@ +package nlb + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newTarget assembles the `nlb target` sub-tree (backend service nodes attached +// to a listener). +func newTarget(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "target", + Short: "Manage NLB backend targets (service nodes)", + Long: "Add, remove and update the backend targets of an NLB listener.", + } + cmd.AddCommand(newTargetAdd(ctx)) + cmd.AddCommand(newTargetRemove(ctx)) + cmd.AddCommand(newTargetUpdate(ctx)) + return cmd +} diff --git a/products/nlb/internal/nlb/target_add.go b/products/nlb/internal/nlb/target_add.go new file mode 100644 index 0000000000..87dcbae455 --- /dev/null +++ b/products/nlb/internal/nlb/target_add.go @@ -0,0 +1,195 @@ +package nlb + +import ( + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + + nlbsdk "github.com/ucloud/ucloud-sdk-go/services/nlb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// parseTarget parses a "--target" value. +// +// Non-IP format: "resourceType:resourceId:port[:weight[:enabled]]" +// +// e.g. "UHost:uhost-abc:80" or "UHost:uhost-abc:80:5:false" +// +// IP format: "IP:ip:port[:weight]:vpcId[:subnetId[:enabled]]" +// +// e.g. "IP:10.0.0.1:80:vpc-xxx" or "IP:10.0.0.1:80:1:vpc-xxx:subnet-yyy:false" +func parseTarget(s string) (nlbsdk.AddNLBTargetsParamTargets, error) { + parts := strings.Split(s, ":") + if len(parts) < 3 || len(parts) > 7 { + return nlbsdk.AddNLBTargetsParamTargets{}, fmt.Errorf( + "invalid --target %q, expected \"resourceType:resourceId:port[:weight[:enabled]]\" or \"IP:ip:port[:weight]:vpcId[:subnetId[:enabled]]\"", s) + } + + parseEnabled := func(idx int) (*bool, error) { + if idx >= len(parts) { + return sdk.Bool(true), nil + } + switch parts[idx] { + case "true", "1": + return sdk.Bool(true), nil + case "false", "0": + return sdk.Bool(false), nil + default: + return nil, fmt.Errorf("invalid enabled value %q in --target %q, expected true/false", parts[idx], s) + } + } + + resourceType := parts[0] + resourceID := parts[1] + port, err := strconv.Atoi(parts[2]) + if err != nil { + return nlbsdk.AddNLBTargetsParamTargets{}, fmt.Errorf("invalid port in --target %q: %w", s, err) + } + + t := nlbsdk.AddNLBTargetsParamTargets{ + Port: sdk.Int(port), + ResourceType: sdk.String(resourceType), + } + + isBool := func(s string) bool { return s == "true" || s == "false" || s == "0" || s == "1" } + + if resourceType == "IP" { + t.ResourceIP = sdk.String(resourceID) + if len(parts) < 4 { + t.Weight = sdk.Int(1) + t.Enabled = sdk.Bool(true) + return t, fmt.Errorf("invalid --target %q: IP type requires vpcId: \"IP:ip:port[:weight]:vpcId[:subnetId[:enabled]]\"", s) + } + // IP format: IP:ip:port [weight?] vpcId [subnetId?] [enabled?] + // If parts[3] is a number → weight at [3], vpcId at [4] + // If parts[3] is not a number → vpcId at [3] (weight=1) + // When no explicit weight and the last part looks like a bool, + // treat it as enabled, not subnetId. + if _, err := strconv.Atoi(parts[3]); err == nil { + // Has explicit weight at [3] + w, _ := strconv.Atoi(parts[3]) + t.Weight = sdk.Int(w) + t.VPCId = sdk.String(parts[4]) + if len(parts) >= 6 { + t.SubnetId = sdk.String(parts[5]) + } + enabled, err := parseEnabled(6) + if err != nil { + return nlbsdk.AddNLBTargetsParamTargets{}, err + } + t.Enabled = enabled + } else { + // No explicit weight, parts[3] is vpcId + t.Weight = sdk.Int(1) + t.VPCId = sdk.String(parts[3]) + remaining := parts[4:] + if len(remaining) == 0 { + t.Enabled = sdk.Bool(true) + } else if len(remaining) == 1 { + if isBool(remaining[0]) { + enabled, _ := parseEnabled(4) // parts[4] is enabled + t.Enabled = enabled + } else { + t.SubnetId = sdk.String(remaining[0]) + t.Enabled = sdk.Bool(true) + } + } else if len(remaining) == 2 { + t.SubnetId = sdk.String(remaining[0]) + enabled, err := parseEnabled(5) + if err != nil { + return nlbsdk.AddNLBTargetsParamTargets{}, err + } + t.Enabled = enabled + } + } + } else { + t.ResourceId = sdk.String(resourceID) + // Non-IP: resourceType:resourceId:port[:weight[:enabled]] + weight := 1 + if len(parts) >= 4 { + weight, err = strconv.Atoi(parts[3]) + if err != nil { + return nlbsdk.AddNLBTargetsParamTargets{}, fmt.Errorf("invalid weight in --target %q: %w", s, err) + } + } + t.Weight = sdk.Int(weight) + enabled, err := parseEnabled(4) + if err != nil { + return nlbsdk.AddNLBTargetsParamTargets{}, err + } + t.Enabled = enabled + } + return t, nil +} + +// newTargetAdd implements `nlb target add`. +func newTargetAdd(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, nlbsdk.NewClient) + req := client.NewAddNLBTargetsRequest() + + var nlbID, listenerID string + var targets []string + + cmd := &cobra.Command{ + Use: "add", + Short: "Add backend targets to an NLB listener", + Long: "Add one or more backend service nodes to an NLB listener. Supports mixed resource types in one call.", + Run: func(c *cobra.Command, args []string) { + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + req.NLBId = sdk.String(ctx.PickResourceID(nlbID)) + req.ListenerId = sdk.String(ctx.PickResourceID(listenerID)) + + parsed := make([]nlbsdk.AddNLBTargetsParamTargets, 0, len(targets)) + for _, t := range targets { + pt, err := parseTarget(t) + if err != nil { + ctx.HandleError(err) + return + } + parsed = append(parsed, pt) + } + req.Targets = parsed + + resp, err := client.AddNLBTargets(req) + if err != nil { + ctx.HandleError(err) + return + } + results := make([]cli.OpResultRow, 0, len(resp.Targets)) + for _, t := range resp.Targets { + fmt.Fprintf(ctx.ProgressWriter(), "nlb-target[%s] added\n", t.Id) + results = append(results, cli.OpResultRow{ResourceID: t.Id, Action: "add-target", Status: "Added"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + flags.StringVar(&nlbID, resourceIDFlag, "", "Required. Resource ID of the NLB instance.") + flags.StringVar(&listenerID, "listener-id", "", "Required. Resource ID of the listener to add targets to.") + flags.StringSliceVar(&targets, "target", nil, + "Required. Repeatable. Non-IP: \"resourceType:resourceId:port[:weight[:enabled]]\". IP: \"IP:ip:port[:weight]:vpcId[:subnetId[:enabled]]\".") + + command.SetCompletion(cmd, resourceIDFlag, func() []string { + return getAllNLBIDNames(ctx, derefStr(req.ProjectId), derefStr(req.Region)) + }) + command.SetCompletion(cmd, "listener-id", func() []string { + return getAllListenerIDNames(ctx, nlbID, derefStr(req.ProjectId), derefStr(req.Region)) + }) + + cmd.MarkFlagRequired(resourceIDFlag) + cmd.MarkFlagRequired("listener-id") + cmd.MarkFlagRequired("target") + + return cmd +} diff --git a/products/nlb/internal/nlb/target_remove.go b/products/nlb/internal/nlb/target_remove.go new file mode 100644 index 0000000000..7825d27a9d --- /dev/null +++ b/products/nlb/internal/nlb/target_remove.go @@ -0,0 +1,84 @@ +package nlb + +import ( + "fmt" + + "github.com/spf13/cobra" + + nlbsdk "github.com/ucloud/ucloud-sdk-go/services/nlb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newTargetRemove implements `nlb target remove`. +func newTargetRemove(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, nlbsdk.NewClient) + req := client.NewRemoveNLBTargetsRequest() + + var nlbID, listenerID string + var targetIDs []string + var yes bool + + cmd := &cobra.Command{ + Use: "remove", + Short: "Remove backend targets from an NLB listener", + Long: "Remove one or more backend service nodes from an NLB listener.", + Run: func(c *cobra.Command, args []string) { + ok, err := ctx.Confirm(yes, "Are you sure you want to remove the target(s)?") + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + req.NLBId = sdk.String(ctx.PickResourceID(nlbID)) + req.ListenerId = sdk.String(ctx.PickResourceID(listenerID)) + ids := make([]string, 0, len(targetIDs)) + for _, idName := range targetIDs { + ids = append(ids, ctx.PickResourceID(idName)) + } + req.Ids = ids + + if _, err := client.RemoveNLBTargets(req); err != nil { + ctx.HandleError(err) + return + } + results := make([]cli.OpResultRow, 0, len(ids)) + for _, id := range ids { + fmt.Fprintf(ctx.ProgressWriter(), "nlb-target[%s] removed\n", id) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "remove-target", Status: "Removed"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + flags.StringVar(&nlbID, resourceIDFlag, "", "Required. Resource ID of the NLB instance.") + flags.StringVar(&listenerID, "listener-id", "", "Required. Resource ID of the listener.") + flags.StringSliceVar(&targetIDs, "target-id", nil, "Required. Target ID(s) to remove (max 40 per request).") + flags.BoolVarP(&yes, "yes", "y", false, "Optional. Skip the confirmation prompt.") + + cmd.MarkFlagRequired(resourceIDFlag) + cmd.MarkFlagRequired("listener-id") + cmd.MarkFlagRequired("target-id") + command.SetCompletion(cmd, resourceIDFlag, func() []string { + return getAllNLBIDNames(ctx, derefStr(req.ProjectId), derefStr(req.Region)) + }) + command.SetCompletion(cmd, "listener-id", func() []string { + return getAllListenerIDNames(ctx, nlbID, derefStr(req.ProjectId), derefStr(req.Region)) + }) + command.SetCompletion(cmd, "target-id", func() []string { + return getAllTargetIDNames(ctx, nlbID, listenerID, derefStr(req.ProjectId), derefStr(req.Region)) + }) + + return cmd +} diff --git a/products/nlb/internal/nlb/target_update.go b/products/nlb/internal/nlb/target_update.go new file mode 100644 index 0000000000..d1d7a6ef3e --- /dev/null +++ b/products/nlb/internal/nlb/target_update.go @@ -0,0 +1,144 @@ +package nlb + +import ( + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + + nlbsdk "github.com/ucloud/ucloud-sdk-go/services/nlb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// parseTargetUpdate parses a "--target" value for the update command. +// +// Format: "targetId[:weight|enabled[:enabled]]" +// +// "nrs-abc:5" → weight=5 +// "nrs-abc:false" → enabled=false +// "nrs-abc:5:false" → weight=5, enabled=false +// +// The second field is auto-detected: if it looks like a bool (true/false/0/1) +// it is treated as enabled; otherwise it is treated as weight. +func parseTargetUpdate(s string) (id string, t nlbsdk.UpdateNLBTargetsAttributeParamTargets, err error) { + parts := strings.Split(s, ":") + if len(parts) < 1 || len(parts) > 3 { + return "", t, fmt.Errorf("invalid --target %q, expected \"targetId[:weight|enabled[:enabled]]\"", s) + } + if len(parts) < 2 { + return "", t, fmt.Errorf("invalid --target %q: at least weight or enabled must be provided", s) + } + id = parts[0] + t.Id = sdk.String(id) + + isBool := func(s string) bool { return s == "true" || s == "false" || s == "0" || s == "1" } + + p1 := parts[1] + hasSecondField := len(parts) >= 3 + + if isBool(p1) { + // "nrs-abc:false" or "nrs-abc:false:..." — p1 is enabled + switch p1 { + case "true", "1": + t.Enabled = sdk.Bool(true) + case "false", "0": + t.Enabled = sdk.Bool(false) + } + if hasSecondField { + return "", t, fmt.Errorf("invalid --target %q: too many fields for \"targetId:enabled\"", s) + } + } else { + // "nrs-abc:5" or "nrs-abc:5:false" — p1 is weight + w, err := strconv.Atoi(p1) + if err != nil { + return "", t, fmt.Errorf("invalid weight/enabled %q in --target %q", p1, s) + } + t.Weight = sdk.Int(w) + if hasSecondField { + switch parts[2] { + case "true", "1": + t.Enabled = sdk.Bool(true) + case "false", "0": + t.Enabled = sdk.Bool(false) + default: + return "", t, fmt.Errorf("invalid enabled value %q in --target %q, expected true/false", parts[2], s) + } + } + } + return id, t, nil +} + +// newTargetUpdate implements `nlb target update`. +func newTargetUpdate(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, nlbsdk.NewClient) + req := client.NewUpdateNLBTargetsAttributeRequest() + + var nlbID, listenerID string + var targets []string + + cmd := &cobra.Command{ + Use: "update", + Short: "Update backend targets of an NLB listener", + Long: "Update the weight and/or enabled state of one or more NLB targets. Each target can have different values.", + Run: func(c *cobra.Command, args []string) { + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + req.NLBId = sdk.String(ctx.PickResourceID(nlbID)) + req.ListenerId = sdk.String(ctx.PickResourceID(listenerID)) + + updates := make([]nlbsdk.UpdateNLBTargetsAttributeParamTargets, 0, len(targets)) + ids := make([]string, 0, len(targets)) + for _, raw := range targets { + id, u, err := parseTargetUpdate(raw) + if err != nil { + ctx.HandleError(err) + return + } + id = ctx.PickResourceID(id) + u.Id = sdk.String(id) + ids = append(ids, id) + updates = append(updates, u) + } + req.Targets = updates + + if _, err := client.UpdateNLBTargetsAttribute(req); err != nil { + ctx.HandleError(err) + return + } + results := make([]cli.OpResultRow, 0, len(ids)) + for _, id := range ids { + fmt.Fprintf(ctx.ProgressWriter(), "nlb-target[%s] updated\n", id) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "update-target", Status: "Updated"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + flags.StringVar(&nlbID, resourceIDFlag, "", "Required. Resource ID of the NLB instance.") + flags.StringVar(&listenerID, "listener-id", "", "Required. Resource ID of the listener.") + flags.StringSliceVar(&targets, "target", nil, "Required. Repeatable. Target as \"targetId:weight|enabled[:enabled]\", e.g. \"nrs-abc:5\" or \"nrs-abc:false\" or \"nrs-abc:5:false\".") + + cmd.MarkFlagRequired(resourceIDFlag) + cmd.MarkFlagRequired("listener-id") + cmd.MarkFlagRequired("target") + command.SetCompletion(cmd, resourceIDFlag, func() []string { + return getAllNLBIDNames(ctx, derefStr(req.ProjectId), derefStr(req.Region)) + }) + command.SetCompletion(cmd, "listener-id", func() []string { + return getAllListenerIDNames(ctx, nlbID, derefStr(req.ProjectId), derefStr(req.Region)) + }) + command.SetCompletion(cmd, "target", func() []string { + return getAllTargetIDNames(ctx, nlbID, listenerID, derefStr(req.ProjectId), derefStr(req.Region)) + }) + + return cmd +} diff --git a/products/nlb/internal/nlb/update.go b/products/nlb/internal/nlb/update.go new file mode 100644 index 0000000000..24fe901de9 --- /dev/null +++ b/products/nlb/internal/nlb/update.go @@ -0,0 +1,74 @@ +package nlb + +import ( + "fmt" + + "github.com/spf13/cobra" + + nlbsdk "github.com/ucloud/ucloud-sdk-go/services/nlb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newUpdate implements `nlb update`. +func newUpdate(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, nlbsdk.NewClient) + req := client.NewUpdateNetworkLoadBalancerAttributeRequest() + + var idNames []string + var name, remark, group string + + cmd := &cobra.Command{ + Use: "update", + Short: "Update NLB instance attributes", + Long: "Update the name, remark or business group of one or more NLB instances.", + Run: func(c *cobra.Command, args []string) { + if name == "" && remark == "" && group == "" { + ctx.HandleError(fmt.Errorf("nothing to update: set at least one of --name/--remark/--group")) + return + } + req.ProjectId = sdk.String(ctx.PickResourceID(*req.ProjectId)) + if name != "" { + req.Name = &name + } + if remark != "" { + req.Remark = &remark + } + if group != "" { + req.Tag = &group + } + results := []cli.OpResultRow{} + for _, idName := range idNames { + id := ctx.PickResourceID(idName) + req.NLBId = sdk.String(id) + if _, err := client.UpdateNetworkLoadBalancerAttribute(req); err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(ctx.ProgressWriter(), "nlb[%s] updated\n", id) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "update", Status: "Updated"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + flags.StringSliceVar(&idNames, resourceIDFlag, nil, "Required. Resource ID(s) of the NLB instances to update.") + flags.StringVar(&name, "name", "", "Optional. New NLB instance name.") + flags.StringVar(&remark, "remark", "", "Optional. New remark.") + flags.StringVar(&group, "group", "", "Optional. New business group.") + + cmd.MarkFlagRequired(resourceIDFlag) + command.SetCompletion(cmd, resourceIDFlag, func() []string { + return getAllNLBIDNames(ctx, derefStr(req.ProjectId), derefStr(req.Region)) + }) + + return cmd +} diff --git a/products/nlb/product.go b/products/nlb/product.go new file mode 100644 index 0000000000..a3d4e4ae48 --- /dev/null +++ b/products/nlb/product.go @@ -0,0 +1,21 @@ +package nlb + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + internalnlb "github.com/ucloud/ucloud-cli/products/nlb/internal/nlb" +) + +type product struct{} + +// New returns the nlb product (registered via hack/gen-products). +func New() cli.Product { return product{} } + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "nlb", Commands: []string{"nlb"}} +} + +func (product) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{internalnlb.NewCommand(ctx)} +} diff --git a/products/nlb/product.yaml b/products/nlb/product.yaml new file mode 100644 index 0000000000..5adc6e6f1b --- /dev/null +++ b/products/nlb/product.yaml @@ -0,0 +1,7 @@ +# products/nlb/product.yaml — NLB (Network Load Balancer) product metadata. +name: nlb +owners: + - Cavan-xu +commands: + - nlb +enabled: true diff --git a/products/nlb/testdata/cmdtree.golden b/products/nlb/testdata/cmdtree.golden new file mode 100644 index 0000000000..813a7527f4 --- /dev/null +++ b/products/nlb/testdata/cmdtree.golden @@ -0,0 +1,102 @@ +ucloud nlb use=nlb short=List and manipulate NLB (Network Load Balancer) instances +ucloud nlb create use=create short=Create an NLB instance + flag=charge-type short= default=Dynamic required= + flag=coupon-id short= default= required= + flag=group short= default=Default required= + flag=ip-version short= default=IPv4 required= + flag=name short= default= required= + flag=project-id short= default= required= + flag=quantity short= default=1 required= + flag=region short= default= required= + flag=remark short= default= required= + flag=subnet-id short= default= required=true + flag=vpc-id short= default= required=true +ucloud nlb delete use=delete short=Delete NLB instances by resource ID + flag=nlb-id short= default=[] required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=release-eip short= default=false required= + flag=yes short=y default=false required= +ucloud nlb describe use=describe short=Show details of one NLB instance + flag=nlb-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= +ucloud nlb list use=list short=List NLB instances + flag=limit short= default=100 required= + flag=nlb-id short= default= required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=subnet-id short= default= required= + flag=vpc-id short= default= required= +ucloud nlb listener use=listener short=List and manipulate NLB listeners +ucloud nlb listener create use=create short=Create an NLB listener + flag=end-port short= default=65535 required=true + flag=forward-src-ip-method short= default= required= + flag=health-check-port short= default=0 required= + flag=health-check-req-msg short= default= required= + flag=health-check-res-msg short= default= required= + flag=health-check-type short= default=Port required= + flag=name short= default= required= + flag=nlb-id short= default= required=true + flag=project-id short= default= required= + flag=protocol short= default= required=true + flag=region short= default= required= + flag=scheduler short= default=RoundRobin required= + flag=start-port short= default=1 required=true + flag=stickiness-timeout short= default=0 required= +ucloud nlb listener delete use=delete short=Delete NLB listeners by resource ID + flag=listener-id short= default=[] required=true + flag=nlb-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=yes short=y default=false required= +ucloud nlb listener list use=list short=List listeners of an NLB instance + flag=limit short= default=100 required= + flag=listener-id short= default= required= + flag=nlb-id short= default= required=true + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= +ucloud nlb listener update use=update short=Update an NLB listener + flag=end-port short= default=65535 required= + flag=forward-src-ip-method short= default= required= + flag=health-check-port short= default=0 required= + flag=health-check-req-msg short= default= required= + flag=health-check-res-msg short= default= required= + flag=health-check-type short= default= required= + flag=listener-id short= default= required=true + flag=name short= default= required= + flag=nlb-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=remark short= default= required= + flag=scheduler short= default= required= + flag=start-port short= default=1 required= +ucloud nlb target use=target short=Manage NLB backend targets (service nodes) +ucloud nlb target add use=add short=Add backend targets to an NLB listener + flag=listener-id short= default= required=true + flag=nlb-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=target short= default=[] required=true +ucloud nlb target remove use=remove short=Remove backend targets from an NLB listener + flag=listener-id short= default= required=true + flag=nlb-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=target-id short= default=[] required=true + flag=yes short=y default=false required= +ucloud nlb target update use=update short=Update backend targets of an NLB listener + flag=listener-id short= default= required=true + flag=nlb-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=target short= default=[] required=true +ucloud nlb update use=update short=Update NLB instance attributes + flag=group short= default= required= + flag=name short= default= required= + flag=nlb-id short= default=[] required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=remark short= default= required= diff --git a/products/nlb/testdata/completion.golden b/products/nlb/testdata/completion.golden new file mode 100644 index 0000000000..1d8e24c388 --- /dev/null +++ b/products/nlb/testdata/completion.golden @@ -0,0 +1,56 @@ +ucloud nlb create charge-type static Dynamic,Month,Year +ucloud nlb create ip-version static DualStack,IPv4,IPv6 +ucloud nlb create project-id dynamic +ucloud nlb create region dynamic +ucloud nlb create subnet-id dynamic +ucloud nlb create vpc-id dynamic +ucloud nlb delete nlb-id dynamic +ucloud nlb delete project-id dynamic +ucloud nlb delete region dynamic +ucloud nlb describe nlb-id dynamic +ucloud nlb describe project-id dynamic +ucloud nlb describe region dynamic +ucloud nlb list nlb-id dynamic +ucloud nlb list project-id dynamic +ucloud nlb list region dynamic +ucloud nlb list subnet-id dynamic +ucloud nlb list vpc-id dynamic +ucloud nlb listener create forward-src-ip-method static ,None,ProxyProto,Toa +ucloud nlb listener create health-check-type static Ping,Port,UDP +ucloud nlb listener create nlb-id dynamic +ucloud nlb listener create project-id dynamic +ucloud nlb listener create protocol static TCP,UDP +ucloud nlb listener create region dynamic +ucloud nlb listener create scheduler static LeastConn,RoundRobin,SourceHash,WeightLeastConn,WeightRoundRobin +ucloud nlb listener delete listener-id static +ucloud nlb listener delete nlb-id dynamic +ucloud nlb listener delete project-id dynamic +ucloud nlb listener delete region dynamic +ucloud nlb listener list listener-id static +ucloud nlb listener list nlb-id dynamic +ucloud nlb listener list project-id dynamic +ucloud nlb listener list region dynamic +ucloud nlb listener update forward-src-ip-method static ,None,ProxyProto,Toa +ucloud nlb listener update health-check-type static Ping,Port,UDP +ucloud nlb listener update listener-id static +ucloud nlb listener update nlb-id dynamic +ucloud nlb listener update project-id dynamic +ucloud nlb listener update region dynamic +ucloud nlb listener update scheduler static LeastConn,RoundRobin,SourceHash,WeightLeastConn,WeightRoundRobin +ucloud nlb target add listener-id static +ucloud nlb target add nlb-id dynamic +ucloud nlb target add project-id dynamic +ucloud nlb target add region dynamic +ucloud nlb target remove listener-id static +ucloud nlb target remove nlb-id dynamic +ucloud nlb target remove project-id dynamic +ucloud nlb target remove region dynamic +ucloud nlb target remove target-id static +ucloud nlb target update listener-id static +ucloud nlb target update nlb-id dynamic +ucloud nlb target update project-id dynamic +ucloud nlb target update region dynamic +ucloud nlb target update target static +ucloud nlb update nlb-id dynamic +ucloud nlb update project-id dynamic +ucloud nlb update region dynamic diff --git a/products/redis/internal/redis/cmd.go b/products/redis/internal/redis/cmd.go index a9686a09f6..0d85760d37 100644 --- a/products/redis/internal/redis/cmd.go +++ b/products/redis/internal/redis/cmd.go @@ -17,5 +17,15 @@ func NewCommand(ctx *cli.Context) *cobra.Command { cmd.AddCommand(newCreate(ctx)) cmd.AddCommand(newDelete(ctx)) cmd.AddCommand(newRestart(ctx)) + cmd.AddCommand(newModifyName(ctx)) + cmd.AddCommand(newModifyPassword(ctx)) + cmd.AddCommand(newListBlock(ctx)) + cmd.AddCommand(newListProxy(ctx)) + cmd.AddCommand(newFlush(ctx)) + cmd.AddCommand(newIsolation(ctx)) + cmd.AddCommand(newResize(ctx)) + cmd.AddCommand(newCreateProxy(ctx)) + cmd.AddCommand(newDeleteProxy(ctx)) + cmd.AddCommand(newResizeProxy(ctx)) return cmd } diff --git a/products/redis/internal/redis/create_proxy.go b/products/redis/internal/redis/create_proxy.go new file mode 100644 index 0000000000..81e59f6824 --- /dev/null +++ b/products/redis/internal/redis/create_proxy.go @@ -0,0 +1,53 @@ +package redis + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/umem" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newCreateProxy returns ucloud redis create-proxy. +func newCreateProxy(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewCreateUDRedisUhproxyRequest() + cmd := &cobra.Command{ + Use: "create-proxy", + Short: "Create proxy for distributed redis", + Long: "Create proxy for distributed redis", + Example: "ucloud redis create-proxy --umem-id udb-xxx --cpu 2", + Run: func(c *cobra.Command, args []string) { + resp, err := client.CreateUDRedisUhproxy(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "proxy[%s] created\n", resp.ResourceId) + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.ResourceId, Action: "create-proxy", Status: "Created"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.SpaceId = flags.String("umem-id", "", "Required. Resource ID of the distributed redis") + req.CPU = flags.Int("cpu", 2, "Required. CPU cores of the proxy") + req.Port = flags.Int("port", 6379, "Optional. Port of the proxy. Default value 6379") + req.ProxyCnt = flags.Int("proxy-cnt", 1, "Optional. Number of proxies to create. Default value 1") + + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("umem-id") + cmd.MarkFlagRequired("cpu") + command.SetCompletion(cmd, "umem-id", func() []string { + return getIDList(ctx, *req.ProjectId, *req.Region) + }) + + return cmd +} diff --git a/products/redis/internal/redis/delete.go b/products/redis/internal/redis/delete.go index 4e9372a695..6f8bbc21be 100644 --- a/products/redis/internal/redis/delete.go +++ b/products/redis/internal/redis/delete.go @@ -2,7 +2,6 @@ package redis import ( "fmt" - "strings" "github.com/spf13/cobra" @@ -31,16 +30,22 @@ func newDelete(ctx *cli.Context) *cobra.Command { results := []cli.OpResultRow{} for _, idname := range idNames { id := ctx.PickResourceID(idname) - if strings.HasPrefix(id, "uredis") || strings.HasPrefix(id, "uhredis") || strings.HasPrefix(id, "uregionredis") { + mode, err := describeRedisMode(ctx, id) + if err != nil { + ctx.HandleError(err) + continue + } + switch mode { + case redisModeMasterReplica: if deleteMasterReplica(ctx, &p, id) { results = append(results, cli.OpResultRow{ResourceID: id, Action: "delete", Status: "Deleted"}) } - } else if strings.HasPrefix(id, "udredis") { + case redisModeDistributed: if deleteDistributed(ctx, &p, id) { results = append(results, cli.OpResultRow{ResourceID: id, Action: "delete", Status: "Deleted"}) } - } else { - fmt.Fprintf(ctx.ProgressWriter(), "redis[%s] unknown id prefix, skip\n", idname) + default: + fmt.Fprintf(ctx.ProgressWriter(), "redis[%s] unknown resource type, skip\n", idname) } } ctx.EmitResult(results...) @@ -54,16 +59,16 @@ func newDelete(ctx *cli.Context) *cobra.Command { flags.StringVar(&p.region, "region", ctx.DefaultRegion(), "Optional. Override default region for this command invocation, see 'ucloud region'") flags.StringVar(&p.zone, "zone", ctx.DefaultZone(), "Optional. Override default availability zone for this command invocation, see 'ucloud region'") flags.StringVar(&p.projectID, "project-id", ctx.DefaultProjectID(), "Optional. Override default project-id for this command invocation, see 'ucloud project list'") + command.SetCompletion(cmd, "region", ctx.RegionList) command.SetCompletion(cmd, "zone", func() []string { return ctx.ZoneList(p.region) }) command.SetCompletion(cmd, "project-id", ctx.ProjectList) - - cmd.MarkFlagRequired("umem-id") - command.SetCompletion(cmd, "umem-id", func() []string { return getIDList(ctx, p.projectID, p.region) }) + cmd.MarkFlagRequired("umem-id") + return cmd } diff --git a/products/redis/internal/redis/delete_proxy.go b/products/redis/internal/redis/delete_proxy.go new file mode 100644 index 0000000000..507d45b3b1 --- /dev/null +++ b/products/redis/internal/redis/delete_proxy.go @@ -0,0 +1,51 @@ +package redis + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/umem" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newDeleteProxy returns ucloud redis delete-proxy. +func newDeleteProxy(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewDeleteUDRedisProxyRequest() + cmd := &cobra.Command{ + Use: "delete-proxy", + Short: "Delete proxy of distributed redis", + Long: "Delete proxy of distributed redis", + Example: "ucloud redis delete-proxy --umem-id udb-xxx --proxy-id proxy-xxx", + Run: func(c *cobra.Command, args []string) { + _, err := client.DeleteUDRedisProxy(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "proxy[%s] deleted\n", *req.ProxyId) + ctx.EmitResult(cli.OpResultRow{ResourceID: *req.ProxyId, Action: "delete-proxy", Status: "Deleted"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.SpaceId = flags.String("umem-id", "", "Required. Resource ID of the distributed redis") + req.ProxyId = flags.String("proxy-id", "", "Required. Proxy ID of the proxy to delete") + + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("umem-id") + cmd.MarkFlagRequired("proxy-id") + command.SetCompletion(cmd, "umem-id", func() []string { + return getIDList(ctx, *req.ProjectId, *req.Region) + }) + + return cmd +} diff --git a/products/redis/internal/redis/flush.go b/products/redis/internal/redis/flush.go new file mode 100644 index 0000000000..b49b1c4c6f --- /dev/null +++ b/products/redis/internal/redis/flush.go @@ -0,0 +1,114 @@ +package redis + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/umem" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +type flushParams struct { + flushType string + dbNum int + region string + zone string + projectID string +} + +// newFlush returns ucloud redis flush. +func newFlush(ctx *cli.Context) *cobra.Command { + var idNames []string + var p flushParams + cmd := &cobra.Command{ + Use: "flush", + Short: "Clear data of redis instances", + Long: "Clear data of redis instances. Master-replica instances call FlushallURedisGroup, distributed instances call RemoveUDRedisData", + Example: "ucloud redis flush --umem-id uredis-rl5xuxx/testcli1 --flush-type FlushAll", + Run: func(c *cobra.Command, args []string) { + results := []cli.OpResultRow{} + for _, idname := range idNames { + id := ctx.PickResourceID(idname) + mode, err := describeRedisMode(ctx, id) + if err != nil { + ctx.HandleError(err) + continue + } + switch mode { + case redisModeMasterReplica: + if flushMasterReplica(ctx, &p, id) { + results = append(results, cli.OpResultRow{ResourceID: id, Action: "flush", Status: "Flushed"}) + } + case redisModeDistributed: + if flushDistributed(ctx, &p, id) { + results = append(results, cli.OpResultRow{ResourceID: id, Action: "flush", Status: "Flushed"}) + } + default: + fmt.Fprintf(ctx.ProgressWriter(), "redis[%s] unknown resource type, skip\n", idname) + } + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&idNames, "umem-id", nil, "Required. Resource ID of redis instances to flush data") + flags.StringVar(&p.flushType, "flush-type", "FlushAll", "Optional. FlushType of redis flush. Only for master-replica instances. Accept values: 'FlushAll', 'FlushDb'") + flags.IntVar(&p.dbNum, "db-num", 0, "Optional. DbNum to flush. Only used when flush-type is FlushDb for master-replica instances") + flags.StringVar(&p.region, "region", ctx.DefaultRegion(), "Optional. Override default region for this command invocation, see 'ucloud region'") + flags.StringVar(&p.zone, "zone", ctx.DefaultZone(), "Optional. Override default availability zone for this command invocation, see 'ucloud region'") + flags.StringVar(&p.projectID, "project-id", ctx.DefaultProjectID(), "Optional. Override default project-id for this command invocation, see 'ucloud project list'") + + command.SetCompletion(cmd, "region", ctx.RegionList) + command.SetCompletion(cmd, "zone", func() []string { return ctx.ZoneList(p.region) }) + command.SetCompletion(cmd, "project-id", ctx.ProjectList) + command.SetCompletion(cmd, "umem-id", func() []string { + return getIDList(ctx, p.projectID, p.region) + }) + + cmd.MarkFlagRequired("umem-id") + + return cmd +} + +func flushMasterReplica(ctx *cli.Context, p *flushParams, id string) bool { + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewFlushallURedisGroupRequest() + req.Region = &p.region + req.Zone = &p.zone + req.ProjectId = &p.projectID + req.GroupId = &id + req.FlushType = &p.flushType + if p.flushType == "FlushDb" { + req.DbNum = sdk.Int(p.dbNum) + } + _, err := client.FlushallURedisGroup(req) + if err != nil { + ctx.HandleError(err) + return false + } + fmt.Fprintf(ctx.ProgressWriter(), "redis[%s] data flushed\n", id) + return true +} + +func flushDistributed(ctx *cli.Context, p *flushParams, id string) bool { + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewRemoveUDRedisDataRequest() + req.Region = &p.region + req.Zone = &p.zone + req.ProjectId = &p.projectID + req.SpaceId = &id + _, err := client.RemoveUDRedisData(req) + if err != nil { + ctx.HandleError(err) + return false + } + fmt.Fprintf(ctx.ProgressWriter(), "redis[%s] data flushed\n", id) + return true +} diff --git a/products/redis/internal/redis/isolation.go b/products/redis/internal/redis/isolation.go new file mode 100644 index 0000000000..36722f7b43 --- /dev/null +++ b/products/redis/internal/redis/isolation.go @@ -0,0 +1,95 @@ +package redis + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/umem" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +type isolationParams struct { + opType string + region string + zone string + projectID string +} + +// newIsolation returns ucloud redis isolation. +func newIsolation(ctx *cli.Context) *cobra.Command { + var idNames []string + var p isolationParams + cmd := &cobra.Command{ + Use: "isolation", + Short: "Open or close redis instances of master-replica type", + Long: "Open or close redis instances of master-replica type. Only master-replica instances are supported. --type open opens redis, --type close closes redis", + Example: "ucloud redis isolation --umem-id uredis-rl5xuxx/testcli1 --type open", + Run: func(c *cobra.Command, args []string) { + results := []cli.OpResultRow{} + transformType := "UNBind" + action := "close" + if p.opType == "open" { + transformType = "Bind" + action = "open" + } + for _, idname := range idNames { + id := ctx.PickResourceID(idname) + mode, err := describeRedisMode(ctx, id) + if err != nil { + ctx.HandleError(err) + continue + } + if mode != redisModeMasterReplica { + fmt.Fprintf(ctx.ProgressWriter(), "redis[%s] is not master-replica type, skip\n", idname) + continue + } + if isolationMasterReplica(ctx, &p, id, transformType, action) { + results = append(results, cli.OpResultRow{ResourceID: id, Action: action, Status: "Done"}) + } + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&idNames, "umem-id", nil, "Required. Resource ID of redis instances to open or close") + flags.StringVar(&p.opType, "type", "close", "Required. Operation type of redis isolation. Accept values: 'open' or 'close'") + flags.StringVar(&p.region, "region", ctx.DefaultRegion(), "Optional. Override default region for this command invocation, see 'ucloud region'") + flags.StringVar(&p.zone, "zone", ctx.DefaultZone(), "Optional. Override default availability zone for this command invocation, see 'ucloud region'") + flags.StringVar(&p.projectID, "project-id", ctx.DefaultProjectID(), "Optional. Override default project-id for this command invocation, see 'ucloud project list'") + + command.SetFlagValues(cmd, "type", "open", "close") + command.SetCompletion(cmd, "region", ctx.RegionList) + command.SetCompletion(cmd, "zone", func() []string { return ctx.ZoneList(p.region) }) + command.SetCompletion(cmd, "project-id", ctx.ProjectList) + command.SetCompletion(cmd, "umem-id", func() []string { + return getIDList(ctx, p.projectID, p.region) + }) + + cmd.MarkFlagRequired("umem-id") + cmd.MarkFlagRequired("type") + + return cmd +} + +func isolationMasterReplica(ctx *cli.Context, p *isolationParams, id, transformType, action string) bool { + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewISolationURedisGroupRequest() + req.Region = &p.region + req.Zone = &p.zone + req.ProjectId = &p.projectID + req.GroupId = &id + req.TransformType = &transformType + _, err := client.ISolationURedisGroup(req) + if err != nil { + ctx.HandleError(err) + return false + } + fmt.Fprintf(ctx.ProgressWriter(), "redis[%s] %sed\n", id, action) + return true +} diff --git a/products/redis/internal/redis/list.go b/products/redis/internal/redis/list.go index e7b9112798..677f9e1a46 100644 --- a/products/redis/internal/redis/list.go +++ b/products/redis/internal/redis/list.go @@ -34,7 +34,7 @@ func newList(ctx *cli.Context) *cobra.Command { ResourceID: ins.ResourceId, Name: ins.Name, Role: ins.Role, - Type: redisTypeMap[ins.ResourceType], + Type: string(resourceTypeToMode(ins.ResourceType)), Group: ins.Tag, Size: fmt.Sprintf("%dGB", ins.Size), UsedSize: fmt.Sprintf("%dMB", ins.UsedSize), @@ -53,7 +53,7 @@ func newList(ctx *cli.Context) *cobra.Command { ResourceID: slave.GroupId, Name: slave.Name, Role: fmt.Sprintf("⮑ %s", slave.Role), - Type: redisTypeMap[slave.ResourceType], + Type: string(resourceTypeToMode(slave.ResourceType)), Group: slave.Tag, Size: fmt.Sprintf("%dGB", slave.Size), UsedSize: fmt.Sprintf("%dMB", slave.UsedSize), diff --git a/products/redis/internal/redis/list_block.go b/products/redis/internal/redis/list_block.go new file mode 100644 index 0000000000..1e1d064027 --- /dev/null +++ b/products/redis/internal/redis/list_block.go @@ -0,0 +1,64 @@ +package redis + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/umem" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newListBlock returns ucloud redis list-block. +func newListBlock(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewDescribeUMemBlockInfoRequest() + cmd := &cobra.Command{ + Use: "list-block", + Short: "List block info of distributed redis", + Long: "List block info of distributed redis", + Run: func(c *cobra.Command, args []string) { + resp, err := client.DescribeUMemBlockInfo(req) + if err != nil { + ctx.HandleError(err) + return + } + list := []BlockRow{} + for _, b := range resp.DataSet { + row := BlockRow{ + BlockID: b.BlockId, + BlockName: b.BlockName, + BlockVip: b.BlockVip, + BlockPort: b.BlockPort, + BlockType: b.BlockType, + BlockState: b.BlockState, + BlockSize: b.BlockSize, + UsedSize: b.BlockUsedSize, + SlotBegin: b.BlockSlotBegin, + SlotEnd: b.BlockSlotEnd, + ReadWeight: b.BlockReadWeight, + } + list = append(list, row) + } + ctx.PrintList(list) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.SpaceId = flags.String("umem-id", "", "Required. Resource ID of the distributed redis") + req.Limit = sdk.Int(100) + req.Offset = sdk.Int(0) + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("umem-id") + command.SetCompletion(cmd, "umem-id", func() []string { + return getIDList(ctx, *req.ProjectId, *req.Region) + }) + + return cmd +} diff --git a/products/redis/internal/redis/list_proxy.go b/products/redis/internal/redis/list_proxy.go new file mode 100644 index 0000000000..3202bc8a8a --- /dev/null +++ b/products/redis/internal/redis/list_proxy.go @@ -0,0 +1,54 @@ +package redis + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/umem" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newListProxy returns ucloud redis list-proxy. +func newListProxy(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewDescribeUDRedisProxyInfoRequest() + cmd := &cobra.Command{ + Use: "list-proxy", + Short: "List proxy info of distributed redis", + Long: "List proxy info of distributed redis", + Run: func(c *cobra.Command, args []string) { + resp, err := client.DescribeUDRedisProxyInfo(req) + if err != nil { + ctx.HandleError(err) + return + } + list := []ProxyRow{} + for _, p := range resp.DataSet { + row := ProxyRow{ + ProxyID: p.ProxyId, + ResourceID: p.ResourceId, + State: p.State, + Vip: p.Vip, + } + list = append(list, row) + } + ctx.PrintList(list) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.SpaceId = flags.String("umem-id", "", "Required. Resource ID of the distributed redis") + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("umem-id") + command.SetCompletion(cmd, "umem-id", func() []string { + return getIDList(ctx, *req.ProjectId, *req.Region) + }) + + return cmd +} diff --git a/products/redis/internal/redis/modify_name.go b/products/redis/internal/redis/modify_name.go new file mode 100644 index 0000000000..7fc6660f3a --- /dev/null +++ b/products/redis/internal/redis/modify_name.go @@ -0,0 +1,109 @@ +package redis + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/umem" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +type modifyNameParams struct { + name string + region string + zone string + projectID string +} + +// newModifyName returns ucloud redis modify-name. +func newModifyName(ctx *cli.Context) *cobra.Command { + var idNames []string + var p modifyNameParams + cmd := &cobra.Command{ + Use: "modify-name", + Short: "Modify redis instance name", + Long: "Modify redis instance name", + Example: "ucloud redis modify-name --umem-id uredis-rl5xuxx/testcli1,uredis-xsdfa/testcli2 --name newname", + Run: func(c *cobra.Command, args []string) { + results := []cli.OpResultRow{} + for _, idname := range idNames { + id := ctx.PickResourceID(idname) + mode, err := describeRedisMode(ctx, id) + if err != nil { + ctx.HandleError(err) + continue + } + switch mode { + case redisModeMasterReplica: + if modifyMasterReplicaName(ctx, &p, id) { + results = append(results, cli.OpResultRow{ResourceID: id, Action: "modify-name", Status: "Modified"}) + } + case redisModeDistributed: + if modifyDistributedName(ctx, &p, id) { + results = append(results, cli.OpResultRow{ResourceID: id, Action: "modify-name", Status: "Modified"}) + } + default: + fmt.Fprintf(ctx.ProgressWriter(), "redis[%s] unknown resource type, skip\n", idname) + } + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&idNames, "umem-id", nil, "Required. Resource ID of redis instances to modify name") + flags.StringVar(&p.name, "name", "", "Required. New name of the redis instance") + flags.StringVar(&p.region, "region", ctx.DefaultRegion(), "Optional. Override default region for this command invocation, see 'ucloud region'") + flags.StringVar(&p.zone, "zone", ctx.DefaultZone(), "Optional. Override default availability zone for this command invocation, see 'ucloud region'") + flags.StringVar(&p.projectID, "project-id", ctx.DefaultProjectID(), "Optional. Override default project-id for this command invocation, see 'ucloud project list'") + + command.SetCompletion(cmd, "region", ctx.RegionList) + command.SetCompletion(cmd, "zone", func() []string { return ctx.ZoneList(p.region) }) + command.SetCompletion(cmd, "project-id", ctx.ProjectList) + command.SetCompletion(cmd, "umem-id", func() []string { + return getIDList(ctx, p.projectID, p.region) + }) + + cmd.MarkFlagRequired("umem-id") + cmd.MarkFlagRequired("name") + + return cmd +} + +func modifyMasterReplicaName(ctx *cli.Context, p *modifyNameParams, id string) bool { + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewModifyURedisGroupNameRequest() + req.Region = &p.region + req.ProjectId = &p.projectID + req.GroupId = &id + req.Name = &p.name + _, err := client.ModifyURedisGroupName(req) + if err != nil { + ctx.HandleError(err) + return false + } + fmt.Fprintf(ctx.ProgressWriter(), "redis[%s] name modified\n", id) + return true +} + +func modifyDistributedName(ctx *cli.Context, p *modifyNameParams, id string) bool { + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewModifyUMemSpaceNameRequest() + req.Region = &p.region + req.Zone = &p.zone + req.ProjectId = &p.projectID + req.SpaceId = &id + req.Name = &p.name + _, err := client.ModifyUMemSpaceName(req) + if err != nil { + ctx.HandleError(err) + return false + } + fmt.Fprintf(ctx.ProgressWriter(), "redis[%s] name modified\n", id) + return true +} diff --git a/products/redis/internal/redis/modify_password.go b/products/redis/internal/redis/modify_password.go new file mode 100644 index 0000000000..ad7e6e3560 --- /dev/null +++ b/products/redis/internal/redis/modify_password.go @@ -0,0 +1,112 @@ +package redis + +import ( + "encoding/base64" + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/umem" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +type modifyPasswordParams struct { + password string + region string + zone string + projectID string +} + +// newModifyPassword returns ucloud redis modify-password. +func newModifyPassword(ctx *cli.Context) *cobra.Command { + var idNames []string + var p modifyPasswordParams + cmd := &cobra.Command{ + Use: "modify-password", + Short: "Modify redis instance password", + Long: "Modify redis instance password", + Example: "ucloud redis modify-password --umem-id uredis-rl5xuxx/testcli1 --password newpassword", + Run: func(c *cobra.Command, args []string) { + results := []cli.OpResultRow{} + encodedPassword := base64.StdEncoding.EncodeToString([]byte(p.password)) + for _, idname := range idNames { + id := ctx.PickResourceID(idname) + mode, err := describeRedisMode(ctx, id) + if err != nil { + ctx.HandleError(err) + continue + } + switch mode { + case redisModeMasterReplica: + if modifyMasterReplicaPassword(ctx, &p, id, encodedPassword) { + results = append(results, cli.OpResultRow{ResourceID: id, Action: "modify-password", Status: "Modified"}) + } + case redisModeDistributed: + if modifyDistributedPassword(ctx, &p, id, encodedPassword) { + results = append(results, cli.OpResultRow{ResourceID: id, Action: "modify-password", Status: "Modified"}) + } + default: + fmt.Fprintf(ctx.ProgressWriter(), "redis[%s] unknown resource type, skip\n", idname) + } + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&idNames, "umem-id", nil, "Required. Resource ID of redis instances to modify password") + flags.StringVar(&p.password, "password", "", "Required. New password of the redis instance") + flags.StringVar(&p.region, "region", ctx.DefaultRegion(), "Optional. Override default region for this command invocation, see 'ucloud region'") + flags.StringVar(&p.zone, "zone", ctx.DefaultZone(), "Optional. Override default availability zone for this command invocation, see 'ucloud region'") + flags.StringVar(&p.projectID, "project-id", ctx.DefaultProjectID(), "Optional. Override default project-id for this command invocation, see 'ucloud project list'") + + command.SetCompletion(cmd, "region", ctx.RegionList) + command.SetCompletion(cmd, "zone", func() []string { return ctx.ZoneList(p.region) }) + command.SetCompletion(cmd, "project-id", ctx.ProjectList) + command.SetCompletion(cmd, "umem-id", func() []string { + return getIDList(ctx, p.projectID, p.region) + }) + + cmd.MarkFlagRequired("umem-id") + cmd.MarkFlagRequired("password") + + return cmd +} + +func modifyMasterReplicaPassword(ctx *cli.Context, p *modifyPasswordParams, id, encodedPassword string) bool { + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewModifyURedisGroupPasswordRequest() + req.Region = &p.region + req.ProjectId = &p.projectID + req.Zone = &p.zone + req.GroupId = &id + req.Password = &encodedPassword + _, err := client.ModifyURedisGroupPassword(req) + if err != nil { + ctx.HandleError(err) + return false + } + fmt.Fprintf(ctx.ProgressWriter(), "redis[%s] password modified\n", id) + return true +} + +func modifyDistributedPassword(ctx *cli.Context, p *modifyPasswordParams, id, encodedPassword string) bool { + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewModifyUMemPasswordRequest() + req.Region = &p.region + req.Zone = &p.zone + req.ProjectId = &p.projectID + req.SpaceId = &id + req.Password = &encodedPassword + _, err := client.ModifyUMemPassword(req) + if err != nil { + ctx.HandleError(err) + return false + } + fmt.Fprintf(ctx.ProgressWriter(), "redis[%s] password modified\n", id) + return true +} diff --git a/products/redis/internal/redis/resize.go b/products/redis/internal/redis/resize.go new file mode 100644 index 0000000000..5abf35ff41 --- /dev/null +++ b/products/redis/internal/redis/resize.go @@ -0,0 +1,116 @@ +package redis + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/umem" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +type resizeParams struct { + region string + zone string + projectID string + size int + blockID string +} + +// newResize returns ucloud redis resize. +func newResize(ctx *cli.Context) *cobra.Command { + var idNames []string + var p resizeParams + cmd := &cobra.Command{ + Use: "resize", + Short: "Resize redis instances", + Long: "Resize redis instances. Master-replica instances call ResizeURedisGroup, distributed instances call ResizeUDRedisBlockSize for the specified block", + Example: "ucloud redis resize --umem-id uredis-rl5xuxx/testcli1 --size-gb 4", + Run: func(c *cobra.Command, args []string) { + results := []cli.OpResultRow{} + for _, idname := range idNames { + id := ctx.PickResourceID(idname) + mode, err := describeRedisMode(ctx, id) + if err != nil { + ctx.HandleError(err) + continue + } + switch mode { + case redisModeMasterReplica: + if resizeMasterReplica(ctx, &p, id) { + results = append(results, cli.OpResultRow{ResourceID: id, Action: "resize", Status: "Resized"}) + } + case redisModeDistributed: + if p.blockID == "" { + fmt.Fprintf(ctx.ProgressWriter(), "redis[%s] --block-id is required for distributed redis\n", idname) + continue + } + if resizeDistributed(ctx, &p, id) { + results = append(results, cli.OpResultRow{ResourceID: id, Action: "resize", Status: "Resized"}) + } + default: + fmt.Fprintf(ctx.ProgressWriter(), "redis[%s] unknown resource type, skip\n", idname) + } + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&idNames, "umem-id", nil, "Required. Resource ID of redis instances to resize") + flags.IntVar(&p.size, "size-gb", 0, "Required. Target memory size in GB") + flags.StringVar(&p.blockID, "block-id", "", "Required for distributed redis. Block ID to resize") + flags.StringVar(&p.region, "region", ctx.DefaultRegion(), "Optional. Override default region for this command invocation, see 'ucloud region'") + flags.StringVar(&p.zone, "zone", ctx.DefaultZone(), "Optional. Override default availability zone for this command invocation, see 'ucloud region'") + flags.StringVar(&p.projectID, "project-id", ctx.DefaultProjectID(), "Optional. Override default project-id for this command invocation, see 'ucloud project list'") + + command.SetCompletion(cmd, "region", ctx.RegionList) + command.SetCompletion(cmd, "zone", func() []string { return ctx.ZoneList(p.region) }) + command.SetCompletion(cmd, "project-id", ctx.ProjectList) + command.SetCompletion(cmd, "umem-id", func() []string { + return getIDList(ctx, p.projectID, p.region) + }) + + cmd.MarkFlagRequired("umem-id") + cmd.MarkFlagRequired("size-gb") + + return cmd +} + +func resizeMasterReplica(ctx *cli.Context, p *resizeParams, id string) bool { + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewResizeURedisGroupRequest() + req.Region = &p.region + req.ProjectId = &p.projectID + req.GroupId = &id + req.Size = &p.size + _, err := client.ResizeURedisGroup(req) + if err != nil { + ctx.HandleError(err) + return false + } + fmt.Fprintf(ctx.ProgressWriter(), "redis[%s] resized\n", id) + return true +} + +func resizeDistributed(ctx *cli.Context, p *resizeParams, id string) bool { + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewResizeUDRedisBlockSizeRequest() + req.Region = &p.region + req.Zone = &p.zone + req.ProjectId = &p.projectID + req.SpaceId = &id + req.BlockId = &p.blockID + req.BlockSize = &p.size + _, err := client.ResizeUDRedisBlockSize(req) + if err != nil { + ctx.HandleError(err) + return false + } + fmt.Fprintf(ctx.ProgressWriter(), "redis[%s] resized\n", id) + return true +} diff --git a/products/redis/internal/redis/resize_proxy.go b/products/redis/internal/redis/resize_proxy.go new file mode 100644 index 0000000000..c22d6e7809 --- /dev/null +++ b/products/redis/internal/redis/resize_proxy.go @@ -0,0 +1,53 @@ +package redis + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/umem" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newResizeProxy returns ucloud redis resize-proxy. +func newResizeProxy(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewResizeUhproxyRequest() + cmd := &cobra.Command{ + Use: "resize-proxy", + Short: "Resize proxy of distributed redis", + Long: "Resize proxy of distributed redis", + Example: "ucloud redis resize-proxy --umem-id udb-xxx --proxy-id proxy-xxx --new-cpu 4", + Run: func(c *cobra.Command, args []string) { + _, err := client.ResizeUhproxy(req) + if err != nil { + ctx.HandleError(err) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "proxy[%s] resized\n", *req.ProxyId) + ctx.EmitResult(cli.OpResultRow{ResourceID: *req.ProxyId, Action: "resize-proxy", Status: "Resized"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + req.SpaceId = flags.String("umem-id", "", "Required. Resource ID of the distributed redis") + req.ProxyId = flags.String("proxy-id", "", "Required. Proxy ID of the proxy to resize") + req.NewCPU = flags.Int("new-cpu", 0, "Required. Target CPU cores of the proxy") + + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("umem-id") + cmd.MarkFlagRequired("proxy-id") + cmd.MarkFlagRequired("new-cpu") + command.SetCompletion(cmd, "umem-id", func() []string { + return getIDList(ctx, *req.ProjectId, *req.Region) + }) + + return cmd +} diff --git a/products/redis/internal/redis/resource_type.go b/products/redis/internal/redis/resource_type.go new file mode 100644 index 0000000000..0e54194551 --- /dev/null +++ b/products/redis/internal/redis/resource_type.go @@ -0,0 +1,43 @@ +package redis + +import ( + "fmt" + + "github.com/ucloud/ucloud-sdk-go/services/umem" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +type redisMode string + +const ( + redisModeUnknown redisMode = "" + redisModeMasterReplica redisMode = "master-replica" + redisModeDistributed redisMode = "distributed" +) + +func resourceTypeToMode(resourceType string) redisMode { + switch resourceType { + case "g4v6", "single": + return redisModeMasterReplica + case "performance", "cluster": + return redisModeDistributed + } + return redisModeUnknown +} + +func describeRedisMode(ctx *cli.Context, id string) (redisMode, error) { + client := cli.NewServiceClient(ctx, umem.NewClient) + req := client.NewDescribeUMemRequest() + req.Protocol = sdk.String("redis") + req.ResourceId = &id + resp, err := client.DescribeUMem(req) + if err != nil { + return redisModeUnknown, err + } + if len(resp.DataSet) < 1 { + return redisModeUnknown, fmt.Errorf("resource [%s] may not exist", id) + } + return resourceTypeToMode(resp.DataSet[0].ResourceType), nil +} diff --git a/products/redis/internal/redis/restart.go b/products/redis/internal/redis/restart.go index a598edc7d1..678f4704f1 100644 --- a/products/redis/internal/redis/restart.go +++ b/products/redis/internal/redis/restart.go @@ -20,14 +20,23 @@ func newRestart(ctx *cli.Context) *cobra.Command { cmd := &cobra.Command{ Use: "restart", Short: "Restart redis instances of master-replica type", - Long: "Restart redis instances of master-replica type", + Long: "Restart redis instances of master-replica type. Only master-replica instances are supported", Run: func(c *cobra.Command, args []string) { - reqs := make([]request.Common, len(idNames)) - for idx, idname := range idNames { + reqs := make([]request.Common, 0, len(idNames)) + for _, idname := range idNames { id := ctx.PickResourceID(idname) + mode, err := describeRedisMode(ctx, id) + if err != nil { + ctx.HandleError(err) + continue + } + if mode != redisModeMasterReplica { + fmt.Fprintf(ctx.ProgressWriter(), "redis[%s] is not master-replica type, skip\n", idname) + continue + } next := *req next.GroupId = &id - reqs[idx] = &next + reqs = append(reqs, &next) } prog := ctx.NewProgress() if len(reqs) > 5 { diff --git a/products/redis/internal/redis/rows.go b/products/redis/internal/redis/rows.go index a79c0da41b..a2a88289e1 100644 --- a/products/redis/internal/redis/rows.go +++ b/products/redis/internal/redis/rows.go @@ -14,7 +14,22 @@ type Row struct { CreateTime string } -var redisTypeMap = map[string]string{ - "single": "master-replica", - "distributed": "distributed", +type BlockRow struct { + BlockID string + BlockName string + BlockVip string + BlockPort int + BlockType string + BlockState string + BlockSize int + UsedSize int + SlotBegin int + SlotEnd int + ReadWeight int +} +type ProxyRow struct { + ProxyID string + ResourceID string + State string + Vip string } diff --git a/products/redis/product.yaml b/products/redis/product.yaml index 34204c7139..bd536e6aa7 100644 --- a/products/redis/product.yaml +++ b/products/redis/product.yaml @@ -1,6 +1,6 @@ name: redis owners: - - Episkey-G + - ucloud-umem-qingpfang commands: - redis enabled: true diff --git a/products/redis/testdata/cmdtree.golden b/products/redis/testdata/cmdtree.golden index f061c49fa3..423f5003b1 100644 --- a/products/redis/testdata/cmdtree.golden +++ b/products/redis/testdata/cmdtree.golden @@ -15,11 +15,38 @@ ucloud redis create use=create short=Create redis instance flag=version short= default=6.0 required= flag=vpc-id short= default= required= flag=zone short= default= required= +ucloud redis create-proxy use=create-proxy short=Create proxy for distributed redis + flag=cpu short= default=2 required=true + flag=port short= default=6379 required= + flag=project-id short= default= required= + flag=proxy-cnt short= default=1 required= + flag=region short= default= required= + flag=umem-id short= default= required=true + flag=zone short= default= required= ucloud redis delete use=delete short=Delete redis instances flag=project-id short= default= required= flag=region short= default= required= flag=umem-id short= default=[] required=true flag=zone short= default= required= +ucloud redis delete-proxy use=delete-proxy short=Delete proxy of distributed redis + flag=project-id short= default= required= + flag=proxy-id short= default= required=true + flag=region short= default= required= + flag=umem-id short= default= required=true + flag=zone short= default= required= +ucloud redis flush use=flush short=Clear data of redis instances + flag=db-num short= default=0 required= + flag=flush-type short= default=FlushAll required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=umem-id short= default=[] required=true + flag=zone short= default= required= +ucloud redis isolation use=isolation short=Open or close redis instances of master-replica type + flag=project-id short= default= required= + flag=region short= default= required= + flag=type short= default=close required=true + flag=umem-id short= default=[] required=true + flag=zone short= default= required= ucloud redis list use=list short=List redis instances flag=limit short= default=100 required= flag=offset short= default=0 required= @@ -27,6 +54,42 @@ ucloud redis list use=list short=List redis instances flag=region short= default= required= flag=umem-id short= default= required= flag=zone short= default= required= +ucloud redis list-block use=list-block short=List block info of distributed redis + flag=project-id short= default= required= + flag=region short= default= required= + flag=umem-id short= default= required=true + flag=zone short= default= required= +ucloud redis list-proxy use=list-proxy short=List proxy info of distributed redis + flag=project-id short= default= required= + flag=region short= default= required= + flag=umem-id short= default= required=true + flag=zone short= default= required= +ucloud redis modify-name use=modify-name short=Modify redis instance name + flag=name short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=umem-id short= default=[] required=true + flag=zone short= default= required= +ucloud redis modify-password use=modify-password short=Modify redis instance password + flag=password short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=umem-id short= default=[] required=true + flag=zone short= default= required= +ucloud redis resize use=resize short=Resize redis instances + flag=block-id short= default= required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=size-gb short= default=0 required=true + flag=umem-id short= default=[] required=true + flag=zone short= default= required= +ucloud redis resize-proxy use=resize-proxy short=Resize proxy of distributed redis + flag=new-cpu short= default=0 required=true + flag=project-id short= default= required= + flag=proxy-id short= default= required=true + flag=region short= default= required= + flag=umem-id short= default= required=true + flag=zone short= default= required= ucloud redis restart use=restart short=Restart redis instances of master-replica type flag=project-id short= default= required= flag=region short= default= required= diff --git a/products/redis/testdata/completion.golden b/products/redis/testdata/completion.golden index 1efe46792c..ce3b69dd56 100644 --- a/products/redis/testdata/completion.golden +++ b/products/redis/testdata/completion.golden @@ -6,14 +6,55 @@ ucloud redis create type static distributed,master-replica ucloud redis create version static 4.0,5.0,6.0,7.0 ucloud redis create vpc-id dynamic ucloud redis create zone dynamic +ucloud redis create-proxy project-id dynamic +ucloud redis create-proxy region dynamic +ucloud redis create-proxy umem-id dynamic +ucloud redis create-proxy zone dynamic ucloud redis delete project-id dynamic ucloud redis delete region dynamic ucloud redis delete umem-id dynamic ucloud redis delete zone dynamic +ucloud redis delete-proxy project-id dynamic +ucloud redis delete-proxy region dynamic +ucloud redis delete-proxy umem-id dynamic +ucloud redis delete-proxy zone dynamic +ucloud redis flush project-id dynamic +ucloud redis flush region dynamic +ucloud redis flush umem-id dynamic +ucloud redis flush zone dynamic +ucloud redis isolation project-id dynamic +ucloud redis isolation region dynamic +ucloud redis isolation type static close,open +ucloud redis isolation umem-id dynamic +ucloud redis isolation zone dynamic ucloud redis list project-id dynamic ucloud redis list region dynamic ucloud redis list umem-id dynamic ucloud redis list zone dynamic +ucloud redis list-block project-id dynamic +ucloud redis list-block region dynamic +ucloud redis list-block umem-id dynamic +ucloud redis list-block zone dynamic +ucloud redis list-proxy project-id dynamic +ucloud redis list-proxy region dynamic +ucloud redis list-proxy umem-id dynamic +ucloud redis list-proxy zone dynamic +ucloud redis modify-name project-id dynamic +ucloud redis modify-name region dynamic +ucloud redis modify-name umem-id dynamic +ucloud redis modify-name zone dynamic +ucloud redis modify-password project-id dynamic +ucloud redis modify-password region dynamic +ucloud redis modify-password umem-id dynamic +ucloud redis modify-password zone dynamic +ucloud redis resize project-id dynamic +ucloud redis resize region dynamic +ucloud redis resize umem-id dynamic +ucloud redis resize zone dynamic +ucloud redis resize-proxy project-id dynamic +ucloud redis resize-proxy region dynamic +ucloud redis resize-proxy umem-id dynamic +ucloud redis resize-proxy zone dynamic ucloud redis restart project-id dynamic ucloud redis restart region dynamic ucloud redis restart umem-id dynamic diff --git a/products/uclickhouse/internal/clickhouse/api.go b/products/uclickhouse/internal/clickhouse/api.go new file mode 100644 index 0000000000..1fff730428 --- /dev/null +++ b/products/uclickhouse/internal/clickhouse/api.go @@ -0,0 +1,47 @@ +package clickhouse + +import ( + "fmt" + "strings" + + uclickhousesdk "github.com/ucloud/ucloud-sdk-go/services/uclickhouse" + uerr "github.com/ucloud/ucloud-sdk-go/ucloud/error" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + "github.com/ucloud/ucloud-sdk-go/ucloud/response" +) + +type opResponse struct { + response.CommonBase +} + +type createUClickhouseClusterResponse struct { + response.CommonBase + Data createUClickhouseClusterResponseData +} + +type createUClickhouseClusterResponseData struct { + ClusterId string +} + +func invokeUClickhouseAction(client *uclickhousesdk.UClickhouseClient, action string, req request.Common, resp response.Common) error { + return enrichUClickhouseError(action, client.Client.InvokeAction(action, req, resp)) +} + +func enrichUClickhouseError(action string, err error) error { + if err == nil { + return nil + } + uErr, ok := err.(uerr.Error) + if !ok || uErr.Code() == 0 { + return err + } + message := strings.TrimSpace(uErr.Message()) + if message == "" { + message = "" + } + detail := fmt.Sprintf("UClickhouse API %s failed. RetCode:%d. Message:%s", action, uErr.Code(), message) + if strings.TrimSpace(uErr.Message()) == "" { + detail += "\nThe service did not return an error message. Check region/project and create-option compatibility, for example: ucloud uclickhouse create-option --region . Rerun with --debug if request details are needed." + } + return fmt.Errorf("%s", detail) +} diff --git a/products/uclickhouse/internal/clickhouse/args.go b/products/uclickhouse/internal/clickhouse/args.go new file mode 100644 index 0000000000..3bc568e7c3 --- /dev/null +++ b/products/uclickhouse/internal/clickhouse/args.go @@ -0,0 +1,69 @@ +package clickhouse + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +func noArgs(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return nil + } + if len(args) == 1 && (args[0] == "true" || args[0] == "false") { + return fmt.Errorf("unexpected argument %q for %s; boolean flags must use --flag=%s", args[0], cmd.CommandPath(), args[0]) + } + return fmt.Errorf("unexpected argument(s) for %s: %s", cmd.CommandPath(), strings.Join(args, " ")) +} + +func noFlagLikeValues(cmd *cobra.Command, names ...string) error { + for _, name := range names { + flag := cmd.Flags().Lookup(name) + if flag == nil || !flag.Changed { + continue + } + value := flag.Value.String() + if strings.HasPrefix(value, "-") { + return fmt.Errorf("flag --%s requires a value; got %q, which looks like another flag", name, value) + } + } + return nil +} + +func requireFlagsWhenBool(cmd *cobra.Command, conditionName string, conditionValue bool, requiredNames ...string) error { + value, err := cmd.Flags().GetBool(conditionName) + if err != nil || value != conditionValue { + return err + } + return requireNonEmptyFlags(cmd, fmt.Sprintf("--%s=%t", conditionName, conditionValue), requiredNames...) +} + +func requireFlagsWhenString(cmd *cobra.Command, conditionName, conditionValue string, requiredNames ...string) error { + value, err := cmd.Flags().GetString(conditionName) + if err != nil || !strings.EqualFold(value, conditionValue) { + return err + } + return requireNonEmptyFlags(cmd, fmt.Sprintf("--%s=%s", conditionName, conditionValue), requiredNames...) +} + +func requireNonEmptyFlags(cmd *cobra.Command, condition string, names ...string) error { + missing := []string{} + for _, name := range names { + flag := cmd.Flags().Lookup(name) + if flag == nil { + continue + } + if isEmptyFlagValue(flag.Value.String()) { + missing = append(missing, "--"+name) + } + } + if len(missing) == 0 { + return nil + } + return fmt.Errorf("missing required flag(s) when %s: %s", condition, strings.Join(missing, ", ")) +} + +func isEmptyFlagValue(value string) bool { + return value == "" || value == "[]" || value == "" +} diff --git a/products/uclickhouse/internal/clickhouse/cmd.go b/products/uclickhouse/internal/clickhouse/cmd.go new file mode 100644 index 0000000000..feb583e014 --- /dev/null +++ b/products/uclickhouse/internal/clickhouse/cmd.go @@ -0,0 +1,26 @@ +package clickhouse + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand builds the `uclickhouse` root command and mounts the subcommands. +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "uclickhouse", + Short: "Manage UClickhouse clusters", + Long: "Manage UClickhouse clusters", + Args: noArgs, + } + cmd.AddCommand(newCreate(ctx)) + cmd.AddCommand(newDelete(ctx)) + cmd.AddCommand(newDescribe(ctx)) + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newExpand(ctx)) + cmd.AddCommand(newResize(ctx)) + cmd.AddCommand(newRestart(ctx)) + cmd.AddCommand(newCreateOption(ctx)) + return cmd +} diff --git a/products/uclickhouse/internal/clickhouse/completion.go b/products/uclickhouse/internal/clickhouse/completion.go new file mode 100644 index 0000000000..34b5d8accb --- /dev/null +++ b/products/uclickhouse/internal/clickhouse/completion.go @@ -0,0 +1,42 @@ +package clickhouse + +import ( + "strings" + + uclickhousesdk "github.com/ucloud/ucloud-sdk-go/services/uclickhouse" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// getClusterList returns "ClusterId/Name" completion candidates for clickhouse-id flags. +func getClusterList(ctx *cli.Context, statuses []string, project, region string) []string { + client := cli.NewServiceClient(ctx, uclickhousesdk.NewClient) + req := client.NewListUClickhouseClusterRequest() + req.ProjectId = sdk.String(project) + req.Region = sdk.String(region) + resp, err := listUClickhouseCluster(client, req) + if err != nil { + return nil + } + list := []string{} + for _, cluster := range resp.Data.Clusters { + if cluster.ClusterId == "" { + continue + } + if statuses != nil { + matched := false + for _, status := range statuses { + if cluster.Status == status { + matched = true + break + } + } + if !matched { + continue + } + } + list = append(list, cluster.ClusterId+"/"+strings.Replace(cluster.ClusterName, " ", "-", -1)) + } + return list +} diff --git a/products/uclickhouse/internal/clickhouse/create.go b/products/uclickhouse/internal/clickhouse/create.go new file mode 100644 index 0000000000..f95d353bc5 --- /dev/null +++ b/products/uclickhouse/internal/clickhouse/create.go @@ -0,0 +1,146 @@ +package clickhouse + +import ( + "encoding/base64" + "fmt" + "strings" + + "github.com/spf13/cobra" + + uclickhousesdk "github.com/ucloud/ucloud-sdk-go/services/uclickhouse" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newCreate ucloud clickhouse create +func newCreate(ctx *cli.Context) *cobra.Command { + var adminPassword *string + var async *bool + var labels []string + client := cli.NewServiceClient(ctx, uclickhousesdk.NewClient) + req := client.NewCreateUClickhouseClusterRequest() + cmd := &cobra.Command{ + Use: "create", + Short: "Create UClickhouse cluster", + Long: "Create UClickhouse cluster", + Args: validateCreateArgs, + Run: func(cmd *cobra.Command, args []string) { + parsedLabels, err := parseLabels(labels) + if err != nil { + ctx.HandleError(err) + return + } + req.Labels = parsedLabels + req.AdminPassword = sdk.String(base64.StdEncoding.EncodeToString([]byte(*adminPassword))) + + w := ctx.ProgressWriter() + resp, err := createUClickhouseCluster(client, req) + if err != nil { + ctx.HandleError(err) + return + } + id := resp.Data.ClusterId + text := fmt.Sprintf("clickhouse[%s] is creating", id) + if *async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeClusterByID(ctx)).Spoll(id, text, []string{STATUS_RUNNING, STATUS_CREATE_FAILED}) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: id, Action: "create", Status: "Creating"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.ClusterName = flags.String("name", "clickhouse", "Optional. Cluster name, default clickhouse") + req.ClickhouseMachineTypeId = flags.String("clickhouse-machine-type-id", "", "Required. ClickHouse machine type ID") + req.DataDiskType = flags.String("data-disk-type", "", "Required. Data disk type") + req.ClickhouseVersion = flags.String("clickhouse-version", "", "Required. ClickHouse version") + adminPassword = flags.String("admin-password", "", "Required. Admin password; CLI base64-encodes it before sending") + req.VPCId = flags.String("vpc-id", "", "Optional. VPC ID") + req.SubnetId = flags.String("subnet-id", "", "Optional. Subnet ID") + req.ShardCount = flags.Int("shard-count", 1, "Optional. Shard count, default 1") + req.ReplicateCount = flags.Int("replicate-count", 2, "Optional. Replicate count, 1 or 2, default 2") + req.DataDiskSize = flags.Int("data-disk-size-gb", 100, "Optional. Data disk size in GB, default 100") + req.ChargeType = flags.String("charge-type", "Month", "Optional. 'Year', 'Month', or 'Dynamic', default Month") + req.Quantity = flags.Int("quantity", 1, "Optional. Purchase duration, default 1") + req.BackupId = flags.String("backup-id", "", "Optional. Backup task ID to restore from") + req.IsZookeeperHA = flags.Bool("zookeeper-ha", true, "Optional. Enable Zookeeper HA, default true") + req.ZookeeperMachineTypeId = flags.String("zookeeper-machine-type-id", "", "Required when --zookeeper-ha=true. Zookeeper machine type ID") + req.ZookeeperDataDiskType = flags.String("zookeeper-data-disk-type", "", "Required when --zookeeper-ha=true. Zookeeper data disk type") + req.ZookeeperDataDiskSize = flags.String("zookeeper-data-disk-size-gb", "", "Required when --zookeeper-ha=true. Zookeeper data disk size in GB") + req.IsSecGroup = flags.String("sec-group", "false", "Optional. Enable security group, true or false") + req.SecGroupIds = flags.String("sec-group-ids", "", "Optional. Security group IDs") + req.IsMultiZone = flags.String("multi-zone", "false", "Optional. Enable multi-zone, true or false") + flags.StringSliceVar(&req.MultiZones, "multi-zone-name", nil, "Optional. Availability zone name for multi-zone clusters") + flags.StringSliceVar(&labels, "label", nil, "Optional. Resource label, format: key=value, repeatable") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + async = flags.Bool("async", false, "Optional. Do not wait for creation to finish") + + command.SetFlagValues(cmd, "charge-type", "Month", "Year", "Dynamic") + command.SetFlagValues(cmd, "sec-group", "false", "true") + command.SetFlagValues(cmd, "multi-zone", "false", "true") + + cmd.MarkFlagRequired("clickhouse-machine-type-id") + cmd.MarkFlagRequired("data-disk-type") + cmd.MarkFlagRequired("clickhouse-version") + cmd.MarkFlagRequired("admin-password") + + return cmd +} + +func createUClickhouseCluster(client *uclickhousesdk.UClickhouseClient, req *uclickhousesdk.CreateUClickhouseClusterRequest) (*createUClickhouseClusterResponse, error) { + var resp createUClickhouseClusterResponse + reqCopier := *req + err := invokeUClickhouseAction(client, "CreateUClickhouseCluster", &reqCopier, &resp) + return &resp, err +} + +func parseLabels(labels []string) ([]uclickhousesdk.CreateUClickhouseClusterParamLabels, error) { + parsed := []uclickhousesdk.CreateUClickhouseClusterParamLabels{} + for _, label := range labels { + key, value, ok := strings.Cut(label, "=") + if !ok || key == "" { + return nil, fmt.Errorf("invalid label %q, want key=value", label) + } + parsed = append(parsed, uclickhousesdk.CreateUClickhouseClusterParamLabels{ + Key: sdk.String(key), + Value: sdk.String(value), + }) + } + return parsed, nil +} + +func validateCreateArgs(cmd *cobra.Command, args []string) error { + if err := noFlagLikeValues( + cmd, + "clickhouse-machine-type-id", + "data-disk-type", + "clickhouse-version", + "zookeeper-machine-type-id", + "zookeeper-data-disk-type", + "zookeeper-data-disk-size-gb", + "sec-group-ids", + ); err != nil { + return err + } + if err := noArgs(cmd, args); err != nil { + return err + } + if err := requireFlagsWhenBool(cmd, "zookeeper-ha", true, + "zookeeper-machine-type-id", + "zookeeper-data-disk-type", + "zookeeper-data-disk-size-gb", + ); err != nil { + return err + } + if err := requireFlagsWhenString(cmd, "sec-group", "true", "sec-group-ids"); err != nil { + return err + } + if err := requireFlagsWhenString(cmd, "multi-zone", "true", "multi-zone-name"); err != nil { + return err + } + return nil +} diff --git a/products/uclickhouse/internal/clickhouse/create_option.go b/products/uclickhouse/internal/clickhouse/create_option.go new file mode 100644 index 0000000000..50d195752f --- /dev/null +++ b/products/uclickhouse/internal/clickhouse/create_option.go @@ -0,0 +1,99 @@ +package clickhouse + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + uclickhousesdk "github.com/ucloud/ucloud-sdk-go/services/uclickhouse" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newCreateOption ucloud clickhouse create-option +func newCreateOption(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, uclickhousesdk.NewClient) + req := client.NewGetUClickhouseClusterCreateOptionRequest() + cmd := &cobra.Command{ + Use: "create-option", + Short: "List available UClickhouse creation options", + Long: "List available UClickhouse creation options", + Args: noArgs, + Run: func(cmd *cobra.Command, args []string) { + resp, err := getUClickhouseClusterCreateOption(client, req) + if err != nil { + ctx.HandleError(err) + return + } + ctx.PrintList(createOptionRows(resp.Data)) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + return cmd +} + +func getUClickhouseClusterCreateOption(client *uclickhousesdk.UClickhouseClient, req *uclickhousesdk.GetUClickhouseClusterCreateOptionRequest) (*uclickhousesdk.GetUClickhouseClusterCreateOptionResponse, error) { + var resp uclickhousesdk.GetUClickhouseClusterCreateOptionResponse + reqCopier := *req + err := invokeUClickhouseAction(client, "GetUClickhouseClusterCreateOption", &reqCopier, &resp) + return &resp, err +} + +func createOptionRows(data uclickhousesdk.GetCreateUClickhouseClusterOptionResponseData) []CreateOptionRow { + rows := []CreateOptionRow{} + for _, version := range data.ClickhouseVersions { + rows = append(rows, CreateOptionRow{ + OptionType: "version", + Version: version.Version, + VersionName: version.VersionName, + MaxNodeCount: fmt.Sprintf("%d", data.MaxNodeCount), + }) + } + rows = append(rows, machineTypeRows("clickhouse", data.MaxNodeCount, data.ClickhouseMachineTypes)...) + rows = append(rows, machineTypeRows("zookeeper", data.MaxNodeCount, data.ZookeeperMachineTypes)...) + return rows +} + +func machineTypeRows(nodeType string, maxNodeCount int, machineTypes []uclickhousesdk.ClickhouseMachineType) []CreateOptionRow { + rows := []CreateOptionRow{} + for _, machineType := range machineTypes { + for _, option := range machineType.ClickhouseMachineTypeOptions { + base := CreateOptionRow{ + OptionType: "machine-type", + NodeType: nodeType, + MachineTypeID: option.ClickhouseMachineTypeId, + MachineTypeName: machineType.ClickhouseMachineTypeName, + MachineType: option.MachineType, + CPU: fmt.Sprintf("%d", option.CPU), + MemoryGB: fmt.Sprintf("%d", option.Memory), + NodeCounts: joinInts(option.NodeCounts), + IsSecGroup: machineType.IsSecgroupMachineType, + MaxNodeCount: fmt.Sprintf("%d", maxNodeCount), + } + rows = append(rows, base) + for _, disk := range option.DataDisks { + diskRow := base + diskRow.OptionType = "data-disk" + diskRow.DiskType = disk.DiskType + diskRow.MinSizeGB = fmt.Sprintf("%d", disk.MinDiskSize) + diskRow.MaxSizeGB = fmt.Sprintf("%d", disk.MaxDiskSize) + diskRow.DefaultSizeGB = fmt.Sprintf("%d", disk.DefaultDataDiskSize) + diskRow.StepGB = fmt.Sprintf("%d", disk.Step) + rows = append(rows, diskRow) + } + } + } + return rows +} + +func joinInts(values []int) string { + parts := make([]string, 0, len(values)) + for _, value := range values { + parts = append(parts, fmt.Sprintf("%d", value)) + } + return strings.Join(parts, ",") +} diff --git a/products/uclickhouse/internal/clickhouse/create_test.go b/products/uclickhouse/internal/clickhouse/create_test.go new file mode 100644 index 0000000000..159cdd0474 --- /dev/null +++ b/products/uclickhouse/internal/clickhouse/create_test.go @@ -0,0 +1,183 @@ +package clickhouse + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/spf13/cobra" + + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/auth" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +func TestCreateRejectsMistypedFlagValueBeforeAPI(t *testing.T) { + cmd, apiCalled, cleanup := newCreateTestCommand(t) + defer cleanup() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs([]string{ + "create", + "--clickhouse-machine-type-id", "s1-x1", + "--data-disk-type", "--data-disk-type", "CLOUD_RSSD", + "--clickhouse-version", "24.8.14.39", + "--admin-password", "4277813Aa", + "--async", + }) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected create to reject mistyped flag value") + } + if *apiCalled { + t.Fatal("create should reject malformed arguments before calling API") + } + if !strings.Contains(err.Error(), `flag --data-disk-type requires a value`) || !strings.Contains(err.Error(), `got "--data-disk-type"`) { + t.Fatalf("error = %q, want data-disk-type value error", err.Error()) + } +} + +func TestCreateRejectsMissingZookeeperOptionsBeforeAPI(t *testing.T) { + cmd, apiCalled, cleanup := newCreateTestCommand(t) + defer cleanup() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs([]string{ + "create", + "--clickhouse-machine-type-id", "s1-x1", + "--name", "cli-jjk", + "--data-disk-type", "CLOUD_RSSD", + "--clickhouse-version", "24.8.14.39", + "--admin-password", "4277813Aa", + "--async", + }) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected create to reject missing zookeeper options") + } + if *apiCalled { + t.Fatal("create should reject missing zookeeper options before calling API") + } + for _, want := range []string{"--zookeeper-machine-type-id", "--zookeeper-data-disk-type", "--zookeeper-data-disk-size-gb"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error = %q, want mention %s", err.Error(), want) + } + } +} + +func TestCreateRejectsOtherMissingConditionalOptionsBeforeAPI(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + { + name: "sec group IDs", + args: []string{"--zookeeper-ha=false", "--sec-group", "true"}, + want: "--sec-group-ids", + }, + { + name: "multi zone names", + args: []string{"--zookeeper-ha=false", "--multi-zone", "true"}, + want: "--multi-zone-name", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd, apiCalled, cleanup := newCreateTestCommand(t) + defer cleanup() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + baseArgs := []string{ + "create", + "--clickhouse-machine-type-id", "s1-x1", + "--data-disk-type", "CLOUD_RSSD", + "--clickhouse-version", "24.8.14.39", + "--admin-password", "4277813Aa", + "--async", + } + cmd.SetArgs(append(baseArgs, tt.args...)) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected create to reject missing conditional options") + } + if *apiCalled { + t.Fatal("create should reject missing conditional options before calling API") + } + if !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error = %q, want mention %s", err.Error(), tt.want) + } + }) + } +} + +func TestCreateRejectsBareBooleanArgument(t *testing.T) { + cmd, apiCalled, cleanup := newCreateTestCommand(t) + defer cleanup() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs([]string{ + "create", + "--clickhouse-machine-type-id", "s1-x1", + "--data-disk-type", "CLOUD_RSSD", + "--clickhouse-version", "24.8.14.39", + "--admin-password", "4277813Aa", + "--zookeeper-ha", "false", + "--async", + }) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected create to reject bare boolean argument") + } + if *apiCalled { + t.Fatal("create should reject bare boolean argument before calling API") + } + if !strings.Contains(err.Error(), "boolean flags must use --flag=false") { + t.Fatalf("error = %q, want boolean flag hint", err.Error()) + } +} + +func newCreateTestCommand(t *testing.T) (*cobra.Command, *bool, func()) { + t.Helper() + apiCalled := false + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + apiCalled = true + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `{"RetCode":0,"Message":"success","Data":{"ClusterId":"uck-test"}}`) + })) + + cfg := sdk.NewConfig() + cfg.BaseUrl = api.URL + cfg.Region = "cn-bj2" + cfg.ProjectId = "org-test" + cred := auth.NewCredential() + cred.PublicKey = "public" + cred.PrivateKey = "private" + + var out, errOut bytes.Buffer + ctx := cli.NewContext(cli.Deps{ + Out: &out, + Err: &errOut, + Format: cli.OutputJSON, + DefaultsProvider: func() command.Defaults { + return command.Defaults{ProjectID: "org-test", Region: "cn-bj2"} + }, + ClientConfig: func() *sdk.Config { + return &cfg + }, + BuildCredential: func() *auth.Credential { + return &cred + }, + AttachHandlers: func(sdk.ServiceClient) {}, + }) + return NewCommand(ctx), &apiCalled, api.Close +} diff --git a/products/uclickhouse/internal/clickhouse/delete.go b/products/uclickhouse/internal/clickhouse/delete.go new file mode 100644 index 0000000000..eed4b22e9a --- /dev/null +++ b/products/uclickhouse/internal/clickhouse/delete.go @@ -0,0 +1,70 @@ +package clickhouse + +import ( + "fmt" + + "github.com/spf13/cobra" + + uclickhousesdk "github.com/ucloud/ucloud-sdk-go/services/uclickhouse" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newDelete ucloud clickhouse delete +func newDelete(ctx *cli.Context) *cobra.Command { + var clusterIDs *[]string + var yes *bool + client := cli.NewServiceClient(ctx, uclickhousesdk.NewClient) + req := client.NewDestroyUClickhouseClusterRequest() + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete UClickhouse clusters", + Long: "Delete UClickhouse clusters", + Args: noArgs, + Run: func(cmd *cobra.Command, args []string) { + ok, err := ctx.Confirm(*yes, "Are you sure to delete UClickhouse cluster(s)?") + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, idName := range *clusterIDs { + id := ctx.PickResourceID(idName) + req.ClusterId = &id + _, err := destroyUClickhouseCluster(client, req) + if err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(w, "clickhouse[%s] deleted\n", id) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "delete", Status: "Deleted"}) + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + clusterIDs = flags.StringSlice("clickhouse-id", nil, "Required. UClickhouse cluster ID(s) to delete") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + yes = flags.BoolP("yes", "y", false, "Optional. Skip confirmation prompt") + + command.SetCompletion(cmd, "clickhouse-id", func() []string { + return getClusterList(ctx, []string{STATUS_RUNNING, STATUS_CREATE_FAILED, STATUS_RESTART_FAILED, STATUS_RESIZE_FAILED, STATUS_EXPAND_FAILED, STATUS_BACKUP_RESTORE_FAILED}, *req.ProjectId, *req.Region) + }) + + cmd.MarkFlagRequired("clickhouse-id") + return cmd +} + +func destroyUClickhouseCluster(client *uclickhousesdk.UClickhouseClient, req *uclickhousesdk.DestroyUClickhouseClusterRequest) (*opResponse, error) { + var resp opResponse + reqCopier := *req + err := invokeUClickhouseAction(client, "DestroyUClickhouseCluster", &reqCopier, &resp) + return &resp, err +} diff --git a/products/uclickhouse/internal/clickhouse/describe.go b/products/uclickhouse/internal/clickhouse/describe.go new file mode 100644 index 0000000000..fbeca1d796 --- /dev/null +++ b/products/uclickhouse/internal/clickhouse/describe.go @@ -0,0 +1,197 @@ +package clickhouse + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/spf13/cobra" + + uclickhousesdk "github.com/ucloud/ucloud-sdk-go/services/uclickhouse" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + "github.com/ucloud/ucloud-sdk-go/ucloud/response" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +type describeUClickhouseClusterResponse struct { + response.CommonBase + Data describeUClickhouseClusterResponseData + Message string +} + +type describeUClickhouseClusterResponseData struct { + ClickhouseNodes []uclickhousesdk.ClickhouseNode + Cluster uclickhousesdk.ClickhouseCluster + Payment uclickhousePayment + ZookeeperNodes []uclickhousesdk.ZookeeperNode +} + +type uclickhousePayment struct { + ChargeType string + CreateTimestamp int + ExpireTimestamp int + OriginalPrice flexibleString + Price flexibleString + ResourceId string +} + +type flexibleString string + +func (v *flexibleString) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + *v = "" + return nil + } + var s string + if err := json.Unmarshal(data, &s); err == nil { + *v = flexibleString(s) + return nil + } + var number json.Number + if err := json.Unmarshal(data, &number); err != nil { + return err + } + *v = flexibleString(number.String()) + return nil +} + +func (v flexibleString) String() string { + return string(v) +} + +// newDescribe ucloud clickhouse describe +func newDescribe(ctx *cli.Context) *cobra.Command { + var clusterID *string + client := cli.NewServiceClient(ctx, uclickhousesdk.NewClient) + req := client.NewDescribeUClickhouseClusterRequest() + cmd := &cobra.Command{ + Use: "describe", + Short: "Describe UClickhouse cluster details", + Long: "Describe UClickhouse cluster details", + Args: noArgs, + Run: func(cmd *cobra.Command, args []string) { + id := ctx.PickResourceID(*clusterID) + req.ClusterId = sdk.String(id) + resp, err := describeUClickhouseCluster(client, req) + if err != nil { + ctx.HandleError(err) + return + } + ctx.PrintList(describeRows(resp.Data)) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + clusterID = flags.String("clickhouse-id", "", "Required. UClickhouse cluster ID to describe") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + + command.SetCompletion(cmd, "clickhouse-id", func() []string { + return getClusterList(ctx, nil, *req.ProjectId, *req.Region) + }) + + cmd.MarkFlagRequired("clickhouse-id") + return cmd +} + +func describeUClickhouseCluster(client *uclickhousesdk.UClickhouseClient, req *uclickhousesdk.DescribeUClickhouseClusterRequest) (*describeUClickhouseClusterResponse, error) { + var resp describeUClickhouseClusterResponse + reqCopier := *req + err := invokeUClickhouseAction(client, "DescribeUClickhouseCluster", &reqCopier, &resp) + return &resp, err +} + +func describeRows(data describeUClickhouseClusterResponseData) []cli.DescribeRow { + cluster := data.Cluster + rows := []cli.DescribeRow{ + {Attribute: "ClusterID", Content: cluster.ClusterId}, + {Attribute: "ClusterName", Content: cluster.ClusterName}, + {Attribute: "Status", Content: cluster.Status}, + {Attribute: "VPCId", Content: cluster.VPCId}, + {Attribute: "SubnetId", Content: cluster.SubnetId}, + {Attribute: "ClickhouseVersion", Content: cluster.ClickhouseVersion}, + {Attribute: "ZookeeperVersion", Content: cluster.ZookeeperVersion}, + {Attribute: "MachineType", Content: cluster.MachineType}, + {Attribute: "ShardCount", Content: fmt.Sprintf("%d", cluster.ShardCount)}, + {Attribute: "ReplicateCount", Content: fmt.Sprintf("%d", cluster.ReplicateCount)}, + {Attribute: "ClickhouseMachineTypeID", Content: cluster.ClickhouseMachineTypeId}, + {Attribute: "ClickhouseMachineTypeName", Content: cluster.ClickhouseMachineTypeName}, + {Attribute: "ClickhouseDataDiskType", Content: cluster.ClickhouseDataDiskType}, + {Attribute: "ClickhouseDataDiskSize", Content: fmt.Sprintf("%dGB", cluster.ClickhouseDataDiskSize)}, + {Attribute: "ClickhouseNodeCPU", Content: fmt.Sprintf("%d", cluster.ClickhouseNodeCPU)}, + {Attribute: "ClickhouseNodeMemory", Content: fmt.Sprintf("%dGB", cluster.ClickhouseNodeMemory)}, + {Attribute: "ZookeeperMachineTypeID", Content: cluster.ZookeeperMachineTypeId}, + {Attribute: "ZookeeperMachineTypeName", Content: cluster.ZookeeperMachineTypeName}, + {Attribute: "ZookeeperDataDiskType", Content: cluster.ZookeeperDataDiskType}, + {Attribute: "ZookeeperDataDiskSize", Content: fmt.Sprintf("%dGB", cluster.ZookeeperDataDiskSize)}, + {Attribute: "ZookeeperNodeCPU", Content: fmt.Sprintf("%d", cluster.ZookeeperNodeCPU)}, + {Attribute: "ZookeeperNodeMemory", Content: fmt.Sprintf("%dGB", cluster.ZookeeperNodeMemory)}, + {Attribute: "IsZookeeperHA", Content: cluster.IsZookeeperHA}, + {Attribute: "IsSecgroup", Content: cluster.IsSecgroup}, + {Attribute: "IsBackup", Content: cluster.IsBackup}, + {Attribute: "IsTieredStorage", Content: cluster.IsTieredStorage}, + {Attribute: "MultiZones", Content: strings.Join(cluster.MultiZones, ",")}, + {Attribute: "CreateTime", Content: formatUnixDate(cluster.CreateTimestamp)}, + {Attribute: "ExpireTime", Content: formatUnixDate(int(cluster.ExpireTimestamp))}, + {Attribute: "Payment.ChargeType", Content: data.Payment.ChargeType}, + {Attribute: "Payment.Price", Content: data.Payment.Price.String()}, + {Attribute: "Payment.OriginalPrice", Content: data.Payment.OriginalPrice.String()}, + } + if len(data.ClickhouseNodes) > 0 { + rows = append(rows, cli.DescribeRow{Attribute: "--- ClickhouseNodes ---", Content: fmt.Sprintf("%d nodes", len(data.ClickhouseNodes))}) + for i, node := range data.ClickhouseNodes { + prefix := fmt.Sprintf("ClickhouseNode[%d]", i) + rows = append(rows, + cli.DescribeRow{Attribute: prefix + ".NodeID", Content: node.NodeId}, + cli.DescribeRow{Attribute: prefix + ".NodeName", Content: node.NodeName}, + cli.DescribeRow{Attribute: prefix + ".Zone", Content: node.Zone}, + cli.DescribeRow{Attribute: prefix + ".IPv4", Content: node.IPv4}, + cli.DescribeRow{Attribute: prefix + ".ServiceStatus", Content: node.ServiceStatus}, + cli.DescribeRow{Attribute: prefix + ".ShardGroup", Content: node.ShardGroup}, + cli.DescribeRow{Attribute: prefix + ".MachineType", Content: node.MachineType}, + cli.DescribeRow{Attribute: prefix + ".CPU", Content: fmt.Sprintf("%d", node.CPU)}, + cli.DescribeRow{Attribute: prefix + ".Memory", Content: fmt.Sprintf("%dGB", node.Memory)}, + cli.DescribeRow{Attribute: prefix + ".DataDiskSize", Content: fmt.Sprintf("%dGB", node.DataDiskSize)}, + cli.DescribeRow{Attribute: prefix + ".DataDiskType", Content: node.DataDiskType}, + ) + } + } + if len(data.ZookeeperNodes) > 0 { + rows = append(rows, cli.DescribeRow{Attribute: "--- ZookeeperNodes ---", Content: fmt.Sprintf("%d nodes", len(data.ZookeeperNodes))}) + for i, node := range data.ZookeeperNodes { + prefix := fmt.Sprintf("ZookeeperNode[%d]", i) + rows = append(rows, + cli.DescribeRow{Attribute: prefix + ".NodeID", Content: node.NodeId}, + cli.DescribeRow{Attribute: prefix + ".NodeName", Content: node.NodeName}, + cli.DescribeRow{Attribute: prefix + ".Zone", Content: node.Zone}, + cli.DescribeRow{Attribute: prefix + ".ServiceStatus", Content: node.ServiceStatus}, + cli.DescribeRow{Attribute: prefix + ".MachineType", Content: node.MachineType}, + cli.DescribeRow{Attribute: prefix + ".CPU", Content: fmt.Sprintf("%d", node.CPU)}, + cli.DescribeRow{Attribute: prefix + ".Memory", Content: fmt.Sprintf("%dGB", node.Memory)}, + cli.DescribeRow{Attribute: prefix + ".DataDiskSize", Content: fmt.Sprintf("%dGB", node.DataDiskSize)}, + cli.DescribeRow{Attribute: prefix + ".DataDiskType", Content: node.DataDiskType}, + ) + } + } + return rows +} + +// describeClusterByID returns the poller's describe func. +func describeClusterByID(ctx *cli.Context) func(clusterID string, commonBase *request.CommonBase) (interface{}, error) { + return func(clusterID string, commonBase *request.CommonBase) (interface{}, error) { + client := cli.NewServiceClient(ctx, uclickhousesdk.NewClient) + req := client.NewDescribeUClickhouseClusterRequest() + if commonBase != nil { + req.CommonBase = *commonBase + } + req.ClusterId = sdk.String(clusterID) + resp, err := describeUClickhouseCluster(client, req) + if err != nil { + return nil, err + } + return &resp.Data.Cluster, nil + } +} diff --git a/products/uclickhouse/internal/clickhouse/expand.go b/products/uclickhouse/internal/clickhouse/expand.go new file mode 100644 index 0000000000..8cdab59910 --- /dev/null +++ b/products/uclickhouse/internal/clickhouse/expand.go @@ -0,0 +1,66 @@ +package clickhouse + +import ( + "fmt" + + "github.com/spf13/cobra" + + uclickhousesdk "github.com/ucloud/ucloud-sdk-go/services/uclickhouse" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newExpand ucloud clickhouse expand +func newExpand(ctx *cli.Context) *cobra.Command { + var async *bool + var clusterID *string + client := cli.NewServiceClient(ctx, uclickhousesdk.NewClient) + req := client.NewExpandUClickhouseClusterRequest() + cmd := &cobra.Command{ + Use: "expand", + Short: "Expand UClickhouse cluster node count", + Long: "Expand UClickhouse cluster node count", + Args: noArgs, + Run: func(cmd *cobra.Command, args []string) { + id := ctx.PickResourceID(*clusterID) + req.ClusterId = &id + w := ctx.ProgressWriter() + _, err := expandUClickhouseCluster(client, req) + if err != nil { + ctx.HandleError(err) + return + } + text := fmt.Sprintf("clickhouse[%s] is expanding", id) + if *async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeClusterByID(ctx)).Spoll(id, text, []string{STATUS_RUNNING, STATUS_EXPAND_FAILED}) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: id, Action: "expand", Status: "Expanding"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + clusterID = flags.String("clickhouse-id", "", "Required. UClickhouse cluster ID to expand") + req.TotalNodeCount = flags.Int("total-node-count", 0, "Required. Total node count after expansion") + req.SyncNodeId = flags.String("sync-node-id", "", "Optional. Existing node ID used to sync schema/user information") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + async = flags.Bool("async", false, "Optional. Do not wait for expansion to finish") + + command.SetCompletion(cmd, "clickhouse-id", func() []string { + return getClusterList(ctx, []string{STATUS_RUNNING}, *req.ProjectId, *req.Region) + }) + + cmd.MarkFlagRequired("clickhouse-id") + cmd.MarkFlagRequired("total-node-count") + return cmd +} + +func expandUClickhouseCluster(client *uclickhousesdk.UClickhouseClient, req *uclickhousesdk.ExpandUClickhouseClusterRequest) (*opResponse, error) { + var resp opResponse + reqCopier := *req + err := invokeUClickhouseAction(client, "ExpandUClickhouseCluster", &reqCopier, &resp) + return &resp, err +} diff --git a/products/uclickhouse/internal/clickhouse/list.go b/products/uclickhouse/internal/clickhouse/list.go new file mode 100644 index 0000000000..fde369ed0f --- /dev/null +++ b/products/uclickhouse/internal/clickhouse/list.go @@ -0,0 +1,77 @@ +package clickhouse + +import ( + "fmt" + + "github.com/spf13/cobra" + + uclickhousesdk "github.com/ucloud/ucloud-sdk-go/services/uclickhouse" + "github.com/ucloud/ucloud-sdk-go/ucloud/response" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +type listUClickhouseClusterResponse struct { + response.CommonBase + Data listUClickhouseClusterResponseData + Message string +} + +type listUClickhouseClusterResponseData struct { + Clusters []uclickhousesdk.ClickhouseCluster + TotalCount int +} + +// newList ucloud clickhouse list +func newList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, uclickhousesdk.NewClient) + req := client.NewListUClickhouseClusterRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List UClickhouse clusters", + Long: "List UClickhouse clusters", + Args: noArgs, + Run: func(cmd *cobra.Command, args []string) { + resp, err := listUClickhouseCluster(client, req) + if err != nil { + ctx.HandleError(err) + return + } + list := []ClusterRow{} + for _, cluster := range resp.Data.Clusters { + list = append(list, clusterRow(cluster)) + } + ctx.PrintList(list) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + return cmd +} + +func listUClickhouseCluster(client *uclickhousesdk.UClickhouseClient, req *uclickhousesdk.ListUClickhouseClusterRequest) (*listUClickhouseClusterResponse, error) { + var resp listUClickhouseClusterResponse + reqCopier := *req + err := invokeUClickhouseAction(client, "ListUClickhouseCluster", &reqCopier, &resp) + return &resp, err +} + +func clusterRow(cluster uclickhousesdk.ClickhouseCluster) ClusterRow { + return ClusterRow{ + ClusterID: cluster.ClusterId, + ClusterName: cluster.ClusterName, + Status: cluster.Status, + ClickhouseVersion: cluster.ClickhouseVersion, + ShardCount: fmt.Sprintf("%d", cluster.ShardCount), + ReplicateCount: fmt.Sprintf("%d", cluster.ReplicateCount), + VPCId: cluster.VPCId, + SubnetId: cluster.SubnetId, + ClickhouseMachineTypeID: cluster.ClickhouseMachineTypeId, + ClickhouseDataDiskType: cluster.ClickhouseDataDiskType, + ClickhouseDataDiskSize: fmt.Sprintf("%d", cluster.ClickhouseDataDiskSize), + CreateTime: formatUnixDate(cluster.CreateTimestamp), + ExpireTime: formatUnixDate(int(cluster.ExpireTimestamp)), + } +} diff --git a/products/uclickhouse/internal/clickhouse/list_test.go b/products/uclickhouse/internal/clickhouse/list_test.go new file mode 100644 index 0000000000..298e0dbb7b --- /dev/null +++ b/products/uclickhouse/internal/clickhouse/list_test.go @@ -0,0 +1,148 @@ +package clickhouse + +import ( + "encoding/json" + "strings" + "testing" + + uclickhousesdk "github.com/ucloud/ucloud-sdk-go/services/uclickhouse" + uerr "github.com/ucloud/ucloud-sdk-go/ucloud/error" +) + +func TestListUClickhouseClusterResponseDecodesClusterArray(t *testing.T) { + body := []byte(`{ + "RetCode": 0, + "Message": "success", + "Data": { + "Clusters": [ + { + "ClusterId": "uck-1", + "ClusterName": "test", + "Status": "RUNNING", + "CreateTimestamp": 1783700833334 + }, + { + "ClusterId": "uck-2", + "ClusterName": "tp_test", + "Status": "RUNNING", + "CreateTimestamp": 1783504224814 + } + ], + "TotalCount": 2 + } + }`) + + var resp listUClickhouseClusterResponse + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("unmarshal list response: %v", err) + } + if got := len(resp.Data.Clusters); got != 2 { + t.Fatalf("cluster count = %d, want 2", got) + } + if got := resp.Data.Clusters[1].ClusterId; got != "uck-2" { + t.Fatalf("second cluster id = %q, want uck-2", got) + } +} + +func TestDescribeUClickhouseClusterResponseDecodesStringPaymentPrice(t *testing.T) { + body := []byte(`{ + "RetCode": 0, + "Message": "success", + "Data": { + "Cluster": { + "ClusterId": "uck-1", + "ClusterName": "test", + "Status": "RUNNING", + "CreateTimestamp": 1783700833334, + "ExpireTimestamp": null + }, + "ClickhouseNodes": [], + "Payment": { + "ChargeType": "Dynamic", + "CreateTimestamp": 1783700878, + "ExpireTimestamp": 1783926000, + "Price": "1.63", + "OriginalPrice": "1.63", + "ResourceId": "uck-1" + }, + "ZookeeperNodes": [] + } + }`) + + var resp describeUClickhouseClusterResponse + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("unmarshal describe response: %v", err) + } + if got := resp.Data.Payment.Price.String(); got != "1.63" { + t.Fatalf("payment price = %q, want 1.63", got) + } +} + +func TestCreateAndOpResponsesDecodeMinimalPayloads(t *testing.T) { + var createResp createUClickhouseClusterResponse + if err := json.Unmarshal([]byte(`{"RetCode":0,"Message":"success","Data":{"ClusterId":"uck-new"}}`), &createResp); err != nil { + t.Fatalf("unmarshal create response: %v", err) + } + if got := createResp.Data.ClusterId; got != "uck-new" { + t.Fatalf("cluster id = %q, want uck-new", got) + } + + var opResp opResponse + if err := json.Unmarshal([]byte(`{"RetCode":0,"Message":"success"}`), &opResp); err != nil { + t.Fatalf("unmarshal op response: %v", err) + } + if got := opResp.Message; got != "success" { + t.Fatalf("message = %q, want success", got) + } +} + +func TestClusterRowFormatsMillisecondCreateTimestamp(t *testing.T) { + row := clusterRow(uclickhousesdk.ClickhouseCluster{ + ClusterId: "uck-1", + CreateTimestamp: 1783700833334, + ExpireTimestamp: 1783926000, + }) + + if row.CreateTime != "2026-07-11" { + t.Fatalf("CreateTime = %q, want 2026-07-11", row.CreateTime) + } + if row.ExpireTime != "2026-07-13" { + t.Fatalf("ExpireTime = %q, want 2026-07-13", row.ExpireTime) + } +} + +func TestFormatUnixDateReturnsEmptyForMissingTimestamp(t *testing.T) { + if got := formatUnixDate(0); got != "" { + t.Fatalf("formatUnixDate(0) = %q, want empty", got) + } +} + +func TestEnrichUClickhouseErrorFormatsEmptyServerMessage(t *testing.T) { + err := enrichUClickhouseError("CreateUClickhouseCluster", uerr.NewServerCodeError(207803, "")) + if err == nil { + t.Fatal("expected enriched error") + } + got := err.Error() + for _, want := range []string{ + "UClickhouse API CreateUClickhouseCluster failed", + "RetCode:207803", + "Message:", + "ucloud uclickhouse create-option", + "--debug", + } { + if !strings.Contains(got, want) { + t.Fatalf("error = %q, want contain %q", got, want) + } + } +} + +func TestEnrichUClickhouseErrorFormatsServerMessage(t *testing.T) { + err := enrichUClickhouseError("ListUClickhouseCluster", uerr.NewServerCodeError(123, "boom")) + if err == nil { + t.Fatal("expected enriched error") + } + got := err.Error() + if !strings.Contains(got, "UClickhouse API ListUClickhouseCluster failed") || !strings.Contains(got, "Message:boom") { + t.Fatalf("error = %q, want action and service message", got) + } +} diff --git a/products/uclickhouse/internal/clickhouse/resize.go b/products/uclickhouse/internal/clickhouse/resize.go new file mode 100644 index 0000000000..05eea48d0b --- /dev/null +++ b/products/uclickhouse/internal/clickhouse/resize.go @@ -0,0 +1,66 @@ +package clickhouse + +import ( + "fmt" + + "github.com/spf13/cobra" + + uclickhousesdk "github.com/ucloud/ucloud-sdk-go/services/uclickhouse" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newResize ucloud clickhouse resize +func newResize(ctx *cli.Context) *cobra.Command { + var async *bool + var clusterID *string + client := cli.NewServiceClient(ctx, uclickhousesdk.NewClient) + req := client.NewResizeUClickhouseClusterRequest() + cmd := &cobra.Command{ + Use: "resize", + Short: "Resize UClickhouse cluster", + Long: "Resize UClickhouse cluster. Set target-machine-type-id to change spec, or target-data-disk-size-gb to expand disk.", + Args: noArgs, + Run: func(cmd *cobra.Command, args []string) { + id := ctx.PickResourceID(*clusterID) + req.ClusterId = &id + w := ctx.ProgressWriter() + _, err := resizeUClickhouseCluster(client, req) + if err != nil { + ctx.HandleError(err) + return + } + text := fmt.Sprintf("clickhouse[%s] is resizing", id) + if *async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeClusterByID(ctx)).Spoll(id, text, []string{STATUS_RUNNING, STATUS_RESIZE_FAILED}) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: id, Action: "resize", Status: "Resizing"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + clusterID = flags.String("clickhouse-id", "", "Required. UClickhouse cluster ID to resize") + req.TargetMachineTypeId = flags.String("target-machine-type-id", "", "Optional. Target machine type ID") + req.TargetDataDiskSize = flags.Int("target-data-disk-size-gb", 0, "Optional. Target data disk size in GB") + req.IsZooKeeperNode = flags.Bool("zookeeper-node", false, "Optional. Resize Zookeeper nodes instead of ClickHouse nodes") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + async = flags.Bool("async", false, "Optional. Do not wait for resize to finish") + + command.SetCompletion(cmd, "clickhouse-id", func() []string { + return getClusterList(ctx, []string{STATUS_RUNNING}, *req.ProjectId, *req.Region) + }) + + cmd.MarkFlagRequired("clickhouse-id") + return cmd +} + +func resizeUClickhouseCluster(client *uclickhousesdk.UClickhouseClient, req *uclickhousesdk.ResizeUClickhouseClusterRequest) (*opResponse, error) { + var resp opResponse + reqCopier := *req + err := invokeUClickhouseAction(client, "ResizeUClickhouseCluster", &reqCopier, &resp) + return &resp, err +} diff --git a/products/uclickhouse/internal/clickhouse/restart.go b/products/uclickhouse/internal/clickhouse/restart.go new file mode 100644 index 0000000000..25a93cea12 --- /dev/null +++ b/products/uclickhouse/internal/clickhouse/restart.go @@ -0,0 +1,73 @@ +package clickhouse + +import ( + "fmt" + + "github.com/spf13/cobra" + + uclickhousesdk "github.com/ucloud/ucloud-sdk-go/services/uclickhouse" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newRestart ucloud clickhouse restart +func newRestart(ctx *cli.Context) *cobra.Command { + var async *bool + var clusterID *string + var yes *bool + client := cli.NewServiceClient(ctx, uclickhousesdk.NewClient) + req := client.NewRestartUClickhouseClusterServiceRequest() + cmd := &cobra.Command{ + Use: "restart", + Short: "Restart UClickhouse cluster service", + Long: "Restart UClickhouse cluster service", + Args: noArgs, + Run: func(cmd *cobra.Command, args []string) { + id := ctx.PickResourceID(*clusterID) + ok, err := ctx.Confirm(*yes, fmt.Sprintf("Are you sure to restart UClickhouse cluster[%s]?", id)) + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + req.ClusterId = &id + w := ctx.ProgressWriter() + _, err = restartUClickhouseClusterService(client, req) + if err != nil { + ctx.HandleError(err) + return + } + text := fmt.Sprintf("clickhouse[%s] is restarting", id) + if *async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeClusterByID(ctx)).Spoll(id, text, []string{STATUS_RUNNING, STATUS_RESTART_FAILED}) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: id, Action: "restart", Status: "Restarting"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + clusterID = flags.String("clickhouse-id", "", "Required. UClickhouse cluster ID to restart") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + yes = flags.BoolP("yes", "y", false, "Optional. Skip confirmation prompt") + async = flags.Bool("async", false, "Optional. Do not wait for restart to finish") + + command.SetCompletion(cmd, "clickhouse-id", func() []string { + return getClusterList(ctx, []string{STATUS_RUNNING, STATUS_RESTART_FAILED}, *req.ProjectId, *req.Region) + }) + + cmd.MarkFlagRequired("clickhouse-id") + return cmd +} + +func restartUClickhouseClusterService(client *uclickhousesdk.UClickhouseClient, req *uclickhousesdk.RestartUClickhouseClusterServiceRequest) (*opResponse, error) { + var resp opResponse + reqCopier := *req + err := invokeUClickhouseAction(client, "RestartUClickhouseClusterService", &reqCopier, &resp) + return &resp, err +} diff --git a/products/uclickhouse/internal/clickhouse/rows.go b/products/uclickhouse/internal/clickhouse/rows.go new file mode 100644 index 0000000000..51a3568b05 --- /dev/null +++ b/products/uclickhouse/internal/clickhouse/rows.go @@ -0,0 +1,39 @@ +package clickhouse + +// ClusterRow represents a UClickhouse cluster in list output. +type ClusterRow struct { + ClusterID string + ClusterName string + Status string + ClickhouseVersion string + ShardCount string + ReplicateCount string + VPCId string + SubnetId string + ClickhouseMachineTypeID string + ClickhouseDataDiskType string + ClickhouseDataDiskSize string + CreateTime string + ExpireTime string +} + +// CreateOptionRow represents an available creation option. +type CreateOptionRow struct { + OptionType string + Version string + VersionName string + NodeType string + MachineTypeID string + MachineTypeName string + MachineType string + CPU string + MemoryGB string + NodeCounts string + IsSecGroup string + DiskType string + MinSizeGB string + MaxSizeGB string + DefaultSizeGB string + StepGB string + MaxNodeCount string +} diff --git a/products/uclickhouse/internal/clickhouse/status.go b/products/uclickhouse/internal/clickhouse/status.go new file mode 100644 index 0000000000..bb44599e6e --- /dev/null +++ b/products/uclickhouse/internal/clickhouse/status.go @@ -0,0 +1,16 @@ +package clickhouse + +const ( + STATUS_CREATING = "CREATING" + STATUS_RUNNING = "RUNNING" + STATUS_RESIZING = "RESIZING" + STATUS_RESTARTING = "RESTARTING" + STATUS_DESTROYING = "DESTROYING" + STATUS_CREATE_FAILED = "CREATE_FAILED" + STATUS_RESTART_FAILED = "RESTART_FAILED" + STATUS_DESTROY_FAILED = "DESTROY_FAILED" + STATUS_RESIZE_FAILED = "RESIZE_FAILED" + STATUS_BACKUP_RESTORE_FAILED = "BACKUP_RESTORE_FAILED" + STATUS_EXPANDING = "EXPANDING" + STATUS_EXPAND_FAILED = "EXPAND_FAILED" +) diff --git a/products/uclickhouse/internal/clickhouse/time.go b/products/uclickhouse/internal/clickhouse/time.go new file mode 100644 index 0000000000..6a4119e3e0 --- /dev/null +++ b/products/uclickhouse/internal/clickhouse/time.go @@ -0,0 +1,13 @@ +package clickhouse + +import "github.com/ucloud/ucloud-cli/internal/common" + +func formatUnixDate(timestamp int) string { + if timestamp <= 0 { + return "" + } + if timestamp > 1000000000000 { + timestamp = timestamp / 1000 + } + return common.FormatDate(timestamp) +} diff --git a/products/uclickhouse/product.go b/products/uclickhouse/product.go new file mode 100644 index 0000000000..bc592c8a3d --- /dev/null +++ b/products/uclickhouse/product.go @@ -0,0 +1,21 @@ +package uclickhouse + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + internalclickhouse "github.com/ucloud/ucloud-cli/products/uclickhouse/internal/clickhouse" +) + +type product struct{} + +// New returns the uclickhouse product (registered via hack/gen-products). +func New() cli.Product { return product{} } + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "uclickhouse", Commands: []string{"uclickhouse"}} +} + +func (product) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{internalclickhouse.NewCommand(ctx)} +} diff --git a/products/uclickhouse/product.yaml b/products/uclickhouse/product.yaml new file mode 100644 index 0000000000..ec85d6f72f --- /dev/null +++ b/products/uclickhouse/product.yaml @@ -0,0 +1,7 @@ +# products/uclickhouse/product.yaml — uclickhouse 产品元数据 +name: uclickhouse +owners: + - jukang-ucloud +commands: + - uclickhouse +enabled: true diff --git a/products/uclickhouse/testdata/cmdtree.golden b/products/uclickhouse/testdata/cmdtree.golden new file mode 100644 index 0000000000..eef0cbf4f9 --- /dev/null +++ b/products/uclickhouse/testdata/cmdtree.golden @@ -0,0 +1,63 @@ +ucloud uclickhouse use=uclickhouse short=Manage UClickhouse clusters +ucloud uclickhouse create use=create short=Create UClickhouse cluster + flag=admin-password short= default= required=true + flag=async short= default=false required= + flag=backup-id short= default= required= + flag=charge-type short= default=Month required= + flag=clickhouse-machine-type-id short= default= required=true + flag=clickhouse-version short= default= required=true + flag=data-disk-size-gb short= default=100 required= + flag=data-disk-type short= default= required=true + flag=label short= default=[] required= + flag=multi-zone short= default=false required= + flag=multi-zone-name short= default=[] required= + flag=name short= default=clickhouse required= + flag=project-id short= default= required= + flag=quantity short= default=1 required= + flag=region short= default= required= + flag=replicate-count short= default=2 required= + flag=sec-group short= default=false required= + flag=sec-group-ids short= default= required= + flag=shard-count short= default=1 required= + flag=subnet-id short= default= required= + flag=vpc-id short= default= required= + flag=zookeeper-data-disk-size-gb short= default= required= + flag=zookeeper-data-disk-type short= default= required= + flag=zookeeper-ha short= default=true required= + flag=zookeeper-machine-type-id short= default= required= +ucloud uclickhouse create-option use=create-option short=List available UClickhouse creation options + flag=project-id short= default= required= + flag=region short= default= required= +ucloud uclickhouse delete use=delete short=Delete UClickhouse clusters + flag=clickhouse-id short= default=[] required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=yes short=y default=false required= +ucloud uclickhouse describe use=describe short=Describe UClickhouse cluster details + flag=clickhouse-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= +ucloud uclickhouse expand use=expand short=Expand UClickhouse cluster node count + flag=async short= default=false required= + flag=clickhouse-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=sync-node-id short= default= required= + flag=total-node-count short= default=0 required=true +ucloud uclickhouse list use=list short=List UClickhouse clusters + flag=project-id short= default= required= + flag=region short= default= required= +ucloud uclickhouse resize use=resize short=Resize UClickhouse cluster + flag=async short= default=false required= + flag=clickhouse-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=target-data-disk-size-gb short= default=0 required= + flag=target-machine-type-id short= default= required= + flag=zookeeper-node short= default=false required= +ucloud uclickhouse restart use=restart short=Restart UClickhouse cluster service + flag=async short= default=false required= + flag=clickhouse-id short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=yes short=y default=false required= diff --git a/products/uclickhouse/testdata/completion.golden b/products/uclickhouse/testdata/completion.golden new file mode 100644 index 0000000000..03e942e32b --- /dev/null +++ b/products/uclickhouse/testdata/completion.golden @@ -0,0 +1,8 @@ +ucloud uclickhouse create charge-type static Dynamic,Month,Year +ucloud uclickhouse create multi-zone static false,true +ucloud uclickhouse create sec-group static false,true +ucloud uclickhouse delete clickhouse-id dynamic +ucloud uclickhouse describe clickhouse-id dynamic +ucloud uclickhouse expand clickhouse-id dynamic +ucloud uclickhouse resize clickhouse-id dynamic +ucloud uclickhouse restart clickhouse-id dynamic diff --git a/products/uddos/internal/mainland/cmd.go b/products/uddos/internal/mainland/cmd.go new file mode 100644 index 0000000000..56645e8172 --- /dev/null +++ b/products/uddos/internal/mainland/cmd.go @@ -0,0 +1,49 @@ +// Package mainland ... +// +// @Brief 国内高防命令组聚合 +// +// @File cmd.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package mainland + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + mainlandip "github.com/ucloud/ucloud-cli/products/uddos/internal/mainland/ip" + mainlandrule "github.com/ucloud/ucloud-cli/products/uddos/internal/mainland/rule" + mainlandsvc "github.com/ucloud/ucloud-cli/products/uddos/internal/mainland/service" +) + +// NewCommand 构建 uddos mainland 命令组 +// +// @Brief 构建国内高防命令组并挂载 service、ip 子命令 +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "mainland", + Short: "Manage mainland China DDoS high-protection services", + Long: "Manage UCloud mainland China (国内高防) DDoS protection services and IPs", + Args: cobra.NoArgs, + } + cmd.AddCommand(mainlandip.NewCommand(ctx)) + cmd.AddCommand(mainlandrule.NewCommand(ctx)) + cmd.AddCommand(mainlandsvc.NewCommand(ctx)) + return cmd +} diff --git a/products/uddos/internal/mainland/ip/cmd.go b/products/uddos/internal/mainland/ip/cmd.go new file mode 100644 index 0000000000..f67213f819 --- /dev/null +++ b/products/uddos/internal/mainland/ip/cmd.go @@ -0,0 +1,56 @@ +// Package ip ... +// +// @Brief 国内高防IP管理命令聚合 +// +// @File cmd.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package ip + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand 构建 uddos mainland ip 命令组 +// +// @Brief 构建国内高防 ip 命令组并挂载子命令 +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "ip", + Short: "Manage mainland DDoS protection IPs", + Long: "List, create and delete mainland BGP DDoS protection IPs", + Args: cobra.NoArgs, + } + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newCreate(ctx)) + cmd.AddCommand(newDelete(ctx)) + return cmd +} + +func strVal(m map[string]interface{}, key string) string { + v, _ := m[key].(string) + return v +} + +func intVal(m map[string]interface{}, key string) int { + v, _ := m[key].(float64) + return int(v) +} diff --git a/products/uddos/internal/mainland/ip/create.go b/products/uddos/internal/mainland/ip/create.go new file mode 100644 index 0000000000..60191913b6 --- /dev/null +++ b/products/uddos/internal/mainland/ip/create.go @@ -0,0 +1,83 @@ +// Package ip ... +// +// @Brief 创建国内高防IP命令 +// +// @File create.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package ip + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newCreate 构建 uddos mainland ip create 命令 +// +// @Brief 构建国内高防 ip create 子命令,调用 CreateBGPServiceIP +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func newCreate(ctx *cli.Context) *cobra.Command { + var resourceID, typeIP, remark, tag string + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a mainland BGP high-protection IP", + Long: "Create a new BGP DDoS protection IP for the specified mainland service", + Example: " ucloud uddos mainland ip create --resource-id ghp-xxxxx", + Run: func(cmd *cobra.Command, args []string) { + client := cli.NewServiceClient(ctx, uaccount.NewClient) + params := map[string]interface{}{ + "Action": "CreateBGPServiceIP", + "ResourceId": resourceID, + "TypeIP": typeIP, + } + if cmd.Flags().Changed("remark") { + params["Remark"] = remark + } + if cmd.Flags().Changed("tag") { + params["Tag"] = tag + } + req := client.NewGenericRequest() + if err := req.SetPayload(params); err != nil { + ctx.HandleError(fmt.Errorf("set payload: %w", err)) + return + } + resp, err := client.GenericInvoke(req) + if err != nil { + ctx.HandleError(fmt.Errorf("CreateBGPServiceIP: %w", err)) + return + } + payload := resp.GetPayload() + defenceIP, _ := payload["DefenceIP"].(string) + fmt.Fprintf(ctx.ProgressWriter(), "BGP IP created: %s\n", defenceIP) + ctx.EmitResult(cli.OpResultRow{ResourceID: defenceIP, Action: "create", Status: "Created"}) + }, + } + + flags := cmd.Flags() + flags.StringVar(&resourceID, "resource-id", "", "Required. Service resource ID") + flags.StringVar(&typeIP, "type-ip", "TypeFree", "Optional. IP type: TypeFree or TypeCharge, default TypeFree") + flags.StringVar(&remark, "remark", "", "Optional. Remark for this IP") + flags.StringVar(&tag, "tag", "", "Optional. Business group tag") + cmd.MarkFlagRequired("resource-id") + return cmd +} diff --git a/products/uddos/internal/mainland/ip/delete.go b/products/uddos/internal/mainland/ip/delete.go new file mode 100644 index 0000000000..47aba00a71 --- /dev/null +++ b/products/uddos/internal/mainland/ip/delete.go @@ -0,0 +1,84 @@ +// Package ip ... +// +// @Brief 删除国内高防IP命令 +// +// @File delete.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package ip + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newDelete 构建 uddos mainland ip delete 命令 +// +// @Brief 构建国内高防 ip delete 子命令,调用 DeleteBGPServiceIP +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func newDelete(ctx *cli.Context) *cobra.Command { + var resourceID, defenceIP string + var yes bool + + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete a mainland BGP high-protection IP", + Long: "Delete a BGP DDoS protection IP from the specified mainland service", + Example: " ucloud uddos mainland ip delete --resource-id ghp-xxxxx --defence-ip 1.2.3.4", + Run: func(cmd *cobra.Command, args []string) { + confirmed, err := ctx.Confirm(yes, fmt.Sprintf("Are you sure to delete defence IP %s from service %s?", defenceIP, resourceID)) + if err != nil { + ctx.HandleError(fmt.Errorf("confirm: %w", err)) + return + } + if !confirmed { + return + } + client := cli.NewServiceClient(ctx, uaccount.NewClient) + params := map[string]interface{}{ + "Action": "DeleteBGPServiceIP", + "ResourceId": resourceID, + "DefenceIp": defenceIP, + } + req := client.NewGenericRequest() + if err := req.SetPayload(params); err != nil { + ctx.HandleError(fmt.Errorf("set payload: %w", err)) + return + } + _, invokeErr := client.GenericInvoke(req) + if invokeErr != nil { + ctx.HandleError(fmt.Errorf("DeleteBGPServiceIP: %w", invokeErr)) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "BGP IP deleted: %s\n", defenceIP) + ctx.EmitResult(cli.OpResultRow{ResourceID: defenceIP, Action: "delete", Status: "Deleted"}) + }, + } + + flags := cmd.Flags() + flags.StringVar(&resourceID, "resource-id", "", "Required. Service resource ID") + flags.StringVar(&defenceIP, "defence-ip", "", "Required. BGP defence IP to delete") + flags.BoolVarP(&yes, "yes", "y", false, "Optional. Skip confirmation prompt") + cmd.MarkFlagRequired("resource-id") + cmd.MarkFlagRequired("defence-ip") + return cmd +} diff --git a/products/uddos/internal/mainland/ip/list.go b/products/uddos/internal/mainland/ip/list.go new file mode 100644 index 0000000000..89932793eb --- /dev/null +++ b/products/uddos/internal/mainland/ip/list.go @@ -0,0 +1,99 @@ +// Package ip ... +// +// @Brief 查询国内高防IP列表命令 +// +// @File list.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package ip + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newList 构建 uddos mainland ip list 命令 +// +// @Brief 构建国内高防 ip list 子命令,调用 GetBGPServiceIP +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func newList(ctx *cli.Context) *cobra.Command { + var resourceID, bgpIP string + var offset, limit int + + cmd := &cobra.Command{ + Use: "list", + Short: "List mainland BGP high-protection IPs", + Long: "List BGP DDoS protection IP addresses for a mainland service instance", + Example: " ucloud uddos mainland ip list --resource-id ghp-xxxxx", + Run: func(cmd *cobra.Command, args []string) { + client := cli.NewServiceClient(ctx, uaccount.NewClient) + params := map[string]interface{}{ + "Action": "GetBGPServiceIP", + "ResourceId": resourceID, + "Offset": offset, + "Limit": limit, + } + if bgpIP != "" { + params["BgpIP"] = bgpIP + } + req := client.NewGenericRequest() + if err := req.SetPayload(params); err != nil { + ctx.HandleError(fmt.Errorf("set payload: %w", err)) + return + } + resp, err := client.GenericInvoke(req) + if err != nil { + ctx.HandleError(fmt.Errorf("GetBGPServiceIP: %w", err)) + return + } + payload := resp.GetPayload() + gameIPInfo, _ := payload["GameIPInfo"].([]interface{}) + rows := make([]IPRow, 0, len(gameIPInfo)) + for _, item := range gameIPInfo { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + rows = append(rows, IPRow{ + DefenceIP: strVal(m, "DefenceIP"), + UserIP: strVal(m, "UserIP"), + LineType: strVal(m, "LineType"), + Status: strVal(m, "Status"), + Cname: strVal(m, "Cname"), + RuleCnt: intVal(m, "RuleCnt"), + DefenceDDosBaseFlow: intVal(m, "DefenceDDosBaseFlow"), + DefenceDDosMaxFlow: intVal(m, "DefenceDDosMaxFlow"), + Remark: strVal(m, "Remark"), + }) + } + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.StringVar(&resourceID, "resource-id", "", "Required. Service resource ID") + flags.StringVar(&bgpIP, "bgp-ip", "", "Optional. Filter by BGP IP address") + flags.IntVar(&offset, "offset", 0, "Optional. Page offset, default 0") + flags.IntVar(&limit, "limit", 20, "Optional. Page size, default 20") + cmd.MarkFlagRequired("resource-id") + return cmd +} diff --git a/products/uddos/internal/mainland/ip/rows.go b/products/uddos/internal/mainland/ip/rows.go new file mode 100644 index 0000000000..3b8b6271dc --- /dev/null +++ b/products/uddos/internal/mainland/ip/rows.go @@ -0,0 +1,27 @@ +// Package ip ... +// +// @Brief 国内高防IP列表行结构体定义 +// +// @File rows.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package ip + +// IPRow 高防IP列表行 +type IPRow struct { + DefenceIP string + UserIP string + LineType string + Status string + Cname string + RuleCnt int + DefenceDDosBaseFlow int + DefenceDDosMaxFlow int + Remark string +} diff --git a/products/uddos/internal/mainland/rule/cmd.go b/products/uddos/internal/mainland/rule/cmd.go new file mode 100644 index 0000000000..8bd9b59093 --- /dev/null +++ b/products/uddos/internal/mainland/rule/cmd.go @@ -0,0 +1,57 @@ +// Package rule ... +// +// @Brief 国内高防转发规则管理命令聚合 +// +// @File cmd.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package rule + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand 构建 uddos mainland rule 命令组 +// +// @Brief 构建国内高防 rule 命令组并挂载子命令 +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "rule", + Short: "Manage BGP forwarding rules", + Long: "List, create, delete and update BGP DDoS protection forwarding rules for mainland services", + Args: cobra.NoArgs, + } + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newCreate(ctx)) + cmd.AddCommand(newDelete(ctx)) + cmd.AddCommand(newUpdate(ctx)) + return cmd +} + +func strVal(m map[string]interface{}, key string) string { + v, _ := m[key].(string) + return v +} + +func intVal(m map[string]interface{}, key string) int { + v, _ := m[key].(float64) + return int(v) +} diff --git a/products/uddos/internal/mainland/rule/create.go b/products/uddos/internal/mainland/rule/create.go new file mode 100644 index 0000000000..1217404314 --- /dev/null +++ b/products/uddos/internal/mainland/rule/create.go @@ -0,0 +1,101 @@ +// Package rule ... +// +// @Brief 创建国内高防转发规则命令 +// +// @File create.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package rule + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newCreate 构建 uddos mainland rule create 命令 +// +// @Brief 构建国内高防 rule create 子命令,调用 CreateBGPServiceFwdRule +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func newCreate(ctx *cli.Context) *cobra.Command { + var resourceID, sourceIP, bgpIP, loadBalance, fwdType, remark string + var bgpIPPort, sourceDetect int + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a BGP forwarding rule", + Long: "Create a forwarding rule for a mainland BGP DDoS protection service", + Example: " ucloud uddos mainland rule create --resource-id ghp-xxxxx --bgp-ip 103.216.x.x --source-ip 10.0.0.1", + Run: func(cmd *cobra.Command, args []string) { + client := cli.NewServiceClient(ctx, uaccount.NewClient) + params := map[string]interface{}{ + "Action": "CreateBGPServiceFwdRule", + "ResourceId": resourceID, + "BgpIP": bgpIP, + "LoadBalance": loadBalance, + "FwdType": fwdType, + "BgpIPPort": bgpIPPort, + "SourceDetect": sourceDetect, + } + if cmd.Flags().Changed("source-ip") { + params["SourceAddrArr"] = []string{sourceIP} + params["SourcePortArr"] = []string{"0"} + params["SourceToaIDArr"] = []string{"0"} + } + if cmd.Flags().Changed("remark") { + params["Remark"] = remark + } + req := client.NewGenericRequest() + if err := req.SetPayload(params); err != nil { + ctx.HandleError(fmt.Errorf("set payload: %w", err)) + return + } + resp, err := client.GenericInvoke(req) + if err != nil { + ctx.HandleError(fmt.Errorf("CreateBGPServiceFwdRule: %w", err)) + return + } + payload := resp.GetPayload() + ruleIndex := intVal(payload, "RuleIndex") + fmt.Fprintf(ctx.ProgressWriter(), "rule[%d] created for service[%s]\n", ruleIndex, resourceID) + ctx.EmitResult(cli.OpResultRow{ + ResourceID: strconv.Itoa(ruleIndex), + Action: "create", + Status: "Created", + }) + }, + } + + flags := cmd.Flags() + flags.StringVar(&resourceID, "resource-id", "", "Required. Service resource ID") + flags.StringVar(&bgpIP, "bgp-ip", "", "Required. BGP IP address for this rule") + flags.StringVar(&sourceIP, "source-ip", "", "Required. Origin server IP address") + flags.StringVar(&loadBalance, "load-balance", "No", "Optional. Enable load balance: Yes or No, default No") + flags.StringVar(&fwdType, "fwd-type", "IP", "Optional. Forwarding protocol: IP, TCP or UDP, default IP") + flags.IntVar(&bgpIPPort, "bgp-ip-port", 0, "Optional. BGP IP port (0 for IP protocol)") + flags.IntVar(&sourceDetect, "source-detect", 0, "Optional. Source detection: 0=disabled, 1=enabled, default 0") + flags.StringVar(&remark, "remark", "", "Optional. Remark for this rule") + cmd.MarkFlagRequired("resource-id") + cmd.MarkFlagRequired("bgp-ip") + cmd.MarkFlagRequired("source-ip") + return cmd +} diff --git a/products/uddos/internal/mainland/rule/delete.go b/products/uddos/internal/mainland/rule/delete.go new file mode 100644 index 0000000000..590f497150 --- /dev/null +++ b/products/uddos/internal/mainland/rule/delete.go @@ -0,0 +1,89 @@ +// Package rule ... +// +// @Brief 删除国内高防转发规则命令 +// +// @File delete.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package rule + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newDelete 构建 uddos mainland rule delete 命令 +// +// @Brief 构建国内高防 rule delete 子命令,调用 DeleteBGPServiceFwdRule +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func newDelete(ctx *cli.Context) *cobra.Command { + var resourceID string + var ruleIndex int + var yes bool + + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete a BGP forwarding rule", + Long: "Delete a forwarding rule from a mainland BGP DDoS protection service", + Example: " ucloud uddos mainland rule delete --resource-id ghp-xxxxx --rule-index 0", + Run: func(cmd *cobra.Command, args []string) { + confirmed, err := ctx.Confirm(yes, fmt.Sprintf("Are you sure to delete rule index %d from service %s?", ruleIndex, resourceID)) + if err != nil { + ctx.HandleError(fmt.Errorf("confirm: %w", err)) + return + } + if !confirmed { + return + } + client := cli.NewServiceClient(ctx, uaccount.NewClient) + params := map[string]interface{}{ + "Action": "DeleteBGPServiceFwdRule", + "ResourceId": resourceID, + "RuleIndex": ruleIndex, + } + req := client.NewGenericRequest() + if err := req.SetPayload(params); err != nil { + ctx.HandleError(fmt.Errorf("set payload: %w", err)) + return + } + _, invokeErr := client.GenericInvoke(req) + if invokeErr != nil { + ctx.HandleError(fmt.Errorf("DeleteBGPServiceFwdRule: %w", invokeErr)) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "rule[%d] deleted from service[%s]\n", ruleIndex, resourceID) + ctx.EmitResult(cli.OpResultRow{ + ResourceID: fmt.Sprintf("%d", ruleIndex), + Action: "delete", + Status: "Deleted", + }) + }, + } + + flags := cmd.Flags() + flags.StringVar(&resourceID, "resource-id", "", "Required. Service resource ID") + flags.IntVar(&ruleIndex, "rule-index", 0, "Required. Rule index to delete") + flags.BoolVarP(&yes, "yes", "y", false, "Optional. Skip confirmation prompt") + cmd.MarkFlagRequired("resource-id") + cmd.MarkFlagRequired("rule-index") + return cmd +} diff --git a/products/uddos/internal/mainland/rule/list.go b/products/uddos/internal/mainland/rule/list.go new file mode 100644 index 0000000000..6016116f59 --- /dev/null +++ b/products/uddos/internal/mainland/rule/list.go @@ -0,0 +1,104 @@ +// Package rule ... +// +// @Brief 查询国内高防转发规则列表命令 +// +// @File list.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package rule + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newList 构建 uddos mainland rule list 命令 +// +// @Brief 构建国内高防 rule list 子命令,调用 GetNapFwdRule +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func newList(ctx *cli.Context) *cobra.Command { + var resourceID, bgpIP string + var ruleIndex, offset, limit int + + cmd := &cobra.Command{ + Use: "list", + Short: "List BGP forwarding rules", + Long: "List forwarding rules for a mainland BGP DDoS protection service", + Example: " ucloud uddos mainland rule list --resource-id ghp-xxxxx", + Run: func(cmd *cobra.Command, args []string) { + client := cli.NewServiceClient(ctx, uaccount.NewClient) + params := map[string]interface{}{ + "Action": "GetBGPServiceFwdRule", + "ResourceId": resourceID, + "Offset": offset, + "Limit": limit, + } + if cmd.Flags().Changed("rule-index") { + params["RuleIndex"] = ruleIndex + } + if bgpIP != "" { + params["NapIP"] = bgpIP + } + req := client.NewGenericRequest() + if err := req.SetPayload(params); err != nil { + ctx.HandleError(fmt.Errorf("set payload: %w", err)) + return + } + resp, err := client.GenericInvoke(req) + if err != nil { + ctx.HandleError(fmt.Errorf("GetBGPServiceFwdRule: %w", err)) + return + } + payload := resp.GetPayload() + ruleInfo, _ := payload["RuleInfo"].([]interface{}) + rows := make([]RuleRow, 0, len(ruleInfo)) + for _, item := range ruleInfo { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + rows = append(rows, RuleRow{ + RuleIndex: strconv.Itoa(intVal(m, "RuleIndex")), + RuleID: strVal(m, "RuleID"), + BgpIP: strVal(m, "BgpIP"), + SourceIP: strVal(m, "SourceIPInfo"), + FwdType: strVal(m, "FwdType"), + BgpIPPort: strconv.Itoa(intVal(m, "BgpIPPort")), + LoadBalance: strVal(m, "LoadBalance"), + SourceDetect: strconv.Itoa(intVal(m, "SourceDetect")), + Remark: strVal(m, "Remark"), + }) + } + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.StringVar(&resourceID, "resource-id", "", "Required. Service resource ID") + flags.StringVar(&bgpIP, "bgp-ip", "", "Optional. Filter by BGP IP address") + flags.IntVar(&ruleIndex, "rule-index", 0, "Optional. Filter by rule index") + flags.IntVar(&offset, "offset", 0, "Optional. Page offset, default 0") + flags.IntVar(&limit, "limit", 32, "Optional. Page size, default 32") + cmd.MarkFlagRequired("resource-id") + return cmd +} diff --git a/products/uddos/internal/mainland/rule/rows.go b/products/uddos/internal/mainland/rule/rows.go new file mode 100644 index 0000000000..8b60472980 --- /dev/null +++ b/products/uddos/internal/mainland/rule/rows.go @@ -0,0 +1,27 @@ +// Package rule ... +// +// @Brief 国内高防转发规则列表行结构体定义 +// +// @File rows.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package rule + +// RuleRow 转发规则列表行 +type RuleRow struct { + RuleIndex string + RuleID string + BgpIP string + SourceIP string + FwdType string + BgpIPPort string + LoadBalance string + SourceDetect string + Remark string +} diff --git a/products/uddos/internal/mainland/rule/update.go b/products/uddos/internal/mainland/rule/update.go new file mode 100644 index 0000000000..760bb6b763 --- /dev/null +++ b/products/uddos/internal/mainland/rule/update.go @@ -0,0 +1,101 @@ +// Package rule ... +// +// @Brief 更新国内高防转发规则命令 +// +// @File update.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package rule + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newUpdate 构建 uddos mainland rule update 命令 +// +// @Brief 构建国内高防 rule update 子命令,调用 UpdateBGPServiceFwdRule +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func newUpdate(ctx *cli.Context) *cobra.Command { + var resourceID, sourceIP, bgpIP, loadBalance, fwdType, ruleID string + var ruleIndex, bgpIPPort, sourceDetect int + + cmd := &cobra.Command{ + Use: "update", + Short: "Update a BGP forwarding rule", + Long: "Update an existing forwarding rule in a mainland BGP DDoS protection service", + Example: " ucloud uddos mainland rule update --resource-id ghp-xxxxx --bgp-ip 1.2.3.4 --rule-index 0 --source-ip 10.0.0.2", + Run: func(cmd *cobra.Command, args []string) { + client := cli.NewServiceClient(ctx, uaccount.NewClient) + params := map[string]interface{}{ + "Action": "UpdateBGPServiceFwdRule", + "ResourceId": resourceID, + "BgpIP": bgpIP, + "RuleIndex": ruleIndex, + "LoadBalance": loadBalance, + "FwdType": fwdType, + "BgpIPPort": bgpIPPort, + "SourceDetect": sourceDetect, + } + if cmd.Flags().Changed("source-ip") { + params["SourceAddrArr"] = []string{sourceIP} + params["SourcePortArr"] = []string{"0"} + params["SourceToaIDArr"] = []string{"0"} + } + if cmd.Flags().Changed("rule-id") { + params["RuleID"] = ruleID + } + req := client.NewGenericRequest() + if err := req.SetPayload(params); err != nil { + ctx.HandleError(fmt.Errorf("set payload: %w", err)) + return + } + _, err := client.GenericInvoke(req) + if err != nil { + ctx.HandleError(fmt.Errorf("UpdateBGPServiceFwdRule: %w", err)) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "rule[%d] updated for service[%s]\n", ruleIndex, resourceID) + ctx.EmitResult(cli.OpResultRow{ + ResourceID: strconv.Itoa(ruleIndex), + Action: "update", + Status: "Updated", + }) + }, + } + + flags := cmd.Flags() + flags.StringVar(&resourceID, "resource-id", "", "Required. Service resource ID") + flags.StringVar(&bgpIP, "bgp-ip", "", "Required. BGP IP address of the rule") + flags.IntVar(&ruleIndex, "rule-index", 0, "Required. Rule index to update") + flags.StringVar(&sourceIP, "source-ip", "", "Optional. New origin server IP address") + flags.StringVar(&ruleID, "rule-id", "", "Optional. Rule ID (alternative to rule-index)") + flags.StringVar(&loadBalance, "load-balance", "No", "Optional. Enable load balance: Yes or No") + flags.StringVar(&fwdType, "fwd-type", "IP", "Optional. Forwarding protocol: IP, TCP or UDP") + flags.IntVar(&bgpIPPort, "bgp-ip-port", 0, "Optional. BGP IP port") + flags.IntVar(&sourceDetect, "source-detect", 0, "Optional. Source detection: 0=disabled, 1=enabled") + cmd.MarkFlagRequired("resource-id") + cmd.MarkFlagRequired("bgp-ip") + cmd.MarkFlagRequired("rule-index") + return cmd +} diff --git a/products/uddos/internal/mainland/service/cmd.go b/products/uddos/internal/mainland/service/cmd.go new file mode 100644 index 0000000000..13ca062312 --- /dev/null +++ b/products/uddos/internal/mainland/service/cmd.go @@ -0,0 +1,55 @@ +// Package service ... +// +// @Brief 国内高防服务管理命令聚合 +// +// @File cmd.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package service + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand 构建 uddos mainland service 命令组 +// +// @Brief 构建国内高防 service 命令组并挂载子命令 +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "service", + Short: "Manage mainland DDoS protection service instances", + Long: "List and create mainland China DDoS high-protection service instances", + Args: cobra.NoArgs, + } + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newCreate(ctx)) + return cmd +} + +func strVal(m map[string]interface{}, key string) string { + v, _ := m[key].(string) + return v +} + +func intVal(m map[string]interface{}, key string) int { + v, _ := m[key].(float64) + return int(v) +} diff --git a/products/uddos/internal/mainland/service/create.go b/products/uddos/internal/mainland/service/create.go new file mode 100644 index 0000000000..8aa673b50f --- /dev/null +++ b/products/uddos/internal/mainland/service/create.go @@ -0,0 +1,218 @@ +// Package service ... +// +// @Brief 创建国内高防服务命令 +// +// @File create.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package service + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// mainlandAreaLineEngineRooms 国内 area-line → 合法 engine-room 列表 +var mainlandAreaLineEngineRooms = map[string][]string{ + "EastChina": {"Zaozhuang", "Yangzhou"}, + "NorthChina": {"Shijiazhuang"}, +} + +// newCreate 构建 uddos mainland service create 命令 +// +// @Brief 构建国内高防 service create 子命令,调用 BuyHighProtectGameService(国内参数) +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func newCreate(ctx *cli.Context) *cobra.Command { + var chargeType, areaLine, engineRoom, name string + var quantity, srcBandwidth, defenceBaseFlow, defenceMaxFlow int + var async bool + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a mainland DDoS high-protection service", + Long: "Create a mainland China DDoS high-protection service via BuyHighProtectGameService (ForwardType=Proxy).", + Example: ` Area line / engine room pairings: + --area-line --engine-room + EastChina Zaozhuang / Yangzhou + NorthChina Shijiazhuang + + Defence flow valid values (Gbps): 30/40/50/60/70/80/100/200/300/400/500/600/700/800 + --defence-max-flow must be >= --defence-base-flow + + # Create a mainland service (East China, Zaozhuang) + ucloud uddos mainland service create --charge-type Month --quantity 1 \ + --area-line EastChina --engine-room Zaozhuang --src-bandwidth 100 \ + --defence-base-flow 30 --defence-max-flow 50 --name my-service + + # Create a mainland service (North China, Shijiazhuang) + ucloud uddos mainland service create --charge-type Month --quantity 1 \ + --area-line NorthChina --engine-room Shijiazhuang --src-bandwidth 100 \ + --defence-base-flow 30 --defence-max-flow 30 --name my-service`, + Run: func(cmd *cobra.Command, args []string) { + if chargeType != "Month" && chargeType != "Year" { + ctx.HandleError(fmt.Errorf(`invalid --charge-type %q, must be "Month" or "Year"`, chargeType)) + return + } + + validRooms, isValidAreaLine := mainlandAreaLineEngineRooms[areaLine] + if !isValidAreaLine { + ctx.HandleError(fmt.Errorf(`invalid --area-line %q, must be "EastChina" or "NorthChina"`, areaLine)) + return + } + validSet := make(map[string]bool, len(validRooms)) + for _, r := range validRooms { + validSet[r] = true + } + if !validSet[engineRoom] { + desc := "" + for i, r := range validRooms { + if i > 0 { + desc += "/" + } + desc += r + } + ctx.HandleError(fmt.Errorf( + "invalid --engine-room %q for --area-line %q, valid values: %s", + engineRoom, areaLine, desc, + )) + return + } + + if srcBandwidth < 50 { + ctx.HandleError(fmt.Errorf("--src-bandwidth minimum is 50 for mainland, got %d", srcBandwidth)) + return + } + if srcBandwidth%10 != 0 { + ctx.HandleError(fmt.Errorf("--src-bandwidth must be a multiple of 10 for mainland, got %d", srcBandwidth)) + return + } + + validFlows := map[int]bool{ + 30: true, 40: true, 50: true, 60: true, 70: true, 80: true, + 100: true, 200: true, 300: true, 400: true, 500: true, + 600: true, 700: true, 800: true, + } + const validFlowDesc = "30/40/50/60/70/80/100/200/300/400/500/600/700/800" + if !validFlows[defenceBaseFlow] { + ctx.HandleError(fmt.Errorf("invalid --defence-base-flow %d, must be one of: %s", defenceBaseFlow, validFlowDesc)) + return + } + if !validFlows[defenceMaxFlow] { + ctx.HandleError(fmt.Errorf("invalid --defence-max-flow %d, must be one of: %s", defenceMaxFlow, validFlowDesc)) + return + } + if defenceMaxFlow < defenceBaseFlow { + ctx.HandleError(fmt.Errorf("--defence-max-flow (%d) must be >= --defence-base-flow (%d)", defenceMaxFlow, defenceBaseFlow)) + return + } + + client := cli.NewServiceClient(ctx, uaccount.NewClient) + params := map[string]interface{}{ + "Action": "BuyHighProtectGameService", + "ChargeType": chargeType, + "Quantity": quantity, + "LineType": "BGP", + "SrcBandwidth": srcBandwidth, + "EngineRoom": []string{engineRoom}, + "AreaLine": areaLine, + "ForwardType": "Proxy", + "DefenceDDosBaseFlowArr": []int{defenceBaseFlow}, + "DefenceDDosMaxFlowArr": []int{defenceMaxFlow}, + "HighProtectGameServiceName": name, + } + req := client.NewGenericRequest() + if err := req.SetPayload(params); err != nil { + ctx.HandleError(fmt.Errorf("set payload: %w", err)) + return + } + resp, err := client.GenericInvoke(req) + if err != nil { + ctx.HandleError(fmt.Errorf("BuyHighProtectGameService: %w", err)) + return + } + payload := resp.GetPayload() + resInfo, _ := payload["ResourceInfo"].(map[string]interface{}) + resourceID := strVal(resInfo, "ResourceId") + if resourceID == "" { + ctx.HandleError(fmt.Errorf("BuyHighProtectGameService returned no ResourceId; the purchase may have failed, check the console")) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "mainland DDoS service created: %s\n", resourceID) + if !async { + ctx.PollerTo(ctx.ProgressWriter(), describeMainlandService(ctx)).Spoll( + resourceID, + fmt.Sprintf("service[%s] is initializing", resourceID), + []string{napServiceStatusStarted}, + ) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: resourceID, Action: "create", Status: "Created"}) + }, + } + + flags := cmd.Flags() + flags.StringVar(&chargeType, "charge-type", "", `Required. Billing type: "Month" or "Year"`) + flags.IntVar(&quantity, "quantity", 0, "Required. Billing duration") + flags.StringVar(&areaLine, "area-line", "", `Required. "EastChina" or "NorthChina"`) + flags.StringVar(&engineRoom, "engine-room", "", "Required. Zaozhuang/Yangzhou (EastChina) or Shijiazhuang (NorthChina)") + flags.IntVar(&srcBandwidth, "src-bandwidth", 0, "Required. Source bandwidth (Mbps), min 50, multiple of 10") + flags.IntVar(&defenceBaseFlow, "defence-base-flow", 0, "Required. Base defence flow (Gbps): 30/40/50/60/70/80/100/200/300/400/500/600/700/800") + flags.IntVar(&defenceMaxFlow, "defence-max-flow", 0, "Required. Max defence flow (Gbps), must be >= defence-base-flow") + flags.StringVar(&name, "name", "", "Required. Service name") + flags.BoolVar(&async, "async", false, "Optional. Do not wait for the service to become available.") + cmd.MarkFlagRequired("charge-type") + cmd.MarkFlagRequired("quantity") + cmd.MarkFlagRequired("area-line") + cmd.MarkFlagRequired("engine-room") + cmd.MarkFlagRequired("src-bandwidth") + cmd.MarkFlagRequired("defence-base-flow") + cmd.MarkFlagRequired("defence-max-flow") + cmd.MarkFlagRequired("name") + return cmd +} + +// describeMainlandService 返回 poller 用的服务状态查询函数, +// 调用 DescribeHighProtectGameServiceInfo 按 ResourceId 查询,返回带 Status 字段的结构体。 +func describeMainlandService(ctx *cli.Context) func(string, *request.CommonBase) (interface{}, error) { + return func(id string, _ *request.CommonBase) (interface{}, error) { + client := cli.NewServiceClient(ctx, uaccount.NewClient) + req := client.NewGenericRequest() + if err := req.SetPayload(map[string]interface{}{ + "Action": "DescribeHighProtectGameServiceInfo", + "ResourceId": id, + "Offset": 0, + "Limit": 1, + }); err != nil { + return nil, fmt.Errorf("set payload: %w", err) + } + resp, err := client.GenericInvoke(req) + if err != nil { + return nil, fmt.Errorf("DescribeHighProtectGameServiceInfo: %w", err) + } + gameInfo, _ := resp.GetPayload()["GameInfo"].([]interface{}) + if len(gameInfo) == 0 { + return nil, nil // 尚未可见,poller 视为 pending 继续轮询 + } + m, _ := gameInfo[0].(map[string]interface{}) + return &serviceStatusRow{Status: strVal(m, "DefenceStatus")}, nil + } +} diff --git a/products/uddos/internal/mainland/service/list.go b/products/uddos/internal/mainland/service/list.go new file mode 100644 index 0000000000..74ed20806a --- /dev/null +++ b/products/uddos/internal/mainland/service/list.go @@ -0,0 +1,100 @@ +// Package service ... +// +// @Brief 查询国内高防服务列表命令 +// +// @File list.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package service + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newList 构建 uddos mainland service list 命令 +// +// @Brief 构建国内高防 service list 子命令,调用 DescribeHighProtectGameServiceInfo +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func newList(ctx *cli.Context) *cobra.Command { + var resourceID string + var offset, limit int + + cmd := &cobra.Command{ + Use: "list", + Short: "List mainland DDoS protection service instances", + Long: "List mainland China DDoS high-protection service instances via DescribeHighProtectGameServiceInfo.", + Example: ` # List all mainland services + ucloud uddos mainland service list + + # Filter by resource ID + ucloud uddos mainland service list --resource-id ghp-xxxxx`, + Run: func(cmd *cobra.Command, args []string) { + client := cli.NewServiceClient(ctx, uaccount.NewClient) + params := map[string]interface{}{ + "Action": "DescribeHighProtectGameServiceInfo", + "Offset": offset, + "Limit": limit, + } + if resourceID != "" { + params["ResourceId"] = resourceID + } + req := client.NewGenericRequest() + if err := req.SetPayload(params); err != nil { + ctx.HandleError(fmt.Errorf("set payload: %w", err)) + return + } + resp, err := client.GenericInvoke(req) + if err != nil { + ctx.HandleError(fmt.Errorf("DescribeHighProtectGameServiceInfo: %w", err)) + return + } + payload := resp.GetPayload() + gameInfo, _ := payload["GameInfo"].([]interface{}) + rows := make([]ServiceRow, 0, len(gameInfo)) + for _, item := range gameInfo { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + expireTime := "" + if ts := intVal(m, "ExpiredTime"); ts > 0 { + expireTime = time.Unix(int64(ts), 0).Format("2006-01-02 15:04:05") + } + rows = append(rows, ServiceRow{ + ResourceID: strVal(m, "ResourceId"), + Name: strVal(m, "HighProtectGameServiceName"), + DefenceStatus: strVal(m, "DefenceStatus"), + ExpireTime: expireTime, + }) + } + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.StringVar(&resourceID, "resource-id", "", "Optional. Filter by resource ID") + flags.IntVar(&offset, "offset", 0, "Optional. Page offset, default 0") + flags.IntVar(&limit, "limit", 20, "Optional. Page size, default 20") + return cmd +} diff --git a/products/uddos/internal/mainland/service/rows.go b/products/uddos/internal/mainland/service/rows.go new file mode 100644 index 0000000000..3ec9d31899 --- /dev/null +++ b/products/uddos/internal/mainland/service/rows.go @@ -0,0 +1,22 @@ +// Package service ... +// +// @Brief 国内高防服务列表行结构体定义 +// +// @File rows.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package service + +// ServiceRow 国内高防服务列表行 +type ServiceRow struct { + ResourceID string + Name string + DefenceStatus string + ExpireTime string +} diff --git a/products/uddos/internal/mainland/service/status.go b/products/uddos/internal/mainland/service/status.go new file mode 100644 index 0000000000..9b0a6ff82f --- /dev/null +++ b/products/uddos/internal/mainland/service/status.go @@ -0,0 +1,28 @@ +// Package service ... +// +// @Brief 国内高防服务生命周期状态定义与轮询辅助 +// +// @File status.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/14 +// +// @CopyRights(C) UCloud All rights reserved. +package service + +// 服务生命周期状态:API 响应字段 DefenceStatus 为字符串,由 nap-api 的 +// NapServiceStatus2Str 映射(NAP_SERVICE_STATUS_IS_NORMAL=1 -> "Started" 等)。 +const ( + napServiceStatusStarted = "Started" // NAP_SERVICE_STATUS_IS_NORMAL(1):创建完成、可用 + napServiceStatusStopped = "Stopped" // NAP_SERVICE_STATUS_IS_STOPPED(2):已停用 + napServiceStatusExpired = "Expired" // NAP_SERVICE_STATUS_IS_EXPIRED(3):已过期 +) + +// serviceStatusRow 是 poller 反射读取的最小结构体:它读取 Status 字段(字符串) +// 与 targetStates 比较(见 pkg/cli/poller.go state(),仅识别 State/Status 字段)。 +type serviceStatusRow struct { + Status string +} diff --git a/products/uddos/internal/overseas/cmd.go b/products/uddos/internal/overseas/cmd.go new file mode 100644 index 0000000000..5f91a4d631 --- /dev/null +++ b/products/uddos/internal/overseas/cmd.go @@ -0,0 +1,47 @@ +// Package overseas ... +// +// @Brief 海外高防命令组聚合 +// +// @File cmd.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package overseas + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + overseasip "github.com/ucloud/ucloud-cli/products/uddos/internal/overseas/ip" + overseassvc "github.com/ucloud/ucloud-cli/products/uddos/internal/overseas/service" +) + +// NewCommand 构建 uddos overseas 命令组 +// +// @Brief 构建海外高防命令组并挂载 service、ip、rule 子命令 +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "overseas", + Short: "Manage overseas DDoS high-protection services", + Long: "Manage UCloud overseas (海外高防) DDoS protection services and IPs", + Args: cobra.NoArgs, + } + cmd.AddCommand(overseasip.NewCommand(ctx)) + cmd.AddCommand(overseassvc.NewCommand(ctx)) + return cmd +} diff --git a/products/uddos/internal/overseas/ip/cmd.go b/products/uddos/internal/overseas/ip/cmd.go new file mode 100644 index 0000000000..9af27d1ac9 --- /dev/null +++ b/products/uddos/internal/overseas/ip/cmd.go @@ -0,0 +1,56 @@ +// Package ip ... +// +// @Brief 海外高防IP管理命令聚合 +// +// @File cmd.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package ip + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand 构建 uddos overseas ip 命令组 +// +// @Brief 构建海外高防 ip 命令组并挂载子命令 +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "ip", + Short: "Manage overseas DDoS protection IPs", + Long: "List, create and delete overseas BGP DDoS protection IPs", + Args: cobra.NoArgs, + } + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newCreate(ctx)) + cmd.AddCommand(newDelete(ctx)) + return cmd +} + +func strVal(m map[string]interface{}, key string) string { + v, _ := m[key].(string) + return v +} + +func intVal(m map[string]interface{}, key string) int { + v, _ := m[key].(float64) + return int(v) +} diff --git a/products/uddos/internal/overseas/ip/create.go b/products/uddos/internal/overseas/ip/create.go new file mode 100644 index 0000000000..747e39026c --- /dev/null +++ b/products/uddos/internal/overseas/ip/create.go @@ -0,0 +1,163 @@ +// Package ip ... +// +// @Brief 创建海外高防IP命令 +// +// @File create.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package ip + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newCreate 构建 uddos overseas ip create 命令 +// +// @Brief 构建海外高防 ip create 子命令,调用 CreateBGPServiceIP +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func newCreate(ctx *cli.Context) *cobra.Command { + var resourceID, typeIP, remark, tag string + + cmd := &cobra.Command{ + Use: "create", + Short: "Create an overseas BGP high-protection IP", + Long: "Create a new BGP DDoS protection IP for the specified overseas service", + Example: " ucloud uddos overseas ip create --resource-id nap-xxxxx", + Run: func(cmd *cobra.Command, args []string) { + client := cli.NewServiceClient(ctx, uaccount.NewClient) + + resolvedEIPRegion, err := lookupEIPRegion(client, resourceID) + if err != nil { + ctx.HandleError(err) + return + } + + params := map[string]interface{}{ + "Action": "CreateBGPServiceIP", + "ResourceId": resourceID, + "TypeIP": typeIP, + "EIPRegion": resolvedEIPRegion, + } + if cmd.Flags().Changed("remark") { + params["Remark"] = remark + } + if cmd.Flags().Changed("tag") { + params["Tag"] = tag + } + req := client.NewGenericRequest() + if err := req.SetPayload(params); err != nil { + ctx.HandleError(fmt.Errorf("set payload: %w", err)) + return + } + resp, err := client.GenericInvoke(req) + if err != nil { + ctx.HandleError(fmt.Errorf("CreateBGPServiceIP: %w", err)) + return + } + payload := resp.GetPayload() + defenceIP, _ := payload["DefenceIP"].(string) + fmt.Fprintf(ctx.ProgressWriter(), "BGP IP created: %s\n", defenceIP) + ctx.EmitResult(cli.OpResultRow{ResourceID: defenceIP, Action: "create", Status: "Created"}) + }, + } + + flags := cmd.Flags() + flags.StringVar(&resourceID, "resource-id", "", "Required. Service resource ID") + flags.StringVar(&typeIP, "type-ip", "TypeFree", "Optional. IP type: TypeFree or TypeCharge, default TypeFree") + flags.StringVar(&remark, "remark", "", "Optional. Remark for this IP") + flags.StringVar(&tag, "tag", "", "Optional. Business group tag") + cmd.MarkFlagRequired("resource-id") + return cmd +} + +// lookupEIPRegion resolves the EIPRegion for an overseas service by querying +// DescribeHighProtectGameServiceInfo then GetNapServiceConfig. +func lookupEIPRegion(client *uaccount.UAccountClient, resourceID string) (string, error) { + // Step 1: fetch service details to get EngineRoom and LineType + svcReq := client.NewGenericRequest() + if err := svcReq.SetPayload(map[string]interface{}{ + "Action": "DescribeNapServiceInfo", + "ResourceId": resourceID, + "NapType": 2, // APAC / overseas + "Limit": 1, + }); err != nil { + return "", fmt.Errorf("DescribeNapServiceInfo set payload: %w", err) + } + svcResp, err := client.GenericInvoke(svcReq) + if err != nil { + return "", fmt.Errorf("DescribeNapServiceInfo: %w", err) + } + svcPayload := svcResp.GetPayload() + serviceInfo, _ := svcPayload["ServiceInfo"].([]interface{}) + if len(serviceInfo) == 0 { + return "", fmt.Errorf("service %s not found", resourceID) + } + svc, ok := serviceInfo[0].(map[string]interface{}) + if !ok { + return "", fmt.Errorf("unexpected service info format") + } + // EngineRoom is returned as []interface{} (comma-split array); take the first element. + engineRoom := "" + if rooms, ok := svc["EngineRoom"].([]interface{}); ok && len(rooms) > 0 { + engineRoom, _ = rooms[0].(string) + } + lineType := strVal(svc, "LineType") + areaLine := strVal(svc, "AreaLine") + + // Step 2: fetch service config to get IpInfo region list + cfgReq := client.NewGenericRequest() + if err := cfgReq.SetPayload(map[string]interface{}{ + "Action": "GetNapServiceConfig", + "AreaLine": areaLine, + "EngineRoom": engineRoom, + "LineType": lineType, + }); err != nil { + return "", fmt.Errorf("GetNapServiceConfig set payload: %w", err) + } + cfgResp, err := client.GenericInvoke(cfgReq) + if err != nil { + return "", fmt.Errorf("GetNapServiceConfig: %w", err) + } + cfgPayload := cfgResp.GetPayload() + configs, _ := cfgPayload["NapServiceConfig"].([]interface{}) + if len(configs) == 0 { + return "", fmt.Errorf("no service config found for areaLine=%s engineRoom=%s lineType=%s", areaLine, engineRoom, lineType) + } + cfg, ok := configs[0].(map[string]interface{}) + if !ok { + return "", fmt.Errorf("unexpected service config format") + } + ipInfoList, _ := cfg["IpInfo"].([]interface{}) + if len(ipInfoList) == 0 { + return "", fmt.Errorf("IpInfo is empty in service config") + } + first, ok := ipInfoList[0].(map[string]interface{}) + if !ok { + return "", fmt.Errorf("unexpected IpInfo entry format") + } + region := strVal(first, "Region") + if region == "" { + return "", fmt.Errorf("IpInfo Region is empty") + } + return region, nil +} diff --git a/products/uddos/internal/overseas/ip/delete.go b/products/uddos/internal/overseas/ip/delete.go new file mode 100644 index 0000000000..dc368fbfb6 --- /dev/null +++ b/products/uddos/internal/overseas/ip/delete.go @@ -0,0 +1,84 @@ +// Package ip ... +// +// @Brief 删除海外高防IP命令 +// +// @File delete.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package ip + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newDelete 构建 uddos overseas ip delete 命令 +// +// @Brief 构建海外高防 ip delete 子命令,调用 DeleteBGPServiceIP +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func newDelete(ctx *cli.Context) *cobra.Command { + var resourceID, defenceIP string + var yes bool + + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete an overseas BGP high-protection IP", + Long: "Delete a BGP DDoS protection IP from the specified overseas service", + Example: " ucloud uddos overseas ip delete --resource-id nap-xxxxx --defence-ip 1.2.3.4", + Run: func(cmd *cobra.Command, args []string) { + confirmed, err := ctx.Confirm(yes, fmt.Sprintf("Are you sure to delete defence IP %s from service %s?", defenceIP, resourceID)) + if err != nil { + ctx.HandleError(fmt.Errorf("confirm: %w", err)) + return + } + if !confirmed { + return + } + client := cli.NewServiceClient(ctx, uaccount.NewClient) + params := map[string]interface{}{ + "Action": "DeleteBGPServiceIP", + "ResourceId": resourceID, + "DefenceIp": defenceIP, + } + req := client.NewGenericRequest() + if err := req.SetPayload(params); err != nil { + ctx.HandleError(fmt.Errorf("set payload: %w", err)) + return + } + _, invokeErr := client.GenericInvoke(req) + if invokeErr != nil { + ctx.HandleError(fmt.Errorf("DeleteBGPServiceIP: %w", invokeErr)) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "BGP IP deleted: %s\n", defenceIP) + ctx.EmitResult(cli.OpResultRow{ResourceID: defenceIP, Action: "delete", Status: "Deleted"}) + }, + } + + flags := cmd.Flags() + flags.StringVar(&resourceID, "resource-id", "", "Required. Service resource ID") + flags.StringVar(&defenceIP, "defence-ip", "", "Required. BGP defence IP to delete") + flags.BoolVarP(&yes, "yes", "y", false, "Optional. Skip confirmation prompt") + cmd.MarkFlagRequired("resource-id") + cmd.MarkFlagRequired("defence-ip") + return cmd +} diff --git a/products/uddos/internal/overseas/ip/list.go b/products/uddos/internal/overseas/ip/list.go new file mode 100644 index 0000000000..2dbd1aa81a --- /dev/null +++ b/products/uddos/internal/overseas/ip/list.go @@ -0,0 +1,102 @@ +// Package ip ... +// +// @Brief 查询海外高防IP列表命令 +// +// @File list.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package ip + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newList 构建 uddos overseas ip list 命令 +// +// @Brief 构建海外高防 ip list 子命令,调用 GetBGPServiceIP +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func newList(ctx *cli.Context) *cobra.Command { + var resourceID, napIP string + var offset, limit int + + cmd := &cobra.Command{ + Use: "list", + Short: "List overseas BGP high-protection IPs", + Long: "List BGP DDoS protection IP addresses for an overseas service instance (Passthrough mode)", + Example: " ucloud uddos overseas ip list --resource-id nap-xxxxx", + Run: func(cmd *cobra.Command, args []string) { + client := cli.NewServiceClient(ctx, uaccount.NewClient) + params := map[string]interface{}{ + "Action": "DescribePassthroughNapIP", + "ResourceId": resourceID, + "Offset": offset, + "Limit": limit, + } + if napIP != "" { + params["NapIp"] = napIP + } + req := client.NewGenericRequest() + if err := req.SetPayload(params); err != nil { + ctx.HandleError(fmt.Errorf("set payload: %w", err)) + return + } + resp, err := client.GenericInvoke(req) + if err != nil { + ctx.HandleError(fmt.Errorf("DescribePassthroughNapIP: %w", err)) + return + } + payload := resp.GetPayload() + ipInfo, _ := payload["IPInfo"].([]interface{}) + rows := make([]IPRow, 0, len(ipInfo)) + for _, item := range ipInfo { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + eipIP := "" + if addrs, ok := m["EIPAddr"].([]interface{}); ok && len(addrs) > 0 { + if first, ok := addrs[0].(map[string]interface{}); ok { + eipIP = strVal(first, "IP") + } + } + rows = append(rows, IPRow{ + EIPIP: eipIP, + EIPID: strVal(m, "EIPId"), + Status: strVal(m, "Status"), + EIPRegion: strVal(m, "EIPRegion"), + Tag: strVal(m, "Tag"), + Remark: strVal(m, "Remark"), + }) + } + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.StringVar(&resourceID, "resource-id", "", "Required. Service resource ID") + flags.StringVar(&napIP, "nap-ip", "", "Optional. Filter by NAP IP address") + flags.IntVar(&offset, "offset", 0, "Optional. Page offset, default 0") + flags.IntVar(&limit, "limit", 20, "Optional. Page size, default 20") + cmd.MarkFlagRequired("resource-id") + return cmd +} diff --git a/products/uddos/internal/overseas/ip/rows.go b/products/uddos/internal/overseas/ip/rows.go new file mode 100644 index 0000000000..b4749e4af0 --- /dev/null +++ b/products/uddos/internal/overseas/ip/rows.go @@ -0,0 +1,24 @@ +// Package ip ... +// +// @Brief 海外高防IP列表行结构体定义 +// +// @File rows.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package ip + +// IPRow 高防IP列表行(透传模式) +type IPRow struct { + EIPIP string + EIPID string + Status string + EIPRegion string + Tag string + Remark string +} diff --git a/products/uddos/internal/overseas/service/cmd.go b/products/uddos/internal/overseas/service/cmd.go new file mode 100644 index 0000000000..6620075f98 --- /dev/null +++ b/products/uddos/internal/overseas/service/cmd.go @@ -0,0 +1,55 @@ +// Package service ... +// +// @Brief 海外高防服务管理命令聚合 +// +// @File cmd.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package service + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand 构建 uddos overseas service 命令组 +// +// @Brief 构建海外高防 service 命令组并挂载子命令 +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "service", + Short: "Manage overseas DDoS protection service instances", + Long: "List and create overseas DDoS high-protection service instances", + Args: cobra.NoArgs, + } + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newCreate(ctx)) + return cmd +} + +func strVal(m map[string]interface{}, key string) string { + v, _ := m[key].(string) + return v +} + +func intVal(m map[string]interface{}, key string) int { + v, _ := m[key].(float64) + return int(v) +} diff --git a/products/uddos/internal/overseas/service/create.go b/products/uddos/internal/overseas/service/create.go new file mode 100644 index 0000000000..dae189eb09 --- /dev/null +++ b/products/uddos/internal/overseas/service/create.go @@ -0,0 +1,204 @@ +// Package service ... +// +// @Brief 创建海外高防服务命令 +// +// @File create.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package service + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// overseasCityToCleaningCenter 海外城市 → 清洗中心(API EngineRoom 值) +var overseasCityToCleaningCenter = map[string]string{ + // 亚太:HongKong 清洗中心 + "HongKong": "HongKong", + "Taipei": "HongKong", + "Singapore": "HongKong", + "Tokyo": "HongKong", + "Seoul": "HongKong", + "Bangkok": "HongKong", + "HoChiMinh": "HongKong", + "Jakarta": "HongKong", + "Manila": "HongKong", + "Mumbai": "HongKong", + // 欧洲:Frankfurt 清洗中心 + "Frankfurt": "Frankfurt", + "London": "Frankfurt", + "Moscow": "Frankfurt", + // 北美:Ashburn 清洗中心 + "Ashburn": "Ashburn", + "LosAngeles": "Ashburn", + "Washington": "Ashburn", +} + +// newCreate 构建 uddos overseas service create 命令 +// +// @Brief 构建海外高防 service create 子命令,调用 BuyHighProtectGameService(海外参数,ForwardType=Passthrough) +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func newCreate(ctx *cli.Context) *cobra.Command { + var chargeType, areaLine, name string + var quantity, srcBandwidth int + var async bool + + cmd := &cobra.Command{ + Use: "create", + Short: "Create an overseas DDoS high-protection service", + Long: "Create an overseas DDoS high-protection service via BuyHighProtectGameService (ForwardType=Passthrough). Defence flow is fixed at 50 Gbps.", + Example: ` AreaLine (--area-line) EngineRoom (API) + HongKong / Taipei / Singapore / Tokyo / HongKong + Seoul / Bangkok / HoChiMinh / Jakarta / + Manila / Mumbai + Frankfurt / London / Moscow Frankfurt + Ashburn / LosAngeles / Washington Ashburn + + src-bandwidth rules (Mbps): <=300 step 50, 300~1000 step 100, 1000~5000 step 500 + + # Create an overseas service (Asia Pacific, HongKong cleaning center) + ucloud uddos overseas service create --charge-type Month --quantity 1 \ + --area-line HongKong --src-bandwidth 100 --name my-service + + # Create an overseas service (Europe, Frankfurt cleaning center) + ucloud uddos overseas service create --charge-type Month --quantity 1 \ + --area-line Frankfurt --src-bandwidth 100 --name my-service`, + Run: func(cmd *cobra.Command, args []string) { + if chargeType != "Month" && chargeType != "Year" { + ctx.HandleError(fmt.Errorf(`invalid --charge-type %q, must be "Month" or "Year"`, chargeType)) + return + } + + cleaningCenter, ok := overseasCityToCleaningCenter[areaLine] + if !ok { + ctx.HandleError(fmt.Errorf( + "invalid --area-line %q; valid values: HongKong/Taipei/Singapore/Tokyo/Seoul/Bangkok/HoChiMinh/Jakarta/Manila/Mumbai/Frankfurt/London/Moscow/Ashburn/LosAngeles/Washington", + areaLine, + )) + return + } + + switch { + case srcBandwidth < 50: + ctx.HandleError(fmt.Errorf("--src-bandwidth minimum is 50 for overseas")) + return + case srcBandwidth > 5000: + ctx.HandleError(fmt.Errorf("--src-bandwidth maximum is 5000 for overseas")) + return + case srcBandwidth <= 300 && srcBandwidth%50 != 0: + ctx.HandleError(fmt.Errorf("--src-bandwidth must be a multiple of 50 when <= 300 (overseas), got %d", srcBandwidth)) + return + case srcBandwidth > 300 && srcBandwidth <= 1000 && srcBandwidth%100 != 0: + ctx.HandleError(fmt.Errorf("--src-bandwidth must be a multiple of 100 when 300~1000 (overseas), got %d", srcBandwidth)) + return + case srcBandwidth > 1000 && srcBandwidth <= 5000 && srcBandwidth%500 != 0: + ctx.HandleError(fmt.Errorf("--src-bandwidth must be a multiple of 500 when 1000~5000 (overseas), got %d", srcBandwidth)) + return + } + + client := cli.NewServiceClient(ctx, uaccount.NewClient) + params := map[string]interface{}{ + "Action": "BuyHighProtectGameService", + "ChargeType": chargeType, + "Quantity": quantity, + "LineType": "BGP", + "SrcBandwidth": srcBandwidth, + "EngineRoom": []string{cleaningCenter}, + "AreaLine": areaLine, + "ForwardType": "Passthrough", + "DefenceDDosBaseFlowArr": []int{50}, + "DefenceDDosMaxFlowArr": []int{50}, + "HighProtectGameServiceName": name, + } + req := client.NewGenericRequest() + if err := req.SetPayload(params); err != nil { + ctx.HandleError(fmt.Errorf("set payload: %w", err)) + return + } + resp, err := client.GenericInvoke(req) + if err != nil { + ctx.HandleError(fmt.Errorf("BuyHighProtectGameService: %w", err)) + return + } + payload := resp.GetPayload() + resInfo, _ := payload["ResourceInfo"].(map[string]interface{}) + resourceID := strVal(resInfo, "ResourceId") + if resourceID == "" { + ctx.HandleError(fmt.Errorf("BuyHighProtectGameService returned no ResourceId; the purchase may have failed, check the console")) + return + } + fmt.Fprintf(ctx.ProgressWriter(), "overseas DDoS service created: %s\n", resourceID) + if !async { + ctx.PollerTo(ctx.ProgressWriter(), describeOverseasService(ctx)).Spoll( + resourceID, + fmt.Sprintf("service[%s] is initializing", resourceID), + []string{napServiceStatusStarted}, + ) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: resourceID, Action: "create", Status: "Created"}) + }, + } + + flags := cmd.Flags() + flags.StringVar(&chargeType, "charge-type", "", `Required. Billing type: "Month" or "Year"`) + flags.IntVar(&quantity, "quantity", 0, "Required. Billing duration") + flags.StringVar(&areaLine, "area-line", "", "Required. AreaLine: HongKong/Frankfurt/Ashburn and their coverage cities (see --help)") + flags.IntVar(&srcBandwidth, "src-bandwidth", 0, "Required. Source bandwidth (Mbps). <=300 step 50, 300~1000 step 100, 1000~5000 step 500") + flags.StringVar(&name, "name", "", "Required. Service name") + flags.BoolVar(&async, "async", false, "Optional. Do not wait for the service to become available.") + cmd.MarkFlagRequired("charge-type") + cmd.MarkFlagRequired("quantity") + cmd.MarkFlagRequired("area-line") + cmd.MarkFlagRequired("src-bandwidth") + cmd.MarkFlagRequired("name") + return cmd +} + +// describeOverseasService 返回 poller 用的服务状态查询函数, +// 调用 DescribeNapServiceInfo(NapType=2)按 ResourceId 查询,返回带 Status 字段的结构体。 +func describeOverseasService(ctx *cli.Context) func(string, *request.CommonBase) (interface{}, error) { + return func(id string, _ *request.CommonBase) (interface{}, error) { + client := cli.NewServiceClient(ctx, uaccount.NewClient) + req := client.NewGenericRequest() + if err := req.SetPayload(map[string]interface{}{ + "Action": "DescribeNapServiceInfo", + "NapType": 2, + "ResourceId": id, + "Offset": 0, + "Limit": 1, + }); err != nil { + return nil, fmt.Errorf("set payload: %w", err) + } + resp, err := client.GenericInvoke(req) + if err != nil { + return nil, fmt.Errorf("DescribeNapServiceInfo: %w", err) + } + serviceInfo, _ := resp.GetPayload()["ServiceInfo"].([]interface{}) + if len(serviceInfo) == 0 { + return nil, nil // 尚未可见,poller 视为 pending 继续轮询 + } + m, _ := serviceInfo[0].(map[string]interface{}) + return &serviceStatusRow{Status: strVal(m, "DefenceStatus")}, nil + } +} diff --git a/products/uddos/internal/overseas/service/list.go b/products/uddos/internal/overseas/service/list.go new file mode 100644 index 0000000000..c333a2a38f --- /dev/null +++ b/products/uddos/internal/overseas/service/list.go @@ -0,0 +1,102 @@ +// Package service ... +// +// @Brief 查询海外高防服务列表命令 +// +// @File list.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package service + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newList 构建 uddos overseas service list 命令 +// +// @Brief 构建海外高防 service list 子命令,调用 DescribeNapServiceInfo(NapType=2) +// +// @Param ctx *cli.Context +// +// @Return *cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +func newList(ctx *cli.Context) *cobra.Command { + var resourceID string + var offset, limit int + + cmd := &cobra.Command{ + Use: "list", + Short: "List overseas DDoS protection service instances", + Long: "List overseas DDoS high-protection service instances via DescribeNapServiceInfo (NapType=2).", + Example: ` # List all overseas services + ucloud uddos overseas service list + + # Filter by resource ID + ucloud uddos overseas service list --resource-id nap-xxxxx`, + Run: func(cmd *cobra.Command, args []string) { + client := cli.NewServiceClient(ctx, uaccount.NewClient) + params := map[string]interface{}{ + "Action": "DescribeNapServiceInfo", + "NapType": 2, + "Offset": offset, + "Limit": limit, + } + if resourceID != "" { + params["ResourceId"] = resourceID + } + req := client.NewGenericRequest() + if err := req.SetPayload(params); err != nil { + ctx.HandleError(fmt.Errorf("set payload: %w", err)) + return + } + resp, err := client.GenericInvoke(req) + if err != nil { + ctx.HandleError(fmt.Errorf("DescribeNapServiceInfo: %w", err)) + return + } + payload := resp.GetPayload() + serviceInfo, _ := payload["ServiceInfo"].([]interface{}) + rows := make([]ServiceRow, 0, len(serviceInfo)) + for _, item := range serviceInfo { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + expireTime := "" + if ts := intVal(m, "ExpiredTime"); ts > 0 { + expireTime = time.Unix(int64(ts), 0).Format("2006-01-02 15:04:05") + } + rows = append(rows, ServiceRow{ + ResourceID: strVal(m, "ResourceId"), + Name: strVal(m, "Name"), + DefenceStatus: strVal(m, "DefenceStatus"), + ExpireTime: expireTime, + Remark: strVal(m, "Remark"), + }) + } + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.StringVar(&resourceID, "resource-id", "", "Optional. Filter by resource ID") + flags.IntVar(&offset, "offset", 0, "Optional. Page offset, default 0") + flags.IntVar(&limit, "limit", 20, "Optional. Page size, default 20") + return cmd +} diff --git a/products/uddos/internal/overseas/service/rows.go b/products/uddos/internal/overseas/service/rows.go new file mode 100644 index 0000000000..23a0b15354 --- /dev/null +++ b/products/uddos/internal/overseas/service/rows.go @@ -0,0 +1,23 @@ +// Package service ... +// +// @Brief 海外高防服务列表行结构体定义 +// +// @File rows.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/11 +// +// @CopyRights(C) UCloud All rights reserved. +package service + +// ServiceRow 海外高防服务列表行 +type ServiceRow struct { + ResourceID string + Name string + DefenceStatus string + ExpireTime string + Remark string +} diff --git a/products/uddos/internal/overseas/service/status.go b/products/uddos/internal/overseas/service/status.go new file mode 100644 index 0000000000..443378947c --- /dev/null +++ b/products/uddos/internal/overseas/service/status.go @@ -0,0 +1,28 @@ +// Package service ... +// +// @Brief 海外高防服务生命周期状态定义与轮询辅助 +// +// @File status.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/14 +// +// @CopyRights(C) UCloud All rights reserved. +package service + +// 服务生命周期状态:API 响应字段 DefenceStatus 为字符串,由 nap-api 的 +// NapServiceStatus2Str 映射(NAP_SERVICE_STATUS_IS_NORMAL=1 -> "Started" 等)。 +const ( + napServiceStatusStarted = "Started" // NAP_SERVICE_STATUS_IS_NORMAL(1):创建完成、可用 + napServiceStatusStopped = "Stopped" // NAP_SERVICE_STATUS_IS_STOPPED(2):已停用 + napServiceStatusExpired = "Expired" // NAP_SERVICE_STATUS_IS_EXPIRED(3):已过期 +) + +// serviceStatusRow 是 poller 反射读取的最小结构体:它读取 Status 字段(字符串) +// 与 targetStates 比较(见 pkg/cli/poller.go state(),仅识别 State/Status 字段)。 +type serviceStatusRow struct { + Status string +} diff --git a/products/uddos/product.go b/products/uddos/product.go new file mode 100644 index 0000000000..eb29ae5fcf --- /dev/null +++ b/products/uddos/product.go @@ -0,0 +1,81 @@ +// Package uddos ... +// +// @Brief UDDoS 高防产品 CLI 入口 +// +// @File product.go +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/09 +// +// @CopyRights(C) UCloud All rights reserved. +package uddos + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/products/uddos/internal/mainland" + "github.com/ucloud/ucloud-cli/products/uddos/internal/overseas" +) + +type product struct{} + +// New returns the uddos product (registered via hack/gen-products). +// +// @Brief 创建 uddos 产品实例 +// +// @Param +// +// @Return cli.Product +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/09 +func New() cli.Product { return product{} } + +// Metadata returns the product metadata. +// +// @Brief 返回产品元数据 +// +// @Param +// +// @Return cli.Metadata +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/09 +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "uddos", Commands: []string{"uddos"}} +} + +// NewCommand builds the uddos root command and mounts subcommand groups. +// +// @Brief 构建 uddos 根命令及子命令组 +// +// @Param ctx *cli.Context +// +// @Return []*cobra.Command +// +// @Author leas.li(cc) +// +// @Email leas.li@ucloud.cn +// +// @Date 2026/07/09 +func (product) NewCommand(ctx *cli.Context) []*cobra.Command { + cmd := &cobra.Command{ + Use: "uddos", + Short: "Manage UCloud DDoS protection services", + Long: "Manage UCloud DDoS high-protection services for mainland China and overseas", + Args: cobra.NoArgs, + } + cmd.AddCommand(mainland.NewCommand(ctx)) + cmd.AddCommand(overseas.NewCommand(ctx)) + return []*cobra.Command{cmd} +} diff --git a/products/uddos/product.yaml b/products/uddos/product.yaml new file mode 100644 index 0000000000..abf427efba --- /dev/null +++ b/products/uddos/product.yaml @@ -0,0 +1,7 @@ +# products/uddos/product.yaml — UDDoS 产品元数据(归属真源,owner 自治维护) +name: uddos +owners: + - leas.li +commands: + - uddos +enabled: true diff --git a/products/uddos/testdata/cmdtree.golden b/products/uddos/testdata/cmdtree.golden new file mode 100644 index 0000000000..7b2ea2d9d0 --- /dev/null +++ b/products/uddos/testdata/cmdtree.golden @@ -0,0 +1,90 @@ +ucloud uddos use=uddos short=Manage UCloud DDoS protection services +ucloud uddos mainland use=mainland short=Manage mainland China DDoS high-protection services +ucloud uddos mainland ip use=ip short=Manage mainland DDoS protection IPs +ucloud uddos mainland ip create use=create short=Create a mainland BGP high-protection IP + flag=remark short= default= required= + flag=resource-id short= default= required=true + flag=tag short= default= required= + flag=type-ip short= default=TypeFree required= +ucloud uddos mainland ip delete use=delete short=Delete a mainland BGP high-protection IP + flag=defence-ip short= default= required=true + flag=resource-id short= default= required=true + flag=yes short=y default=false required= +ucloud uddos mainland ip list use=list short=List mainland BGP high-protection IPs + flag=bgp-ip short= default= required= + flag=limit short= default=20 required= + flag=offset short= default=0 required= + flag=resource-id short= default= required=true +ucloud uddos mainland rule use=rule short=Manage BGP forwarding rules +ucloud uddos mainland rule create use=create short=Create a BGP forwarding rule + flag=bgp-ip short= default= required=true + flag=bgp-ip-port short= default=0 required= + flag=fwd-type short= default=IP required= + flag=load-balance short= default=No required= + flag=remark short= default= required= + flag=resource-id short= default= required=true + flag=source-detect short= default=0 required= + flag=source-ip short= default= required=true +ucloud uddos mainland rule delete use=delete short=Delete a BGP forwarding rule + flag=resource-id short= default= required=true + flag=rule-index short= default=0 required=true + flag=yes short=y default=false required= +ucloud uddos mainland rule list use=list short=List BGP forwarding rules + flag=bgp-ip short= default= required= + flag=limit short= default=32 required= + flag=offset short= default=0 required= + flag=resource-id short= default= required=true + flag=rule-index short= default=0 required= +ucloud uddos mainland rule update use=update short=Update a BGP forwarding rule + flag=bgp-ip short= default= required=true + flag=bgp-ip-port short= default=0 required= + flag=fwd-type short= default=IP required= + flag=load-balance short= default=No required= + flag=resource-id short= default= required=true + flag=rule-id short= default= required= + flag=rule-index short= default=0 required=true + flag=source-detect short= default=0 required= + flag=source-ip short= default= required= +ucloud uddos mainland service use=service short=Manage mainland DDoS protection service instances +ucloud uddos mainland service create use=create short=Create a mainland DDoS high-protection service + flag=area-line short= default= required=true + flag=async short= default=false required= + flag=charge-type short= default= required=true + flag=defence-base-flow short= default=0 required=true + flag=defence-max-flow short= default=0 required=true + flag=engine-room short= default= required=true + flag=name short= default= required=true + flag=quantity short= default=0 required=true + flag=src-bandwidth short= default=0 required=true +ucloud uddos mainland service list use=list short=List mainland DDoS protection service instances + flag=limit short= default=20 required= + flag=offset short= default=0 required= + flag=resource-id short= default= required= +ucloud uddos overseas use=overseas short=Manage overseas DDoS high-protection services +ucloud uddos overseas ip use=ip short=Manage overseas DDoS protection IPs +ucloud uddos overseas ip create use=create short=Create an overseas BGP high-protection IP + flag=remark short= default= required= + flag=resource-id short= default= required=true + flag=tag short= default= required= + flag=type-ip short= default=TypeFree required= +ucloud uddos overseas ip delete use=delete short=Delete an overseas BGP high-protection IP + flag=defence-ip short= default= required=true + flag=resource-id short= default= required=true + flag=yes short=y default=false required= +ucloud uddos overseas ip list use=list short=List overseas BGP high-protection IPs + flag=limit short= default=20 required= + flag=nap-ip short= default= required= + flag=offset short= default=0 required= + flag=resource-id short= default= required=true +ucloud uddos overseas service use=service short=Manage overseas DDoS protection service instances +ucloud uddos overseas service create use=create short=Create an overseas DDoS high-protection service + flag=area-line short= default= required=true + flag=async short= default=false required= + flag=charge-type short= default= required=true + flag=name short= default= required=true + flag=quantity short= default=0 required=true + flag=src-bandwidth short= default=0 required=true +ucloud uddos overseas service list use=list short=List overseas DDoS protection service instances + flag=limit short= default=20 required= + flag=offset short= default=0 required= + flag=resource-id short= default= required= diff --git a/products/uddos/testdata/completion.golden b/products/uddos/testdata/completion.golden new file mode 100644 index 0000000000..e69de29bb2 diff --git a/products/uddos/testdata/testcases.md b/products/uddos/testdata/testcases.md new file mode 100644 index 0000000000..1b9bae5e9a --- /dev/null +++ b/products/uddos/testdata/testcases.md @@ -0,0 +1,257 @@ +# uddos CLI 测试用例 + +覆盖范围:`ucloud uddos mainland` 与 `ucloud uddos overseas` 下全部子命令。 + +--- + +## 一、国内高防 — mainland service + +### 1.1 service create + +**前置条件**:已登录,账号有购买高防服务的权限。 + +| TC# | 场景 | 命令 | 预期结果 | +|-----|------|------|----------| +| M-SVC-C-01 | 正常创建(华东,枣庄机房) | `ucloud uddos mainland service create --charge-type Month --quantity 1 --area-line EastChina --engine-room Zaozhuang --src-bandwidth 100 --defence-base-flow 30 --defence-max-flow 50 --name my-svc` | 输出包含新建服务的 ResourceID,状态 Created | +| M-SVC-C-02 | 正常创建(华东,扬州机房) | 同上,`--engine-room Yangzhou` | 正常返回 ResourceID | +| M-SVC-C-03 | 正常创建(华北,石家庄机房) | `--area-line NorthChina --engine-room Shijiazhuang` | 正常返回 ResourceID | +| M-SVC-C-04 | base-flow = max-flow | `--defence-base-flow 30 --defence-max-flow 30` | 正常创建,不报错 | +| M-SVC-C-05 | 按年计费 | `--charge-type Year --quantity 1` | 正常创建 | +| M-SVC-C-06 | src-bandwidth 为最小值 50 | `--src-bandwidth 50` | 正常创建 | +| M-SVC-C-07 | src-bandwidth 为 10 的整百倍 | `--src-bandwidth 200` | 正常创建 | +| M-SVC-C-08 | **charge-type 非法值** | `--charge-type Daily` | 报错:`invalid --charge-type "Daily", must be "Month" or "Year"` | +| M-SVC-C-09 | **area-line 非法值** | `--area-line SouthChina` | 报错:`invalid --area-line "SouthChina"` | +| M-SVC-C-10 | **engine-room 与 area-line 不匹配** | `--area-line EastChina --engine-room Shijiazhuang` | 报错:`invalid --engine-room "Shijiazhuang" for --area-line "EastChina"` | +| M-SVC-C-11 | **engine-room 与 NorthChina 不匹配** | `--area-line NorthChina --engine-room Zaozhuang` | 报错:`invalid --engine-room "Zaozhuang" for --area-line "NorthChina"` | +| M-SVC-C-12 | **src-bandwidth 低于下限** | `--src-bandwidth 40` | 报错:`--src-bandwidth minimum is 50` | +| M-SVC-C-13 | **src-bandwidth 不是 10 的倍数** | `--src-bandwidth 55` | 报错:`--src-bandwidth must be a multiple of 10` | +| M-SVC-C-14 | **defence-base-flow 不在白名单** | `--defence-base-flow 35` | 报错:`invalid --defence-base-flow 35` | +| M-SVC-C-15 | **defence-max-flow 不在白名单** | `--defence-max-flow 45` | 报错:`invalid --defence-max-flow 45` | +| M-SVC-C-16 | **max-flow 小于 base-flow** | `--defence-base-flow 50 --defence-max-flow 30` | 报错:`--defence-max-flow (30) must be >= --defence-base-flow (50)` | +| M-SVC-C-17 | **缺少 required flag** | 省略 `--name` | cobra 报错:required flag not set | +| M-SVC-C-18 | 默认同步等待就绪 | 正常参数(不带 `--async`) | 先打印 `mainland DDoS service created: ghp-xxxxx`,再轮询 `DescribeHighProtectGameServiceInfo` 直到 `DefenceStatus="Started"`,输出 `service[ghp-xxxxx] is initializing...done` | +| M-SVC-C-19 | `--async` 不等待 | `... --async` | 打印 created 叙述后立即返回,不轮询 | +| M-SVC-C-20 | ResourceId 缺失(下单未成功) | 模拟 API 返回无 ResourceInfo | 报错:`BuyHighProtectGameService returned no ResourceId; ...`,不静默当成功 | + +**defence flow 白名单**:30 / 40 / 50 / 60 / 70 / 80 / 100 / 200 / 300 / 400 / 500 / 600 / 700 / 800 (Gbps) + +**服务生命周期状态**:API 响应 `DefenceStatus` 为字符串,`Started`(=NAP_SERVICE_STATUS_IS_NORMAL,创建完成)/ `Stopped` / `Expired`。轮询目标态 = `Started`。10 分钟超时输出 `...timeout`(购买已完成,仅未等到可用)。 + +--- + +### 1.2 service list + +| TC# | 场景 | 命令 | 预期结果 | +|-----|------|------|----------| +| M-SVC-L-01 | 列出全部服务 | `ucloud uddos mainland service list` | 表格输出所有国内高防服务,含 ResourceID / Name / DefenceStatus / ExpireTime | +| M-SVC-L-02 | 按 resource-id 过滤 | `ucloud uddos mainland service list --resource-id ghp-xxxxx` | 仅返回指定服务行,其余不显示 | +| M-SVC-L-03 | 分页 offset/limit | `--offset 10 --limit 5` | 返回从第 11 条起的最多 5 条记录 | +| M-SVC-L-04 | resource-id 不存在 | `--resource-id ghp-notexist` | 输出空表格,不报错 | +| M-SVC-L-05 | JSON 输出格式 | `... --output json` | 输出合法 JSON 数组,字段完整 | + +--- + +## 二、国内高防 — mainland ip + +### 2.1 ip create + +| TC# | 场景 | 命令 | 预期结果 | +|-----|------|------|----------| +| M-IP-C-01 | 正常创建,仅必填参数 | `ucloud uddos mainland ip create --resource-id ghp-xxxxx` | 输出新建 BGP IP 地址,状态 Created | +| M-IP-C-02 | 指定 type-ip=TypeCharge | `... --type-ip TypeCharge` | 正常创建计费类型 IP | +| M-IP-C-03 | 指定 remark 和 tag | `... --remark "test" --tag "biz-group"` | 正常创建,API 参数中含 Remark 和 Tag | +| M-IP-C-04 | **缺少 resource-id** | 省略 `--resource-id` | cobra 报错:required flag not set | +| M-IP-C-05 | resource-id 不存在 | `--resource-id ghp-notexist` | API 返回业务错误,CLI 打印错误信息 | + +> 可选 flag:`--type-ip`(默认 `TypeFree`)、`--remark`、`--tag`。已移除 `--block-udp` / `--eip-region`。 + +--- + +### 2.2 ip list + +| TC# | 场景 | 命令 | 预期结果 | +|-----|------|------|----------| +| M-IP-L-01 | 列出指定服务下所有 IP | `ucloud uddos mainland ip list --resource-id ghp-xxxxx` | 表格输出所有 BGP IP,含 DefenceIP / UserIP / LineType / Status / RuleCnt 等 | +| M-IP-L-02 | 按 bgp-ip 过滤 | `... --bgp-ip 1.2.3.4` | 仅返回匹配行 | +| M-IP-L-03 | 分页 | `... --offset 0 --limit 5` | 最多返回 5 条 | +| M-IP-L-04 | 无 IP 时输出 | `--resource-id ghp-empty` | 输出空表格,不报错 | +| M-IP-L-05 | **缺少 resource-id** | 省略 `--resource-id` | cobra 报错:required flag not set | + +--- + +### 2.3 ip delete + +| TC# | 场景 | 命令 | 预期结果 | +|-----|------|------|----------| +| M-IP-D-01 | 正常删除(带 --yes) | `ucloud uddos mainland ip delete --resource-id ghp-xxxxx --defence-ip 1.2.3.4 --yes` | 跳过确认,输出 `BGP IP deleted: 1.2.3.4`,状态 Deleted | +| M-IP-D-02 | 交互确认输入 y | 不带 `--yes`,stdin 输入 `y` | 执行删除,输出同上 | +| M-IP-D-03 | 交互确认输入 n | 不带 `--yes`,stdin 输入 `n` | 取消删除,无输出,不报错 | +| M-IP-D-04 | **缺少 resource-id** | 省略 `--resource-id` | cobra 报错:required flag not set | +| M-IP-D-05 | **缺少 defence-ip** | 省略 `--defence-ip` | cobra 报错:required flag not set | +| M-IP-D-06 | defence-ip 不存在 | `--defence-ip 9.9.9.9` | API 返回业务错误,CLI 打印错误信息 | + +--- + +## 三、国内高防 — mainland rule + +### 3.1 rule create + +| TC# | 场景 | 命令 | 预期结果 | +|-----|------|------|----------| +| M-RL-C-01 | 最小参数创建 IP 协议规则 | `ucloud uddos mainland rule create --resource-id ghp-xxxxx --bgp-ip 1.2.3.4 --source-ip 10.0.0.1` | 输出 `rule[N] created for service[ghp-xxxxx]`,状态 Created;API 参数含 SourceAddrArr、SourcePortArr、SourceToaIDArr | +| M-RL-C-02 | TCP 协议 + 端口 | `... --fwd-type TCP --bgp-ip-port 80` | 正常创建 TCP 转发规则 | +| M-RL-C-03 | UDP 协议 + 端口 | `... --fwd-type UDP --bgp-ip-port 53` | 正常创建 UDP 转发规则 | +| M-RL-C-04 | 开启负载均衡 | `... --load-balance Yes` | API 参数 LoadBalance=Yes | +| M-RL-C-05 | 指定 remark | `... --remark "main rule"` | API 参数含 Remark | +| M-RL-C-06 | **缺少 resource-id** | 省略 `--resource-id` | cobra 报错:required flag not set | +| M-RL-C-07 | **缺少 bgp-ip** | 省略 `--bgp-ip` | cobra 报错:required flag not set | +| M-RL-C-08 | **缺少 source-ip** | 省略 `--source-ip` | cobra 报错:required flag not set | + +> `--source-ip` 现为**必填**(required=true)。 + +--- + +### 3.2 rule list + +| TC# | 场景 | 命令 | 预期结果 | +|-----|------|------|----------| +| M-RL-L-01 | 列出指定服务下所有规则 | `ucloud uddos mainland rule list --resource-id ghp-xxxxx` | 表格输出所有规则,含 RuleIndex / BgpIP / FwdType / SourceIP / LoadBalance 等 | +| M-RL-L-02 | 按 bgp-ip 过滤 | `... --bgp-ip 1.2.3.4` | 仅返回该 IP 下的规则 | +| M-RL-L-03 | 按 rule-index 过滤 | `... --rule-index 0` | 仅返回 index=0 的规则 | +| M-RL-L-04 | 分页 | `... --limit 10 --offset 0` | 最多 10 条,默认 limit=32 | +| M-RL-L-05 | **缺少 resource-id** | 省略 `--resource-id` | cobra 报错:required flag not set | + +--- + +### 3.3 rule update + +| TC# | 场景 | 命令 | 预期结果 | +|-----|------|------|----------| +| M-RL-U-01 | 更新 source-ip | `ucloud uddos mainland rule update --resource-id ghp-xxxxx --bgp-ip 1.2.3.4 --rule-index 0 --source-ip 10.0.0.2` | 输出 `rule[0] updated for service[ghp-xxxxx]`,状态 Updated | +| M-RL-U-02 | 切换转发协议 | `... --fwd-type TCP --bgp-ip-port 443` | 正常更新 | +| M-RL-U-03 | 开启/关闭源地址探测 | `... --source-detect 1` | API 参数 SourceDetect=1 | +| M-RL-U-04 | 通过 rule-id 定位规则 | `... --rule-id rule-abc` | API 参数含 RuleID | +| M-RL-U-05 | **缺少 resource-id** | 省略 `--resource-id` | cobra 报错:required flag not set | +| M-RL-U-06 | **缺少 bgp-ip** | 省略 `--bgp-ip` | cobra 报错:required flag not set | +| M-RL-U-07 | **缺少 rule-index** | 省略 `--rule-index` | cobra 报错:required flag not set | + +--- + +### 3.4 rule delete + +| TC# | 场景 | 命令 | 预期结果 | +|-----|------|------|----------| +| M-RL-D-01 | 正常删除(带 --yes) | `ucloud uddos mainland rule delete --resource-id ghp-xxxxx --rule-index 0 --yes` | 输出 `rule[0] deleted from service[ghp-xxxxx]`,状态 Deleted | +| M-RL-D-02 | 交互确认输入 y | 不带 `--yes`,stdin 输入 `y` | 执行删除 | +| M-RL-D-03 | 交互确认输入 n | 不带 `--yes`,stdin 输入 `n` | 取消删除,不报错 | +| M-RL-D-04 | **缺少 resource-id** | 省略 `--resource-id` | cobra 报错:required flag not set | +| M-RL-D-05 | **缺少 rule-index** | 省略 `--rule-index` | cobra 报错:required flag not set | +| M-RL-D-06 | rule-index 不存在 | `--rule-index 9999` | API 返回业务错误,CLI 打印错误信息 | + +--- + +## 四、海外高防 — overseas service + +### 4.1 service create + +**必填 flag**:`--charge-type`、`--quantity`、`--area-line`、`--src-bandwidth`、`--name`(无 `--engine-room`,已由 `--area-line` 取代;防护流量固定 50 Gbps)。 + +**area-line → 清洗中心(API EngineRoom)映射**: +- HongKong 清洗中心:HongKong / Taipei / Singapore / Tokyo / Seoul / Bangkok / HoChiMinh / Jakarta / Manila / Mumbai +- Frankfurt 清洗中心:Frankfurt / London / Moscow +- Ashburn 清洗中心:Ashburn / LosAngeles / Washington + +**src-bandwidth 步进规则**:≤300 Mbps 步进 50;300~1000 步进 100;1000~5000 步进 500。 + +| TC# | 场景 | 命令 | 预期结果 | +|-----|------|------|----------| +| O-SVC-C-01 | 亚太清洗中心(HongKong) | `ucloud uddos overseas service create --charge-type Month --quantity 1 --area-line HongKong --src-bandwidth 100 --name my-svc` | 正常返回 ResourceID,状态 Created | +| O-SVC-C-02 | 亚太城市节点(Singapore) | `--area-line Singapore` | 正常创建,EngineRoom 自动映射为 HongKong 清洗中心 | +| O-SVC-C-03 | 欧洲(Frankfurt) | `--area-line Frankfurt` | 正常创建 | +| O-SVC-C-04 | 欧洲城市(London) | `--area-line London` | 正常创建,EngineRoom 映射为 Frankfurt | +| O-SVC-C-05 | 北美(Ashburn) | `--area-line Ashburn` | 正常创建 | +| O-SVC-C-06 | 北美城市(LosAngeles) | `--area-line LosAngeles` | 正常创建,EngineRoom 映射为 Ashburn | +| O-SVC-C-07 | src-bandwidth 最小值 50 | `--src-bandwidth 50` | 正常创建 | +| O-SVC-C-08 | src-bandwidth 300(步进边界) | `--src-bandwidth 300` | 正常创建 | +| O-SVC-C-09 | src-bandwidth 400(进入 100 步进) | `--src-bandwidth 400` | 正常创建 | +| O-SVC-C-10 | src-bandwidth 1000(步进边界) | `--src-bandwidth 1000` | 正常创建 | +| O-SVC-C-11 | src-bandwidth 最大值 5000 | `--src-bandwidth 5000` | 正常创建 | +| O-SVC-C-12 | **charge-type 非法值** | `--charge-type Daily` | 报错:`invalid --charge-type "Daily", must be "Month" or "Year"` | +| O-SVC-C-13 | **area-line 不在映射表** | `--area-line Shanghai` | 报错:`invalid --area-line "Shanghai"; valid values: HongKong/...` | +| O-SVC-C-14 | **src-bandwidth 低于下限** | `--src-bandwidth 49` | 报错:`--src-bandwidth minimum is 50 for overseas` | +| O-SVC-C-15 | **src-bandwidth 超过上限** | `--src-bandwidth 5001` | 报错:`--src-bandwidth maximum is 5000 for overseas` | +| O-SVC-C-16 | **≤300 时不是 50 的倍数** | `--src-bandwidth 75` | 报错:`must be a multiple of 50 when <= 300 (overseas)` | +| O-SVC-C-17 | **300~1000 时不是 100 的倍数** | `--src-bandwidth 350` | 报错:`must be a multiple of 100 when 300~1000 (overseas)` | +| O-SVC-C-18 | **1000~5000 时不是 500 的倍数** | `--src-bandwidth 1200` | 报错:`must be a multiple of 500 when 1000~5000 (overseas)` | +| O-SVC-C-19 | **缺少 required flag** | 省略 `--area-line` | cobra 报错:required flag not set | +| O-SVC-C-20 | 默认同步等待就绪 | 正常参数(不带 `--async`) | 先打印 `overseas DDoS service created: nap-xxxxx`,再轮询 `DescribeNapServiceInfo`(NapType=2) 直到 `DefenceStatus="Started"`,输出 `service[nap-xxxxx] is initializing...done` | +| O-SVC-C-21 | `--async` 不等待 | `... --async` | 打印 created 叙述后立即返回,不轮询 | +| O-SVC-C-22 | ResourceId 缺失(下单未成功) | 模拟 API 返回无 ResourceInfo | 报错:`BuyHighProtectGameService returned no ResourceId; ...`,不静默当成功 | + +**服务生命周期状态**:API 响应 `DefenceStatus` 为字符串,`Started`(创建完成)/ `Stopped` / `Expired`。轮询目标态 = `Started`。 + +--- + +### 4.2 service list + +| TC# | 场景 | 命令 | 预期结果 | +|-----|------|------|----------| +| O-SVC-L-01 | 列出全部海外服务 | `ucloud uddos overseas service list` | 表格输出所有 NapType=2 的服务,含 ResourceID / Name / DefenceStatus / ExpireTime / Remark | +| O-SVC-L-02 | 按 resource-id 过滤 | `... --resource-id nap-xxxxx` | 仅返回指定服务 | +| O-SVC-L-03 | 分页 | `... --offset 0 --limit 10` | 最多返回 10 条 | +| O-SVC-L-04 | 无服务时输出 | (账号下无海外高防) | 输出空表格,不报错 | + +--- + +## 五、海外高防 — overseas ip + +### 5.1 ip create + +> 海外高防为透传模式(Passthrough),EIPRegion **始终自动解析**:通过 `DescribeNapServiceInfo` + `GetNapServiceConfig` 取服务配置 IpInfo 的 Region。无手动 `--eip-region` / `--block-udp` flag。可选 flag:`--type-ip`(默认 `TypeFree`)、`--remark`、`--tag`。 + +| TC# | 场景 | 命令 | 预期结果 | +|-----|------|------|----------| +| O-IP-C-01 | 自动解析 EIPRegion(仅必填参数) | `ucloud uddos overseas ip create --resource-id nap-xxxxx` | 自动查询服务配置获取 EIPRegion,创建 IP,输出 `BGP IP created: x.x.x.x`,状态 Created | +| O-IP-C-02 | 指定 type-ip=TypeCharge | `... --type-ip TypeCharge` | 正常创建计费类型 IP | +| O-IP-C-03 | 指定 remark 和 tag | `... --remark "r1" --tag "grp1"` | API 参数含 Remark / Tag | +| O-IP-C-04 | **缺少 resource-id** | 省略 `--resource-id` | cobra 报错:required flag not set | +| O-IP-C-05 | resource-id 查不到服务 | `--resource-id nap-notexist` | 报错:`service nap-notexist not found` | +| O-IP-C-06 | 服务配置 IpInfo 为空 | (数据库无 IpInfo 配置的服务) | 报错:`IpInfo is empty in service config` | + +--- + +### 5.2 ip list + +| TC# | 场景 | 命令 | 预期结果 | +|-----|------|------|----------| +| O-IP-L-01 | 列出指定服务下所有 IP | `ucloud uddos overseas ip list --resource-id nap-xxxxx` | 调用 `DescribePassthroughNapIP`,输出 EIPIP / EIPID / Status / EIPRegion / Tag / Remark | +| O-IP-L-02 | 按 nap-ip 过滤 | `... --nap-ip 1.2.3.4` | 仅返回匹配行,API 参数含 NapIp | +| O-IP-L-03 | 分页 | `... --offset 0 --limit 5` | 最多返回 5 条 | +| O-IP-L-04 | 无 IP 时输出 | `--resource-id nap-empty` | 输出空表格,不报错 | +| O-IP-L-05 | **缺少 resource-id** | 省略 `--resource-id` | cobra 报错:required flag not set | + +--- + +### 5.3 ip delete + +| TC# | 场景 | 命令 | 预期结果 | +|-----|------|------|----------| +| O-IP-D-01 | 正常删除(带 --yes) | `ucloud uddos overseas ip delete --resource-id nap-xxxxx --defence-ip 1.2.3.4 --yes` | 输出 `BGP IP deleted: 1.2.3.4`,状态 Deleted | +| O-IP-D-02 | 交互确认输入 y | 不带 `--yes`,stdin 输入 `y` | 执行删除 | +| O-IP-D-03 | 交互确认输入 n | 不带 `--yes`,stdin 输入 `n` | 取消删除,不报错 | +| O-IP-D-04 | **缺少 resource-id** | 省略 `--resource-id` | cobra 报错:required flag not set | +| O-IP-D-05 | **缺少 defence-ip** | 省略 `--defence-ip` | cobra 报错:required flag not set | + +--- + +## 六、通用 / 输出格式测试 + +| TC# | 场景 | 命令 | 预期结果 | +|-----|------|------|----------| +| G-01 | JSON 输出格式 | 任意 list 命令 + `--output json` | 输出合法 JSON 数组,字段名与结构体一致 | +| G-02 | YAML 输出格式 | 任意 list 命令 + `--output yaml` | 输出合法 YAML | +| G-03 | 进度提示去向(JSON 模式) | 带 `--output json` 的 create/delete 命令 | 进度文本输出到 stderr,ResourceID 结果输出到 stdout | +| G-04 | 进度提示去向(Table 模式) | 不带 `--output`(默认表格) | 进度文本与结果均输出到 stdout | +| G-05 | 帮助信息 | `ucloud uddos --help` / `ucloud uddos mainland --help` | 输出可读的帮助文本,Example 字段完整 | +| G-06 | 命令树完整性 | `ucloud uddos mainland rule --help` | 列出 create / list / update / delete 四个子命令 | +| G-07 | 海外命令树完整性 | `ucloud uddos overseas ip --help` | 列出 create / list / delete 三个子命令 | diff --git a/products/ukafka/internal/ukafka/add_node.go b/products/ukafka/internal/ukafka/add_node.go new file mode 100644 index 0000000000..d51062290f --- /dev/null +++ b/products/ukafka/internal/ukafka/add_node.go @@ -0,0 +1,64 @@ +package ukafka + +import ( + "fmt" + + "github.com/spf13/cobra" + + ukafkasdk "github.com/ucloud/ucloud-sdk-go/services/ukafka" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newAddNode ucloud ukafka add-node +func newAddNode(ctx *cli.Context) *cobra.Command { + var async *bool + var nodeCount *int + var nodeType *string + var instanceID *string + + client := cli.NewServiceClient(ctx, ukafkasdk.NewClient) + req := client.NewAddUKafkaInstanceNodeRequest() + + cmd := &cobra.Command{ + Use: "add-node", + Short: "Add nodes to UKafka instance", + Long: "Add nodes to UKafka instance", + Run: func(cmd *cobra.Command, args []string) { + w := ctx.ProgressWriter() + req.InstanceId = sdk.String(*instanceID) + req.NodeCount = sdk.String(fmt.Sprintf("%d", *nodeCount)) + req.NodeType = sdk.String(*nodeType) + + _, err := client.AddUKafkaInstanceNode(req) + if err != nil { + ctx.HandleError(err) + return + } + + text := fmt.Sprintf("ukafka[%s] adding %d node(s)", *instanceID, *nodeCount) + if *async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeUKafkaInstanceByID(ctx)).Spoll(*instanceID, text, []string{StateRunning, StateAbnormal}) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: *instanceID, Action: "add-node", Status: "Running"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + instanceID = flags.String("ukafka-id", "", "Required. Instance ID") + nodeCount = flags.Int("node-count", 1, "Required. Number of nodes to add") + nodeType = flags.String("node-type", "", "Required. Node type") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + async = flags.Bool("async", false, "Optional. Do not wait for operation to finish") + + cmd.MarkFlagRequired("ukafka-id") + cmd.MarkFlagRequired("node-type") + + return cmd +} diff --git a/products/ukafka/internal/ukafka/appversion.go b/products/ukafka/internal/ukafka/appversion.go new file mode 100644 index 0000000000..d4bd810165 --- /dev/null +++ b/products/ukafka/internal/ukafka/appversion.go @@ -0,0 +1,42 @@ +package ukafka + +import ( + "github.com/spf13/cobra" + + ukafkasdk "github.com/ucloud/ucloud-sdk-go/services/ukafka" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newAppVersion ucloud ukafka app-version +func newAppVersion(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, ukafkasdk.NewClient) + req := client.NewListUKafkaFrameworkVersionRequest() + cmd := &cobra.Command{ + Use: "app-version", + Short: "List available Kafka versions", + Long: "List available Kafka versions", + Run: func(cmd *cobra.Command, args []string) { + resp, err := client.ListUKafkaFrameworkVersion(req) + if err != nil { + ctx.HandleError(err) + return + } + list := []VersionRow{} + for _, v := range resp.FrameworkVersions { + row := VersionRow{ + Version: v.Version, + Label: v.Label, + } + list = append(list, row) + } + ctx.PrintList(list) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + return cmd +} diff --git a/products/ukafka/internal/ukafka/check_topic.go b/products/ukafka/internal/ukafka/check_topic.go new file mode 100644 index 0000000000..8ff559b799 --- /dev/null +++ b/products/ukafka/internal/ukafka/check_topic.go @@ -0,0 +1,95 @@ +package ukafka + +import ( + "fmt" + + "github.com/spf13/cobra" + + ukafkasdk "github.com/ucloud/ucloud-sdk-go/services/ukafka" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// IsUKafkaTopicNameExistResponse 自定义响应结构 +type IsUKafkaTopicNameExistResponse struct { + RetCode int `json:"RetCode"` + Action string `json:"Action"` + IsExist string `json:"IsExist"` + Message string `json:"Message"` +} + +// newCheckTopic ucloud ukafka check-topic +func newCheckTopic(ctx *cli.Context) *cobra.Command { + var instanceID *string + var topicName *string + + client := cli.NewServiceClient(ctx, ukafkasdk.NewClient) + req := client.NewIsUKafkaTopicNameExistRequest() + + cmd := &cobra.Command{ + Use: "check-topic", + Short: "Check if a topic name exists in UKafka instance", + Long: "Check if a topic name exists in UKafka instance", + Run: func(cmd *cobra.Command, args []string) { + // 使用 GenericInvoke 调用 API + genReq := client.Client.NewGenericRequest() + genReq.SetAction("IsUKafkaTopicNameExist") + genReq.SetRegion(*req.Region) + genReq.SetZone(*req.Zone) + if req.ProjectId != nil && *req.ProjectId != "" { + genReq.SetProjectId(*req.ProjectId) + } + + payload := map[string]interface{}{ + "ClusterInstanceId": *instanceID, + "TopicName": *topicName, + } + genReq.SetPayload(payload) + + genResp, err := client.Client.GenericInvoke(genReq) + if err != nil { + ctx.HandleError(err) + return + } + + var resp IsUKafkaTopicNameExistResponse + if err := genResp.Unmarshal(&resp); err != nil { + ctx.HandleError(fmt.Errorf("parse response: %w", err)) + return + } + + if resp.RetCode != 0 { + ctx.HandleError(fmt.Errorf("API error: RetCode=%d, Message=%s", resp.RetCode, resp.Message)) + return + } + + // 输出结构化结果 + rows := []cli.DescribeRow{ + {Attribute: "InstanceID", Content: *instanceID}, + {Attribute: "TopicName", Content: *topicName}, + {Attribute: "Exists", Content: resp.IsExist}, + } + ctx.PrintList(rows) + + // 同时输出事件结果 + ctx.EmitResult(cli.OpResultRow{ + ResourceID: *instanceID, + Action: "check-topic", + Status: resp.IsExist, + }) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + instanceID = flags.String("ukafka-id", "", "Required. Instance ID") + topicName = flags.String("topic-name", "", "Required. Topic name to check") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + + cmd.MarkFlagRequired("ukafka-id") + cmd.MarkFlagRequired("topic-name") + + return cmd +} diff --git a/products/ukafka/internal/ukafka/cmd.go b/products/ukafka/internal/ukafka/cmd.go new file mode 100644 index 0000000000..a5ed0bc893 --- /dev/null +++ b/products/ukafka/internal/ukafka/cmd.go @@ -0,0 +1,30 @@ +package ukafka + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand builds the `ukafka` root command and mounts the subcommands. +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "ukafka", + Short: "Manage UKafka instances", + Long: "Manage UKafka instances", + } + cmd.AddCommand(newCreate(ctx)) + cmd.AddCommand(newDelete(ctx)) + cmd.AddCommand(newDescribe(ctx)) + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newNodeConf(ctx)) + cmd.AddCommand(newAppVersion(ctx)) + cmd.AddCommand(newAddNode(ctx)) + cmd.AddCommand(newDescribeConsumer(ctx)) + cmd.AddCommand(newCheckTopic(ctx)) + cmd.AddCommand(newListConsumers(ctx)) + cmd.AddCommand(newListTopics(ctx)) + cmd.AddCommand(newModifyType(ctx)) + cmd.AddCommand(newResizeDisk(ctx)) + return cmd +} diff --git a/products/ukafka/internal/ukafka/create.go b/products/ukafka/internal/ukafka/create.go new file mode 100644 index 0000000000..387d2ce4f1 --- /dev/null +++ b/products/ukafka/internal/ukafka/create.go @@ -0,0 +1,72 @@ +package ukafka + +import ( + "fmt" + + "github.com/spf13/cobra" + + ukafkasdk "github.com/ucloud/ucloud-sdk-go/services/ukafka" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newCreate ucloud ukafka create +func newCreate(ctx *cli.Context) *cobra.Command { + var async *bool + client := cli.NewServiceClient(ctx, ukafkasdk.NewClient) + req := client.NewCreateUKafkaInstanceRequest() + cmd := &cobra.Command{ + Use: "create", + Short: "Create UKafka instance", + Long: "Create UKafka instance", + Run: func(cmd *cobra.Command, args []string) { + w := ctx.ProgressWriter() + resp, err := client.CreateUKafkaInstance(req) + if err != nil { + ctx.HandleError(err) + return + } + text := fmt.Sprintf("ukafka[%s] is creating", resp.InstanceId) + if *async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeUKafkaInstanceByID(ctx)).Spoll(resp.InstanceId, text, []string{StateRunning, StateAbnormal}) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.InstanceId, Action: "create", Status: "Creating"}) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.InstanceName = flags.String("name", "", "Required. Instance name") + req.FrameworkVersion = flags.String("kafka-version", "", "Required. Kafka version, e.g. 2.12-2.4.1") + req.NodeType = flags.String("node-type", "", "Required. Node type") + req.DiskSize = flags.Int("disk-size-gb", 0, "Required. Disk size in GB") + req.NodeCount = flags.Int("node-count", 3, "Optional. Node count, default 3") + req.LogRetentionHours = flags.String("log-retention-hours", "72", "Optional. Log retention hours (1-240), default 72") + req.VPCId = flags.String("vpc-id", "", "Optional. VPC ID") + req.SubnetId = flags.String("subnet-id", "", "Optional. Subnet ID") + req.BusinessId = flags.String("business-id", "", "Optional. Business group ID") + req.Quantity = flags.String("quantity", "1", "Optional. Instance quantity, default 1") + req.DiskControllerType = flags.String("disk-controller-type", "NONE", "Optional. Disk controller type: NONE or CLEAN") + req.DiskThreshold = flags.String("disk-threshold", "90", "Optional. Disk cleanup threshold (70-90), default 90") + req.IsSecurityEnabled = flags.String("enable-security", "false", "Optional. Enable security group: true or false") + async = flags.Bool("async", false, "Optional. Do not wait for creation to finish") + + // Bind common params with Tab completion + // Note: UKafka SDK uses *string for ChargeType/Quantity (not *int), + // so we cannot use ctx.BindCommonParams which assumes standard types. + // Instead we bind region/zone/project-id individually with completion. + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + command.SetFlagValues(cmd, "charge-type", "Month", "Year", "Dynamic") + + cmd.MarkFlagRequired("name") + cmd.MarkFlagRequired("kafka-version") + cmd.MarkFlagRequired("node-type") + cmd.MarkFlagRequired("disk-size-gb") + + return cmd +} diff --git a/products/ukafka/internal/ukafka/delete.go b/products/ukafka/internal/ukafka/delete.go new file mode 100644 index 0000000000..d9f1eba566 --- /dev/null +++ b/products/ukafka/internal/ukafka/delete.go @@ -0,0 +1,59 @@ +package ukafka + +import ( + "fmt" + + "github.com/spf13/cobra" + + ukafkasdk "github.com/ucloud/ucloud-sdk-go/services/ukafka" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newDelete ucloud ukafka delete +func newDelete(ctx *cli.Context) *cobra.Command { + var yes *bool + var instanceIDs *[]string + client := cli.NewServiceClient(ctx, ukafkasdk.NewClient) + req := client.NewDeleteUKafkaInstanceRequest() + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete UKafka instances", + Long: "Delete UKafka instances", + Run: func(cmd *cobra.Command, args []string) { + ok, err := ctx.Confirm(*yes, "Are you sure to delete UKafka instance(s)?") + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, idName := range *instanceIDs { + id := ctx.PickResourceID(idName) + req.InstanceId = &id + _, err := client.DeleteUKafkaInstance(req) + if err != nil { + ctx.HandleError(err) + continue + } + fmt.Fprintf(w, "ukafka[%s] deleted\n", id) + results = append(results, cli.OpResultRow{ResourceID: id, Action: "delete", Status: "Deleted"}) + } + ctx.EmitResult(results...) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + instanceIDs = flags.StringSlice("ukafka-id", nil, "Required. Instance ID(s) to delete") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + yes = flags.BoolP("yes", "y", false, "Optional. Skip confirmation prompt") + + cmd.MarkFlagRequired("ukafka-id") + + return cmd +} diff --git a/products/ukafka/internal/ukafka/describe.go b/products/ukafka/internal/ukafka/describe.go new file mode 100644 index 0000000000..b0f9d0a9f4 --- /dev/null +++ b/products/ukafka/internal/ukafka/describe.go @@ -0,0 +1,182 @@ +package ukafka + +import ( + "fmt" + + "github.com/spf13/cobra" + + ukafkasdk "github.com/ucloud/ucloud-sdk-go/services/ukafka" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// DescribeUKafkaInstanceResponse 自定义响应结构 +type DescribeUKafkaInstanceResponse struct { + RetCode int `json:"RetCode"` + Action string `json:"Action"` + ClusterSet []ClusterInfo `json:"ClusterSet"` + Message string `json:"Message"` +} + +// ClusterInfo 实例信息 +type ClusterInfo struct { + Zone string `json:"Zone"` + ClusterInstanceId string `json:"ClusterInstanceId"` + ClusterInstanceName string `json:"ClusterInstanceName"` + Remark string `json:"Remark"` + Tag string `json:"Tag"` + Framework string `json:"Framework"` + FrameworkVersion string `json:"FrameworkVersion"` + NetworkId string `json:"NetworkId"` + VPCId string `json:"VPCId"` + SubnetId string `json:"SubnetId"` + BusinessId string `json:"BusinessId"` + UHostSet []Broker `json:"UHostSet"` + IsOpenSecgroup bool `json:"IsOpenSecgroup"` + ChargeType string `json:"ChargeType"` + AutoRenew string `json:"AutoRenew"` + ValidBrokerNum int `json:"ValidBrokerNum"` + UHostCount int `json:"UHostCount"` + ExpireTime int `json:"ExpireTime"` + CreateTime int `json:"CreateTime"` + RunningTime int `json:"RunningTime"` + State string `json:"State"` +} + +// Broker 节点信息 +type Broker struct { + BrokerId string `json:"BrokerId"` + UHostId string `json:"UHostId"` + ResourceId string `json:"ResourceId"` + UHostRole string `json:"UHostRole"` + UHostName string `json:"UHostName"` + DomainName string `json:"DomainName"` + Remark string `json:"Remark"` + CreateTime int `json:"CreateTime"` + ExpireTime int `json:"ExpireTime"` + InstanceGroupType string `json:"InstanceGroupType"` + SecurityGroupId string `json:"SecurityGroupId"` + State string `json:"State"` + ZooKeeper string `json:"ZooKeeper"` + UHostConfig UHostConfig `json:"UHostConfig"` + IPSet []IPInfo `json:"IPSet"` + KafkaPort int `json:"KafkaPort"` + ZooKeeperPort int `json:"ZooKeeperPort"` +} + +// UHostConfig 节点配置 +type UHostConfig struct { + CPU int `json:"CPU"` + Memory int `json:"Memory"` + DataDiskSize int `json:"DataDiskSize"` + DiskType string `json:"DiskType"` +} + +// IPInfo IP信息 +type IPInfo struct { + Type string `json:"Type"` + IP string `json:"IP"` +} + +// newDescribe ucloud ukafka describe +func newDescribe(ctx *cli.Context) *cobra.Command { + var instanceID *string + client := cli.NewServiceClient(ctx, ukafkasdk.NewClient) + req := client.NewDescribeUKafkaInstanceRequest() + cmd := &cobra.Command{ + Use: "describe", + Short: "Describe UKafka instance details", + Long: "Describe UKafka instance details", + Run: func(cmd *cobra.Command, args []string) { + id := ctx.PickResourceID(*instanceID) + + // 使用 GenericInvoke 绕过 SDK 类型问题 + genReq := client.Client.NewGenericRequest() + genReq.SetAction("DescribeUKafkaInstance") + genReq.SetRegion(*req.Region) + genReq.SetZone(*req.Zone) + if req.ProjectId != nil && *req.ProjectId != "" { + genReq.SetProjectId(*req.ProjectId) + } + + payload := map[string]interface{}{ + "ClusterInstanceId": id, + } + genReq.SetPayload(payload) + + genResp, err := client.Client.GenericInvoke(genReq) + if err != nil { + ctx.HandleError(err) + return + } + + var resp DescribeUKafkaInstanceResponse + if err := genResp.Unmarshal(&resp); err != nil { + ctx.HandleError(fmt.Errorf("parse response: %w", err)) + return + } + + if resp.RetCode != 0 { + ctx.HandleError(fmt.Errorf("API error: RetCode=%d, Message=%s", resp.RetCode, resp.Message)) + return + } + + if len(resp.ClusterSet) == 0 { + ctx.EmitResult(cli.OpResultRow{ResourceID: id, Action: "describe", Status: "NotFound"}) + return + } + + cluster := resp.ClusterSet[0] + rows := []cli.DescribeRow{ + {Attribute: "InstanceID", Content: cluster.ClusterInstanceId}, + {Attribute: "InstanceName", Content: cluster.ClusterInstanceName}, + {Attribute: "Region", Content: *req.Region}, + {Attribute: "Zone", Content: cluster.Zone}, + {Attribute: "State", Content: cluster.State}, + {Attribute: "Framework", Content: cluster.Framework}, + {Attribute: "FrameworkVersion", Content: cluster.FrameworkVersion}, + {Attribute: "VPCId", Content: cluster.VPCId}, + {Attribute: "SubnetId", Content: cluster.SubnetId}, + {Attribute: "BusinessId", Content: cluster.BusinessId}, + {Attribute: "ChargeType", Content: cluster.ChargeType}, + {Attribute: "AutoRenew", Content: cluster.AutoRenew}, + {Attribute: "Remark", Content: cluster.Remark}, + } + + // Add node information + if len(cluster.UHostSet) > 0 { + rows = append(rows, cli.DescribeRow{Attribute: "--- Nodes ---", Content: fmt.Sprintf("%d nodes", len(cluster.UHostSet))}) + for i, node := range cluster.UHostSet { + prefix := fmt.Sprintf("Node[%d]", i) + var ip string + if len(node.IPSet) > 0 { + ip = node.IPSet[0].IP + } + rows = append(rows, + cli.DescribeRow{Attribute: prefix + ".NodeID", Content: node.UHostId}, + cli.DescribeRow{Attribute: prefix + ".NodeName", Content: node.UHostName}, + cli.DescribeRow{Attribute: prefix + ".NodeRole", Content: node.UHostRole}, + cli.DescribeRow{Attribute: prefix + ".State", Content: node.State}, + cli.DescribeRow{Attribute: prefix + ".IP", Content: ip}, + cli.DescribeRow{Attribute: prefix + ".CPU", Content: fmt.Sprintf("%d", node.UHostConfig.CPU)}, + cli.DescribeRow{Attribute: prefix + ".Memory", Content: fmt.Sprintf("%dMB", node.UHostConfig.Memory)}, + cli.DescribeRow{Attribute: prefix + ".DiskSize", Content: fmt.Sprintf("%dGB", node.UHostConfig.DataDiskSize)}, + cli.DescribeRow{Attribute: prefix + ".DiskType", Content: node.UHostConfig.DiskType}, + cli.DescribeRow{Attribute: prefix + ".KafkaPort", Content: fmt.Sprintf("%d", node.KafkaPort)}, + ) + } + } + ctx.PrintList(rows) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + instanceID = flags.String("ukafka-id", "", "Required. Instance ID to describe") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + + cmd.MarkFlagRequired("ukafka-id") + + return cmd +} diff --git a/products/ukafka/internal/ukafka/describe_consumer.go b/products/ukafka/internal/ukafka/describe_consumer.go new file mode 100644 index 0000000000..6f8c1344ab --- /dev/null +++ b/products/ukafka/internal/ukafka/describe_consumer.go @@ -0,0 +1,68 @@ +package ukafka + +import ( + "fmt" + + "github.com/spf13/cobra" + + ukafkasdk "github.com/ucloud/ucloud-sdk-go/services/ukafka" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newDescribeConsumer ucloud ukafka describe-consumer +func newDescribeConsumer(ctx *cli.Context) *cobra.Command { + var instanceID *string + var consumerGroup *string + var consumerType *string + + client := cli.NewServiceClient(ctx, ukafkasdk.NewClient) + req := client.NewDescribeUKafkaConsumerRequest() + + cmd := &cobra.Command{ + Use: "describe-consumer", + Short: "Describe Kafka consumer group details", + Long: "Describe Kafka consumer group details", + Run: func(cmd *cobra.Command, args []string) { + req.ClusterInstanceId = sdk.String(*instanceID) + req.ConsumerGroup = sdk.String(*consumerGroup) + req.Type = sdk.String(*consumerType) + + resp, err := client.DescribeUKafkaConsumer(req) + if err != nil { + ctx.HandleError(err) + return + } + + rows := []cli.DescribeRow{ + {Attribute: "GroupName", Content: resp.GroupName}, + {Attribute: "Type", Content: resp.Type}, + } + if len(resp.Topics) > 0 { + for i, topic := range resp.Topics { + rows = append(rows, cli.DescribeRow{ + Attribute: fmt.Sprintf("Topic[%d]", i), + Content: topic, + }) + } + } + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + instanceID = flags.String("ukafka-id", "", "Required. Instance ID") + consumerGroup = flags.String("consumer-group", "", "Required. Consumer group name") + consumerType = flags.String("type", "", "Required. Consumer group type (e.g. ZK, KF)") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + + cmd.MarkFlagRequired("ukafka-id") + cmd.MarkFlagRequired("consumer-group") + cmd.MarkFlagRequired("type") + + return cmd +} diff --git a/products/ukafka/internal/ukafka/list.go b/products/ukafka/internal/ukafka/list.go new file mode 100644 index 0000000000..e6645bc518 --- /dev/null +++ b/products/ukafka/internal/ukafka/list.go @@ -0,0 +1,133 @@ +package ukafka + +import ( + "fmt" + + "github.com/spf13/cobra" + + ukafkasdk "github.com/ucloud/ucloud-sdk-go/services/ukafka" + + "github.com/ucloud/ucloud-cli/internal/common" + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// ListUKafkaInstanceResponse 自定义响应结构,修复 SDK 类型问题 +type ListUKafkaInstanceResponse struct { + RetCode int `json:"RetCode"` + Action string `json:"Action"` + TotalCount int `json:"TotalCount"` + ClusterSet []ClusterSetRaw `json:"ClusterSet"` + Message string `json:"Message"` +} + +// ClusterSetRaw 实例信息 +type ClusterSetRaw struct { + Zone string `json:"Zone"` + ClusterInstanceId string `json:"ClusterInstanceId"` + ClusterInstanceName string `json:"ClusterInstanceName"` + Framework string `json:"Framework"` + FrameworkVersion string `json:"FrameworkVersion"` + Remark string `json:"Remark"` + CreateTime int `json:"CreateTime"` + RunningTime int `json:"RunningTime"` + ExpireTime int `json:"ExpireTime"` + AutoRenew string `json:"AutoRenew"` + ChargeType string `json:"ChargeType"` + UHostCount int `json:"UHostCount"` + State string `json:"State"` + Tag string `json:"Tag"` + InstanceGroupType string `json:"InstanceGroupType"` + VPCId string `json:"VPCId"` + SubnetId string `json:"SubnetId"` + BusinessId string `json:"BusinessId"` +} + +// newList ucloud ukafka list +func newList(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, ukafkasdk.NewClient) + req := client.NewListUKafkaInstanceRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List UKafka instances", + Long: "List UKafka instances", + Run: func(cmd *cobra.Command, args []string) { + // 使用 GenericInvoke 绕过 SDK 类型问题 + genReq := client.Client.NewGenericRequest() + genReq.SetAction("ListUKafkaInstance") + genReq.SetRegion(*req.Region) + genReq.SetZone(*req.Zone) + if req.ProjectId != nil && *req.ProjectId != "" { + genReq.SetProjectId(*req.ProjectId) + } + + // 构建额外参数 + payload := map[string]interface{}{} + if req.Offset != nil && *req.Offset != "0" { + payload["Offset"] = *req.Offset + } + if req.Limit != nil && *req.Limit != "60" { + payload["Limit"] = *req.Limit + } + if req.VPCId != nil && *req.VPCId != "" { + payload["VPCId"] = *req.VPCId + } + if req.SubnetId != nil && *req.SubnetId != "" { + payload["SubnetId"] = *req.SubnetId + } + if req.BusinessId != nil && *req.BusinessId != "" { + payload["BusinessId"] = *req.BusinessId + } + if len(payload) > 0 { + genReq.SetPayload(payload) + } + + genResp, err := client.Client.GenericInvoke(genReq) + if err != nil { + ctx.HandleError(err) + return + } + + var resp ListUKafkaInstanceResponse + if err := genResp.Unmarshal(&resp); err != nil { + ctx.HandleError(fmt.Errorf("parse response: %w", err)) + return + } + + if resp.RetCode != 0 { + ctx.HandleError(fmt.Errorf("API error: RetCode=%d, Message=%s", resp.RetCode, resp.Message)) + return + } + + list := []InstanceRow{} + for _, ins := range resp.ClusterSet { + row := InstanceRow{ + InstanceID: ins.ClusterInstanceId, + InstanceName: ins.ClusterInstanceName, + Framework: ins.Framework, + Version: ins.FrameworkVersion, + Zone: ins.Zone, + State: ins.State, + NodeCount: fmt.Sprintf("%d", ins.UHostCount), + VPCId: ins.VPCId, + SubnetId: ins.SubnetId, + ChargeType: ins.ChargeType, + CreateTime: common.FormatDate(ins.CreateTime), + ExpireTime: common.FormatDate(ins.ExpireTime), + } + list = append(list, row) + } + ctx.PrintList(list) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", "", "Optional. Assign availability zone") + req.Offset = flags.String("offset", "0", "Optional. Offset") + req.Limit = flags.String("limit", "60", "Optional. Limit, default 60") + req.VPCId = flags.String("vpc-id", "", "Optional. VPC ID") + req.SubnetId = flags.String("subnet-id", "", "Optional. Subnet ID") + req.BusinessId = flags.String("business-id", "", "Optional. Business group ID") + return cmd +} diff --git a/products/ukafka/internal/ukafka/list_consumers.go b/products/ukafka/internal/ukafka/list_consumers.go new file mode 100644 index 0000000000..80e1e2cf0a --- /dev/null +++ b/products/ukafka/internal/ukafka/list_consumers.go @@ -0,0 +1,58 @@ +package ukafka + +import ( + "fmt" + + "github.com/spf13/cobra" + + ukafkasdk "github.com/ucloud/ucloud-sdk-go/services/ukafka" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newListConsumers ucloud ukafka list-consumers +func newListConsumers(ctx *cli.Context) *cobra.Command { + var instanceID *string + + client := cli.NewServiceClient(ctx, ukafkasdk.NewClient) + req := client.NewListUKafkaConsumersRequest() + + cmd := &cobra.Command{ + Use: "list-consumers", + Short: "List Kafka consumer groups", + Long: "List Kafka consumer groups in UKafka instance", + Run: func(cmd *cobra.Command, args []string) { + req.ClusterInstanceId = sdk.String(*instanceID) + + resp, err := client.ListUKafkaConsumers(req) + if err != nil { + ctx.HandleError(err) + return + } + + list := []ConsumerGroupRow{} + for _, g := range resp.Groups { + row := ConsumerGroupRow{ + GroupName: g.GroupName, + Type: g.Type, + NumOfTopics: fmt.Sprintf("%d", g.NumOfTopics), + GroupID: g.GroupId, + } + list = append(list, row) + } + ctx.PrintList(list) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + instanceID = flags.String("ukafka-id", "", "Required. Instance ID") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + + cmd.MarkFlagRequired("ukafka-id") + + return cmd +} diff --git a/products/ukafka/internal/ukafka/list_topics.go b/products/ukafka/internal/ukafka/list_topics.go new file mode 100644 index 0000000000..7198afc3f4 --- /dev/null +++ b/products/ukafka/internal/ukafka/list_topics.go @@ -0,0 +1,101 @@ +package ukafka + +import ( + "fmt" + + "github.com/spf13/cobra" + + ukafkasdk "github.com/ucloud/ucloud-sdk-go/services/ukafka" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// ListUKafkaTopicsResponse 自定义响应结构 +type ListUKafkaTopicsResponse struct { + RetCode int `json:"RetCode"` + Action string `json:"Action"` + TopicList []TopicInfo `json:"TopicList"` + Length int `json:"Length"` + Message string `json:"Message"` +} + +// TopicInfo topic信息 +type TopicInfo struct { + Topic string `json:"Topic"` + NumOfPartition int `json:"NumOfPartition"` + NumOfOccupyBroker int `json:"NumOfOccupyBroker"` + NumOfReplica int `json:"NumOfReplica"` + Status string `json:"Status"` + UnderReplicasPer string `json:"UnderReplicasPer"` +} + +// newListTopics ucloud ukafka list-topics +func newListTopics(ctx *cli.Context) *cobra.Command { + var instanceID *string + + client := cli.NewServiceClient(ctx, ukafkasdk.NewClient) + req := client.NewListUKafkaTopicsRequest() + + cmd := &cobra.Command{ + Use: "list-topics", + Short: "List Kafka topics in UKafka instance", + Long: "List Kafka topics in UKafka instance", + Run: func(cmd *cobra.Command, args []string) { + // 使用 GenericInvoke 绕过 SDK 类型问题 + genReq := client.Client.NewGenericRequest() + genReq.SetAction("ListUKafkaTopics") + genReq.SetRegion(*req.Region) + genReq.SetZone(*req.Zone) + if req.ProjectId != nil && *req.ProjectId != "" { + genReq.SetProjectId(*req.ProjectId) + } + + payload := map[string]interface{}{ + "ClusterInstanceId": *instanceID, + } + genReq.SetPayload(payload) + + genResp, err := client.Client.GenericInvoke(genReq) + if err != nil { + ctx.HandleError(err) + return + } + + var resp ListUKafkaTopicsResponse + if err := genResp.Unmarshal(&resp); err != nil { + ctx.HandleError(fmt.Errorf("parse response: %w", err)) + return + } + + if resp.RetCode != 0 { + ctx.HandleError(fmt.Errorf("API error: RetCode=%d, Message=%s", resp.RetCode, resp.Message)) + return + } + + list := []TopicRow{} + for _, t := range resp.TopicList { + row := TopicRow{ + Topic: t.Topic, + NumOfPartition: fmt.Sprintf("%d", t.NumOfPartition), + NumOfReplica: fmt.Sprintf("%d", t.NumOfReplica), + NumOfOccupyBroker: fmt.Sprintf("%d", t.NumOfOccupyBroker), + UnderReplicasPer: t.UnderReplicasPer, + Status: t.Status, + } + list = append(list, row) + } + ctx.PrintList(list) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + instanceID = flags.String("ukafka-id", "", "Required. Instance ID") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + + cmd.MarkFlagRequired("ukafka-id") + + return cmd +} diff --git a/products/ukafka/internal/ukafka/modify_type.go b/products/ukafka/internal/ukafka/modify_type.go new file mode 100644 index 0000000000..711fe418b4 --- /dev/null +++ b/products/ukafka/internal/ukafka/modify_type.go @@ -0,0 +1,61 @@ +package ukafka + +import ( + "fmt" + + "github.com/spf13/cobra" + + ukafkasdk "github.com/ucloud/ucloud-sdk-go/services/ukafka" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newModifyType ucloud ukafka modify-type +func newModifyType(ctx *cli.Context) *cobra.Command { + var async *bool + var instanceID *string + var nodeType *string + + client := cli.NewServiceClient(ctx, ukafkasdk.NewClient) + req := client.NewModifyUKafkaInstanceTypeRequest() + + cmd := &cobra.Command{ + Use: "modify-type", + Short: "Modify UKafka instance type (CPU and memory)", + Long: "Modify UKafka instance type, only upgrade CPU and memory", + Run: func(cmd *cobra.Command, args []string) { + w := ctx.ProgressWriter() + req.InstanceId = sdk.String(*instanceID) + req.NodeType = sdk.String(*nodeType) + + _, err := client.ModifyUKafkaInstanceType(req) + if err != nil { + ctx.HandleError(err) + return + } + + text := fmt.Sprintf("ukafka[%s] is modifying type to %s", *instanceID, *nodeType) + if *async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeUKafkaInstanceByID(ctx)).Spoll(*instanceID, text, []string{StateRunning, StateAbnormal}) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: *instanceID, Action: "modify-type", Status: "Running"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + instanceID = flags.String("ukafka-id", "", "Required. Instance ID") + nodeType = flags.String("node-type", "", "Required. Target node type") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + async = flags.Bool("async", false, "Optional. Do not wait for operation to finish") + + cmd.MarkFlagRequired("ukafka-id") + cmd.MarkFlagRequired("node-type") + + return cmd +} diff --git a/products/ukafka/internal/ukafka/nodeconf.go b/products/ukafka/internal/ukafka/nodeconf.go new file mode 100644 index 0000000000..5a32920eac --- /dev/null +++ b/products/ukafka/internal/ukafka/nodeconf.go @@ -0,0 +1,105 @@ +package ukafka + +import ( + "fmt" + + "github.com/spf13/cobra" + + ukafkasdk "github.com/ucloud/ucloud-sdk-go/services/ukafka" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// GetUKafkaNodeTypeResponse 自定义响应结构 +type GetUKafkaNodeTypeResponse struct { + RetCode int `json:"RetCode"` + Action string `json:"Action"` + NodeTypeSet []InstanceType `json:"NodeTypeSet"` + TotalCount int `json:"TotalCount"` + Message string `json:"Message"` +} + +// InstanceType 机型信息 +type InstanceType struct { + NodeTypeName string `json:"NodeTypeName"` + CPU int `json:"CPU"` + Memory int `json:"Memory"` + DiskType string `json:"DiskType"` + DiskSet []DiskSet `json:"DiskSet"` + MaxDiskSize int `json:"MaxDiskSize"` + MinDiskSize int `json:"MinDiskSize"` + IsOpenSecGroup bool `json:"IsOpenSecGroup"` + UHostFamily string `json:"UHostFamily"` +} + +// DiskSet 磁盘配置 +type DiskSet struct { + Type string `json:"Type"` + Size int `json:"Size"` +} + +// newNodeConf ucloud ukafka node-conf +func newNodeConf(ctx *cli.Context) *cobra.Command { + client := cli.NewServiceClient(ctx, ukafkasdk.NewClient) + req := client.NewGetUKafkaNodeTypeRequest() + cmd := &cobra.Command{ + Use: "node-conf", + Short: "List available UKafka node configurations", + Long: "List available UKafka node configurations", + Run: func(cmd *cobra.Command, args []string) { + // 使用 GenericInvoke 绕过 SDK 类型问题 + genReq := client.Client.NewGenericRequest() + genReq.SetAction("GetUKafkaNodeType") + genReq.SetRegion(*req.Region) + genReq.SetZone(*req.Zone) + if req.ProjectId != nil && *req.ProjectId != "" { + genReq.SetProjectId(*req.ProjectId) + } + if req.NodeType != nil && *req.NodeType != "" { + payload := map[string]interface{}{ + "NodeType": *req.NodeType, + } + genReq.SetPayload(payload) + } + + genResp, err := client.Client.GenericInvoke(genReq) + if err != nil { + ctx.HandleError(err) + return + } + + var resp GetUKafkaNodeTypeResponse + if err := genResp.Unmarshal(&resp); err != nil { + ctx.HandleError(fmt.Errorf("parse response: %w", err)) + return + } + + if resp.RetCode != 0 { + ctx.HandleError(fmt.Errorf("API error: RetCode=%d, Message=%s", resp.RetCode, resp.Message)) + return + } + + list := []NodeConfRow{} + for _, t := range resp.NodeTypeSet { + row := NodeConfRow{ + NodeType: t.NodeTypeName, + CPU: fmt.Sprintf("%d", t.CPU), + Memory: fmt.Sprintf("%dMB", t.Memory), + DiskType: t.DiskType, + MinDiskSize: fmt.Sprintf("%d", t.MinDiskSize), + MaxDiskSize: fmt.Sprintf("%d", t.MaxDiskSize), + SecGroup: fmt.Sprintf("%v", t.IsOpenSecGroup), + } + list = append(list, row) + } + ctx.PrintList(list) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + req.NodeType = flags.String("node-type", "", "Optional. Specify node type") + return cmd +} diff --git a/products/ukafka/internal/ukafka/poll.go b/products/ukafka/internal/ukafka/poll.go new file mode 100644 index 0000000000..d5dd12e05e --- /dev/null +++ b/products/ukafka/internal/ukafka/poll.go @@ -0,0 +1,59 @@ +package ukafka + +import ( + "fmt" + + ukafkasdk "github.com/ucloud/ucloud-sdk-go/services/ukafka" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// describeUKafkaInstanceByID returns the poller's describe func +// It uses commonBase to get region and zone for the request +func describeUKafkaInstanceByID(ctx *cli.Context) func(instanceID string, commonBase *request.CommonBase) (interface{}, error) { + return func(instanceID string, commonBase *request.CommonBase) (interface{}, error) { + client := cli.NewServiceClient(ctx, ukafkasdk.NewClient) + genReq := client.Client.NewGenericRequest() + genReq.SetAction("DescribeUKafkaInstance") + + // Use region/zone from commonBase if available, otherwise use defaults + if commonBase != nil { + if commonBase.Region != nil && *commonBase.Region != "" { + genReq.SetRegion(*commonBase.Region) + } else { + genReq.SetRegion(ctx.DefaultRegion()) + } + if commonBase.Zone != nil && *commonBase.Zone != "" { + genReq.SetZone(*commonBase.Zone) + } else { + genReq.SetZone(ctx.DefaultZone()) + } + if commonBase.ProjectId != nil && *commonBase.ProjectId != "" { + genReq.SetProjectId(*commonBase.ProjectId) + } + } else { + genReq.SetRegion(ctx.DefaultRegion()) + genReq.SetZone(ctx.DefaultZone()) + } + + payload := map[string]interface{}{ + "ClusterInstanceId": instanceID, + } + genReq.SetPayload(payload) + + genResp, err := client.Client.GenericInvoke(genReq) + if err != nil { + return nil, err + } + + var resp DescribeUKafkaInstanceResponse + if err := genResp.Unmarshal(&resp); err != nil { + return nil, err + } + if len(resp.ClusterSet) == 0 { + return nil, fmt.Errorf("instance not found") + } + return &resp.ClusterSet[0], nil + } +} diff --git a/products/ukafka/internal/ukafka/resize_disk.go b/products/ukafka/internal/ukafka/resize_disk.go new file mode 100644 index 0000000000..baa9143323 --- /dev/null +++ b/products/ukafka/internal/ukafka/resize_disk.go @@ -0,0 +1,61 @@ +package ukafka + +import ( + "fmt" + + "github.com/spf13/cobra" + + ukafkasdk "github.com/ucloud/ucloud-sdk-go/services/ukafka" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newResizeDisk ucloud ukafka resize-disk +func newResizeDisk(ctx *cli.Context) *cobra.Command { + var async *bool + var instanceID *string + var diskSize *int + + client := cli.NewServiceClient(ctx, ukafkasdk.NewClient) + req := client.NewResizeUKafkaDiskRequest() + + cmd := &cobra.Command{ + Use: "resize-disk", + Short: "Resize UKafka instance disk", + Long: "Resize UKafka instance disk size", + Run: func(cmd *cobra.Command, args []string) { + w := ctx.ProgressWriter() + req.InstanceId = sdk.String(*instanceID) + req.DiskSize = sdk.Int(*diskSize) + + _, err := client.ResizeUKafkaDisk(req) + if err != nil { + ctx.HandleError(err) + return + } + + text := fmt.Sprintf("ukafka[%s] is resizing disk to %dGB", *instanceID, *diskSize) + if *async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeUKafkaInstanceByID(ctx)).Spoll(*instanceID, text, []string{StateRunning, StateAbnormal}) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: *instanceID, Action: "resize-disk", Status: "Running"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + instanceID = flags.String("ukafka-id", "", "Required. Instance ID") + diskSize = flags.Int("disk-size-gb", 0, "Required. Target disk size in GB") + req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id") + req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region") + req.Zone = flags.String("zone", ctx.DefaultZone(), "Optional. Assign availability zone") + async = flags.Bool("async", false, "Optional. Do not wait for operation to finish") + + cmd.MarkFlagRequired("ukafka-id") + cmd.MarkFlagRequired("disk-size-gb") + + return cmd +} diff --git a/products/ukafka/internal/ukafka/rows.go b/products/ukafka/internal/ukafka/rows.go new file mode 100644 index 0000000000..0333924e31 --- /dev/null +++ b/products/ukafka/internal/ukafka/rows.go @@ -0,0 +1,52 @@ +package ukafka + +// InstanceRow represents a UKafka instance in list output +type InstanceRow struct { + InstanceID string + InstanceName string + Framework string + Version string + Zone string + State string + NodeCount string + VPCId string + SubnetId string + ChargeType string + CreateTime string + ExpireTime string +} + +// NodeConfRow represents a node configuration in node-conf output +type NodeConfRow struct { + NodeType string + CPU string + Memory string + DiskType string + MinDiskSize string + MaxDiskSize string + SecGroup string +} + +// VersionRow represents a version in app-version output +type VersionRow struct { + Version string + Label string +} + +// ConsumerGroupRow represents a consumer group in list-consumers output +type ConsumerGroupRow struct { + GroupName string + Type string + NumOfTopics string + GroupID string +} + +// TopicRow represents a topic in list-topics output +type TopicRow struct { + Topic string + NumOfPartition string + NumOfReplica string + NumOfOccupyBroker string + UnderReplicasPer string + Status string +} diff --git a/products/ukafka/internal/ukafka/status.go b/products/ukafka/internal/ukafka/status.go new file mode 100644 index 0000000000..3df0605b4a --- /dev/null +++ b/products/ukafka/internal/ukafka/status.go @@ -0,0 +1,12 @@ +package ukafka + +// Instance state constants +const ( + StateRunning = "Running" + StateAbnormal = "Abnormal" + StateCreating = "Creating" + StateDeleting = "Deleting" + StateDeleted = "Deleted" + StateUpdating = "Updating" + StateDeploying = "Deploying" +) diff --git a/products/ukafka/product.go b/products/ukafka/product.go new file mode 100644 index 0000000000..a3a277b1f5 --- /dev/null +++ b/products/ukafka/product.go @@ -0,0 +1,21 @@ +package ukafka + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + internalukafka "github.com/ucloud/ucloud-cli/products/ukafka/internal/ukafka" +) + +type product struct{} + +// New returns the ukafka product (registered via hack/gen-products) +func New() cli.Product { return product{} } + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "ukafka", Commands: []string{"ukafka"}} +} + +func (product) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{internalukafka.NewCommand(ctx)} +} diff --git a/products/ukafka/product.yaml b/products/ukafka/product.yaml new file mode 100644 index 0000000000..fd1ae115d3 --- /dev/null +++ b/products/ukafka/product.yaml @@ -0,0 +1,7 @@ +# products/ukafka/product.yaml — ukafka 产品元数据 +name: ukafka +owners: + - zxl-wangwang +commands: + - ukafka +enabled: true diff --git a/products/ukafka/testdata/cmdtree.golden b/products/ukafka/testdata/cmdtree.golden new file mode 100644 index 0000000000..a3be5b9cb6 --- /dev/null +++ b/products/ukafka/testdata/cmdtree.golden @@ -0,0 +1,93 @@ +ucloud ukafka use=ukafka short=Manage UKafka instances +ucloud ukafka add-node use=add-node short=Add nodes to UKafka instance + flag=async short= default=false required= + flag=node-count short= default=1 required= + flag=node-type short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=ukafka-id short= default= required=true + flag=zone short= default= required= +ucloud ukafka app-version use=app-version short=List available Kafka versions + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud ukafka check-topic use=check-topic short=Check if a topic name exists in UKafka instance + flag=project-id short= default= required= + flag=region short= default= required= + flag=topic-name short= default= required=true + flag=ukafka-id short= default= required=true + flag=zone short= default= required= +ucloud ukafka create use=create short=Create UKafka instance + flag=async short= default=false required= + flag=business-id short= default= required= + flag=disk-controller-type short= default=NONE required= + flag=disk-size-gb short= default=0 required=true + flag=disk-threshold short= default=90 required= + flag=enable-security short= default=false required= + flag=kafka-version short= default= required=true + flag=log-retention-hours short= default=72 required= + flag=name short= default= required=true + flag=node-count short= default=3 required= + flag=node-type short= default= required=true + flag=project-id short= default= required= + flag=quantity short= default=1 required= + flag=region short= default= required= + flag=subnet-id short= default= required= + flag=vpc-id short= default= required= + flag=zone short= default= required= +ucloud ukafka delete use=delete short=Delete UKafka instances + flag=project-id short= default= required= + flag=region short= default= required= + flag=ukafka-id short= default=[] required=true + flag=yes short=y default=false required= + flag=zone short= default= required= +ucloud ukafka describe use=describe short=Describe UKafka instance details + flag=project-id short= default= required= + flag=region short= default= required= + flag=ukafka-id short= default= required=true + flag=zone short= default= required= +ucloud ukafka describe-consumer use=describe-consumer short=Describe Kafka consumer group details + flag=consumer-group short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=type short= default= required=true + flag=ukafka-id short= default= required=true + flag=zone short= default= required= +ucloud ukafka list use=list short=List UKafka instances + flag=business-id short= default= required= + flag=limit short= default=60 required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=subnet-id short= default= required= + flag=vpc-id short= default= required= + flag=zone short= default= required= +ucloud ukafka list-consumers use=list-consumers short=List Kafka consumer groups + flag=project-id short= default= required= + flag=region short= default= required= + flag=ukafka-id short= default= required=true + flag=zone short= default= required= +ucloud ukafka list-topics use=list-topics short=List Kafka topics in UKafka instance + flag=project-id short= default= required= + flag=region short= default= required= + flag=ukafka-id short= default= required=true + flag=zone short= default= required= +ucloud ukafka modify-type use=modify-type short=Modify UKafka instance type (CPU and memory) + flag=async short= default=false required= + flag=node-type short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=ukafka-id short= default= required=true + flag=zone short= default= required= +ucloud ukafka node-conf use=node-conf short=List available UKafka node configurations + flag=node-type short= default= required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud ukafka resize-disk use=resize-disk short=Resize UKafka instance disk + flag=async short= default=false required= + flag=disk-size-gb short= default=0 required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=ukafka-id short= default= required=true + flag=zone short= default= required= diff --git a/products/ukafka/testdata/completion.golden b/products/ukafka/testdata/completion.golden new file mode 100644 index 0000000000..5ebec444a9 --- /dev/null +++ b/products/ukafka/testdata/completion.golden @@ -0,0 +1,3 @@ +ucloud ukafka create project-id dynamic +ucloud ukafka create region dynamic +ucloud ukafka create zone dynamic diff --git a/products/umongodb/internal/umongodb/api.go b/products/umongodb/internal/umongodb/api.go new file mode 100644 index 0000000000..60504b3919 --- /dev/null +++ b/products/umongodb/internal/umongodb/api.go @@ -0,0 +1,39 @@ +package umongodb + +import ( + "fmt" + "time" + + "github.com/ucloud/ucloud-sdk-go/services/uaccount" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// defaultCallTimeout is the HTTP timeout for GenericInvoke calls. +// MongoDB creation APIs are slow; 5 minutes avoids premature timeouts. +const defaultCallTimeout = 5 * time.Minute + +// genericCall invokes a UMongoDB API via GenericInvoke and returns the +// raw payload map. All MongoDB commands use this instead of typed SDK methods. +func genericCall(ctx *cli.Context, action string, params map[string]interface{}) (map[string]interface{}, error) { + client := cli.NewServiceClient(ctx, uaccount.NewClient) + req := client.NewGenericRequest() + if err := req.SetPayload(params); err != nil { + return nil, fmt.Errorf("set payload for %s: %w", action, err) + } + req.WithTimeout(defaultCallTimeout) + resp, err := client.GenericInvoke(req) + if err != nil { + return nil, err + } + payload := resp.GetPayload() + retCode, _ := payload["RetCode"].(float64) + if retCode != 0 { + msg, _ := payload["Message"].(string) + if msg == "" { + msg = fmt.Sprintf("API %s returned RetCode %v", action, retCode) + } + return nil, fmt.Errorf("%s: %s", action, msg) + } + return payload, nil +} diff --git a/products/umongodb/internal/umongodb/cmd.go b/products/umongodb/internal/umongodb/cmd.go new file mode 100644 index 0000000000..75da68ed73 --- /dev/null +++ b/products/umongodb/internal/umongodb/cmd.go @@ -0,0 +1,31 @@ +package umongodb + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand assembles the umongodb command tree. +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: productName, + Short: "Manipulate MongoDB on UCloud platform", + Long: "Manipulate MongoDB on UCloud platform", + } + + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newListVersions(ctx)) + cmd.AddCommand(newListTemplates(ctx)) + cmd.AddCommand(newListMachineSpecs(ctx)) + cmd.AddCommand(newDescribe(ctx)) + cmd.AddCommand(newCreateReplset(ctx)) + cmd.AddCommand(newCreateSharded(ctx)) + cmd.AddCommand(newStart(ctx)) + cmd.AddCommand(newStop(ctx)) + cmd.AddCommand(newRestart(ctx)) + cmd.AddCommand(newDeleteReplset(ctx)) + cmd.AddCommand(newDeleteSharded(ctx)) + + return cmd +} diff --git a/products/umongodb/internal/umongodb/completion.go b/products/umongodb/internal/umongodb/completion.go new file mode 100644 index 0000000000..50fce56062 --- /dev/null +++ b/products/umongodb/internal/umongodb/completion.go @@ -0,0 +1,289 @@ +package umongodb + +import ( + "fmt" + "strings" + + "github.com/ucloud/ucloud-sdk-go/services/vpc" + "github.com/ucloud/ucloud-sdk-go/services/umongodb" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// --------------------------------------------------------------------------- +// Generic helpers +// --------------------------------------------------------------------------- + +// derefStr safely dereferences a *string bound by a flag, returning "" for nil. +func derefStr(p *string) string { + if p == nil { + return "" + } + return *p +} + +// stateAllowed reports whether state passes the optional allow-list. A nil +// allow-list means "any state". +func stateAllowed(state string, states []string) bool { + if states == nil { + return true + } + for _, s := range states { + if s == state { + return true + } + } + return false +} + +// --------------------------------------------------------------------------- +// MongoDB instance ID completion (ListUMongoDBInstances) — GenericInvoke +// SDK has no typed method for ListUMongoDBInstances. +// --------------------------------------------------------------------------- + +// getMongoDBIDList returns the resource-id completion candidates for the +// --umongodb-id flag, in the conventional "id/name" form. states restricts +// candidates to those whose State is in the set (nil = any state). +func getMongoDBIDList(ctx *cli.Context, states []string, region, zone, projectID string) []string { + params := map[string]interface{}{ + "Action": "ListUMongoDBInstances", + "Region": region, + } + if zone != "" { + params["Zone"] = zone + } + if projectID != "" { + params["ProjectId"] = projectID + } + payload, err := genericCall(ctx, "ListUMongoDBInstances", params) + if err != nil { + return nil + } + dataSet, ok := payload["DataSet"].([]interface{}) + if !ok { + return nil + } + candidates := make([]string, 0, len(dataSet)) + for _, item := range dataSet { + ins, ok := item.(map[string]interface{}) + if !ok { + continue + } + if states != nil { + // ListUMongoDBInstances has no state filter param; + // client-side filter if states provided. + st, _ := ins["State"].(string) + if !stateAllowed(st, states) { + continue + } + } + id, _ := ins["ClusterId"].(string) + name, _ := ins["Name"].(string) + candidates = append(candidates, fmt.Sprintf("%s/%s", id, name)) + } + return candidates +} + +// --------------------------------------------------------------------------- +// MongoDB version completion (ListUMongoDBVersion) — GenericInvoke +// SDK typed response lacks EngineType and DefaultDBVersion fields. +// --------------------------------------------------------------------------- + +// getMongoDBVersionList returns available MongoDB version strings via +// ListUMongoDBVersion. +func getMongoDBVersionList(ctx *cli.Context, region, zone string) []string { + params := map[string]interface{}{ + "Action": "ListUMongoDBVersion", + "Region": region, + "Zone": zone, + } + payload, err := genericCall(ctx, "ListUMongoDBVersion", params) + if err != nil { + return nil + } + dataSet, ok := payload["DataSet"].([]interface{}) + if !ok { + return nil + } + list := make([]string, 0, len(dataSet)) + for _, item := range dataSet { + v, ok := item.(map[string]interface{}) + if !ok { + continue + } + ver, _ := v["DBVersion"].(string) + if ver != "" { + list = append(list, ver) + } + } + return list +} + +// --------------------------------------------------------------------------- +// Config template completion and auto-default (ListUMongoDBConfigTemplate) +// Migrated to typed SDK. +// --------------------------------------------------------------------------- + +// getDefaultTemplateID fetches the default config template ID for a given +// MongoDB version and cluster type via typed ListUMongoDBConfigTemplate. +func getDefaultTemplateID(ctx *cli.Context, dbVersion, clusterType, project, region string) (string, error) { + client := cli.NewServiceClient(ctx, umongodb.NewClient) + req := client.NewListUMongoDBConfigTemplateRequest() + req.Region = ®ion + if project != "" { + req.ProjectId = &project + } + resp, err := client.ListUMongoDBConfigTemplate(req) + if err != nil { + return "", err + } + if len(resp.DataSet) == 0 { + return "", fmt.Errorf("no config template found for version %s / type %s in %s", dbVersion, clusterType, region) + } + for _, t := range resp.DataSet { + if t.MongodbVersion == dbVersion && t.ClusterType == clusterType && t.TemplateType == "DefaultTemplate" { + if t.TemplateId != "" { + return t.TemplateId, nil + } + } + } + return "", fmt.Errorf("no default config template found for version %s / type %s in %s", dbVersion, clusterType, region) +} + +// getMongoDBTemplateList returns config template candidates as "id/name" strings +// via typed ListUMongoDBConfigTemplate. +func getMongoDBTemplateList(ctx *cli.Context, dbVersion, project, region string) []string { + client := cli.NewServiceClient(ctx, umongodb.NewClient) + req := client.NewListUMongoDBConfigTemplateRequest() + req.Region = ®ion + if project != "" { + req.ProjectId = &project + } + resp, err := client.ListUMongoDBConfigTemplate(req) + if err != nil { + return nil + } + var list []string + for _, t := range resp.DataSet { + // Filter by version if specified + if dbVersion != "" { + if !strings.EqualFold(t.MongodbVersion, dbVersion) { + continue + } + } + if t.TemplateId != "" { + list = append(list, fmt.Sprintf("%s/%s", t.TemplateId, t.TemplateName)) + } + } + return list +} + +// --------------------------------------------------------------------------- +// Machine spec completion (ListUMongoDBMachineSpec) — GenericInvoke +// SDK has no typed method for ListUMongoDBMachineSpec. +// --------------------------------------------------------------------------- + +// getMongoDBMachineSpecList returns machine type candidates from +// ListUMongoDBMachineSpec, flattened from nested ComputeType arrays. +func getMongoDBMachineSpecList(ctx *cli.Context, region, zone string) []string { + params := map[string]interface{}{ + "Action": "ListUMongoDBMachineSpec", + "Region": region, + "Zone": zone, + } + payload, err := genericCall(ctx, "ListUMongoDBMachineSpec", params) + if err != nil { + return nil + } + dataSet, ok := payload["DataSet"].([]interface{}) + if !ok { + return nil + } + var list []string + for _, item := range dataSet { + spec, ok := item.(map[string]interface{}) + if !ok { + continue + } + ct, ok := spec["ComputeType"].([]interface{}) + if !ok { + continue + } + for _, c := range ct { + m, ok := c.(map[string]interface{}) + if !ok { + continue + } + id, _ := m["MachineTypeId"].(string) + desc, _ := m["Description"].(string) + if id != "" { + list = append(list, fmt.Sprintf("%s/%s", id, desc)) + } + } + } + return list +} + +// --------------------------------------------------------------------------- +// VPC / Subnet completion (copied per boundary rule, products must be self-contained) +// --------------------------------------------------------------------------- + +func getAllVPCIns(ctx *cli.Context, project, region string) ([]vpc.VPCInfo, error) { + client := cli.NewServiceClient(ctx, vpc.NewClient) + req := client.NewDescribeVPCRequest() + req.ProjectId = &project + req.Region = ®ion + resp, err := client.DescribeVPC(req) + if err != nil { + return nil, err + } + return resp.DataSet, nil +} + +func getAllVPCIdNames(ctx *cli.Context, project, region string) []string { + vpcInsList, err := getAllVPCIns(ctx, project, region) + if err != nil { + return nil + } + list := make([]string, 0, len(vpcInsList)) + for _, v := range vpcInsList { + list = append(list, fmt.Sprintf("%s/%s", v.VPCId, v.Name)) + } + return list +} + +func getAllSubnets(ctx *cli.Context, vpcID, project, region string) ([]vpc.SubnetInfo, error) { + client := cli.NewServiceClient(ctx, vpc.NewClient) + req := client.NewDescribeSubnetRequest() + req.ProjectId = &project + req.Region = ®ion + if vpcID != "" { + req.VPCId = &vpcID + } + subnets := []vpc.SubnetInfo{} + for limit, offset := 50, 0; ; offset += limit { + req.Limit = &limit + req.Offset = &offset + resp, err := client.DescribeSubnet(req) + if err != nil { + return nil, err + } + subnets = append(subnets, resp.DataSet...) + if limit+offset >= resp.TotalCount { + break + } + } + return subnets, nil +} + +func getAllSubnetIDNames(ctx *cli.Context, vpcID, project, region string) []string { + subnets, err := getAllSubnets(ctx, vpcID, project, region) + if err != nil { + return nil + } + list := make([]string, 0, len(subnets)) + for _, s := range subnets { + list = append(list, fmt.Sprintf("%s/%s", s.SubnetId, s.SubnetName)) + } + return list +} diff --git a/products/umongodb/internal/umongodb/const.go b/products/umongodb/internal/umongodb/const.go new file mode 100644 index 0000000000..b310b00914 --- /dev/null +++ b/products/umongodb/internal/umongodb/const.go @@ -0,0 +1,9 @@ +package umongodb + +// productName is the single source of truth for the umongodb command name +// and its resource-id flag (--umongodb-id). +const productName = "umongodb" + +// resourceIDFlag is the resource-id flag, named after the product per the +// onboarding contract. +const resourceIDFlag = productName + "-id" // "umongodb-id" diff --git a/products/umongodb/internal/umongodb/create_replset.go b/products/umongodb/internal/umongodb/create_replset.go new file mode 100644 index 0000000000..98aa064f61 --- /dev/null +++ b/products/umongodb/internal/umongodb/create_replset.go @@ -0,0 +1,145 @@ +package umongodb + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newCreateReplset implements `umongodb create-replset`. +// Uses GenericInvoke because the typed CreateUMongoDBReplSetRequest +// lacks the TemplateId field required by the API. +func newCreateReplset(ctx *cli.Context) *cobra.Command { + var common request.CommonBase + + var name, password, version string + var diskSpaceGB, nodeCount int + var machineTypeID string + var port int + var templateID string + var vpcID, subnetID, tag string + var chargeType string + var quantity int + + cmd := &cobra.Command{ + Use: "create-replset", + Short: "Create a MongoDB replica set", + Long: "Create a MongoDB replica set asynchronously. Use 'umongodb list' to check creation status.", + Run: func(c *cobra.Command, args []string) { + region := common.GetRegion() + zone := common.GetZone() + projectID := common.GetProjectId() + + // Auto-default template ID if not specified + if templateID == "" { + id, err := getDefaultTemplateID(ctx, version, "ReplicaSet", projectID, region) + if err != nil { + ctx.HandleError(err) + return + } + templateID = id + } + + params := map[string]interface{}{ + "Action": "CreateUMongoDBReplSet", + "Region": region, + "Zone": zone, + "Name": name, + "AdminPassword": password, + "DBVersion": version, + "DiskSpace": diskSpaceGB, + "MachineTypeId": machineTypeID, + "NodeCount": nodeCount, + "TemplateId": templateID, + } + if projectID != "" { + params["ProjectId"] = projectID + } + + // Optional params — only set when explicitly changed + if c.Flags().Changed("port") { + params["ListenPort"] = port + } + if c.Flags().Changed("vpc-id") { + params["VPCId"] = vpcID + } + if c.Flags().Changed("subnet-id") { + params["SubnetId"] = subnetID + } + if c.Flags().Changed("tag") { + params["Tag"] = tag + } + if c.Flags().Changed("charge-type") { + params["ChargeType"] = chargeType + } + if c.Flags().Changed("quantity") { + params["Quantity"] = quantity + } + + if _, err := genericCall(ctx, "CreateUMongoDBReplSet", params); err != nil { + ctx.HandleError(err) + return + } + + w := ctx.ProgressWriter() + fmt.Fprintln(w, fmt.Sprintf("%s is creating (use 'umongodb list' to check status)", name)) + ctx.EmitResult(cli.OpResultRow{ResourceID: name, Action: "create", Status: "Creating"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + // Required flags + flags.StringVar(&name, "name", "", "Required. Instance name, at least 6 characters.") + flags.StringVar(&password, "password", "", "Required. Admin password.") + flags.StringVar(&version, "version", "", "Required. MongoDB version, e.g. \"MongoDB 6.0\".") + flags.IntVar(&diskSpaceGB, "disk-space-gb", 0, "Required. Disk space in GB (20-32000, multiples of 10).") + flags.StringVar(&machineTypeID, "machine-type-id", "", "Required. Machine type ID, e.g. o.mongo2m.medium.") + flags.IntVar(&nodeCount, "node-count", 3, "Node count (3, 5, or 7).") + + // Optional flags + flags.IntVar(&port, "port", 27017, "Optional. Service port.") + flags.StringVar(&templateID, "template-id", "", "Optional. Config template ID. Auto-fetched if omitted.") + flags.StringVar(&vpcID, "vpc-id", "", "Optional. VPC ID. See 'ucloud vpc list'.") + flags.StringVar(&subnetID, "subnet-id", "", "Optional. Subnet ID. See 'ucloud subnet list'.") + flags.StringVar(&tag, "tag", "", "Optional. Business group name.") + flags.StringVar(&chargeType, "charge-type", "Month", "Optional. Charge type: Year / Month / Dynamic / Trial.") + flags.IntVar(&quantity, "quantity", 1, "Optional. Purchase duration in months.") + + ctx.BindRegion(cmd, &common) + ctx.BindZone(cmd, &common) + ctx.BindProjectID(cmd, &common) + + // Completions + command.SetCompletion(cmd, "version", func() []string { + return getMongoDBVersionList(ctx, common.GetRegion(), common.GetZone()) + }) + command.SetCompletion(cmd, "machine-type-id", func() []string { + return getMongoDBMachineSpecList(ctx, common.GetRegion(), common.GetZone()) + }) + command.SetCompletion(cmd, "template-id", func() []string { + return getMongoDBTemplateList(ctx, version, common.GetProjectId(), common.GetRegion()) + }) + command.SetCompletion(cmd, "vpc-id", func() []string { + return getAllVPCIdNames(ctx, common.GetProjectId(), common.GetRegion()) + }) + command.SetCompletion(cmd, "subnet-id", func() []string { + return getAllSubnetIDNames(ctx, vpcID, common.GetProjectId(), common.GetRegion()) + }) + command.SetFlagValues(cmd, "charge-type", "Year", "Month", "Dynamic", "Trial") + command.SetFlagValues(cmd, "node-count", "3", "5", "7") + + cmd.MarkFlagRequired("name") + cmd.MarkFlagRequired("password") + cmd.MarkFlagRequired("version") + cmd.MarkFlagRequired("disk-space-gb") + cmd.MarkFlagRequired("machine-type-id") + + return cmd +} diff --git a/products/umongodb/internal/umongodb/create_sharded.go b/products/umongodb/internal/umongodb/create_sharded.go new file mode 100644 index 0000000000..79fa53ba5b --- /dev/null +++ b/products/umongodb/internal/umongodb/create_sharded.go @@ -0,0 +1,151 @@ +package umongodb + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/umongodb" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newCreateSharded implements `umongodb create-sharded`. +func newCreateSharded(ctx *cli.Context) *cobra.Command { + + var name, password, version string + var shardCount, nodeCount, diskSpaceGB int + var machineTypeID string + var mongosNodeCount int + var mongosMachineTypeID string + var port int + var templateID string + var vpcID, subnetID, tag string + var chargeType string + var quantity int + + client := cli.NewServiceClient(ctx, umongodb.NewClient) + req := client.NewCreateUMongoDBShardedClusterRequest() + + cmd := &cobra.Command{ + Use: "create-sharded", + Short: "Create a MongoDB sharded cluster", + Long: "Create a MongoDB sharded cluster asynchronously. Use 'umongodb list' to check creation status.", + Run: func(c *cobra.Command, args []string) { + // Auto-default template ID if not specified + if templateID == "" { + id, err := getDefaultTemplateID(ctx, version, "SharedCluster", *req.ProjectId, *req.Region) + if err != nil { + ctx.HandleError(err) + return + } + templateID = id + } + + req.Name = &name + req.AdminPassword = &password + req.DBVersion = &version + req.ShardCount = &shardCount + req.NodeCount = &nodeCount + req.DiskSpace = &diskSpaceGB + req.MachineTypeId = &machineTypeID + req.TemplateId = &templateID + + // Optional params — only set when explicitly changed + if c.Flags().Changed("mongos-node-count") { + req.MongosNodeCount = &mongosNodeCount + } + if c.Flags().Changed("mongos-machine-type-id") { + req.MongosMachineTypeId = &mongosMachineTypeID + } + if c.Flags().Changed("port") { + req.ListenPort = &port + } + if c.Flags().Changed("vpc-id") { + req.VPCId = &vpcID + } + if c.Flags().Changed("subnet-id") { + req.SubnetId = &subnetID + } + if c.Flags().Changed("tag") { + req.Tag = &tag + } + if c.Flags().Changed("charge-type") { + req.ChargeType = &chargeType + } + if c.Flags().Changed("quantity") { + req.Quantity = &quantity + } + + // MongoDB creation is slow; use 5-minute timeout + req.WithTimeout(5 * time.Minute) + + _, err := client.CreateUMongoDBShardedCluster(req) + if err != nil { + ctx.HandleError(err) + return + } + + w := ctx.ProgressWriter() + fmt.Fprintln(w, fmt.Sprintf("%s is creating (use 'umongodb list' to check status)", name)) + ctx.EmitResult(cli.OpResultRow{ResourceID: name, Action: "create", Status: "Creating"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + // Required flags + flags.StringVar(&name, "name", "", "Required. Instance name, at least 6 characters.") + flags.StringVar(&password, "password", "", "Required. Admin password.") + flags.StringVar(&version, "version", "", "Required. MongoDB version, e.g. \"MongoDB 6.0\".") + flags.IntVar(&shardCount, "shard-count", 0, "Required. Number of shards.") + flags.IntVar(&nodeCount, "node-count", 0, "Required. Number of nodes per shard.") + flags.IntVar(&diskSpaceGB, "disk-space-gb", 0, "Required. Data node disk space in GB (20-32000, multiples of 10).") + flags.StringVar(&machineTypeID, "machine-type-id", "", "Required. Data node machine type ID, e.g. o.mongo2m.medium.") + + // Optional flags + flags.IntVar(&mongosNodeCount, "mongos-node-count", 0, "Optional. Mongos node count.") + flags.StringVar(&mongosMachineTypeID, "mongos-machine-type-id", "", "Optional. Mongos node machine type ID.") + flags.IntVar(&port, "port", 27017, "Optional. Service port.") + flags.StringVar(&templateID, "template-id", "", "Optional. Config template ID. Auto-fetched if omitted.") + flags.StringVar(&vpcID, "vpc-id", "", "Optional. VPC ID. See 'ucloud vpc list'.") + flags.StringVar(&subnetID, "subnet-id", "", "Optional. Subnet ID. See 'ucloud subnet list'.") + flags.StringVar(&tag, "tag", "", "Optional. Business group name.") + flags.StringVar(&chargeType, "charge-type", "Month", "Optional. Charge type: Year / Month / Dynamic / Trial.") + flags.IntVar(&quantity, "quantity", 1, "Optional. Purchase duration in months.") + + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + // Completions + command.SetCompletion(cmd, "version", func() []string { + return getMongoDBVersionList(ctx, *req.Region, *req.Zone) + }) + command.SetCompletion(cmd, "machine-type-id", func() []string { + return getMongoDBMachineSpecList(ctx, *req.Region, *req.Zone) + }) + command.SetCompletion(cmd, "template-id", func() []string { + return getMongoDBTemplateList(ctx, version, *req.ProjectId, *req.Region) + }) + command.SetCompletion(cmd, "vpc-id", func() []string { + return getAllVPCIdNames(ctx, *req.ProjectId, *req.Region) + }) + command.SetCompletion(cmd, "subnet-id", func() []string { + return getAllSubnetIDNames(ctx, vpcID, *req.ProjectId, *req.Region) + }) + command.SetFlagValues(cmd, "charge-type", "Year", "Month", "Dynamic", "Trial") + + cmd.MarkFlagRequired("name") + cmd.MarkFlagRequired("password") + cmd.MarkFlagRequired("version") + cmd.MarkFlagRequired("shard-count") + cmd.MarkFlagRequired("node-count") + cmd.MarkFlagRequired("disk-space-gb") + cmd.MarkFlagRequired("machine-type-id") + + return cmd +} diff --git a/products/umongodb/internal/umongodb/delete.go b/products/umongodb/internal/umongodb/delete.go new file mode 100644 index 0000000000..9156fc47d7 --- /dev/null +++ b/products/umongodb/internal/umongodb/delete.go @@ -0,0 +1,141 @@ +package umongodb + +import ( + "fmt" + + "github.com/spf13/cobra" + + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-sdk-go/services/umongodb" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// deleteOpts captures the configuration that differs between delete-replset +// and delete-sharded so the shared newDeleteCmd can handle both. +type deleteOpts struct { + use string + short string + long string + action string // GenericInvoke action name + idParam string // GenericInvoke cluster ID param name ("ClusterId" or "ShardedClusterId") + idFlagDesc string // help text for the --umongodb-id flag +} + +// newDeleteCmd returns a cobra.Command that stops (optionally) and then +// deletes MongoDB clusters via GenericInvoke. The stop step uses the typed +// SDK; the delete step uses GenericInvoke because the SDK has no typed +// delete methods. +func newDeleteCmd(ctx *cli.Context, opts deleteOpts) *cobra.Command { + var async bool + var skipStop bool + var yes bool + var ids []string + + stopClient := cli.NewServiceClient(ctx, umongodb.NewClient) + stopReq := stopClient.NewStopUMongoDBClusterRequest() + + var common request.CommonBase + + cmd := &cobra.Command{ + Use: opts.use, + Short: opts.short, + Long: opts.long, + Run: func(c *cobra.Command, args []string) { + // Confirm before destructive operation + ok, err := ctx.Confirm(yes, "Are you sure you want to delete the umongodb cluster(s)?") + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + + region := common.GetRegion() + zone := common.GetZone() + projectID := common.GetProjectId() + + // Set loop-invariant stop fields once + if !skipStop { + stopReq.Region = ®ion + if zone != "" { + stopReq.Zone = &zone + } + if projectID != "" { + stopReq.ProjectId = &projectID + } + } + + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, idName := range ids { + id := ctx.PickResourceID(idName) + + // Step 1: Stop the cluster (skip when --skip-stop is set) + if !skipStop { + stopReq.ClusterId = sdk.String(id) + _, err := stopClient.StopUMongoDBCluster(stopReq) + if err != nil { + ctx.HandleError(fmt.Errorf("stop %s before delete: %w", id, err)) + continue + } + + // Always poll for Stopped before deleting, even in async mode. + // Only the final delete-step polling is skipped when --async is set. + text := fmt.Sprintf("%s[%s] is stopping before delete", productName, id) + ctx.PollerTo(w, describeByID(ctx, region, zone)).Spoll(id, text, []string{stateStopped, stateFail}) + } + + // Step 2: Delete the cluster via GenericInvoke + params := map[string]interface{}{ + "Action": opts.action, + "Region": region, + opts.idParam: id, + } + if zone != "" { + params["Zone"] = zone + } + if projectID != "" { + params["ProjectId"] = projectID + } + + if _, err := genericCall(ctx, opts.action, params); err != nil { + ctx.HandleError(fmt.Errorf("delete %s: %w", id, err)) + continue + } + + text := fmt.Sprintf("%s[%s] is deleting", productName, id) + if async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeByID(ctx, region, zone)).Spoll(id, text, []string{stateFail}) + } + results = append(results, cli.OpResultRow{ResourceID: id, Action: "delete", Status: "Deleting"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&ids, resourceIDFlag, nil, "Required. "+opts.idFlagDesc) + flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the operation to finish.") + flags.BoolVar(&skipStop, "skip-stop", false, "Optional. Skip the stop-before-delete step.") + flags.BoolVarP(&yes, "yes", "y", false, "Optional. Do not prompt for confirmation.") + + ctx.BindRegion(cmd, &common) + ctx.BindZone(cmd, &common) + ctx.BindProjectID(cmd, &common) + + cmd.MarkFlagRequired(resourceIDFlag) + command.SetCompletion(cmd, resourceIDFlag, func() []string { + return getMongoDBIDList(ctx, nil, common.GetRegion(), common.GetZone(), common.GetProjectId()) + }) + + return cmd +} diff --git a/products/umongodb/internal/umongodb/delete_replset.go b/products/umongodb/internal/umongodb/delete_replset.go new file mode 100644 index 0000000000..94ea789098 --- /dev/null +++ b/products/umongodb/internal/umongodb/delete_replset.go @@ -0,0 +1,19 @@ +package umongodb + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newDeleteReplset implements `umongodb delete-replset`. +func newDeleteReplset(ctx *cli.Context) *cobra.Command { + return newDeleteCmd(ctx, deleteOpts{ + use: "delete-replset", + short: "Delete MongoDB replica set instances", + long: "Delete one or more MongoDB replica set instances. The cluster is stopped before deletion unless --skip-stop is set.", + action: "DeleteUMongoDBReplSet", + idParam: "ClusterId", + idFlagDesc: "Cluster ID(s) of replica set instances to delete.", + }) +} diff --git a/products/umongodb/internal/umongodb/delete_sharded.go b/products/umongodb/internal/umongodb/delete_sharded.go new file mode 100644 index 0000000000..c316f35113 --- /dev/null +++ b/products/umongodb/internal/umongodb/delete_sharded.go @@ -0,0 +1,19 @@ +package umongodb + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newDeleteSharded implements `umongodb delete-sharded`. +func newDeleteSharded(ctx *cli.Context) *cobra.Command { + return newDeleteCmd(ctx, deleteOpts{ + use: "delete-sharded", + short: "Delete MongoDB sharded cluster instances", + long: "Delete one or more MongoDB sharded cluster instances. The cluster is stopped before deletion unless --skip-stop is set.", + action: "DeleteUMongoDBShardedCluster", + idParam: "ShardedClusterId", + idFlagDesc: "Cluster ID(s) of sharded cluster instances to delete.", + }) +} diff --git a/products/umongodb/internal/umongodb/describe.go b/products/umongodb/internal/umongodb/describe.go new file mode 100644 index 0000000000..101d5cc0c1 --- /dev/null +++ b/products/umongodb/internal/umongodb/describe.go @@ -0,0 +1,87 @@ +package umongodb + +import ( + "strconv" + "time" + + "github.com/spf13/cobra" + + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-sdk-go/services/umongodb" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newDescribe implements `umongodb describe`. +func newDescribe(ctx *cli.Context) *cobra.Command { + var clusterID string + + client := cli.NewServiceClient(ctx, umongodb.NewClient) + req := client.NewDescribeUMongoDBInstanceRequest() + + cmd := &cobra.Command{ + Use: "describe", + Short: "Show details of one MongoDB instance", + Long: "Show the full attribute/value detail of a single MongoDB instance.", + Run: func(c *cobra.Command, args []string) { + req.ClusterId = sdk.String(ctx.PickResourceID(clusterID)) + + resp, err := client.DescribeUMongoDBInstance(req) + if err != nil { + ctx.HandleError(err) + return + } + + ci := resp.ClusterInfo + rows := []cli.DescribeRow{ + {Attribute: "ClusterId", Content: ci.ClusterId}, + {Attribute: "Name", Content: ci.InstanceName}, + {Attribute: "ClusterType", Content: ci.ClusterType}, + {Attribute: "State", Content: ci.State}, + {Attribute: "DBVersion", Content: ci.DBVersion}, + {Attribute: "ConnectURL", Content: ci.ConnectURL}, + {Attribute: "VPCId", Content: ci.VPCId}, + {Attribute: "SubnetId", Content: ci.SubnetId}, + {Attribute: "Tag", Content: ci.Tag}, + {Attribute: "DiskSpace(GB)", Content: strconv.Itoa(ci.DiskSpace)}, + {Attribute: "MachineType", Content: ci.MachineTypeId}, + } + + // Shard info (for sharded clusters) + if ci.ShardCount > 0 { + rows = append(rows, cli.DescribeRow{Attribute: "ShardCount", Content: strconv.Itoa(ci.ShardCount)}) + } + if ci.ShardNodeCount > 0 { + rows = append(rows, cli.DescribeRow{Attribute: "ShardNodeCount", Content: strconv.Itoa(ci.ShardNodeCount)}) + } + if ci.MongosCount > 0 { + rows = append(rows, cli.DescribeRow{Attribute: "MongosCount", Content: strconv.Itoa(ci.MongosCount)}) + } + + // CreateTime + if ci.CreateTime > 0 { + rows = append(rows, cli.DescribeRow{Attribute: "CreateTime", Content: time.Unix(int64(ci.CreateTime), 0).Format("2006-01-02 15:04:05")}) + } + + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVar(&clusterID, resourceIDFlag, "", "Required. Cluster ID of the MongoDB instance to describe.") + + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired(resourceIDFlag) + command.SetCompletion(cmd, resourceIDFlag, func() []string { + return getMongoDBIDList(ctx, nil, *req.Region, *req.Zone, *req.ProjectId) + }) + + return cmd +} diff --git a/products/umongodb/internal/umongodb/list.go b/products/umongodb/internal/umongodb/list.go new file mode 100644 index 0000000000..4a105008a8 --- /dev/null +++ b/products/umongodb/internal/umongodb/list.go @@ -0,0 +1,92 @@ +package umongodb + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newList implements `umongodb list`. +func newList(ctx *cli.Context) *cobra.Command { + var common request.CommonBase + + cmd := &cobra.Command{ + Use: "list", + Short: "List MongoDB instances", + Long: "List MongoDB instances in the active region/zone/project.", + Run: func(c *cobra.Command, args []string) { + params := map[string]interface{}{ + "Action": "ListUMongoDBInstances", + "Region": common.GetRegion(), + } + if zone := common.GetZone(); zone != "" { + params["Zone"] = zone + } + if projectID := common.GetProjectId(); projectID != "" { + params["ProjectId"] = projectID + } + + payload, err := genericCall(ctx, "ListUMongoDBInstances", params) + if err != nil { + ctx.HandleError(err) + return + } + + dataSet, ok := payload["DataSet"].([]interface{}) + if !ok { + // No instances in this region — return empty list, not an error. + ctx.PrintList([]instanceRow{}) + return + } + + rows := make([]instanceRow, 0, len(dataSet)) + for _, item := range dataSet { + ins, ok := item.(map[string]interface{}) + if !ok { + continue + } + row := instanceRow{ + ResourceID: strVal(ins, "ClusterId"), + Name: strVal(ins, "Name"), + Version: strVal(ins, "DBVersion"), + Status: strVal(ins, "State"), + } + row.ClusterType = strVal(ins, "ClusterType") + row.ConnectURL = strVal(ins, "ConnectURL") + + // DiskSpace + if v, ok := ins["DiskSpace"].(float64); ok { + row.DiskGB = int(v) + } + + // Machine type from nested DataComputeType + if dct, ok := ins["DataComputeType"].(map[string]interface{}); ok { + row.MachineType = strVal(dct, "Description") + } + + rows = append(rows, row) + } + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, &common) + ctx.BindZone(cmd, &common) + ctx.BindProjectID(cmd, &common) + + return cmd +} + +// strVal extracts a string value from a map, returning "" if missing or wrong type. +func strVal(m map[string]interface{}, key string) string { + v, ok := m[key].(string) + if !ok { + return "" + } + return v +} diff --git a/products/umongodb/internal/umongodb/list_machine_specs.go b/products/umongodb/internal/umongodb/list_machine_specs.go new file mode 100644 index 0000000000..b41906b19d --- /dev/null +++ b/products/umongodb/internal/umongodb/list_machine_specs.go @@ -0,0 +1,120 @@ +package umongodb + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newListMachineSpecs implements `umongodb list-machine-specs`. +func newListMachineSpecs(ctx *cli.Context) *cobra.Command { + var common request.CommonBase + var classTypeFilter string + + type specRow struct { + MachineTypeId string + Description string + Cpu int + MemoryGB int + ClassType string + DiskTypes string + } + + cmd := &cobra.Command{ + Use: "list-machine-specs", + Short: "List MongoDB machine specifications", + Long: "List available MongoDB machine types grouped by class type, including supported disk types.", + Run: func(c *cobra.Command, args []string) { + params := map[string]interface{}{ + "Action": "ListUMongoDBMachineSpec", + "Region": common.GetRegion(), + "Zone": common.GetZone(), + } + if projectID := common.GetProjectId(); projectID != "" { + params["ProjectId"] = projectID + } + if classTypeFilter != "" { + params["ClassType"] = classTypeFilter + } + + payload, err := genericCall(ctx, "ListUMongoDBMachineSpec", params) + if err != nil { + ctx.HandleError(err) + return + } + + dataSet, ok := payload["DataSet"].([]interface{}) + if !ok { + // No machine specs — return empty list, not an error. + ctx.PrintList([]specRow{}) + return + } + + rows := make([]specRow, 0) + for _, item := range dataSet { + spec, ok := item.(map[string]interface{}) + if !ok { + continue + } + classType, _ := spec["ClassType"].(string) + + // Flatten disk types + diskTypes := "" + if dt, ok := spec["DiskType"].([]interface{}); ok { + parts := make([]string, 0, len(dt)) + for _, d := range dt { + if s, ok := d.(string); ok { + parts = append(parts, s) + } + } + diskTypes = fmt.Sprintf("[%s]", strings.Join(parts, ", ")) + } + + // Flatten compute types + if ct, ok := spec["ComputeType"].([]interface{}); ok { + for _, c := range ct { + m, ok := c.(map[string]interface{}) + if !ok { + continue + } + id, _ := m["MachineTypeId"].(string) + desc, _ := m["Description"].(string) + var cpu, mem int + if v, ok := m["Cpu"].(float64); ok { + cpu = int(v) + } + if v, ok := m["Memory"].(float64); ok { + mem = int(v) + } + rows = append(rows, specRow{ + MachineTypeId: id, + Description: desc, + Cpu: cpu, + MemoryGB: mem, + ClassType: classType, + DiskTypes: diskTypes, + }) + } + } + } + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVar(&classTypeFilter, "class-type", "", "Optional. Filter by class type: O or N.") + + ctx.BindRegion(cmd, &common) + ctx.BindZone(cmd, &common) + ctx.BindProjectID(cmd, &common) + + return cmd +} + diff --git a/products/umongodb/internal/umongodb/list_templates.go b/products/umongodb/internal/umongodb/list_templates.go new file mode 100644 index 0000000000..d284a96298 --- /dev/null +++ b/products/umongodb/internal/umongodb/list_templates.go @@ -0,0 +1,71 @@ +package umongodb + +import ( + "strings" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/umongodb" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newListTemplates implements `umongodb list-templates`. +func newListTemplates(ctx *cli.Context) *cobra.Command { + var versionFilter, clusterTypeFilter string + + type templateRow struct { + TemplateId string + Name string + MongodbVersion string + ClusterType string + TemplateType string + } + + client := cli.NewServiceClient(ctx, umongodb.NewClient) + req := client.NewListUMongoDBConfigTemplateRequest() + + cmd := &cobra.Command{ + Use: "list-templates", + Short: "List MongoDB config templates", + Long: "List MongoDB config templates for the current region.", + Run: func(c *cobra.Command, args []string) { + resp, err := client.ListUMongoDBConfigTemplate(req) + if err != nil { + ctx.HandleError(err) + return + } + + rows := make([]templateRow, 0, len(resp.DataSet)) + for _, t := range resp.DataSet { + // Apply filters + if versionFilter != "" && !strings.EqualFold(t.MongodbVersion, versionFilter) { + continue + } + if clusterTypeFilter != "" && !strings.EqualFold(t.ClusterType, clusterTypeFilter) { + continue + } + + rows = append(rows, templateRow{ + TemplateId: t.TemplateId, + Name: t.TemplateName, + MongodbVersion: t.MongodbVersion, + ClusterType: t.ClusterType, + TemplateType: t.TemplateType, + }) + } + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVar(&versionFilter, "version", "", "Optional. Filter by MongoDB version, e.g. \"MongoDB 6.0\".") + flags.StringVar(&clusterTypeFilter, "cluster-type", "", "Optional. Filter by cluster type: ReplicaSet or SharedCluster.") + + ctx.BindRegion(cmd, req) + ctx.BindProjectID(cmd, req) + + return cmd +} diff --git a/products/umongodb/internal/umongodb/list_versions.go b/products/umongodb/internal/umongodb/list_versions.go new file mode 100644 index 0000000000..991da77c80 --- /dev/null +++ b/products/umongodb/internal/umongodb/list_versions.go @@ -0,0 +1,80 @@ +package umongodb + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newListVersions implements `umongodb list-versions`. +func newListVersions(ctx *cli.Context) *cobra.Command { + var common request.CommonBase + + type versionRow struct { + Version string + EngineType string + } + + cmd := &cobra.Command{ + Use: "list-versions", + Short: "List available MongoDB versions", + Long: "List MongoDB versions supported in the current region/zone.", + Run: func(c *cobra.Command, args []string) { + params := map[string]interface{}{ + "Action": "ListUMongoDBVersion", + "Region": common.GetRegion(), + "Zone": common.GetZone(), + } + if projectID := common.GetProjectId(); projectID != "" { + params["ProjectId"] = projectID + } + + payload, err := genericCall(ctx, "ListUMongoDBVersion", params) + if err != nil { + ctx.HandleError(err) + return + } + + dataSet, ok := payload["DataSet"].([]interface{}) + if !ok { + // No versions — return empty list, not an error. + ctx.PrintList([]versionRow{}) + return + } + + // Get default version + defaultVer := "" + if dv, ok := payload["DefaultDBVersion"].(map[string]interface{}); ok { + defaultVer, _ = dv["DBVersion"].(string) + } + + rows := make([]versionRow, 0, len(dataSet)) + for _, item := range dataSet { + v, ok := item.(map[string]interface{}) + if !ok { + continue + } + ver, _ := v["DBVersion"].(string) + eng, _ := v["EngineType"].(string) + if ver != "" { + if ver == defaultVer { + eng += " (default)" + } + rows = append(rows, versionRow{Version: ver, EngineType: eng}) + } + } + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, &common) + ctx.BindZone(cmd, &common) + ctx.BindProjectID(cmd, &common) + + return cmd +} diff --git a/products/umongodb/internal/umongodb/poll.go b/products/umongodb/internal/umongodb/poll.go new file mode 100644 index 0000000000..974e344190 --- /dev/null +++ b/products/umongodb/internal/umongodb/poll.go @@ -0,0 +1,29 @@ +package umongodb + +import ( + "github.com/ucloud/ucloud-sdk-go/services/umongodb" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// describeByID returns the Poller describe func: given a ClusterId it fetches +// the current cluster state via typed DescribeUMongoDBInstance. +// region/zone are captured at call time because the Poller always passes nil +// for CommonBase (see image/internal/image/describe.go for the same pattern). +func describeByID(ctx *cli.Context, region, zone string) func(string, *request.CommonBase) (interface{}, error) { + return func(id string, _ *request.CommonBase) (interface{}, error) { + client := cli.NewServiceClient(ctx, umongodb.NewClient) + req := client.NewDescribeUMongoDBInstanceRequest() + req.Region = ®ion + if zone != "" { + req.Zone = &zone + } + req.ClusterId = &id + resp, err := client.DescribeUMongoDBInstance(req) + if err != nil { + return nil, err + } + return &resp.ClusterInfo, nil + } +} diff --git a/products/umongodb/internal/umongodb/restart.go b/products/umongodb/internal/umongodb/restart.go new file mode 100644 index 0000000000..4204898450 --- /dev/null +++ b/products/umongodb/internal/umongodb/restart.go @@ -0,0 +1,66 @@ +package umongodb + +import ( + "fmt" + + "github.com/spf13/cobra" + + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-sdk-go/services/umongodb" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newRestart implements `umongodb restart`. +func newRestart(ctx *cli.Context) *cobra.Command { + var async bool + var ids []string + + client := cli.NewServiceClient(ctx, umongodb.NewClient) + req := client.NewRestartUMongoDBClusterRequest() + + cmd := &cobra.Command{ + Use: "restart", + Short: "Restart MongoDB instances", + Long: "Restart one or more MongoDB instances.", + Run: func(c *cobra.Command, args []string) { + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, idName := range ids { + id := ctx.PickResourceID(idName) + req.ClusterId = sdk.String(id) + _, err := client.RestartUMongoDBCluster(req) + if err != nil { + ctx.HandleError(err) + continue + } + text := fmt.Sprintf("%s[%s] is restarting", productName, id) + if async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeByID(ctx, *req.Region, *req.Zone)).Spoll(id, text, []string{stateRunning, stateFail}) + } + results = append(results, cli.OpResultRow{ResourceID: id, Action: "restart", Status: "Restarting"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&ids, resourceIDFlag, nil, "Required. Cluster ID(s) of MongoDB instances to restart.") + flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the operation to finish.") + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired(resourceIDFlag) + command.SetCompletion(cmd, resourceIDFlag, func() []string { + return getMongoDBIDList(ctx, nil, *req.Region, *req.Zone, *req.ProjectId) + }) + + return cmd +} diff --git a/products/umongodb/internal/umongodb/rows.go b/products/umongodb/internal/umongodb/rows.go new file mode 100644 index 0000000000..0d5dd81bf8 --- /dev/null +++ b/products/umongodb/internal/umongodb/rows.go @@ -0,0 +1,15 @@ +package umongodb + +// instanceRow is the output struct for `umongodb list`. When passed to +// ctx.PrintList in table mode, the exported field NAMES become the column +// headers, in declaration order. +type instanceRow struct { + ResourceID string + Name string + ClusterType string + Version string + MachineType string + DiskGB int + ConnectURL string + Status string +} diff --git a/products/umongodb/internal/umongodb/start.go b/products/umongodb/internal/umongodb/start.go new file mode 100644 index 0000000000..ea9c9fd31a --- /dev/null +++ b/products/umongodb/internal/umongodb/start.go @@ -0,0 +1,66 @@ +package umongodb + +import ( + "fmt" + + "github.com/spf13/cobra" + + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-sdk-go/services/umongodb" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newStart implements `umongodb start`. +func newStart(ctx *cli.Context) *cobra.Command { + var async bool + var ids []string + + client := cli.NewServiceClient(ctx, umongodb.NewClient) + req := client.NewStartUMongoDBClusterRequest() + + cmd := &cobra.Command{ + Use: "start", + Short: "Start MongoDB instances", + Long: "Start one or more stopped MongoDB instances.", + Run: func(c *cobra.Command, args []string) { + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, idName := range ids { + id := ctx.PickResourceID(idName) + req.ClusterId = sdk.String(id) + _, err := client.StartUMongoDBCluster(req) + if err != nil { + ctx.HandleError(err) + continue + } + text := fmt.Sprintf("%s[%s] is starting", productName, id) + if async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeByID(ctx, *req.Region, *req.Zone)).Spoll(id, text, []string{stateRunning, stateFail}) + } + results = append(results, cli.OpResultRow{ResourceID: id, Action: "start", Status: "Starting"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&ids, resourceIDFlag, nil, "Required. Cluster ID(s) of MongoDB instances to start.") + flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the operation to finish.") + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired(resourceIDFlag) + command.SetCompletion(cmd, resourceIDFlag, func() []string { + return getMongoDBIDList(ctx, []string{stateStopped}, *req.Region, *req.Zone, *req.ProjectId) + }) + + return cmd +} diff --git a/products/umongodb/internal/umongodb/status.go b/products/umongodb/internal/umongodb/status.go new file mode 100644 index 0000000000..71033cdba4 --- /dev/null +++ b/products/umongodb/internal/umongodb/status.go @@ -0,0 +1,8 @@ +package umongodb + +// Terminal states for the Poller. UMongoDB uses "Stopped" (not UDB's "Shutoff"). +const ( + stateRunning = "Running" + stateStopped = "Stopped" + stateFail = "InitFailed" +) diff --git a/products/umongodb/internal/umongodb/stop.go b/products/umongodb/internal/umongodb/stop.go new file mode 100644 index 0000000000..4b204a4e48 --- /dev/null +++ b/products/umongodb/internal/umongodb/stop.go @@ -0,0 +1,66 @@ +package umongodb + +import ( + "fmt" + + "github.com/spf13/cobra" + + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-sdk-go/services/umongodb" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newStop implements `umongodb stop`. +func newStop(ctx *cli.Context) *cobra.Command { + var async bool + var ids []string + + client := cli.NewServiceClient(ctx, umongodb.NewClient) + req := client.NewStopUMongoDBClusterRequest() + + cmd := &cobra.Command{ + Use: "stop", + Short: "Stop MongoDB instances", + Long: "Stop one or more running MongoDB instances.", + Run: func(c *cobra.Command, args []string) { + w := ctx.ProgressWriter() + results := []cli.OpResultRow{} + for _, idName := range ids { + id := ctx.PickResourceID(idName) + req.ClusterId = sdk.String(id) + _, err := client.StopUMongoDBCluster(req) + if err != nil { + ctx.HandleError(err) + continue + } + text := fmt.Sprintf("%s[%s] is stopping", productName, id) + if async { + fmt.Fprintln(w, text) + } else { + ctx.PollerTo(w, describeByID(ctx, *req.Region, *req.Zone)).Spoll(id, text, []string{stateStopped, stateFail}) + } + results = append(results, cli.OpResultRow{ResourceID: id, Action: "stop", Status: "Stopping"}) + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVar(&ids, resourceIDFlag, nil, "Required. Cluster ID(s) of MongoDB instances to stop.") + flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the operation to finish.") + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired(resourceIDFlag) + command.SetCompletion(cmd, resourceIDFlag, func() []string { + return getMongoDBIDList(ctx, []string{stateRunning}, *req.Region, *req.Zone, *req.ProjectId) + }) + + return cmd +} diff --git a/products/umongodb/product.go b/products/umongodb/product.go new file mode 100644 index 0000000000..55227b4e0f --- /dev/null +++ b/products/umongodb/product.go @@ -0,0 +1,21 @@ +package umongodb + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + internalumongodb "github.com/ucloud/ucloud-cli/products/umongodb/internal/umongodb" +) + +type product struct{} + +// New returns the umongodb product (registered via hack/gen-products). +func New() cli.Product { return product{} } + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "umongodb", Commands: []string{"umongodb"}} +} + +func (product) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{internalumongodb.NewCommand(ctx)} +} diff --git a/products/umongodb/product.yaml b/products/umongodb/product.yaml new file mode 100644 index 0000000000..233960f635 --- /dev/null +++ b/products/umongodb/product.yaml @@ -0,0 +1,8 @@ +# products/umongodb/product.yaml — umongodb 产品元数据(归属真源,owner 自治维护) +name: umongodb +owners: + - xingxingso + - jinfz12 +commands: + - umongodb +enabled: true diff --git a/products/umongodb/testdata/cmdtree.golden b/products/umongodb/testdata/cmdtree.golden new file mode 100644 index 0000000000..0504806674 --- /dev/null +++ b/products/umongodb/testdata/cmdtree.golden @@ -0,0 +1,95 @@ +ucloud umongodb use=umongodb short=Manipulate MongoDB on UCloud platform +ucloud umongodb create-replset use=create-replset short=Create a MongoDB replica set + flag=charge-type short= default=Month required= + flag=disk-space-gb short= default=0 required=true + flag=machine-type-id short= default= required=true + flag=name short= default= required=true + flag=node-count short= default=3 required= + flag=password short= default= required=true + flag=port short= default=27017 required= + flag=project-id short= default= required= + flag=quantity short= default=1 required= + flag=region short= default= required= + flag=subnet-id short= default= required= + flag=tag short= default= required= + flag=template-id short= default= required= + flag=version short= default= required=true + flag=vpc-id short= default= required= + flag=zone short= default= required= +ucloud umongodb create-sharded use=create-sharded short=Create a MongoDB sharded cluster + flag=charge-type short= default=Month required= + flag=disk-space-gb short= default=0 required=true + flag=machine-type-id short= default= required=true + flag=mongos-machine-type-id short= default= required= + flag=mongos-node-count short= default=0 required= + flag=name short= default= required=true + flag=node-count short= default=0 required=true + flag=password short= default= required=true + flag=port short= default=27017 required= + flag=project-id short= default= required= + flag=quantity short= default=1 required= + flag=region short= default= required= + flag=shard-count short= default=0 required=true + flag=subnet-id short= default= required= + flag=tag short= default= required= + flag=template-id short= default= required= + flag=version short= default= required=true + flag=vpc-id short= default= required= + flag=zone short= default= required= +ucloud umongodb delete-replset use=delete-replset short=Delete MongoDB replica set instances + flag=async short=a default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=skip-stop short= default=false required= + flag=umongodb-id short= default=[] required=true + flag=yes short=y default=false required= + flag=zone short= default= required= +ucloud umongodb delete-sharded use=delete-sharded short=Delete MongoDB sharded cluster instances + flag=async short=a default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=skip-stop short= default=false required= + flag=umongodb-id short= default=[] required=true + flag=yes short=y default=false required= + flag=zone short= default= required= +ucloud umongodb describe use=describe short=Show details of one MongoDB instance + flag=project-id short= default= required= + flag=region short= default= required= + flag=umongodb-id short= default= required=true + flag=zone short= default= required= +ucloud umongodb list use=list short=List MongoDB instances + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud umongodb list-machine-specs use=list-machine-specs short=List MongoDB machine specifications + flag=class-type short= default= required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud umongodb list-templates use=list-templates short=List MongoDB config templates + flag=cluster-type short= default= required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=version short= default= required= +ucloud umongodb list-versions use=list-versions short=List available MongoDB versions + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud umongodb restart use=restart short=Restart MongoDB instances + flag=async short=a default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=umongodb-id short= default=[] required=true + flag=zone short= default= required= +ucloud umongodb start use=start short=Start MongoDB instances + flag=async short=a default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=umongodb-id short= default=[] required=true + flag=zone short= default= required= +ucloud umongodb stop use=stop short=Stop MongoDB instances + flag=async short=a default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=umongodb-id short= default=[] required=true + flag=zone short= default= required= diff --git a/products/umongodb/testdata/completion.golden b/products/umongodb/testdata/completion.golden new file mode 100644 index 0000000000..8e98411a3f --- /dev/null +++ b/products/umongodb/testdata/completion.golden @@ -0,0 +1,54 @@ +ucloud umongodb create-replset charge-type static Dynamic,Month,Trial,Year +ucloud umongodb create-replset machine-type-id dynamic +ucloud umongodb create-replset node-count static 3,5,7 +ucloud umongodb create-replset project-id dynamic +ucloud umongodb create-replset region dynamic +ucloud umongodb create-replset subnet-id dynamic +ucloud umongodb create-replset template-id dynamic +ucloud umongodb create-replset version dynamic +ucloud umongodb create-replset vpc-id dynamic +ucloud umongodb create-replset zone dynamic +ucloud umongodb create-sharded charge-type static Dynamic,Month,Trial,Year +ucloud umongodb create-sharded machine-type-id dynamic +ucloud umongodb create-sharded project-id dynamic +ucloud umongodb create-sharded region dynamic +ucloud umongodb create-sharded subnet-id dynamic +ucloud umongodb create-sharded template-id dynamic +ucloud umongodb create-sharded version dynamic +ucloud umongodb create-sharded vpc-id dynamic +ucloud umongodb create-sharded zone dynamic +ucloud umongodb delete-replset project-id dynamic +ucloud umongodb delete-replset region dynamic +ucloud umongodb delete-replset umongodb-id dynamic +ucloud umongodb delete-replset zone dynamic +ucloud umongodb delete-sharded project-id dynamic +ucloud umongodb delete-sharded region dynamic +ucloud umongodb delete-sharded umongodb-id dynamic +ucloud umongodb delete-sharded zone dynamic +ucloud umongodb describe project-id dynamic +ucloud umongodb describe region dynamic +ucloud umongodb describe umongodb-id dynamic +ucloud umongodb describe zone dynamic +ucloud umongodb list project-id dynamic +ucloud umongodb list region dynamic +ucloud umongodb list zone dynamic +ucloud umongodb list-machine-specs project-id dynamic +ucloud umongodb list-machine-specs region dynamic +ucloud umongodb list-machine-specs zone dynamic +ucloud umongodb list-templates project-id dynamic +ucloud umongodb list-templates region dynamic +ucloud umongodb list-versions project-id dynamic +ucloud umongodb list-versions region dynamic +ucloud umongodb list-versions zone dynamic +ucloud umongodb restart project-id dynamic +ucloud umongodb restart region dynamic +ucloud umongodb restart umongodb-id dynamic +ucloud umongodb restart zone dynamic +ucloud umongodb start project-id dynamic +ucloud umongodb start region dynamic +ucloud umongodb start umongodb-id dynamic +ucloud umongodb start zone dynamic +ucloud umongodb stop project-id dynamic +ucloud umongodb stop region dynamic +ucloud umongodb stop umongodb-id dynamic +ucloud umongodb stop zone dynamic diff --git a/products/urocketmq/internal/service/create.go b/products/urocketmq/internal/service/create.go index f7bc6bceab..17dd796222 100644 --- a/products/urocketmq/internal/service/create.go +++ b/products/urocketmq/internal/service/create.go @@ -59,8 +59,8 @@ func newCreate(ctx *cli.Context) *cobra.Command { req.Edition = flags.String("edition", "Enterprise", "Required. Edition. Unique value: Enterprise") req.Mode = flags.String("mode", "PrivateNet", "Required. Network mode. Unique value: PrivateNet") req.Name = flags.String("name", "", "Required. Service name. Regex: ^[a-zA-Z0-9-_]{1,36}$") - req.PublicVersion = flags.String("public-version", "", "Required. Cluster version. Enum: v4, v5. Note: each region currently supports only one version, confirm with region") - req.Storage = flags.Int("storage-gb", 0, "Required. Message storage space in GB. Range depends on TPS") + req.PublicVersion = flags.String("public-version", "", "Cluster version. Options vary by region, see doc for supported values: https://github.com/UCloudDoc-Team/rocketmq/blob/master/price/index.md, e.g. v4, v5 (each region only support one version)") + req.Storage = flags.Int("storage-gb", 0, "Required. Storage space in GB. Check the doc first to determine available values: https://github.com/UCloudDoc-Team/rocketmq/blob/master/price/index.md") req.SubnetId = flags.String("subnet-id", "", "Required. Subnet ID. Default to current region's default subnet") req.Tps = flags.String("tps", "", "Required. Transactions per second. Enum: 10000, 20000, 50000, 100000, 200000. Note: v4 supports 20000, 50000, 100000, 200000; v5 currently supports only 10000, 20000.") req.VPCId = flags.String("vpc-id", "", "Required. VPC ID. Default to current region's default VPC") @@ -77,7 +77,6 @@ func newCreate(ctx *cli.Context) *cobra.Command { command.SetFlagValues(cmd, "charge-type", "Year", "Month", "Dynamic") command.SetFlagValues(cmd, "edition", "Enterprise") command.SetFlagValues(cmd, "mode", "PrivateNet") - command.SetFlagValues(cmd, "public-version", "v4", "v5") command.SetFlagValues(cmd, "tps", "10000", "20000", "50000", "100000", "200000") command.SetCompletion(cmd, "vpc-id", func() []string { @@ -91,7 +90,6 @@ func newCreate(ctx *cli.Context) *cobra.Command { cmd.MarkFlagRequired("edition") cmd.MarkFlagRequired("mode") cmd.MarkFlagRequired("name") - cmd.MarkFlagRequired("public-version") cmd.MarkFlagRequired("storage-gb") cmd.MarkFlagRequired("subnet-id") cmd.MarkFlagRequired("tps") diff --git a/products/urocketmq/internal/service/price.go b/products/urocketmq/internal/service/price.go index a6ec4150cd..fe42255451 100644 --- a/products/urocketmq/internal/service/price.go +++ b/products/urocketmq/internal/service/price.go @@ -58,9 +58,9 @@ func newPrice(ctx *cli.Context) *cobra.Command { req.ChargeType = flags.String("charge-type", "Month", "Required. Charge type. Enum: Year, Month, Dynamic") req.Edition = flags.String("edition", "Enterprise", "Required. Edition. Unique value: Enterprise") req.Mode = flags.String("mode", "PrivateNet", "Required. Network mode. Unique value: PrivateNet") - req.PublicVersion = flags.String("public-version", "", "Required. Cluster version. Enum: v4, v5. Note: each region currently supports only one version, confirm with region") + req.PublicVersion = flags.String("public-version", "", "Cluster version. Options vary by region, see doc for supported values: https://github.com/UCloudDoc-Team/rocketmq/blob/master/price/index.md, e.g. v4, v5 (each region only support one version)") req.Quantity = flags.Int("quantity", 1, "Optional. Purchase duration in months. Month: 1-9(month), 0=until end of current month; Dynamic: ignore; Year: use --quantity as years") - req.Storage = flags.Int("storage-gb", 0, "Required. Message storage space in GB") + req.Storage = flags.Int("storage-gb", 0, "Required. Storage space in GB. Check the doc first to determine available values: https://github.com/UCloudDoc-Team/rocketmq/blob/master/price/index.md") req.TPS = flags.Int("tps", 0, "Required. Transactions per second. Enum: 10000, 20000, 50000, 100000, 200000. Note: v4 supports 20000, 50000, 100000, 200000; v5 currently supports only 10000, 20000.") ctx.BindProjectID(cmd, req) @@ -69,13 +69,11 @@ func newPrice(ctx *cli.Context) *cobra.Command { command.SetFlagValues(cmd, "charge-type", "Year", "Month", "Dynamic") command.SetFlagValues(cmd, "edition", "Enterprise") command.SetFlagValues(cmd, "mode", "PrivateNet") - command.SetFlagValues(cmd, "public-version", "v4", "v5") command.SetFlagValues(cmd, "tps", "10000", "20000", "50000", "100000", "200000") cmd.MarkFlagRequired("charge-type") cmd.MarkFlagRequired("edition") cmd.MarkFlagRequired("mode") - cmd.MarkFlagRequired("public-version") cmd.MarkFlagRequired("storage-gb") cmd.MarkFlagRequired("tps") diff --git a/products/urocketmq/testdata/cmdtree.golden b/products/urocketmq/testdata/cmdtree.golden index 88a8f813ec..0685cd2b99 100644 --- a/products/urocketmq/testdata/cmdtree.golden +++ b/products/urocketmq/testdata/cmdtree.golden @@ -49,7 +49,7 @@ ucloud urocketmq service create use=create short=Create a URocketMQ service inst flag=mode short= default=PrivateNet required=true flag=name short= default= required=true flag=project-id short= default= required= - flag=public-version short= default= required=true + flag=public-version short= default= required= flag=quantity short= default=1 required= flag=region short= default= required= flag=remark short= default= required= @@ -79,7 +79,7 @@ ucloud urocketmq service price use=price short=Get price of URocketMQ service in flag=edition short= default=Enterprise required=true flag=mode short= default=PrivateNet required=true flag=project-id short= default= required= - flag=public-version short= default= required=true + flag=public-version short= default= required= flag=quantity short= default=1 required= flag=region short= default= required= flag=storage-gb short= default=0 required=true diff --git a/products/urocketmq/testdata/completion.golden b/products/urocketmq/testdata/completion.golden index b720fa0fb3..76732fc573 100644 --- a/products/urocketmq/testdata/completion.golden +++ b/products/urocketmq/testdata/completion.golden @@ -25,7 +25,6 @@ ucloud urocketmq service create charge-type static Dynamic,Month,Year ucloud urocketmq service create edition static Enterprise ucloud urocketmq service create mode static PrivateNet ucloud urocketmq service create project-id dynamic -ucloud urocketmq service create public-version static v4,v5 ucloud urocketmq service create region dynamic ucloud urocketmq service create subnet-id dynamic ucloud urocketmq service create tps static 10000,100000,20000,200000,50000 @@ -44,7 +43,6 @@ ucloud urocketmq service price charge-type static Dynamic,Month,Year ucloud urocketmq service price edition static Enterprise ucloud urocketmq service price mode static PrivateNet ucloud urocketmq service price project-id dynamic -ucloud urocketmq service price public-version static v4,v5 ucloud urocketmq service price region dynamic ucloud urocketmq service price tps static 10000,100000,20000,200000,50000 ucloud urocketmq service update-name project-id dynamic diff --git a/products/utidb/internal/tidb/api.go b/products/utidb/internal/tidb/api.go new file mode 100644 index 0000000000..f7a316ceba --- /dev/null +++ b/products/utidb/internal/tidb/api.go @@ -0,0 +1,75 @@ +package tidb + +import ( + "fmt" + + "github.com/ucloud/ucloud-sdk-go/services/tidb" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// invokeAPI calls a TiDB API action via GenericRequest. +// +// Why GenericInvoke instead of typed NewXxxRequest() field assignment (§5): +// the SDK FormEncoder emits string slices as NodeTypes.0, but TiDB APIs expect +// flat NodeTypes0; nested NodeConfig / Labels / SecGroupInfo must be nested +// maps so the encoder emits NodeConfig.0.Field. Typed requests cannot express +// both encodings correctly for this product. +func invokeAPI(ctx *cli.Context, action string, params map[string]interface{}) (map[string]interface{}, error) { + client := cli.NewServiceClient(ctx, tidb.NewClient) + req := client.NewGenericRequest() + allParams := map[string]interface{}{ + "Action": action, + } + for k, v := range params { + allParams[k] = v + } + if err := req.SetPayload(allParams); err != nil { + return nil, fmt.Errorf("set payload: %w", err) + } + resp, err := client.GenericInvoke(req) + if err != nil { + return nil, err + } + return resp.GetPayload(), nil +} + +func mergeCommonParams(region, zone, projectID string, params map[string]interface{}) map[string]interface{} { + out := make(map[string]interface{}, len(params)+3) + for k, v := range params { + out[k] = v + } + if region != "" { + out["Region"] = region + } + if zone != "" { + out["Zone"] = zone + } + if projectID != "" { + out["ProjectId"] = projectID + } + return out +} + +func flattenIndexedStrings(params map[string]interface{}, name string, values []string) { + for i, v := range values { + params[fmt.Sprintf("%s%d", name, i)] = v + } +} + +func getTiDBClusterUhostSpecs(ctx *cli.Context, region, zone, projectID string, nodeTypes []string) ([]tidb.UhostSpecs, error) { + params := mergeCommonParams(region, zone, projectID, map[string]interface{}{}) + flattenIndexedStrings(params, "NodeTypes", formatNodeTypes(nodeTypes)) + payload, err := invokeAPI(ctx, "GetTiDBClusterUhostSpecs", params) + if err != nil { + return nil, err + } + return parseUhostSpecsFromPayload(payload), nil +} + +func getTiDBClusterPayload(ctx *cli.Context, region, zone, projectID, id string) (map[string]interface{}, error) { + params := mergeCommonParams(region, zone, projectID, map[string]interface{}{ + "Id": id, + }) + return invokeAPI(ctx, "GetTiDBClusterService", params) +} diff --git a/products/utidb/internal/tidb/backup.go b/products/utidb/internal/tidb/backup.go new file mode 100644 index 0000000000..24b7b86896 --- /dev/null +++ b/products/utidb/internal/tidb/backup.go @@ -0,0 +1,58 @@ +package tidb + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/tidb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newBackup ucloud utidb backup +func newBackup(ctx *cli.Context) *cobra.Command { + var id, backupFilter, backupTs string + + client := cli.NewServiceClient(ctx, tidb.NewClient) + req := client.NewStartTiDBClusterBackupRequest() + + cmd := &cobra.Command{ + Use: "backup", + Short: "Start a backup of a UTiDB instance", + Long: "Start a backup of a UTiDB instance", + Run: func(c *cobra.Command, args []string) { + req.Id = sdk.String(ctx.PickResourceID(id)) + if backupFilter != "" { + req.BackupFilter = sdk.String(backupFilter) + } + if backupTs != "" { + req.BackupTs = sdk.String(backupTs) + } + resp, err := client.StartTiDBClusterBackup(req) + if err != nil { + handleAPIError(ctx, err) + return + } + ctx.EmitResult(cli.OpResultRow{ResourceID: resp.BackupId, Action: "backup", Status: stateBackingUp}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVar(&id, "utidb-id", "", "Required. Resource ID of the UTiDB instance") + flags.StringVar(&backupFilter, "backup-filter", "", "Optional. Backup filter rule") + flags.StringVar(&backupTs, "backup-ts", "", "Optional. Backup timestamp") + + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("utidb-id") + command.SetCompletion(cmd, "utidb-id", func() []string { + return listResourceIDs(ctx, nil, req.GetRegion(), req.GetZone(), req.GetProjectId()) + }) + + return cmd +} diff --git a/products/utidb/internal/tidb/cmd.go b/products/utidb/internal/tidb/cmd.go new file mode 100644 index 0000000000..77d7e05f7c --- /dev/null +++ b/products/utidb/internal/tidb/cmd.go @@ -0,0 +1,27 @@ +package tidb + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand builds the `utidb` root command and mounts all verbs. +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "utidb", + Short: "Manipulate UTiDB instances on UCloud platform", + Long: helpUTiDBRoot, + } + cmd.AddCommand(newList(ctx)) + cmd.AddCommand(newDescribe(ctx)) + cmd.AddCommand(newCreate(ctx)) + cmd.AddCommand(newDelete(ctx)) + cmd.AddCommand(newBackup(ctx)) + cmd.AddCommand(newListBackup(ctx)) + cmd.AddCommand(newScaleNode(ctx)) + cmd.AddCommand(newResizeDisk(ctx)) + cmd.AddCommand(newModifySpec(ctx)) + cmd.AddCommand(newListSpecs(ctx)) + return cmd +} diff --git a/products/utidb/internal/tidb/completion.go b/products/utidb/internal/tidb/completion.go new file mode 100644 index 0000000000..5cf1686695 --- /dev/null +++ b/products/utidb/internal/tidb/completion.go @@ -0,0 +1,93 @@ +package tidb + +import ( + "fmt" + + "github.com/ucloud/ucloud-sdk-go/services/tidb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// listResourceIDs returns UTiDB instance IDs formatted as id/name. +func listResourceIDs(ctx *cli.Context, states []string, region, zone, projectID string) []string { + client := cli.NewServiceClient(ctx, tidb.NewClient) + req := client.NewListTiDBClusterServiceRequest() + if region != "" { + req.Region = sdk.String(region) + } + if zone != "" { + req.Zone = sdk.String(zone) + } + if projectID != "" { + req.ProjectId = sdk.String(projectID) + } + resp, err := client.ListTiDBClusterService(req) + if err != nil { + return nil + } + var list []string + for _, d := range resp.Data { + if states != nil { + found := false + for _, s := range states { + if s == d.State { + found = true + break + } + } + if !found { + continue + } + } + list = append(list, fmt.Sprintf("%s/%s", d.Id, d.Name)) + } + return list +} + +// listNodeTypes returns all available node types by querying specs. +func listNodeTypes(ctx *cli.Context, region, zone string) []string { + seedTypes := []string{"tidb", "tikv", "pd", "tiflash"} + specs, err := getTiDBClusterUhostSpecs(ctx, region, zone, "", seedTypes) + if err != nil { + return nil + } + seen := make(map[string]bool) + var list []string + for _, s := range specs { + if !seen[s.NodeType] { + seen[s.NodeType] = true + list = append(list, s.NodeType) + } + } + return list +} + +// listConfigIDs returns uhost config IDs for the given node type. +func listConfigIDs(ctx *cli.Context, region, zone, nodeType string) []string { + if nodeType == "" { + return nil + } + specs, err := getTiDBClusterUhostSpecs(ctx, region, zone, "", []string{nodeType}) + if err != nil { + return nil + } + var list []string + for _, s := range specs { + list = append(list, fmt.Sprintf("%s/%s", s.ConfigId, s.ConfigName)) + } + return list +} + +// listServerIDs returns server IDs of the given UTiDB instance for scale-in completion. +// Format: /@ +func listServerIDs(ctx *cli.Context, region, zone, projectID, id string) []string { + if id == "" { + return nil + } + payload, err := getTiDBClusterPayload(ctx, region, zone, projectID, id) + if err != nil { + return nil + } + return extractServerIDs(payload) +} diff --git a/products/utidb/internal/tidb/create.go b/products/utidb/internal/tidb/create.go new file mode 100644 index 0000000000..19f44cc7ae --- /dev/null +++ b/products/utidb/internal/tidb/create.go @@ -0,0 +1,260 @@ +package tidb + +import ( + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/tidb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// parseCreateNodeConfig parses a CLI node-config string into the SDK type. +// Format: ConfigId=xxx,DiskSize=N,NodeCount=N,ServerType=tidb +func parseCreateNodeConfig(s string) (tidb.CreateTiDBClusterServiceParamNodeConfig, error) { + var cfg tidb.CreateTiDBClusterServiceParamNodeConfig + parts := strings.Split(s, ",") + for _, part := range parts { + kv := strings.SplitN(part, "=", 2) + if len(kv) != 2 { + return cfg, fmt.Errorf("invalid node-config segment %q, expected key=value", part) + } + key := strings.TrimSpace(kv[0]) + val := strings.TrimSpace(kv[1]) + switch key { + case "ConfigId": + cfg.ConfigId = sdk.String(val) + case "DiskSize": + n, err := strconv.Atoi(val) + if err != nil { + return cfg, fmt.Errorf("invalid DiskSize %q: %w", val, err) + } + cfg.DiskSize = sdk.Int(n) + case "NodeCount": + n, err := strconv.Atoi(val) + if err != nil { + return cfg, fmt.Errorf("invalid NodeCount %q: %w", val, err) + } + cfg.NodeCount = sdk.Int(n) + case "ServerType": + cfg.ServerType = sdk.String(val) + default: + return cfg, fmt.Errorf("unknown node-config key %q", key) + } + } + if cfg.ConfigId == nil || cfg.DiskSize == nil || cfg.NodeCount == nil || cfg.ServerType == nil { + return cfg, fmt.Errorf("node-config must include ConfigId, DiskSize, NodeCount and ServerType") + } + if err := validateServerType(*cfg.ServerType); err != nil { + return cfg, err + } + return cfg, nil +} + +func parseCreateLabels(ss []string) []tidb.CreateTiDBClusterServiceParamLabels { + var out []tidb.CreateTiDBClusterServiceParamLabels + for _, s := range ss { + parts := strings.SplitN(s, "=", 2) + if len(parts) == 2 { + out = append(out, tidb.CreateTiDBClusterServiceParamLabels{ + Key: sdk.String(strings.TrimSpace(parts[0])), + Value: sdk.String(strings.TrimSpace(parts[1])), + }) + } + } + return out +} + +func parseCreateSecGroupInfo(ss []string) ([]tidb.CreateTiDBClusterServiceParamSecGroupInfo, error) { + var out []tidb.CreateTiDBClusterServiceParamSecGroupInfo + for _, s := range ss { + var item tidb.CreateTiDBClusterServiceParamSecGroupInfo + parts := strings.Split(s, ",") + for _, part := range parts { + kv := strings.SplitN(part, "=", 2) + if len(kv) != 2 { + continue + } + key := strings.TrimSpace(kv[0]) + val := strings.TrimSpace(kv[1]) + switch key { + case "SecGroupId": + item.SecGroupId = sdk.String(val) + case "Priority": + n, err := strconv.Atoi(val) + if err != nil { + return nil, fmt.Errorf("invalid Priority %q: %w", val, err) + } + item.Priority = sdk.Int(n) + } + } + out = append(out, item) + } + return out, nil +} + +// newCreate ucloud utidb create +func newCreate(ctx *cli.Context) *cobra.Command { + var name, password, chargeType, dtType, pubUlbID, vpcID, subnetID string + var dbVersion, ip, port, coupon, promotionID, templateID string + var quantity float64 + var activityID, ruleID int + var alertStrategyIDs []int + var labels, secGroupInfo []string + var nodeConfigs []string + var async bool + + client := cli.NewServiceClient(ctx, tidb.NewClient) + req := client.NewCreateTiDBClusterServiceRequest() + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a UTiDB instance", + Long: helpCreateLong, + Run: func(c *cobra.Command, args []string) { + var configs []tidb.CreateTiDBClusterServiceParamNodeConfig + for _, s := range nodeConfigs { + cfg, err := parseCreateNodeConfig(s) + if err != nil { + ctx.HandleError(err) + return + } + configs = append(configs, cfg) + } + + params := mergeCommonParams(req.GetRegion(), req.GetZone(), req.GetProjectId(), map[string]interface{}{ + "Name": name, + "Password": password, + "ChargeType": chargeType, + "DTType": dtType, + "VPCId": vpcID, + "SubnetId": subnetID, + "Quantity": quantity, + }) + if pubUlbID != "" { + params["PubUlbId"] = pubUlbID + } + if dbVersion != "" { + params["DbVersion"] = dbVersion + } + if ip != "" { + params["Ip"] = ip + } + if port != "" { + params["Port"] = port + } + if promotionID != "" { + params["PromotionId"] = promotionID + } + if templateID != "" { + params["TemplateId"] = templateID + } + if activityID != 0 { + params["ActivityId"] = activityID + } + if ruleID != 0 { + params["RuleId"] = ruleID + } + if coupon != "" { + params["Coupon"] = coupon + } + if len(alertStrategyIDs) > 0 { + params["AlertStrategyIds"] = alertStrategyIDs + } + if len(labels) > 0 { + labelMaps := make([]map[string]interface{}, 0, len(labels)) + for _, l := range parseCreateLabels(labels) { + labelMaps = append(labelMaps, labelToMap(l)) + } + params["Labels"] = labelMaps + } + if len(secGroupInfo) > 0 { + infos, err := parseCreateSecGroupInfo(secGroupInfo) + if err != nil { + ctx.HandleError(err) + return + } + secMaps := make([]map[string]interface{}, 0, len(infos)) + for _, info := range infos { + secMaps = append(secMaps, secGroupToMap(info)) + } + params["SecGroupInfo"] = secMaps + } + nodeConfigMaps := make([]map[string]interface{}, 0, len(configs)) + for _, cfg := range configs { + nodeConfigMaps = append(nodeConfigMaps, createNodeConfigToMap(cfg)) + } + params["NodeConfig"] = nodeConfigMaps + + payload, err := invokeAPI(ctx, "CreateTiDBClusterService", params) + if err != nil { + handleAPIError(ctx, err) + return + } + data, _ := payload["Data"].(map[string]interface{}) + clusterID := stringVal(data["Id"]) + if clusterID == "" { + ctx.HandleError(fmt.Errorf("empty cluster ID in response")) + return + } + + w := ctx.ProgressWriter() + if async { + fmt.Fprintf(w, "utidb[%s] is creating\n", clusterID) + } else { + text := fmt.Sprintf("utidb[%s] is creating", clusterID) + spollCreate(ctx, w, req.GetRegion(), req.GetZone(), req.GetProjectId(), clusterID, text) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: clusterID, Action: "create", Status: "Creating"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVar(&name, "name", "", "Required. Instance name") + flags.StringVar(&password, "password", "", "Required. Admin password") + flags.StringVar(&chargeType, "charge-type", "", "Required. Charge type: Month, Year, Dynamic, Trial") + flags.StringVar(&dtType, "dt-type", "", "Required. Disaster tolerance: 10 (same AZ), 20 (cross AZ)") + flags.StringVar(&pubUlbID, "pub-ulb-id", "", "Optional. Public ULB ID") + flags.StringVar(&vpcID, "vpc-id", "", "Required. VPC ID") + flags.StringVar(&subnetID, "subnet-id", "", "Required. Subnet ID") + flags.Float64Var(&quantity, "quantity", 1, "Required. Purchase duration") + flags.StringArrayVar(&nodeConfigs, "node-config", nil, "Required. Per node type: ConfigId=xxx,DiskSize=N,NodeCount=N,ServerType=tidb|tikv|pd|tiflash") + + flags.StringVar(&dbVersion, "db-version", "", "Optional. Database version, e.g. v8.5.1, v8.5.6") + flags.StringVar(&ip, "ip", "", "Optional. Specified IP address") + flags.StringVar(&port, "port", "", "Optional. Specified port") + flags.StringVar(&coupon, "coupon", "", "Optional. Coupon ID") + flags.StringVar(&promotionID, "promotion-id", "", "Optional. Promotion ID") + flags.StringVar(&templateID, "template-id", "", "Optional. Parameter template ID") + flags.IntVar(&activityID, "activity-id", 0, "Optional. Activity ID") + flags.IntVar(&ruleID, "rule-id", 0, "Optional. Rule ID") + flags.IntSliceVar(&alertStrategyIDs, "alert-strategy-ids", nil, "Optional. Alert strategy IDs") + flags.StringSliceVar(&labels, "labels", nil, "Optional. Resource labels, format: key=value, repeatable") + flags.StringArrayVar(&secGroupInfo, "sec-group-info", nil, "Optional. Security group info, format: SecGroupId=xxx,Priority=N, repeatable") + flags.BoolVar(&async, "async", false, "Optional. Do not wait for creation to finish") + + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("name") + cmd.MarkFlagRequired("password") + cmd.MarkFlagRequired("charge-type") + cmd.MarkFlagRequired("dt-type") + cmd.MarkFlagRequired("vpc-id") + cmd.MarkFlagRequired("subnet-id") + cmd.MarkFlagRequired("quantity") + cmd.MarkFlagRequired("node-config") + + command.SetFlagValues(cmd, "charge-type", "Month", "Year", "Dynamic", "Trial") + command.SetFlagValues(cmd, "dt-type", "10", "20") + + return cmd +} diff --git a/products/utidb/internal/tidb/delete.go b/products/utidb/internal/tidb/delete.go new file mode 100644 index 0000000000..20c284b9b7 --- /dev/null +++ b/products/utidb/internal/tidb/delete.go @@ -0,0 +1,80 @@ +package tidb + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/tidb" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newDelete ucloud utidb delete +func newDelete(ctx *cli.Context) *cobra.Command { + var id string + var deleteBackup bool + var yes, async bool + + client := cli.NewServiceClient(ctx, tidb.NewClient) + req := client.NewDeleteTiDBClusterServiceRequest() + + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete a UTiDB instance", + Long: "Delete a UTiDB instance", + Run: func(c *cobra.Command, args []string) { + ok, err := ctx.Confirm(yes, fmt.Sprintf("Are you sure to delete UTiDB instance %s?", id)) + if err != nil { + ctx.HandleError(err) + return + } + if !ok { + return + } + + pickedID := ctx.PickResourceID(id) + params := mergeCommonParams(req.GetRegion(), req.GetZone(), req.GetProjectId(), map[string]interface{}{ + "Id": pickedID, + }) + if deleteBackup { + params["DeleteBackup"] = true + } + + _, err = invokeAPI(ctx, "DeleteTiDBClusterService", params) + if err != nil { + handleAPIError(ctx, err) + return + } + + w := ctx.ProgressWriter() + if async { + fmt.Fprintf(w, "utidb[%s] is deleting\n", pickedID) + } else { + text := fmt.Sprintf("utidb[%s] is deleting", pickedID) + ctx.PollerTo(w, describeByID(ctx, req.GetRegion(), req.GetZone(), req.GetProjectId())).Spoll(pickedID, text, []string{stateDeleted, stateDeleteFail}) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: pickedID, Action: "delete", Status: "Deleted"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVar(&id, "utidb-id", "", "Required. Resource ID of the UTiDB instance to delete") + flags.BoolVar(&deleteBackup, "delete-backup", false, "Optional. Also delete backup data") + flags.BoolVarP(&yes, "yes", "y", false, "Optional. Do not prompt for confirmation") + flags.BoolVar(&async, "async", false, "Optional. Do not wait for deletion to finish") + + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("utidb-id") + command.SetCompletion(cmd, "utidb-id", func() []string { + return listResourceIDs(ctx, nil, req.GetRegion(), req.GetZone(), req.GetProjectId()) + }) + + return cmd +} diff --git a/products/utidb/internal/tidb/describe.go b/products/utidb/internal/tidb/describe.go new file mode 100644 index 0000000000..e62aa1ed66 --- /dev/null +++ b/products/utidb/internal/tidb/describe.go @@ -0,0 +1,66 @@ +package tidb + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/tidb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newDescribe ucloud utidb describe +func newDescribe(ctx *cli.Context) *cobra.Command { + var id string + client := cli.NewServiceClient(ctx, tidb.NewClient) + req := client.NewGetTiDBClusterServiceRequest() + cmd := &cobra.Command{ + Use: "describe", + Short: "Show details of a UTiDB instance", + Long: "Show details of a UTiDB instance", + Run: func(c *cobra.Command, args []string) { + req.Id = sdk.String(ctx.PickResourceID(id)) + resp, err := client.GetTiDBClusterService(req) + if err != nil { + handleAPIError(ctx, err) + return + } + d := resp.Data + rows := []cli.DescribeRow{ + {Attribute: "ID", Content: d.Id}, + {Attribute: "Name", Content: d.Name}, + {Attribute: "State", Content: d.State}, + {Attribute: "Port", Content: fmt.Sprintf("%d", d.Port)}, + {Attribute: "IP", Content: d.Ip}, + {Attribute: "VPCId", Content: d.VPCId}, + {Attribute: "SubnetId", Content: d.SubnetId}, + {Attribute: "Version", Content: d.Version}, + {Attribute: "CreateTime", Content: fmt.Sprintf("%d", d.CreateTime)}, + {Attribute: "DTType", Content: fmt.Sprintf("%d", d.DTType)}, + {Attribute: "AutoBackup", Content: d.AutoBackup}, + {Attribute: "BinlogState", Content: d.BinlogState}, + {Attribute: "TiFlashState", Content: d.TiFlashState}, + {Attribute: "DashboardUrl", Content: d.DashboardUrl}, + {Attribute: "GrafanaUrl", Content: d.GrafanaUrl}, + } + ctx.PrintList(rows) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVar(&id, "utidb-id", "", "Required. Resource ID of the UTiDB instance") + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("utidb-id") + command.SetCompletion(cmd, "utidb-id", func() []string { + return listResourceIDs(ctx, nil, req.GetRegion(), req.GetZone(), req.GetProjectId()) + }) + + return cmd +} diff --git a/products/utidb/internal/tidb/encode.go b/products/utidb/internal/tidb/encode.go new file mode 100644 index 0000000000..bf54c3b39c --- /dev/null +++ b/products/utidb/internal/tidb/encode.go @@ -0,0 +1,109 @@ +package tidb + +import ( + "strings" + + "github.com/ucloud/ucloud-sdk-go/services/tidb" +) + +func formatNodeType(s string) string { + s = strings.TrimSpace(strings.ToLower(s)) + switch s { + case "tidb": + return "Tidb" + case "tikv": + return "Tikv" + case "pd": + return "Pd" + case "tiflash": + return "Tiflash" + default: + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] + } +} + +func formatNodeTypes(types []string) []string { + out := make([]string, len(types)) + for i, t := range types { + out[i] = formatNodeType(t) + } + return out +} + +func createNodeConfigToMap(cfg tidb.CreateTiDBClusterServiceParamNodeConfig) map[string]interface{} { + m := map[string]interface{}{} + if cfg.ConfigId != nil { + m["ConfigId"] = *cfg.ConfigId + } + if cfg.DiskSize != nil { + m["DiskSize"] = *cfg.DiskSize + } + if cfg.NodeCount != nil { + m["NodeCount"] = *cfg.NodeCount + } + if cfg.ServerType != nil { + m["ServerType"] = formatNodeType(*cfg.ServerType) + } + return m +} + +func labelToMap(l tidb.CreateTiDBClusterServiceParamLabels) map[string]interface{} { + m := map[string]interface{}{} + if l.Key != nil { + m["Key"] = *l.Key + } + if l.Value != nil { + m["Value"] = *l.Value + } + return m +} + +func secGroupToMap(s tidb.CreateTiDBClusterServiceParamSecGroupInfo) map[string]interface{} { + m := map[string]interface{}{} + if s.SecGroupId != nil { + m["SecGroupId"] = *s.SecGroupId + } + if s.Priority != nil { + m["Priority"] = *s.Priority + } + return m +} + +func scaleNodeConfigToMap(cfg tidb.ModifyTiDBClusterNodeParamNodeConfig) map[string]interface{} { + m := map[string]interface{}{} + if cfg.ConfigId != nil { + m["ConfigId"] = *cfg.ConfigId + } + if cfg.NodeCount != nil { + m["NodeCount"] = *cfg.NodeCount + } + if cfg.ServerType != nil { + m["ServerType"] = formatNodeType(*cfg.ServerType) + } + return m +} + +func resizeDiskNodeConfigToMap(cfg tidb.ModifyTiDBClusterUhostDiskParamNodeConfig) map[string]interface{} { + m := map[string]interface{}{} + if cfg.DiskSize != nil { + m["DiskSize"] = *cfg.DiskSize + } + if cfg.ServerType != nil { + m["ServerType"] = formatNodeType(*cfg.ServerType) + } + return m +} + +func modifySpecNodeConfigToMap(cfg tidb.ModifyTiDBClusterUhostSpecsParamNodeConfig) map[string]interface{} { + m := map[string]interface{}{} + if cfg.ConfigId != nil { + m["ConfigId"] = *cfg.ConfigId + } + if cfg.ServerType != nil { + m["ServerType"] = formatNodeType(*cfg.ServerType) + } + return m +} diff --git a/products/utidb/internal/tidb/errors.go b/products/utidb/internal/tidb/errors.go new file mode 100644 index 0000000000..607b3f23bc --- /dev/null +++ b/products/utidb/internal/tidb/errors.go @@ -0,0 +1,40 @@ +package tidb + +import ( + uerr "github.com/ucloud/ucloud-sdk-go/ucloud/error" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// retCodeHints maps TiDB API RetCode to user-facing hints when Message is empty +// or too terse. Keep product-specific codes here rather than global CLI error handling. +var retCodeHints = map[int]string{ + 202555: "backup databases is empty (备份数据库为空库)", +} + +func retCodeHint(code int) string { + return retCodeHints[code] +} + +// enrichAPIError returns an error with a clearer Message for known TiDB RetCodes. +func enrichAPIError(err error) error { + uErr, ok := err.(uerr.Error) + if !ok || uErr.Code() == 0 { + return err + } + hint := retCodeHint(uErr.Code()) + if hint == "" { + return err + } + msg := uErr.Message() + if msg == "" { + msg = hint + } else { + msg = msg + "; " + hint + } + return uerr.NewServerCodeError(uErr.Code(), msg) +} + +func handleAPIError(ctx *cli.Context, err error) { + ctx.HandleError(enrichAPIError(err)) +} diff --git a/products/utidb/internal/tidb/errors_test.go b/products/utidb/internal/tidb/errors_test.go new file mode 100644 index 0000000000..184f57a1f3 --- /dev/null +++ b/products/utidb/internal/tidb/errors_test.go @@ -0,0 +1,34 @@ +package tidb + +import ( + "strings" + "testing" + + uerr "github.com/ucloud/ucloud-sdk-go/ucloud/error" +) + +func TestEnrichAPIError_202555(t *testing.T) { + err := uerr.NewServerCodeError(202555, "") + got := enrichAPIError(err) + uErr, ok := got.(uerr.Error) + if !ok { + t.Fatalf("want uerr.Error, got %T", got) + } + if uErr.Code() != 202555 { + t.Fatalf("code = %d, want 202555", uErr.Code()) + } + if !strings.Contains(uErr.Message(), "backup databases is empty") { + t.Fatalf("message = %q, want empty-db hint", uErr.Message()) + } + if !strings.Contains(uErr.Message(), "备份数据库为空库") { + t.Fatalf("message = %q, want Chinese hint", uErr.Message()) + } +} + +func TestEnrichAPIError_unknownCode(t *testing.T) { + err := uerr.NewServerCodeError(999999, "original") + got := enrichAPIError(err) + if got != err { + t.Fatal("unknown code should be unchanged") + } +} diff --git a/products/utidb/internal/tidb/help.go b/products/utidb/internal/tidb/help.go new file mode 100644 index 0000000000..7c0a6a7029 --- /dev/null +++ b/products/utidb/internal/tidb/help.go @@ -0,0 +1,46 @@ +package tidb + +const ( + helpUTiDBRoot = `Manage UTiDB (TiDB) cluster instances on UCloud. + +Common enums: + ServerType (node type): tidb, tikv, pd, tiflash (case-insensitive; sent to API as Tidb/Tikv/Pd/Tiflash) + ScaleType: SCALEOUT (expand), SCALEIN (shrink; scale-node/resize-disk only) + ChargeType: Month, Year, Dynamic, Trial + DTType: 10 (same AZ), 20 (cross AZ) + DbVersion: e.g. v8.5.1, v8.5.6 (use list-specs to see available specs per region)` + + helpCreateLong = `Create a UTiDB instance. + +Repeat --node-config for each node type. Example: + --node-config 'ConfigId=tidb_2c_4g,DiskSize=100,NodeCount=3,ServerType=tidb' + --node-config 'ConfigId=tikv_4c_16g,DiskSize=200,NodeCount=3,ServerType=tikv' + --node-config 'ConfigId=pd_2c_4g,DiskSize=50,NodeCount=3,ServerType=pd' + +Use 'utidb list-specs --node-types tidb,tikv,pd' to discover ConfigId values.` + + helpScaleNodeLong = `Scale nodes of a UTiDB instance. + +ScaleType: + SCALEOUT Expand nodes; NodeCount is the target total count after scaling. + SCALEIN Shrink nodes; NodeCount is the target total count after scaling. + Requires --server-id of the node to remove (use tab completion or GetTiDBClusterService). + +Example SCALEOUT: --scale-type SCALEOUT --node-config 'ConfigId=tikv_4c_16g,NodeCount=4,ServerType=tikv' +Example SCALEIN: --scale-type SCALEIN --server-id --node-config 'ConfigId=tikv_4c_16g,NodeCount=3,ServerType=tikv'` + + helpResizeDiskLong = `Resize disk of a UTiDB instance. + +ScaleType: SCALEOUT or SCALEIN (disk expansion or shrink per node type). +Example: --scale-type SCALEOUT --node-config 'DiskSize=300,ServerType=tikv'` + + helpModifySpecLong = `Modify uhost specs of a UTiDB instance. + +Example: --node-config 'ConfigId=tidb_4c_8g,ServerType=tidb' +Use 'utidb list-specs' to discover ConfigId values per node type.` + + helpListSpecsLong = `List available uhost specs for UTiDB node types. + +Node types: tidb, tikv, pd, tiflash (comma-separated). +Example: --node-types tidb,tikv,pd` +) diff --git a/products/utidb/internal/tidb/list.go b/products/utidb/internal/tidb/list.go new file mode 100644 index 0000000000..1c7b37d7d2 --- /dev/null +++ b/products/utidb/internal/tidb/list.go @@ -0,0 +1,52 @@ +package tidb + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/tidb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newList ucloud utidb list. +// ListTiDBClusterService Limit/Offset are *string in the SDK, so +// ctx.BindCommonParams (BindLimit expects *int) cannot be used here. +func newList(ctx *cli.Context) *cobra.Command { + var limit, offset string + client := cli.NewServiceClient(ctx, tidb.NewClient) + req := client.NewListTiDBClusterServiceRequest() + cmd := &cobra.Command{ + Use: "list", + Short: "List UTiDB instances", + Long: "List UTiDB instances", + Run: func(c *cobra.Command, args []string) { + if limit != "" { + req.Limit = sdk.String(limit) + } + if offset != "" { + req.Offset = sdk.String(offset) + } + resp, err := client.ListTiDBClusterService(req) + if err != nil { + handleAPIError(ctx, err) + return + } + rows := []instanceRow{} + for _, d := range resp.Data { + rows = append(rows, newInstanceRowFromData(d)) + } + ctx.PrintList(rows) + }, + } + flags := cmd.Flags() + flags.SortFlags = false + + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + flags.StringVar(&limit, "limit", "", "Optional. The maximum number of resources per page") + flags.StringVar(&offset, "offset", "", "Optional. The index of resource which start to list") + + return cmd +} diff --git a/products/utidb/internal/tidb/list_backup.go b/products/utidb/internal/tidb/list_backup.go new file mode 100644 index 0000000000..c69805d5b2 --- /dev/null +++ b/products/utidb/internal/tidb/list_backup.go @@ -0,0 +1,56 @@ +package tidb + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/tidb" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newListBackup ucloud utidb list-backup +func newListBackup(ctx *cli.Context) *cobra.Command { + var id string + var limit, offset int + + client := cli.NewServiceClient(ctx, tidb.NewClient) + req := client.NewListTiDBClusterBackupRequest() + + cmd := &cobra.Command{ + Use: "list-backup", + Short: "List backups of a UTiDB instance", + Long: "List backups of a UTiDB instance", + Run: func(c *cobra.Command, args []string) { + params := mergeCommonParams(req.GetRegion(), req.GetZone(), req.GetProjectId(), map[string]interface{}{ + "Id": ctx.PickResourceID(id), + "Limit": limit, + "Offset": offset, + }) + payload, err := invokeAPI(ctx, "ListTiDBClusterBackup", params) + if err != nil { + handleAPIError(ctx, err) + return + } + ctx.PrintList(parseBackupRowsFromPayload(payload)) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVar(&id, "utidb-id", "", "Required. Resource ID of the UTiDB instance") + flags.IntVar(&limit, "limit", 30, "Optional. The maximum number of resources per page") + flags.IntVar(&offset, "offset", 0, "Optional. The index of resource which start to list") + + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("utidb-id") + command.SetCompletion(cmd, "utidb-id", func() []string { + return listResourceIDs(ctx, nil, req.GetRegion(), req.GetZone(), req.GetProjectId()) + }) + + return cmd +} diff --git a/products/utidb/internal/tidb/list_specs.go b/products/utidb/internal/tidb/list_specs.go new file mode 100644 index 0000000000..75a45cc540 --- /dev/null +++ b/products/utidb/internal/tidb/list_specs.go @@ -0,0 +1,58 @@ +package tidb + +import ( + "strings" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/tidb" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newListSpecs ucloud utidb list-specs +func newListSpecs(ctx *cli.Context) *cobra.Command { + var nodeTypes string + + client := cli.NewServiceClient(ctx, tidb.NewClient) + req := client.NewGetTiDBClusterUhostSpecsRequest() + + cmd := &cobra.Command{ + Use: "list-specs", + Short: "List available uhost specs", + Long: helpListSpecsLong, + Run: func(c *cobra.Command, args []string) { + types := strings.Split(nodeTypes, ",") + for i := range types { + types[i] = strings.TrimSpace(types[i]) + } + specs, err := getTiDBClusterUhostSpecs(ctx, req.GetRegion(), req.GetZone(), req.GetProjectId(), types) + if err != nil { + handleAPIError(ctx, err) + return + } + rows := []specRow{} + for _, s := range specs { + rows = append(rows, newSpecRowFromData(s)) + } + ctx.PrintList(rows) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVar(&nodeTypes, "node-types", "", "Required. Comma-separated node types: tidb, tikv, pd, tiflash") + + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("node-types") + command.SetCompletion(cmd, "node-types", func() []string { + return listNodeTypes(ctx, req.GetRegion(), req.GetZone()) + }) + + return cmd +} diff --git a/products/utidb/internal/tidb/modify_spec.go b/products/utidb/internal/tidb/modify_spec.go new file mode 100644 index 0000000000..359f7b3a9e --- /dev/null +++ b/products/utidb/internal/tidb/modify_spec.go @@ -0,0 +1,111 @@ +package tidb + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/tidb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// parseModifySpecNodeConfig parses the CLI node-config string for modify-spec. +// Format: ConfigId=xxx,ServerType=tidb +func parseModifySpecNodeConfig(s string) (tidb.ModifyTiDBClusterUhostSpecsParamNodeConfig, error) { + var cfg tidb.ModifyTiDBClusterUhostSpecsParamNodeConfig + parts := strings.Split(s, ",") + for _, part := range parts { + kv := strings.SplitN(part, "=", 2) + if len(kv) != 2 { + return cfg, fmt.Errorf("invalid node-config segment %q, expected key=value", part) + } + key := strings.TrimSpace(kv[0]) + val := strings.TrimSpace(kv[1]) + switch key { + case "ConfigId": + cfg.ConfigId = sdk.String(val) + case "ServerType": + cfg.ServerType = sdk.String(val) + default: + return cfg, fmt.Errorf("unknown node-config key %q", key) + } + } + if cfg.ConfigId == nil || cfg.ServerType == nil { + return cfg, fmt.Errorf("node-config must include ConfigId and ServerType") + } + if err := validateServerType(*cfg.ServerType); err != nil { + return cfg, err + } + return cfg, nil +} + +// newModifySpec ucloud utidb modify-spec +func newModifySpec(ctx *cli.Context) *cobra.Command { + var id, nodeConfig string + var startTime int + var async bool + + client := cli.NewServiceClient(ctx, tidb.NewClient) + req := client.NewModifyTiDBClusterUhostSpecsRequest() + + cmd := &cobra.Command{ + Use: "modify-spec", + Short: "Modify uhost specs of a UTiDB instance", + Long: helpModifySpecLong, + Run: func(c *cobra.Command, args []string) { + cfg, err := parseModifySpecNodeConfig(nodeConfig) + if err != nil { + ctx.HandleError(err) + return + } + + pickedID := ctx.PickResourceID(id) + params := mergeCommonParams(req.GetRegion(), req.GetZone(), req.GetProjectId(), map[string]interface{}{ + "Id": pickedID, + }) + params["NodeConfig"] = modifySpecNodeConfigToMap(cfg) + if startTime != 0 { + params["StartTime"] = startTime + } + + _, err = invokeAPI(ctx, "ModifyTiDBClusterUhostSpecs", params) + if err != nil { + handleAPIError(ctx, err) + return + } + + w := ctx.ProgressWriter() + if async { + fmt.Fprintf(w, "utidb[%s] is modifying spec\n", pickedID) + } else { + text := fmt.Sprintf("utidb[%s] is modifying spec", pickedID) + spollUpgrade(ctx, w, req.GetRegion(), req.GetZone(), req.GetProjectId(), pickedID, text) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: pickedID, Action: "modify-spec", Status: "Modifying"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVar(&id, "utidb-id", "", "Required. Resource ID of the UTiDB instance") + flags.StringVar(&nodeConfig, "node-config", "", "Required. ConfigId=xxx,ServerType=tidb|tikv|pd|tiflash") + flags.IntVar(&startTime, "start-time", 0, "Optional. Task start time") + flags.BoolVar(&async, "async", false, "Optional. Do not wait for modify-spec to finish") + + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("utidb-id") + cmd.MarkFlagRequired("node-config") + command.SetCompletion(cmd, "utidb-id", func() []string { + return listResourceIDs(ctx, nil, req.GetRegion(), req.GetZone(), req.GetProjectId()) + }) + + return cmd +} diff --git a/products/utidb/internal/tidb/parse.go b/products/utidb/internal/tidb/parse.go new file mode 100644 index 0000000000..2f5dd971d7 --- /dev/null +++ b/products/utidb/internal/tidb/parse.go @@ -0,0 +1,107 @@ +package tidb + +import ( + "fmt" + "strings" + + "github.com/ucloud/ucloud-sdk-go/services/tidb" +) + +func parseUhostSpecsFromPayload(payload map[string]interface{}) []tidb.UhostSpecs { + data, ok := payload["Data"].([]interface{}) + if !ok { + return nil + } + specs := make([]tidb.UhostSpecs, 0, len(data)) + for _, item := range data { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + specs = append(specs, tidb.UhostSpecs{ + ConfigId: stringVal(m["ConfigId"]), + ConfigName: stringVal(m["ConfigName"]), + NodeType: stringVal(m["NodeType"]), + CoreNum: intVal(m["CoreNum"]), + Memory: intVal(m["Memory"]), + MinDiskCapacity: intVal(m["MinDiskCapacity"]), + MaxDiskCapacity: intVal(m["MaxDiskCapacity"]), + DiskStep: intVal(m["DiskStep"]), + }) + } + return specs +} + +func parseBackupRowsFromPayload(payload map[string]interface{}) []backupRow { + data, ok := payload["Data"].([]interface{}) + if !ok { + return nil + } + rows := make([]backupRow, 0, len(data)) + for _, item := range data { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + rows = append(rows, backupRow{ + BackupID: stringVal(m["BackupId"]), + BackupType: stringVal(m["BackupType"]), + State: stringVal(m["State"]), + BackupSize: intVal(m["BackupSize"]), + BackupStartTime: intVal(m["BackupStartTime"]), + BackupEndTime: intVal(m["BackupEndTime"]), + }) + } + return rows +} + +var clusterServerKeys = []string{"TiDBServers", "TiKVServers", "PDServers", "TiFlashServers"} + +func extractServerIDs(payload map[string]interface{}) []string { + data, _ := payload["Data"].(map[string]interface{}) + if data == nil { + return nil + } + cluster, _ := data["TiDBCluster"].(map[string]interface{}) + if cluster == nil { + cluster = data + } + var ids []string + for _, key := range clusterServerKeys { + servers, _ := cluster[key].([]interface{}) + for _, item := range servers { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + serverID := stringVal(m["ServerId"]) + if serverID == "" { + continue + } + host := stringVal(m["HostIp"]) + nodeType := strings.TrimSuffix(key, "Servers") + if host != "" { + ids = append(ids, fmt.Sprintf("%s/%s@%s", serverID, strings.ToLower(nodeType), host)) + } else { + ids = append(ids, fmt.Sprintf("%s/%s", serverID, strings.ToLower(nodeType))) + } + } + } + return ids +} + +func stringVal(v interface{}) string { + s, _ := v.(string) + return s +} + +func intVal(v interface{}) int { + switch n := v.(type) { + case float64: + return int(n) + case int: + return n + default: + return 0 + } +} diff --git a/products/utidb/internal/tidb/poll.go b/products/utidb/internal/tidb/poll.go new file mode 100644 index 0000000000..1c67c6357b --- /dev/null +++ b/products/utidb/internal/tidb/poll.go @@ -0,0 +1,54 @@ +package tidb + +import ( + "io" + + "github.com/ucloud/ucloud-sdk-go/services/tidb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func createPollTargets() []string { + return []string{stateAvailable, stateRunning, stateCreateFail} +} + +func upgradePollTargets() []string { + return []string{stateAvailable, stateRunning, stateUpgradeFail} +} + +func spollCreate(ctx *cli.Context, w io.Writer, region, zone, projectID, id, text string) { + ctx.PollerTo(w, describeByID(ctx, region, zone, projectID)). + Spoll(id, text, createPollTargets()) +} + +func spollUpgrade(ctx *cli.Context, w io.Writer, region, zone, projectID, id, text string) { + ctx.PollerTo(w, describeByID(ctx, region, zone, projectID)). + Spoll(id, text, upgradePollTargets()) +} + +// describeByID returns a poller function that reads a UTiDB instance by ID. +// The returned data is a pointer to UTiDBServiceData so the poller can read its +// State field via reflection. +func describeByID(ctx *cli.Context, region, zone, projectID string) func(string, *request.CommonBase) (interface{}, error) { + return func(id string, _ *request.CommonBase) (interface{}, error) { + client := cli.NewServiceClient(ctx, tidb.NewClient) + req := client.NewGetTiDBClusterServiceRequest() + if region != "" { + req.Region = sdk.String(region) + } + if zone != "" { + req.Zone = sdk.String(zone) + } + if projectID != "" { + req.ProjectId = sdk.String(projectID) + } + req.Id = sdk.String(id) + resp, err := client.GetTiDBClusterService(req) + if err != nil { + return nil, err + } + return &resp.Data, nil + } +} diff --git a/products/utidb/internal/tidb/resize_disk.go b/products/utidb/internal/tidb/resize_disk.go new file mode 100644 index 0000000000..3175bb0d72 --- /dev/null +++ b/products/utidb/internal/tidb/resize_disk.go @@ -0,0 +1,120 @@ +package tidb + +import ( + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/tidb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// parseResizeDiskNodeConfig parses the CLI node-config string for resize-disk. +// Format: DiskSize=N,ServerType=tidb +func parseResizeDiskNodeConfig(s string) (tidb.ModifyTiDBClusterUhostDiskParamNodeConfig, error) { + var cfg tidb.ModifyTiDBClusterUhostDiskParamNodeConfig + parts := strings.Split(s, ",") + for _, part := range parts { + kv := strings.SplitN(part, "=", 2) + if len(kv) != 2 { + return cfg, fmt.Errorf("invalid node-config segment %q, expected key=value", part) + } + key := strings.TrimSpace(kv[0]) + val := strings.TrimSpace(kv[1]) + switch key { + case "DiskSize": + n, err := strconv.Atoi(val) + if err != nil { + return cfg, fmt.Errorf("invalid DiskSize %q: %w", val, err) + } + cfg.DiskSize = sdk.Int(n) + case "ServerType": + cfg.ServerType = sdk.String(val) + default: + return cfg, fmt.Errorf("unknown node-config key %q", key) + } + } + if cfg.DiskSize == nil || cfg.ServerType == nil { + return cfg, fmt.Errorf("node-config must include DiskSize and ServerType") + } + if err := validateServerType(*cfg.ServerType); err != nil { + return cfg, err + } + return cfg, nil +} + +// newResizeDisk ucloud utidb resize-disk +func newResizeDisk(ctx *cli.Context) *cobra.Command { + var id, scaleType, nodeConfig string + var startTime int + var async bool + + client := cli.NewServiceClient(ctx, tidb.NewClient) + req := client.NewModifyTiDBClusterUhostDiskRequest() + + cmd := &cobra.Command{ + Use: "resize-disk", + Short: "Resize disk of a UTiDB instance", + Long: helpResizeDiskLong, + Run: func(c *cobra.Command, args []string) { + cfg, err := parseResizeDiskNodeConfig(nodeConfig) + if err != nil { + ctx.HandleError(err) + return + } + + pickedID := ctx.PickResourceID(id) + params := mergeCommonParams(req.GetRegion(), req.GetZone(), req.GetProjectId(), map[string]interface{}{ + "Id": pickedID, + "ScaleType": scaleType, + }) + params["NodeConfig"] = resizeDiskNodeConfigToMap(cfg) + if startTime != 0 { + params["StartTime"] = startTime + } + + _, err = invokeAPI(ctx, "ModifyTiDBClusterUhostDisk", params) + if err != nil { + handleAPIError(ctx, err) + return + } + + w := ctx.ProgressWriter() + if async { + fmt.Fprintf(w, "utidb[%s] is resizing disk\n", pickedID) + } else { + text := fmt.Sprintf("utidb[%s] is resizing disk", pickedID) + spollUpgrade(ctx, w, req.GetRegion(), req.GetZone(), req.GetProjectId(), pickedID, text) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: pickedID, Action: "resize-disk", Status: "Resizing"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVar(&id, "utidb-id", "", "Required. Resource ID of the UTiDB instance") + flags.StringVar(&scaleType, "scale-type", "", "Required. SCALEOUT (expand disk) or SCALEIN (shrink disk)") + flags.StringVar(&nodeConfig, "node-config", "", "Required. DiskSize=N,ServerType=tidb|tikv|pd|tiflash") + flags.IntVar(&startTime, "start-time", 0, "Optional. Task start time") + flags.BoolVar(&async, "async", false, "Optional. Do not wait for resize to finish") + + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("utidb-id") + cmd.MarkFlagRequired("scale-type") + cmd.MarkFlagRequired("node-config") + command.SetFlagValues(cmd, "scale-type", "SCALEOUT", "SCALEIN") + command.SetCompletion(cmd, "utidb-id", func() []string { + return listResourceIDs(ctx, nil, req.GetRegion(), req.GetZone(), req.GetProjectId()) + }) + + return cmd +} diff --git a/products/utidb/internal/tidb/rows.go b/products/utidb/internal/tidb/rows.go new file mode 100644 index 0000000000..83161275a8 --- /dev/null +++ b/products/utidb/internal/tidb/rows.go @@ -0,0 +1,69 @@ +package tidb + +import ( + "github.com/ucloud/ucloud-sdk-go/services/tidb" +) + +// instanceRow is the table row for a UTiDB instance. +type instanceRow struct { + ID string + Name string + State string + DTType int + Port int + IP string + Version string + CreateTime int + VPCID string + SubnetID string +} + +func newInstanceRowFromData(d tidb.UTiDBServiceData) instanceRow { + return instanceRow{ + ID: d.Id, + Name: d.Name, + State: d.State, + DTType: d.DTType, + Port: d.Port, + IP: d.Ip, + Version: d.Version, + CreateTime: d.CreateTime, + VPCID: d.VPCId, + SubnetID: d.SubnetId, + } +} + +// backupRow is the table row for a UTiDB backup. +type backupRow struct { + BackupID string + BackupType string + State string + BackupSize int + BackupStartTime int + BackupEndTime int +} + +// specRow is the table row for a UTiDB uhost spec. +type specRow struct { + ConfigID string + ConfigName string + NodeType string + CoreNum int + Memory int + MinDiskCapacity int + MaxDiskCapacity int + DiskStep int +} + +func newSpecRowFromData(d tidb.UhostSpecs) specRow { + return specRow{ + ConfigID: d.ConfigId, + ConfigName: d.ConfigName, + NodeType: d.NodeType, + CoreNum: d.CoreNum, + Memory: d.Memory, + MinDiskCapacity: d.MinDiskCapacity, + MaxDiskCapacity: d.MaxDiskCapacity, + DiskStep: d.DiskStep, + } +} diff --git a/products/utidb/internal/tidb/scale_node.go b/products/utidb/internal/tidb/scale_node.go new file mode 100644 index 0000000000..6a81a215c5 --- /dev/null +++ b/products/utidb/internal/tidb/scale_node.go @@ -0,0 +1,134 @@ +package tidb + +import ( + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-sdk-go/services/tidb" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// parseScaleNodeConfig parses the CLI node-config string for scale-node. +// Format: ConfigId=xxx,NodeCount=N,ServerType=tidb +func parseScaleNodeConfig(s string) (tidb.ModifyTiDBClusterNodeParamNodeConfig, error) { + var cfg tidb.ModifyTiDBClusterNodeParamNodeConfig + parts := strings.Split(s, ",") + for _, part := range parts { + kv := strings.SplitN(part, "=", 2) + if len(kv) != 2 { + return cfg, fmt.Errorf("invalid node-config segment %q, expected key=value", part) + } + key := strings.TrimSpace(kv[0]) + val := strings.TrimSpace(kv[1]) + switch key { + case "ConfigId": + cfg.ConfigId = sdk.String(val) + case "NodeCount": + n, err := strconv.Atoi(val) + if err != nil { + return cfg, fmt.Errorf("invalid NodeCount %q: %w", val, err) + } + cfg.NodeCount = sdk.Int(n) + case "ServerType": + cfg.ServerType = sdk.String(val) + default: + return cfg, fmt.Errorf("unknown node-config key %q", key) + } + } + if cfg.ConfigId == nil || cfg.NodeCount == nil || cfg.ServerType == nil { + return cfg, fmt.Errorf("node-config must include ConfigId, NodeCount and ServerType") + } + if err := validateServerType(*cfg.ServerType); err != nil { + return cfg, err + } + return cfg, nil +} + +// newScaleNode ucloud utidb scale-node +func newScaleNode(ctx *cli.Context) *cobra.Command { + var id, scaleType, nodeConfig, serverID string + var startTime int + var async bool + + client := cli.NewServiceClient(ctx, tidb.NewClient) + req := client.NewModifyTiDBClusterNodeRequest() + + cmd := &cobra.Command{ + Use: "scale-node", + Short: "Scale nodes of a UTiDB instance (SCALEOUT/SCALEIN)", + Long: helpScaleNodeLong, + Run: func(c *cobra.Command, args []string) { + if scaleType == "SCALEIN" && serverID == "" { + ctx.HandleError(fmt.Errorf("server-id is required when scale-type is SCALEIN")) + return + } + + cfg, err := parseScaleNodeConfig(nodeConfig) + if err != nil { + ctx.HandleError(err) + return + } + + pickedID := ctx.PickResourceID(id) + params := mergeCommonParams(req.GetRegion(), req.GetZone(), req.GetProjectId(), map[string]interface{}{ + "Id": pickedID, + "ScaleType": scaleType, + }) + params["NodeConfig"] = scaleNodeConfigToMap(cfg) + if serverID != "" { + params["ServerId"] = serverID + } + if startTime != 0 { + params["StartTime"] = startTime + } + + _, err = invokeAPI(ctx, "ModifyTiDBClusterNode", params) + if err != nil { + handleAPIError(ctx, err) + return + } + + w := ctx.ProgressWriter() + if async { + fmt.Fprintf(w, "utidb[%s] is scaling nodes\n", pickedID) + } else { + text := fmt.Sprintf("utidb[%s] is scaling nodes", pickedID) + spollUpgrade(ctx, w, req.GetRegion(), req.GetZone(), req.GetProjectId(), pickedID, text) + } + ctx.EmitResult(cli.OpResultRow{ResourceID: pickedID, Action: "scale-node", Status: "Scaling"}) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVar(&id, "utidb-id", "", "Required. Resource ID of the UTiDB instance") + flags.StringVar(&scaleType, "scale-type", "", "Required. SCALEOUT (expand) or SCALEIN (shrink; requires --server-id)") + flags.StringVar(&nodeConfig, "node-config", "", "Required. ConfigId=xxx,NodeCount=N,ServerType=tidb|tikv|pd|tiflash (target count after scale)") + flags.StringVar(&serverID, "server-id", "", "Required for SCALEIN. Server ID of the node to remove (tab completion lists cluster nodes)") + flags.IntVar(&startTime, "start-time", 0, "Optional. Task start time") + flags.BoolVar(&async, "async", false, "Optional. Do not wait for scaling to finish") + + ctx.BindRegion(cmd, req) + ctx.BindZone(cmd, req) + ctx.BindProjectID(cmd, req) + + cmd.MarkFlagRequired("utidb-id") + cmd.MarkFlagRequired("scale-type") + cmd.MarkFlagRequired("node-config") + command.SetFlagValues(cmd, "scale-type", "SCALEOUT", "SCALEIN") + command.SetCompletion(cmd, "utidb-id", func() []string { + return listResourceIDs(ctx, nil, req.GetRegion(), req.GetZone(), req.GetProjectId()) + }) + command.SetCompletion(cmd, "server-id", func() []string { + return listServerIDs(ctx, req.GetRegion(), req.GetZone(), req.GetProjectId(), ctx.PickResourceID(id)) + }) + + return cmd +} diff --git a/products/utidb/internal/tidb/status.go b/products/utidb/internal/tidb/status.go new file mode 100644 index 0000000000..d06c4eeb7e --- /dev/null +++ b/products/utidb/internal/tidb/status.go @@ -0,0 +1,14 @@ +package tidb + +// UTiDB domain state constants used by pollers / EmitResult. +// Terminal states validated on prod (cn-bj2): Available after create/scale/resize; +// delete removes the instance (poll may hit Deleted or describe error depending on timing). +const ( + stateAvailable = "Available" + stateRunning = "Running" + stateDeleted = "Deleted" + stateCreateFail = "CreateFailed" + stateDeleteFail = "DeleteFailed" + stateBackingUp = "BackingUp" + stateUpgradeFail = "UpgradeFailed" +) diff --git a/products/utidb/internal/tidb/validation.go b/products/utidb/internal/tidb/validation.go new file mode 100644 index 0000000000..8358610493 --- /dev/null +++ b/products/utidb/internal/tidb/validation.go @@ -0,0 +1,23 @@ +package tidb + +import ( + "fmt" + "strings" +) + +// serverTypeValues lists accepted ServerType values (CLI input, case-insensitive). +var serverTypeValues = []string{"tidb", "tikv", "pd", "tiflash"} + +func helpServerTypes() string { + return strings.Join(serverTypeValues, ", ") +} + +func validateServerType(serverType string) error { + st := strings.ToLower(strings.TrimSpace(serverType)) + for _, v := range serverTypeValues { + if st == v { + return nil + } + } + return fmt.Errorf("invalid ServerType %q: must be one of %s", serverType, helpServerTypes()) +} diff --git a/products/utidb/internal/tidb/validation_test.go b/products/utidb/internal/tidb/validation_test.go new file mode 100644 index 0000000000..c00f71287a --- /dev/null +++ b/products/utidb/internal/tidb/validation_test.go @@ -0,0 +1,14 @@ +package tidb + +import "testing" + +func TestValidateServerType(t *testing.T) { + for _, st := range []string{"tidb", "TiKV", "PD", "tiflash"} { + if err := validateServerType(st); err != nil { + t.Fatalf("ServerType %q: %v", st, err) + } + } + if err := validateServerType("mysql"); err == nil { + t.Fatal("want error for unknown ServerType") + } +} diff --git a/products/utidb/product.go b/products/utidb/product.go new file mode 100644 index 0000000000..4b99c65052 --- /dev/null +++ b/products/utidb/product.go @@ -0,0 +1,21 @@ +package utidb + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/products/utidb/internal/tidb" +) + +type product struct{} + +// New returns the utidb product (registered via hack/gen-products). +func New() cli.Product { return product{} } + +func (product) Metadata() cli.Metadata { + return cli.Metadata{Name: "utidb", Commands: []string{"utidb"}} +} + +func (product) NewCommand(ctx *cli.Context) []*cobra.Command { + return []*cobra.Command{tidb.NewCommand(ctx)} +} diff --git a/products/utidb/product.yaml b/products/utidb/product.yaml new file mode 100644 index 0000000000..97fc057c8f --- /dev/null +++ b/products/utidb/product.yaml @@ -0,0 +1,8 @@ +# products/utidb/product.yaml — UTiDB 产品元数据 +name: utidb +owners: + - xingxingso + - jinfz12 +commands: + - utidb +enabled: true diff --git a/products/utidb/testdata/cmdtree.golden b/products/utidb/testdata/cmdtree.golden new file mode 100644 index 0000000000..0223a1bb21 --- /dev/null +++ b/products/utidb/testdata/cmdtree.golden @@ -0,0 +1,91 @@ +ucloud utidb use=utidb short=Manipulate UTiDB instances on UCloud platform +ucloud utidb backup use=backup short=Start a backup of a UTiDB instance + flag=backup-filter short= default= required= + flag=backup-ts short= default= required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=utidb-id short= default= required=true + flag=zone short= default= required= +ucloud utidb create use=create short=Create a UTiDB instance + flag=activity-id short= default=0 required= + flag=alert-strategy-ids short= default=[] required= + flag=async short= default=false required= + flag=charge-type short= default= required=true + flag=coupon short= default= required= + flag=db-version short= default= required= + flag=dt-type short= default= required=true + flag=ip short= default= required= + flag=labels short= default=[] required= + flag=name short= default= required=true + flag=node-config short= default=[] required=true + flag=password short= default= required=true + flag=port short= default= required= + flag=project-id short= default= required= + flag=promotion-id short= default= required= + flag=pub-ulb-id short= default= required= + flag=quantity short= default=1 required=true + flag=region short= default= required= + flag=rule-id short= default=0 required= + flag=sec-group-info short= default=[] required= + flag=subnet-id short= default= required=true + flag=template-id short= default= required= + flag=vpc-id short= default= required=true + flag=zone short= default= required= +ucloud utidb delete use=delete short=Delete a UTiDB instance + flag=async short= default=false required= + flag=delete-backup short= default=false required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=utidb-id short= default= required=true + flag=yes short=y default=false required= + flag=zone short= default= required= +ucloud utidb describe use=describe short=Show details of a UTiDB instance + flag=project-id short= default= required= + flag=region short= default= required= + flag=utidb-id short= default= required=true + flag=zone short= default= required= +ucloud utidb list use=list short=List UTiDB instances + flag=limit short= default= required= + flag=offset short= default= required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud utidb list-backup use=list-backup short=List backups of a UTiDB instance + flag=limit short= default=30 required= + flag=offset short= default=0 required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=utidb-id short= default= required=true + flag=zone short= default= required= +ucloud utidb list-specs use=list-specs short=List available uhost specs + flag=node-types short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=zone short= default= required= +ucloud utidb modify-spec use=modify-spec short=Modify uhost specs of a UTiDB instance + flag=async short= default=false required= + flag=node-config short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=start-time short= default=0 required= + flag=utidb-id short= default= required=true + flag=zone short= default= required= +ucloud utidb resize-disk use=resize-disk short=Resize disk of a UTiDB instance + flag=async short= default=false required= + flag=node-config short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=scale-type short= default= required=true + flag=start-time short= default=0 required= + flag=utidb-id short= default= required=true + flag=zone short= default= required= +ucloud utidb scale-node use=scale-node short=Scale nodes of a UTiDB instance (SCALEOUT/SCALEIN) + flag=async short= default=false required= + flag=node-config short= default= required=true + flag=project-id short= default= required= + flag=region short= default= required= + flag=scale-type short= default= required=true + flag=server-id short= default= required= + flag=start-time short= default=0 required= + flag=utidb-id short= default= required=true + flag=zone short= default= required= diff --git a/products/utidb/testdata/completion.golden b/products/utidb/testdata/completion.golden new file mode 100644 index 0000000000..7ab47024b4 --- /dev/null +++ b/products/utidb/testdata/completion.golden @@ -0,0 +1,43 @@ +ucloud utidb backup project-id dynamic +ucloud utidb backup region dynamic +ucloud utidb backup utidb-id dynamic +ucloud utidb backup zone dynamic +ucloud utidb create charge-type static Dynamic,Month,Trial,Year +ucloud utidb create dt-type static 10,20 +ucloud utidb create project-id dynamic +ucloud utidb create region dynamic +ucloud utidb create zone dynamic +ucloud utidb delete project-id dynamic +ucloud utidb delete region dynamic +ucloud utidb delete utidb-id dynamic +ucloud utidb delete zone dynamic +ucloud utidb describe project-id dynamic +ucloud utidb describe region dynamic +ucloud utidb describe utidb-id dynamic +ucloud utidb describe zone dynamic +ucloud utidb list project-id dynamic +ucloud utidb list region dynamic +ucloud utidb list zone dynamic +ucloud utidb list-backup project-id dynamic +ucloud utidb list-backup region dynamic +ucloud utidb list-backup utidb-id dynamic +ucloud utidb list-backup zone dynamic +ucloud utidb list-specs node-types dynamic +ucloud utidb list-specs project-id dynamic +ucloud utidb list-specs region dynamic +ucloud utidb list-specs zone dynamic +ucloud utidb modify-spec project-id dynamic +ucloud utidb modify-spec region dynamic +ucloud utidb modify-spec utidb-id dynamic +ucloud utidb modify-spec zone dynamic +ucloud utidb resize-disk project-id dynamic +ucloud utidb resize-disk region dynamic +ucloud utidb resize-disk scale-type static SCALEIN,SCALEOUT +ucloud utidb resize-disk utidb-id dynamic +ucloud utidb resize-disk zone dynamic +ucloud utidb scale-node project-id dynamic +ucloud utidb scale-node region dynamic +ucloud utidb scale-node scale-type static SCALEIN,SCALEOUT +ucloud utidb scale-node server-id static +ucloud utidb scale-node utidb-id dynamic +ucloud utidb scale-node zone dynamic