diff --git a/.gitignore b/.gitignore
index 33b28cb225..513fbbbf93 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@ out
.idea/
bin/
.DS_Store
+.codegraph/
diff --git a/cmd/configure.go b/cmd/configure.go
index 9bd82c6273..8b4785e976 100644
--- a/cmd/configure.go
+++ b/cmd/configure.go
@@ -99,7 +99,6 @@ func NewCmdInit() *cobra.Command {
fmt.Println("No default project, skip.")
}
platform.ConfigIns.Timeout = platform.DefaultTimeoutSec
- platform.ConfigIns.BaseURL = platform.DefaultBaseURL
platform.ConfigIns.MaxRetryTimes = sdk.Int(platform.DefaultMaxRetryTimes)
platform.ConfigIns.Active = true
fmt.Printf("Configured default base url:%s\n", platform.ConfigIns.BaseURL)
@@ -223,6 +222,7 @@ func NewCmdConfig() *cobra.Command {
PublicKey: cfg.PublicKey,
Profile: cfg.Profile,
BaseURL: cfg.BaseURL,
+ ChannelKey: cfg.ChannelKey,
Timeout: cfg.Timeout,
Active: cfg.Active,
Region: cfg.Region,
@@ -247,6 +247,14 @@ func NewCmdConfig() *cobra.Command {
cacheConfig.BaseURL = cfg.BaseURL
}
+ //channel-key 属连接类参数,与 base-url 同批应用:必须早于下方 region/project
+ //远程校验,否则校验请求不带 key,专属云 profile 在配置时即报错而配不上。
+ //用 Changed() 而非空值判断:空是合法值(专属云切回主站需清除它),
+ //--channel-key "" 应能清空,这与 base-url「空=不改」的既有局限不同。
+ if c.Flags().Changed("channel-key") {
+ cacheConfig.ChannelKey = cfg.ChannelKey
+ }
+
if cfg.Timeout == 0 {
if cacheConfig.Timeout == 0 {
cacheConfig.Timeout = platform.DefaultTimeoutSec
@@ -342,6 +350,7 @@ func NewCmdConfig() *cobra.Command {
flags.StringVar(&cfg.Zone, "zone", "", "Optional. Set default zone. For instance 'cn-bj2-02'. See 'ucloud region'")
flags.StringVar(&cfg.ProjectID, "project-id", "", "Optional. Set default project. For instance 'org-xxxxxx'. See 'ucloud project list")
flags.StringVar(&cfg.BaseURL, "base-url", "", "Optional. Set default base url. For instance 'https://api.ucloud.cn/'")
+ flags.StringVar(&cfg.ChannelKey, "channel-key", "", "Optional. Set channel-key for a dedicated cloud channel that reuses the main-site domain. For instance 'ch_xxx'. Leave empty for the main site or a channel with its own domain")
flags.IntVar(&cfg.Timeout, "timeout-sec", 0, "Optional. Set default timeout for requesting API. Unit: seconds")
cfg.MaxRetryTimes = flags.Int("max-retry-times", 0, "Optional. Set default max-retry-times for idempotent APIs which can be called many times without side effect, for example 'ReleaseEIP'")
flags.StringVar(&active, "active", "", "Optional. Mark the profile to be effective or not. Accept valeus: true or false")
@@ -423,6 +432,7 @@ func NewCmdConfigAdd() *cobra.Command {
flags.StringVar(&cfg.Zone, "zone", "", "Optional. Set default zone. For instance 'cn-bj2-02'. See 'ucloud region'")
flags.StringVar(&cfg.ProjectID, "project-id", "", "Optional. Set default project. For instance 'org-xxxxxx'. See 'ucloud project list")
flags.StringVar(&cfg.BaseURL, "base-url", platform.DefaultBaseURL, "Optional. Set default base url. For instance 'https://api.ucloud.cn/'")
+ flags.StringVar(&cfg.ChannelKey, "channel-key", "", "Optional. Set channel-key for a dedicated cloud channel that reuses the main-site domain. For instance 'ch_xxx'. Leave empty for the main site or a channel with its own domain")
flags.IntVar(&cfg.Timeout, "timeout-sec", platform.DefaultTimeoutSec, "Optional. Set default timeout for requesting API. Unit: seconds")
cfg.MaxRetryTimes = flags.Int("max-retry-times", platform.DefaultMaxRetryTimes, "Optional. Set default max-retry-times for idempotent APIs which can be called many times without side effect, for example 'ReleaseEIP'")
flags.StringVar(&active, "active", "false", "Optional. Mark the profile to be effective or not. Accept valeus: true or false")
@@ -472,12 +482,19 @@ func NewCmdConfigUpdate() *cobra.Command {
platform.AggConfigListIns.UpdateAggConfig(cacheConfig)
}
- //先应用连接类参数(base-url/timeout-sec/max-retry-times),确保接下来的远程校验
+ //先应用连接类参数(base-url/channel-key/timeout-sec/max-retry-times),确保接下来的远程校验
//打到新网关而不是旧的(可能已不可用的)网关,避免旧base-url坏掉后无法改回的死锁
if cfg.BaseURL != "" {
cacheConfig.BaseURL = cfg.BaseURL
}
+ //channel-key 同属连接类参数:专属云 profile 的远程校验请求必须带上它,
+ //否则网关报 174 而校验失败,profile 永远配不上。
+ //Changed() 使 --channel-key "" 可清除(专属云切回主站的场景)。
+ if c.Flags().Changed("channel-key") {
+ cacheConfig.ChannelKey = cfg.ChannelKey
+ }
+
if timeout != "" {
seconds, err := strconv.Atoi(timeout)
if err != nil {
@@ -561,6 +578,7 @@ func NewCmdConfigUpdate() *cobra.Command {
flags.StringVar(&cfg.Zone, "zone", "", "Optional. Set default zone. For instance 'cn-bj2-02'. See 'ucloud region'")
flags.StringVar(&cfg.ProjectID, "project-id", "", "Optional. Set default project. For instance 'org-xxxxxx'. See 'ucloud project list")
flags.StringVar(&cfg.BaseURL, "base-url", "", "Optional. Set default base url. For instance 'https://api.ucloud.cn/'")
+ flags.StringVar(&cfg.ChannelKey, "channel-key", "", "Optional. Set channel-key for a dedicated cloud channel that reuses the main-site domain. For instance 'ch_xxx'. Pass an empty value to clear it")
flags.StringVar(&timeout, "timeout-sec", "", "Optional. Set default timeout for requesting API. Unit: seconds")
flags.StringVar(&maxRetries, "max-retry-times", "", "Optional. Set default max retry times for idempotent APIs which can be called many times without side effect, for example 'ReleaseEIP'")
flags.StringVar(&active, "active", "", "Optional. Mark the profile to be effective")
diff --git a/cmd/configure_test.go b/cmd/configure_test.go
index e90cd82d9b..58b7b3f0ce 100644
--- a/cmd/configure_test.go
+++ b/cmd/configure_test.go
@@ -6,9 +6,13 @@ import (
"io/ioutil"
"net/http"
"net/http/httptest"
+ "os"
"path/filepath"
"strings"
"testing"
+ "time"
+
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
"github.com/ucloud/ucloud-cli/cmd/internal/platform"
)
@@ -203,3 +207,96 @@ func TestInitSaveOverwritesExistingOAuthOnlyProfile(t *testing.T) {
got.AccessToken, got.RefreshToken, got.ExpiresAt)
}
}
+
+// stubStdin 把 os.Stdin 换成预置内容的临时文件,驱动 init 流程里的 fmt.Scanf 交互
+func stubStdin(t *testing.T, input string) {
+ t.Helper()
+ f, err := os.CreateTemp(t.TempDir(), "stdin")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.WriteString(input); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.Seek(0, io.SeekStart); err != nil {
+ t.Fatal(err)
+ }
+ old := os.Stdin
+ os.Stdin = f
+ t.Cleanup(func() {
+ os.Stdin = old
+ f.Close()
+ })
+}
+
+// 回归:init 交互中用户输入的自定义 base-url 必须落盘。曾在流程末尾无条件回填
+// DefaultBaseURL,导致输入的专属云域名只在远程校验期生效、存盘的却是主站默认值,
+// 且回显的也是覆盖后的值,用户无从察觉。
+func TestInitPersistsUserSuppliedBaseURL(t *testing.T) {
+ gateway := fakeGatewayServer(t)
+ defer gateway.Close()
+
+ dir := t.TempDir()
+ cfgPath := filepath.Join(dir, "config.json")
+ credPath := filepath.Join(dir, "credential.json")
+
+ m, err := platform.NewAggConfigManager(cfgPath, credPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // 全新 init 现场:profile 尚未落盘,BaseURL 为包级默认值。timeout/max-retry 取小值让
+ // 远程校验打本地假网关时快速收敛(init 末尾仍会把二者重置为各自默认值)
+ cfg := &platform.AggConfig{
+ Profile: platform.DefaultProfile,
+ BaseURL: platform.DefaultBaseURL,
+ Timeout: 3,
+ MaxRetryTimes: sdk.Int(0),
+ }
+
+ oldCfgPath, oldCredPath := platform.ConfigFilePath, platform.CredentialFilePath
+ oldM, oldC := platform.AggConfigListIns, platform.ConfigIns
+ oldCC, oldAC, oldRT := platform.ClientConfig, platform.AuthCredential, activeRuntime
+ // Run 末尾的 InitConfig 按包级路径读写配置,指向临时目录避免污染真实 ~/.ucloud
+ platform.ConfigFilePath, platform.CredentialFilePath = cfgPath, credPath
+ platform.AggConfigListIns, platform.ConfigIns = m, cfg
+ // ConfigPublicKey/ConfigPrivateKey 直接写 AuthCredential,为 nil 会 panic
+ platform.AuthCredential = &platform.CredentialConfig{}
+ // 末尾 printHello 经 activeRuntime 取 client:钉到假网关,避免测试真的打外网
+ platform.ClientConfig = &sdk.Config{BaseUrl: gateway.URL, Timeout: 3 * time.Second}
+ setActiveRuntimeFromBaseGlobals()
+ defer func() {
+ platform.ConfigFilePath, platform.CredentialFilePath = oldCfgPath, oldCredPath
+ platform.AggConfigListIns, platform.ConfigIns = oldM, oldC
+ platform.ClientConfig, platform.AuthCredential, activeRuntime = oldCC, oldAC, oldRT
+ }()
+
+ // init 依次 Scanf 读取:public-key、private-key、base-url、是否上传日志
+ stubStdin(t, fmt.Sprintf("pub\npri\n%s\nno\n", gateway.URL))
+
+ cmd := NewCmdInit()
+ cmd.Run(cmd, nil)
+
+ // 重新读盘验证持久化,而非只看内存
+ m2, err := platform.NewAggConfigManager(cfgPath, credPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, ok := m2.GetAggConfigByProfile(platform.DefaultProfile)
+ if !ok {
+ t.Fatal("profile default missing after reload")
+ }
+ if got.BaseURL != gateway.URL {
+ t.Errorf("base_url on disk = %q, want user supplied %q (init must not overwrite it with the default %q)",
+ got.BaseURL, gateway.URL, platform.DefaultBaseURL)
+ }
+ // 同一流程里 init 本就该落的默认值,不能被本次修复带偏
+ if got.Timeout != platform.DefaultTimeoutSec {
+ t.Errorf("timeout_sec on disk = %d, want default %d", got.Timeout, platform.DefaultTimeoutSec)
+ }
+ if got.MaxRetryTimes == nil || *got.MaxRetryTimes != platform.DefaultMaxRetryTimes {
+ t.Errorf("max_retry_times on disk = %v, want default %d", got.MaxRetryTimes, platform.DefaultMaxRetryTimes)
+ }
+ if !got.Active {
+ t.Error("active on disk = false, want true")
+ }
+}
diff --git a/cmd/internal/platform/client.go b/cmd/internal/platform/client.go
index 99cd966a4a..e714daa213 100644
--- a/cmd/internal/platform/client.go
+++ b/cmd/internal/platform/client.go
@@ -29,9 +29,11 @@ func newCredHeaderInjector(credConfig *CredentialConfig) sdk.HttpRequestHandler
return req, err
}
if credConfig.AuthMode == AuthModeOAuth {
- // 仅对 form-urlencoded body 剥离(JSON 编码器虽当前不可达,但
- // url.ParseQuery 对 JSON 往往"成功",重编码会悄悄毁掉 body)
- if req.GetHeaderMap()[http.HeaderNameContentType] == http.MimeFormURLEncoded {
+ // 按 Content-Type 分派剥离:编码器决定 body 形态,剥离方式必须与之配对。
+ // 不能只按一种形态盲剥 —— url.ParseQuery 对 JSON 往往"成功",重编码会
+ // 悄悄毁掉 body;不认识的 Content-Type 一律不碰 body。
+ switch req.GetHeaderMap()[http.HeaderNameContentType] {
+ case http.MimeFormURLEncoded:
vals, err := url.ParseQuery(string(req.GetRequestBody()))
if err != nil {
// 剥不掉就明确失败:客户端报错优于网关 171
@@ -42,6 +44,22 @@ func newCredHeaderInjector(credConfig *CredentialConfig) sdk.HttpRequestHandler
if err := req.SetRequestBody([]byte(vals.Encode())); err != nil {
return req, err
}
+ case http.MimeJSON:
+ // JSONEncoder 把 cred.Apply 附加的 Signature/PublicKey 放在 body 顶层
+ // (SDK ucloud/request/encoder_json.go),与 form 分支同构剥离。
+ var payload map[string]interface{}
+ if err := json.Unmarshal(req.GetRequestBody(), &payload); err != nil {
+ return req, fmt.Errorf("strip signature params from oauth json request failed: %w", err)
+ }
+ delete(payload, "Signature")
+ delete(payload, "PublicKey")
+ bs, err := json.Marshal(payload)
+ if err != nil {
+ return req, fmt.Errorf("re-encode oauth json request failed: %w", err)
+ }
+ if err := req.SetRequestBody(bs); err != nil {
+ return req, err
+ }
}
if credConfig.AccessToken != "" {
if err := req.SetHeader("Authorization", "Bearer "+credConfig.AccessToken); err != nil {
@@ -53,13 +71,45 @@ func newCredHeaderInjector(credConfig *CredentialConfig) sdk.HttpRequestHandler
}
}
+// newChannelHeaderInjector 返回专属云渠道头注入 handler。
+//
+// 网关据 channel-key 识别「复用主站域名」的专属云渠道(如 api.ucloud-global.com);
+// 独立域名渠道与主站用户没有这个 key,故空值必须完全不注入该头 —— 与本文件中
+// Cookie/Csrf-Token「空值也照旧 set」的历史行为刻意相反:那是存量契约,而这是新增头,
+// 给全部存量用户平白加一个空头即构成回归。
+//
+// 取值源是 ac 而非 credConfig:channel-key 是接入点配置(与 base_url 同类、成对配置),
+// 不是凭据机制,与 auth_mode 正交(spec auth-guidelines「一个请求只携带一种凭据机制」
+// 说的是凭据,不含本头)。且 newServiceClientForConfig 传入的 ac 正是用户此刻正在配置
+// 的 profile,config 子命令自身的 region/project 远程校验请求因此天然带上正确的 key,
+// 不会出现「配置时校验失败 → 配不上」的死锁。
+//
+// 实测(2026-07-16,真实 combo 账号 + 真实网关):同一 token 同一域名,唯一变量为本头,
+// RetCode 174 → 0;且 Go 的 Header.Set 规范化为 Channel-Key 后网关照常接受。
+func newChannelHeaderInjector(ac *AggConfig) sdk.HttpRequestHandler {
+ return func(c *sdk.Client, req *http.HttpRequest) (*http.HttpRequest, error) {
+ if ac == nil || ac.ChannelKey == "" {
+ return req, nil
+ }
+ return req, req.SetHeader("channel-key", ac.ChannelKey)
+ }
+}
+
// authRetCodeWhitelist 鉴权类 RetCode 白名单(D6)。实测网关(2026-06-11 实探):
// 鉴权失败以 HTTP 200 + RetCode 返回,401 仅作防御性分支保留。
// 174 "Token Not Exists":伪造与已过期的 Bearer 同为 174(已实测确认);属网关
// 前置鉴权拒绝,业务必未执行,重放一次安全。网关团队书面确认仍待补档(spec §7)。
// 170(缺签名,oauth 请求恒带 Bearer 不会触发)、171/172(AK/SK 路径)不入列。
+//
+// 174 的第三种成因(2026-07-16 实测发现,已知且刻意保留现状):channel-key 与账号
+// 所属渠道不匹配时网关同样返回 174(同一有效 token 换成别的渠道 key 即报 174)。
+// 于是本白名单会把「配错 channel-key」误判为 token 过期 → 刷新(refresh_token 轮转、
+// 旧的立即作废)→ 重放 → 仍 174。后果是每次请求白耗一轮刷新,且错误文案把用户导向
+// 重新登录 —— 而重新登录永远治不好。本次不改控制流(改了会波及正常的过期刷新路径)。
+// 排障:OAuth profile 报 174 且重新登录无效时,优先检查 channel_key 是否与账号所属
+// 渠道匹配。
var authRetCodeWhitelist = map[int]bool{
- 174: true, // Token Not Exists:无效或过期 Bearer
+ 174: true, // Token Not Exists:无效或过期 Bearer(亦可能是 channel-key 不匹配,见上)
}
// isAuthFailure 判定是否鉴权类失败:HTTP 401 或 body RetCode 在白名单(网关前置鉴权,业务必未执行)。
@@ -161,8 +211,45 @@ func BuildCredentialFrom(credConfig *CredentialConfig) *auth.Credential {
return buildCredential(credConfig)
}
-// attachHandlers 把三个平台 handler 挂到 service client 上:
-// project-id 归一化、凭据头注入、oauth 反应式重试。
+// normalizeProjectID 把 project-id 从补全带出的 "id/name" 形态还原成纯 id
+// (补全候选由 getProjectList 生成,形如 "org-xxx/ProjectName")。
+//
+// typed 请求 SetProjectId 写进 CommonBase 即可。GenericRequest 要多一步:SDK 的
+// BaseGenericRequest 把 GetProjectId() override 成 payload 优先,却没有 override
+// SetProjectId —— SetProjectId 写的是 CommonBase,而 GetPayload() 末尾用 payload
+// 覆盖 CommonBase(SDK ucloud/request/generic.go),于是归一化被 payload 里的原值
+// 吃掉,"org-x/Name" 原样上行,网关报 RetCode 292 Project [org-x/Name] not exists
+// (2026-07-14 对 api.ucloud.cn 实测)。凡是把 ProjectId 放进 payload map 的产品
+// 都会中招,故在此一并同步 payload。
+//
+// 未发生归一化时(已是纯 id 或为空,即绝大多数调用)直接返回,payload 一字不动 ——
+// 行为与历史逐字节一致。
+func normalizeProjectID(req request.Common) (request.Common, error) {
+ raw := req.GetProjectId()
+ normalized := PickResourceID(raw)
+ if err := req.SetProjectId(normalized); err != nil {
+ return req, err
+ }
+ if raw == normalized {
+ return req, nil
+ }
+ gr, ok := req.(request.GenericRequest)
+ if !ok {
+ return req, nil
+ }
+ payload := gr.GetPayload()
+ if _, exists := payload["ProjectId"]; !exists {
+ return req, nil
+ }
+ payload["ProjectId"] = normalized
+ if err := gr.SetPayload(payload); err != nil {
+ return req, err
+ }
+ return req, nil
+}
+
+// attachHandlers 把平台 handler 挂到 service client 上:
+// project-id 归一化、请求日志、凭据头注入、专属云渠道头注入、oauth 反应式重试。
// credConfig 与 ac 显式传入:NewClient 借此传它自己的构造来源 profile(ac),
// 重试目标必须是构造本 client 的 profile,而非包级 ConfigIns
// (详见 newOAuthRetryHandler 的注释:os.Args 扫描识别不了 -p X/--profile=X,
@@ -172,8 +259,7 @@ func attachHandlersWithManager(sc sdk.ServiceClient, credConfig *CredentialConfi
credConfig = &CredentialConfig{}
}
sc.AddRequestHandler(func(c *sdk.Client, req request.Common) (request.Common, error) {
- err := req.SetProjectId(PickResourceID(req.GetProjectId()))
- return req, err
+ return normalizeProjectID(req)
})
// Platform request logging: every API request is logged uniformly at the SDK
// layer (replaces per-command hand-rolled logging; products no longer build
@@ -185,6 +271,7 @@ func attachHandlersWithManager(sc sdk.ServiceClient, credConfig *CredentialConfi
return req, nil
})
sc.AddHttpRequestHandler(newCredHeaderInjector(credConfig))
+ sc.AddHttpRequestHandler(newChannelHeaderInjector(ac))
sc.AddHttpResponseHandler(newOAuthRetryHandler(credConfig, ac, manager))
}
diff --git a/cmd/internal/platform/client_test.go b/cmd/internal/platform/client_test.go
index fdf114efc9..8b565ec19e 100644
--- a/cmd/internal/platform/client_test.go
+++ b/cmd/internal/platform/client_test.go
@@ -13,6 +13,8 @@ import (
uhttp "github.com/ucloud/ucloud-sdk-go/private/protocol/http"
"github.com/ucloud/ucloud-sdk-go/services/uaccount"
+ "github.com/ucloud/ucloud-sdk-go/ucloud"
+ "github.com/ucloud/ucloud-sdk-go/ucloud/request"
)
func injectorHeaders(t *testing.T, cred *CredentialConfig) map[string]string {
@@ -61,6 +63,144 @@ func TestInjectorOAuthEmptyToken(t *testing.T) {
}
}
+// injectBody 把 body 以指定 Content-Type 过一遍 injector,返回处理后的请求。
+func injectBody(t *testing.T, cred *CredentialConfig, contentType, body string) *uhttp.HttpRequest {
+ t.Helper()
+ req := uhttp.NewHttpRequest()
+ if err := req.SetHeader(uhttp.HeaderNameContentType, contentType); err != nil {
+ t.Fatal(err)
+ }
+ if err := req.SetRequestBody([]byte(body)); err != nil {
+ t.Fatal(err)
+ }
+ out, err := newCredHeaderInjector(cred)(nil, req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return out
+}
+
+// oauth + JSON body:剥离 Signature/PublicKey,其余字段与 Content-Type 原样保留。
+// JSON 编码器此前不可达;products/pgsql(#127) 是第一个走这条路的产品(UPgSQL 网关
+// 无法把 form 的字符串 "100" unmarshal 进 Go 的 *int,RetCode 214001,故切 JSONEncoder)。
+func TestInjectorOAuthStripsJSONSignature(t *testing.T) {
+ body := `{"Action":"ListUPgSQLParamTemplate","Count":100,"PublicKey":"pub","Region":"cn-bj2","Signature":"deadbeef"}`
+ out := injectBody(t, &CredentialConfig{AuthMode: AuthModeOAuth, AccessToken: "tok123"}, uhttp.MimeJSON, body)
+
+ var got map[string]interface{}
+ if err := json.Unmarshal(out.GetRequestBody(), &got); err != nil {
+ t.Fatalf("body is not valid json after strip: %v", err)
+ }
+ if _, ok := got["Signature"]; ok {
+ t.Error("Signature must be stripped from oauth json body")
+ }
+ if _, ok := got["PublicKey"]; ok {
+ t.Error("PublicKey must be stripped from oauth json body")
+ }
+ if got["Action"] != "ListUPgSQLParamTemplate" || got["Region"] != "cn-bj2" {
+ t.Errorf("business fields must survive untouched, got %v", got)
+ }
+ // int 必须仍是 JSON number —— 产品切 JSONEncoder 的全部意义就在于此,
+ // 剥离过程若把它变回字符串就重新踩回 214001。
+ if got["Count"] != float64(100) {
+ t.Errorf("Count = %#v, want JSON number 100", got["Count"])
+ }
+ if out.GetHeaderMap()[uhttp.HeaderNameContentType] != uhttp.MimeJSON {
+ t.Error("Content-Type must stay application/json")
+ }
+ if out.GetHeaderMap()["Authorization"] != "Bearer tok123" {
+ t.Error("Bearer must still be injected for json body")
+ }
+}
+
+// CRITICAL 回归:非 oauth(aksk)模式下 JSON body 必须逐字节不变 ——
+// AK/SK 路径的签名就活在 body 里,碰一下就验签失败。
+func TestInjectorAkskJSONBodyUntouched(t *testing.T) {
+ body := `{"Action":"X","PublicKey":"pub","Signature":"deadbeef"}`
+ out := injectBody(t, &CredentialConfig{PublicKey: "pub", PrivateKey: "pri"}, uhttp.MimeJSON, body)
+ if string(out.GetRequestBody()) != body {
+ t.Errorf("aksk json body must be byte-identical\n got: %s\nwant: %s", out.GetRequestBody(), body)
+ }
+}
+
+// CRITICAL 回归:oauth + form body 行为与历史完全一致(本次只加分支,不动 form)。
+func TestInjectorOAuthFormUnchanged(t *testing.T) {
+ body := "Action=X&PublicKey=pub&Region=cn-bj2&Signature=deadbeef"
+ out := injectBody(t, &CredentialConfig{AuthMode: AuthModeOAuth, AccessToken: "tok"}, uhttp.MimeFormURLEncoded, body)
+ vals, err := url.ParseQuery(string(out.GetRequestBody()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if vals.Has("Signature") || vals.Has("PublicKey") {
+ t.Errorf("form signature params must be stripped, got %s", out.GetRequestBody())
+ }
+ if vals.Get("Action") != "X" || vals.Get("Region") != "cn-bj2" {
+ t.Errorf("form business fields changed: %s", out.GetRequestBody())
+ }
+}
+
+// 不认识的 Content-Type:不碰 body(盲目重编码会毁掉它),但 Bearer 照常注入。
+func TestInjectorOAuthUnknownContentTypeBodyUntouched(t *testing.T) {
+ body := `deadbeef`
+ out := injectBody(t, &CredentialConfig{AuthMode: AuthModeOAuth, AccessToken: "tok"}, "application/xml", body)
+ if string(out.GetRequestBody()) != body {
+ t.Errorf("unknown content-type body must be untouched, got %s", out.GetRequestBody())
+ }
+ if out.GetHeaderMap()["Authorization"] != "Bearer tok" {
+ t.Error("Bearer must still be injected for unknown content-type")
+ }
+}
+
+// 端到端:真实 SDK JSONEncoder + 真实 oauth 凭据,确认
+// (a) SDK 即使凭据为空也会附加 Signature(这正是必须剥离的原因);
+// (b) injector 之后 body 内再无签名参数,只剩 Bearer 一种凭据机制。
+func TestInjectorOAuthJSONEndToEndWithSDKEncoder(t *testing.T) {
+ credConfig := &CredentialConfig{AuthMode: AuthModeOAuth, AccessToken: "tok"}
+ cfg := ucloud.NewConfig()
+ cred := BuildCredentialFrom(credConfig)
+
+ req := &request.CommonBase{}
+ if err := req.SetAction("ListUPgSQLParamTemplate"); err != nil {
+ t.Fatal(err)
+ }
+ if err := req.SetRegion("cn-bj2"); err != nil {
+ t.Fatal(err)
+ }
+ httpReq, err := request.NewJSONEncoder(&cfg, cred).Encode(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var before map[string]interface{}
+ if err := json.Unmarshal(httpReq.GetRequestBody(), &before); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := before["Signature"]; !ok {
+ t.Fatal("premise broken: SDK JSONEncoder no longer attaches Signature for empty credential")
+ }
+
+ out, err := newCredHeaderInjector(credConfig)(nil, httpReq)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var after map[string]interface{}
+ if err := json.Unmarshal(out.GetRequestBody(), &after); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := after["Signature"]; ok {
+ t.Error("Signature survived the injector on a real SDK-encoded json body")
+ }
+ if _, ok := after["PublicKey"]; ok {
+ t.Error("PublicKey survived the injector on a real SDK-encoded json body")
+ }
+ if after["Action"] != "ListUPgSQLParamTemplate" {
+ t.Errorf("Action lost: %v", after)
+ }
+ if out.GetHeaderMap()["Authorization"] != "Bearer tok" {
+ t.Error("Bearer missing after injector")
+ }
+}
+
// recordedRequest 记录业务请求实际携带的 header 与全部参数(query + form 合并)
type recordedRequest struct {
header http.Header
@@ -136,6 +276,132 @@ func TestOAuthProfileWithRetainedKeysDoesNotSign(t *testing.T) {
}
}
+// channelInjectorHeaders 单独驱动渠道头注入 handler,取其产出的 header map。
+// 注意查的是 SetHeader 原样存入的 map(未经 Go 规范化),故 key 必须与注入端字面一致。
+func channelInjectorHeaders(t *testing.T, ac *AggConfig) map[string]string {
+ t.Helper()
+ h := newChannelHeaderInjector(ac)
+ req, err := h(nil, uhttp.NewHttpRequest())
+ if err != nil {
+ t.Fatal(err)
+ }
+ return req.GetHeaderMap()
+}
+
+// 配了 channel_key 的 profile:注入 channel-key 头(AC1)
+func TestChannelInjectorSetsHeader(t *testing.T) {
+ headers := channelInjectorHeaders(t, &AggConfig{ChannelKey: "ch_combo_test"})
+ if headers["channel-key"] != "ch_combo_test" {
+ t.Errorf("channel-key = %q, want ch_combo_test", headers["channel-key"])
+ }
+}
+
+// CRITICAL 零回归(AC2):未配 channel_key 时该头必须完全不存在——不是空值,是键不存在。
+// 主站用户与独立域名专属云渠道恒走此路径,线路字节必须与引入本特性前逐字节一致。
+// 与同文件 Cookie/Csrf-Token「空值也照旧 set」的存量契约刻意相反:那是历史行为,
+// 本头是新增的,注入空头会给全部存量用户构成回归。
+func TestChannelInjectorAbsentWhenEmpty(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ ac *AggConfig
+ }{
+ {"empty channel key", &AggConfig{}},
+ {"nil agg config", nil}, // AttachHandlersWith(sc, nil, nil, nil) 降级路径确实存在
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ if v, ok := channelInjectorHeaders(t, tc.ac)["channel-key"]; ok {
+ t.Errorf("channel-key header must be absent, got present with value %q", v)
+ }
+ })
+ }
+}
+
+// channel-key 与凭据机制正交(AC3):auth_mode 不影响其注入。
+// spec auth-guidelines 的「一个请求只携带一种凭据机制」约束的是凭据,channel-key 是
+// 渠道路由标识,不在其列。2026-07-16 真实网关实测:Bearer 与 channel-key 同时上行被接受。
+func TestChannelInjectorOrthogonalToAuthMode(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ ac *AggConfig
+ }{
+ {"aksk", &AggConfig{ChannelKey: "ch_x", PublicKey: "pub", PrivateKey: "pri"}},
+ {"oauth", &AggConfig{ChannelKey: "ch_x", AuthMode: AuthModeOAuth, AccessToken: "tok"}},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := channelInjectorHeaders(t, tc.ac)["channel-key"]; got != "ch_x" {
+ t.Errorf("channel-key = %q, want ch_x", got)
+ }
+ })
+ }
+}
+
+// 端到端:经完整 handler 链发真实请求,断言服务端侧确实收到该头。
+// 这是唯一能验证「Go 的 Header.Set 规范化为 Channel-Key 后网关仍可取到」的用例
+// (单测查的 map 未规范化,验证不到线路形式),也顺带证明 handler 确实被挂上了。
+func TestChannelKeyHeaderEndToEnd(t *testing.T) {
+ rec := &recordedRequest{}
+ s := bizRecorderServer(t, rec)
+ defer s.Close()
+
+ ac := &AggConfig{
+ Profile: "combo", BaseURL: s.URL, Timeout: 15, MaxRetryTimes: intPtr(0),
+ Region: "hk", ChannelKey: "ch_combo_e2e",
+ PublicKey: "pub", PrivateKey: "pri",
+ }
+ callGetRegion(t, ac, rec)
+
+ // http.Header.Get 大小写不敏感:线路上是 Channel-Key,此处照样取得到
+ if got := rec.header.Get("channel-key"); got != "ch_combo_e2e" {
+ t.Errorf("server received channel-key = %q, want ch_combo_e2e", got)
+ }
+}
+
+// AttachHandlers(读包级 ConfigIns 的那条路径,GetUserInfo 等在用)同样注入 channel-key。
+// 该路径实测难以触发(仅 agree_upload_log=true 的 DAS 日志上传会走 GetUserInfo),故以单测钉死。
+func TestAttachHandlersInjectsChannelKeyFromConfigIns(t *testing.T) {
+ rec := &recordedRequest{}
+ s := bizRecorderServer(t, rec)
+ defer s.Close()
+
+ oldConfig, oldClientConfig, oldCred := ConfigIns, ClientConfig, AuthCredential
+ t.Cleanup(func() { ConfigIns, ClientConfig, AuthCredential = oldConfig, oldClientConfig, oldCred })
+
+ ac := &AggConfig{
+ Profile: "combo", BaseURL: s.URL, Timeout: 15, MaxRetryTimes: intPtr(0),
+ Region: "hk", ChannelKey: "ch_from_configins",
+ PublicKey: "pub", PrivateKey: "pri",
+ }
+ ConfigIns = ac
+ if err := InitClientRuntime(ac); err != nil {
+ t.Fatal(err)
+ }
+ client := uaccount.NewClient(ClientConfig, BuildCredential())
+ AttachHandlers(client) // 包级路径:等价于 AttachHandlersWith(sc, AuthCredential, ConfigIns, ...)
+ if _, err := client.GetRegion(client.NewGetRegionRequest()); err != nil {
+ t.Fatalf("GetRegion failed: %v", err)
+ }
+ if got := rec.header.Get("channel-key"); got != "ch_from_configins" {
+ t.Errorf("AttachHandlers must inject channel-key from ConfigIns, got %q", got)
+ }
+}
+
+// 端到端零回归:未配 channel_key 时服务端不得看到该头
+func TestChannelKeyHeaderAbsentEndToEnd(t *testing.T) {
+ rec := &recordedRequest{}
+ s := bizRecorderServer(t, rec)
+ defer s.Close()
+
+ ac := &AggConfig{
+ Profile: "mainsite", BaseURL: s.URL, Timeout: 15, MaxRetryTimes: intPtr(0),
+ Region: "cn-bj2", PublicKey: "pub", PrivateKey: "pri",
+ }
+ callGetRegion(t, ac, rec)
+
+ if _, ok := rec.header["Channel-Key"]; ok {
+ t.Errorf("main-site profile must not send channel-key header, got %q", rec.header.Get("channel-key"))
+ }
+}
+
// 401 自动重放矩阵(D6 反应式兜底):401→刷新→重放成功;aksk 模式不重放。
// 注意 SDK 行为:HttpClient.Send 对 status>=400 返回 (nil, StatusError),
// 401 的 body 在 handler 层不可见,鉴权失败只能从 err 判定。
diff --git a/cmd/internal/platform/config.go b/cmd/internal/platform/config.go
index 7c6a7da8f0..12c140018b 100644
--- a/cmd/internal/platform/config.go
+++ b/cmd/internal/platform/config.go
@@ -102,6 +102,7 @@ type GlobalFlag struct {
PublicKey string
PrivateKey string
BaseURL string
+ ChannelKey string
Timeout int
WaitTimeout int // 同步轮询总超时(秒),0 表示用内置默认(600s)
MaxRetryTimes int
@@ -119,6 +120,10 @@ type CLIConfig struct {
MaxRetryTimes *int `json:"max_retry_times"`
AgreeUploadLog bool `json:"agree_upload_log"`
OAuthBaseURL string `json:"oauth_base_url,omitempty"`
+ // ChannelKey 专属云渠道标识,与 base_url 成对配置(复用主站域名的专属云渠道必填,
+ // 独立域名渠道与主站用户为空)。属接入点配置而非用户凭据,故落在 config.json,
+ // 不进 credential.json。
+ ChannelKey string `json:"channel_key,omitempty"`
}
// CredentialConfig credential element
@@ -155,6 +160,7 @@ type AggConfig struct {
RefreshToken string `json:"refresh_token,omitempty"`
ExpiresAt int64 `json:"expires_at,omitempty"`
OAuthBaseURL string `json:"oauth_base_url,omitempty"`
+ ChannelKey string `json:"channel_key,omitempty"`
}
// ConfigPublicKey 输入公钥
@@ -248,6 +254,7 @@ func (p *AggConfig) copyToCLIConfig(target *CLIConfig) {
target.MaxRetryTimes = p.MaxRetryTimes
target.AgreeUploadLog = p.AgreeUploadLog
target.OAuthBaseURL = p.OAuthBaseURL
+ target.ChannelKey = p.ChannelKey
}
func (p *AggConfig) copyToCredentialConfig(target *CredentialConfig) {
@@ -389,6 +396,7 @@ func (p *AggConfigManager) Load() error {
RefreshToken: cred.RefreshToken,
ExpiresAt: cred.ExpiresAt,
OAuthBaseURL: config.OAuthBaseURL,
+ ChannelKey: config.ChannelKey,
}
}
@@ -668,7 +676,7 @@ func ListAggConfig(json bool) {
HandleError(err)
}
} else {
- PrintTable(aggConfigs, []string{"Profile", "Active", "AuthMode", "ProjectID", "Region", "Zone", "BaseURL", "Timeout", "PublicKey", "PrivateKey", "MaxRetryTimes", "AgreeUploadLog"})
+ PrintTable(aggConfigs, []string{"Profile", "Active", "AuthMode", "ProjectID", "Region", "Zone", "BaseURL", "ChannelKey", "Timeout", "PublicKey", "PrivateKey", "MaxRetryTimes", "AgreeUploadLog"})
}
}
@@ -897,6 +905,9 @@ func mergeConfigIns(ins *AggConfig) {
if Global.BaseURL != "" {
ins.BaseURL = Global.BaseURL
}
+ if Global.ChannelKey != "" {
+ ins.ChannelKey = Global.ChannelKey
+ }
if Global.Timeout != 0 {
ins.Timeout = Global.Timeout
}
diff --git a/cmd/internal/platform/config_test.go b/cmd/internal/platform/config_test.go
index 7d2a556322..307fa93e36 100644
--- a/cmd/internal/platform/config_test.go
+++ b/cmd/internal/platform/config_test.go
@@ -3,6 +3,7 @@ package platform
import (
"io/ioutil"
"os"
+ "strings"
"testing"
)
@@ -127,6 +128,70 @@ func TestOAuthFieldsRoundTrip(t *testing.T) {
}
}
+// channel_key 写入后能读回(AC4)。覆盖跨层链路 copyToCLIConfig → config.json → Load,
+// 任一层漏改都会表现为「能存不能读」或「存了不落盘」。
+func TestChannelKeyRoundTrip(t *testing.T) {
+ os.MkdirAll(".ucloud", 0700)
+ defer os.RemoveAll(".ucloud")
+ m, err := NewAggConfigManager(".ucloud/config.json", ".ucloud/credential.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ ac := &AggConfig{
+ Profile: "combo", Active: true, BaseURL: "https://api.ucloud-global.com/", Timeout: 15,
+ MaxRetryTimes: intPtr(3), ChannelKey: "ch_combo_roundtrip",
+ }
+ if err := m.Append(ac); err != nil {
+ t.Fatal(err)
+ }
+
+ m2, err := NewAggConfigManager(".ucloud/config.json", ".ucloud/credential.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, ok := m2.GetAggConfigByProfile("combo")
+ if !ok {
+ t.Fatal("profile combo missing after reload")
+ }
+ if got.ChannelKey != "ch_combo_roundtrip" {
+ t.Errorf("ChannelKey lost on round trip: got %q, want ch_combo_roundtrip", got.ChannelKey)
+ }
+
+ // channel-key 是接入点配置而非凭据:绝不能落进 credential.json
+ raw, err := os.ReadFile(".ucloud/credential.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(raw), "ch_combo_roundtrip") {
+ t.Error("channel_key must not be persisted to credential.json (it is an endpoint config, not a credential)")
+ }
+}
+
+// 向后兼容(AC6):不含 channel_key 字段的旧 config.json 照常加载,且该值为空。
+func TestConfigWithoutChannelKeyLoads(t *testing.T) {
+ os.MkdirAll(".ucloud", 0700)
+ defer os.RemoveAll(".ucloud")
+ if err := os.WriteFile(".ucloud/config.json",
+ []byte(`[{"profile":"legacy","active":true,"base_url":"https://api.ucloud.cn/","timeout_sec":15,"max_retry_times":3}]`), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(".ucloud/credential.json",
+ []byte(`[{"profile":"legacy","public_key":"pub","private_key":"pri"}]`), 0600); err != nil {
+ t.Fatal(err)
+ }
+ m, err := NewAggConfigManager(".ucloud/config.json", ".ucloud/credential.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, ok := m.GetAggConfigByProfile("legacy")
+ if !ok {
+ t.Fatal("legacy profile missing")
+ }
+ if got.ChannelKey != "" {
+ t.Errorf("ChannelKey = %q, want empty for a legacy config", got.ChannelKey)
+ }
+}
+
// UpdateAggConfig 必须以传入的 config 为准:当传入指针与 map 内条目不是同一个对象时
// (如 `ucloud --profile <不存在>` 回退到包级默认 ConfigIns 而盘上已有同名 profile),
// 不能静默把 map 里的旧数据存盘、丢掉调用方的数据。
diff --git a/cmd/internal/platform/normalize_projectid_test.go b/cmd/internal/platform/normalize_projectid_test.go
new file mode 100644
index 0000000000..1a8728c124
--- /dev/null
+++ b/cmd/internal/platform/normalize_projectid_test.go
@@ -0,0 +1,171 @@
+package platform
+
+import (
+ "testing"
+
+ "github.com/ucloud/ucloud-sdk-go/ucloud/request"
+)
+
+// projectIDOnWire 按 SDK 默认 form 编码器编码 req,返回真正会上行的 ProjectId。
+// 断言 wire 值而不是 GetPayload() 中间态:产品踩的坑正是「中间态已归一化、
+// wire 上却是原值」,只有编码结果能证伪。
+func projectIDOnWire(t *testing.T, req request.Common) (string, bool) {
+ t.Helper()
+ form, err := request.EncodeForm(req)
+ if err != nil {
+ t.Fatalf("encode form: %v", err)
+ }
+ v, ok := form["ProjectId"]
+ return v, ok
+}
+
+// TestNormalizeProjectIDAcrossProductShapes 覆盖 master 上各产品传 project-id 的全部
+// 形态(每条 name 标注取样来源),断言 wire 上的最终值。
+//
+// 平台补全 getProjectList 给出的候选是 "org-xxx/ProjectName"(cmd/project.go),
+// 平台 handler 负责还原成纯 id。把 ProjectId 放进 generic payload map 的产品,
+// 归一化会被 SDK 的 payload 覆盖语义吃掉 —— 详见 normalizeProjectID 的注释。
+func TestNormalizeProjectIDAcrossProductShapes(t *testing.T) {
+ const idName = "org-x/MyProject"
+ const bare = "org-x"
+
+ tests := []struct {
+ name string
+ build func(t *testing.T) request.Common
+ wantWire string
+ wantSet bool
+ }{
+ {
+ // 取样:umongodb create_replset.go:61 / utidb api.go:49
+ // sqlserver create.go:89 / pgsql(#127) supabase params()
+ // 这是本次修复的目标形态:修复前 wire 上是 "org-x/MyProject",
+ // 网关报 RetCode 292 Project [org-x/MyProject] not exists。
+ name: "generic payload map + id/name (umongodb/utidb/sqlserver/pgsql-supabase)",
+ build: func(t *testing.T) request.Common {
+ gr := &request.BaseGenericRequest{}
+ if err := gr.SetPayload(map[string]interface{}{"Action": "X", "ProjectId": idName}); err != nil {
+ t.Fatal(err)
+ }
+ return gr
+ },
+ wantWire: bare, wantSet: true,
+ },
+ {
+ // 同上形态但用户传的已是纯 id(不按 Tab 补全)—— 修复前后行为必须一致。
+ name: "generic payload map + bare id (未按 Tab,最常见)",
+ build: func(t *testing.T) request.Common {
+ gr := &request.BaseGenericRequest{}
+ if err := gr.SetPayload(map[string]interface{}{"Action": "X", "ProjectId": bare}); err != nil {
+ t.Fatal(err)
+ }
+ return gr
+ },
+ wantWire: bare, wantSet: true,
+ },
+ {
+ // 取样:cloudwatch query_metric_data.go:180 (BindProjectID 绑 CommonBase)
+ // ukafka list.go:60 (genReq.SetProjectId)
+ // payload 不含 ProjectId → 一直是好的,本次修复不得改变它。
+ name: "generic CommonBase only + id/name (cloudwatch/ukafka)",
+ build: func(t *testing.T) request.Common {
+ gr := &request.BaseGenericRequest{}
+ if err := gr.SetPayload(map[string]interface{}{"Action": "X"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := gr.SetProjectId(idName); err != nil {
+ t.Fatal(err)
+ }
+ return gr
+ },
+ wantWire: bare, wantSet: true,
+ },
+ {
+ // 取样:mysql create.go:50 (payload 只有 DBVersion/Region/Zone) / uddos (不传 project)
+ // 无 project-id → wire 上不该凭空出现该字段。
+ name: "generic 无 project-id (mysql/uddos)",
+ build: func(t *testing.T) request.Common {
+ gr := &request.BaseGenericRequest{}
+ if err := gr.SetPayload(map[string]interface{}{"Action": "X"}); err != nil {
+ t.Fatal(err)
+ }
+ return gr
+ },
+ wantWire: "", wantSet: false,
+ },
+ {
+ // 绝大多数产品:typed SDK 请求 —— 历史行为,必须不变。
+ name: "typed request + id/name (绝大多数产品)",
+ build: func(t *testing.T) request.Common {
+ req := &request.CommonBase{}
+ if err := req.SetProjectId(idName); err != nil {
+ t.Fatal(err)
+ }
+ return req
+ },
+ wantWire: bare, wantSet: true,
+ },
+ {
+ name: "typed request + bare id",
+ build: func(t *testing.T) request.Common {
+ req := &request.CommonBase{}
+ if err := req.SetProjectId(bare); err != nil {
+ t.Fatal(err)
+ }
+ return req
+ },
+ wantWire: bare, wantSet: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ out, err := normalizeProjectID(tt.build(t))
+ if err != nil {
+ t.Fatalf("normalizeProjectID: %v", err)
+ }
+ got, ok := projectIDOnWire(t, out)
+ if ok != tt.wantSet {
+ t.Fatalf("ProjectId present on wire = %v, want %v (got %q)", ok, tt.wantSet, got)
+ }
+ if got != tt.wantWire {
+ t.Errorf("ProjectId on wire = %q, want %q", got, tt.wantWire)
+ }
+ })
+ }
+}
+
+// 归一化不得殃及 payload 里的其它字段 —— SetPayload 是整表替换,回归保护。
+func TestNormalizeProjectIDLeavesOtherPayloadFieldsIntact(t *testing.T) {
+ gr := &request.BaseGenericRequest{}
+ if err := gr.SetPayload(map[string]interface{}{
+ "Action": "CreateUMongoDBReplSet",
+ "ProjectId": "org-x/MyProject",
+ "Region": "cn-bj2",
+ "Zone": "cn-bj2-02",
+ "Name": "my/instance/with/slashes", // 业务字段里的 "/" 绝不能被 pick
+ "DiskSpace": 100, // int 必须仍是 int(JSON 编码器依赖它)
+ "IsMemoryDB": true,
+ }); err != nil {
+ t.Fatal(err)
+ }
+ out, err := normalizeProjectID(gr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ payload := out.(request.GenericRequest).GetPayload()
+ if payload["ProjectId"] != "org-x" {
+ t.Errorf("ProjectId = %v, want org-x", payload["ProjectId"])
+ }
+ if payload["Name"] != "my/instance/with/slashes" {
+ t.Errorf("business field with slashes was mangled: %v", payload["Name"])
+ }
+ if payload["DiskSpace"] != 100 {
+ t.Errorf("DiskSpace = %#v, want int 100 (type must survive for JSON encoder)", payload["DiskSpace"])
+ }
+ if payload["IsMemoryDB"] != true {
+ t.Errorf("IsMemoryDB = %#v, want bool true", payload["IsMemoryDB"])
+ }
+ if payload["Action"] != "CreateUMongoDBReplSet" || payload["Region"] != "cn-bj2" || payload["Zone"] != "cn-bj2-02" {
+ t.Errorf("common fields changed: %v", payload)
+ }
+}
diff --git a/cmd/products.gen.go b/cmd/products.gen.go
index ce9f68beeb..f539d1327c 100644
--- a/cmd/products.gen.go
+++ b/cmd/products.gen.go
@@ -3,6 +3,7 @@ package cmd
import (
"github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/products/cloudwatch"
"github.com/ucloud/ucloud-cli/products/css"
"github.com/ucloud/ucloud-cli/products/eip"
"github.com/ucloud/ucloud-cli/products/firewall"
@@ -12,17 +13,25 @@ import (
"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/pgsql"
"github.com/ucloud/ucloud-cli/products/redis"
"github.com/ucloud/ucloud-cli/products/sharedbw"
+ "github.com/ucloud/ucloud-cli/products/sqlserver"
"github.com/ucloud/ucloud-cli/products/subnet"
"github.com/ucloud/ucloud-cli/products/uclickhouse"
+ "github.com/ucloud/ucloud-cli/products/udac"
"github.com/ucloud/ucloud-cli/products/uddos"
"github.com/ucloud/ucloud-cli/products/udisk"
+ "github.com/ucloud/ucloud-cli/products/udns"
"github.com/ucloud/ucloud-cli/products/udpn"
"github.com/ucloud/ucloud-cli/products/ufs"
+ "github.com/ucloud/ucloud-cli/products/ugn"
+ "github.com/ucloud/ucloud-cli/products/uhadoop"
"github.com/ucloud/ucloud-cli/products/uhost"
+ "github.com/ucloud/ucloud-cli/products/uk8s"
"github.com/ucloud/ucloud-cli/products/ukafka"
"github.com/ucloud/ucloud-cli/products/ulb"
+ "github.com/ucloud/ucloud-cli/products/ulhost"
"github.com/ucloud/ucloud-cli/products/umodelverse"
"github.com/ucloud/ucloud-cli/products/umongodb"
"github.com/ucloud/ucloud-cli/products/upfs"
@@ -36,6 +45,7 @@ import (
// registeredProducts returns the platform-registered products.
func registeredProducts() []cli.Product {
return []cli.Product{
+ cloudwatch.New(),
css.New(),
eip.New(),
firewall.New(),
@@ -45,17 +55,25 @@ func registeredProducts() []cli.Product {
mysql.New(),
nlb.New(),
pathx.New(),
+ pgsql.New(),
redis.New(),
sharedbw.New(),
+ sqlserver.New(),
subnet.New(),
uclickhouse.New(),
+ udac.New(),
uddos.New(),
udisk.New(),
+ udns.New(),
udpn.New(),
ufs.New(),
+ ugn.New(),
+ uhadoop.New(),
uhost.New(),
+ uk8s.New(),
ukafka.New(),
ulb.New(),
+ ulhost.New(),
umodelverse.New(),
umongodb.New(),
upfs.New(),
diff --git a/cmd/root.go b/cmd/root.go
index 40bd350a94..8922d1133d 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -180,16 +180,21 @@ func addProductCommands(root *cobra.Command, products []cli.Product, ctx *cli.Co
}
}
-// applyGlobalOverrideFlags adds the five per-invocation override flags to
+// applyGlobalOverrideFlags adds the per-invocation override flags to
// every top-level command that is not in the exempt list. Running this after
// both addPlatformCommands and addProductCommands ensures product commands
// also receive the flags.
+//
+// Each flag registered here must also be scanned from os.Args in initGlobals:
+// mergeConfigIns reads global.* before cobra parses flags, so registration
+// alone is not enough for the override to take effect.
func applyGlobalOverrideFlags(root *cobra.Command) {
for _, c := range root.Commands() {
if c.Name() != "init" && c.Name() != "gendoc" && c.Name() != "config" && c.Name() != "auth" {
c.PersistentFlags().StringVar(&global.PublicKey, "public-key", global.PublicKey, "Set public-key to override the public-key in local config file")
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().StringVar(&global.ChannelKey, "channel-key", "", "Set channel-key to override the channel-key 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")
@@ -303,34 +308,39 @@ func init() {
//-1表示不覆盖配置文件中的MaxRetryTimes参数
global.MaxRetryTimes = -1
- for idx, arg := range os.Args {
- if arg == "--profile" && len(os.Args) > idx+1 && os.Args[idx+1] != "" {
- global.Profile = os.Args[idx+1]
- }
- if arg == "--public-key" && len(os.Args) > idx+1 && os.Args[idx+1] != "" {
- global.PublicKey = os.Args[idx+1]
- }
- if arg == "--private-key" && len(os.Args) > idx+1 && os.Args[idx+1] != "" {
- global.PrivateKey = os.Args[idx+1]
- }
- if arg == "--base-url" && len(os.Args) > idx+1 && os.Args[idx+1] != "" {
- global.BaseURL = os.Args[idx+1]
- }
- if arg == "--timeout-sec" && len(os.Args) > idx+1 && os.Args[idx+1] != "" {
- sec, err := strconv.Atoi(os.Args[idx+1])
- if err != nil {
- fmt.Printf("parse timeout-sec failed: %v\n", err)
- } else {
- global.Timeout = sec
- }
+ // 启动期预扫描:在 cobra 解析前把连接类参数落到 global.*,供 InitClientRuntime 用。
+ // 每个 flag 都识别 `--flag value` 与 `--flag=value` 两形式(scanFlagValue)。
+ // --profile 额外识别短选项 -p:漏识别会让 ConfigIns 指向错误 profile,
+ // OAuth 刷新可能把别人的 Bearer 重放到当前请求(见 platform/client.go 注释)。
+ if v, ok := scanFlagValue(os.Args, "--profile", "-p"); ok {
+ global.Profile = v
+ }
+ if v, ok := scanFlagValue(os.Args, "--public-key"); ok {
+ global.PublicKey = v
+ }
+ if v, ok := scanFlagValue(os.Args, "--private-key"); ok {
+ global.PrivateKey = v
+ }
+ if v, ok := scanFlagValue(os.Args, "--base-url"); ok {
+ global.BaseURL = v
+ }
+ if v, ok := scanFlagValue(os.Args, "--channel-key"); ok {
+ global.ChannelKey = v
+ }
+ if v, ok := scanFlagValue(os.Args, "--timeout-sec"); ok {
+ sec, err := strconv.Atoi(v)
+ if err != nil {
+ fmt.Printf("parse timeout-sec failed: %v\n", err)
+ } else {
+ global.Timeout = sec
}
- if arg == "--max-retry-times" && len(os.Args) > idx+1 && os.Args[idx+1] != "" {
- times, err := strconv.Atoi(os.Args[idx+1])
- if err != nil {
- fmt.Printf("parse max-retry-times failed: %v\n", err)
- } else {
- global.MaxRetryTimes = times
- }
+ }
+ if v, ok := scanFlagValue(os.Args, "--max-retry-times"); ok {
+ times, err := strconv.Atoi(v)
+ if err != nil {
+ fmt.Printf("parse max-retry-times failed: %v\n", err)
+ } else {
+ global.MaxRetryTimes = times
}
}
if sec, found, err := parseWaitTimeoutSec(os.Args); found {
@@ -343,6 +353,34 @@ func init() {
cobra.EnableCommandSorting = false
}
+// scanFlagValue finds the value of any of names in args, recognizing both the
+// space form (`--profile foo`) and the equals form (`--profile=foo`). Multiple
+// names allow aliases, e.g. scanFlagValue(args, "--profile", "-p"). The first
+// (leftmost) hit wins; an empty value (`--profile=` or a trailing `--profile`
+// with no following arg) counts as no hit and scanning continues.
+//
+// This is the startup pre-scan that must run before cobra parses flags, because
+// connection-class params (base-url/channel-key/profile/keys) must be settled
+// before InitClientRuntime builds sdk.Config. Exact-comparison-only scans missed
+// the equals form — the same #119 bug parseWaitTimeoutSec already fixed for
+// --wait-timeout-sec. Attached/combined shorthand (`-pfoo`, `-dpfoo`) is out of
+// scope: hand-parsing combined shorthand is error-prone and its residual risk
+// matches today's behavior (the pre-scan never recognized `-p` at all).
+func scanFlagValue(args []string, names ...string) (string, bool) {
+ for i, arg := range args {
+ for _, name := range names {
+ if arg == name {
+ if i+1 < len(args) && args[i+1] != "" {
+ return args[i+1], true
+ }
+ } else if v, ok := strings.CutPrefix(arg, name+"="); ok && v != "" {
+ return v, true
+ }
+ }
+ }
+ return "", 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.
diff --git a/cmd/root_test.go b/cmd/root_test.go
index d3884ba294..a18b02b347 100644
--- a/cmd/root_test.go
+++ b/cmd/root_test.go
@@ -81,3 +81,102 @@ func TestParseWaitTimeoutSec(t *testing.T) {
})
}
}
+
+// TestScanFlagValue covers the generalized startup pre-scan helper. Same #119
+// bug as parseWaitTimeoutSec (equals form silently ignored), now for the
+// connection-class flags. The critical -pfoo case pins the R3 boundary:
+// attached/combined shorthand is intentionally NOT recognized.
+func TestScanFlagValue(t *testing.T) {
+ tests := []struct {
+ name string
+ args []string
+ names []string
+ wantVal string
+ wantFound bool
+ }{
+ {
+ name: "long space form",
+ args: []string{"ucloud", "config", "--profile", "foo"},
+ names: []string{"--profile", "-p"},
+ wantVal: "foo",
+ wantFound: true,
+ },
+ {
+ name: "long equals form",
+ args: []string{"ucloud", "config", "--profile=foo"},
+ names: []string{"--profile", "-p"},
+ wantVal: "foo",
+ wantFound: true,
+ },
+ {
+ name: "short space form",
+ args: []string{"ucloud", "config", "-p", "foo"},
+ names: []string{"--profile", "-p"},
+ wantVal: "foo",
+ wantFound: true,
+ },
+ {
+ name: "short equals form",
+ args: []string{"ucloud", "config", "-p=foo"},
+ names: []string{"--profile", "-p"},
+ wantVal: "foo",
+ wantFound: true,
+ },
+ {
+ // R3 boundary: attached shorthand is out of scope, must NOT match.
+ name: "attached shorthand not recognized",
+ args: []string{"ucloud", "config", "-pfoo"},
+ names: []string{"--profile", "-p"},
+ wantVal: "",
+ wantFound: false,
+ },
+ {
+ name: "empty equals is no hit",
+ args: []string{"ucloud", "config", "--profile="},
+ names: []string{"--profile", "-p"},
+ wantVal: "",
+ wantFound: false,
+ },
+ {
+ name: "trailing flag no value",
+ args: []string{"ucloud", "config", "--profile"},
+ names: []string{"--profile", "-p"},
+ wantVal: "",
+ wantFound: false,
+ },
+ {
+ name: "absent",
+ args: []string{"ucloud", "config"},
+ names: []string{"--profile", "-p"},
+ wantVal: "",
+ wantFound: false,
+ },
+ {
+ name: "base-url equals form single name",
+ args: []string{"ucloud", "uhost", "list", "--base-url=http://x/"},
+ names: []string{"--base-url"},
+ wantVal: "http://x/",
+ wantFound: true,
+ },
+ {
+ // leftmost hit wins (pre-scan only needs one early value, no override)
+ name: "leftmost wins",
+ args: []string{"ucloud", "--profile", "a", "--profile", "b"},
+ names: []string{"--profile", "-p"},
+ wantVal: "a",
+ wantFound: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ val, found := scanFlagValue(tt.args, tt.names...)
+ if val != tt.wantVal {
+ t.Errorf("val = %q, want %q", val, tt.wantVal)
+ }
+ if found != tt.wantFound {
+ t.Errorf("found = %v, want %v", found, tt.wantFound)
+ }
+ })
+ }
+}
diff --git a/go.mod b/go.mod
index 28bb7f119c..e801bcced5 100644
--- a/go.mod
+++ b/go.mod
@@ -1,29 +1,27 @@
module github.com/ucloud/ucloud-cli
-go 1.25
+go 1.25.0
require (
github.com/fatih/color v1.13.0
github.com/gofrs/flock v0.8.1
github.com/mattn/go-isatty v0.0.14
github.com/satori/go.uuid v1.2.0
- github.com/sirupsen/logrus v1.3.0
+ github.com/sirupsen/logrus v1.8.3
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
- github.com/ucloud/ucloud-sdk-go v0.22.90
- gopkg.in/yaml.v2 v2.2.2
+ github.com/ucloud/ucloud-sdk-go v0.22.96
+ gopkg.in/yaml.v2 v2.2.8
)
require (
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
- github.com/konsorten/go-windows-terminal-sequences v1.0.1 // indirect
github.com/kr/pretty v0.1.0 // indirect
github.com/mattn/go-colorable v0.1.9 // indirect
github.com/pkg/errors v0.8.0 // indirect
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
+ golang.org/x/sys v0.45.0 // indirect
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
)
diff --git a/go.sum b/go.sum
index 02ec0d7689..3892e1726a 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,6 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
@@ -8,7 +9,6 @@ github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
-github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@@ -28,23 +28,25 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
-github.com/sirupsen/logrus v1.3.0 h1:hI/7Q+DtNZ2kINb6qt/lS+IyXnHQe9e90POfeewL/ME=
github.com/sirupsen/logrus v1.3.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
+github.com/sirupsen/logrus v1.8.3 h1:DBBfY8eMYazKEJHb3JKpSPfpgd2mBCoNFlQx6C5fftU=
+github.com/sirupsen/logrus v1.8.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
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.90 h1:OYYPYlWZFQ5DB15akEkrd/ty28zkd6Als7PQ6shywzk=
-github.com/ucloud/ucloud-sdk-go v0.22.90/go.mod h1:dyLmFHmUfgb4RZKYQP9IArlvQ2pxzFthfhwxRzOEPIw=
+github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/ucloud/ucloud-sdk-go v0.22.96 h1:efyqknRMEaoik6lH1g3DqO0XBipz4xhurGAXcQcDc2Q=
+github.com/ucloud/ucloud-sdk-go v0.22.96/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=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -53,11 +55,14 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 h1:xHms4gcpe1YE7A3yIllJXP16CMAGuqwO2lX1mTyyRRc=
-golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
+golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
-gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/hack/snapshot/testdata/cmdtree.golden b/hack/snapshot/testdata/cmdtree.golden
index 193adca1c3..614457878d 100644
--- a/hack/snapshot/testdata/cmdtree.golden
+++ b/hack/snapshot/testdata/cmdtree.golden
@@ -14,6 +14,7 @@ ucloud config use=config short=add or update configurations
flag=active short= default= required=
flag=agree-upload-log short= default=false required=
flag=base-url short= default= required=
+ flag=channel-key short= default= required=
flag=max-retry-times short= default=0 required=
flag=private-key short= default= required=
flag=profile short= default= required=
@@ -26,6 +27,7 @@ ucloud config add use=add short=add configuration
flag=active short= default=false required=
flag=agree-upload-log short= default=false required=
flag=base-url short= default=https://api.ucloud.cn/ required=
+ flag=channel-key short= default= required=
flag=max-retry-times short= default=3 required=
flag=private-key short= default= required=true
flag=profile short= default= required=true
@@ -41,6 +43,7 @@ ucloud config update use=update short=update configurations
flag=active short= default= required=
flag=agree-upload-log short= default= required=
flag=base-url short= default= required=
+ flag=channel-key short= default= required=
flag=max-retry-times short= default= required=
flag=private-key short= default= required=
flag=profile short= default= required=true
diff --git a/products/cloudwatch/internal/cloudwatch/cmd.go b/products/cloudwatch/internal/cloudwatch/cmd.go
new file mode 100644
index 0000000000..593d277731
--- /dev/null
+++ b/products/cloudwatch/internal/cloudwatch/cmd.go
@@ -0,0 +1,20 @@
+package cloudwatch
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// NewCommand builds the `cloudwatch` root command and mounts its public verbs.
+func NewCommand(ctx *cli.Context) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "cloudwatch",
+ Short: "Discover and query CloudWatch metrics",
+ Long: "List monitored products and metrics, then query metric data.",
+ }
+ cmd.AddCommand(newListProducts(ctx))
+ cmd.AddCommand(newListMetrics(ctx))
+ cmd.AddCommand(newQueryMetricData(ctx))
+ return cmd
+}
diff --git a/products/cloudwatch/internal/cloudwatch/completion.go b/products/cloudwatch/internal/cloudwatch/completion.go
new file mode 100644
index 0000000000..a4b1665bb4
--- /dev/null
+++ b/products/cloudwatch/internal/cloudwatch/completion.go
@@ -0,0 +1,66 @@
+package cloudwatch
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+var (
+ calcMethodValues = []string{"raw", "max", "min", "avg", "sum"}
+ periodValues = []string{"60", "300", "3600", "21600", "86400"}
+)
+
+// productKeyCandidates returns the live product-key list for --product
+// completion by calling ListMonitorProduct — the authoritative, dynamic
+// source (products are added/retired over time; a hardcoded list would go
+// stale). No request fields are required (empty Filter matches everything).
+func productKeyCandidates(ctx *cli.Context) func() []string {
+ return func() []string {
+ client := newGenericClient(ctx)
+ req := client.NewGenericRequest()
+ out, err := invoke(client, req, map[string]interface{}{
+ "Action": "ListMonitorProduct",
+ })
+ if err != nil {
+ return nil
+ }
+ var resp listMonitorProductResp
+ if err := decodeData(out, &resp); err != nil {
+ return nil
+ }
+ keys := make([]string, 0, len(resp.List))
+ for _, p := range resp.List {
+ keys = append(keys, p.ProductKey)
+ }
+ return keys
+ }
+}
+
+func registerQueryMetricDataCompletions(cmd *cobra.Command) {
+ command.SetFlagValues(cmd, "calc-method", calcMethodValues...)
+ command.SetFlagValues(cmd, "period", periodValues...)
+}
+
+func validateEnum(name, value string, allowed []string) error {
+ for _, candidate := range allowed {
+ if value == candidate {
+ return nil
+ }
+ }
+ return fmt.Errorf("%s must be one of: %s", name, joinEnumValues(allowed))
+}
+
+func joinEnumValues(values []string) string {
+ result := ""
+ for i, value := range values {
+ if i > 0 {
+ result += ", "
+ }
+ result += value
+ }
+ return result
+}
diff --git a/products/cloudwatch/internal/cloudwatch/invoke.go b/products/cloudwatch/internal/cloudwatch/invoke.go
new file mode 100644
index 0000000000..31a65ee890
--- /dev/null
+++ b/products/cloudwatch/internal/cloudwatch/invoke.go
@@ -0,0 +1,59 @@
+package cloudwatch
+
+import (
+ "encoding/json"
+ "fmt"
+
+ sdkcloudwatch "github.com/ucloud/ucloud-sdk-go/services/cloudwatch"
+ "github.com/ucloud/ucloud-sdk-go/ucloud/request"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// newGenericClient returns the authed CloudWatch service client used purely as
+// the carrier for GenericInvoke. The SDK does provide a strongly-typed
+// CloudWatchClient (services/cloudwatch), but this product intentionally does
+// NOT call its typed methods — it only borrows the client (and the platform
+// credential/signature/handler chain cli.NewServiceClient wires up) to send
+// generic Action requests, exactly like `ucloud api` and the mysql product do
+// for actions with no typed SDK method. Using the cloudwatch client (rather
+// than uaccount) makes the call site read as "this is a CloudWatch request"
+// without coupling to the SDK's generated request/response types.
+func newGenericClient(ctx *cli.Context) *sdkcloudwatch.CloudWatchClient {
+ return cli.NewServiceClient(ctx, sdkcloudwatch.NewClient)
+}
+
+// invoke sends req with the given payload and returns the decoded SkymFlameAPI
+// envelope payload (map: {Action, TraceId, RetCode, Message, Data, TotalCount?}).
+//
+// Business errors (envelope RetCode != 0) do NOT need to be checked here: the
+// SDK's built-in errorHandler (ucloud/handlers.go, registered by default on
+// every *ucloud.Client) already inspects resp.GetRetCode() after every
+// InvokeAction call and converts a non-zero RetCode into a uerr.Error, which
+// GenericInvoke returns as err. So `err != nil` from GenericInvoke already
+// covers both transport errors (network/timeout/signature) and business
+// errors — callers only need ctx.HandleError(err); there is nothing left for
+// product code to inspect on the payload for error purposes.
+func invoke(client *sdkcloudwatch.CloudWatchClient, req request.GenericRequest, payload map[string]interface{}) (map[string]interface{}, error) {
+ if err := req.SetPayload(payload); 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
+}
+
+// decodeData decodes the envelope's Data field into out (a pointer to the
+// caller's local response struct) by re-marshaling the interface{} value.
+func decodeData(payload map[string]interface{}, out interface{}) error {
+ raw, err := json.Marshal(payload["Data"])
+ if err != nil {
+ return fmt.Errorf("marshal Data: %w", err)
+ }
+ if err := json.Unmarshal(raw, out); err != nil {
+ return fmt.Errorf("unmarshal Data: %w", err)
+ }
+ return nil
+}
diff --git a/products/cloudwatch/internal/cloudwatch/list_metrics.go b/products/cloudwatch/internal/cloudwatch/list_metrics.go
new file mode 100644
index 0000000000..da3cd25bb9
--- /dev/null
+++ b/products/cloudwatch/internal/cloudwatch/list_metrics.go
@@ -0,0 +1,97 @@
+package cloudwatch
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// getProductMetricResp is the local decode target for the GetProductMetrics
+// envelope Data. Fields mirror SkymFlameAPI dto.GetProductMetricListResp
+// (release branch) — only what the CLI renders is declared.
+type getProductMetricResp struct {
+ Total int64 `json:"Total"`
+ List []metricItem `json:"List"`
+}
+
+type metricItem struct {
+ Metric string `json:"Metric"`
+ MetricName string `json:"MetricName"`
+ MetricChName string `json:"MetricChName"`
+ FrequencyMs int32 `json:"FrequencyMs"`
+ Unit *unitItem `json:"Unit"`
+}
+
+type unitItem struct {
+ UnitChName string `json:"UnitChName"`
+ UnitName string `json:"UnitName"`
+}
+
+func newListMetrics(ctx *cli.Context) *cobra.Command {
+ var product, monitorType string
+ client := newGenericClient(ctx)
+ req := client.NewGenericRequest()
+
+ cmd := &cobra.Command{
+ Use: "list-metrics",
+ Short: "List metrics for a product",
+ Long: "List the metrics available for one monitored product.",
+ Example: ` # List all UHost metrics
+ ucloud cloudwatch list-metrics --product uhost
+
+ # List only basic UHost metrics
+ ucloud cloudwatch list-metrics --product uhost --monitor-type basic`,
+ Args: cobra.NoArgs,
+ Run: func(c *cobra.Command, args []string) {
+ payload := map[string]interface{}{
+ "Action": "GetProductMetrics",
+ "ProductKey": product,
+ }
+ if monitorType != "" {
+ payload["MonitorType"] = monitorType
+ }
+ out, err := invoke(client, req, payload)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ var resp getProductMetricResp
+ if err := decodeData(out, &resp); err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ rows := make([]MetricRow, 0, len(resp.List))
+ for _, m := range resp.List {
+ name := m.MetricChName
+ if name == "" {
+ name = m.MetricName
+ }
+ unit := ""
+ if m.Unit != nil {
+ unit = m.Unit.UnitChName
+ if unit == "" {
+ unit = m.Unit.UnitName
+ }
+ }
+ rows = append(rows, MetricRow{
+ Metric: m.Metric,
+ MetricName: name,
+ Unit: unit,
+ FrequencyMs: m.FrequencyMs,
+ })
+ }
+ ctx.PrintList(rows)
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+ flags.StringVar(&product, "product", "", "Required. Product key returned by list-products, for example uhost")
+ flags.StringVar(&monitorType, "monitor-type", "", "Optional. Metric type filter; omit to list all types")
+ cmd.MarkFlagRequired("product")
+
+ command.SetCompletion(cmd, "product", productKeyCandidates(ctx))
+
+ return cmd
+}
diff --git a/products/cloudwatch/internal/cloudwatch/list_products.go b/products/cloudwatch/internal/cloudwatch/list_products.go
new file mode 100644
index 0000000000..c0f7366b92
--- /dev/null
+++ b/products/cloudwatch/internal/cloudwatch/list_products.go
@@ -0,0 +1,63 @@
+package cloudwatch
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+type monitorProductItem struct {
+ ProductKey string `json:"ProductKey"`
+ ProductName string `json:"ProductName"`
+ ProductChName string `json:"ProductChName"`
+ IsSupportHighPrecision bool `json:"IsSupportHighPrecision"`
+}
+
+type listMonitorProductResp struct {
+ Total int `json:"Total"`
+ List []monitorProductItem `json:"List"`
+}
+
+func newListProducts(ctx *cli.Context) *cobra.Command {
+ client := newGenericClient(ctx)
+ req := client.NewGenericRequest()
+
+ cmd := &cobra.Command{
+ Use: "list-products",
+ Short: "List monitored products",
+ Long: "List products that can be queried with CloudWatch.",
+ Example: ` # List monitored products
+ ucloud cloudwatch list-products
+
+ # Print only product keys as JSON
+ ucloud cloudwatch list-products --output json | jq -r '.[].Product'`,
+ Args: cobra.NoArgs,
+ Run: func(c *cobra.Command, args []string) {
+ out, err := invoke(client, req, map[string]interface{}{
+ "Action": "ListMonitorProduct",
+ })
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+
+ var resp listMonitorProductResp
+ if err := decodeData(out, &resp); err != nil {
+ ctx.HandleError(err)
+ return
+ }
+
+ rows := make([]MonitorProductRow, 0, len(resp.List))
+ for _, p := range resp.List {
+ rows = append(rows, MonitorProductRow{
+ Product: p.ProductKey,
+ ProductName: p.ProductName,
+ ProductChName: p.ProductChName,
+ })
+ }
+ ctx.PrintList(rows)
+ },
+ }
+
+ return cmd
+}
diff --git a/products/cloudwatch/internal/cloudwatch/query_metric_data.go b/products/cloudwatch/internal/cloudwatch/query_metric_data.go
new file mode 100644
index 0000000000..74bdb5aaea
--- /dev/null
+++ b/products/cloudwatch/internal/cloudwatch/query_metric_data.go
@@ -0,0 +1,252 @@
+package cloudwatch
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/internal/common"
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// queryMetricDataResp is the local decode target for the QueryMetricDataSet
+// envelope Data. Fields mirror SkymFlameAPI dto.QueryMetricDataResp (release).
+type queryMetricDataResp struct {
+ List []metricInfo `json:"List"`
+ InvalidResourceIds []string `json:"InvalidResourceIds"`
+}
+
+type metricInfo struct {
+ ErrCode int `json:"ErrCode"`
+ ErrMsg string `json:"ErrMsg"`
+ Metric string `json:"Metric"`
+ Results []metricValues `json:"Results"`
+}
+
+type metricValues struct {
+ ResourceID string `json:"ResourceId"`
+ ResourceName string `json:"ResourceName"`
+ TagMap map[string]string `json:"TagMap"`
+ Values []metricPoint `json:"Values"`
+}
+
+type metricPoint struct {
+ Timestamp int64 `json:"Timestamp"`
+ Value float64 `json:"Value"`
+}
+
+// Note on request encoding: the SDK's generic form encoder expands maps and
+// slices but rejects nested structs. MetricInfos is therefore built as a
+// []map[string]interface{} (with each TagList entry also a map), not structs.
+
+// newQueryMetricData ucloud cloudwatch query-metric-data
+func newQueryMetricData(ctx *cli.Context) *cobra.Command {
+ var product, calcMethod string
+ var resourceIDs, metrics []string
+ var startTime, endTime, period int64
+ var tags []string
+ client := newGenericClient(ctx)
+ req := client.NewGenericRequest()
+
+ cmd := &cobra.Command{
+ Use: "query-metric-data",
+ Short: "Query metric data",
+ Long: "Query time-series data for one or more resources and metrics. Every resource is paired with every metric.",
+ Example: ` # Query one metric on one resource for the default last-hour window
+ ucloud cloudwatch query-metric-data --product uhost --resource-id uhost-xxx --metric uhost_cpu_used
+
+ # Repeat --resource-id and --metric for multiple resources and metrics
+ ucloud cloudwatch query-metric-data --product uhost \
+ --resource-id uhost-a --resource-id uhost-b \
+ --metric uhost_cpu_used --metric uhost_mem_used \
+ --tag env=prod --tag role=web --calc-method avg --period 300
+
+ # Values for the same tag key are OR-ed; different keys are AND-ed
+ ucloud cloudwatch query-metric-data --product uhost --resource-id uhost-a \
+ --metric uhost_cpu_used --tag env=prod --tag env=staging --tag role=web`,
+ Args: cobra.NoArgs,
+ Run: func(c *cobra.Command, args []string) {
+ if req.GetProjectId() == "" {
+ ctx.HandleError(fmt.Errorf("project-id is required for query-metric-data; pass --project-id or configure a default project"))
+ return
+ }
+ // default time window: the last hour
+ now := time.Now().Unix()
+ if endTime == 0 {
+ endTime = now
+ }
+ if startTime == 0 {
+ startTime = endTime - 3600
+ }
+ if startTime >= endTime {
+ ctx.HandleError(fmt.Errorf("start-time must be earlier than end-time"))
+ return
+ }
+ if err := validateEnum("calc-method", calcMethod, calcMethodValues); err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ if period != 0 {
+ if err := validateEnum("period", fmt.Sprint(period), periodValues); err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ }
+
+ tagList, err := parseTags(tags)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+
+ // Cartesian product of --resource-id x --metric: one MetricInfos
+ // entry per (resource, metric) combination, all queried in the
+ // same request. The backend enforces its own cap on the total
+ // number of combinations (config.VM.ReqBatchMaxNum, not fixed at
+ // compile time) — CLI does not pre-validate a count, an
+ // over-limit request surfaces as a normal backend error via
+ // ctx.HandleError.
+ metricInfos := buildMetricInfos(ctx, resourceIDs, metrics, tagList)
+
+ payload := map[string]interface{}{
+ "Action": "QueryMetricDataSet",
+ "ProductKey": product,
+ "StartTime": startTime,
+ "EndTime": endTime,
+ "CalcMethod": calcMethod,
+ "MetricInfos": metricInfos,
+ }
+ if period != 0 {
+ payload["Period"] = period
+ }
+ out, err := invoke(client, req, payload)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ var resp queryMetricDataResp
+ if err := decodeData(out, &resp); err != nil {
+ ctx.HandleError(err)
+ return
+ }
+
+ rows := make([]DataPointRow, 0)
+ for _, mi := range resp.List {
+ if mi.ErrCode != 0 {
+ ctx.LogWarn(fmt.Sprintf("metric %s error: %s", mi.Metric, mi.ErrMsg))
+ continue
+ }
+ for _, mv := range mi.Results {
+ for _, pt := range mv.Values {
+ rows = append(rows, DataPointRow{
+ ResourceID: mv.ResourceID,
+ ResourceName: mv.ResourceName,
+ Metric: mi.Metric,
+ Timestamp: common.FormatDateTime(int(pt.Timestamp)),
+ Value: pt.Value,
+ Tags: flattenTagMap(mv.TagMap),
+ })
+ }
+ }
+ }
+ if len(resp.InvalidResourceIds) > 0 {
+ ctx.LogWarn(fmt.Sprintf("invalid resource ids: %v", resp.InvalidResourceIds))
+ }
+ if len(rows) == 0 {
+ ctx.LogWarn("no data points in the given time range")
+ }
+ ctx.PrintList(rows)
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+ flags.StringVar(&product, "product", "", "Required. Product key returned by list-products, for example uhost")
+ flags.StringArrayVar(&resourceIDs, "resource-id", nil, "Required. Resource ID; repeat --resource-id to query multiple resources (values are not comma-split)")
+ flags.StringArrayVar(&metrics, "metric", nil, "Required. Metric key returned by list-metrics; repeat --metric to query multiple metrics (values are not comma-split)")
+ flags.Int64Var(&startTime, "start-time", 0, "Optional. Start time as Unix seconds; defaults to one hour before end-time")
+ flags.Int64Var(&endTime, "end-time", 0, "Optional. End time as Unix seconds; defaults to the current time")
+ flags.StringVar(&calcMethod, "calc-method", "raw", "Optional. Calculation method: raw, max, min, avg, or sum")
+ flags.Int64Var(&period, "period", 0, "Optional. Data interval in seconds: 60, 300, 3600, 21600, or 86400; omit to choose automatically")
+ flags.StringArrayVar(&tags, "tag", nil, "Optional. Tag filter as key=value; repeat --tag for multiple values or keys. Same-key values are OR-ed, different keys are AND-ed; commas in values are preserved")
+ cmd.MarkFlagRequired("product")
+ cmd.MarkFlagRequired("resource-id")
+ cmd.MarkFlagRequired("metric")
+
+ ctx.BindProjectID(cmd, req)
+ cmd.Flags().Lookup("project-id").Usage = "Required. Project ID"
+ cmd.Flags().Lookup("project-id").DefValue = ""
+ ctx.BindRegion(cmd, req)
+ cmd.Flags().Lookup("region").Usage = "Optional. Region"
+ cmd.Flags().Lookup("region").DefValue = ""
+ command.SetCompletion(cmd, "product", productKeyCandidates(ctx))
+ registerQueryMetricDataCompletions(cmd)
+
+ return cmd
+}
+
+func buildMetricInfos(ctx *cli.Context, resourceIDs, metrics []string, tagList []map[string]interface{}) []map[string]interface{} {
+ metricInfos := make([]map[string]interface{}, 0, len(resourceIDs)*len(metrics))
+ for _, rid := range resourceIDs {
+ for _, metric := range metrics {
+ info := map[string]interface{}{
+ "Metric": metric,
+ "ResourceId": ctx.PickResourceID(rid),
+ }
+ if len(tagList) > 0 {
+ info["TagList"] = tagList
+ }
+ metricInfos = append(metricInfos, info)
+ }
+ }
+ return metricInfos
+}
+
+// parseTags converts repeated --tag "key=value" flags into TagList entries.
+// Each entry is a map (not a struct) so the SDK generic form encoder can expand
+// it into TagList.N.TagKey / TagList.N.TagValues.M form. Values for the same
+// key are grouped into one entry (OR); separate keys remain separate entries
+// (AND).
+func parseTags(tags []string) ([]map[string]interface{}, error) {
+ if len(tags) == 0 {
+ return nil, nil
+ }
+ valuesByKey := make(map[string][]string, len(tags))
+ keys := make([]string, 0, len(tags))
+ for _, t := range tags {
+ kv := strings.SplitN(t, "=", 2)
+ if len(kv) != 2 || kv[0] == "" || kv[1] == "" {
+ return nil, fmt.Errorf("invalid --tag %q, want key=value; repeat --tag for multiple values", t)
+ }
+ if _, exists := valuesByKey[kv[0]]; !exists {
+ keys = append(keys, kv[0])
+ }
+ valuesByKey[kv[0]] = append(valuesByKey[kv[0]], kv[1])
+ }
+ list := make([]map[string]interface{}, 0, len(keys))
+ for _, key := range keys {
+ list = append(list, map[string]interface{}{"TagKey": key, "TagValues": valuesByKey[key]})
+ }
+ return list, nil
+}
+
+// flattenTagMap renders a tag map as a stable "k=v, k2=v2" string.
+func flattenTagMap(m map[string]string) string {
+ if len(m) == 0 {
+ return ""
+ }
+ keys := make([]string, 0, len(m))
+ for k := range m {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ parts := make([]string, 0, len(keys))
+ for _, k := range keys {
+ parts = append(parts, k+"="+m[k])
+ }
+ return strings.Join(parts, ", ")
+}
diff --git a/products/cloudwatch/internal/cloudwatch/rows.go b/products/cloudwatch/internal/cloudwatch/rows.go
new file mode 100644
index 0000000000..5eafa96d31
--- /dev/null
+++ b/products/cloudwatch/internal/cloudwatch/rows.go
@@ -0,0 +1,29 @@
+package cloudwatch
+
+// MonitorProductRow is one product definition returned by ListMonitorProduct.
+type MonitorProductRow struct {
+ Product string
+ ProductName string
+ ProductChName string
+}
+
+// MetricRow is one table row for `cloudwatch list-metrics`.
+// One row per metric of the queried product.
+type MetricRow struct {
+ Metric string
+ MetricName string // MetricChName — Chinese display name, closer to console habit
+ Unit string // Unit.UnitChName (empty when Unit is nil)
+ FrequencyMs int32
+}
+
+// DataPointRow is one table row for `cloudwatch query-metric-data`.
+// The nested response (metric → resource → point[]) is flattened so each row
+// is a single (resource, metric, timestamp) sample.
+type DataPointRow struct {
+ ResourceID string
+ ResourceName string
+ Metric string
+ Timestamp string // common.FormatDateTime(point.Timestamp)
+ Value float64
+ Tags string // TagMap flattened to "k=v, k2=v2"; empty when no tags
+}
diff --git a/products/cloudwatch/product.go b/products/cloudwatch/product.go
new file mode 100644
index 0000000000..3f6cf1049f
--- /dev/null
+++ b/products/cloudwatch/product.go
@@ -0,0 +1,22 @@
+package cloudwatch
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ internalcloudwatch "github.com/ucloud/ucloud-cli/products/cloudwatch/internal/cloudwatch"
+)
+
+type Product struct{}
+
+func New() cli.Product { return &Product{} }
+
+func (*Product) Metadata() cli.Metadata {
+ return cli.Metadata{Name: "cloudwatch", Commands: []string{"cloudwatch"}}
+}
+
+func (*Product) NewCommand(ctx *cli.Context) []*cobra.Command {
+ return []*cobra.Command{internalcloudwatch.NewCommand(ctx)}
+}
+
+var _ cli.Product = (*Product)(nil)
diff --git a/products/cloudwatch/product.yaml b/products/cloudwatch/product.yaml
new file mode 100644
index 0000000000..0e3303704d
--- /dev/null
+++ b/products/cloudwatch/product.yaml
@@ -0,0 +1,7 @@
+# products/cloudwatch/product.yaml — cloudwatch 产品元数据(归属真源,owner 自治维护)
+name: cloudwatch
+owners:
+ - YAYALE-WA
+commands:
+ - cloudwatch
+enabled: true
diff --git a/products/cloudwatch/testdata/cmdtree.golden b/products/cloudwatch/testdata/cmdtree.golden
new file mode 100644
index 0000000000..497daa25f7
--- /dev/null
+++ b/products/cloudwatch/testdata/cmdtree.golden
@@ -0,0 +1,16 @@
+ucloud cloudwatch use=cloudwatch short=Discover and query CloudWatch metrics
+ucloud cloudwatch list-metrics use=list-metrics short=List metrics for a product
+ flag=monitor-type short= default= required=
+ flag=product short= default= required=true
+ucloud cloudwatch list-products use=list-products short=List monitored products
+ucloud cloudwatch query-metric-data use=query-metric-data short=Query metric data
+ flag=calc-method short= default=raw required=
+ flag=end-time short= default=0 required=
+ flag=metric short= default=[] required=true
+ flag=period short= default=0 required=
+ flag=product short= default= required=true
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=resource-id short= default=[] required=true
+ flag=start-time short= default=0 required=
+ flag=tag short= default=[] required=
diff --git a/products/cloudwatch/testdata/completion.golden b/products/cloudwatch/testdata/completion.golden
new file mode 100644
index 0000000000..9e5f2a2ffa
--- /dev/null
+++ b/products/cloudwatch/testdata/completion.golden
@@ -0,0 +1,6 @@
+ucloud cloudwatch list-metrics product dynamic
+ucloud cloudwatch query-metric-data calc-method static avg,max,min,raw,sum
+ucloud cloudwatch query-metric-data period static 21600,300,3600,60,86400
+ucloud cloudwatch query-metric-data product dynamic
+ucloud cloudwatch query-metric-data project-id dynamic
+ucloud cloudwatch query-metric-data region dynamic
diff --git a/products/pgsql/internal/pgsql/backup.go b/products/pgsql/internal/pgsql/backup.go
new file mode 100644
index 0000000000..a512003271
--- /dev/null
+++ b/products/pgsql/internal/pgsql/backup.go
@@ -0,0 +1,21 @@
+package pgsql
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// newPgsqlBackup ucloud pgsql backup
+func newPgsqlBackup(ctx *cli.Context) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "backup",
+ Short: "List and manipulate backups of UPgSQL instances",
+ Long: "List and manipulate backups of UPgSQL instances",
+ }
+ cmd.AddCommand(newBackupList(ctx))
+ cmd.AddCommand(newBackupDownload(ctx))
+ cmd.AddCommand(newBackupStrategy(ctx))
+ cmd.AddCommand(newBackupUpdateStrategy(ctx))
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/backup_download.go b/products/pgsql/internal/pgsql/backup_download.go
new file mode 100644
index 0000000000..df71da3c4d
--- /dev/null
+++ b/products/pgsql/internal/pgsql/backup_download.go
@@ -0,0 +1,51 @@
+package pgsql
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newBackupDownload ucloud pgsql backup download
+func newBackupDownload(ctx *cli.Context) *cobra.Command {
+ client := newUPgSQLClient(ctx)
+ req := client.NewGetUPgSQLBackupURLRequest()
+ cmd := &cobra.Command{
+ Use: "download",
+ Short: "Display download URLs of a UPgSQL backup",
+ Long: "Display the public and inner download URLs of a UPgSQL backup",
+ Run: func(c *cobra.Command, args []string) {
+ *req.InstanceID = ctx.PickResourceID(*req.InstanceID)
+ resp, err := client.GetUPgSQLBackupURL(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ ctx.PrintList([]PgsqlBackupURLRow{{
+ BackupPath: resp.BackupPath,
+ InnerBackupPath: resp.InnerBackupPath,
+ }})
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ req.InstanceID = flags.String("instance-id", "", "Required. Resource ID of the UPgSQL instance")
+ req.BackupID = flags.String("backup-id", "", "Required. Backup ID of the backup to download")
+ ctx.BindProjectID(cmd, req)
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+
+ cmd.MarkFlagRequired("instance-id")
+ cmd.MarkFlagRequired("backup-id")
+ command.SetCompletion(cmd, "instance-id", func() []string {
+ return getUPgSQLIDList(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone())
+ })
+ command.SetCompletion(cmd, "backup-id", func() []string {
+ return getBackupIDList(ctx, *req.InstanceID, req.GetProjectId(), req.GetRegion(), req.GetZone())
+ })
+
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/backup_list.go b/products/pgsql/internal/pgsql/backup_list.go
new file mode 100644
index 0000000000..27e94ce69e
--- /dev/null
+++ b/products/pgsql/internal/pgsql/backup_list.go
@@ -0,0 +1,80 @@
+package pgsql
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ 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"
+)
+
+var pgsqlBackupTypeMap = map[string]int{
+ "auto": 1,
+ "manual": 2,
+}
+var pgsqlReverseBackupTypeMap = map[int]string{
+ 1: "auto",
+ 2: "manual",
+ 0: "all",
+}
+
+// newBackupList ucloud pgsql backup list
+func newBackupList(ctx *cli.Context) *cobra.Command {
+ var bpType string
+ client := newUPgSQLClient(ctx)
+ req := client.NewListUPgSQLBackupRequest()
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List backups of a UPgSQL instance",
+ Long: "List backups of a UPgSQL instance",
+ Run: func(c *cobra.Command, args []string) {
+ *req.InstanceID = ctx.PickResourceID(*req.InstanceID)
+ if v, ok := pgsqlBackupTypeMap[bpType]; ok {
+ req.BackupType = sdk.Int(v)
+ }
+ resp, err := client.ListUPgSQLBackup(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ list := []PgsqlBackupRow{}
+ for _, b := range resp.DataSet {
+ list = append(list, PgsqlBackupRow{
+ BackupID: b.BackupID,
+ BackupName: b.BackupName,
+ InstanceID: b.InstanceID,
+ State: b.State,
+ BackupType: pgsqlReverseBackupTypeMap[b.BackupType],
+ BackupSize: fmt.Sprintf("%dB", b.BackupSize),
+ BackupStartTime: common.FormatDateTime(b.BackupStartTime),
+ BackupEndTime: common.FormatDateTime(b.BackupEndTime),
+ })
+ }
+ ctx.PrintList(list)
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ req.InstanceID = flags.String("instance-id", "", "Required. Resource ID of the UPgSQL instance")
+ flags.StringVar(&bpType, "backup-type", "", "Optional. Backup type. Accept values: auto, manual")
+ ctx.BindProjectID(cmd, req)
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+ ctx.BindOffset(cmd, req)
+ ctx.BindLimit(cmd, req)
+
+ command.SetFlagValues(cmd, "backup-type", "auto", "manual")
+ command.SetCompletion(cmd, "instance-id", func() []string {
+ return getUPgSQLIDList(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone())
+ })
+
+ cmd.MarkFlagRequired("instance-id")
+
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/backup_strategy.go b/products/pgsql/internal/pgsql/backup_strategy.go
new file mode 100644
index 0000000000..adda81ea4d
--- /dev/null
+++ b/products/pgsql/internal/pgsql/backup_strategy.go
@@ -0,0 +1,50 @@
+package pgsql
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newBackupStrategy ucloud pgsql backup strategy
+func newBackupStrategy(ctx *cli.Context) *cobra.Command {
+ client := newUPgSQLClient(ctx)
+ req := client.NewGetUPgSQLBackupStrategyRequest()
+ cmd := &cobra.Command{
+ Use: "strategy",
+ Short: "Display the backup strategy of a UPgSQL instance",
+ Long: "Display the backup strategy of a UPgSQL instance",
+ Run: func(c *cobra.Command, args []string) {
+ *req.InstanceID = ctx.PickResourceID(*req.InstanceID)
+ resp, err := client.GetUPgSQLBackupStrategy(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ fmt.Fprintln(ctx.ProgressWriter(), "BackupStrategy:")
+ ctx.PrintList([]PgsqlBackupStrategyRow{{
+ BackupMethod: resp.BackupMethod,
+ BackupTimeRange: resp.BackupTimeRange,
+ BackupWeek: resp.BackupWeek,
+ }})
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ req.InstanceID = flags.String("instance-id", "", "Required. Resource ID of the UPgSQL instance")
+ ctx.BindProjectID(cmd, req)
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+
+ cmd.MarkFlagRequired("instance-id")
+ command.SetCompletion(cmd, "instance-id", func() []string {
+ return getUPgSQLIDList(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone())
+ })
+
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/backup_update_strategy.go b/products/pgsql/internal/pgsql/backup_update_strategy.go
new file mode 100644
index 0000000000..f301d69f74
--- /dev/null
+++ b/products/pgsql/internal/pgsql/backup_update_strategy.go
@@ -0,0 +1,49 @@
+package pgsql
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newBackupUpdateStrategy ucloud pgsql backup update-strategy
+func newBackupUpdateStrategy(ctx *cli.Context) *cobra.Command {
+ client := newUPgSQLClient(ctx)
+ req := client.NewUpdateUPgSQLBackupStrategyRequest()
+ cmd := &cobra.Command{
+ Use: "update-strategy",
+ Short: "Update the backup strategy of a UPgSQL instance",
+ Long: "Update the backup strategy of a UPgSQL instance",
+ Run: func(c *cobra.Command, args []string) {
+ *req.InstanceID = ctx.PickResourceID(*req.InstanceID)
+ _, err := client.UpdateUPgSQLBackupStrategy(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ fmt.Fprintf(ctx.ProgressWriter(), "backup strategy of pgsql[%s] updated\n", *req.InstanceID)
+ ctx.EmitResult(cli.OpResultRow{ResourceID: *req.InstanceID, Action: "update-strategy", Status: "Updated"})
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ req.InstanceID = flags.String("instance-id", "", "Required. Resource ID of the UPgSQL instance")
+ req.BackupTimeRange = flags.String("backup-time-range", "", "Optional. Auto backup start time range, e.g. (3:00~4:00)")
+ req.BackupWeek = flags.String("backup-week", "", "Optional. Days of week to start auto backup, e.g. 1,2,3,4,5,6,7")
+ req.BackupMethod = flags.String("backup-method", "", "Optional. Default backup method")
+ ctx.BindProjectID(cmd, req)
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+
+ cmd.MarkFlagRequired("instance-id")
+ command.SetCompletion(cmd, "instance-id", func() []string {
+ return getUPgSQLIDList(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone())
+ })
+
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/client.go b/products/pgsql/internal/pgsql/client.go
new file mode 100644
index 0000000000..29b61a139a
--- /dev/null
+++ b/products/pgsql/internal/pgsql/client.go
@@ -0,0 +1,38 @@
+package pgsql
+
+import (
+ "github.com/ucloud/ucloud-sdk-go/services/upgsql"
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+ "github.com/ucloud/ucloud-sdk-go/ucloud/request"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// newUPgSQLClient returns an authed UPgSQL client whose requests are encoded as
+// JSON bodies instead of form-urlencoded.
+//
+// The UPgSQL gateway cannot unmarshal form-urlencoded string values into Go
+// int/bool request fields (e.g. ListUPgSQLParamTemplate.Count), returning
+// RetCode 214001 "json: cannot unmarshal string into Go struct field ... of
+// type int". The SDK default form encoder serializes *int/*bool as the strings
+// "100"/"false", which trips this. Switching to NewJSONEncoder keeps numeric
+// and boolean fields as JSON numbers/booleans, which the gateway accepts.
+//
+// The encoder is swapped per-request via an SDK request handler (runs before
+// buildHTTPRequest) using the SAME config+credential the default form encoder
+// would use, so signing is unchanged. This covers every typed call that goes
+// through this client, including the completion helpers.
+//
+// Note: this works for AK/SK profiles (Signature lives in the signed JSON
+// body). OAuth profiles additionally need the platform cred-header injector to
+// strip Signature/PublicKey from a JSON body (it currently only strips form
+// bodies) — that is a separate platform-layer change; until then OAuth+pgsql
+// remains non-functional (as it is today).
+func newUPgSQLClient(ctx *cli.Context) *upgsql.UPgSQLClient {
+ client := cli.NewServiceClient(ctx, upgsql.NewClient)
+ _ = client.AddRequestHandler(func(c *sdk.Client, req request.Common) (request.Common, error) {
+ req.SetEncoder(request.NewJSONEncoder(c.GetConfig(), c.GetCredential()))
+ return req, nil
+ })
+ return client
+}
diff --git a/products/pgsql/internal/pgsql/cmd.go b/products/pgsql/internal/pgsql/cmd.go
new file mode 100644
index 0000000000..36b66b7e57
--- /dev/null
+++ b/products/pgsql/internal/pgsql/cmd.go
@@ -0,0 +1,22 @@
+package pgsql
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// NewCommand builds the `pgsql` root command and mounts the `db` subtree.
+func NewCommand(ctx *cli.Context) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "pgsql",
+ Short: "Manipulate UPgSQL on UCloud platform",
+ Long: "Manipulate UPgSQL (UCloud PostgreSQL) on UCloud platform",
+ }
+ cmd.AddCommand(newPgsqlDB(ctx))
+ cmd.AddCommand(newPgsqlConf(ctx))
+ cmd.AddCommand(newPgsqlBackup(ctx))
+ cmd.AddCommand(newPgsqlLog(ctx))
+ cmd.AddCommand(newPgsqlSupabase(ctx))
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/completion.go b/products/pgsql/internal/pgsql/completion.go
new file mode 100644
index 0000000000..2332eabc03
--- /dev/null
+++ b/products/pgsql/internal/pgsql/completion.go
@@ -0,0 +1,203 @@
+package pgsql
+
+import (
+ "fmt"
+
+ "github.com/ucloud/ucloud-sdk-go/services/upgsql"
+ "github.com/ucloud/ucloud-sdk-go/services/vpc"
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+var pgsqlVersionList = []string{"postgresql-10.4", "postgresql-13.4"}
+
+// getAllVPCIns mirrors products/mysql/internal/mysql/completion.go getAllVPCIns,
+// copied here (not imported) so the product stays self-contained per the
+// boundary rules (hack/check-product rule1).
+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
+}
+
+// getAllVPCIdNames mirrors products/mysql/internal/mysql/completion.go getAllVPCIdNames.
+func getAllVPCIdNames(ctx *cli.Context, project, region string) []string {
+ vpcInsList, err := getAllVPCIns(ctx, project, region)
+ list := []string{}
+ if err != nil {
+ return nil
+ }
+ for _, v := range vpcInsList {
+ list = append(list, fmt.Sprintf("%s/%s", v.VPCId, v.Name))
+ }
+ return list
+}
+
+// getAllSubnets mirrors products/mysql/internal/mysql/completion.go getAllSubnets.
+func getAllSubnets(ctx *cli.Context, vpcID, project, region string) ([]vpc.SubnetInfo, error) {
+ 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))
+ }
+ subnets := []vpc.SubnetInfo{}
+ for limit, offset := 50, 0; ; offset += limit {
+ req.Limit = sdk.Int(limit)
+ req.Offset = sdk.Int(offset)
+ resp, err := client.DescribeSubnet(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return nil, err
+ }
+ subnets = append(subnets, resp.DataSet...)
+ if limit+offset >= resp.TotalCount {
+ break
+ }
+ }
+ return subnets, nil
+}
+
+// getAllSubnetIDNames mirrors products/mysql/internal/mysql/completion.go getAllSubnetIDNames.
+func getAllSubnetIDNames(ctx *cli.Context, vpcID, project, region string) []string {
+ subnets, err := getAllSubnets(ctx, vpcID, project, region)
+ if err != nil {
+ return nil
+ }
+ list := []string{}
+ for _, s := range subnets {
+ list = append(list, fmt.Sprintf("%s/%s", s.SubnetId, s.SubnetName))
+ }
+ return list
+}
+
+// getUPgSQLList returns all UPgSQL instances for the given project/region/zone.
+// ListUPgSQLInstance has no Limit/Offset, so a single call returns the full set.
+func getUPgSQLList(ctx *cli.Context, project, region, zone string) ([]upgsql.UDBInstanceSet, error) {
+ client := newUPgSQLClient(ctx)
+ req := client.NewListUPgSQLInstanceRequest()
+ req.ProjectId = sdk.String(project)
+ req.Region = sdk.String(region)
+ req.Zone = sdk.String(zone)
+ resp, err := client.ListUPgSQLInstance(req)
+ if err != nil {
+ return nil, err
+ }
+ return resp.DataSet, nil
+}
+
+// getUPgSQLIDList returns "InstanceID/Name" completion candidates for --instance-id.
+func getUPgSQLIDList(ctx *cli.Context, project, region, zone string) []string {
+ instances, err := getUPgSQLList(ctx, project, region, zone)
+ if err != nil {
+ return nil
+ }
+ list := []string{}
+ for _, ins := range instances {
+ list = append(list, fmt.Sprintf("%s/%s", ins.InstanceID, ins.Name))
+ }
+ return list
+}
+
+// listParamTemplates returns the available UPgSQL param templates for the given
+// project/region/zone, paginating via item-count (the API exposes no TotalCount).
+func listParamTemplates(ctx *cli.Context, project, region, zone string) ([]upgsql.TemplateGroup, error) {
+ client := newUPgSQLClient(ctx)
+ req := client.NewListUPgSQLParamTemplateRequest()
+ req.ProjectId = sdk.String(project)
+ req.Region = sdk.String(region)
+ req.Zone = sdk.String(zone)
+ list := []upgsql.TemplateGroup{}
+
+ resp, err := client.ListUPgSQLParamTemplate(req)
+ if err != nil {
+ return nil, err
+ }
+ list = resp.Data
+ return list, nil
+}
+
+// getDefaultParamGroupID picks a param template for the given DB version via
+// ListUPgSQLParamTemplate. The API has no DBVersion filter, so we match by the
+// DBVersion field in the returned templates and fall back to the first one.
+func getDefaultParamGroupID(ctx *cli.Context, dbVersion, project, region, zone string) (int, error) {
+ templates, err := listParamTemplates(ctx, project, region, zone)
+ if err != nil {
+ return 0, fmt.Errorf("call ListUPgSQLParamTemplate: %w", err)
+ }
+ if len(templates) == 0 {
+ return 0, fmt.Errorf("no param template found in %s/%s", region, zone)
+ }
+ for _, t := range templates {
+ if t.DBVersion == dbVersion {
+ return t.GroupID, nil
+ }
+ }
+ return templates[0].GroupID, nil
+}
+
+// listParamTemplateIDNames returns "GroupID/GroupName" candidates for --param-group-id.
+func listParamTemplateIDNames(ctx *cli.Context, project, region, zone string) []string {
+ templates, err := listParamTemplates(ctx, project, region, zone)
+ if err != nil {
+ return nil
+ }
+ list := []string{}
+ for _, t := range templates {
+ list = append(list, fmt.Sprintf("%d/%s", t.GroupID, t.GroupName))
+ }
+ return list
+}
+
+// listMachineTypeIDNames returns "ID/Description" candidates for --machine-type.
+func listMachineTypeIDNames(ctx *cli.Context, project, region, zone string) []string {
+ client := newUPgSQLClient(ctx)
+ req := client.NewListUPgSQLMachineTypeRequest()
+ req.ProjectId = sdk.String(project)
+ req.Region = sdk.String(region)
+ req.Zone = sdk.String(zone)
+ resp, err := client.ListUPgSQLMachineType(req)
+ if err != nil {
+ return nil
+ }
+ list := []string{}
+ for _, mt := range resp.DataSet {
+ list = append(list, fmt.Sprintf("%s/%s", mt.ID, mt.Description))
+ }
+ return list
+}
+
+// getBackupIDList returns "BackupID/BackupName" candidates for --backup-id,
+// paginating via TotalCount (ListUPgSQLBackup exposes TotalCount).
+func getBackupIDList(ctx *cli.Context, instanceID, project, region, zone string) []string {
+ client := newUPgSQLClient(ctx)
+ req := client.NewListUPgSQLBackupRequest()
+ req.ProjectId = sdk.String(project)
+ req.Region = sdk.String(region)
+ req.Zone = sdk.String(zone)
+ req.InstanceID = sdk.String(instanceID)
+ list := []string{}
+ for limit, offset := 100, 0; ; offset += limit {
+ req.Limit = sdk.Int(limit)
+ req.Offset = sdk.Int(offset)
+ resp, err := client.ListUPgSQLBackup(req)
+ if err != nil {
+ return nil
+ }
+ for _, b := range resp.DataSet {
+ list = append(list, fmt.Sprintf("%s/%s", b.BackupID, b.BackupName))
+ }
+ if offset+limit >= resp.TotalCount {
+ break
+ }
+ }
+ return list
+}
diff --git a/products/pgsql/internal/pgsql/conf.go b/products/pgsql/internal/pgsql/conf.go
new file mode 100644
index 0000000000..57952faeea
--- /dev/null
+++ b/products/pgsql/internal/pgsql/conf.go
@@ -0,0 +1,23 @@
+package pgsql
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// newPgsqlConf ucloud pgsql conf
+func newPgsqlConf(ctx *cli.Context) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "conf",
+ Short: "List and manipulate parameter templates of UPgSQL instances",
+ Long: "List and manipulate parameter templates of UPgSQL instances",
+ }
+ cmd.AddCommand(newConfList(ctx))
+ cmd.AddCommand(newConfDescribe(ctx))
+ cmd.AddCommand(newConfCreate(ctx))
+ cmd.AddCommand(newConfDelete(ctx))
+ cmd.AddCommand(newConfDownload(ctx))
+ cmd.AddCommand(newConfUpload(ctx))
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/conf_create.go b/products/pgsql/internal/pgsql/conf_create.go
new file mode 100644
index 0000000000..9a259b6e78
--- /dev/null
+++ b/products/pgsql/internal/pgsql/conf_create.go
@@ -0,0 +1,62 @@
+package pgsql
+
+import (
+ "fmt"
+ "strconv"
+
+ "github.com/spf13/cobra"
+
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newConfCreate ucloud pgsql conf create
+func newConfCreate(ctx *cli.Context) *cobra.Command {
+ var srcConfID string
+ client := newUPgSQLClient(ctx)
+ req := client.NewCreateUPgSQLParamTemplateRequest()
+ cmd := &cobra.Command{
+ Use: "create",
+ Short: "Create a UPgSQL parameter template from a base template",
+ Long: "Create a UPgSQL parameter template from a base template",
+ Run: func(c *cobra.Command, args []string) {
+ id, err := strconv.Atoi(ctx.PickResourceID(srcConfID))
+ if err != nil {
+ ctx.HandleError(fmt.Errorf("invalid src-conf-id: %w", err))
+ return
+ }
+ req.SrcGroupID = sdk.Int(id)
+ resp, err := client.CreateUPgSQLParamTemplate(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ fmt.Fprintf(ctx.ProgressWriter(), "conf[%d] created\n", resp.GroupID)
+ ctx.EmitResult(cli.OpResultRow{ResourceID: strconv.Itoa(resp.GroupID), Action: "create", Status: "Created"})
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ req.GroupName = flags.String("name", "", "Required. Name of the parameter template")
+ flags.StringVar(&srcConfID, "src-conf-id", "", "Required. Group ID of the base template to clone from")
+ req.DBVersion = flags.String("db-version", "", "Required. DB version. Options: postgresql-10.4, postgresql-13.4")
+ req.Description = flags.String("description", "", "Optional. Description of the parameter template")
+ ctx.BindProjectID(cmd, req)
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+
+ command.SetFlagValues(cmd, "db-version", pgsqlVersionList...)
+ command.SetCompletion(cmd, "src-conf-id", func() []string {
+ return listParamTemplateIDNames(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone())
+ })
+
+ cmd.MarkFlagRequired("name")
+ cmd.MarkFlagRequired("src-conf-id")
+ cmd.MarkFlagRequired("db-version")
+
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/conf_delete.go b/products/pgsql/internal/pgsql/conf_delete.go
new file mode 100644
index 0000000000..4c047731b4
--- /dev/null
+++ b/products/pgsql/internal/pgsql/conf_delete.go
@@ -0,0 +1,55 @@
+package pgsql
+
+import (
+ "fmt"
+ "strconv"
+
+ "github.com/spf13/cobra"
+
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newConfDelete ucloud pgsql conf delete
+func newConfDelete(ctx *cli.Context) *cobra.Command {
+ var confID string
+ client := newUPgSQLClient(ctx)
+ req := client.NewDeleteUPgSQLParamTemplateRequest()
+ cmd := &cobra.Command{
+ Use: "delete",
+ Short: "Delete a UPgSQL parameter template",
+ Long: "Delete a UPgSQL parameter template",
+ Run: func(c *cobra.Command, args []string) {
+ id, err := strconv.Atoi(ctx.PickResourceID(confID))
+ if err != nil {
+ ctx.HandleError(fmt.Errorf("invalid conf-id: %w", err))
+ return
+ }
+ req.GroupID = sdk.Int(id)
+ _, err = client.DeleteUPgSQLParamTemplate(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ fmt.Fprintf(ctx.ProgressWriter(), "conf[%s] deleted\n", confID)
+ ctx.EmitResult(cli.OpResultRow{ResourceID: strconv.Itoa(id), Action: "delete", Status: "Deleted"})
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ flags.StringVar(&confID, "conf-id", "", "Required. Group ID of the parameter template to delete")
+ ctx.BindProjectID(cmd, req)
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+
+ cmd.MarkFlagRequired("conf-id")
+ command.SetCompletion(cmd, "conf-id", func() []string {
+ return listParamTemplateIDNames(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone())
+ })
+
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/conf_describe.go b/products/pgsql/internal/pgsql/conf_describe.go
new file mode 100644
index 0000000000..9e5f8474fe
--- /dev/null
+++ b/products/pgsql/internal/pgsql/conf_describe.go
@@ -0,0 +1,92 @@
+package pgsql
+
+import (
+ "fmt"
+ "strconv"
+
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-sdk-go/services/upgsql"
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newConfDescribe ucloud pgsql conf describe
+func newConfDescribe(ctx *cli.Context) *cobra.Command {
+ var confID string
+ client := newUPgSQLClient(ctx)
+ req := client.NewGetUPgSQLParamTemplateRequest()
+ cmd := &cobra.Command{
+ Use: "describe",
+ Short: "Display details of a UPgSQL parameter template",
+ Long: "Display details of a UPgSQL parameter template",
+ Run: func(c *cobra.Command, args []string) {
+ id, err := strconv.Atoi(ctx.PickResourceID(confID))
+ if err != nil {
+ ctx.HandleError(fmt.Errorf("invalid conf-id: %w", err))
+ return
+ }
+ req.GroupID = sdk.Int(id)
+ resp, err := client.GetUPgSQLParamTemplate(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+
+ // Template metadata comes from ListUPgSQLParamTemplate (GetUPgSQLParamTemplate
+ // returns only the param list). Fetch and filter by GroupID.
+ templates, err := listParamTemplates(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone())
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ var group upgsql.TemplateGroup
+ for _, t := range templates {
+ if t.GroupID == id {
+ group = t
+ break
+ }
+ }
+ attrs := []cli.DescribeRow{
+ {Attribute: "GroupID", Content: strconv.Itoa(group.GroupID)},
+ {Attribute: "GroupName", Content: group.GroupName},
+ {Attribute: "DBVersion", Content: group.DBVersion},
+ {Attribute: "Description", Content: group.Description},
+ {Attribute: "Modifiable", Content: strconv.FormatBool(group.Modifiable)},
+ }
+ fmt.Fprintln(ctx.ProgressWriter(), "Attributes:")
+ ctx.PrintList(attrs)
+
+ params := []PgsqlConfParamRow{}
+ for _, p := range resp.Data {
+ if p.Key == "" {
+ continue
+ }
+ params = append(params, PgsqlConfParamRow{
+ Key: p.Key,
+ Value: p.Value,
+ Modifiable: p.Modifiable,
+ })
+ }
+ fmt.Fprintln(ctx.ProgressWriter(), "\nParameters:")
+ ctx.PrintList(params)
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ flags.StringVar(&confID, "conf-id", "", "Required. Group ID of the parameter template to describe")
+ ctx.BindProjectID(cmd, req)
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+
+ cmd.MarkFlagRequired("conf-id")
+ command.SetCompletion(cmd, "conf-id", func() []string {
+ return listParamTemplateIDNames(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone())
+ })
+
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/conf_download.go b/products/pgsql/internal/pgsql/conf_download.go
new file mode 100644
index 0000000000..a0d46bd263
--- /dev/null
+++ b/products/pgsql/internal/pgsql/conf_download.go
@@ -0,0 +1,54 @@
+package pgsql
+
+import (
+ "fmt"
+ "strconv"
+
+ "github.com/spf13/cobra"
+
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newConfDownload ucloud pgsql conf download
+func newConfDownload(ctx *cli.Context) *cobra.Command {
+ var confID string
+ client := newUPgSQLClient(ctx)
+ req := client.NewDownloadUPgSQLParamTemplateRequest()
+ cmd := &cobra.Command{
+ Use: "download",
+ Short: "Download a UPgSQL parameter template (base64 content)",
+ Long: "Download a UPgSQL parameter template and print its base64 content to stdout",
+ Run: func(c *cobra.Command, args []string) {
+ id, err := strconv.Atoi(ctx.PickResourceID(confID))
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ req.GroupID = sdk.Int(id)
+ resp, err := client.DownloadUPgSQLParamTemplate(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ fmt.Fprint(ctx.Out(), resp.Content)
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ flags.StringVar(&confID, "conf-id", "", "Required. Group ID of the parameter template to download")
+ ctx.BindProjectID(cmd, req)
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+
+ cmd.MarkFlagRequired("conf-id")
+ command.SetCompletion(cmd, "conf-id", func() []string {
+ return listParamTemplateIDNames(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone())
+ })
+
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/conf_list.go b/products/pgsql/internal/pgsql/conf_list.go
new file mode 100644
index 0000000000..19af1efa1f
--- /dev/null
+++ b/products/pgsql/internal/pgsql/conf_list.go
@@ -0,0 +1,45 @@
+package pgsql
+
+import (
+ "github.com/spf13/cobra"
+ "github.com/ucloud/ucloud-sdk-go/ucloud/request"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// newConfList ucloud pgsql conf list
+func newConfList(ctx *cli.Context) *cobra.Command {
+ var common request.CommonBase
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List UPgSQL parameter templates",
+ Long: "List UPgSQL parameter templates",
+ Run: func(c *cobra.Command, args []string) {
+ templates, err := listParamTemplates(ctx, common.GetProjectId(), common.GetRegion(), common.GetZone())
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ rows := []PgsqlConfRow{}
+ for _, t := range templates {
+ rows = append(rows, PgsqlConfRow{
+ GroupID: t.GroupID,
+ GroupName: t.GroupName,
+ DBVersion: t.DBVersion,
+ Description: t.Description,
+ Modifiable: t.Modifiable,
+ })
+ }
+ ctx.PrintList(rows)
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ ctx.BindProjectID(cmd, &common)
+ ctx.BindRegion(cmd, &common)
+ ctx.BindZone(cmd, &common)
+
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/conf_upload.go b/products/pgsql/internal/pgsql/conf_upload.go
new file mode 100644
index 0000000000..d4dec6720d
--- /dev/null
+++ b/products/pgsql/internal/pgsql/conf_upload.go
@@ -0,0 +1,65 @@
+package pgsql
+
+import (
+ "encoding/base64"
+ "fmt"
+ "strconv"
+
+ "github.com/spf13/cobra"
+
+ 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"
+)
+
+// newConfUpload ucloud pgsql conf upload
+func newConfUpload(ctx *cli.Context) *cobra.Command {
+ var file string
+ client := newUPgSQLClient(ctx)
+ req := client.NewUploadUPgSQLParamTemplateRequest()
+ cmd := &cobra.Command{
+ Use: "upload",
+ Short: "Create a UPgSQL parameter template by uploading a local config file",
+ Long: "Create a UPgSQL parameter template by uploading a local config file",
+ Run: func(c *cobra.Command, args []string) {
+ content, err := cli.ReadFile(file)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ req.Content = sdk.String(base64.StdEncoding.EncodeToString([]byte(content)))
+ resp, err := client.UploadUPgSQLParamTemplate(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ fmt.Fprintf(ctx.ProgressWriter(), "conf[%d] uploaded\n", resp.GroupID)
+ ctx.EmitResult(cli.OpResultRow{ResourceID: strconv.Itoa(resp.GroupID), Action: "upload", Status: "Uploaded"})
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ flags.StringVar(&file, "conf-file", "", "Required. Path of the local configuration file")
+ req.GroupName = flags.String("name", "", "Required. Name of the parameter template")
+ req.DBVersion = flags.String("db-version", "", "Required. DB version. Options: postgresql-10.4, postgresql-13.4")
+ req.Description = flags.String("description", "", "Optional. Description of the parameter template")
+ req.ParamGroupType = flags.String("param-group-type", "", "Optional. Parameter group type")
+ ctx.BindProjectID(cmd, req)
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+
+ command.SetFlagValues(cmd, "db-version", pgsqlVersionList...)
+ command.SetCompletion(cmd, "conf-file", func() []string {
+ return common.GetFileList("")
+ })
+
+ cmd.MarkFlagRequired("conf-file")
+ cmd.MarkFlagRequired("name")
+ cmd.MarkFlagRequired("db-version")
+
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/create.go b/products/pgsql/internal/pgsql/create.go
new file mode 100644
index 0000000000..f5fbd6f0b0
--- /dev/null
+++ b/products/pgsql/internal/pgsql/create.go
@@ -0,0 +1,121 @@
+package pgsql
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newCreate ucloud pgsql db create
+func newCreate(ctx *cli.Context) *cobra.Command {
+ var paramGroupID int
+ var async bool
+ var password string
+
+ client := newUPgSQLClient(ctx)
+ req := client.NewCreateUPgSQLInstanceRequest()
+ cmd := &cobra.Command{
+ Use: "create",
+ Short: "Create a UPgSQL instance",
+ Long: "Create a UPgSQL instance on UCloud platform",
+ Run: func(c *cobra.Command, args []string) {
+ if len(*req.Name) < 6 {
+ ctx.HandleError(fmt.Errorf("name must be at least 6 characters"))
+ return
+ }
+ if password == "" {
+ ctx.HandleError(fmt.Errorf("admin password is required"))
+ return
+ }
+
+ // ParamGroupID: user-provided wins; otherwise auto-fetch a default template.
+ if c.Flags().Changed("param-group-id") {
+ req.ParamGroupID = sdk.Int(paramGroupID)
+ } else {
+ id, err := getDefaultParamGroupID(ctx, *req.DBVersion, req.GetProjectId(), req.GetRegion(), req.GetZone())
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ req.ParamGroupID = sdk.Int(id)
+ }
+
+ // VPCID/SubnetID accept "id/name" form; pick the id.
+ *req.VPCID = ctx.PickResourceID(*req.VPCID)
+ *req.SubnetID = ctx.PickResourceID(*req.SubnetID)
+ req.AdminPassword = sdk.String(password)
+
+ resp, err := client.CreateUPgSQLInstance(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+
+ instanceID := resp.InstanceID
+ if instanceID == "" {
+ ctx.HandleError(fmt.Errorf("empty InstanceID in response"))
+ return
+ }
+
+ w := ctx.ProgressWriter()
+ if async {
+ fmt.Fprintf(w, "pgsql[%s] is initializing\n", instanceID)
+ } else {
+ text := fmt.Sprintf("pgsql[%s] is initializing", instanceID)
+ ctx.PollerTo(w, describePgsqlByID(ctx)).Spoll(instanceID, text, []string{PGSQL_RUNNING, PGSQL_INIT_FAILED, PGSQL_START_FAILED})
+ }
+ ctx.EmitResult(cli.OpResultRow{ResourceID: instanceID, Action: "create", Status: "Initializing"})
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ // Required flags
+ req.Name = flags.String("name", "", "Required. Instance name, at least 6 characters")
+ flags.StringVar(&password, "password", "", "Required. Admin password")
+ req.DBVersion = flags.String("version", "", "Required. DB version. Options: postgresql-10.4, postgresql-13.4")
+ req.MachineType = flags.String("machine-type", "", "Required. Machine type ID, e.g. o.pgsql2m.medium. See 'ucloud pgsql db list-machine-type'")
+ req.VPCID = flags.String("vpc-id", "", "Required. VPC ID. See 'ucloud vpc list'")
+ req.SubnetID = flags.String("subnet-id", "", "Required. Subnet ID. See 'ucloud subnet list'")
+
+ // Optional flags
+ flags.IntVar(¶mGroupID, "param-group-id", 0, "Optional. Param group ID. Auto-fetched if omitted. See 'ucloud pgsql conf list'")
+ req.DiskSpace = flags.String("disk-size-gb", "100", "Optional. Disk size (GiB), at least 20, default 100")
+ req.Port = flags.Int("port", 5432, "Optional. Port, default 5432")
+ req.InstanceMode = flags.String("mode", "Normal", "Optional. Normal / HA")
+ flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for creation to finish")
+
+ ctx.BindProjectID(cmd, req)
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+
+ command.SetFlagValues(cmd, "version", pgsqlVersionList...)
+ command.SetFlagValues(cmd, "mode", "Normal", "HA")
+ command.SetCompletion(cmd, "vpc-id", func() []string {
+ return getAllVPCIdNames(ctx, req.GetProjectId(), req.GetRegion())
+ })
+ command.SetCompletion(cmd, "subnet-id", func() []string {
+ return getAllSubnetIDNames(ctx, *req.VPCID, req.GetProjectId(), req.GetRegion())
+ })
+ command.SetCompletion(cmd, "param-group-id", func() []string {
+ return listParamTemplateIDNames(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone())
+ })
+ command.SetCompletion(cmd, "machine-type", func() []string {
+ return listMachineTypeIDNames(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone())
+ })
+
+ cmd.MarkFlagRequired("name")
+ cmd.MarkFlagRequired("password")
+ cmd.MarkFlagRequired("version")
+ cmd.MarkFlagRequired("machine-type")
+ cmd.MarkFlagRequired("vpc-id")
+ cmd.MarkFlagRequired("subnet-id")
+
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/create_readonly.go b/products/pgsql/internal/pgsql/create_readonly.go
new file mode 100644
index 0000000000..5925a86027
--- /dev/null
+++ b/products/pgsql/internal/pgsql/create_readonly.go
@@ -0,0 +1,84 @@
+package pgsql
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newCreateReadonly ucloud pgsql db create-readonly
+func newCreateReadonly(ctx *cli.Context) *cobra.Command {
+ var async bool
+ client := newUPgSQLClient(ctx)
+ req := client.NewCreateUPgSQLReadonlyRequest()
+ cmd := &cobra.Command{
+ Use: "create-readonly",
+ Short: "Create a readonly replica for a UPgSQL instance",
+ Long: "Create a readonly replica synchronizing from a source UPgSQL instance",
+ Run: func(c *cobra.Command, args []string) {
+ *req.SrcInstanceID = ctx.PickResourceID(*req.SrcInstanceID)
+ if c.Flags().Changed("vpc-id") {
+ *req.VPCID = ctx.PickResourceID(*req.VPCID)
+ }
+ if c.Flags().Changed("subnet-id") {
+ *req.SubnetID = ctx.PickResourceID(*req.SubnetID)
+ }
+ resp, err := client.CreateUPgSQLReadonly(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ instanceID := resp.InstanceID
+ if instanceID == "" {
+ ctx.HandleError(fmt.Errorf("empty InstanceID in response"))
+ return
+ }
+ w := ctx.ProgressWriter()
+ if async {
+ fmt.Fprintf(w, "pgsql[%s] is initializing\n", instanceID)
+ } else {
+ text := fmt.Sprintf("pgsql[%s] is initializing", instanceID)
+ ctx.PollerTo(w, describePgsqlByID(ctx)).Spoll(instanceID, text, []string{PGSQL_RUNNING, PGSQL_INIT_FAILED, PGSQL_START_FAILED})
+ }
+ ctx.EmitResult(cli.OpResultRow{ResourceID: instanceID, Action: "create-readonly", Status: "Initializing"})
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ req.Name = flags.String("name", "", "Required. Name of the readonly replica")
+ req.SrcInstanceID = flags.String("src-instance-id", "", "Required. Resource ID of the source UPgSQL instance")
+ req.MachineType = flags.String("machine-type", "", "Required. Machine type ID, e.g. o.pgsql2m.medium. See 'ucloud pgsql db list-machine-type'")
+ req.DiskSpace = flags.Int("disk-size-gb", 0, "Required. Disk space (GiB)")
+ req.Port = flags.Int("port", 5432, "Optional. Port of the readonly replica, default 5432")
+ req.VPCID = flags.String("vpc-id", "", "Optional. VPC ID. Defaults to the source instance's VPC")
+ req.SubnetID = flags.String("subnet-id", "", "Optional. Subnet ID. Defaults to the source instance's subnet")
+ flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the long-running operation to finish")
+ ctx.BindProjectID(cmd, req)
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+
+ command.SetCompletion(cmd, "src-instance-id", func() []string {
+ return getUPgSQLIDList(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone())
+ })
+ command.SetCompletion(cmd, "vpc-id", func() []string {
+ return getAllVPCIdNames(ctx, req.GetProjectId(), req.GetRegion())
+ })
+ command.SetCompletion(cmd, "subnet-id", func() []string {
+ return getAllSubnetIDNames(ctx, *req.VPCID, req.GetProjectId(), req.GetRegion())
+ })
+ command.SetCompletion(cmd, "machine-type", func() []string {
+ return listMachineTypeIDNames(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone())
+ })
+
+ cmd.MarkFlagRequired("name")
+ cmd.MarkFlagRequired("src-instance-id")
+ cmd.MarkFlagRequired("machine-type")
+ cmd.MarkFlagRequired("disk-size-gb")
+
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/db.go b/products/pgsql/internal/pgsql/db.go
new file mode 100644
index 0000000000..3540322bf9
--- /dev/null
+++ b/products/pgsql/internal/pgsql/db.go
@@ -0,0 +1,36 @@
+package pgsql
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// newPgsqlDB ucloud pgsql db
+func newPgsqlDB(ctx *cli.Context) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "db",
+ Short: "Manage UPgSQL instances",
+ Long: "Manage UPgSQL instances",
+ }
+
+ cmd.AddCommand(newList(ctx))
+ cmd.AddCommand(newGet(ctx))
+ cmd.AddCommand(newCreate(ctx))
+ cmd.AddCommand(newDelete(ctx))
+ cmd.AddCommand(newStart(ctx))
+ cmd.AddCommand(newStop(ctx))
+ cmd.AddCommand(newRestart(ctx))
+ cmd.AddCommand(newUpgrade(ctx))
+ cmd.AddCommand(newListMachineType(ctx))
+ cmd.AddCommand(newListVersion(ctx))
+ cmd.AddCommand(newCreateReadonly(ctx))
+ cmd.AddCommand(newStopCreatingReadonly(ctx))
+ cmd.AddCommand(newUpdateName(ctx))
+ cmd.AddCommand(newUpdateRemark(ctx))
+ cmd.AddCommand(newResetPassword(ctx))
+ cmd.AddCommand(newPrice(ctx))
+ cmd.AddCommand(newUpgradePrice(ctx))
+
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/delete.go b/products/pgsql/internal/pgsql/delete.go
new file mode 100644
index 0000000000..dcdac3429a
--- /dev/null
+++ b/products/pgsql/internal/pgsql/delete.go
@@ -0,0 +1,77 @@
+package pgsql
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-sdk-go/services/upgsql"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newDelete ucloud pgsql db delete
+func newDelete(ctx *cli.Context) *cobra.Command {
+ var idNames []string
+ var yes bool
+ client := newUPgSQLClient(ctx)
+ req := client.NewDeleteUPgSQLInstanceRequest()
+ cmd := &cobra.Command{
+ Use: "delete",
+ Short: "Delete UPgSQL instances by instance-id",
+ Long: "Delete UPgSQL instances by instance-id",
+ Run: func(c *cobra.Command, args []string) {
+ ok, err := ctx.Confirm(yes, "Are you sure you want to delete the pgsql instance(s)?")
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ if !ok {
+ return
+ }
+ w := ctx.ProgressWriter()
+ results := []cli.OpResultRow{}
+ for _, idname := range idNames {
+ id := ctx.PickResourceID(idname)
+ any, err := describePgsqlByID(ctx)(id, nil)
+ if err != nil {
+ ctx.HandleError(err)
+ continue
+ }
+ req.InstanceID = &id
+ ins, ok := any.(*upgsql.UDBInstance)
+ if ok && ins.State == PGSQL_RUNNING {
+ stopReq := client.NewStopUPgSQLInstanceRequest()
+ stopReq.ProjectId = req.ProjectId
+ stopReq.Region = req.Region
+ stopReq.Zone = req.Zone
+ stopReq.InstanceID = req.InstanceID
+ stopPgsqlIns(ctx, stopReq, false, w)
+ }
+ _, err = client.DeleteUPgSQLInstance(req)
+ if err != nil {
+ ctx.HandleError(err)
+ continue
+ }
+ fmt.Fprintf(w, "pgsql[%s] deleted\n", idname)
+ results = append(results, cli.OpResultRow{ResourceID: id, Action: "delete", Status: "Deleted"})
+ }
+ ctx.EmitResult(results...)
+ },
+ }
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ flags.StringSliceVar(&idNames, "instance-id", nil, "Required. Resource ID of UPgSQL instances to delete")
+ ctx.BindProjectID(cmd, req)
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+ flags.BoolVarP(&yes, "yes", "y", false, "Optional. Do not prompt for confirmation.")
+
+ cmd.MarkFlagRequired("instance-id")
+ command.SetCompletion(cmd, "instance-id", func() []string {
+ return getUPgSQLIDList(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone())
+ })
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/get.go b/products/pgsql/internal/pgsql/get.go
new file mode 100644
index 0000000000..6e8f7f9e3a
--- /dev/null
+++ b/products/pgsql/internal/pgsql/get.go
@@ -0,0 +1,78 @@
+package pgsql
+
+import (
+ "fmt"
+ "strconv"
+
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/internal/common"
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newGet ucloud pgsql db get
+func newGet(ctx *cli.Context) *cobra.Command {
+ client := newUPgSQLClient(ctx)
+ req := client.NewGetUPgSQLInstanceRequest()
+ cmd := &cobra.Command{
+ Use: "get",
+ Short: "Display details of a UPgSQL instance",
+ Long: "Display details of a UPgSQL instance",
+ Run: func(c *cobra.Command, args []string) {
+ *req.InstanceID = ctx.PickResourceID(*req.InstanceID)
+ resp, err := client.GetUPgSQLInstance(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ ins := resp.DataSet
+ if ins.InstanceID == "" {
+ ctx.HandleError(fmt.Errorf("pgsql[%s] may not exist", *req.InstanceID))
+ return
+ }
+ attrs := []cli.DescribeRow{
+ {Attribute: "InstanceID", Content: ins.InstanceID},
+ {Attribute: "Name", Content: ins.Name},
+ {Attribute: "State", Content: ins.State},
+ {Attribute: "Zone", Content: ins.Zone},
+ {Attribute: "BackupZone", Content: ins.BackupZone},
+ {Attribute: "DBVersion", Content: ins.DBVersion},
+ {Attribute: "InstanceMode", Content: ins.InstanceMode},
+ {Attribute: "AdminUser", Content: ins.AdminUser},
+ {Attribute: "IP", Content: ins.IP},
+ {Attribute: "Port", Content: strconv.Itoa(ins.Port)},
+ {Attribute: "VPCID", Content: ins.VPCID},
+ {Attribute: "SubnetID", Content: ins.SubnetID},
+ {Attribute: "ParamGroupID", Content: strconv.Itoa(ins.ParamGroupID)},
+ {Attribute: "MemoryLimit", Content: fmt.Sprintf("%dMB", ins.MemoryLimit)},
+ {Attribute: "DiskSpace", Content: fmt.Sprintf("%dGB", ins.DiskSpace)},
+ {Attribute: "DiskUsedSize", Content: fmt.Sprintf("%.2fGB", ins.DiskUsedSize)},
+ {Attribute: "Remark", Content: ins.Remark},
+ {Attribute: "BackupCount", Content: strconv.Itoa(ins.BackupCount)},
+ {Attribute: "BackupBeginTime", Content: strconv.Itoa(ins.BackupBeginTime)},
+ {Attribute: "BackupDate", Content: ins.BackupDate},
+ {Attribute: "CreateTime", Content: common.FormatDateTime(ins.CreateTime)},
+ {Attribute: "ModifyTime", Content: common.FormatDateTime(ins.ModifyTime)},
+ {Attribute: "ExpiredTime", Content: common.FormatDateTime(ins.ExpiredTime)},
+ }
+ fmt.Fprintln(ctx.ProgressWriter(), "Attributes:")
+ ctx.PrintList(attrs)
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ req.InstanceID = flags.String("instance-id", "", "Required. Resource ID of the UPgSQL instance")
+ ctx.BindProjectID(cmd, req)
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+
+ cmd.MarkFlagRequired("instance-id")
+ command.SetCompletion(cmd, "instance-id", func() []string {
+ return getUPgSQLIDList(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone())
+ })
+
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/list.go b/products/pgsql/internal/pgsql/list.go
new file mode 100644
index 0000000000..bdd5f86c54
--- /dev/null
+++ b/products/pgsql/internal/pgsql/list.go
@@ -0,0 +1,81 @@
+package pgsql
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-sdk-go/services/upgsql"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newList ucloud pgsql db list
+func newList(ctx *cli.Context) *cobra.Command {
+ var instanceID string
+ client := newUPgSQLClient(ctx)
+ req := client.NewListUPgSQLInstanceRequest()
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List UPgSQL instances",
+ Long: "List UPgSQL instances",
+ Run: func(c *cobra.Command, args []string) {
+ resp, err := client.ListUPgSQLInstance(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ list := []PgsqlInstanceRow{}
+ for _, ins := range resp.DataSet {
+ if instanceID != "" && ins.InstanceID != instanceID {
+ continue
+ }
+ list = append(list, toInstanceRow(ins))
+ for _, slave := range ins.DataSet {
+ list = append(list, toInstanceRow(upgsql.UDBInstanceSet{
+ Zone: slave.Zone,
+ InstanceID: slave.InstanceID,
+ Name: slave.Name,
+ DBVersion: slave.DBVersion,
+ InstanceMode: slave.InstanceMode,
+ State: slave.State,
+ VPCID: slave.VPCID,
+ SubnetID: slave.SubnetID,
+ IP: slave.IP,
+ }))
+ }
+ }
+ ctx.PrintList(list)
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ flags.StringVar(&instanceID, "instance-id", "", "Optional. List the specified UPgSQL instance only")
+ ctx.BindProjectID(cmd, req)
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+
+ command.SetCompletion(cmd, "instance-id", func() []string {
+ return getUPgSQLIDList(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone())
+ })
+
+ return cmd
+}
+
+// toInstanceRow maps an SDK UDBInstanceSet to a PgsqlInstanceRow. role is non-empty
+// for readonly slaves (prefixed with ⮭ to indent under the master row).
+func toInstanceRow(ins upgsql.UDBInstanceSet) PgsqlInstanceRow {
+ row := PgsqlInstanceRow{
+ Name: ins.Name,
+ InstanceID: ins.InstanceID,
+ Zone: ins.Zone,
+ State: ins.State,
+ IP: ins.IP,
+ VPC: ins.VPCID,
+ Subnet: ins.SubnetID,
+ InstanceMode: ins.InstanceMode,
+ DBVersion: ins.DBVersion,
+ }
+ return row
+}
diff --git a/products/pgsql/internal/pgsql/list_machine_type.go b/products/pgsql/internal/pgsql/list_machine_type.go
new file mode 100644
index 0000000000..4effc4d606
--- /dev/null
+++ b/products/pgsql/internal/pgsql/list_machine_type.go
@@ -0,0 +1,45 @@
+package pgsql
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// newListMachineType ucloud pgsql db list-machine-type
+func newListMachineType(ctx *cli.Context) *cobra.Command {
+ client := newUPgSQLClient(ctx)
+ req := client.NewListUPgSQLMachineTypeRequest()
+ cmd := &cobra.Command{
+ Use: "list-machine-type",
+ Short: "List available UPgSQL machine types",
+ Long: "List available UPgSQL machine types via ListUPgSQLMachineType API",
+ Run: func(c *cobra.Command, args []string) {
+ resp, err := client.ListUPgSQLMachineType(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ rows := []PgsqlMachineTypeRow{}
+ for _, mt := range resp.DataSet {
+ rows = append(rows, PgsqlMachineTypeRow{
+ ID: mt.ID,
+ Description: mt.Description,
+ Cpu: mt.Cpu,
+ Memory: mt.Memory,
+ Os: mt.Os,
+ })
+ }
+ ctx.PrintList(rows)
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ ctx.BindProjectID(cmd, req)
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/list_version.go b/products/pgsql/internal/pgsql/list_version.go
new file mode 100644
index 0000000000..70ac39225d
--- /dev/null
+++ b/products/pgsql/internal/pgsql/list_version.go
@@ -0,0 +1,42 @@
+package pgsql
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// newListVersion ucloud pgsql db list-version
+func newListVersion(ctx *cli.Context) *cobra.Command {
+ client := newUPgSQLClient(ctx)
+ req := client.NewListUPgSQLVersionRequest()
+ cmd := &cobra.Command{
+ Use: "list-version",
+ Short: "List available UPgSQL versions",
+ Long: "List available UPgSQL versions via ListUPgSQLVersion API",
+ Run: func(c *cobra.Command, args []string) {
+ resp, err := client.ListUPgSQLVersion(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ rows := []PgsqlVersionRow{}
+ for _, v := range resp.DataSet {
+ rows = append(rows, PgsqlVersionRow{
+ DBVersion: v.DBVersion,
+ Available: v.Available,
+ })
+ }
+ ctx.PrintList(rows)
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ ctx.BindProjectID(cmd, req)
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/log.go b/products/pgsql/internal/pgsql/log.go
new file mode 100644
index 0000000000..0d9575954f
--- /dev/null
+++ b/products/pgsql/internal/pgsql/log.go
@@ -0,0 +1,19 @@
+package pgsql
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// newPgsqlLog ucloud pgsql log
+func newPgsqlLog(ctx *cli.Context) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "log",
+ Short: "List and back up logs of UPgSQL instances",
+ Long: "List and back up logs of UPgSQL instances",
+ }
+ cmd.AddCommand(newLogList(ctx))
+ cmd.AddCommand(newLogBackup(ctx))
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/log_backup.go b/products/pgsql/internal/pgsql/log_backup.go
new file mode 100644
index 0000000000..3e931bf756
--- /dev/null
+++ b/products/pgsql/internal/pgsql/log_backup.go
@@ -0,0 +1,78 @@
+package pgsql
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ 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"
+)
+
+var pgsqlLogTypeList = []string{"slow", "error"}
+
+// newLogBackup ucloud pgsql log backup
+func newLogBackup(ctx *cli.Context) *cobra.Command {
+ var beginTime, endTime string
+ client := newUPgSQLClient(ctx)
+ req := client.NewBackupUPgSQLLogRequest()
+ cmd := &cobra.Command{
+ Use: "backup",
+ Short: "Back up the log package of a UPgSQL instance",
+ Long: "Back up the slow/error log package of a UPgSQL instance",
+ Run: func(c *cobra.Command, args []string) {
+ *req.InstanceID = ctx.PickResourceID(*req.InstanceID)
+ if beginTime != "" {
+ bt, err := time.Parse(common.DateTimeLayout, beginTime)
+ if err != nil {
+ ctx.HandleError(fmt.Errorf("invalid begin-time (use %s): %w", common.DateTimeLayout, err))
+ return
+ }
+ req.BeginTime = sdk.Int(int(bt.Unix()))
+ }
+ if endTime != "" {
+ et, err := time.Parse(common.DateTimeLayout, endTime)
+ if err != nil {
+ ctx.HandleError(fmt.Errorf("invalid end-time (use %s): %w", common.DateTimeLayout, err))
+ return
+ }
+ req.EndTime = sdk.Int(int(et.Unix()))
+ }
+ _, err := client.BackupUPgSQLLog(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ fmt.Fprintf(ctx.ProgressWriter(), "log of pgsql[%s] backuped\n", *req.InstanceID)
+ ctx.EmitResult(cli.OpResultRow{ResourceID: *req.InstanceID, Action: "log-backup", Status: "Backuped"})
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ req.InstanceID = flags.String("instance-id", "", "Required. Resource ID of the UPgSQL instance")
+ req.BackupName = flags.String("name", "", "Required. Name of the exported backup file")
+ req.BackupFile = flags.String("backup-file", "", "Required. Name of the log query result file")
+ req.LogType = flags.String("log-type", "", "Optional. Log type. Accept values: slow, error")
+ flags.StringVar(&beginTime, "begin-time", "", "Optional. Log begin time, e.g. 2019-01-02/15:04:05")
+ flags.StringVar(&endTime, "end-time", "", "Optional. Log end time, e.g. 2019-01-02/15:04:05")
+ ctx.BindProjectID(cmd, req)
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+
+ command.SetFlagValues(cmd, "log-type", pgsqlLogTypeList...)
+ command.SetCompletion(cmd, "instance-id", func() []string {
+ return getUPgSQLIDList(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone())
+ })
+
+ cmd.MarkFlagRequired("instance-id")
+ cmd.MarkFlagRequired("name")
+ cmd.MarkFlagRequired("backup-file")
+
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/log_list.go b/products/pgsql/internal/pgsql/log_list.go
new file mode 100644
index 0000000000..5606e335f6
--- /dev/null
+++ b/products/pgsql/internal/pgsql/log_list.go
@@ -0,0 +1,75 @@
+package pgsql
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ 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"
+)
+
+// newLogList ucloud pgsql log list
+func newLogList(ctx *cli.Context) *cobra.Command {
+ var beginTime, endTime string
+ client := newUPgSQLClient(ctx)
+ req := client.NewListUPgSQLLogRequest()
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List logs of a UPgSQL instance within a time range",
+ Long: "List logs of a UPgSQL instance within a time range",
+ Run: func(c *cobra.Command, args []string) {
+ *req.InstanceID = ctx.PickResourceID(*req.InstanceID)
+ bt, err := time.Parse(common.DateTimeLayout, beginTime)
+ if err != nil {
+ ctx.HandleError(fmt.Errorf("invalid begin-time (use %s): %w", common.DateTimeLayout, err))
+ return
+ }
+ req.BeginTime = sdk.Int(int(bt.Unix()))
+ et, err := time.Parse(common.DateTimeLayout, endTime)
+ if err != nil {
+ ctx.HandleError(fmt.Errorf("invalid end-time (use %s): %w", common.DateTimeLayout, err))
+ return
+ }
+ req.EndTime = sdk.Int(int(et.Unix()))
+ resp, err := client.ListUPgSQLLog(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ list := []PgsqlLogRow{}
+ for _, l := range resp.DataSet {
+ list = append(list, PgsqlLogRow{
+ Name: l.Name,
+ Size: fmt.Sprintf("%dB", l.Size),
+ BeginTime: common.FormatDateTime(l.BeginTime),
+ EndTime: common.FormatDateTime(l.EndTime),
+ })
+ }
+ ctx.PrintList(list)
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ req.InstanceID = flags.String("instance-id", "", "Required. Resource ID of the UPgSQL instance")
+ flags.StringVar(&beginTime, "begin-time", "", "Required. Begin time, e.g. 2019-01-02/15:04:05")
+ flags.StringVar(&endTime, "end-time", "", "Required. End time, e.g. 2019-01-02/15:04:05")
+ ctx.BindProjectID(cmd, req)
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+
+ cmd.MarkFlagRequired("instance-id")
+ cmd.MarkFlagRequired("begin-time")
+ cmd.MarkFlagRequired("end-time")
+ command.SetCompletion(cmd, "instance-id", func() []string {
+ return getUPgSQLIDList(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone())
+ })
+
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/poll.go b/products/pgsql/internal/pgsql/poll.go
new file mode 100644
index 0000000000..2a04c10d1f
--- /dev/null
+++ b/products/pgsql/internal/pgsql/poll.go
@@ -0,0 +1,54 @@
+package pgsql
+
+import (
+ "fmt"
+ "io"
+
+ "github.com/ucloud/ucloud-sdk-go/services/upgsql"
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+ "github.com/ucloud/ucloud-sdk-go/ucloud/request"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// stopPgsqlIns stops the instance and narrates progress to out (the caller passes
+// ctx.ProgressWriter(): stdout in table mode, stderr in json/yaml). Returns the
+// stop error so callers can decide whether to record a structured result.
+// Mirrors products/mysql/internal/mysql/poll.go stopUdbIns.
+func stopPgsqlIns(ctx *cli.Context, req *upgsql.StopUPgSQLInstanceRequest, async bool, out io.Writer) error {
+ client := newUPgSQLClient(ctx)
+ _, err := client.StopUPgSQLInstance(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return err
+ }
+ text := fmt.Sprintf("pgsql[%s] is stopping", *req.InstanceID)
+ if async {
+ fmt.Fprintln(out, text)
+ } else {
+ ctx.PollerTo(out, describePgsqlByID(ctx)).Spoll(*req.InstanceID, text, []string{PGSQL_STOPPED, PGSQL_SHUTDOWN_FAILED})
+ }
+ return nil
+}
+
+// describePgsqlByID returns the poller's describe func, closing over ctx so it
+// can build an authed upgsql client. Mirrors products/mysql/internal/mysql/poll.go
+// describeUdbByID, but uses GetUPgSQLInstance (single-instance describe).
+func describePgsqlByID(ctx *cli.Context) func(instanceID string, commonBase *request.CommonBase) (interface{}, error) {
+ return func(instanceID string, commonBase *request.CommonBase) (interface{}, error) {
+ client := newUPgSQLClient(ctx)
+ req := client.NewGetUPgSQLInstanceRequest()
+ if commonBase != nil {
+ req.CommonBase = *commonBase
+ }
+ req.InstanceID = sdk.String(instanceID)
+ resp, err := client.GetUPgSQLInstance(req)
+ if err != nil {
+ return nil, err
+ }
+ if resp.DataSet.InstanceID == "" {
+ return nil, fmt.Errorf("pgsql[%s] may not exist", instanceID)
+ }
+ return &resp.DataSet, nil
+ }
+}
diff --git a/products/pgsql/internal/pgsql/price.go b/products/pgsql/internal/pgsql/price.go
new file mode 100644
index 0000000000..c78d984e59
--- /dev/null
+++ b/products/pgsql/internal/pgsql/price.go
@@ -0,0 +1,63 @@
+package pgsql
+
+import (
+ "github.com/spf13/cobra"
+
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newPrice ucloud pgsql db price
+func newPrice(ctx *cli.Context) *cobra.Command {
+ client := newUPgSQLClient(ctx)
+ req := client.NewGetUPgSQLInstancePriceRequest()
+ cmd := &cobra.Command{
+ Use: "price",
+ Short: "Get the price of creating UPgSQL instances",
+ Long: "Get the price of creating UPgSQL instances",
+ Run: func(c *cobra.Command, args []string) {
+ if *req.ChargeType == "Dynamic" {
+ req.Quantity = sdk.Int(0)
+ }
+ resp, err := client.GetUPgSQLInstancePrice(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ rows := []PgsqlPriceRow{}
+ for _, p := range resp.PriceSet {
+ rows = append(rows, PgsqlPriceRow{
+ ChargeType: p.ChargeType,
+ Price: p.Price,
+ OriginalPrice: p.OriginalPrice,
+ })
+ }
+ ctx.PrintList(rows)
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ req.MachineType = flags.String("machine-type", "", "Required. Machine type ID, e.g. o.pgsql2m.medium. See 'ucloud pgsql db list-machine-type'")
+ req.DiskSpace = flags.Int("disk-size-gb", 0, "Required. Disk space (GiB)")
+ req.InstanceMode = flags.String("mode", "Normal", "Required. Normal / HA")
+ req.ChargeType = flags.String("charge-type", "Month", "Optional. Year / Month / Dynamic")
+ req.Quantity = flags.Int("quantity", 1, "Optional. Purchase duration. Month: 1-9, 0=until end of month; Dynamic: ignored; Year: years")
+ ctx.BindProjectID(cmd, req)
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+
+ command.SetFlagValues(cmd, "charge-type", "Year", "Month", "Dynamic")
+ command.SetFlagValues(cmd, "mode", "Normal", "HA")
+ command.SetCompletion(cmd, "machine-type", func() []string {
+ return listMachineTypeIDNames(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone())
+ })
+
+ cmd.MarkFlagRequired("machine-type")
+ cmd.MarkFlagRequired("disk-size-gb")
+
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/reset_password.go b/products/pgsql/internal/pgsql/reset_password.go
new file mode 100644
index 0000000000..890e5ac01a
--- /dev/null
+++ b/products/pgsql/internal/pgsql/reset_password.go
@@ -0,0 +1,54 @@
+package pgsql
+
+import (
+ "github.com/spf13/cobra"
+
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newResetPassword ucloud pgsql db reset-password
+func newResetPassword(ctx *cli.Context) *cobra.Command {
+ var idNames []string
+ client := newUPgSQLClient(ctx)
+ req := client.NewUpdateUPgSQLPasswordRequest()
+ cmd := &cobra.Command{
+ Use: "reset-password",
+ Short: "Reset the admin password of UPgSQL instances",
+ Long: "Reset the admin password of UPgSQL instances",
+ Run: func(c *cobra.Command, args []string) {
+ results := []cli.OpResultRow{}
+ for _, idname := range idNames {
+ id := ctx.PickResourceID(idname)
+ req.InstanceID = sdk.String(id)
+ _, err := client.UpdateUPgSQLPassword(req)
+ if err != nil {
+ ctx.HandleError(err)
+ continue
+ }
+ results = append(results, cli.OpResultRow{ResourceID: id, Action: "reset-password", Status: "PasswordReset"})
+ }
+ ctx.EmitResult(results...)
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ flags.StringSliceVar(&idNames, "instance-id", nil, "Required. Resource ID of UPgSQL instances to reset password")
+ req.Password = flags.String("password", "", "Required. New password")
+ req.Name = flags.String("name", "", "Optional. Database user name, default root")
+ ctx.BindProjectID(cmd, req)
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+
+ cmd.MarkFlagRequired("instance-id")
+ cmd.MarkFlagRequired("password")
+ command.SetCompletion(cmd, "instance-id", func() []string {
+ return getUPgSQLIDList(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone())
+ })
+
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/restart.go b/products/pgsql/internal/pgsql/restart.go
new file mode 100644
index 0000000000..071b50eede
--- /dev/null
+++ b/products/pgsql/internal/pgsql/restart.go
@@ -0,0 +1,66 @@
+package pgsql
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newRestart ucloud pgsql db restart
+func newRestart(ctx *cli.Context) *cobra.Command {
+ var async bool
+ var idNames []string
+ client := newUPgSQLClient(ctx)
+ req := client.NewRestartUPgSQLInstanceRequest()
+ cmd := &cobra.Command{
+ Use: "restart",
+ Short: "Restart UPgSQL instances by instance-id",
+ Long: "Restart UPgSQL instances by instance-id",
+ Run: func(c *cobra.Command, args []string) {
+ w := ctx.ProgressWriter()
+ results := []cli.OpResultRow{}
+ for _, idname := range idNames {
+ id := ctx.PickResourceID(idname)
+ req.InstanceID = sdk.String(id)
+ _, err := client.RestartUPgSQLInstance(req)
+ if err != nil {
+ ctx.HandleError(err)
+ continue
+ }
+ if async {
+ fmt.Fprintf(w, "pgsql[%s] is restarting\n", idname)
+ } else {
+ text := fmt.Sprintf("pgsql[%s] is restarting", idname)
+ ctx.PollerTo(w, describePgsqlByID(ctx)).Spoll(id, text, []string{PGSQL_RUNNING, PGSQL_START_FAILED, PGSQL_SHUTDOWN_FAILED})
+ }
+ results = append(results, cli.OpResultRow{ResourceID: id, Action: "restart", Status: "Restarting"})
+ }
+ ctx.EmitResult(results...)
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ flags.StringSliceVar(&idNames, "instance-id", nil, "Required. Resource ID of UPgSQL instances to restart")
+ ctx.BindProjectID(cmd, req)
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+ req.ForceToRestart = flags.Bool("force", false, "Optional. Restart UPgSQL instances by force or not")
+ req.RestartHost = flags.Bool("restart-host", false, "Optional. Restart the host together or not")
+ flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the long-running operation to finish.")
+
+ cmd.MarkFlagRequired("instance-id")
+
+ command.SetFlagValues(cmd, "force", "true", "false")
+ command.SetFlagValues(cmd, "restart-host", "true", "false")
+ command.SetCompletion(cmd, "instance-id", func() []string {
+ return getUPgSQLIDList(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone())
+ })
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/rows.go b/products/pgsql/internal/pgsql/rows.go
new file mode 100644
index 0000000000..e66e514df2
--- /dev/null
+++ b/products/pgsql/internal/pgsql/rows.go
@@ -0,0 +1,120 @@
+package pgsql
+
+// PgsqlInstanceRow is the table row for `pgsql db list`.
+type PgsqlInstanceRow struct {
+ Name string
+ InstanceID string
+ Zone string
+ State string
+ IP string
+ VPC string
+ Subnet string
+ InstanceMode string
+ DBVersion string
+ Port int
+ DiskSpace int
+ Memory int
+}
+
+// PgsqlMachineTypeRow is the table row for `pgsql db list-machine-type`.
+type PgsqlMachineTypeRow struct {
+ ID string
+ Description string
+ Cpu int
+ Memory int
+ Os string
+}
+
+// PgsqlVersionRow is the table row for `pgsql db list-version`.
+type PgsqlVersionRow struct {
+ DBVersion string
+ Available string
+}
+
+// PgsqlConfRow is the table row for `pgsql conf list`.
+type PgsqlConfRow struct {
+ GroupID int
+ GroupName string
+ DBVersion string
+ Description string
+ Modifiable bool
+}
+
+// PgsqlConfParamRow is the table row for `pgsql conf describe` parameter list.
+type PgsqlConfParamRow struct {
+ Key string
+ Value string
+ Modifiable bool
+}
+
+// PgsqlBackupRow is the table row for `pgsql backup list`.
+type PgsqlBackupRow struct {
+ BackupID string
+ BackupName string
+ InstanceID string
+ State string
+ BackupType string
+ BackupSize string
+ BackupStartTime string
+ BackupEndTime string
+}
+
+// PgsqlBackupURLRow is the table row for `pgsql backup download`.
+type PgsqlBackupURLRow struct {
+ BackupPath string
+ InnerBackupPath string
+}
+
+// PgsqlBackupStrategyRow is the table row for `pgsql backup strategy`.
+type PgsqlBackupStrategyRow struct {
+ BackupMethod string
+ BackupTimeRange string
+ BackupWeek string
+}
+
+// PgsqlLogRow is the table row for `pgsql log list`.
+type PgsqlLogRow struct {
+ Name string
+ Size string
+ BeginTime string
+ EndTime string
+}
+
+// PgsqlPriceRow is the table row for `pgsql db price` / `upgrade-price`.
+type PgsqlPriceRow struct {
+ ChargeType string
+ Price float64
+ OriginalPrice float64
+}
+
+// SupabaseInstanceRow is the table row for `pgsql supabase list`.
+type SupabaseInstanceRow struct {
+ USupabaseName string
+ InstanceID string
+ UPgSQLID string
+ Zone string
+ IntranetAddress string
+ Port int
+ State string
+}
+
+// SupabaseStorageConfigRow is the table row for `pgsql supabase get-storage-config`.
+type SupabaseStorageConfigRow struct {
+ Key string
+ Value string
+ Description string
+ Required bool
+}
+
+// SupabaseAPIKeyRow is the table row for `pgsql supabase get-api-key`.
+type SupabaseAPIKeyRow struct {
+ ServiceKey string
+ AnonKey string
+}
+
+// SupabaseChargeRow is the table row for `pgsql supabase external-price` /
+// `bandwidth-upgrade-price`.
+type SupabaseChargeRow struct {
+ ChargeType string
+ Price int
+}
diff --git a/products/pgsql/internal/pgsql/start.go b/products/pgsql/internal/pgsql/start.go
new file mode 100644
index 0000000000..85568db2c4
--- /dev/null
+++ b/products/pgsql/internal/pgsql/start.go
@@ -0,0 +1,61 @@
+package pgsql
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newStart ucloud pgsql db start
+func newStart(ctx *cli.Context) *cobra.Command {
+ var async bool
+ var idNames []string
+ client := newUPgSQLClient(ctx)
+ req := client.NewStartUPgSQLInstanceRequest()
+ cmd := &cobra.Command{
+ Use: "start",
+ Short: "Start UPgSQL instances by instance-id",
+ Long: "Start UPgSQL instances by instance-id",
+ Run: func(c *cobra.Command, args []string) {
+ w := ctx.ProgressWriter()
+ results := []cli.OpResultRow{}
+ for _, idname := range idNames {
+ id := ctx.PickResourceID(idname)
+ req.InstanceID = sdk.String(id)
+ _, err := client.StartUPgSQLInstance(req)
+ if err != nil {
+ ctx.HandleError(err)
+ continue
+ }
+ if async {
+ fmt.Fprintf(w, "pgsql[%s] is starting\n", idname)
+ } else {
+ text := fmt.Sprintf("pgsql[%s] is starting", idname)
+ ctx.PollerTo(w, describePgsqlByID(ctx)).Spoll(id, text, []string{PGSQL_RUNNING, PGSQL_START_FAILED})
+ }
+ results = append(results, cli.OpResultRow{ResourceID: id, Action: "start", Status: "Starting"})
+ }
+ ctx.EmitResult(results...)
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ flags.StringSliceVar(&idNames, "instance-id", nil, "Required. Resource ID of UPgSQL instances to start")
+ ctx.BindProjectID(cmd, req)
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+ flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the long-running operation to finish.")
+
+ cmd.MarkFlagRequired("instance-id")
+ command.SetCompletion(cmd, "instance-id", func() []string {
+ return getUPgSQLIDList(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone())
+ })
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/status.go b/products/pgsql/internal/pgsql/status.go
new file mode 100644
index 0000000000..527efe3693
--- /dev/null
+++ b/products/pgsql/internal/pgsql/status.go
@@ -0,0 +1,47 @@
+package pgsql
+
+// UPgSQL-domain state constants.
+//
+// These follow the LIVE API State enum (as observed from ListUPgSQLInstance /
+// GetUPgSQLInstance responses): Initing / InitFailed / Starting / Running /
+// Stopping / Stopped / Deleted / Upgrading / Promoting / Recovering /
+// RecoverFailed / StartFailed / ShutdownFailed / Deleting / DeleteFailed.
+//
+// NOTE: the published GetUPgSQLInstance doc lists "Shutoff"/"Shutdown"/"Fail"
+// but the live API does NOT return those — a stopped instance reports State=
+// "Stopped" (not "Shutoff"). Using the doc values caused the stop poller to
+// never match its target and spin until the 10m timeout. Trust the live enum.
+const (
+ PGSQL_RUNNING = "Running"
+ PGSQL_STOPPING = "Stopping"
+ PGSQL_STOPPED = "Stopped"
+ PGSQL_INITING = "Initing"
+ PGSQL_INIT_FAILED = "InitFailed"
+ PGSQL_STARTING = "Starting"
+ PGSQL_START_FAILED = "StartFailed"
+ PGSQL_SHUTDOWN_FAILED = "ShutdownFailed"
+ PGSQL_DELETING = "Deleting"
+ PGSQL_DELETED = "Deleted"
+ PGSQL_DELETE_FAILED = "DeleteFailed"
+ PGSQL_UPGRADING = "Upgrading"
+ PGSQL_PROMOTING = "Promoting"
+ PGSQL_RECOVERING = "Recovering"
+ PGSQL_RECOVER_FAILED = "RecoverFailed"
+)
+
+// Backup state constants, from ListUPgSQLBackup.UPgSQLBackup.State enum values
+// (Backuping / Success / Failed / Expired). Display-only; backup ops are not polled.
+const (
+ PGSQL_BACKUP_SUCCESS = "Success"
+ PGSQL_BACKUP_FAILED = "Failed"
+)
+
+// USupabase state constants (live-observed from ListUSupabaseInstance /
+// DescribeUSupabase responses). Used as poll targets for start/stop/restart.
+// As with the pgsql enum, the published doc is untrusted; these are confirmed
+// live (Running confirmed; Stopped pending a live stop verification).
+const (
+ SUPABASE_RUNNING = "Running"
+ SUPABASE_STOPPED = "Stopped"
+ SUPABASE_FAIL = "Fail"
+)
diff --git a/products/pgsql/internal/pgsql/stop.go b/products/pgsql/internal/pgsql/stop.go
new file mode 100644
index 0000000000..ad2feff783
--- /dev/null
+++ b/products/pgsql/internal/pgsql/stop.go
@@ -0,0 +1,58 @@
+package pgsql
+
+import (
+ "github.com/spf13/cobra"
+
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newStop ucloud pgsql db stop
+func newStop(ctx *cli.Context) *cobra.Command {
+ var idNames []string
+ var async bool
+ client := newUPgSQLClient(ctx)
+ req := client.NewStopUPgSQLInstanceRequest()
+ cmd := &cobra.Command{
+ Use: "stop",
+ Short: "Stop UPgSQL instances by instance-id",
+ Long: "Stop UPgSQL instances by instance-id",
+ Run: func(c *cobra.Command, args []string) {
+ w := ctx.ProgressWriter()
+ results := []cli.OpResultRow{}
+ for _, idname := range idNames {
+ id := ctx.PickResourceID(idname)
+ req.InstanceID = sdk.String(id)
+ if err := stopPgsqlIns(ctx, req, async, w); err != nil {
+ ctx.HandleError(err)
+ continue
+ }
+ results = append(results, cli.OpResultRow{ResourceID: id, Action: "stop", Status: "Stopping"})
+ }
+ ctx.EmitResult(results...)
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ flags.StringSliceVar(&idNames, "instance-id", nil, "Required. Resource ID of UPgSQL instances to stop")
+ ctx.BindProjectID(cmd, req)
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+ req.ForceToStop = flags.Bool("force", false, "Optional. Stop UPgSQL instances by force or not")
+ req.StopHost = flags.Bool("stop-host", false, "Optional. Stop the host together or not")
+ flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the long-running operation to finish.")
+
+ cmd.MarkFlagRequired("instance-id")
+
+ command.SetFlagValues(cmd, "force", "true", "false")
+ command.SetFlagValues(cmd, "stop-host", "true", "false")
+ command.SetCompletion(cmd, "instance-id", func() []string {
+ return getUPgSQLIDList(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone())
+ })
+
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/stop_creating_readonly.go b/products/pgsql/internal/pgsql/stop_creating_readonly.go
new file mode 100644
index 0000000000..c49b67dd53
--- /dev/null
+++ b/products/pgsql/internal/pgsql/stop_creating_readonly.go
@@ -0,0 +1,51 @@
+package pgsql
+
+import (
+ "github.com/spf13/cobra"
+
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newStopCreatingReadonly ucloud pgsql db stop-creating-readonly
+func newStopCreatingReadonly(ctx *cli.Context) *cobra.Command {
+ var idNames []string
+ client := newUPgSQLClient(ctx)
+ req := client.NewStopUPgSQLCreatingReadonlyRequest()
+ cmd := &cobra.Command{
+ Use: "stop-creating-readonly",
+ Short: "Stop readonly replicas that are still being created",
+ Long: "Stop readonly replicas of a UPgSQL instance that are still being created",
+ Run: func(c *cobra.Command, args []string) {
+ results := []cli.OpResultRow{}
+ for _, idname := range idNames {
+ id := ctx.PickResourceID(idname)
+ req.InstanceID = sdk.String(id)
+ _, err := client.StopUPgSQLCreatingReadonly(req)
+ if err != nil {
+ ctx.HandleError(err)
+ continue
+ }
+ results = append(results, cli.OpResultRow{ResourceID: id, Action: "stop-creating-readonly", Status: "Stopped"})
+ }
+ ctx.EmitResult(results...)
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ flags.StringSliceVar(&idNames, "instance-id", nil, "Required. Resource ID of the readonly replicas to stop creating")
+ ctx.BindProjectID(cmd, req)
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+
+ cmd.MarkFlagRequired("instance-id")
+ command.SetCompletion(cmd, "instance-id", func() []string {
+ return getUPgSQLIDList(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone())
+ })
+
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/supabase.go b/products/pgsql/internal/pgsql/supabase.go
new file mode 100644
index 0000000000..0974adeb5e
--- /dev/null
+++ b/products/pgsql/internal/pgsql/supabase.go
@@ -0,0 +1,39 @@
+package pgsql
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// newPgsqlSupabase ucloud pgsql supabase
+//
+// USupabase is a pgsql-attached enhancement product (a community Supabase stack
+// deployed onto a UPgSQL host). Its gateway actions are not in ucloud-sdk-go, so
+// they are invoked generically (see supabase_client.go). The MemoryDB (AI memory)
+// variant is the same backend toggled by --memory-db; create has its own action.
+func newPgsqlSupabase(ctx *cli.Context) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "supabase",
+ Short: "Manage USupabase instances (and the MemoryDB AI-memory variant)",
+ Long: "Manage USupabase instances (and the MemoryDB AI-memory variant)",
+ }
+ cmd.AddCommand(newSupabaseList(ctx))
+ cmd.AddCommand(newSupabaseDescribe(ctx))
+ cmd.AddCommand(newSupabaseCreate(ctx))
+ cmd.AddCommand(newSupabaseCreateMemoryDB(ctx))
+ cmd.AddCommand(newSupabaseDelete(ctx))
+ cmd.AddCommand(newSupabaseStart(ctx))
+ cmd.AddCommand(newSupabaseStop(ctx))
+ cmd.AddCommand(newSupabaseRestart(ctx))
+ cmd.AddCommand(newSupabaseResetPassword(ctx))
+ cmd.AddCommand(newSupabaseGetAPIKey(ctx))
+ cmd.AddCommand(newSupabaseGetStorageConfig(ctx))
+ cmd.AddCommand(newSupabaseSetStorageConfig(ctx))
+ cmd.AddCommand(newSupabaseEnableExternal(ctx))
+ cmd.AddCommand(newSupabaseDisableExternal(ctx))
+ cmd.AddCommand(newSupabaseModifyExternal(ctx))
+ cmd.AddCommand(newSupabaseExternalPrice(ctx))
+ cmd.AddCommand(newSupabaseBandwidthUpgradePrice(ctx))
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/supabase_bandwidth_upgrade_price.go b/products/pgsql/internal/pgsql/supabase_bandwidth_upgrade_price.go
new file mode 100644
index 0000000000..29be42dd2b
--- /dev/null
+++ b/products/pgsql/internal/pgsql/supabase_bandwidth_upgrade_price.go
@@ -0,0 +1,51 @@
+package pgsql
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// newSupabaseBandwidthUpgradePrice ucloud pgsql supabase bandwidth-upgrade-price
+func newSupabaseBandwidthUpgradePrice(ctx *cli.Context) *cobra.Command {
+ var instanceID string
+ var bandwidth int
+ var common *supabaseCommon
+ cmd := &cobra.Command{
+ Use: "bandwidth-upgrade-price",
+ Short: "Get the price of upgrading external-access bandwidth of a USupabase instance",
+ Long: "Get the price of upgrading external-access bandwidth of a USupabase instance",
+ Run: func(c *cobra.Command, args []string) {
+ params := common.params()
+ params["InstanceID"] = instanceID
+ params["Bandwidth"] = bandwidth
+ payload, err := invokeSupabase(ctx, "DescribeUSupabaseBandwithUpgradePrice", params)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ rows := []SupabaseChargeRow{}
+ if ds, ok := payload["DataSet"].([]interface{}); ok {
+ for _, item := range ds {
+ m, _ := item.(map[string]interface{})
+ rows = append(rows, SupabaseChargeRow{
+ ChargeType: getString(m, "ChargeType"),
+ Price: getInt(m, "Price"),
+ })
+ }
+ }
+ ctx.PrintList(rows)
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ common = bindSupabaseCommon(cmd, ctx)
+ flags.StringVar(&instanceID, "instance-id", "", "Required. Resource ID of the USupabase instance")
+ flags.IntVar(&bandwidth, "bandwidth", 0, "Required. Target bandwidth (Mbps)")
+ cmd.MarkFlagRequired("instance-id")
+ cmd.MarkFlagRequired("bandwidth")
+
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/supabase_client.go b/products/pgsql/internal/pgsql/supabase_client.go
new file mode 100644
index 0000000000..2a876627e9
--- /dev/null
+++ b/products/pgsql/internal/pgsql/supabase_client.go
@@ -0,0 +1,58 @@
+package pgsql
+
+import (
+ "fmt"
+
+ "github.com/ucloud/ucloud-sdk-go/services/uaccount"
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+ "github.com/ucloud/ucloud-sdk-go/ucloud/request"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// newUSupabaseClient returns an authed generic SDK client whose requests are
+// JSON-encoded, reusing the platform credential/handlers (so AK/SK and OAuth
+// profiles both sign correctly, and project-id normalization / OAuth retry
+// apply uniformly). ucloud-sdk-go has no typed USupabase methods, so supabase
+// actions are invoked generically: a simple map payload signed and POSTed as a
+// JSON body. JSON (not form) is mandatory — the USupabase gateway does
+// json.Unmarshal on the request and fails on string-into-int fields (same
+// 214001 bug as UPgSQL).
+func newUSupabaseClient(ctx *cli.Context) *uaccount.UAccountClient {
+ client := cli.NewServiceClient(ctx, uaccount.NewClient)
+ _ = client.AddRequestHandler(func(c *sdk.Client, req request.Common) (request.Common, error) {
+ req.SetEncoder(request.NewJSONEncoder(c.GetConfig(), c.GetCredential()))
+ return req, nil
+ })
+ return client
+}
+
+// invokeSupabase calls a USupabase action with a simple map payload and returns
+// the response payload map. Region/Zone/ProjectId/IsMemoryDB and business fields
+// are all part of params; the caller is responsible for putting them in. The
+// SDK signs the map (cred.Apply adds PublicKey + Signature) and the JSONEncoder
+// marshals it with native types. A non-zero RetCode is returned as an error by
+// GenericInvoke (RetCodePatcher), so callers forward it to ctx.HandleError.
+func invokeSupabase(ctx *cli.Context, action string, params map[string]interface{}) (map[string]interface{}, error) {
+ client := newUSupabaseClient(ctx)
+ req := client.NewGenericRequest()
+ payload := make(map[string]interface{}, len(params)+1)
+ payload["Action"] = action
+ for k, v := range params {
+ payload[k] = v
+ }
+ if err := req.SetPayload(payload); 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
+}
+
+// supabaseState is a typed view over a generic response's State field, used to
+// avoid repeated map[string]interface{} casts in command code.
+type supabaseState struct {
+ State string
+}
diff --git a/products/pgsql/internal/pgsql/supabase_create.go b/products/pgsql/internal/pgsql/supabase_create.go
new file mode 100644
index 0000000000..06b9bc900e
--- /dev/null
+++ b/products/pgsql/internal/pgsql/supabase_create.go
@@ -0,0 +1,126 @@
+package pgsql
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// supabaseCreateFlags holds the shared business flags for CreateUSupabase and
+// CreateUMemoryDB (the two requests have identical business fields).
+type supabaseCreateFlags struct {
+ common *supabaseCommon
+ instanceName, dashboardName, dashboardPassword, upgsqlUserName, dbVersion string
+ upgsqlPassword, machineType, subnetID, vpcID, instanceMode, chargeType string
+ usupabasePort, paramGroupID, upgsqlPort, diskSpace, quantity int
+ labels []string
+}
+
+func bindSupabaseCreate(cmd *cobra.Command, ctx *cli.Context) *supabaseCreateFlags {
+ f := &supabaseCreateFlags{}
+ f.common = bindSupabaseCommon(cmd, ctx)
+ flags := cmd.Flags()
+ flags.StringVar(&f.instanceName, "name", "", "Required. Supabase instance name")
+ flags.StringVar(&f.dashboardName, "dashboard-name", "", "Required. Dashboard user name")
+ flags.StringVar(&f.dashboardPassword, "dashboard-password", "", "Required. Dashboard password")
+ flags.IntVar(&f.usupabasePort, "supabase-port", 8000, "Optional. Supabase service port, default 8000")
+ flags.StringVar(&f.upgsqlUserName, "pgsql-user", "", "Required. UPgSQL user name")
+ flags.StringVar(&f.dbVersion, "db-version", "", "Required. UPgSQL version, e.g. postgresql-13.4")
+ flags.IntVar(&f.paramGroupID, "param-group-id", 0, "Required. UPgSQL param group ID")
+ flags.StringVar(&f.upgsqlPassword, "pgsql-password", "", "Required. UPgSQL password")
+ flags.IntVar(&f.upgsqlPort, "pgsql-port", 5432, "Optional. UPgSQL port, default 5432")
+ flags.IntVar(&f.diskSpace, "disk-size-gb", 0, "Required. Disk space (GiB)")
+ flags.StringVar(&f.machineType, "machine-type", "", "Required. Machine type, e.g. o.pgsql2m.medium. See 'ucloud pgsql db list-machine-type'")
+ flags.StringVar(&f.subnetID, "subnet-id", "", "Required. Subnet ID")
+ flags.StringVar(&f.vpcID, "vpc-id", "", "Required. VPC ID")
+ flags.StringVar(&f.instanceMode, "mode", "Normal", "Optional. Normal / HA")
+ flags.StringVar(&f.chargeType, "charge-type", "Month", "Optional. Year / Month / Dynamic")
+ flags.IntVar(&f.quantity, "quantity", 1, "Optional. Purchase duration")
+ flags.StringSliceVar(&f.labels, "label", nil, "Optional. Resource label key=value (repeatable)")
+ command.SetFlagValues(cmd, "db-version", pgsqlVersionList...)
+ command.SetFlagValues(cmd, "mode", "Normal", "HA")
+ command.SetFlagValues(cmd, "charge-type", "Year", "Month", "Dynamic")
+ return f
+}
+
+func (f *supabaseCreateFlags) params() map[string]interface{} {
+ p := f.common.params()
+ p["InstanceName"] = f.instanceName
+ p["DashboardName"] = f.dashboardName
+ p["DashboardPassword"] = f.dashboardPassword
+ p["USupabasePort"] = f.usupabasePort
+ p["UPgSQLUserName"] = f.upgsqlUserName
+ p["DBVersion"] = f.dbVersion
+ p["ParamGroupID"] = f.paramGroupID
+ p["UPgSQLPassword"] = f.upgsqlPassword
+ p["UPgSQLPort"] = f.upgsqlPort
+ p["DiskSpace"] = f.diskSpace
+ p["MachineType"] = f.machineType
+ p["SubnetID"] = f.subnetID
+ p["VPCID"] = f.vpcID
+ p["InstanceMode"] = f.instanceMode
+ p["ChargeType"] = f.chargeType
+ p["Quantity"] = f.quantity
+ labels := []map[string]interface{}{}
+ for _, l := range f.labels {
+ parts := strings.SplitN(l, "=", 2)
+ if len(parts) == 2 {
+ labels = append(labels, map[string]interface{}{"Key": parts[0], "Value": parts[1]})
+ }
+ }
+ p["Labels"] = labels
+ return p
+}
+
+// runSupabaseCreate is shared by create and create-memory-db: invoke the action,
+// poll the returned InstanceID to Running, emit the result.
+func runSupabaseCreate(ctx *cli.Context, action string, f *supabaseCreateFlags, async bool) {
+ params := f.params()
+ payload, err := invokeSupabase(ctx, action, params)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ instanceID := getString(payload, "InstanceID")
+ if instanceID == "" {
+ ctx.HandleError(fmt.Errorf("empty InstanceID in response"))
+ return
+ }
+ w := ctx.ProgressWriter()
+ if async {
+ fmt.Fprintf(w, "supabase[%s] is initializing\n", instanceID)
+ } else {
+ text := fmt.Sprintf("supabase[%s] is initializing", instanceID)
+ ctx.PollerTo(w, describeSupabaseByID(ctx, f.common.region, f.common.zone, f.common.projectID, f.common.memoryDB)).
+ Spoll(instanceID, text, []string{SUPABASE_RUNNING, SUPABASE_FAIL})
+ }
+ ctx.EmitResult(cli.OpResultRow{ResourceID: instanceID, Action: "create", Status: "Initializing"})
+}
+
+// newSupabaseCreate ucloud pgsql supabase create
+func newSupabaseCreate(ctx *cli.Context) *cobra.Command {
+ var async bool
+ var f *supabaseCreateFlags
+ cmd := &cobra.Command{
+ Use: "create",
+ Short: "Create a USupabase instance",
+ Long: "Create a USupabase instance (deploys a Supabase stack onto a UPgSQL host)",
+ Run: func(c *cobra.Command, args []string) {
+ runSupabaseCreate(ctx, "CreateUSupabase", f, async)
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+ f = bindSupabaseCreate(cmd, ctx)
+ flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the long-running operation to finish")
+
+ for _, req := range []string{"name", "dashboard-name", "dashboard-password", "pgsql-user", "db-version", "param-group-id", "pgsql-password", "disk-size-gb", "machine-type", "subnet-id", "vpc-id"} {
+ cmd.MarkFlagRequired(req)
+ }
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/supabase_create_memory_db.go b/products/pgsql/internal/pgsql/supabase_create_memory_db.go
new file mode 100644
index 0000000000..1c693782b1
--- /dev/null
+++ b/products/pgsql/internal/pgsql/supabase_create_memory_db.go
@@ -0,0 +1,35 @@
+package pgsql
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// newSupabaseCreateMemoryDB ucloud pgsql supabase create-memory-db
+//
+// The MemoryDB (AI memory) variant uses the same business fields as create but
+// the dedicated CreateUMemoryDB action (CreateUSupabaseRequest has no IsMemoryDB
+// field, so the two are distinguished by action name, not a flag).
+func newSupabaseCreateMemoryDB(ctx *cli.Context) *cobra.Command {
+ var async bool
+ var f *supabaseCreateFlags
+ cmd := &cobra.Command{
+ Use: "create-memory-db",
+ Short: "Create a UMemoryDB (AI memory) instance",
+ Long: "Create a UMemoryDB (AI memory) instance — the Supabase-based AI memory variant",
+ Run: func(c *cobra.Command, args []string) {
+ runSupabaseCreate(ctx, "CreateUMemoryDB", f, async)
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+ f = bindSupabaseCreate(cmd, ctx)
+ flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the long-running operation to finish")
+
+ for _, req := range []string{"name", "dashboard-name", "dashboard-password", "pgsql-user", "db-version", "param-group-id", "pgsql-password", "disk-size-gb", "machine-type", "subnet-id", "vpc-id"} {
+ cmd.MarkFlagRequired(req)
+ }
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/supabase_delete.go b/products/pgsql/internal/pgsql/supabase_delete.go
new file mode 100644
index 0000000000..cac85e3322
--- /dev/null
+++ b/products/pgsql/internal/pgsql/supabase_delete.go
@@ -0,0 +1,49 @@
+package pgsql
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// newSupabaseDelete ucloud pgsql supabase delete
+func newSupabaseDelete(ctx *cli.Context) *cobra.Command {
+ var instanceID string
+ var yes bool
+ var common *supabaseCommon
+ cmd := &cobra.Command{
+ Use: "delete",
+ Short: "Delete a USupabase instance",
+ Long: "Delete a USupabase instance",
+ Run: func(c *cobra.Command, args []string) {
+ ok, err := ctx.Confirm(yes, fmt.Sprintf("Are you sure you want to delete supabase[%s]?", instanceID))
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ if !ok {
+ return
+ }
+ params := common.params()
+ params["InstanceID"] = instanceID
+ if _, err := invokeSupabase(ctx, "DeleteUSupabase", params); err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ fmt.Fprintf(ctx.ProgressWriter(), "supabase[%s] deleted\n", instanceID)
+ ctx.EmitResult(cli.OpResultRow{ResourceID: instanceID, Action: "delete", Status: "Deleted"})
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ common = bindSupabaseCommon(cmd, ctx)
+ flags.StringVar(&instanceID, "instance-id", "", "Required. Resource ID of the USupabase instance")
+ flags.BoolVarP(&yes, "yes", "y", false, "Optional. Do not prompt for confirmation")
+ cmd.MarkFlagRequired("instance-id")
+
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/supabase_describe.go b/products/pgsql/internal/pgsql/supabase_describe.go
new file mode 100644
index 0000000000..85cc74cc0a
--- /dev/null
+++ b/products/pgsql/internal/pgsql/supabase_describe.go
@@ -0,0 +1,62 @@
+package pgsql
+
+import (
+ "fmt"
+ "strconv"
+
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// newSupabaseDescribe ucloud pgsql supabase describe
+func newSupabaseDescribe(ctx *cli.Context) *cobra.Command {
+ var instanceID string
+ var common *supabaseCommon
+ cmd := &cobra.Command{
+ Use: "describe",
+ Short: "Display details of a USupabase instance",
+ Long: "Display details of a USupabase instance",
+ Run: func(c *cobra.Command, args []string) {
+ params := common.params()
+ params["InstanceID"] = instanceID
+ payload, err := invokeSupabase(ctx, "DescribeUSupabase", params)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ ds, ok := payload["DataSet"].(map[string]interface{})
+ if !ok {
+ ctx.HandleError(fmt.Errorf("pgsql supabase[%s] may not exist", instanceID))
+ return
+ }
+ attrs := []cli.DescribeRow{
+ {Attribute: "InstanceID", Content: getString(ds, "InstanceID")},
+ {Attribute: "USupabaseName", Content: getString(ds, "USupabaseName")},
+ {Attribute: "State", Content: getString(ds, "State")},
+ {Attribute: "Zone", Content: getString(ds, "Zone")},
+ {Attribute: "UPgSQLID", Content: getString(ds, "UPgSQLID")},
+ {Attribute: "VPCID", Content: getString(ds, "VPCID")},
+ {Attribute: "SubnetID", Content: getString(ds, "SubnetID")},
+ {Attribute: "IntranetAddress", Content: getString(ds, "IntranetAddress")},
+ {Attribute: "Port", Content: strconv.Itoa(getInt(ds, "Port"))},
+ {Attribute: "ExternalNetworkStatus", Content: getString(ds, "ExternalNetworkStatus")},
+ {Attribute: "ExternalNetworkAddress", Content: getString(ds, "ExternalNetworkAddress")},
+ {Attribute: "ExternalNetworkPort", Content: strconv.Itoa(getInt(ds, "ExternalNetworkPort"))},
+ {Attribute: "Bandwidth", Content: strconv.Itoa(getInt(ds, "Bandwidth"))},
+ {Attribute: "WhiteList", Content: getString(ds, "WhiteList")},
+ }
+ fmt.Fprintln(ctx.ProgressWriter(), "Attributes:")
+ ctx.PrintList(attrs)
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ common = bindSupabaseCommon(cmd, ctx)
+ flags.StringVar(&instanceID, "instance-id", "", "Required. Resource ID of the USupabase instance")
+ cmd.MarkFlagRequired("instance-id")
+
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/supabase_disable_external.go b/products/pgsql/internal/pgsql/supabase_disable_external.go
new file mode 100644
index 0000000000..3e9426e45d
--- /dev/null
+++ b/products/pgsql/internal/pgsql/supabase_disable_external.go
@@ -0,0 +1,39 @@
+package pgsql
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// newSupabaseDisableExternal ucloud pgsql supabase disable-external-access
+func newSupabaseDisableExternal(ctx *cli.Context) *cobra.Command {
+ var instanceID string
+ var common *supabaseCommon
+ cmd := &cobra.Command{
+ Use: "disable-external-access",
+ Short: "Disable external access for a USupabase instance",
+ Long: "Disable external network access for a USupabase instance",
+ Run: func(c *cobra.Command, args []string) {
+ params := common.params()
+ params["InstanceID"] = instanceID
+ if _, err := invokeSupabase(ctx, "DisableUSupabaseExternalAccess", params); err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ fmt.Fprintf(ctx.ProgressWriter(), "supabase[%s] external access disabled\n", instanceID)
+ ctx.EmitResult(cli.OpResultRow{ResourceID: instanceID, Action: "disable-external-access", Status: "Disabled"})
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ common = bindSupabaseCommon(cmd, ctx)
+ flags.StringVar(&instanceID, "instance-id", "", "Required. Resource ID of the USupabase instance")
+ cmd.MarkFlagRequired("instance-id")
+
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/supabase_enable_external.go b/products/pgsql/internal/pgsql/supabase_enable_external.go
new file mode 100644
index 0000000000..2915d39206
--- /dev/null
+++ b/products/pgsql/internal/pgsql/supabase_enable_external.go
@@ -0,0 +1,43 @@
+package pgsql
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// newSupabaseEnableExternal ucloud pgsql supabase enable-external-access
+func newSupabaseEnableExternal(ctx *cli.Context) *cobra.Command {
+ var instanceID string
+ var bandwidth int
+ var common *supabaseCommon
+ cmd := &cobra.Command{
+ Use: "enable-external-access",
+ Short: "Enable external access for a USupabase instance",
+ Long: "Enable external network access for a USupabase instance",
+ Run: func(c *cobra.Command, args []string) {
+ params := common.params()
+ params["InstanceID"] = instanceID
+ params["Bandwidth"] = bandwidth
+ if _, err := invokeSupabase(ctx, "EnableUSupabaseExternalAccess", params); err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ fmt.Fprintf(ctx.ProgressWriter(), "supabase[%s] external access enabled\n", instanceID)
+ ctx.EmitResult(cli.OpResultRow{ResourceID: instanceID, Action: "enable-external-access", Status: "Enabled"})
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ common = bindSupabaseCommon(cmd, ctx)
+ flags.StringVar(&instanceID, "instance-id", "", "Required. Resource ID of the USupabase instance")
+ flags.IntVar(&bandwidth, "bandwidth", 0, "Required. Bandwidth (Mbps)")
+ cmd.MarkFlagRequired("instance-id")
+ cmd.MarkFlagRequired("bandwidth")
+
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/supabase_external_price.go b/products/pgsql/internal/pgsql/supabase_external_price.go
new file mode 100644
index 0000000000..8131b87bb2
--- /dev/null
+++ b/products/pgsql/internal/pgsql/supabase_external_price.go
@@ -0,0 +1,51 @@
+package pgsql
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// newSupabaseExternalPrice ucloud pgsql supabase external-price
+func newSupabaseExternalPrice(ctx *cli.Context) *cobra.Command {
+ var instanceID string
+ var bandwidth int
+ var common *supabaseCommon
+ cmd := &cobra.Command{
+ Use: "external-price",
+ Short: "Get the price of enabling external access for a USupabase instance",
+ Long: "Get the price of enabling external access for a USupabase instance",
+ Run: func(c *cobra.Command, args []string) {
+ params := common.params()
+ params["InstanceID"] = instanceID
+ params["BandWidth"] = bandwidth
+ payload, err := invokeSupabase(ctx, "DescribeUSupabaseExternalPrice", params)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ rows := []SupabaseChargeRow{}
+ if ds, ok := payload["DataSet"].([]interface{}); ok {
+ for _, item := range ds {
+ m, _ := item.(map[string]interface{})
+ rows = append(rows, SupabaseChargeRow{
+ ChargeType: getString(m, "ChargeType"),
+ Price: getInt(m, "Price"),
+ })
+ }
+ }
+ ctx.PrintList(rows)
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ common = bindSupabaseCommon(cmd, ctx)
+ flags.StringVar(&instanceID, "instance-id", "", "Required. Resource ID of the USupabase instance")
+ flags.IntVar(&bandwidth, "bandwidth", 0, "Required. Bandwidth (Mbps)")
+ cmd.MarkFlagRequired("instance-id")
+ cmd.MarkFlagRequired("bandwidth")
+
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/supabase_get_api_key.go b/products/pgsql/internal/pgsql/supabase_get_api_key.go
new file mode 100644
index 0000000000..2f1347bbbf
--- /dev/null
+++ b/products/pgsql/internal/pgsql/supabase_get_api_key.go
@@ -0,0 +1,41 @@
+package pgsql
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// newSupabaseGetAPIKey ucloud pgsql supabase get-api-key
+func newSupabaseGetAPIKey(ctx *cli.Context) *cobra.Command {
+ var instanceID string
+ var common *supabaseCommon
+ cmd := &cobra.Command{
+ Use: "get-api-key",
+ Short: "Display the API keys (service key + anon key) of a USupabase instance",
+ Long: "Display the API keys (service key + anon key) of a USupabase instance",
+ Run: func(c *cobra.Command, args []string) {
+ params := common.params()
+ params["InstanceID"] = instanceID
+ payload, err := invokeSupabase(ctx, "GetUSupabaseAPIKey", params)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ key, _ := payload["Key"].(map[string]interface{})
+ ctx.PrintList([]SupabaseAPIKeyRow{{
+ ServiceKey: getString(key, "ServiceKey"),
+ AnonKey: getString(key, "AnonKey"),
+ }})
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ common = bindSupabaseCommon(cmd, ctx)
+ flags.StringVar(&instanceID, "instance-id", "", "Required. Resource ID of the USupabase instance")
+ cmd.MarkFlagRequired("instance-id")
+
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/supabase_get_storage_config.go b/products/pgsql/internal/pgsql/supabase_get_storage_config.go
new file mode 100644
index 0000000000..566549b7dc
--- /dev/null
+++ b/products/pgsql/internal/pgsql/supabase_get_storage_config.go
@@ -0,0 +1,49 @@
+package pgsql
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// newSupabaseGetStorageConfig ucloud pgsql supabase get-storage-config
+func newSupabaseGetStorageConfig(ctx *cli.Context) *cobra.Command {
+ var instanceID string
+ var common *supabaseCommon
+ cmd := &cobra.Command{
+ Use: "get-storage-config",
+ Short: "Display the storage configuration of a USupabase instance",
+ Long: "Display the storage configuration of a USupabase instance",
+ Run: func(c *cobra.Command, args []string) {
+ params := common.params()
+ params["InstanceID"] = instanceID
+ payload, err := invokeSupabase(ctx, "GetUSupabaseStorageConfig", params)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ rows := []SupabaseStorageConfigRow{}
+ if ds, ok := payload["DataSet"].([]interface{}); ok {
+ for _, item := range ds {
+ m, _ := item.(map[string]interface{})
+ rows = append(rows, SupabaseStorageConfigRow{
+ Key: getString(m, "Key"),
+ Value: getString(m, "Value"),
+ Description: getString(m, "Description"),
+ Required: getBool(m, "Required"),
+ })
+ }
+ }
+ ctx.PrintList(rows)
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ common = bindSupabaseCommon(cmd, ctx)
+ flags.StringVar(&instanceID, "instance-id", "", "Required. Resource ID of the USupabase instance")
+ cmd.MarkFlagRequired("instance-id")
+
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/supabase_list.go b/products/pgsql/internal/pgsql/supabase_list.go
new file mode 100644
index 0000000000..9a77036b1b
--- /dev/null
+++ b/products/pgsql/internal/pgsql/supabase_list.go
@@ -0,0 +1,133 @@
+package pgsql
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// supabaseCommon holds the region/zone/project-id/memory-db flags shared by
+// every supabase command. Bound via bindSupabaseCommon so flag order stays
+// consistent across verbs (the completion golden depends on it).
+type supabaseCommon struct {
+ region string
+ zone string
+ projectID string
+ memoryDB bool
+}
+
+// bindSupabaseCommon registers --region/--zone/--project-id/--memory-db with
+// ctx defaults + completion. ProjectId is ALWAYS bound: USupabase IAM checks
+// require the ProjectId context, so every action carries it.
+func bindSupabaseCommon(cmd *cobra.Command, ctx *cli.Context) *supabaseCommon {
+ c := &supabaseCommon{}
+ flags := cmd.Flags()
+ flags.StringVar(&c.region, "region", ctx.DefaultRegion(), "Optional. Override default region, see 'ucloud region'")
+ flags.StringVar(&c.zone, "zone", ctx.DefaultZone(), "Optional. Override default zone, see 'ucloud region'")
+ flags.StringVar(&c.projectID, "project-id", ctx.DefaultProjectID(), "Optional. Override default project-id, see 'ucloud project list'")
+ flags.BoolVar(&c.memoryDB, "memory-db", false, "Optional. Operate on the MemoryDB (AI memory) variant")
+ command.SetCompletion(cmd, "region", func() []string { return ctx.RegionList() })
+ command.SetCompletion(cmd, "zone", func() []string { return ctx.ZoneList(c.region) })
+ command.SetCompletion(cmd, "project-id", func() []string { return ctx.ProjectList() })
+ return c
+}
+
+// params builds the map payload common to every supabase action: Region/Zone/
+// ProjectId/IsMemoryDB. ProjectId is mandatory (IAM context). Business fields
+// are added by the caller.
+func (c *supabaseCommon) params() map[string]interface{} {
+ p := map[string]interface{}{
+ "Region": c.region,
+ "Zone": c.zone,
+ "ProjectId": c.projectID,
+ }
+ if c.memoryDB {
+ p["IsMemoryDB"] = true
+ }
+ return p
+}
+
+// newSupabaseList ucloud pgsql supabase list
+func newSupabaseList(ctx *cli.Context) *cobra.Command {
+ var instanceID string
+ var limit, offset int
+ var common *supabaseCommon
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List USupabase instances",
+ Long: "List USupabase instances (or MemoryDB instances with --memory-db)",
+ Run: func(c *cobra.Command, args []string) {
+ params := common.params()
+ if instanceID != "" {
+ params["InstanceID"] = instanceID
+ }
+ if limit > 0 {
+ params["Limit"] = limit
+ }
+ if offset > 0 {
+ params["Offset"] = offset
+ }
+ payload, err := invokeSupabase(ctx, "ListUSupabaseInstance", params)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ rows := []SupabaseInstanceRow{}
+ if ds, ok := payload["DataSet"].([]interface{}); ok {
+ for _, item := range ds {
+ m, ok := item.(map[string]interface{})
+ if !ok {
+ continue
+ }
+ rows = append(rows, SupabaseInstanceRow{
+ USupabaseName: getString(m, "USupabaseName"),
+ InstanceID: getString(m, "InstanceID"),
+ UPgSQLID: getString(m, "UPgSQLID"),
+ Zone: getString(m, "Zone"),
+ IntranetAddress: getString(m, "IntranetAddress"),
+ Port: getInt(m, "Port"),
+ State: getString(m, "State"),
+ })
+ }
+ }
+ ctx.PrintList(rows)
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ common = bindSupabaseCommon(cmd, ctx)
+ flags.StringVar(&instanceID, "instance-id", "", "Optional. List only the specified USupabase instance")
+ flags.IntVar(&limit, "limit", 0, "Optional. Max instances per page (0 = default)")
+ flags.IntVar(&offset, "offset", 0, "Optional. Offset")
+
+ return cmd
+}
+
+// getString / getInt are tiny helpers over a generic response map (JSON numbers
+// unmarshal as float64).
+func getString(m map[string]interface{}, key string) string {
+ if v, ok := m[key].(string); ok {
+ return v
+ }
+ return ""
+}
+
+func getInt(m map[string]interface{}, key string) int {
+ switch v := m[key].(type) {
+ case float64:
+ return int(v)
+ case int:
+ return v
+ }
+ return 0
+}
+
+func getBool(m map[string]interface{}, key string) bool {
+ if v, ok := m[key].(bool); ok {
+ return v
+ }
+ return false
+}
diff --git a/products/pgsql/internal/pgsql/supabase_modify_external.go b/products/pgsql/internal/pgsql/supabase_modify_external.go
new file mode 100644
index 0000000000..f9d2515def
--- /dev/null
+++ b/products/pgsql/internal/pgsql/supabase_modify_external.go
@@ -0,0 +1,52 @@
+package pgsql
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// newSupabaseModifyExternal ucloud pgsql supabase modify-external-access
+func newSupabaseModifyExternal(ctx *cli.Context) *cobra.Command {
+ var instanceID, whiteList string
+ var bandwidth, port int
+ var common *supabaseCommon
+ cmd := &cobra.Command{
+ Use: "modify-external-access",
+ Short: "Modify external-access settings of a USupabase instance",
+ Long: "Modify external-access bandwidth/port/whitelist of a USupabase instance",
+ Run: func(c *cobra.Command, args []string) {
+ params := common.params()
+ params["InstanceID"] = instanceID
+ if c.Flags().Changed("bandwidth") {
+ params["Bandwidth"] = bandwidth
+ }
+ if c.Flags().Changed("port") {
+ params["Port"] = port
+ }
+ if c.Flags().Changed("white-list") {
+ params["WhiteList"] = whiteList
+ }
+ if _, err := invokeSupabase(ctx, "ModifyUSupabaseExternalAccessInfo", params); err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ fmt.Fprintf(ctx.ProgressWriter(), "supabase[%s] external access modified\n", instanceID)
+ ctx.EmitResult(cli.OpResultRow{ResourceID: instanceID, Action: "modify-external-access", Status: "Modified"})
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ common = bindSupabaseCommon(cmd, ctx)
+ flags.StringVar(&instanceID, "instance-id", "", "Required. Resource ID of the USupabase instance")
+ flags.IntVar(&bandwidth, "bandwidth", 0, "Optional. New bandwidth (Mbps)")
+ flags.IntVar(&port, "port", 0, "Optional. New external port")
+ flags.StringVar(&whiteList, "white-list", "", "Optional. New IP whitelist")
+ cmd.MarkFlagRequired("instance-id")
+
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/supabase_poll.go b/products/pgsql/internal/pgsql/supabase_poll.go
new file mode 100644
index 0000000000..26df50bb4d
--- /dev/null
+++ b/products/pgsql/internal/pgsql/supabase_poll.go
@@ -0,0 +1,36 @@
+package pgsql
+
+import (
+ "fmt"
+
+ "github.com/ucloud/ucloud-sdk-go/ucloud/request"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// describeSupabaseByID returns the poller's describe func: DescribeUSupabase → State.
+// region/zone/projectID/memoryDB are captured from the originating command's flags so
+// the poll uses the same scope (USupabase IAM requires the ProjectId context).
+// Signature matches pkg/cli.Poller: func(string, *request.CommonBase) (interface{}, error).
+func describeSupabaseByID(ctx *cli.Context, region, zone, projectID string, memoryDB bool) func(instanceID string, _ *request.CommonBase) (interface{}, error) {
+ return func(instanceID string, _ *request.CommonBase) (interface{}, error) {
+ params := map[string]interface{}{
+ "Region": region,
+ "Zone": zone,
+ "ProjectId": projectID,
+ "InstanceID": instanceID,
+ }
+ if memoryDB {
+ params["IsMemoryDB"] = true
+ }
+ payload, err := invokeSupabase(ctx, "DescribeUSupabase", params)
+ if err != nil {
+ return nil, err
+ }
+ ds, ok := payload["DataSet"].(map[string]interface{})
+ if !ok {
+ return nil, fmt.Errorf("pgsql supabase[%s] may not exist", instanceID)
+ }
+ return &supabaseState{State: getString(ds, "State")}, nil
+ }
+}
diff --git a/products/pgsql/internal/pgsql/supabase_reset_password.go b/products/pgsql/internal/pgsql/supabase_reset_password.go
new file mode 100644
index 0000000000..df4322b114
--- /dev/null
+++ b/products/pgsql/internal/pgsql/supabase_reset_password.go
@@ -0,0 +1,42 @@
+package pgsql
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// newSupabaseResetPassword ucloud pgsql supabase reset-password
+func newSupabaseResetPassword(ctx *cli.Context) *cobra.Command {
+ var instanceID, password string
+ var common *supabaseCommon
+ cmd := &cobra.Command{
+ Use: "reset-password",
+ Short: "Reset the dashboard password of a USupabase instance",
+ Long: "Reset the dashboard password of a USupabase instance",
+ Run: func(c *cobra.Command, args []string) {
+ params := common.params()
+ params["InstanceID"] = instanceID
+ params["Password"] = password
+ if _, err := invokeSupabase(ctx, "ResetUSupabasePassword", params); err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ fmt.Fprintf(ctx.ProgressWriter(), "supabase[%s] password reset\n", instanceID)
+ ctx.EmitResult(cli.OpResultRow{ResourceID: instanceID, Action: "reset-password", Status: "PasswordReset"})
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ common = bindSupabaseCommon(cmd, ctx)
+ flags.StringVar(&instanceID, "instance-id", "", "Required. Resource ID of the USupabase instance")
+ flags.StringVar(&password, "password", "", "Required. New dashboard password")
+ cmd.MarkFlagRequired("instance-id")
+ cmd.MarkFlagRequired("password")
+
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/supabase_restart.go b/products/pgsql/internal/pgsql/supabase_restart.go
new file mode 100644
index 0000000000..7eb9038538
--- /dev/null
+++ b/products/pgsql/internal/pgsql/supabase_restart.go
@@ -0,0 +1,48 @@
+package pgsql
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// newSupabaseRestart ucloud pgsql supabase restart
+func newSupabaseRestart(ctx *cli.Context) *cobra.Command {
+ var instanceID string
+ var async bool
+ var common *supabaseCommon
+ cmd := &cobra.Command{
+ Use: "restart",
+ Short: "Restart a USupabase instance",
+ Long: "Restart a USupabase instance",
+ Run: func(c *cobra.Command, args []string) {
+ params := common.params()
+ params["InstanceID"] = instanceID
+ if _, err := invokeSupabase(ctx, "RestartUSupabase", params); err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ w := ctx.ProgressWriter()
+ if async {
+ fmt.Fprintf(w, "supabase[%s] is restarting\n", instanceID)
+ } else {
+ text := fmt.Sprintf("supabase[%s] is restarting", instanceID)
+ ctx.PollerTo(w, describeSupabaseByID(ctx, common.region, common.zone, common.projectID, common.memoryDB)).
+ Spoll(instanceID, text, []string{SUPABASE_RUNNING, SUPABASE_FAIL})
+ }
+ ctx.EmitResult(cli.OpResultRow{ResourceID: instanceID, Action: "restart", Status: "Restarting"})
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ common = bindSupabaseCommon(cmd, ctx)
+ flags.StringVar(&instanceID, "instance-id", "", "Required. Resource ID of the USupabase instance")
+ flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the long-running operation to finish")
+ cmd.MarkFlagRequired("instance-id")
+
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/supabase_set_storage_config.go b/products/pgsql/internal/pgsql/supabase_set_storage_config.go
new file mode 100644
index 0000000000..e62258a213
--- /dev/null
+++ b/products/pgsql/internal/pgsql/supabase_set_storage_config.go
@@ -0,0 +1,56 @@
+package pgsql
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// newSupabaseSetStorageConfig ucloud pgsql supabase set-storage-config
+func newSupabaseSetStorageConfig(ctx *cli.Context) *cobra.Command {
+ var instanceID string
+ var configs []string
+ var common *supabaseCommon
+ cmd := &cobra.Command{
+ Use: "set-storage-config",
+ Short: "Set storage configuration entries of a USupabase instance",
+ Long: "Set storage configuration entries of a USupabase instance (use --config key=value, repeatable)",
+ Run: func(c *cobra.Command, args []string) {
+ entries := []map[string]interface{}{}
+ for _, kv := range configs {
+ parts := strings.SplitN(kv, "=", 2)
+ if len(parts) != 2 {
+ ctx.HandleError(fmt.Errorf("invalid --config %q, expected key=value", kv))
+ return
+ }
+ entries = append(entries, map[string]interface{}{
+ "Key": parts[0],
+ "Value": parts[1],
+ })
+ }
+ params := common.params()
+ params["InstanceID"] = instanceID
+ params["ConfigEntry"] = entries
+ if _, err := invokeSupabase(ctx, "SetUSupabaseStorageConfig", params); err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ fmt.Fprintf(ctx.ProgressWriter(), "supabase[%s] storage config set\n", instanceID)
+ ctx.EmitResult(cli.OpResultRow{ResourceID: instanceID, Action: "set-storage-config", Status: "Set"})
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ common = bindSupabaseCommon(cmd, ctx)
+ flags.StringVar(&instanceID, "instance-id", "", "Required. Resource ID of the USupabase instance")
+ flags.StringSliceVar(&configs, "config", nil, "Required. Storage config entry, format key=value (repeatable)")
+ cmd.MarkFlagRequired("instance-id")
+ cmd.MarkFlagRequired("config")
+
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/supabase_start.go b/products/pgsql/internal/pgsql/supabase_start.go
new file mode 100644
index 0000000000..78ed02c65f
--- /dev/null
+++ b/products/pgsql/internal/pgsql/supabase_start.go
@@ -0,0 +1,48 @@
+package pgsql
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// newSupabaseStart ucloud pgsql supabase start
+func newSupabaseStart(ctx *cli.Context) *cobra.Command {
+ var instanceID string
+ var async bool
+ var common *supabaseCommon
+ cmd := &cobra.Command{
+ Use: "start",
+ Short: "Start a USupabase instance",
+ Long: "Start a USupabase instance",
+ Run: func(c *cobra.Command, args []string) {
+ params := common.params()
+ params["InstanceID"] = instanceID
+ if _, err := invokeSupabase(ctx, "StartUSupabase", params); err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ w := ctx.ProgressWriter()
+ if async {
+ fmt.Fprintf(w, "supabase[%s] is starting\n", instanceID)
+ } else {
+ text := fmt.Sprintf("supabase[%s] is starting", instanceID)
+ ctx.PollerTo(w, describeSupabaseByID(ctx, common.region, common.zone, common.projectID, common.memoryDB)).
+ Spoll(instanceID, text, []string{SUPABASE_RUNNING, SUPABASE_FAIL})
+ }
+ ctx.EmitResult(cli.OpResultRow{ResourceID: instanceID, Action: "start", Status: "Starting"})
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ common = bindSupabaseCommon(cmd, ctx)
+ flags.StringVar(&instanceID, "instance-id", "", "Required. Resource ID of the USupabase instance")
+ flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the long-running operation to finish")
+ cmd.MarkFlagRequired("instance-id")
+
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/supabase_stop.go b/products/pgsql/internal/pgsql/supabase_stop.go
new file mode 100644
index 0000000000..1ecd55c809
--- /dev/null
+++ b/products/pgsql/internal/pgsql/supabase_stop.go
@@ -0,0 +1,48 @@
+package pgsql
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// newSupabaseStop ucloud pgsql supabase stop
+func newSupabaseStop(ctx *cli.Context) *cobra.Command {
+ var instanceID string
+ var async bool
+ var common *supabaseCommon
+ cmd := &cobra.Command{
+ Use: "stop",
+ Short: "Stop a USupabase instance",
+ Long: "Stop a USupabase instance",
+ Run: func(c *cobra.Command, args []string) {
+ params := common.params()
+ params["InstanceID"] = instanceID
+ if _, err := invokeSupabase(ctx, "StopUSupabase", params); err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ w := ctx.ProgressWriter()
+ if async {
+ fmt.Fprintf(w, "supabase[%s] is stopping\n", instanceID)
+ } else {
+ text := fmt.Sprintf("supabase[%s] is stopping", instanceID)
+ ctx.PollerTo(w, describeSupabaseByID(ctx, common.region, common.zone, common.projectID, common.memoryDB)).
+ Spoll(instanceID, text, []string{SUPABASE_STOPPED, SUPABASE_FAIL})
+ }
+ ctx.EmitResult(cli.OpResultRow{ResourceID: instanceID, Action: "stop", Status: "Stopping"})
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ common = bindSupabaseCommon(cmd, ctx)
+ flags.StringVar(&instanceID, "instance-id", "", "Required. Resource ID of the USupabase instance")
+ flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the long-running operation to finish")
+ cmd.MarkFlagRequired("instance-id")
+
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/update_name.go b/products/pgsql/internal/pgsql/update_name.go
new file mode 100644
index 0000000000..f353d6aa9c
--- /dev/null
+++ b/products/pgsql/internal/pgsql/update_name.go
@@ -0,0 +1,45 @@
+package pgsql
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newUpdateName ucloud pgsql db update-name
+func newUpdateName(ctx *cli.Context) *cobra.Command {
+ client := newUPgSQLClient(ctx)
+ req := client.NewUpdateUPgSQLAttributeRequest()
+ cmd := &cobra.Command{
+ Use: "update-name",
+ Short: "Update the name of a UPgSQL instance",
+ Long: "Update the name of a UPgSQL instance",
+ Run: func(c *cobra.Command, args []string) {
+ *req.InstanceID = ctx.PickResourceID(*req.InstanceID)
+ _, err := client.UpdateUPgSQLAttribute(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ ctx.EmitResult(cli.OpResultRow{ResourceID: *req.InstanceID, Action: "update-name", Status: "Updated"})
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ req.InstanceID = flags.String("instance-id", "", "Required. Resource ID of the UPgSQL instance")
+ req.Name = flags.String("name", "", "Required. New instance name")
+ ctx.BindProjectID(cmd, req)
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+
+ cmd.MarkFlagRequired("instance-id")
+ cmd.MarkFlagRequired("name")
+ command.SetCompletion(cmd, "instance-id", func() []string {
+ return getUPgSQLIDList(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone())
+ })
+
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/update_remark.go b/products/pgsql/internal/pgsql/update_remark.go
new file mode 100644
index 0000000000..46585d0bf4
--- /dev/null
+++ b/products/pgsql/internal/pgsql/update_remark.go
@@ -0,0 +1,64 @@
+package pgsql
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-sdk-go/services/upgsql"
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newUpdateRemark ucloud pgsql db update-remark
+func newUpdateRemark(ctx *cli.Context) *cobra.Command {
+ client := newUPgSQLClient(ctx)
+ req := client.NewUpdateUPgSQLAttributeRequest()
+ cmd := &cobra.Command{
+ Use: "update-remark",
+ Short: "Update the remark of a UPgSQL instance",
+ Long: "Update the remark of a UPgSQL instance",
+ Run: func(c *cobra.Command, args []string) {
+ *req.InstanceID = ctx.PickResourceID(*req.InstanceID)
+ // UpdateUPgSQLAttribute requires Name to be present even when only the
+ // remark changes — omitting it returns RetCode 230 "Params [Name] not
+ // available". Fetch the current name and re-send it alongside Remark.
+ any, err := describePgsqlByID(ctx)(*req.InstanceID, nil)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ ins, ok := any.(*upgsql.UDBInstance)
+ if !ok {
+ ctx.HandleError(fmt.Errorf("fetch pgsql[%s] instance", *req.InstanceID))
+ return
+ }
+ req.Name = sdk.String(ins.Name)
+ _, err = client.UpdateUPgSQLAttribute(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ ctx.EmitResult(cli.OpResultRow{ResourceID: *req.InstanceID, Action: "update-remark", Status: "Updated"})
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ req.InstanceID = flags.String("instance-id", "", "Required. Resource ID of the UPgSQL instance")
+ req.Remark = flags.String("remark", "", "Required. New remark")
+ ctx.BindProjectID(cmd, req)
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+
+ cmd.MarkFlagRequired("instance-id")
+ cmd.MarkFlagRequired("remark")
+ command.SetCompletion(cmd, "instance-id", func() []string {
+ return getUPgSQLIDList(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone())
+ })
+
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/upgrade.go b/products/pgsql/internal/pgsql/upgrade.go
new file mode 100644
index 0000000000..836f08ffec
--- /dev/null
+++ b/products/pgsql/internal/pgsql/upgrade.go
@@ -0,0 +1,80 @@
+package pgsql
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newUpgrade ucloud pgsql db upgrade
+func newUpgrade(ctx *cli.Context) *cobra.Command {
+ var async bool
+ var idNames []string
+ var machineType string
+ var diskSpace int
+ client := newUPgSQLClient(ctx)
+ req := client.NewUpgradeUPgSQLInstanceRequest()
+ cmd := &cobra.Command{
+ Use: "upgrade",
+ Short: "Upgrade disk space and/or machine type of UPgSQL instances",
+ Long: "Upgrade disk space and/or machine type of UPgSQL instances",
+ Run: func(c *cobra.Command, args []string) {
+ if machineType == "" && diskSpace == 0 {
+ ctx.HandleError(fmt.Errorf("at least one of --machine-type or --disk-size-gb is required"))
+ return
+ }
+ w := ctx.ProgressWriter()
+ results := []cli.OpResultRow{}
+ for _, idname := range idNames {
+ id := ctx.PickResourceID(idname)
+ req.InstanceID = sdk.String(id)
+ if machineType != "" {
+ req.MachineType = sdk.String(machineType)
+ }
+ if diskSpace != 0 {
+ req.DiskSpace = sdk.Int(diskSpace)
+ }
+ _, err := client.UpgradeUPgSQLInstance(req)
+ if err != nil {
+ ctx.HandleError(err)
+ continue
+ }
+ if async {
+ fmt.Fprintf(w, "pgsql[%s] is upgrading\n", idname)
+ } else {
+ text := fmt.Sprintf("pgsql[%s] is upgrading", idname)
+ ctx.PollerTo(w, describePgsqlByID(ctx)).Spoll(id, text, []string{PGSQL_RUNNING, PGSQL_INIT_FAILED, PGSQL_START_FAILED})
+ }
+ results = append(results, cli.OpResultRow{ResourceID: id, Action: "upgrade", Status: "Upgrading"})
+ }
+ ctx.EmitResult(results...)
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ flags.StringSliceVar(&idNames, "instance-id", nil, "Required. Resource ID of UPgSQL instances to upgrade")
+ ctx.BindProjectID(cmd, req)
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+ flags.StringVar(&machineType, "machine-type", "", "Optional. New machine type ID. See 'ucloud pgsql db list-machine-type'")
+ flags.IntVar(&diskSpace, "disk-size-gb", 0, "Optional. New disk size (GiB)")
+ flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the long-running operation to finish")
+
+ cmd.MarkFlagRequired("instance-id")
+
+ command.SetCompletion(cmd, "instance-id", func() []string {
+ return getUPgSQLIDList(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone())
+ })
+ command.SetCompletion(cmd, "machine-type", func() []string {
+ return listMachineTypeIDNames(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone())
+ })
+
+ return cmd
+}
diff --git a/products/pgsql/internal/pgsql/upgrade_price.go b/products/pgsql/internal/pgsql/upgrade_price.go
new file mode 100644
index 0000000000..daf5b38d9e
--- /dev/null
+++ b/products/pgsql/internal/pgsql/upgrade_price.go
@@ -0,0 +1,56 @@
+package pgsql
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newUpgradePrice ucloud pgsql db upgrade-price
+func newUpgradePrice(ctx *cli.Context) *cobra.Command {
+ client := newUPgSQLClient(ctx)
+ req := client.NewGetUPgSQLUpgradePriceRequest()
+ cmd := &cobra.Command{
+ Use: "upgrade-price",
+ Short: "Get the price of upgrading a UPgSQL instance",
+ Long: "Get the price of upgrading disk space and/or machine type of a UPgSQL instance",
+ Run: func(c *cobra.Command, args []string) {
+ *req.InstanceID = ctx.PickResourceID(*req.InstanceID)
+ resp, err := client.GetUPgSQLUpgradePrice(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ ctx.PrintList([]PgsqlPriceRow{{
+ Price: resp.Price,
+ OriginalPrice: resp.OriginalPrice,
+ }})
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ req.InstanceID = flags.String("instance-id", "", "Required. Resource ID of the UPgSQL instance")
+ req.MachineType = flags.String("machine-type", "", "Required. New machine type ID. See 'ucloud pgsql db list-machine-type'")
+ req.DiskSpace = flags.Int("disk-size-gb", 0, "Required. New disk space (GiB)")
+ req.InstanceMode = flags.String("mode", "Normal", "Optional. Normal / HA")
+ ctx.BindProjectID(cmd, req)
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+
+ command.SetFlagValues(cmd, "mode", "Normal", "HA")
+ command.SetCompletion(cmd, "instance-id", func() []string {
+ return getUPgSQLIDList(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone())
+ })
+ command.SetCompletion(cmd, "machine-type", func() []string {
+ return listMachineTypeIDNames(ctx, req.GetProjectId(), req.GetRegion(), req.GetZone())
+ })
+
+ cmd.MarkFlagRequired("instance-id")
+ cmd.MarkFlagRequired("machine-type")
+ cmd.MarkFlagRequired("disk-size-gb")
+
+ return cmd
+}
diff --git a/products/pgsql/product.go b/products/pgsql/product.go
new file mode 100644
index 0000000000..faf8234a1d
--- /dev/null
+++ b/products/pgsql/product.go
@@ -0,0 +1,20 @@
+package pgsql
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ internalpgsql "github.com/ucloud/ucloud-cli/products/pgsql/internal/pgsql"
+)
+
+type product struct{}
+
+func New() cli.Product { return product{} }
+
+func (product) Metadata() cli.Metadata {
+ return cli.Metadata{Name: "pgsql", Commands: []string{"pgsql"}}
+}
+
+func (product) NewCommand(ctx *cli.Context) []*cobra.Command {
+ return []*cobra.Command{internalpgsql.NewCommand(ctx)}
+}
diff --git a/products/pgsql/product.yaml b/products/pgsql/product.yaml
new file mode 100644
index 0000000000..0b3459c914
--- /dev/null
+++ b/products/pgsql/product.yaml
@@ -0,0 +1,6 @@
+name: pgsql
+owners:
+ - jemy-wang
+commands:
+ - pgsql
+enabled: true
diff --git a/products/pgsql/testdata/cmdtree.golden b/products/pgsql/testdata/cmdtree.golden
new file mode 100644
index 0000000000..c99152d73c
--- /dev/null
+++ b/products/pgsql/testdata/cmdtree.golden
@@ -0,0 +1,358 @@
+ucloud pgsql use=pgsql short=Manipulate UPgSQL on UCloud platform
+ucloud pgsql backup use=backup short=List and manipulate backups of UPgSQL instances
+ucloud pgsql backup download use=download short=Display download URLs of a UPgSQL backup
+ flag=backup-id short= default= required=true
+ flag=instance-id short= default= required=true
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=zone short= default= required=
+ucloud pgsql backup list use=list short=List backups of a UPgSQL instance
+ flag=backup-type short= default= required=
+ flag=instance-id short= default= required=true
+ flag=limit short= default=100 required=
+ flag=offset short= default=0 required=
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=zone short= default= required=
+ucloud pgsql backup strategy use=strategy short=Display the backup strategy of a UPgSQL instance
+ flag=instance-id short= default= required=true
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=zone short= default= required=
+ucloud pgsql backup update-strategy use=update-strategy short=Update the backup strategy of a UPgSQL instance
+ flag=backup-method short= default= required=
+ flag=backup-time-range short= default= required=
+ flag=backup-week short= default= required=
+ flag=instance-id short= default= required=true
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=zone short= default= required=
+ucloud pgsql conf use=conf short=List and manipulate parameter templates of UPgSQL instances
+ucloud pgsql conf create use=create short=Create a UPgSQL parameter template from a base template
+ flag=db-version short= default= required=true
+ flag=description short= default= required=
+ flag=name short= default= required=true
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=src-conf-id short= default= required=true
+ flag=zone short= default= required=
+ucloud pgsql conf delete use=delete short=Delete a UPgSQL parameter template
+ flag=conf-id short= default= required=true
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=zone short= default= required=
+ucloud pgsql conf describe use=describe short=Display details of a UPgSQL parameter template
+ flag=conf-id short= default= required=true
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=zone short= default= required=
+ucloud pgsql conf download use=download short=Download a UPgSQL parameter template (base64 content)
+ flag=conf-id short= default= required=true
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=zone short= default= required=
+ucloud pgsql conf list use=list short=List UPgSQL parameter templates
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=zone short= default= required=
+ucloud pgsql conf upload use=upload short=Create a UPgSQL parameter template by uploading a local config file
+ flag=conf-file short= default= required=true
+ flag=db-version short= default= required=true
+ flag=description short= default= required=
+ flag=name short= default= required=true
+ flag=param-group-type short= default= required=
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=zone short= default= required=
+ucloud pgsql db use=db short=Manage UPgSQL instances
+ucloud pgsql db create use=create short=Create a UPgSQL instance
+ flag=async short=a default=false required=
+ flag=disk-size-gb short= default=100 required=
+ flag=machine-type short= default= required=true
+ flag=mode short= default=Normal required=
+ flag=name short= default= required=true
+ flag=param-group-id short= default=0 required=
+ flag=password short= default= required=true
+ flag=port short= default=5432 required=
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=subnet-id short= default= required=true
+ flag=version short= default= required=true
+ flag=vpc-id short= default= required=true
+ flag=zone short= default= required=
+ucloud pgsql db create-readonly use=create-readonly short=Create a readonly replica for a UPgSQL instance
+ flag=async short=a default=false required=
+ flag=disk-size-gb short= default=0 required=true
+ flag=machine-type short= default= required=true
+ flag=name short= default= required=true
+ flag=port short= default=5432 required=
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=src-instance-id short= default= required=true
+ flag=subnet-id short= default= required=
+ flag=vpc-id short= default= required=
+ flag=zone short= default= required=
+ucloud pgsql db delete use=delete short=Delete UPgSQL instances by instance-id
+ flag=instance-id short= default=[] required=true
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=yes short=y default=false required=
+ flag=zone short= default= required=
+ucloud pgsql db get use=get short=Display details of a UPgSQL instance
+ flag=instance-id short= default= required=true
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=zone short= default= required=
+ucloud pgsql db list use=list short=List UPgSQL instances
+ flag=instance-id short= default= required=
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=zone short= default= required=
+ucloud pgsql db list-machine-type use=list-machine-type short=List available UPgSQL machine types
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=zone short= default= required=
+ucloud pgsql db list-version use=list-version short=List available UPgSQL versions
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=zone short= default= required=
+ucloud pgsql db price use=price short=Get the price of creating UPgSQL instances
+ flag=charge-type short= default=Month required=
+ flag=disk-size-gb short= default=0 required=true
+ flag=machine-type short= default= required=true
+ flag=mode short= default=Normal required=
+ flag=project-id short= default= required=
+ flag=quantity short= default=1 required=
+ flag=region short= default= required=
+ flag=zone short= default= required=
+ucloud pgsql db reset-password use=reset-password short=Reset the admin password of UPgSQL instances
+ flag=instance-id short= default=[] required=true
+ flag=name short= default= required=
+ flag=password short= default= required=true
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=zone short= default= required=
+ucloud pgsql db restart use=restart short=Restart UPgSQL instances by instance-id
+ flag=async short=a default=false required=
+ flag=force short= default=false required=
+ flag=instance-id short= default=[] required=true
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=restart-host short= default=false required=
+ flag=zone short= default= required=
+ucloud pgsql db start use=start short=Start UPgSQL instances by instance-id
+ flag=async short=a default=false required=
+ flag=instance-id short= default=[] required=true
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=zone short= default= required=
+ucloud pgsql db stop use=stop short=Stop UPgSQL instances by instance-id
+ flag=async short=a default=false required=
+ flag=force short= default=false required=
+ flag=instance-id short= default=[] required=true
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=stop-host short= default=false required=
+ flag=zone short= default= required=
+ucloud pgsql db stop-creating-readonly use=stop-creating-readonly short=Stop readonly replicas that are still being created
+ flag=instance-id short= default=[] required=true
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=zone short= default= required=
+ucloud pgsql db update-name use=update-name short=Update the name of a UPgSQL instance
+ flag=instance-id short= default= required=true
+ flag=name short= default= required=true
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=zone short= default= required=
+ucloud pgsql db update-remark use=update-remark short=Update the remark of a UPgSQL instance
+ flag=instance-id short= default= required=true
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=remark short= default= required=true
+ flag=zone short= default= required=
+ucloud pgsql db upgrade use=upgrade short=Upgrade disk space and/or machine type of UPgSQL instances
+ flag=async short=a default=false required=
+ flag=disk-size-gb short= default=0 required=
+ flag=instance-id short= default=[] required=true
+ flag=machine-type short= default= required=
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=zone short= default= required=
+ucloud pgsql db upgrade-price use=upgrade-price short=Get the price of upgrading a UPgSQL instance
+ flag=disk-size-gb short= default=0 required=true
+ flag=instance-id short= default= required=true
+ flag=machine-type short= default= required=true
+ flag=mode short= default=Normal required=
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=zone short= default= required=
+ucloud pgsql log use=log short=List and back up logs of UPgSQL instances
+ucloud pgsql log backup use=backup short=Back up the log package of a UPgSQL instance
+ flag=backup-file short= default= required=true
+ flag=begin-time short= default= required=
+ flag=end-time short= default= required=
+ flag=instance-id short= default= required=true
+ flag=log-type short= default= required=
+ flag=name short= default= required=true
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=zone short= default= required=
+ucloud pgsql log list use=list short=List logs of a UPgSQL instance within a time range
+ flag=begin-time short= default= required=true
+ flag=end-time short= default= required=true
+ flag=instance-id short= default= required=true
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=zone short= default= required=
+ucloud pgsql supabase use=supabase short=Manage USupabase instances (and the MemoryDB AI-memory variant)
+ucloud pgsql supabase bandwidth-upgrade-price use=bandwidth-upgrade-price short=Get the price of upgrading external-access bandwidth of a USupabase instance
+ flag=bandwidth short= default=0 required=true
+ flag=instance-id short= default= required=true
+ flag=memory-db short= default=false required=
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=zone short= default= required=
+ucloud pgsql supabase create use=create short=Create a USupabase instance
+ flag=async short=a default=false required=
+ flag=charge-type short= default=Month required=
+ flag=dashboard-name short= default= required=true
+ flag=dashboard-password short= default= required=true
+ flag=db-version short= default= required=true
+ flag=disk-size-gb short= default=0 required=true
+ flag=label short= default=[] required=
+ flag=machine-type short= default= required=true
+ flag=memory-db short= default=false required=
+ flag=mode short= default=Normal required=
+ flag=name short= default= required=true
+ flag=param-group-id short= default=0 required=true
+ flag=pgsql-password short= default= required=true
+ flag=pgsql-port short= default=5432 required=
+ flag=pgsql-user 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=true
+ flag=supabase-port short= default=8000 required=
+ flag=vpc-id short= default= required=true
+ flag=zone short= default= required=
+ucloud pgsql supabase create-memory-db use=create-memory-db short=Create a UMemoryDB (AI memory) instance
+ flag=async short=a default=false required=
+ flag=charge-type short= default=Month required=
+ flag=dashboard-name short= default= required=true
+ flag=dashboard-password short= default= required=true
+ flag=db-version short= default= required=true
+ flag=disk-size-gb short= default=0 required=true
+ flag=label short= default=[] required=
+ flag=machine-type short= default= required=true
+ flag=memory-db short= default=false required=
+ flag=mode short= default=Normal required=
+ flag=name short= default= required=true
+ flag=param-group-id short= default=0 required=true
+ flag=pgsql-password short= default= required=true
+ flag=pgsql-port short= default=5432 required=
+ flag=pgsql-user 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=true
+ flag=supabase-port short= default=8000 required=
+ flag=vpc-id short= default= required=true
+ flag=zone short= default= required=
+ucloud pgsql supabase delete use=delete short=Delete a USupabase instance
+ flag=instance-id short= default= required=true
+ flag=memory-db short= default=false required=
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=yes short=y default=false required=
+ flag=zone short= default= required=
+ucloud pgsql supabase describe use=describe short=Display details of a USupabase instance
+ flag=instance-id short= default= required=true
+ flag=memory-db short= default=false required=
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=zone short= default= required=
+ucloud pgsql supabase disable-external-access use=disable-external-access short=Disable external access for a USupabase instance
+ flag=instance-id short= default= required=true
+ flag=memory-db short= default=false required=
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=zone short= default= required=
+ucloud pgsql supabase enable-external-access use=enable-external-access short=Enable external access for a USupabase instance
+ flag=bandwidth short= default=0 required=true
+ flag=instance-id short= default= required=true
+ flag=memory-db short= default=false required=
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=zone short= default= required=
+ucloud pgsql supabase external-price use=external-price short=Get the price of enabling external access for a USupabase instance
+ flag=bandwidth short= default=0 required=true
+ flag=instance-id short= default= required=true
+ flag=memory-db short= default=false required=
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=zone short= default= required=
+ucloud pgsql supabase get-api-key use=get-api-key short=Display the API keys (service key + anon key) of a USupabase instance
+ flag=instance-id short= default= required=true
+ flag=memory-db short= default=false required=
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=zone short= default= required=
+ucloud pgsql supabase get-storage-config use=get-storage-config short=Display the storage configuration of a USupabase instance
+ flag=instance-id short= default= required=true
+ flag=memory-db short= default=false required=
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=zone short= default= required=
+ucloud pgsql supabase list use=list short=List USupabase instances
+ flag=instance-id short= default= required=
+ flag=limit short= default=0 required=
+ flag=memory-db short= default=false required=
+ flag=offset short= default=0 required=
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=zone short= default= required=
+ucloud pgsql supabase modify-external-access use=modify-external-access short=Modify external-access settings of a USupabase instance
+ flag=bandwidth short= default=0 required=
+ flag=instance-id short= default= required=true
+ flag=memory-db short= default=false required=
+ flag=port short= default=0 required=
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=white-list short= default= required=
+ flag=zone short= default= required=
+ucloud pgsql supabase reset-password use=reset-password short=Reset the dashboard password of a USupabase instance
+ flag=instance-id short= default= required=true
+ flag=memory-db short= default=false required=
+ flag=password short= default= required=true
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=zone short= default= required=
+ucloud pgsql supabase restart use=restart short=Restart a USupabase instance
+ flag=async short=a default=false required=
+ flag=instance-id short= default= required=true
+ flag=memory-db short= default=false required=
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=zone short= default= required=
+ucloud pgsql supabase set-storage-config use=set-storage-config short=Set storage configuration entries of a USupabase instance
+ flag=config short= default=[] required=true
+ flag=instance-id short= default= required=true
+ flag=memory-db short= default=false required=
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=zone short= default= required=
+ucloud pgsql supabase start use=start short=Start a USupabase instance
+ flag=async short=a default=false required=
+ flag=instance-id short= default= required=true
+ flag=memory-db short= default=false required=
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=zone short= default= required=
+ucloud pgsql supabase stop use=stop short=Stop a USupabase instance
+ flag=async short=a default=false required=
+ flag=instance-id short= default= required=true
+ flag=memory-db short= default=false required=
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=zone short= default= required=
diff --git a/products/pgsql/testdata/completion.golden b/products/pgsql/testdata/completion.golden
new file mode 100644
index 0000000000..a1f798a9ef
--- /dev/null
+++ b/products/pgsql/testdata/completion.golden
@@ -0,0 +1,192 @@
+ucloud pgsql backup download backup-id dynamic
+ucloud pgsql backup download instance-id dynamic
+ucloud pgsql backup download project-id dynamic
+ucloud pgsql backup download region dynamic
+ucloud pgsql backup download zone dynamic
+ucloud pgsql backup list backup-type static auto,manual
+ucloud pgsql backup list instance-id dynamic
+ucloud pgsql backup list project-id dynamic
+ucloud pgsql backup list region dynamic
+ucloud pgsql backup list zone dynamic
+ucloud pgsql backup strategy instance-id dynamic
+ucloud pgsql backup strategy project-id dynamic
+ucloud pgsql backup strategy region dynamic
+ucloud pgsql backup strategy zone dynamic
+ucloud pgsql backup update-strategy instance-id dynamic
+ucloud pgsql backup update-strategy project-id dynamic
+ucloud pgsql backup update-strategy region dynamic
+ucloud pgsql backup update-strategy zone dynamic
+ucloud pgsql conf create db-version static postgresql-10.4,postgresql-13.4
+ucloud pgsql conf create project-id dynamic
+ucloud pgsql conf create region dynamic
+ucloud pgsql conf create src-conf-id dynamic
+ucloud pgsql conf create zone dynamic
+ucloud pgsql conf delete conf-id dynamic
+ucloud pgsql conf delete project-id dynamic
+ucloud pgsql conf delete region dynamic
+ucloud pgsql conf delete zone dynamic
+ucloud pgsql conf describe conf-id dynamic
+ucloud pgsql conf describe project-id dynamic
+ucloud pgsql conf describe region dynamic
+ucloud pgsql conf describe zone dynamic
+ucloud pgsql conf download conf-id dynamic
+ucloud pgsql conf download project-id dynamic
+ucloud pgsql conf download region dynamic
+ucloud pgsql conf download zone dynamic
+ucloud pgsql conf list project-id dynamic
+ucloud pgsql conf list region dynamic
+ucloud pgsql conf list zone dynamic
+ucloud pgsql conf upload conf-file static
+ucloud pgsql conf upload db-version static postgresql-10.4,postgresql-13.4
+ucloud pgsql conf upload project-id dynamic
+ucloud pgsql conf upload region dynamic
+ucloud pgsql conf upload zone dynamic
+ucloud pgsql db create machine-type dynamic
+ucloud pgsql db create mode static HA,Normal
+ucloud pgsql db create param-group-id dynamic
+ucloud pgsql db create project-id dynamic
+ucloud pgsql db create region dynamic
+ucloud pgsql db create subnet-id dynamic
+ucloud pgsql db create version static postgresql-10.4,postgresql-13.4
+ucloud pgsql db create vpc-id dynamic
+ucloud pgsql db create zone dynamic
+ucloud pgsql db create-readonly machine-type dynamic
+ucloud pgsql db create-readonly project-id dynamic
+ucloud pgsql db create-readonly region dynamic
+ucloud pgsql db create-readonly src-instance-id dynamic
+ucloud pgsql db create-readonly subnet-id dynamic
+ucloud pgsql db create-readonly vpc-id dynamic
+ucloud pgsql db create-readonly zone dynamic
+ucloud pgsql db delete instance-id dynamic
+ucloud pgsql db delete project-id dynamic
+ucloud pgsql db delete region dynamic
+ucloud pgsql db delete zone dynamic
+ucloud pgsql db get instance-id dynamic
+ucloud pgsql db get project-id dynamic
+ucloud pgsql db get region dynamic
+ucloud pgsql db get zone dynamic
+ucloud pgsql db list instance-id dynamic
+ucloud pgsql db list project-id dynamic
+ucloud pgsql db list region dynamic
+ucloud pgsql db list zone dynamic
+ucloud pgsql db list-machine-type project-id dynamic
+ucloud pgsql db list-machine-type region dynamic
+ucloud pgsql db list-machine-type zone dynamic
+ucloud pgsql db list-version project-id dynamic
+ucloud pgsql db list-version region dynamic
+ucloud pgsql db list-version zone dynamic
+ucloud pgsql db price charge-type static Dynamic,Month,Year
+ucloud pgsql db price machine-type dynamic
+ucloud pgsql db price mode static HA,Normal
+ucloud pgsql db price project-id dynamic
+ucloud pgsql db price region dynamic
+ucloud pgsql db price zone dynamic
+ucloud pgsql db reset-password instance-id dynamic
+ucloud pgsql db reset-password project-id dynamic
+ucloud pgsql db reset-password region dynamic
+ucloud pgsql db reset-password zone dynamic
+ucloud pgsql db restart force static false,true
+ucloud pgsql db restart instance-id dynamic
+ucloud pgsql db restart project-id dynamic
+ucloud pgsql db restart region dynamic
+ucloud pgsql db restart restart-host static false,true
+ucloud pgsql db restart zone dynamic
+ucloud pgsql db start instance-id dynamic
+ucloud pgsql db start project-id dynamic
+ucloud pgsql db start region dynamic
+ucloud pgsql db start zone dynamic
+ucloud pgsql db stop force static false,true
+ucloud pgsql db stop instance-id dynamic
+ucloud pgsql db stop project-id dynamic
+ucloud pgsql db stop region dynamic
+ucloud pgsql db stop stop-host static false,true
+ucloud pgsql db stop zone dynamic
+ucloud pgsql db stop-creating-readonly instance-id dynamic
+ucloud pgsql db stop-creating-readonly project-id dynamic
+ucloud pgsql db stop-creating-readonly region dynamic
+ucloud pgsql db stop-creating-readonly zone dynamic
+ucloud pgsql db update-name instance-id dynamic
+ucloud pgsql db update-name project-id dynamic
+ucloud pgsql db update-name region dynamic
+ucloud pgsql db update-name zone dynamic
+ucloud pgsql db update-remark instance-id dynamic
+ucloud pgsql db update-remark project-id dynamic
+ucloud pgsql db update-remark region dynamic
+ucloud pgsql db update-remark zone dynamic
+ucloud pgsql db upgrade instance-id dynamic
+ucloud pgsql db upgrade machine-type dynamic
+ucloud pgsql db upgrade project-id dynamic
+ucloud pgsql db upgrade region dynamic
+ucloud pgsql db upgrade zone dynamic
+ucloud pgsql db upgrade-price instance-id dynamic
+ucloud pgsql db upgrade-price machine-type dynamic
+ucloud pgsql db upgrade-price mode static HA,Normal
+ucloud pgsql db upgrade-price project-id dynamic
+ucloud pgsql db upgrade-price region dynamic
+ucloud pgsql db upgrade-price zone dynamic
+ucloud pgsql log backup instance-id dynamic
+ucloud pgsql log backup log-type static error,slow
+ucloud pgsql log backup project-id dynamic
+ucloud pgsql log backup region dynamic
+ucloud pgsql log backup zone dynamic
+ucloud pgsql log list instance-id dynamic
+ucloud pgsql log list project-id dynamic
+ucloud pgsql log list region dynamic
+ucloud pgsql log list zone dynamic
+ucloud pgsql supabase bandwidth-upgrade-price project-id dynamic
+ucloud pgsql supabase bandwidth-upgrade-price region dynamic
+ucloud pgsql supabase bandwidth-upgrade-price zone dynamic
+ucloud pgsql supabase create charge-type static Dynamic,Month,Year
+ucloud pgsql supabase create db-version static postgresql-10.4,postgresql-13.4
+ucloud pgsql supabase create mode static HA,Normal
+ucloud pgsql supabase create project-id dynamic
+ucloud pgsql supabase create region dynamic
+ucloud pgsql supabase create zone dynamic
+ucloud pgsql supabase create-memory-db charge-type static Dynamic,Month,Year
+ucloud pgsql supabase create-memory-db db-version static postgresql-10.4,postgresql-13.4
+ucloud pgsql supabase create-memory-db mode static HA,Normal
+ucloud pgsql supabase create-memory-db project-id dynamic
+ucloud pgsql supabase create-memory-db region dynamic
+ucloud pgsql supabase create-memory-db zone dynamic
+ucloud pgsql supabase delete project-id dynamic
+ucloud pgsql supabase delete region dynamic
+ucloud pgsql supabase delete zone dynamic
+ucloud pgsql supabase describe project-id dynamic
+ucloud pgsql supabase describe region dynamic
+ucloud pgsql supabase describe zone dynamic
+ucloud pgsql supabase disable-external-access project-id dynamic
+ucloud pgsql supabase disable-external-access region dynamic
+ucloud pgsql supabase disable-external-access zone dynamic
+ucloud pgsql supabase enable-external-access project-id dynamic
+ucloud pgsql supabase enable-external-access region dynamic
+ucloud pgsql supabase enable-external-access zone dynamic
+ucloud pgsql supabase external-price project-id dynamic
+ucloud pgsql supabase external-price region dynamic
+ucloud pgsql supabase external-price zone dynamic
+ucloud pgsql supabase get-api-key project-id dynamic
+ucloud pgsql supabase get-api-key region dynamic
+ucloud pgsql supabase get-api-key zone dynamic
+ucloud pgsql supabase get-storage-config project-id dynamic
+ucloud pgsql supabase get-storage-config region dynamic
+ucloud pgsql supabase get-storage-config zone dynamic
+ucloud pgsql supabase list project-id dynamic
+ucloud pgsql supabase list region dynamic
+ucloud pgsql supabase list zone dynamic
+ucloud pgsql supabase modify-external-access project-id dynamic
+ucloud pgsql supabase modify-external-access region dynamic
+ucloud pgsql supabase modify-external-access zone dynamic
+ucloud pgsql supabase reset-password project-id dynamic
+ucloud pgsql supabase reset-password region dynamic
+ucloud pgsql supabase reset-password zone dynamic
+ucloud pgsql supabase restart project-id dynamic
+ucloud pgsql supabase restart region dynamic
+ucloud pgsql supabase restart zone dynamic
+ucloud pgsql supabase set-storage-config project-id dynamic
+ucloud pgsql supabase set-storage-config region dynamic
+ucloud pgsql supabase set-storage-config zone dynamic
+ucloud pgsql supabase start project-id dynamic
+ucloud pgsql supabase start region dynamic
+ucloud pgsql supabase start zone dynamic
+ucloud pgsql supabase stop project-id dynamic
+ucloud pgsql supabase stop region dynamic
+ucloud pgsql supabase stop zone dynamic
diff --git a/products/sqlserver/internal/sqlserver/cmd.go b/products/sqlserver/internal/sqlserver/cmd.go
new file mode 100644
index 0000000000..1d58f2c0b0
--- /dev/null
+++ b/products/sqlserver/internal/sqlserver/cmd.go
@@ -0,0 +1,18 @@
+package sqlserver
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// NewCommand builds the `sqlserver` root command and mounts the `db` subtree.
+func NewCommand(ctx *cli.Context) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "sqlserver",
+ Short: "Manipulate SQL Server on UCloud platform",
+ Long: "Manipulate SQL Server on UCloud platform",
+ }
+ cmd.AddCommand(newSQLServerDB(ctx))
+ return cmd
+}
diff --git a/products/sqlserver/internal/sqlserver/completion.go b/products/sqlserver/internal/sqlserver/completion.go
new file mode 100644
index 0000000000..294e46b555
--- /dev/null
+++ b/products/sqlserver/internal/sqlserver/completion.go
@@ -0,0 +1,126 @@
+package sqlserver
+
+import (
+ "fmt"
+
+ "github.com/ucloud/ucloud-sdk-go/services/udb"
+ "github.com/ucloud/ucloud-sdk-go/services/vpc"
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+var dbVersionList = []string{"sqlserver-2017", "sqlserver-2019", "sqlserver-2022"}
+
+// getAllVPCIns returns VPC list for the given project and region.
+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
+}
+
+// getAllVPCIdNames returns VPC ID/Name pairs for shell completion.
+func getAllVPCIdNames(ctx *cli.Context, project, region string) []string {
+ vpcInsList, err := getAllVPCIns(ctx, project, region)
+ list := []string{}
+ if err != nil {
+ return nil
+ }
+ for _, v := range vpcInsList {
+ list = append(list, fmt.Sprintf("%s/%s", v.VPCId, v.Name))
+ }
+ return list
+}
+
+// getAllSubnets returns subnet list for the given VPC, project and region.
+func getAllSubnets(ctx *cli.Context, vpcID, project, region string) ([]vpc.SubnetInfo, error) {
+ 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))
+ }
+ subnets := []vpc.SubnetInfo{}
+ for limit, offset := 50, 0; ; offset += limit {
+ req.Limit = sdk.Int(limit)
+ req.Offset = sdk.Int(offset)
+ resp, err := client.DescribeSubnet(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return nil, err
+ }
+ subnets = append(subnets, resp.DataSet...)
+ if limit+offset >= resp.TotalCount {
+ break
+ }
+ }
+ return subnets, nil
+}
+
+// getAllSubnetIDNames returns Subnet ID/Name pairs for shell completion.
+func getAllSubnetIDNames(ctx *cli.Context, vpcID, project, region string) []string {
+ subnets, err := getAllSubnets(ctx, vpcID, project, region)
+ if err != nil {
+ return nil
+ }
+ list := []string{}
+ for _, s := range subnets {
+ list = append(list, fmt.Sprintf("%s/%s", s.SubnetId, s.SubnetName))
+ }
+ return list
+}
+
+func getUDBIDList(ctx *cli.Context, states []string, dbType, project, region, zone string) []string {
+ udbs, err := getUDBList(ctx, states, dbType, project, region, zone)
+ if err != nil {
+ return nil
+ }
+ list := []string{}
+ for _, db := range udbs {
+ list = append(list, fmt.Sprintf("%s/%s", db.DBId, db.Name))
+ }
+ return list
+}
+
+func getUDBList(ctx *cli.Context, states []string, dbType, project, region, zone string) ([]udb.UDBInstanceSet, error) {
+ client := cli.NewServiceClient(ctx, udb.NewClient)
+ req := client.NewDescribeUDBInstanceRequest()
+ if dbType == "" {
+ dbType = "sqlserver"
+ }
+ req.ClassType = &dbType
+ req.ProjectId = &project
+ req.Region = ®ion
+ req.Zone = &zone
+ list := []udb.UDBInstanceSet{}
+ for offset, limit := 0, 50; ; offset += limit {
+ req.Offset = sdk.Int(offset)
+ req.Limit = sdk.Int(limit)
+ resp, err := client.DescribeUDBInstance(req)
+ if err != nil {
+ return nil, err
+ }
+ for _, ins := range resp.DataSet {
+ if states != nil {
+ for _, s := range states {
+ if s == ins.State {
+ list = append(list, ins)
+ }
+ }
+ } else {
+ list = append(list, ins)
+ }
+ }
+ if offset+limit >= resp.TotalCount {
+ break
+ }
+ }
+ return list, nil
+}
diff --git a/products/sqlserver/internal/sqlserver/create.go b/products/sqlserver/internal/sqlserver/create.go
new file mode 100644
index 0000000000..b94e0809aa
--- /dev/null
+++ b/products/sqlserver/internal/sqlserver/create.go
@@ -0,0 +1,226 @@
+package sqlserver
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/spf13/cobra"
+ "github.com/spf13/pflag"
+
+ "github.com/ucloud/ucloud-sdk-go/services/uaccount"
+ "github.com/ucloud/ucloud-sdk-go/ucloud/request"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+var dbStorageClassList = []string{"CLOUD_RSSD"}
+var dbSpecClassList = []string{"O"}
+var dbMachineTypeList = []string{
+ "o.sqlserver2m.medium", // 2C4G
+ "o.sqlserver2m.xlarge", // 4C8G
+ "o.sqlserver2m.2xlarge", // 8C16G
+ "o.sqlserver2m.4xlarge", // 16C32G
+ "o.sqlserver2m.8xlarge", // 32C64G
+ "o.sqlserver4m.medium", // 2C8G
+ "o.sqlserver4m.xlarge", // 4C16G
+ "o.sqlserver4m.2xlarge", // 8C32G
+ "o.sqlserver4m.4xlarge", // 16C64G
+ "o.sqlserver4m.8xlarge", // 32C128G
+ "o.sqlserver8m.medium", // 2C16G
+ "o.sqlserver8m.xlarge", // 4C32G
+ "o.sqlserver8m.2xlarge", // 8C64G
+ "o.sqlserver8m.4xlarge", // 16C128G
+ "o.sqlserver8m.8xlarge", // 32C256G
+}
+
+// newCreate returns the "create" command for SQL Server Normal (single-node) instances.
+func newCreate(ctx *cli.Context) *cobra.Command {
+ var labels []string
+ var name, password, version, storageClass, specClass string
+ var cpu, memory, port, diskSpace int
+ var chargeType string
+ var quantity int
+ var vpcID, subnetID string
+ var backupCount, backupTime, backupDuration int
+ var tag, alarmTemplateID string
+ var couponID string
+ var async bool
+ var common request.CommonBase
+
+ cmd := &cobra.Command{
+ Use: "create",
+ Short: "Create SQL Server instance (Normal/single-node mode) on UCloud platform",
+ Long: "Create SQL Server instance (Normal/single-node mode) on UCloud platform",
+ Run: func(c *cobra.Command, args []string) {
+ region := common.GetRegion()
+ zone := common.GetZone()
+ projectID := common.GetProjectId()
+ if len(name) < 6 {
+ ctx.HandleError(fmt.Errorf("name must be at least 6 characters"))
+ return
+ }
+ if diskSpace < 20 || diskSpace > 32000 {
+ ctx.HandleError(fmt.Errorf("disk-size-gb must be between 20 and 32000"))
+ return
+ }
+
+ params := map[string]interface{}{
+ "Action": "CreateUDBSQLServerInstance",
+ "Region": region,
+ "Zone": zone,
+ "Name": name,
+ "AdminPassword": password,
+ "DBTypeId": version,
+ "Port": port,
+ "DiskSpace": diskSpace,
+ "CPU": cpu,
+ "MemoryLimit": memory,
+ "StorageClass": storageClass,
+ "SpecificationClass": specClass,
+ "ChargeType": chargeType,
+ "Quantity": quantity,
+ "InstanceMode": "Normal",
+ "BackupCount": backupCount,
+ "BackupTime": backupTime,
+ "BackupDuration": backupDuration,
+ }
+ if projectID != "" {
+ params["ProjectId"] = projectID
+ }
+
+ // Optional params, only send when explicitly set
+ 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("alarm-template-id") {
+ params["AlarmTemplateId"] = alarmTemplateID
+ }
+ if c.Flags().Changed("coupon-id") {
+ params["CouponId"] = couponID
+ }
+
+ idx := 0
+ for _, l := range labels {
+ parts := strings.SplitN(l, "=", 2)
+ if len(parts) == 2 {
+ params[fmt.Sprintf("Labels.%d.Key", idx)] = parts[0]
+ params[fmt.Sprintf("Labels.%d.Value", idx)] = parts[1]
+ idx++
+ }
+ }
+
+ client := cli.NewServiceClient(ctx, uaccount.NewClient)
+ 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(err)
+ return
+ }
+
+ dbID, _ := resp.GetPayload()["DBId"].(string)
+ if dbID == "" {
+ ctx.HandleError(fmt.Errorf("empty DBId in response"))
+ return
+ }
+ w := ctx.ProgressWriter()
+ if async {
+ fmt.Fprintf(w, "udb[%s] is initializing\n", dbID)
+ } else {
+ text := fmt.Sprintf("udb[%s] is initializing", dbID)
+ ctx.PollerTo(w, describeUdbByID(ctx)).Spoll(dbID, text, []string{UDB_RUNNING, UDB_FAIL})
+ }
+ ctx.EmitResult(cli.OpResultRow{ResourceID: dbID, Action: "create", Status: "Initializing"})
+ },
+ }
+
+ 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, "db-type", "", "Required. SQL Server version. Options: sqlserver-2017, sqlserver-2019, sqlserver-2022")
+
+ // Optional flags with defaults
+ ctx.BindRegion(cmd, &common)
+ ctx.BindZone(cmd, &common)
+ ctx.BindProjectID(cmd, &common)
+ flags.IntVar(&cpu, "cpu", 2, "Optional. CPU cores. Options: 2/4/8/16/32/64, default 2")
+ flags.IntVar(&memory, "memory", 4000, "Optional. Memory limit (MB). Options: 2000/4000/6000/8000/12000/16000/24000/32000/48000/64000/96000/128000/192000/256000/320000, default 4000")
+ flags.IntVar(&port, "port", 1433, "Optional. Port, default 1433")
+ flags.IntVar(&diskSpace, "disk-size-gb", 50, "Optional. Disk size (GiB), 20-32000, default 50")
+ flags.StringVar(&storageClass, "storage-class", "CLOUD_RSSD", "Optional. Storage class: CLOUD_RSSD")
+ flags.StringVar(&specClass, "spec-class", "O", "Optional. Spec class: O(NVMe)")
+
+ flags.StringVar(&chargeType, "charge-type", "Month", "Optional. Year / Month / Dynamic")
+ flags.IntVar(&quantity, "quantity", 1, "Optional. Purchase duration")
+ 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.IntVar(&backupCount, "backup-count", 7, "Optional. Weekly backup count, default 7")
+ flags.IntVar(&backupTime, "backup-time", 1, "Optional. Backup start hour (0-23), default 1")
+ flags.IntVar(&backupDuration, "backup-duration", 24, "Optional. Backup interval hours, default 24")
+ flags.StringVar(&tag, "tag", "", "Optional. Business group name")
+ flags.StringVar(&alarmTemplateID, "alarm-template-id", "", "Optional. Alarm template ID")
+ flags.StringSliceVar(&labels, "label", nil, "Optional. Resource label, format: key=value, repeatable")
+ flags.StringVar(&couponID, "coupon-id", "", "Optional. Coupon ID")
+ flags.BoolVar(&async, "async", false, "Optional. Do not wait for creation to finish")
+
+ command.SetFlagValues(cmd, "db-type", dbVersionList...)
+ command.SetFlagValues(cmd, "storage-class", dbStorageClassList...)
+ command.SetFlagValues(cmd, "spec-class", dbSpecClassList...)
+ command.SetFlagValues(cmd, "charge-type", "Month", "Dynamic", "Year")
+ command.SetFlagValues(cmd, "cpu", "2", "4", "8", "16", "32", "64")
+
+ 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())
+ })
+
+ cmd.MarkFlagRequired("name")
+ cmd.MarkFlagRequired("password")
+ cmd.MarkFlagRequired("db-type")
+
+ // Custom usage, highlight required flags
+ requiredFlags := []string{"name", "password", "db-type"}
+ cmd.SetUsageFunc(func(c *cobra.Command) error {
+ w := c.OutOrStderr()
+ fmt.Fprintln(w, "Usage:")
+ fmt.Fprintf(w, " %s [flags]\n\n", c.CommandPath())
+ fmt.Fprintln(w, "★ Required flags (must be provided):")
+ for _, name := range requiredFlags {
+ f := c.Flags().Lookup(name)
+ if f != nil {
+ fmt.Fprintf(w, " --%-20s %s\n", f.Name, f.Usage)
+ }
+ }
+ fmt.Fprintln(w, "\nOptional flags:")
+ c.Flags().VisitAll(func(f *pflag.Flag) {
+ for _, req := range requiredFlags {
+ if f.Name == req {
+ return
+ }
+ }
+ defVal := ""
+ if f.DefValue != "" && f.DefValue != "[]" {
+ defVal = fmt.Sprintf(" (default %s)", f.DefValue)
+ }
+ fmt.Fprintf(w, " --%-20s %s%s\n", f.Name, f.Usage, defVal)
+ })
+ return nil
+ })
+
+ return cmd
+}
diff --git a/products/sqlserver/internal/sqlserver/create_alwayson.go b/products/sqlserver/internal/sqlserver/create_alwayson.go
new file mode 100644
index 0000000000..f7ab29e161
--- /dev/null
+++ b/products/sqlserver/internal/sqlserver/create_alwayson.go
@@ -0,0 +1,202 @@
+package sqlserver
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/spf13/cobra"
+ "github.com/spf13/pflag"
+
+ "github.com/ucloud/ucloud-sdk-go/services/uaccount"
+ "github.com/ucloud/ucloud-sdk-go/ucloud/request"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newCreateAlwaysOn returns the "create-alwayson" command for SQL Server HA (AlwaysOn cluster) instances.
+func newCreateAlwaysOn(ctx *cli.Context) *cobra.Command {
+ var labels []string
+ var name, password, version, machineType, storageClass, specClass string
+ var port, diskSpace int
+ var chargeType string
+ var quantity int
+ var vpcID, subnetID string
+ var backupCount, backupTime, backupDuration int
+ var tag, alarmTemplateID string
+ var couponID string
+ var async bool
+ var common request.CommonBase
+
+ cmd := &cobra.Command{
+ Use: "create-alwayson",
+ Short: "Create SQL Server instance (HA/AlwaysOn cluster mode) on UCloud platform",
+ Long: "Create SQL Server instance (HA/AlwaysOn cluster mode) on UCloud platform.",
+ Run: func(c *cobra.Command, args []string) {
+ region := common.GetRegion()
+ zone := common.GetZone()
+ projectID := common.GetProjectId()
+ if len(name) < 6 {
+ ctx.HandleError(fmt.Errorf("name must be at least 6 characters"))
+ return
+ }
+ if diskSpace < 20 || diskSpace > 32000 {
+ ctx.HandleError(fmt.Errorf("disk-size-gb must be between 20 and 32000"))
+ return
+ }
+
+ params := map[string]interface{}{
+ "Action": "CreateUDBSQLServerInstance",
+ "Region": region,
+ "Zone": zone,
+ "Name": name,
+ "AdminPassword": password,
+ "DBTypeId": version,
+ "Port": port,
+ "DiskSpace": diskSpace,
+ "MachineType": machineType,
+ "VPCId": vpcID,
+ "SubnetId": subnetID,
+ "StorageClass": storageClass,
+ "SpecificationClass": specClass,
+ "ChargeType": chargeType,
+ "Quantity": quantity,
+ "InstanceMode": "AlwaysOn",
+ "BackupCount": backupCount,
+ "BackupTime": backupTime,
+ "BackupDuration": backupDuration,
+ }
+ if projectID != "" {
+ params["ProjectId"] = projectID
+ }
+
+ // Optional params, only send when explicitly set
+ if c.Flags().Changed("tag") {
+ params["Tag"] = tag
+ }
+ if c.Flags().Changed("alarm-template-id") {
+ params["AlarmTemplateId"] = alarmTemplateID
+ }
+ if c.Flags().Changed("coupon-id") {
+ params["CouponId"] = couponID
+ }
+
+ idx := 0
+ for _, l := range labels {
+ parts := strings.SplitN(l, "=", 2)
+ if len(parts) == 2 {
+ params[fmt.Sprintf("Labels.%d.Key", idx)] = parts[0]
+ params[fmt.Sprintf("Labels.%d.Value", idx)] = parts[1]
+ idx++
+ }
+ }
+
+ client := cli.NewServiceClient(ctx, uaccount.NewClient)
+ 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(err)
+ return
+ }
+
+ dbID, _ := resp.GetPayload()["DBId"].(string)
+ if dbID == "" {
+ ctx.HandleError(fmt.Errorf("empty DBId in response"))
+ return
+ }
+ w := ctx.ProgressWriter()
+ if async {
+ fmt.Fprintf(w, "udb[%s] is initializing\n", dbID)
+ } else {
+ text := fmt.Sprintf("udb[%s] is initializing", dbID)
+ ctx.PollerTo(w, describeUdbByID(ctx)).Spoll(dbID, text, []string{UDB_RUNNING, UDB_FAIL})
+ }
+ ctx.EmitResult(cli.OpResultRow{ResourceID: dbID, Action: "create-alwayson", Status: "Initializing"})
+ },
+ }
+
+ 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, "db-type", "", "Required. SQL Server version. Options: sqlserver-2017, sqlserver-2019, sqlserver-2022")
+ flags.StringVar(&vpcID, "vpc-id", "", "Required. VPC ID. See 'ucloud vpc list'")
+ flags.StringVar(&subnetID, "subnet-id", "", "Required. Subnet ID. See 'ucloud subnet list'")
+
+ // Optional flags with defaults
+ ctx.BindRegion(cmd, &common)
+ ctx.BindZone(cmd, &common)
+ ctx.BindProjectID(cmd, &common)
+ flags.StringVar(&machineType, "machine-type", "o.sqlserver2m.medium", "Optional. Machine type ID, e.g. o.sqlserver2m.medium for 2C4G. Use ListUDBMachineType API with InstanceMode=AlwaysOn to get available types")
+ flags.IntVar(&port, "port", 1433, "Optional. Port, default 1433")
+ flags.IntVar(&diskSpace, "disk-size-gb", 50, "Optional. Disk size (GiB), 20-32000, default 50")
+ flags.StringVar(&storageClass, "storage-class", "CLOUD_RSSD", "Optional. Storage class: CLOUD_RSSD")
+ flags.StringVar(&specClass, "spec-class", "O", "Optional. Spec class: O(NVMe)")
+
+ flags.StringVar(&chargeType, "charge-type", "Month", "Optional. Year / Month / Dynamic")
+ flags.IntVar(&quantity, "quantity", 1, "Optional. Purchase duration")
+ flags.IntVar(&backupCount, "backup-count", 7, "Optional. Weekly backup count, default 7")
+ flags.IntVar(&backupTime, "backup-time", 1, "Optional. Backup start hour (0-23), default 1")
+ flags.IntVar(&backupDuration, "backup-duration", 24, "Optional. Backup interval hours, default 24")
+ flags.StringVar(&tag, "tag", "", "Optional. Business group name")
+ flags.StringVar(&alarmTemplateID, "alarm-template-id", "", "Optional. Alarm template ID")
+ flags.StringSliceVar(&labels, "label", nil, "Optional. Resource label, format: key=value, repeatable")
+ flags.StringVar(&couponID, "coupon-id", "", "Optional. Coupon ID")
+ flags.BoolVar(&async, "async", false, "Optional. Do not wait for creation to finish")
+
+ command.SetFlagValues(cmd, "db-type", dbVersionList...)
+ command.SetFlagValues(cmd, "storage-class", dbStorageClassList...)
+ command.SetFlagValues(cmd, "spec-class", dbSpecClassList...)
+ command.SetFlagValues(cmd, "charge-type", "Month", "Dynamic", "Year")
+ command.SetFlagValues(cmd, "machine-type", dbMachineTypeList...)
+
+ 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())
+ })
+
+ cmd.MarkFlagRequired("name")
+ cmd.MarkFlagRequired("password")
+ cmd.MarkFlagRequired("db-type")
+ cmd.MarkFlagRequired("vpc-id")
+ cmd.MarkFlagRequired("subnet-id")
+
+ // Custom usage, highlight required flags
+ requiredFlags := []string{"name", "password", "db-type", "vpc-id", "subnet-id"}
+ cmd.SetUsageFunc(func(c *cobra.Command) error {
+ w := c.OutOrStderr()
+ fmt.Fprintln(w, "Usage:")
+ fmt.Fprintf(w, " %s [flags]\n\n", c.CommandPath())
+ fmt.Fprintln(w, "★ Required flags (must be provided):")
+ for _, name := range requiredFlags {
+ f := c.Flags().Lookup(name)
+ if f != nil {
+ fmt.Fprintf(w, " --%-20s %s\n", f.Name, f.Usage)
+ }
+ }
+ fmt.Fprintln(w, "\nOptional flags:")
+ c.Flags().VisitAll(func(f *pflag.Flag) {
+ for _, req := range requiredFlags {
+ if f.Name == req {
+ return
+ }
+ }
+ defVal := ""
+ if f.DefValue != "" && f.DefValue != "[]" {
+ defVal = fmt.Sprintf(" (default %s)", f.DefValue)
+ }
+ fmt.Fprintf(w, " --%-20s %s%s\n", f.Name, f.Usage, defVal)
+ })
+ return nil
+ })
+
+ return cmd
+}
diff --git a/products/sqlserver/internal/sqlserver/db.go b/products/sqlserver/internal/sqlserver/db.go
new file mode 100644
index 0000000000..f2cf1d2e8a
--- /dev/null
+++ b/products/sqlserver/internal/sqlserver/db.go
@@ -0,0 +1,26 @@
+package sqlserver
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// newSQLServerDB builds the "sqlserver db" subcommand group for instance lifecycle operations.
+func newSQLServerDB(ctx *cli.Context) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "db",
+ Short: "Manage SQL Server instances",
+ Long: "Manage SQL Server instances",
+ }
+
+ cmd.AddCommand(newList(ctx))
+ cmd.AddCommand(newCreate(ctx))
+ // cmd.AddCommand(newCreateAlwaysOn(ctx))
+ cmd.AddCommand(newDelete(ctx))
+ cmd.AddCommand(newStart(ctx))
+ cmd.AddCommand(newStop(ctx))
+ cmd.AddCommand(newRestart(ctx))
+
+ return cmd
+}
diff --git a/products/sqlserver/internal/sqlserver/delete.go b/products/sqlserver/internal/sqlserver/delete.go
new file mode 100644
index 0000000000..0612c46f94
--- /dev/null
+++ b/products/sqlserver/internal/sqlserver/delete.go
@@ -0,0 +1,77 @@
+package sqlserver
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-sdk-go/services/udb"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newDelete returns the "delete" command for SQL Server instances.
+func newDelete(ctx *cli.Context) *cobra.Command {
+ var idNames []string
+ var yes bool
+ client := cli.NewServiceClient(ctx, udb.NewClient)
+ req := client.NewDeleteUDBInstanceRequest()
+ cmd := &cobra.Command{
+ Use: "delete",
+ Short: "Delete SQL Server instances by udb-id",
+ Long: "Delete SQL Server instances by udb-id",
+ Run: func(c *cobra.Command, args []string) {
+ ok, err := ctx.Confirm(yes, "Are you sure you want to delete the udb(s)?")
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ if !ok {
+ return
+ }
+ w := ctx.ProgressWriter()
+ results := []cli.OpResultRow{}
+ for _, idname := range idNames {
+ id := ctx.PickResourceID(idname)
+ any, err := describeUdbByID(ctx)(id, nil)
+ if err != nil {
+ ctx.HandleError(err)
+ continue
+ }
+ req.DBId = &id
+ ins, ok := any.(*udb.UDBInstanceSet)
+ if ok && ins.State == UDB_RUNNING {
+ stopReq := client.NewStopUDBInstanceRequest()
+ stopReq.ProjectId = req.ProjectId
+ stopReq.Region = req.Region
+ stopReq.Zone = req.Zone
+ stopReq.DBId = req.DBId
+ stopUdbIns(ctx, stopReq, false, w)
+ }
+ _, err = client.DeleteUDBInstance(req)
+ if err != nil {
+ ctx.HandleError(err)
+ continue
+ }
+ fmt.Fprintf(w, "udb[%s] deleted\n", idname)
+ results = append(results, cli.OpResultRow{ResourceID: id, Action: "delete", Status: "Deleted"})
+ }
+ ctx.EmitResult(results...)
+ },
+ }
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ flags.StringSliceVar(&idNames, "udb-id", nil, "Required. Resource ID of UDB instances to delete")
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+ ctx.BindProjectID(cmd, req)
+ flags.BoolVarP(&yes, "yes", "y", false, "Optional. Do not prompt for confirmation.")
+
+ cmd.MarkFlagRequired("udb-id")
+ command.SetCompletion(cmd, "udb-id", func() []string {
+ return getUDBIDList(ctx, nil, "", *req.ProjectId, *req.Region, *req.Zone)
+ })
+ return cmd
+}
diff --git a/products/sqlserver/internal/sqlserver/list.go b/products/sqlserver/internal/sqlserver/list.go
new file mode 100644
index 0000000000..07d7f579d3
--- /dev/null
+++ b/products/sqlserver/internal/sqlserver/list.go
@@ -0,0 +1,86 @@
+package sqlserver
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-sdk-go/services/udb"
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newList returns the "list" command for SQL Server instances.
+func newList(ctx *cli.Context) *cobra.Command {
+ client := cli.NewServiceClient(ctx, udb.NewClient)
+ req := client.NewDescribeUDBInstanceRequest()
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List SQL Server instances",
+ Long: "List SQL Server instances",
+ Run: func(c *cobra.Command, args []string) {
+ if *req.DBId != "" {
+ *req.DBId = ctx.PickResourceID(*req.DBId)
+ }
+ resp, err := client.DescribeUDBInstance(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ list := []UDBSQLServerRow{}
+ for _, ins := range resp.DataSet {
+ row := UDBSQLServerRow{}
+ row.Name = ins.Name
+ row.Zone = ins.Zone
+ row.Role = ins.Role
+ row.ResourceID = ins.DBId
+ row.Group = ins.Tag
+ row.VPC = ins.VPCId
+ row.Subnet = ins.SubnetId
+ row.IP = ins.VirtualIP
+ row.Mode = ins.InstanceMode
+ row.DiskType = ins.InstanceType
+ row.Status = ins.State
+ row.Config = fmt.Sprintf("%s|%dG|%dG", ins.DBTypeId, ins.MemoryLimit/1000, ins.DiskSpace)
+ list = append(list, row)
+ for _, slave := range ins.DataSet {
+ row := UDBSQLServerRow{}
+ row.Name = slave.Name
+ row.Zone = slave.Zone
+ row.Role = fmt.Sprintf("⮑ %s", slave.Role)
+ row.ResourceID = slave.DBId
+ row.Group = slave.Tag
+ row.VPC = slave.VPCId
+ row.Subnet = slave.SubnetId
+ row.IP = slave.VirtualIP
+ row.Mode = slave.InstanceMode
+ row.DiskType = slave.InstanceType
+ row.Config = fmt.Sprintf("%s|%dG|%dG", slave.DBTypeId, slave.MemoryLimit/1000, slave.DiskSpace)
+ row.Status = slave.State
+ list = append(list, row)
+ }
+ }
+ ctx.PrintList(list)
+ },
+ }
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ req.DBId = flags.String("udb-id", "", "Optional. List the specified SQL Server instance")
+ ctx.BindProjectID(cmd, req)
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+ ctx.BindLimit(cmd, req)
+ ctx.BindOffset(cmd, req)
+ req.IncludeSlaves = flags.Bool("include-slaves", false, "Optional. When specifying the udb-id, whether to display its slaves together. Accept values:true, false")
+ req.ClassType = sdk.String("sqlserver")
+
+ command.SetFlagValues(cmd, "include-slaves", "true", "false")
+ command.SetCompletion(cmd, "udb-id", func() []string {
+ return getUDBIDList(ctx, nil, "sqlserver", *req.ProjectId, *req.Region, *req.Zone)
+ })
+
+ return cmd
+}
diff --git a/products/sqlserver/internal/sqlserver/poll.go b/products/sqlserver/internal/sqlserver/poll.go
new file mode 100644
index 0000000000..0866f52bb2
--- /dev/null
+++ b/products/sqlserver/internal/sqlserver/poll.go
@@ -0,0 +1,52 @@
+package sqlserver
+
+import (
+ "fmt"
+ "io"
+
+ "github.com/ucloud/ucloud-sdk-go/services/udb"
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+ "github.com/ucloud/ucloud-sdk-go/ucloud/request"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// stopUdbIns stops the instance and narrates progress to out (the caller passes
+// ctx.ProgressWriter(): stdout in table mode, stderr in json/yaml). Returns the
+// stop error so callers can decide whether to record a structured result.
+func stopUdbIns(ctx *cli.Context, req *udb.StopUDBInstanceRequest, async bool, out io.Writer) error {
+ client := cli.NewServiceClient(ctx, udb.NewClient)
+ _, err := client.StopUDBInstance(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return err
+ }
+ text := fmt.Sprintf("udb[%s] is stopping", *req.DBId)
+ if async {
+ fmt.Fprintln(out, text)
+ } else {
+ ctx.PollerTo(out, describeUdbByID(ctx)).Spoll(*req.DBId, text, []string{UDB_SHUTOFF, UDB_FAIL})
+ }
+ return nil
+}
+
+// describeUdbByID returns the poller's describe func, closing over ctx so it
+// can build an authed udb client.
+func describeUdbByID(ctx *cli.Context) func(udbID string, commonBase *request.CommonBase) (interface{}, error) {
+ return func(udbID string, commonBase *request.CommonBase) (interface{}, error) {
+ client := cli.NewServiceClient(ctx, udb.NewClient)
+ req := client.NewDescribeUDBInstanceRequest()
+ if commonBase != nil {
+ req.CommonBase = *commonBase
+ }
+ req.DBId = sdk.String(udbID)
+ resp, err := client.DescribeUDBInstance(req)
+ if err != nil {
+ return nil, err
+ }
+ if len(resp.DataSet) < 1 {
+ return nil, fmt.Errorf("udb[%s] may not exist", udbID)
+ }
+ return &resp.DataSet[0], nil
+ }
+}
diff --git a/products/sqlserver/internal/sqlserver/restart.go b/products/sqlserver/internal/sqlserver/restart.go
new file mode 100644
index 0000000000..a36923c932
--- /dev/null
+++ b/products/sqlserver/internal/sqlserver/restart.go
@@ -0,0 +1,61 @@
+package sqlserver
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-sdk-go/services/udb"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newRestart returns the "restart" command for SQL Server instances.
+func newRestart(ctx *cli.Context) *cobra.Command {
+ var async bool
+ var idNames []string
+ client := cli.NewServiceClient(ctx, udb.NewClient)
+ req := client.NewRestartUDBInstanceRequest()
+ cmd := &cobra.Command{
+ Use: "restart",
+ Short: "Restart SQL Server instances by udb-id",
+ Long: "Restart SQL Server instances by udb-id",
+ Run: func(c *cobra.Command, args []string) {
+ w := ctx.ProgressWriter()
+ results := []cli.OpResultRow{}
+ for _, idname := range idNames {
+ id := ctx.PickResourceID(idname)
+ req.DBId = &id
+ _, err := client.RestartUDBInstance(req)
+ if err != nil {
+ ctx.HandleError(err)
+ continue
+ }
+ if async {
+ fmt.Fprintf(w, "udb[%s] is restarting\n", idname)
+ } else {
+ text := fmt.Sprintf("udb[%s] is restarting", idname)
+ ctx.PollerTo(w, describeUdbByID(ctx)).Spoll(*req.DBId, text, []string{UDB_RUNNING, UDB_FAIL})
+ }
+ results = append(results, cli.OpResultRow{ResourceID: id, Action: "restart", Status: "Restarting"})
+ }
+ ctx.EmitResult(results...)
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ flags.StringSliceVar(&idNames, "udb-id", nil, "Required. Resource ID of UDB instances to restart")
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+ ctx.BindProjectID(cmd, req)
+ flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the long-running operation to finish.")
+
+ cmd.MarkFlagRequired("udb-id")
+ command.SetCompletion(cmd, "udb-id", func() []string {
+ return getUDBIDList(ctx, nil, "", *req.ProjectId, *req.Region, *req.Zone)
+ })
+ return cmd
+}
diff --git a/products/sqlserver/internal/sqlserver/rows.go b/products/sqlserver/internal/sqlserver/rows.go
new file mode 100644
index 0000000000..8c0010d7a8
--- /dev/null
+++ b/products/sqlserver/internal/sqlserver/rows.go
@@ -0,0 +1,26 @@
+package sqlserver
+
+// MachineTypeRow 计算规格表格行
+type MachineTypeRow struct {
+ ID string
+ Description string
+ Cpu int
+ Memory int
+ Group string
+}
+
+// UDBSQLServerRow 表格行
+type UDBSQLServerRow struct {
+ Name string
+ ResourceID string
+ Role string
+ Status string
+ Config string
+ Mode string
+ DiskType string
+ IP string
+ Group string
+ Zone string
+ VPC string
+ Subnet string
+}
diff --git a/products/sqlserver/internal/sqlserver/start.go b/products/sqlserver/internal/sqlserver/start.go
new file mode 100644
index 0000000000..47118abe4d
--- /dev/null
+++ b/products/sqlserver/internal/sqlserver/start.go
@@ -0,0 +1,62 @@
+package sqlserver
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-sdk-go/services/udb"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newStart returns the "start" command for SQL Server instances.
+func newStart(ctx *cli.Context) *cobra.Command {
+ var async bool
+ var idNames []string
+ client := cli.NewServiceClient(ctx, udb.NewClient)
+ req := client.NewStartUDBInstanceRequest()
+ cmd := &cobra.Command{
+ Use: "start",
+ Short: "Start SQL Server instances by udb-id",
+ Long: "Start SQL Server instances by udb-id",
+ Run: func(c *cobra.Command, args []string) {
+ w := ctx.ProgressWriter()
+ results := []cli.OpResultRow{}
+ for _, idname := range idNames {
+ id := ctx.PickResourceID(idname)
+ req.DBId = &id
+ _, err := client.StartUDBInstance(req)
+ if err != nil {
+ ctx.HandleError(err)
+ continue
+ }
+ if async {
+ fmt.Fprintf(w, "udb[%s] is starting\n", idname)
+ } else {
+ text := fmt.Sprintf("udb[%s] is starting", idname)
+ ctx.PollerTo(w, describeUdbByID(ctx)).Spoll(*req.DBId, text, []string{UDB_RUNNING, UDB_FAIL})
+ }
+ results = append(results, cli.OpResultRow{ResourceID: id, Action: "start", Status: "Starting"})
+ }
+ ctx.EmitResult(results...)
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ flags.StringSliceVar(&idNames, "udb-id", nil, "Required. Resource ID of UDB instances to start")
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+ ctx.BindProjectID(cmd, req)
+ flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the long-running operation to finish.")
+
+ cmd.MarkFlagRequired("udb-id")
+
+ command.SetCompletion(cmd, "udb-id", func() []string {
+ return getUDBIDList(ctx, []string{UDB_SHUTOFF}, "", *req.ProjectId, *req.Region, *req.Zone)
+ })
+ return cmd
+}
diff --git a/products/sqlserver/internal/sqlserver/status.go b/products/sqlserver/internal/sqlserver/status.go
new file mode 100644
index 0000000000..d29a669dbb
--- /dev/null
+++ b/products/sqlserver/internal/sqlserver/status.go
@@ -0,0 +1,11 @@
+package sqlserver
+
+// UDB-domain state constants, product-owned copies.
+const (
+ UDB_FAIL = "Fail"
+ UDB_RUNNING = "Running"
+ UDB_SHUTOFF = "Shutoff"
+ UDB_RECOVER_FAIL = "Recover fail"
+ UDB_UPGRADE_FAIL = "UpgradeFail"
+ UDB_TOBE_SWITCH = "WaitForSwitch"
+)
diff --git a/products/sqlserver/internal/sqlserver/stop.go b/products/sqlserver/internal/sqlserver/stop.go
new file mode 100644
index 0000000000..bd7071c3bd
--- /dev/null
+++ b/products/sqlserver/internal/sqlserver/stop.go
@@ -0,0 +1,56 @@
+package sqlserver
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-sdk-go/services/udb"
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newStop returns the "stop" command for SQL Server instances.
+func newStop(ctx *cli.Context) *cobra.Command {
+ var idNames []string
+ var async bool
+ client := cli.NewServiceClient(ctx, udb.NewClient)
+ req := client.NewStopUDBInstanceRequest()
+ cmd := &cobra.Command{
+ Use: "stop",
+ Short: "Stop SQL Server instances by udb-id",
+ Long: "Stop SQL Server instances by udb-id",
+ Run: func(c *cobra.Command, args []string) {
+ w := ctx.ProgressWriter()
+ results := []cli.OpResultRow{}
+ for _, idname := range idNames {
+ id := ctx.PickResourceID(idname)
+ req.DBId = sdk.String(id)
+ if err := stopUdbIns(ctx, req, async, w); err != nil {
+ continue
+ }
+ results = append(results, cli.OpResultRow{ResourceID: id, Action: "stop", Status: "Stopping"})
+ }
+ ctx.EmitResult(results...)
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ flags.StringSliceVar(&idNames, "udb-id", nil, "Required. Resource ID of UDB instances to stop")
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+ ctx.BindProjectID(cmd, req)
+ req.ForceToKill = flags.Bool("force", false, "Optional. Stop UDB instances by force or not")
+ flags.BoolVarP(&async, "async", "a", false, "Optional. Do not wait for the long-running operation to finish.")
+
+ cmd.MarkFlagRequired("udb-id")
+
+ command.SetFlagValues(cmd, "force", "true", "false")
+ command.SetCompletion(cmd, "udb-id", func() []string {
+ return getUDBIDList(ctx, []string{UDB_RUNNING}, "", *req.ProjectId, *req.Region, *req.Zone)
+ })
+
+ return cmd
+}
diff --git a/products/sqlserver/product.go b/products/sqlserver/product.go
new file mode 100644
index 0000000000..c782dbafa8
--- /dev/null
+++ b/products/sqlserver/product.go
@@ -0,0 +1,20 @@
+package sqlserver
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ internalsqlserver "github.com/ucloud/ucloud-cli/products/sqlserver/internal/sqlserver"
+)
+
+type product struct{}
+
+func New() cli.Product { return product{} }
+
+func (product) Metadata() cli.Metadata {
+ return cli.Metadata{Name: "sqlserver", Commands: []string{"sqlserver"}}
+}
+
+func (product) NewCommand(ctx *cli.Context) []*cobra.Command {
+ return []*cobra.Command{internalsqlserver.NewCommand(ctx)}
+}
diff --git a/products/sqlserver/product.yaml b/products/sqlserver/product.yaml
new file mode 100644
index 0000000000..1e313e67f1
--- /dev/null
+++ b/products/sqlserver/product.yaml
@@ -0,0 +1,6 @@
+name: sqlserver
+owners:
+ - xiaolaohu-hi
+commands:
+ - sqlserver
+enabled: true
diff --git a/products/sqlserver/testdata/cmdtree.golden b/products/sqlserver/testdata/cmdtree.golden
new file mode 100644
index 0000000000..37684005de
--- /dev/null
+++ b/products/sqlserver/testdata/cmdtree.golden
@@ -0,0 +1,60 @@
+ucloud sqlserver use=sqlserver short=Manipulate SQL Server on UCloud platform
+ucloud sqlserver db use=db short=Manage SQL Server instances
+ucloud sqlserver db create use=create short=Create SQL Server instance (Normal/single-node mode) on UCloud platform
+ flag=alarm-template-id short= default= required=
+ flag=async short= default=false required=
+ flag=backup-count short= default=7 required=
+ flag=backup-duration short= default=24 required=
+ flag=backup-time short= default=1 required=
+ flag=charge-type short= default=Month required=
+ flag=coupon-id short= default= required=
+ flag=cpu short= default=2 required=
+ flag=db-type short= default= required=true
+ flag=disk-size-gb short= default=50 required=
+ flag=label short= default=[] required=
+ flag=memory short= default=4000 required=
+ flag=name short= default= required=true
+ flag=password short= default= required=true
+ flag=port short= default=1433 required=
+ flag=project-id short= default= required=
+ flag=quantity short= default=1 required=
+ flag=region short= default= required=
+ flag=spec-class short= default=O required=
+ flag=storage-class short= default=CLOUD_RSSD required=
+ flag=subnet-id short= default= required=
+ flag=tag short= default= required=
+ flag=vpc-id short= default= required=
+ flag=zone short= default= required=
+ucloud sqlserver db delete use=delete short=Delete SQL Server instances by udb-id
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=udb-id short= default=[] required=true
+ flag=yes short=y default=false required=
+ flag=zone short= default= required=
+ucloud sqlserver db list use=list short=List SQL Server instances
+ flag=include-slaves short= default=false required=
+ flag=limit short= default=100 required=
+ flag=offset short= default=0 required=
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=udb-id short= default= required=
+ flag=zone short= default= required=
+ucloud sqlserver db restart use=restart short=Restart SQL Server instances by udb-id
+ flag=async short=a default=false required=
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=udb-id short= default=[] required=true
+ flag=zone short= default= required=
+ucloud sqlserver db start use=start short=Start SQL Server instances by udb-id
+ flag=async short=a default=false required=
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=udb-id short= default=[] required=true
+ flag=zone short= default= required=
+ucloud sqlserver db stop use=stop short=Stop SQL Server instances by udb-id
+ flag=async short=a default=false required=
+ flag=force short= default=false required=
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=udb-id short= default=[] required=true
+ flag=zone short= default= required=
diff --git a/products/sqlserver/testdata/completion.golden b/products/sqlserver/testdata/completion.golden
new file mode 100644
index 0000000000..3d1ea4d8dc
--- /dev/null
+++ b/products/sqlserver/testdata/completion.golden
@@ -0,0 +1,32 @@
+ucloud sqlserver db create charge-type static Dynamic,Month,Year
+ucloud sqlserver db create cpu static 16,2,32,4,64,8
+ucloud sqlserver db create db-type static sqlserver-2017,sqlserver-2019,sqlserver-2022
+ucloud sqlserver db create project-id dynamic
+ucloud sqlserver db create region dynamic
+ucloud sqlserver db create spec-class static O
+ucloud sqlserver db create storage-class static CLOUD_RSSD
+ucloud sqlserver db create subnet-id dynamic
+ucloud sqlserver db create vpc-id dynamic
+ucloud sqlserver db create zone dynamic
+ucloud sqlserver db delete project-id dynamic
+ucloud sqlserver db delete region dynamic
+ucloud sqlserver db delete udb-id dynamic
+ucloud sqlserver db delete zone dynamic
+ucloud sqlserver db list include-slaves static false,true
+ucloud sqlserver db list project-id dynamic
+ucloud sqlserver db list region dynamic
+ucloud sqlserver db list udb-id dynamic
+ucloud sqlserver db list zone dynamic
+ucloud sqlserver db restart project-id dynamic
+ucloud sqlserver db restart region dynamic
+ucloud sqlserver db restart udb-id dynamic
+ucloud sqlserver db restart zone dynamic
+ucloud sqlserver db start project-id dynamic
+ucloud sqlserver db start region dynamic
+ucloud sqlserver db start udb-id dynamic
+ucloud sqlserver db start zone dynamic
+ucloud sqlserver db stop force static false,true
+ucloud sqlserver db stop project-id dynamic
+ucloud sqlserver db stop region dynamic
+ucloud sqlserver db stop udb-id dynamic
+ucloud sqlserver db stop zone dynamic
diff --git a/products/uclickhouse/internal/clickhouse/list_test.go b/products/uclickhouse/internal/clickhouse/list_test.go
index 298e0dbb7b..e28cea1510 100644
--- a/products/uclickhouse/internal/clickhouse/list_test.go
+++ b/products/uclickhouse/internal/clickhouse/list_test.go
@@ -103,11 +103,13 @@ func TestClusterRowFormatsMillisecondCreateTimestamp(t *testing.T) {
ExpireTimestamp: 1783926000,
})
- if row.CreateTime != "2026-07-11" {
- t.Fatalf("CreateTime = %q, want 2026-07-11", row.CreateTime)
+ // Expected values go through formatUnixDate with second-precision inputs so
+ // the assertions hold in any local timezone.
+ if want := formatUnixDate(1783700833); row.CreateTime != want {
+ t.Fatalf("CreateTime = %q, want %q", row.CreateTime, want)
}
- if row.ExpireTime != "2026-07-13" {
- t.Fatalf("ExpireTime = %q, want 2026-07-13", row.ExpireTime)
+ if want := formatUnixDate(1783926000); row.ExpireTime != want {
+ t.Fatalf("ExpireTime = %q, want %q", row.ExpireTime, want)
}
}
diff --git a/products/udac/internal/udac/cmd.go b/products/udac/internal/udac/cmd.go
new file mode 100644
index 0000000000..ee148284f5
--- /dev/null
+++ b/products/udac/internal/udac/cmd.go
@@ -0,0 +1,23 @@
+package udac
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// NewCommand builds the `udac` root command and mounts the subcommands.
+func NewCommand(ctx *cli.Context) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "udac",
+ Short: "Manage Database Autonomous Center (UDAC) instances",
+ Long: "Import, export, and list database instances in the Database Autonomous Center (UDAC).",
+ Args: cobra.NoArgs,
+ }
+
+ cmd.AddCommand(newImport(ctx))
+ cmd.AddCommand(newExport(ctx))
+ cmd.AddCommand(newList(ctx))
+
+ return cmd
+}
diff --git a/products/udac/internal/udac/export.go b/products/udac/internal/udac/export.go
new file mode 100644
index 0000000000..6179df0de4
--- /dev/null
+++ b/products/udac/internal/udac/export.go
@@ -0,0 +1,204 @@
+package udac
+
+import (
+ "fmt"
+ "strings"
+
+ "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"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// actionForExportType 根据实例类型返回从 UDAC 移除对应的 Action 名。
+func actionForExportType(instanceType string) (string, error) {
+ switch instanceType {
+ case "mysql":
+ return "RemoveUDACMySQLInstances", nil
+ case "mongodb":
+ return "RemoveUDACUMongoDBClusters", nil
+ default:
+ return "", fmt.Errorf("unsupported instance type: %s, supported: %v", instanceType, SupportedTypes)
+ }
+}
+
+// exportInstances 调用 UDAC 移除 MySQL API,所有实例共用同一个 zone。
+// 走 SDK 默认 FormEncoder(扁平化格式),MySQL 后端接受。
+func exportInstances(ctx *cli.Context, action, projectID, zone string, instanceIDs []string) (map[string]interface{}, error) {
+ instanceInfoSet := make([]interface{}, 0, len(instanceIDs))
+ for _, id := range instanceIDs {
+ instanceInfoSet = append(instanceInfoSet, map[string]interface{}{
+ "ID": id,
+ "Zone": zone,
+ })
+ }
+
+ params := map[string]interface{}{
+ "Action": action,
+ "ProjectId": projectID,
+ "InstanceInfoSet": instanceInfoSet,
+ }
+
+ client := cli.NewServiceClient(ctx, uaccount.NewClient)
+ req := client.NewGenericRequest()
+ if err := req.SetPayload(params); 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 exportMongoDBClusters(ctx *cli.Context, action, projectID, region string, clusterIDs []string) (map[string]interface{}, error) {
+ mongoDBClusterSet := make([]interface{}, 0, len(clusterIDs))
+ for _, id := range clusterIDs {
+ mongoDBClusterSet = append(mongoDBClusterSet, map[string]interface{}{
+ "ClusterId": id,
+ "Region": region,
+ })
+ }
+
+ params := map[string]interface{}{
+ "Action": action,
+ "ProjectId": projectID,
+ "Region": region,
+ "MongoDBClusterSet": mongoDBClusterSet,
+ }
+
+ client := cli.NewServiceClient(ctx, uaccount.NewClient)
+ req := client.NewGenericRequest()
+ if err := req.SetPayload(params); err != nil {
+ return nil, fmt.Errorf("set payload: %w", err)
+ }
+ req.SetEncoder(request.NewJSONEncoder(client.GetConfig(), client.GetCredential()))
+ resp, err := client.GenericInvoke(req)
+ if err != nil {
+ return nil, err
+ }
+ return resp.GetPayload(), nil
+}
+
+// newExport implements `ucloud udac export`
+// MySQL 必填:--udb-id, --type=mysql, --zone, --project-id
+// MongoDB 必填:--udb-id, --type=mongodb, --region, --project-id
+// export 是从 UDAC 移除实例(不是导出数据),语义同 import 的反向操作。
+func newExport(ctx *cli.Context) *cobra.Command {
+ var instanceIDs []string
+ var instanceType string
+ var common request.CommonBase
+
+ cmd := &cobra.Command{
+ Use: "export",
+ Short: "Export database instances from UDAC",
+ Long: `Export existing database instances from the Database Autonomous Center (UDAC).
+
+You must specify the instance type via --type. Supported types: mysql, mongodb.
+
+Required flags:
+ mysql: --udb-id, --type=mysql, --project-id (--zone falls back to config default)
+ mongodb: --udb-id, --type=mongodb, --project-id (--region falls back to config default)
+
+--project-id, --region, --zone fall back to config defaults (default-project-id,
+default-region, default-zone).
+
+This is a synchronous operation: the command returns after the export API responds.`,
+ Run: func(c *cobra.Command, args []string) {
+ // 1. 开头单独校验 udb-id 和 type(早失败)
+ if len(instanceIDs) == 0 {
+ ctx.HandleError(fmt.Errorf("required flag(s) not set: %s", resourceIDFlag))
+ return
+ }
+ if instanceType == "" {
+ ctx.HandleError(fmt.Errorf("required flag(s) not set: type"))
+ return
+ }
+
+ // 2. 类型校验 + Action 选择
+ action, err := actionForExportType(instanceType)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+
+ // 3. 从 common 取绑定值
+ projectID := common.GetProjectId()
+ region := common.GetRegion()
+ zone := common.GetZone()
+
+ // 4. 类型相关必填校验(配置默认值兜底,空时报错)
+ var missing []string
+ if projectID == "" {
+ missing = append(missing, "project-id")
+ }
+ if instanceType == "mysql" && zone == "" {
+ missing = append(missing, "zone")
+ }
+ if instanceType == "mongodb" && region == "" {
+ missing = append(missing, "region")
+ }
+ if len(missing) > 0 {
+ ctx.HandleError(fmt.Errorf("required flag(s) not set: %s", strings.Join(missing, ", ")))
+ return
+ }
+
+ // 5. 归一化 instance ID(支持 "udb-xxx/instance-name" 格式)
+ for i, id := range instanceIDs {
+ instanceIDs[i] = ctx.PickResourceID(id)
+ }
+
+ // 6. 按类型分流调用 API
+ var payload map[string]interface{}
+ if instanceType == "mongodb" {
+ payload, err = exportMongoDBClusters(ctx, action, projectID, region, instanceIDs)
+ } else {
+ payload, err = exportInstances(ctx, action, projectID, zone, instanceIDs)
+ }
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ if len(payload) == 0 {
+ ctx.HandleError(fmt.Errorf("empty response from server"))
+ return
+ }
+
+ // 7. 输出
+ w := ctx.ProgressWriter()
+ for _, id := range instanceIDs {
+ if instanceType == "mongodb" {
+ fmt.Fprintf(w, "%s[%s] exported successfully (type: %s, region: %s)\n", productName, id, instanceType, region)
+ } else {
+ fmt.Fprintf(w, "%s[%s] exported successfully (type: %s, zone: %s)\n", productName, id, instanceType, zone)
+ }
+ }
+ results := make([]cli.OpResultRow, 0, len(instanceIDs))
+ for _, id := range instanceIDs {
+ results = append(results, cli.OpResultRow{
+ ResourceID: id,
+ Action: "export",
+ Status: "Exported",
+ })
+ }
+ ctx.EmitResult(results...)
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ flags.StringSliceVar(&instanceIDs, resourceIDFlag, nil, "Required. Instance ID(s) to export. Repeatable.")
+ flags.StringVar(&instanceType, typeFlag, "", "Required. Instance type: mysql, mongodb.")
+
+ // 公共参数绑定:project-id/region/zone 都用配置默认值兜底
+ ctx.BindRegion(cmd, &common)
+ ctx.BindZone(cmd, &common)
+ ctx.BindProjectID(cmd, &common)
+
+ command.SetFlagValues(cmd, typeFlag, SupportedTypes...)
+
+ return cmd
+}
diff --git a/products/udac/internal/udac/import.go b/products/udac/internal/udac/import.go
new file mode 100644
index 0000000000..4076fa6bef
--- /dev/null
+++ b/products/udac/internal/udac/import.go
@@ -0,0 +1,203 @@
+package udac
+
+import (
+ "fmt"
+ "strings"
+
+ "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"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// actionForImportType 根据实例类型返回导入对应的 UDAC Action 名。
+func actionForImportType(instanceType string) (string, error) {
+ switch instanceType {
+ case "mysql":
+ return "AddUDACMySQLInstances", nil
+ case "mongodb":
+ return "AddUDACUMongoDBClusters", nil
+ default:
+ return "", fmt.Errorf("unsupported instance type: %s, supported: %v", instanceType, SupportedTypes)
+ }
+}
+
+// importInstances 调用 UDAC 导入 MySQL API,所有实例共用同一个 zone。
+// 走 SDK 默认 FormEncoder(扁平化格式),MySQL 后端接受。
+func importInstances(ctx *cli.Context, action, projectID, zone string, instanceIDs []string) (map[string]interface{}, error) {
+ instanceInfoSet := make([]interface{}, 0, len(instanceIDs))
+ for _, id := range instanceIDs {
+ instanceInfoSet = append(instanceInfoSet, map[string]interface{}{
+ "ID": id,
+ "Zone": zone,
+ })
+ }
+
+ params := map[string]interface{}{
+ "Action": action,
+ "ProjectId": projectID,
+ "InstanceInfoSet": instanceInfoSet,
+ }
+
+ client := cli.NewServiceClient(ctx, uaccount.NewClient)
+ req := client.NewGenericRequest()
+ if err := req.SetPayload(params); 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 importMongoDBClusters(ctx *cli.Context, action, projectID, region string, clusterIDs []string) (map[string]interface{}, error) {
+ mongoDBClusterSet := make([]interface{}, 0, len(clusterIDs))
+ for _, id := range clusterIDs {
+ mongoDBClusterSet = append(mongoDBClusterSet, map[string]interface{}{
+ "ClusterId": id,
+ "Region": region,
+ })
+ }
+
+ params := map[string]interface{}{
+ "Action": action,
+ "ProjectId": projectID,
+ "Region": region,
+ "MongoDBClusterSet": mongoDBClusterSet,
+ }
+
+ client := cli.NewServiceClient(ctx, uaccount.NewClient)
+ req := client.NewGenericRequest()
+ if err := req.SetPayload(params); err != nil {
+ return nil, fmt.Errorf("set payload: %w", err)
+ }
+ req.SetEncoder(request.NewJSONEncoder(client.GetConfig(), client.GetCredential()))
+ resp, err := client.GenericInvoke(req)
+ if err != nil {
+ return nil, err
+ }
+ return resp.GetPayload(), nil
+}
+
+// newImport implements `ucloud udac import`
+// MySQL 必填:--udb-id, --type=mysql, --zone, --project-id
+// MongoDB 必填:--udb-id, --type=mongodb, --region, --project-id
+func newImport(ctx *cli.Context) *cobra.Command {
+ var instanceIDs []string
+ var instanceType string
+ var common request.CommonBase
+
+ cmd := &cobra.Command{
+ Use: "import",
+ Short: "Import database instances into UDAC",
+ Long: `Import existing database instances into the Database Autonomous Center (UDAC) for autonomous management.
+
+You must specify the instance type via --type. Supported types: mysql, mongodb.
+
+Required flags:
+ mysql: --udb-id, --type=mysql, --project-id (--zone falls back to config default)
+ mongodb: --udb-id, --type=mongodb, --project-id (--region falls back to config default)
+
+--project-id, --region, --zone fall back to config defaults (default-project-id,
+default-region, default-zone).
+
+This is a synchronous operation: the command returns after the import API responds.`,
+ Run: func(c *cobra.Command, args []string) {
+ // 1. 开头单独校验 udb-id 和 type(早失败)
+ if len(instanceIDs) == 0 {
+ ctx.HandleError(fmt.Errorf("required flag(s) not set: %s", resourceIDFlag))
+ return
+ }
+ if instanceType == "" {
+ ctx.HandleError(fmt.Errorf("required flag(s) not set: type"))
+ return
+ }
+
+ // 2. 类型校验 + Action 选择
+ action, err := actionForImportType(instanceType)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+
+ // 3. 从 common 取绑定值
+ projectID := common.GetProjectId()
+ region := common.GetRegion()
+ zone := common.GetZone()
+
+ // 4. 类型相关必填校验(配置默认值兜底,空时报错)
+ var missing []string
+ if projectID == "" {
+ missing = append(missing, "project-id")
+ }
+ if instanceType == "mysql" && zone == "" {
+ missing = append(missing, "zone")
+ }
+ if instanceType == "mongodb" && region == "" {
+ missing = append(missing, "region")
+ }
+ if len(missing) > 0 {
+ ctx.HandleError(fmt.Errorf("required flag(s) not set: %s", strings.Join(missing, ", ")))
+ return
+ }
+
+ // 5. 归一化 instance ID(支持 "udb-xxx/instance-name" 格式)
+ for i, id := range instanceIDs {
+ instanceIDs[i] = ctx.PickResourceID(id)
+ }
+
+ // 6. 按类型分流调用 API
+ var payload map[string]interface{}
+ if instanceType == "mongodb" {
+ payload, err = importMongoDBClusters(ctx, action, projectID, region, instanceIDs)
+ } else {
+ payload, err = importInstances(ctx, action, projectID, zone, instanceIDs)
+ }
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ if len(payload) == 0 {
+ ctx.HandleError(fmt.Errorf("empty response from server"))
+ return
+ }
+
+ // 7. 输出
+ w := ctx.ProgressWriter()
+ for _, id := range instanceIDs {
+ if instanceType == "mongodb" {
+ fmt.Fprintf(w, "%s[%s] imported successfully (type: %s, region: %s)\n", productName, id, instanceType, region)
+ } else {
+ fmt.Fprintf(w, "%s[%s] imported successfully (type: %s, zone: %s)\n", productName, id, instanceType, zone)
+ }
+ }
+ results := make([]cli.OpResultRow, 0, len(instanceIDs))
+ for _, id := range instanceIDs {
+ results = append(results, cli.OpResultRow{
+ ResourceID: id,
+ Action: "import",
+ Status: "Imported",
+ })
+ }
+ ctx.EmitResult(results...)
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ flags.StringSliceVar(&instanceIDs, resourceIDFlag, nil, "Required. Instance ID(s) to import. Repeatable.")
+ flags.StringVar(&instanceType, typeFlag, "", "Required. Instance type: mysql, mongodb.")
+
+ // 公共参数绑定:project-id/region/zone 都用配置默认值兜底
+ ctx.BindRegion(cmd, &common)
+ ctx.BindZone(cmd, &common)
+ ctx.BindProjectID(cmd, &common)
+
+ command.SetFlagValues(cmd, typeFlag, SupportedTypes...)
+
+ return cmd
+}
diff --git a/products/udac/internal/udac/list.go b/products/udac/internal/udac/list.go
new file mode 100644
index 0000000000..1274b8cd49
--- /dev/null
+++ b/products/udac/internal/udac/list.go
@@ -0,0 +1,299 @@
+package udac
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "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"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// actionForType 根据实例类型返回对应的 UDAC Action 名。
+func actionForType(instanceType string) (string, error) {
+ switch instanceType {
+ case "mysql":
+ return "ListUDACMySQLInstance", nil
+ case "mongodb":
+ return "ListUDACUMongoDBClusters", nil
+ default:
+ return "", fmt.Errorf("unsupported instance type: %s, supported: %v", instanceType, SupportedTypes)
+ }
+}
+
+// regionFromZone 从 zone 推导 region。
+// UCloud zone 命名规则:{region}-{suffix},例如 cn-bj2-02 → cn-bj2、hk-02 → hk。
+func regionFromZone(zone string) string {
+ if i := strings.LastIndex(zone, "-"); i > 0 {
+ return zone[:i]
+ }
+ return zone
+}
+
+// newInstanceRow 从 API 返回的 map 构造一行展示数据。
+// overrideRegion 非空时用用户传入值,否则优先读 API 返回的 Region,再兜底从 Zone 推导。
+// MongoDB 响应字段:ClusterId/Name/Region/State/JoinTime(无 Zone)。
+// MySQL 响应字段:ID 或 InstanceId/Name/Zone/State/Status/JoinTime(无 Region)。
+func newInstanceRow(m map[string]interface{}, instanceType, overrideRegion string) (importedInstanceRow, bool) {
+ id := firstString(m, "ClusterId", "InstanceId", "ID")
+ if id == "" {
+ return importedInstanceRow{}, false
+ }
+ zoneVal := getString(m, "Zone")
+ regionVal := getString(m, "Region")
+ if regionVal == "" {
+ regionVal = overrideRegion
+ }
+ if regionVal == "" {
+ regionVal = regionFromZone(zoneVal)
+ }
+ status := firstString(m, "State", "Status")
+ joinTime := getInt64(m, "JoinTime")
+ importTime := ""
+ if joinTime > 0 {
+ importTime = time.Unix(joinTime, 0).Format(time.RFC3339)
+ }
+ return importedInstanceRow{
+ ResourceID: id,
+ InstanceID: id,
+ Name: getString(m, "Name"),
+ Type: instanceType,
+ Status: status,
+ ImportTime: importTime,
+ Region: regionVal,
+ Zone: zoneVal,
+ }, true
+}
+
+// firstString 按顺序尝试多个 key,返回第一个非空字符串。
+func firstString(m map[string]interface{}, keys ...string) string {
+ for _, k := range keys {
+ if v := getString(m, k); v != "" {
+ return v
+ }
+ }
+ return ""
+}
+
+// fetchUDACInstances 调用 UDAC list API,返回账号下全部实例。
+func fetchUDACInstances(ctx *cli.Context, action, projectID string) ([]map[string]interface{}, error) {
+ params := map[string]interface{}{
+ "Action": action,
+ "ProjectId": projectID,
+ }
+
+ client := cli.NewServiceClient(ctx, uaccount.NewClient)
+ req := client.NewGenericRequest()
+ if err := req.SetPayload(params); err != nil {
+ return nil, fmt.Errorf("set payload: %w", err)
+ }
+ resp, err := client.GenericInvoke(req)
+ if err != nil {
+ return nil, err
+ }
+ payload := resp.GetPayload()
+
+ var raw []interface{}
+ for _, key := range []string{"InstanceInfoSet", "Instances", "DataSet"} {
+ if val, ok := payload[key].([]interface{}); ok {
+ raw = val
+ break
+ }
+ }
+ out := make([]map[string]interface{}, 0, len(raw))
+ for _, item := range raw {
+ if m, ok := item.(map[string]interface{}); ok {
+ out = append(out, m)
+ }
+ }
+ return out, nil
+}
+
+// newList implements `ucloud udac list`
+// --project-id 必填(配置默认值兜底);其他可选。
+func newList(ctx *cli.Context) *cobra.Command {
+ var instanceID, instanceType, statusFilter string
+ var allRegions bool
+ var common request.CommonBase
+
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List imported database instances in UDAC",
+ Long: `List database instances that have been imported into the Database Autonomous Center (UDAC).
+
+Required flag: --project-id (falls back to default-project-id from config if set).
+
+Optional filters:
+ --type Instance type: mysql, mongodb
+ --region Filter by region. Defaults to config's default-region.
+ --zone Filter by zone. If omitted, list across all zones in the region.
+ --udb-id List only the specified instance.
+ --status Filter by status (e.g., Running, Failed).
+ --all-regions List instances across all regions (ignore --region and config default).
+
+When both --region and --zone are specified, the zone must belong to the region.`,
+ Run: func(c *cobra.Command, args []string) {
+ // 1. 必填校验:project-id(配置默认值兜底)
+ projectID := common.GetProjectId()
+ if projectID == "" {
+ ctx.HandleError(fmt.Errorf("required flag(s) not set: project-id"))
+ return
+ }
+
+ // 2. 类型校验 + Action 选择
+ action, err := actionForType(instanceType)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+
+ // 3. 确定 region 过滤值:--all-regions 优先级最高
+ if allRegions && c.Flags().Changed("region") {
+ ctx.HandleError(fmt.Errorf("--all-regions and --region are mutually exclusive"))
+ return
+ }
+ region := common.GetRegion()
+ zone := common.GetZone()
+ if allRegions {
+ region = ""
+ }
+
+ // 4. region/zone 一致性校验
+ if region != "" && zone != "" && !strings.HasPrefix(zone, region+"-") {
+ ctx.HandleError(fmt.Errorf("zone %s does not belong to region %s", zone, region))
+ return
+ }
+
+ // mongodb 响应无 Zone 字段,--zone 过滤会静默清空
+ if instanceType == "mongodb" && zone != "" {
+ ctx.HandleError(fmt.Errorf("--zone is not supported for mongodb, use --region instead"))
+ return
+ }
+
+ // 5. 拉取全部实例(API 只带 ProjectId,不传 Region/Zone)
+ instances, err := fetchUDACInstances(ctx, action, projectID)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+
+ // 6. 客户端过滤
+ instanceID = ctx.PickResourceID(instanceID)
+ rows := make([]importedInstanceRow, 0, len(instances))
+ for _, m := range instances {
+ if instanceID != "" && firstString(m, "ClusterId", "InstanceId", "ID") != instanceID {
+ continue
+ }
+ zoneVal := getString(m, "Zone")
+ if zone != "" && zoneVal != zone {
+ continue
+ }
+ if region != "" {
+ // 优先用 API 返回的 Region,其次从 Zone 推导
+ instanceRegion := getString(m, "Region")
+ if instanceRegion == "" {
+ instanceRegion = regionFromZone(zoneVal)
+ }
+ if instanceRegion != region {
+ continue
+ }
+ }
+ if statusFilter != "" && firstString(m, "State", "Status") != statusFilter {
+ continue
+ }
+ if row, ok := newInstanceRow(m, instanceType, region); ok {
+ rows = append(rows, row)
+ }
+ }
+
+ // 7. 输出(空列表正常输出空表,不报错)
+ ctx.PrintList(rows)
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ flags.StringVar(&instanceID, resourceIDFlag, "", "Optional. List only the specified instance.")
+ flags.StringVar(&instanceType, typeFlag, "mysql", "Optional. Instance type: mysql, mongodb.")
+ flags.StringVar(&statusFilter, "status", "", "Optional. Filter by status (e.g., Running, Failed).")
+ flags.BoolVar(&allRegions, "all-regions", false, "Optional. List instances across all regions (ignore --region and config default).")
+
+ // 公共参数绑定:region/zone/project-id 都用配置默认值兜底
+ ctx.BindRegion(cmd, &common)
+ ctx.BindZoneEmpty(cmd, &common)
+ ctx.BindProjectID(cmd, &common)
+
+ command.SetFlagValues(cmd, typeFlag, SupportedTypes...)
+ command.SetCompletion(cmd, resourceIDFlag, func() []string {
+ return listImportedInstanceIDs(ctx, instanceType, common.GetRegion(), common.GetZone(), common.GetProjectId())
+ })
+
+ return cmd
+}
+
+func listImportedInstanceIDs(ctx *cli.Context, instanceType, region, zone, projectID string) []string {
+ action, err := actionForType(instanceType)
+ if err != nil {
+ return nil
+ }
+ if instanceType == "mongodb" && zone != "" {
+ return nil
+ }
+ instances, err := fetchUDACInstances(ctx, action, projectID)
+ if err != nil {
+ return nil
+ }
+ out := make([]string, 0, len(instances))
+ for _, m := range instances {
+ zoneVal := getString(m, "Zone")
+ if zone != "" && zoneVal != zone {
+ continue
+ }
+ if region != "" {
+ instanceRegion := getString(m, "Region")
+ if instanceRegion == "" {
+ instanceRegion = regionFromZone(zoneVal)
+ }
+ if instanceRegion != region {
+ continue
+ }
+ }
+ id := firstString(m, "ClusterId", "InstanceId", "ID")
+ if id == "" {
+ continue
+ }
+ if name := getString(m, "Name"); name != "" {
+ out = append(out, id+"/"+name)
+ } else {
+ out = append(out, id)
+ }
+ }
+ return out
+}
+
+// getString 从 map 中安全获取字符串值
+func getString(m map[string]interface{}, key string) string {
+ if val, ok := m[key].(string); ok {
+ return val
+ }
+ return ""
+}
+
+// getInt64 从 map 中安全获取 int64 值(兼容 float64/int64/int)
+func getInt64(m map[string]interface{}, key string) int64 {
+ if val, ok := m[key].(float64); ok {
+ return int64(val)
+ }
+ if val, ok := m[key].(int64); ok {
+ return int64(val)
+ }
+ if val, ok := m[key].(int); ok {
+ return int64(val)
+ }
+ return 0
+}
diff --git a/products/udac/internal/udac/rows.go b/products/udac/internal/udac/rows.go
new file mode 100644
index 0000000000..2c605b76a2
--- /dev/null
+++ b/products/udac/internal/udac/rows.go
@@ -0,0 +1,13 @@
+package udac
+
+// importedInstanceRow is the row format for list output
+type importedInstanceRow struct {
+ ResourceID string `json:"resourceId" header:"ResourceID"`
+ InstanceID string `json:"instanceId" header:"InstanceID"`
+ Name string `json:"name" header:"Name"`
+ Type string `json:"type" header:"Type"`
+ Status string `json:"status" header:"Status"`
+ ImportTime string `json:"importTime" header:"ImportTime"`
+ Region string `json:"region" header:"Region"`
+ Zone string `json:"zone" header:"Zone"`
+}
diff --git a/products/udac/internal/udac/status.go b/products/udac/internal/udac/status.go
new file mode 100644
index 0000000000..e2a54d62aa
--- /dev/null
+++ b/products/udac/internal/udac/status.go
@@ -0,0 +1,10 @@
+package udac
+
+const (
+ productName = "udac"
+ resourceIDFlag = "udb-id" // list 命令的 --udb-id flag
+ typeFlag = "type" // 实例类型 flag
+)
+
+// SupportedTypes 支持的实例类型列表
+var SupportedTypes = []string{"mysql", "mongodb"}
diff --git a/products/udac/product.go b/products/udac/product.go
new file mode 100644
index 0000000000..b051f8e869
--- /dev/null
+++ b/products/udac/product.go
@@ -0,0 +1,20 @@
+package udac
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ internaludac "github.com/ucloud/ucloud-cli/products/udac/internal/udac"
+)
+
+type product struct{}
+
+func New() cli.Product { return product{} }
+
+func (product) Metadata() cli.Metadata {
+ return cli.Metadata{Name: "udac", Commands: []string{"udac"}}
+}
+
+func (product) NewCommand(ctx *cli.Context) []*cobra.Command {
+ return []*cobra.Command{internaludac.NewCommand(ctx)}
+}
diff --git a/products/udac/product.yaml b/products/udac/product.yaml
new file mode 100644
index 0000000000..874bd471f8
--- /dev/null
+++ b/products/udac/product.yaml
@@ -0,0 +1,6 @@
+name: udac
+owners:
+ - nzr1122
+commands:
+ - udac
+enabled: true
diff --git a/products/udac/testdata/cmdtree.golden b/products/udac/testdata/cmdtree.golden
new file mode 100644
index 0000000000..1b03224684
--- /dev/null
+++ b/products/udac/testdata/cmdtree.golden
@@ -0,0 +1,21 @@
+ucloud udac use=udac short=Manage Database Autonomous Center (UDAC) instances
+ucloud udac export use=export short=Export database instances from UDAC
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=type short= default= required=
+ flag=udb-id short= default=[] required=
+ flag=zone short= default= required=
+ucloud udac import use=import short=Import database instances into UDAC
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=type short= default= required=
+ flag=udb-id short= default=[] required=
+ flag=zone short= default= required=
+ucloud udac list use=list short=List imported database instances in UDAC
+ flag=all-regions short= default=false required=
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=status short= default= required=
+ flag=type short= default=mysql required=
+ flag=udb-id short= default= required=
+ flag=zone short= default= required=
diff --git a/products/udac/testdata/completion.golden b/products/udac/testdata/completion.golden
new file mode 100644
index 0000000000..26ee94763f
--- /dev/null
+++ b/products/udac/testdata/completion.golden
@@ -0,0 +1,13 @@
+ucloud udac export project-id dynamic
+ucloud udac export region dynamic
+ucloud udac export type static mongodb,mysql
+ucloud udac export zone dynamic
+ucloud udac import project-id dynamic
+ucloud udac import region dynamic
+ucloud udac import type static mongodb,mysql
+ucloud udac import zone dynamic
+ucloud udac list project-id dynamic
+ucloud udac list region dynamic
+ucloud udac list type static mongodb,mysql
+ucloud udac list udb-id dynamic
+ucloud udac list zone dynamic
diff --git a/products/udns/internal/udns/associate_vpc.go b/products/udns/internal/udns/associate_vpc.go
new file mode 100644
index 0000000000..58dd862f26
--- /dev/null
+++ b/products/udns/internal/udns/associate_vpc.go
@@ -0,0 +1,42 @@
+package udns
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ udnssdk "github.com/ucloud/ucloud-sdk-go/services/udns"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+func newAssociateVPCCommand(ctx *cli.Context) *cobra.Command {
+ client := cli.NewServiceClient(ctx, udnssdk.NewClient)
+ req := client.NewAssociateUDNSZoneVPCRequest()
+ cmd := &cobra.Command{
+ Use: "associate-vpc",
+ Short: "Associate a UDNS zone with a VPC",
+ Long: "Associate a UDNS zone with a VPC",
+ Run: func(cmd *cobra.Command, args []string) {
+ zoneID := ctx.PickResourceID(*req.DNSZoneId)
+ req.DNSZoneId = &zoneID
+ _, err := client.AssociateUDNSZoneVPC(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ fmt.Fprintf(ctx.ProgressWriter(), "zone[%s] associated with vpc[%s]\n", zoneID, *req.VPCId)
+ ctx.EmitResult(cli.OpResultRow{ResourceID: zoneID, Action: "associate-vpc", Status: "Associated"})
+ },
+ }
+ flags := cmd.Flags()
+ flags.SortFlags = false
+ req.DNSZoneId = flags.String("zone-id", "", "Required. Zone resource ID")
+ req.VPCId = flags.String("vpc-id", "", "Required. VPC resource ID")
+ req.VPCProjectId = flags.String("vpc-project-id", "", "Required. Project ID that owns the VPC")
+ ctx.BindRegion(cmd, req)
+ cmd.MarkFlagRequired("zone-id")
+ cmd.MarkFlagRequired("vpc-id")
+ cmd.MarkFlagRequired("vpc-project-id")
+ return cmd
+}
diff --git a/products/udns/internal/udns/cmd.go b/products/udns/internal/udns/cmd.go
new file mode 100644
index 0000000000..551d60c4ee
--- /dev/null
+++ b/products/udns/internal/udns/cmd.go
@@ -0,0 +1,22 @@
+package udns
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+func NewCommand(ctx *cli.Context) *cobra.Command {
+ root := &cobra.Command{
+ Use: "udns",
+ Short: "List and manipulate ucloud private dns(udns) instance and record",
+ Long: "List and manipulate ucloud private dns(udns) instance and record",
+ }
+ root.AddCommand(newCreateCommand(ctx))
+ root.AddCommand(newListCommand(ctx))
+ root.AddCommand(newModifyCommand(ctx))
+ root.AddCommand(newAssociateVPCCommand(ctx))
+ root.AddCommand(newDisassociateVPCCommand(ctx))
+ root.AddCommand(newRecordCommand(ctx))
+ return root
+}
diff --git a/products/udns/internal/udns/create.go b/products/udns/internal/udns/create.go
new file mode 100644
index 0000000000..781599be6c
--- /dev/null
+++ b/products/udns/internal/udns/create.go
@@ -0,0 +1,48 @@
+package udns
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ udnssdk "github.com/ucloud/ucloud-sdk-go/services/udns"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+func newCreateCommand(ctx *cli.Context) *cobra.Command {
+ client := cli.NewServiceClient(ctx, udnssdk.NewClient)
+ req := client.NewCreateUDNSZoneRequest()
+ cmd := &cobra.Command{
+ Use: "create",
+ Short: "Create a UDNS private DNS zone",
+ Long: "Create a UDNS private DNS zone",
+ Run: func(cmd *cobra.Command, args []string) {
+ resp, err := client.CreateUDNSZone(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ fmt.Fprintf(ctx.ProgressWriter(), "zone[%s] created\n", resp.DNSZoneId)
+ ctx.EmitResult(cli.OpResultRow{ResourceID: resp.DNSZoneId, Action: "create", Status: "Created"})
+ },
+ }
+ flags := cmd.Flags()
+ flags.SortFlags = false
+ req.DNSZoneName = flags.String("zone-name", "", "Required. Domain name string")
+ req.Type = flags.String("type", "", "Required. Zone type: private or public")
+ 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.IsRecursionEnabled = flags.String("recursion", "", "Optional. enable or disable")
+ req.Tag = flags.String("tag", "", "Optional. Business group")
+ req.Remark = flags.String("remark", "", "Optional. Remark")
+ ctx.BindRegion(cmd, req)
+ ctx.BindProjectID(cmd, req)
+ command.SetFlagValues(cmd, "type", "private", "public")
+ command.SetFlagValues(cmd, "charge-type", "Month", "Year", "Dynamic")
+ command.SetFlagValues(cmd, "recursion", "enable", "disable")
+ cmd.MarkFlagRequired("zone-name")
+ cmd.MarkFlagRequired("type")
+ return cmd
+}
diff --git a/products/udns/internal/udns/disassociate_vpc.go b/products/udns/internal/udns/disassociate_vpc.go
new file mode 100644
index 0000000000..2c6773091d
--- /dev/null
+++ b/products/udns/internal/udns/disassociate_vpc.go
@@ -0,0 +1,42 @@
+package udns
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ udnssdk "github.com/ucloud/ucloud-sdk-go/services/udns"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+func newDisassociateVPCCommand(ctx *cli.Context) *cobra.Command {
+ client := cli.NewServiceClient(ctx, udnssdk.NewClient)
+ req := client.NewDisassociateUDNSZoneVPCRequest()
+ cmd := &cobra.Command{
+ Use: "disassociate-vpc",
+ Short: "Disassociate a UDNS zone from a VPC",
+ Long: "Disassociate a UDNS zone from a VPC",
+ Run: func(cmd *cobra.Command, args []string) {
+ zoneID := ctx.PickResourceID(*req.DNSZoneId)
+ req.DNSZoneId = &zoneID
+ _, err := client.DisassociateUDNSZoneVPC(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ fmt.Fprintf(ctx.ProgressWriter(), "zone[%s] disassociated from vpc[%s]\n", zoneID, *req.VPCId)
+ ctx.EmitResult(cli.OpResultRow{ResourceID: zoneID, Action: "disassociate-vpc", Status: "Disassociated"})
+ },
+ }
+ flags := cmd.Flags()
+ flags.SortFlags = false
+ req.DNSZoneId = flags.String("zone-id", "", "Required. Zone resource ID")
+ req.VPCId = flags.String("vpc-id", "", "Required. VPC resource ID")
+ req.VPCProjectId = flags.String("vpc-project-id", "", "Required. Project ID that owns the VPC")
+ ctx.BindRegion(cmd, req)
+ cmd.MarkFlagRequired("zone-id")
+ cmd.MarkFlagRequired("vpc-id")
+ cmd.MarkFlagRequired("vpc-project-id")
+ return cmd
+}
diff --git a/products/udns/internal/udns/list.go b/products/udns/internal/udns/list.go
new file mode 100644
index 0000000000..b6fdb0935d
--- /dev/null
+++ b/products/udns/internal/udns/list.go
@@ -0,0 +1,62 @@
+package udns
+
+import (
+ "strings"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ udnssdk "github.com/ucloud/ucloud-sdk-go/services/udns"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+func newListCommand(ctx *cli.Context) *cobra.Command {
+ client := cli.NewServiceClient(ctx, udnssdk.NewClient)
+ req := client.NewDescribeUDNSZoneRequest()
+ var zoneIDs []string
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List UDNS zones",
+ Long: "List UDNS zones",
+ Run: func(cmd *cobra.Command, args []string) {
+ req.DNSZoneIds = zoneIDs
+ resp, err := client.DescribeUDNSZone(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ rows := make([]zoneRow, 0, len(resp.DNSZoneInfos))
+ for _, z := range resp.DNSZoneInfos {
+ rows = append(rows, toZoneRow(z))
+ }
+ ctx.PrintList(rows)
+ },
+ }
+ flags := cmd.Flags()
+ flags.SortFlags = false
+ flags.StringSliceVar(&zoneIDs, "zone-id", nil, "Optional. Filter by zone ID (repeatable)")
+ ctx.BindRegion(cmd, req)
+ ctx.BindProjectID(cmd, req)
+ req.Offset = flags.Int("offset", 0, "Optional. Pagination offset; default 0")
+ req.Limit = flags.Int("limit", 20, "Optional. Pagination limit; default 20")
+ return cmd
+}
+
+func toZoneRow(z udnssdk.ZoneInfo) zoneRow {
+ vpcIDs := make([]string, 0, len(z.VPCInfos))
+ for _, v := range z.VPCInfos {
+ vpcIDs = append(vpcIDs, v.VPCId)
+ }
+ return zoneRow{
+ ZoneID: z.DNSZoneId,
+ Name: z.DNSZoneName,
+ ChargeType: z.ChargeType,
+ Recursion: z.IsRecursionEnabled,
+ VPCs: strings.Join(vpcIDs, ","),
+ Tag: z.Tag,
+ Remark: z.Remark,
+ CreateTime: time.Unix(int64(z.CreateTime), 0).Format("2006-01-02"),
+ ExpireTime: time.Unix(int64(z.ExpireTime), 0).Format("2006-01-02"),
+ }
+}
diff --git a/products/udns/internal/udns/modify.go b/products/udns/internal/udns/modify.go
new file mode 100644
index 0000000000..2b7585bb80
--- /dev/null
+++ b/products/udns/internal/udns/modify.go
@@ -0,0 +1,43 @@
+package udns
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ udnssdk "github.com/ucloud/ucloud-sdk-go/services/udns"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+func newModifyCommand(ctx *cli.Context) *cobra.Command {
+ client := cli.NewServiceClient(ctx, udnssdk.NewClient)
+ req := client.NewModifyUDNSZoneRequest()
+ cmd := &cobra.Command{
+ Use: "modify",
+ Short: "Modify a UDNS zone",
+ Long: "Modify a UDNS zone (recursion and remark only)",
+ Run: func(cmd *cobra.Command, args []string) {
+ id := ctx.PickResourceID(*req.DNSZoneId)
+ req.DNSZoneId = &id
+ _, err := client.ModifyUDNSZone(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ fmt.Fprintf(ctx.ProgressWriter(), "zone[%s] modified\n", id)
+ ctx.EmitResult(cli.OpResultRow{ResourceID: id, Action: "modify", Status: "Modified"})
+ },
+ }
+ flags := cmd.Flags()
+ flags.SortFlags = false
+ req.DNSZoneId = flags.String("zone-id", "", "Required. Zone resource ID")
+ req.IsRecursionEnabled = flags.String("recursion", "", "Optional. enable or disable")
+ req.Remark = flags.String("remark", "", "Optional. Remark")
+ ctx.BindRegion(cmd, req)
+ ctx.BindProjectID(cmd, req)
+ command.SetFlagValues(cmd, "recursion", "enable", "disable")
+ cmd.MarkFlagRequired("zone-id")
+ return cmd
+}
diff --git a/products/udns/internal/udns/record.go b/products/udns/internal/udns/record.go
new file mode 100644
index 0000000000..dbf9e0d953
--- /dev/null
+++ b/products/udns/internal/udns/record.go
@@ -0,0 +1,20 @@
+package udns
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+func newRecordCommand(ctx *cli.Context) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "record",
+ Short: "Manage DNS records within a UDNS zone",
+ Long: "Manage DNS records within a UDNS zone",
+ }
+ cmd.AddCommand(newRecordListCommand(ctx))
+ cmd.AddCommand(newRecordCreateCommand(ctx))
+ cmd.AddCommand(newRecordModifyCommand(ctx))
+ cmd.AddCommand(newRecordDeleteCommand(ctx))
+ return cmd
+}
diff --git a/products/udns/internal/udns/record_create.go b/products/udns/internal/udns/record_create.go
new file mode 100644
index 0000000000..8deed7fa62
--- /dev/null
+++ b/products/udns/internal/udns/record_create.go
@@ -0,0 +1,50 @@
+package udns
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ udnssdk "github.com/ucloud/ucloud-sdk-go/services/udns"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+func newRecordCreateCommand(ctx *cli.Context) *cobra.Command {
+ client := cli.NewServiceClient(ctx, udnssdk.NewClient)
+ req := client.NewCreateUDNSRecordRequest()
+ cmd := &cobra.Command{
+ Use: "create",
+ Short: "Create a DNS record in a UDNS zone",
+ Long: "Create a DNS record in a UDNS zone",
+ Run: func(cmd *cobra.Command, args []string) {
+ resp, err := client.CreateUDNSRecord(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ fmt.Fprintf(ctx.ProgressWriter(), "record[%s] created\n", resp.DNSRecordId)
+ ctx.EmitResult(cli.OpResultRow{ResourceID: resp.DNSRecordId, Action: "create", Status: "Created"})
+ },
+ }
+ flags := cmd.Flags()
+ flags.SortFlags = false
+ req.DNSZoneId = flags.String("zone-id", "", "Required. Zone resource ID")
+ req.Name = flags.String("name", "", "Required. Host record (subdomain prefix)")
+ req.Type = flags.String("type", "", "Required. Record type: A, AAAA, CNAME, MX, TXT, SRV, PTR")
+ req.Value = flags.String("value", "", `Required. Value string: "IP|weight|enabled,..." e.g. "192.168.1.1|1|1"`)
+ req.ValueType = flags.String("value-type", "", "Required. Normal or Multivalue")
+ req.TTL = flags.Int("ttl", 5, "Optional. TTL in seconds (5-600); default 5")
+ req.Remark = flags.String("remark", "", "Optional. Remark")
+ ctx.BindRegion(cmd, req)
+ ctx.BindProjectID(cmd, req)
+ command.SetFlagValues(cmd, "type", "A", "AAAA", "CNAME", "MX", "TXT", "SRV", "PTR")
+ command.SetFlagValues(cmd, "value-type", "Normal", "Multivalue")
+ cmd.MarkFlagRequired("zone-id")
+ cmd.MarkFlagRequired("name")
+ cmd.MarkFlagRequired("type")
+ cmd.MarkFlagRequired("value")
+ cmd.MarkFlagRequired("value-type")
+ return cmd
+}
diff --git a/products/udns/internal/udns/record_delete.go b/products/udns/internal/udns/record_delete.go
new file mode 100644
index 0000000000..f59915d6e0
--- /dev/null
+++ b/products/udns/internal/udns/record_delete.go
@@ -0,0 +1,58 @@
+package udns
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ udnssdk "github.com/ucloud/ucloud-sdk-go/services/udns"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+func newRecordDeleteCommand(ctx *cli.Context) *cobra.Command {
+ client := cli.NewServiceClient(ctx, udnssdk.NewClient)
+ req := client.NewDeleteUDNSRecordRequest()
+ var recordIDs []string
+ var yes bool
+ cmd := &cobra.Command{
+ Use: "delete",
+ Short: "Delete DNS records from a UDNS zone",
+ Long: "Delete DNS records from a UDNS zone",
+ Run: func(cmd *cobra.Command, args []string) {
+ ok, err := ctx.Confirm(yes, "Are you sure you want to delete the record(s)?")
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ if !ok {
+ return
+ }
+ for i, id := range recordIDs {
+ recordIDs[i] = ctx.PickResourceID(id)
+ }
+ req.RecordIds = recordIDs
+ _, err = client.DeleteUDNSRecord(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ fmt.Fprintf(ctx.ProgressWriter(), "records %v deleted from zone[%s]\n", recordIDs, *req.DNSZoneId)
+ results := make([]cli.OpResultRow, 0, len(recordIDs))
+ for _, id := range recordIDs {
+ results = append(results, cli.OpResultRow{ResourceID: id, Action: "delete", Status: "Deleted"})
+ }
+ ctx.EmitResult(results...)
+ },
+ }
+ flags := cmd.Flags()
+ flags.SortFlags = false
+ req.DNSZoneId = flags.String("zone-id", "", "Required. Zone resource ID")
+ flags.StringSliceVar(&recordIDs, "record-id", nil, "Required. Record resource ID (repeatable)")
+ flags.BoolVarP(&yes, "yes", "y", false, "Optional. Skip the confirmation prompt.")
+ ctx.BindRegion(cmd, req)
+ ctx.BindProjectID(cmd, req)
+ cmd.MarkFlagRequired("zone-id")
+ cmd.MarkFlagRequired("record-id")
+ return cmd
+}
diff --git a/products/udns/internal/udns/record_list.go b/products/udns/internal/udns/record_list.go
new file mode 100644
index 0000000000..034d556532
--- /dev/null
+++ b/products/udns/internal/udns/record_list.go
@@ -0,0 +1,63 @@
+package udns
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ udnssdk "github.com/ucloud/ucloud-sdk-go/services/udns"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+func newRecordListCommand(ctx *cli.Context) *cobra.Command {
+ client := cli.NewServiceClient(ctx, udnssdk.NewClient)
+ req := client.NewDescribeUDNSRecordRequest()
+ var recordIDs []string
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List DNS records in a UDNS zone",
+ Long: "List DNS records in a UDNS zone",
+ Run: func(cmd *cobra.Command, args []string) {
+ req.RecordIds = recordIDs
+ resp, err := client.DescribeUDNSRecord(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ rows := make([]recordRow, 0, len(resp.RecordInfos))
+ for _, r := range resp.RecordInfos {
+ rows = append(rows, toRecordRow(r))
+ }
+ ctx.PrintList(rows)
+ },
+ }
+ flags := cmd.Flags()
+ flags.SortFlags = false
+ req.DNSZoneId = flags.String("zone-id", "", "Required. Zone resource ID")
+ flags.StringSliceVar(&recordIDs, "record-id", nil, "Optional. Filter by record ID (repeatable)")
+ req.Query = flags.String("query", "", "Optional. Fuzzy search string")
+ ctx.BindRegion(cmd, req)
+ ctx.BindProjectID(cmd, req)
+ req.Offset = flags.Int("offset", 0, "Optional. Pagination offset; default 0")
+ req.Limit = flags.Int("limit", 20, "Optional. Pagination limit; default 20")
+ cmd.MarkFlagRequired("zone-id")
+ return cmd
+}
+
+func toRecordRow(r udnssdk.RecordInfo) recordRow {
+ values := make([]string, 0, len(r.ValueSet))
+ for _, v := range r.ValueSet {
+ values = append(values, fmt.Sprintf("%s|%d|%d", v.Data, v.Weight, v.IsEnabled))
+ }
+ return recordRow{
+ RecordID: r.RecordId,
+ Name: r.Name,
+ Type: r.Type,
+ TTL: fmt.Sprintf("%d", r.TTL),
+ Values: strings.Join(values, ","),
+ ValueType: r.ValueType,
+ Remark: r.Remark,
+ }
+}
diff --git a/products/udns/internal/udns/record_modify.go b/products/udns/internal/udns/record_modify.go
new file mode 100644
index 0000000000..f93a72c27c
--- /dev/null
+++ b/products/udns/internal/udns/record_modify.go
@@ -0,0 +1,54 @@
+package udns
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ udnssdk "github.com/ucloud/ucloud-sdk-go/services/udns"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+func newRecordModifyCommand(ctx *cli.Context) *cobra.Command {
+ var ttl int
+ client := cli.NewServiceClient(ctx, udnssdk.NewClient)
+ req := client.NewModifyUDNSRecordRequest()
+ cmd := &cobra.Command{
+ Use: "modify",
+ Short: "Modify a DNS record in a UDNS zone",
+ Long: "Modify a DNS record in a UDNS zone",
+ Run: func(cmd *cobra.Command, args []string) {
+ if cmd.Flags().Changed("ttl") {
+ req.TTL = &ttl
+ }
+ recordID := ctx.PickResourceID(*req.RecordId)
+ req.RecordId = &recordID
+ _, err := client.ModifyUDNSRecord(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ fmt.Fprintf(ctx.ProgressWriter(), "record[%s] modified\n", recordID)
+ ctx.EmitResult(cli.OpResultRow{ResourceID: recordID, Action: "modify", Status: "Modified"})
+ },
+ }
+ flags := cmd.Flags()
+ flags.SortFlags = false
+ req.DNSZoneId = flags.String("zone-id", "", "Required. Zone resource ID")
+ req.RecordId = flags.String("record-id", "", "Required. Record resource ID")
+ req.Type = flags.String("type", "", "Optional. Record type: A, AAAA, CNAME, MX, TXT, SRV, PTR")
+ req.Value = flags.String("value", "", `Optional. Value string: "IP|weight|enabled,..."`)
+ req.ValueType = flags.String("value-type", "", "Optional. Normal or Multivalue")
+ //req.TTL = flags.Int("ttl", 0, "Optional. TTL in seconds (5-600); 0 means unchanged")
+ flags.IntVar(&ttl, "ttl", 0, "Optional. TTL in seconds (5-600); 0 means unchanged")
+ req.Remark = flags.String("remark", "", "Optional. Remark")
+ ctx.BindRegion(cmd, req)
+ ctx.BindProjectID(cmd, req)
+ command.SetFlagValues(cmd, "type", "A", "AAAA", "CNAME", "MX", "TXT", "SRV", "PTR")
+ command.SetFlagValues(cmd, "value-type", "Normal", "Multivalue")
+ cmd.MarkFlagRequired("zone-id")
+ cmd.MarkFlagRequired("record-id")
+ return cmd
+}
diff --git a/products/udns/internal/udns/rows.go b/products/udns/internal/udns/rows.go
new file mode 100644
index 0000000000..2375d56a27
--- /dev/null
+++ b/products/udns/internal/udns/rows.go
@@ -0,0 +1,23 @@
+package udns
+
+type zoneRow struct {
+ ZoneID string
+ Name string
+ ChargeType string
+ Recursion string
+ VPCs string
+ Tag string
+ Remark string
+ CreateTime string
+ ExpireTime string
+}
+
+type recordRow struct {
+ RecordID string
+ Name string
+ Type string
+ TTL string
+ Values string
+ ValueType string
+ Remark string
+}
diff --git a/products/udns/product.go b/products/udns/product.go
new file mode 100644
index 0000000000..5504ff5db3
--- /dev/null
+++ b/products/udns/product.go
@@ -0,0 +1,27 @@
+package udns
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ internaludns "github.com/ucloud/ucloud-cli/products/udns/internal/udns"
+)
+
+type udns struct{}
+
+func New() cli.Product {
+ return udns{}
+}
+
+func (u udns) Metadata() cli.Metadata {
+ return cli.Metadata{
+ Name: "udns",
+ Commands: []string{"udns"},
+ }
+}
+
+func (u udns) NewCommand(ctx *cli.Context) []*cobra.Command {
+ return []*cobra.Command{internaludns.NewCommand(ctx)}
+}
+
+var _ cli.Product = udns{}
diff --git a/products/udns/product.yaml b/products/udns/product.yaml
new file mode 100644
index 0000000000..829290591e
--- /dev/null
+++ b/products/udns/product.yaml
@@ -0,0 +1,7 @@
+# products/udns/product.yaml — udisk 产品元数据(归属真源,owner 自治维护)
+name: udns
+owners:
+ - mingfeng-ucloud
+commands:
+ - udns
+enabled: true
diff --git a/products/udns/testdata/cmdtree.golden b/products/udns/testdata/cmdtree.golden
new file mode 100644
index 0000000000..ba662323d0
--- /dev/null
+++ b/products/udns/testdata/cmdtree.golden
@@ -0,0 +1,68 @@
+ucloud udns use=udns short=List and manipulate ucloud private dns(udns) instance and record
+ucloud udns associate-vpc use=associate-vpc short=Associate a UDNS zone with a VPC
+ flag=region short= default= required=
+ flag=vpc-id short= default= required=true
+ flag=vpc-project-id short= default= required=true
+ flag=zone-id short= default= required=true
+ucloud udns create use=create short=Create a UDNS private DNS zone
+ flag=charge-type short= default=Month required=
+ flag=project-id short= default= required=
+ flag=quantity short= default=1 required=
+ flag=recursion short= default= required=
+ flag=region short= default= required=
+ flag=remark short= default= required=
+ flag=tag short= default= required=
+ flag=type short= default= required=true
+ flag=zone-name short= default= required=true
+ucloud udns disassociate-vpc use=disassociate-vpc short=Disassociate a UDNS zone from a VPC
+ flag=region short= default= required=
+ flag=vpc-id short= default= required=true
+ flag=vpc-project-id short= default= required=true
+ flag=zone-id short= default= required=true
+ucloud udns list use=list short=List UDNS zones
+ flag=limit short= default=20 required=
+ flag=offset short= default=0 required=
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=zone-id short= default=[] required=
+ucloud udns modify use=modify short=Modify a UDNS zone
+ flag=project-id short= default= required=
+ flag=recursion short= default= required=
+ flag=region short= default= required=
+ flag=remark short= default= required=
+ flag=zone-id short= default= required=true
+ucloud udns record use=record short=Manage DNS records within a UDNS zone
+ucloud udns record create use=create short=Create a DNS record in a UDNS zone
+ flag=name short= default= required=true
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=remark short= default= required=
+ flag=ttl short= default=5 required=
+ flag=type short= default= required=true
+ flag=value short= default= required=true
+ flag=value-type short= default= required=true
+ flag=zone-id short= default= required=true
+ucloud udns record delete use=delete short=Delete DNS records from a UDNS zone
+ flag=project-id short= default= required=
+ flag=record-id short= default=[] required=true
+ flag=region short= default= required=
+ flag=yes short=y default=false required=
+ flag=zone-id short= default= required=true
+ucloud udns record list use=list short=List DNS records in a UDNS zone
+ flag=limit short= default=20 required=
+ flag=offset short= default=0 required=
+ flag=project-id short= default= required=
+ flag=query short= default= required=
+ flag=record-id short= default=[] required=
+ flag=region short= default= required=
+ flag=zone-id short= default= required=true
+ucloud udns record modify use=modify short=Modify a DNS record in a UDNS zone
+ flag=project-id short= default= required=
+ flag=record-id short= default= required=true
+ flag=region short= default= required=
+ flag=remark short= default= required=
+ flag=ttl short= default=0 required=
+ flag=type short= default= required=
+ flag=value short= default= required=
+ flag=value-type short= default= required=
+ flag=zone-id short= default= required=true
diff --git a/products/udns/testdata/completion.golden b/products/udns/testdata/completion.golden
new file mode 100644
index 0000000000..649ac668a1
--- /dev/null
+++ b/products/udns/testdata/completion.golden
@@ -0,0 +1,24 @@
+ucloud udns associate-vpc region dynamic
+ucloud udns create charge-type static Dynamic,Month,Year
+ucloud udns create project-id dynamic
+ucloud udns create recursion static disable,enable
+ucloud udns create region dynamic
+ucloud udns create type static private,public
+ucloud udns disassociate-vpc region dynamic
+ucloud udns list project-id dynamic
+ucloud udns list region dynamic
+ucloud udns modify project-id dynamic
+ucloud udns modify recursion static disable,enable
+ucloud udns modify region dynamic
+ucloud udns record create project-id dynamic
+ucloud udns record create region dynamic
+ucloud udns record create type static A,AAAA,CNAME,MX,PTR,SRV,TXT
+ucloud udns record create value-type static Multivalue,Normal
+ucloud udns record delete project-id dynamic
+ucloud udns record delete region dynamic
+ucloud udns record list project-id dynamic
+ucloud udns record list region dynamic
+ucloud udns record modify project-id dynamic
+ucloud udns record modify region dynamic
+ucloud udns record modify type static A,AAAA,CNAME,MX,PTR,SRV,TXT
+ucloud udns record modify value-type static Multivalue,Normal
diff --git a/products/ugn/internal/ugn/bw.go b/products/ugn/internal/ugn/bw.go
new file mode 100644
index 0000000000..b164ae6c2a
--- /dev/null
+++ b/products/ugn/internal/ugn/bw.go
@@ -0,0 +1,23 @@
+package ugn
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// newBW ucloud ugn bw
+func newBW(ctx *cli.Context) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "bw",
+ Short: "List and manipulate ugn bandwidth packages",
+ Long: "List and manipulate ugn bandwidth packages",
+ }
+
+ cmd.AddCommand(newBWCreate(ctx))
+ cmd.AddCommand(newBWDelete(ctx))
+ cmd.AddCommand(newBWList(ctx))
+ cmd.AddCommand(newBWModifyBandwidth(ctx))
+
+ return cmd
+}
diff --git a/products/ugn/internal/ugn/bw_create.go b/products/ugn/internal/ugn/bw_create.go
new file mode 100644
index 0000000000..6b026d2541
--- /dev/null
+++ b/products/ugn/internal/ugn/bw_create.go
@@ -0,0 +1,66 @@
+package ugn
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ ugnsdk "github.com/ucloud/ucloud-sdk-go/services/ugn"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newBWCreate ucloud ugn bw create
+func newBWCreate(ctx *cli.Context) *cobra.Command {
+ client := cli.NewServiceClient(ctx, ugnsdk.NewClient)
+ req := client.NewCreateSimpleUGNBwPackageRequest()
+ cmd := &cobra.Command{
+ Use: "create",
+ Short: "Create a ugn bandwidth package",
+ Long: "Create a ugn bandwidth package",
+ Run: func(c *cobra.Command, args []string) {
+ _, err := client.CreateSimpleUGNBwPackage(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ fmt.Fprintf(ctx.ProgressWriter(), "ugn bw created\n")
+ ctx.EmitResult(cli.OpResultRow{Action: "create-bw", Status: "Created"})
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ req.UGNID = flags.String("ugn-id", "", "Required. Resource ID of the ugn instance")
+ req.BandWidth = flags.Int("bandwidth", 0, "Required. Bandwidth value in Mbps")
+ req.Path = flags.String("path", "IGP", "Path policy: Delay/IGP/TCO")
+ req.PayMode = flags.String("pay-mode", "", "Required. Pay mode: FixedBw/Max5/Traffic")
+ req.ChargeType = flags.String("charge-type", "", "Required. Charge type: Month/Postpay")
+ req.RegionA = flags.String("region-a", "", "Required. Region A of the bandwidth package")
+ req.RegionB = flags.String("region-b", "", "Required. Region B of the bandwidth package")
+ req.Name = flags.String("name", "", "Optional. Bandwidth package name")
+ req.Qos = flags.String("qos", "Platinum", "Optional. QoS: Diamond/Platinum/Gold")
+ req.CouponId = flags.String("coupon-id", "", "Optional. Coupon ID")
+ req.Quantity = flags.Float64("quantity", 1, "Optional. Duration in months, default 1")
+
+ ctx.BindProjectID(cmd, req)
+ ctx.SetCompletion(cmd, "ugn-id", func() []string {
+ return getAllUGNIdNames(ctx, *req.ProjectId)
+ })
+ ctx.SetCompletion(cmd, "project-id", ctx.ProjectList)
+ command.SetFlagValues(cmd, "path", "Delay", "IGP", "TCO")
+ command.SetFlagValues(cmd, "pay-mode", "FixedBw", "Max5", "Traffic")
+ command.SetFlagValues(cmd, "charge-type", "Month", "Postpay")
+ command.SetFlagValues(cmd, "qos", "Diamond", "Platinum", "Gold")
+
+ cmd.MarkFlagRequired("ugn-id")
+ cmd.MarkFlagRequired("bandwidth")
+ cmd.MarkFlagRequired("pay-mode")
+ cmd.MarkFlagRequired("charge-type")
+ cmd.MarkFlagRequired("region-a")
+ cmd.MarkFlagRequired("region-b")
+
+ return cmd
+}
diff --git a/products/ugn/internal/ugn/bw_delete.go b/products/ugn/internal/ugn/bw_delete.go
new file mode 100644
index 0000000000..831d479bec
--- /dev/null
+++ b/products/ugn/internal/ugn/bw_delete.go
@@ -0,0 +1,53 @@
+package ugn
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ ugnsdk "github.com/ucloud/ucloud-sdk-go/services/ugn"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// newBWDelete ucloud ugn bw delete
+func newBWDelete(ctx *cli.Context) *cobra.Command {
+ client := cli.NewServiceClient(ctx, ugnsdk.NewClient)
+ req := client.NewDeleteUGNBwPackageRequest()
+ var yes bool
+
+ cmd := &cobra.Command{
+ Use: "delete",
+ Short: "Delete ugn bandwidth packages",
+ Long: "Delete ugn bandwidth packages",
+ Run: func(c *cobra.Command, args []string) {
+ ok, err := ctx.Confirm(yes, "Are you sure you want to delete the bandwidth package?")
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ if !ok {
+ return
+ }
+ _, err = client.DeleteUGNBwPackage(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ fmt.Fprintf(ctx.ProgressWriter(), "ugn bw[%s] deleted\n", *req.BwPackageID)
+ ctx.EmitResult(cli.OpResultRow{ResourceID: *req.BwPackageID, Action: "delete-bw", Status: "Deleted"})
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ req.BwPackageID = flags.String("bw-package-id", "", "Required. Resource ID of the bandwidth package to delete")
+ req.UGNID = flags.String("ugn-id", "", "Required. Resource ID of the ugn instance")
+ flags.BoolVarP(&yes, "yes", "y", false, "Optional. Skip the confirmation prompt.")
+
+ cmd.MarkFlagRequired("bw-package-id")
+ cmd.MarkFlagRequired("ugn-id")
+
+ return cmd
+}
diff --git a/products/ugn/internal/ugn/bw_list.go b/products/ugn/internal/ugn/bw_list.go
new file mode 100644
index 0000000000..1a22ce04b5
--- /dev/null
+++ b/products/ugn/internal/ugn/bw_list.go
@@ -0,0 +1,83 @@
+package ugn
+
+import (
+ "github.com/spf13/cobra"
+
+ ugnsdk "github.com/ucloud/ucloud-sdk-go/services/ugn"
+
+ "github.com/ucloud/ucloud-cli/internal/common"
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// bwRow is the table row for `ucloud ugn bw list`.
+type bwRow struct {
+ ResourceID string
+ Name string
+ BandwidthMbps float64
+ RegionA string
+ RegionB string
+ Path string
+ QoS string
+ ChargeType string
+ CreateTime string
+ ExpireTime string
+}
+
+// newBWList ucloud ugn bw list
+func newBWList(ctx *cli.Context) *cobra.Command {
+ client := cli.NewServiceClient(ctx, ugnsdk.NewClient)
+ req := client.NewGetSimpleUGNBwPackagesRequest()
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List ugn bandwidth packages",
+ Long: "List ugn bandwidth packages",
+ Run: func(c *cobra.Command, args []string) {
+ *req.UGNID = ctx.PickResourceID(*req.UGNID)
+ resp, err := client.GetSimpleUGNBwPackages(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ rows := make([]bwRow, 0, len(resp.BwPackages))
+ for _, bw := range resp.BwPackages {
+ path := bw.Path
+ if path == "None" {
+ path = "IGP"
+ }
+ expireTime := ""
+ if bw.ExpireTime > 0 {
+ expireTime = common.FormatDate(bw.ExpireTime)
+ }
+ rows = append(rows, bwRow{
+ ResourceID: bw.PackageID,
+ Name: bw.Name,
+ BandwidthMbps: bw.BandWidth,
+ RegionA: bw.RegionA,
+ RegionB: bw.RegionB,
+ Path: path,
+ QoS: bw.Qos,
+ ChargeType: bw.PayMode,
+ CreateTime: common.FormatDate(bw.CreateTime),
+ ExpireTime: expireTime,
+ })
+ }
+ ctx.PrintList(rows)
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ req.UGNID = flags.String("ugn-id", "", "Required. Resource ID of the ugn instance")
+ ctx.BindOffset(cmd, req)
+ req.Limit = flags.Int("limit", 50, "Optional. Limit")
+ req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Project-id, see 'ucloud project list'")
+
+ cmd.MarkFlagRequired("ugn-id")
+ ctx.SetCompletion(cmd, "ugn-id", func() []string {
+ return getAllUGNIdNames(ctx, *req.ProjectId)
+ })
+ ctx.SetCompletion(cmd, "project-id", ctx.ProjectList)
+
+ return cmd
+}
diff --git a/products/ugn/internal/ugn/bw_modify_bandwidth.go b/products/ugn/internal/ugn/bw_modify_bandwidth.go
new file mode 100644
index 0000000000..d1c2502cf4
--- /dev/null
+++ b/products/ugn/internal/ugn/bw_modify_bandwidth.go
@@ -0,0 +1,44 @@
+package ugn
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ ugnsdk "github.com/ucloud/ucloud-sdk-go/services/ugn"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// newBWModifyBandwidth ucloud ugn bw modify-bandwidth
+func newBWModifyBandwidth(ctx *cli.Context) *cobra.Command {
+ client := cli.NewServiceClient(ctx, ugnsdk.NewClient)
+ req := client.NewModifyUGNBandwidthRequest()
+ cmd := &cobra.Command{
+ Use: "modify-bandwidth",
+ Short: "Modify ugn bandwidth",
+ Long: "Modify ugn bandwidth",
+ Run: func(c *cobra.Command, args []string) {
+ _, err := client.ModifyUGNBandwidth(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ fmt.Fprintf(ctx.ProgressWriter(), "ugn bw[%s] bandwidth modified to %d\n", *req.PackageID, *req.BandWidth)
+ ctx.EmitResult(cli.OpResultRow{ResourceID: *req.PackageID, Action: "modify-bandwidth", Status: "Modified"})
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ req.BandWidth = flags.Int("bandwidth", 0, "Required. New bandwidth value")
+ req.PackageID = flags.String("package-id", "", "Required. Bandwidth package ID")
+ req.UGNID = flags.String("ugn-id", "", "Required. Resource ID of the ugn instance")
+
+ cmd.MarkFlagRequired("bandwidth")
+ cmd.MarkFlagRequired("package-id")
+ cmd.MarkFlagRequired("ugn-id")
+
+ return cmd
+}
diff --git a/products/ugn/internal/ugn/cmd.go b/products/ugn/internal/ugn/cmd.go
new file mode 100644
index 0000000000..b2be218dd2
--- /dev/null
+++ b/products/ugn/internal/ugn/cmd.go
@@ -0,0 +1,27 @@
+package ugn
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// NewCommand builds the `ugn` root command.
+func NewCommand(ctx *cli.Context) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "ugn",
+ Short: "List and manipulate ugn instances",
+ Long: "List and manipulate ugn instances",
+ }
+
+ cmd.AddCommand(newList(ctx))
+ cmd.AddCommand(newGet(ctx))
+ cmd.AddCommand(newCreate(ctx))
+ cmd.AddCommand(newDelete(ctx))
+ cmd.AddCommand(newRegion(ctx))
+ cmd.AddCommand(newBW(ctx))
+ cmd.AddCommand(newNetwork(ctx))
+ cmd.AddCommand(newRoute(ctx))
+
+ return cmd
+}
diff --git a/products/ugn/internal/ugn/completion.go b/products/ugn/internal/ugn/completion.go
new file mode 100644
index 0000000000..557ce5737b
--- /dev/null
+++ b/products/ugn/internal/ugn/completion.go
@@ -0,0 +1,42 @@
+package ugn
+
+import (
+ "fmt"
+
+ ugnsdk "github.com/ucloud/ucloud-sdk-go/services/ugn"
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+func getAllUGNIns(ctx *cli.Context, project string) ([]ugnsdk.UGN, error) {
+ client := cli.NewServiceClient(ctx, ugnsdk.NewClient)
+ req := client.NewListUGNRequest()
+ req.ProjectId = sdk.String(cli.PickResourceID(project))
+ list := make([]ugnsdk.UGN, 0)
+ for offset, limit := 0, 50; offset <= 1000; offset += limit {
+ req.Limit = sdk.Int(limit)
+ req.Offset = sdk.Int(offset)
+ resp, err := client.ListUGN(req)
+ if err != nil {
+ return nil, err
+ }
+ list = append(list, resp.UGNs...)
+ if offset+limit >= resp.TotalCount {
+ break
+ }
+ }
+ return list, nil
+}
+
+func getAllUGNIdNames(ctx *cli.Context, project string) []string {
+ ugnInsList, err := getAllUGNIns(ctx, project)
+ if err != nil {
+ return nil
+ }
+ idNameList := []string{}
+ for _, ugn := range ugnInsList {
+ idNameList = append(idNameList, fmt.Sprintf("%s/%s", ugn.UGNID, ugn.Name))
+ }
+ return idNameList
+}
diff --git a/products/ugn/internal/ugn/create.go b/products/ugn/internal/ugn/create.go
new file mode 100644
index 0000000000..4151bad730
--- /dev/null
+++ b/products/ugn/internal/ugn/create.go
@@ -0,0 +1,44 @@
+package ugn
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ ugnsdk "github.com/ucloud/ucloud-sdk-go/services/ugn"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// newCreate ucloud ugn create
+func newCreate(ctx *cli.Context) *cobra.Command {
+ client := cli.NewServiceClient(ctx, ugnsdk.NewClient)
+ req := client.NewCreateUGNRequest()
+ cmd := &cobra.Command{
+ Use: "create",
+ Short: "Create a ugn instance",
+ Long: "Create a ugn instance",
+ Run: func(c *cobra.Command, args []string) {
+ resp, err := client.CreateUGN(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ fmt.Fprintf(ctx.ProgressWriter(), "ugn[%s] created\n", resp.UGNID)
+ ctx.EmitResult(cli.OpResultRow{ResourceID: resp.UGNID, Action: "create", Status: "Created"})
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ req.Name = flags.String("name", "", "Required. Name of the ugn instance to create")
+ req.Remark = flags.String("remark", "", "Optional. Remark")
+
+ ctx.BindProjectID(cmd, req)
+ ctx.SetCompletion(cmd, "project-id", ctx.ProjectList)
+
+ cmd.MarkFlagRequired("name")
+
+ return cmd
+}
diff --git a/products/ugn/internal/ugn/delete.go b/products/ugn/internal/ugn/delete.go
new file mode 100644
index 0000000000..746c9d5eab
--- /dev/null
+++ b/products/ugn/internal/ugn/delete.go
@@ -0,0 +1,65 @@
+package ugn
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ ugnsdk "github.com/ucloud/ucloud-sdk-go/services/ugn"
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newDelete ucloud ugn delete
+func newDelete(ctx *cli.Context) *cobra.Command {
+ idNames := []string{}
+ client := cli.NewServiceClient(ctx, ugnsdk.NewClient)
+ req := client.NewDelUGNRequest()
+ var yes bool
+
+ cmd := &cobra.Command{
+ Use: "delete",
+ Short: "Delete ugn instances",
+ Long: "Delete ugn instances",
+ Run: func(c *cobra.Command, args []string) {
+ ok, err := ctx.Confirm(yes, "Are you sure you want to delete the ugn instance(s)?")
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ if !ok {
+ return
+ }
+ results := []cli.OpResultRow{}
+ for _, idname := range idNames {
+ id := ctx.PickResourceID(idname)
+ req.UGNID = sdk.String(id)
+ _, err := client.DelUGN(req)
+ if err != nil {
+ ctx.HandleError(err)
+ continue
+ }
+ fmt.Fprintf(ctx.ProgressWriter(), "ugn[%s] deleted\n", idname)
+ results = append(results, cli.OpResultRow{ResourceID: id, Action: "delete", Status: "Deleted"})
+ }
+ ctx.EmitResult(results...)
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ flags.StringSliceVar(&idNames, "ugn-id", nil, "Required. Resource ID of ugn instances to delete")
+ flags.BoolVarP(&yes, "yes", "y", false, "Optional. Skip the confirmation prompt.")
+ req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Project-id, see 'ucloud project list'")
+
+ cmd.MarkFlagRequired("ugn-id")
+ command.SetCompletion(cmd, "ugn-id", func() []string {
+ return getAllUGNIdNames(ctx, *req.ProjectId)
+ })
+ command.SetCompletion(cmd, "project-id", ctx.ProjectList)
+
+ return cmd
+}
diff --git a/products/ugn/internal/ugn/get.go b/products/ugn/internal/ugn/get.go
new file mode 100644
index 0000000000..49b2df4783
--- /dev/null
+++ b/products/ugn/internal/ugn/get.go
@@ -0,0 +1,232 @@
+package ugn
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ ugnsdk "github.com/ucloud/ucloud-sdk-go/services/ugn"
+
+ "github.com/ucloud/ucloud-cli/internal/common"
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// getNetworkRow is the table row for networks in ugn get.
+type getNetworkRow struct {
+ NetworkID string
+ Name string
+ OrgName string
+ Region string
+ Type string
+ CreateTime string
+}
+
+// getBwRow is the table row for bw packages in ugn get.
+type getBwRow struct {
+ PackageID string
+ Name string
+ BandwidthMbps float64
+ RegionA string
+ RegionB string
+ Path string
+ QoS string
+ PayMode string
+ CreateTime string
+ ExpireTime string
+}
+
+// getRouteRow is the table row for routes in ugn get.
+type getRouteRow struct {
+ DstAddr string
+ NextHopID string
+ NextHopType string
+ NextHopRegion string
+ Priority int
+ Conflict string
+ Deny string
+ Restrict string
+}
+
+// getPolicyRow is the table row for policies in ugn get.
+type getPolicyRow struct {
+ PolicyID string
+ Name string
+ Priority int
+ Direction string
+ Action string
+ RoutePriority int
+ Enabled string
+ DstAddrs string
+ SrcAddrs string
+ CreateTime string
+}
+
+// newGet ucloud ugn get
+func newGet(ctx *cli.Context) *cobra.Command {
+ client := cli.NewServiceClient(ctx, ugnsdk.NewClient)
+ req := client.NewDescribeSimpleUGNRequest()
+ cmd := &cobra.Command{
+ Use: "get",
+ Short: "Show details of one ugn instance",
+ Long: "Show details of one ugn instance",
+ Run: func(c *cobra.Command, args []string) {
+ *req.UGNID = ctx.PickResourceID(*req.UGNID)
+ resp, err := client.DescribeSimpleUGN(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+
+ // 基本信息
+ basic := []cli.DescribeRow{
+ {Attribute: "ResourceID", Content: resp.UGN.UGNID},
+ {Attribute: "Name", Content: resp.UGN.Name},
+ {Attribute: "Remark", Content: resp.UGN.Remark},
+ {Attribute: "CreateTime", Content: common.FormatDate(resp.UGN.CreateTime)},
+ {Attribute: "NetworkCount", Content: fmt.Sprintf("%d", resp.UGN.NetworkCount)},
+ {Attribute: "BwPackageCount", Content: fmt.Sprintf("%d", resp.UGN.BwPackageCount)},
+ }
+ printDescribe(ctx, basic)
+
+ // 网络实例列表
+ fmt.Fprintln(ctx.ProgressWriter())
+ fmt.Fprintf(ctx.ProgressWriter(), "Networks (%d):\n", len(resp.Networks))
+ if len(resp.Networks) > 0 {
+ networkRows := make([]getNetworkRow, 0, len(resp.Networks))
+ for _, nw := range resp.Networks {
+ networkRows = append(networkRows, getNetworkRow{
+ NetworkID: nw.NetworkID,
+ Name: nw.Name,
+ OrgName: nw.OrgName,
+ Region: nw.Region,
+ Type: nw.Type,
+ CreateTime: common.FormatDate(nw.CreateTime),
+ })
+ }
+ ctx.PrintList(networkRows)
+ }
+
+ // 带宽包列表
+ fmt.Fprintln(ctx.ProgressWriter())
+ fmt.Fprintf(ctx.ProgressWriter(), "Bandwidth Packages (%d):\n", len(resp.BwPackages))
+ if len(resp.BwPackages) > 0 {
+ bwRows := make([]getBwRow, 0, len(resp.BwPackages))
+ for _, bw := range resp.BwPackages {
+ expireTime := ""
+ if bw.ExpireTime > 0 {
+ expireTime = common.FormatDate(bw.ExpireTime)
+ }
+ path := bw.Path
+ if path == "None" {
+ path = "IGP"
+ }
+ bwRows = append(bwRows, getBwRow{
+ PackageID: bw.PackageID,
+ Name: bw.Name,
+ BandwidthMbps: bw.BandWidth,
+ RegionA: bw.RegionA,
+ RegionB: bw.RegionB,
+ Path: path,
+ QoS: bw.Qos,
+ PayMode: bw.PayMode,
+ CreateTime: common.FormatDate(bw.CreateTime),
+ ExpireTime: expireTime,
+ })
+ }
+ ctx.PrintList(bwRows)
+ }
+
+ // 路由列表
+ fmt.Fprintln(ctx.ProgressWriter())
+ fmt.Fprintf(ctx.ProgressWriter(), "Routes (%d):\n", len(resp.Routes))
+ if len(resp.Routes) > 0 {
+ routeRows := make([]getRouteRow, 0, len(resp.Routes))
+ for _, r := range resp.Routes {
+ routeRows = append(routeRows, getRouteRow{
+ DstAddr: r.DstAddr,
+ NextHopID: r.NextHopID,
+ NextHopType: r.NextHopType,
+ NextHopRegion: r.NextHopRegion,
+ Priority: r.Priority,
+ Conflict: strconv.FormatBool(r.Conflict),
+ Deny: strconv.FormatBool(r.Deny),
+ Restrict: strconv.FormatBool(r.Restrict),
+ })
+ }
+ ctx.PrintList(routeRows)
+ }
+
+ // 路由策略列表
+ fmt.Fprintln(ctx.ProgressWriter())
+ fmt.Fprintf(ctx.ProgressWriter(), "Policies (%d):\n", len(resp.Policies))
+ if len(resp.Policies) > 0 {
+ policyRows := make([]getPolicyRow, 0, len(resp.Policies))
+ for _, p := range resp.Policies {
+ dstAddrs := make([]string, 0, len(p.DstNetworks))
+ for _, n := range p.DstNetworks {
+ if len(n.Prefixes) > 0 {
+ dstAddrs = append(dstAddrs, fmt.Sprintf("%s(%s)", n.NetworkId, strings.Join(n.Prefixes, ",")))
+ } else {
+ dstAddrs = append(dstAddrs, n.NetworkId)
+ }
+ }
+ srcAddrs := make([]string, 0, len(p.SrcNetworks))
+ for _, n := range p.SrcNetworks {
+ if len(n.Prefixes) > 0 {
+ srcAddrs = append(srcAddrs, fmt.Sprintf("%s(%s)", n.NetworkId, strings.Join(n.Prefixes, ",")))
+ } else {
+ srcAddrs = append(srcAddrs, n.NetworkId)
+ }
+ }
+ policyRows = append(policyRows, getPolicyRow{
+ PolicyID: p.PolicyId,
+ Name: p.Name,
+ Priority: p.Priority,
+ Direction: p.Direction,
+ Action: p.Action,
+ RoutePriority: p.RoutePriority,
+ Enabled: strconv.FormatBool(p.Enabled),
+ DstAddrs: strings.Join(dstAddrs, ", "),
+ SrcAddrs: strings.Join(srcAddrs, ", "),
+ CreateTime: common.FormatDate(p.CreateTime),
+ })
+ }
+ ctx.PrintList(policyRows)
+ }
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ req.UGNID = flags.String("ugn-id", "", "Required. Resource ID of the ugn instance to describe")
+ req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Project-id, see 'ucloud project list'")
+
+ cmd.MarkFlagRequired("ugn-id")
+ ctx.SetCompletion(cmd, "ugn-id", func() []string {
+ return getAllUGNIdNames(ctx, *req.ProjectId)
+ })
+ ctx.SetCompletion(cmd, "project-id", ctx.ProjectList)
+
+ 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/ugn/internal/ugn/list.go b/products/ugn/internal/ugn/list.go
new file mode 100644
index 0000000000..317bc5a226
--- /dev/null
+++ b/products/ugn/internal/ugn/list.go
@@ -0,0 +1,49 @@
+package ugn
+
+import (
+ "github.com/spf13/cobra"
+
+ ugnsdk "github.com/ucloud/ucloud-sdk-go/services/ugn"
+
+ "github.com/ucloud/ucloud-cli/internal/common"
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// newList ucloud ugn list
+func newList(ctx *cli.Context) *cobra.Command {
+ client := cli.NewServiceClient(ctx, ugnsdk.NewClient)
+ req := client.NewListUGNRequest()
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List ugn instances",
+ Long: "List ugn instances",
+ Run: func(c *cobra.Command, args []string) {
+ resp, err := client.ListUGN(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ list := []UGNRow{}
+ for _, ugn := range resp.UGNs {
+ list = append(list, UGNRow{
+ ResourceID: ugn.UGNID,
+ Name: ugn.Name,
+ Remark: ugn.Remark,
+ NetworkCount: ugn.NetworkCount,
+ BwPackageCount: ugn.BwPackageCount,
+ CreateTime: common.FormatDate(ugn.CreateTime),
+ })
+ }
+ ctx.PrintList(list)
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ ctx.BindOffset(cmd, req)
+ req.Limit = flags.Int("limit", 50, "Optional. Limit")
+ req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Project-id, see 'ucloud project list'")
+
+ return cmd
+}
diff --git a/products/ugn/internal/ugn/network.go b/products/ugn/internal/ugn/network.go
new file mode 100644
index 0000000000..e7f5cd1984
--- /dev/null
+++ b/products/ugn/internal/ugn/network.go
@@ -0,0 +1,21 @@
+package ugn
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// newNetwork ucloud ugn network
+func newNetwork(ctx *cli.Context) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "network",
+ Short: "Manage ugn network instances",
+ Long: "Manage ugn network instances",
+ }
+
+ cmd.AddCommand(newNetworkAttach(ctx))
+ cmd.AddCommand(newNetworkDetach(ctx))
+
+ return cmd
+}
diff --git a/products/ugn/internal/ugn/network_attach.go b/products/ugn/internal/ugn/network_attach.go
new file mode 100644
index 0000000000..84e85660bc
--- /dev/null
+++ b/products/ugn/internal/ugn/network_attach.go
@@ -0,0 +1,72 @@
+package ugn
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ ugnsdk "github.com/ucloud/ucloud-sdk-go/services/ugn"
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// newNetworkAttach ucloud ugn network attach
+func newNetworkAttach(ctx *cli.Context) *cobra.Command {
+ client := cli.NewServiceClient(ctx, ugnsdk.NewClient)
+ req := client.NewAttachUGNNetworksRequest()
+
+ var networkIDs []string
+ var networkOrgNames, networkRegions, networkTypes []string
+
+ cmd := &cobra.Command{
+ Use: "attach",
+ Short: "Attach network instances to ugn",
+ Long: "Attach network instances to ugn",
+ Run: func(c *cobra.Command, args []string) {
+ n := len(networkIDs)
+ if len(networkTypes) != n || len(networkRegions) != n || len(networkOrgNames) != n {
+ ctx.HandleError(fmt.Errorf(
+ "network-id(%d), network-type(%d), network-region(%d), network-project-id(%d) must be provided in equal numbers",
+ n, len(networkTypes), len(networkRegions), len(networkOrgNames)))
+ return
+ }
+ networks := make([]ugnsdk.AttachUGNNetworksParamNetworks, 0, n)
+ for i, id := range networkIDs {
+ id = ctx.PickResourceID(id)
+ networks = append(networks, ugnsdk.AttachUGNNetworksParamNetworks{
+ NetworkID: sdk.String(id),
+ OrgName: sdk.String(networkOrgNames[i]),
+ Region: sdk.String(networkRegions[i]),
+ Type: sdk.String(networkTypes[i]),
+ })
+ }
+ req.Networks = networks
+
+ _, err := client.AttachUGNNetworks(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ fmt.Fprintf(ctx.ProgressWriter(), "networks attached to ugn[%s]\n", *req.UGNID)
+ ctx.EmitResult(cli.OpResultRow{ResourceID: *req.UGNID, Action: "attach-network", Status: "Attached"})
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ flags.StringSliceVar(&networkIDs, "network-id", nil, "Required. Network IDs, e.g. vnet-xxxxx. Repeatable or comma-separated.")
+ flags.StringSliceVar(&networkOrgNames, "network-project-id", nil, "Required. Project ID of the networks, one per network-id")
+ flags.StringSliceVar(&networkRegions, "network-region", nil, "Required. Region of the networks, one per network-id")
+ flags.StringSliceVar(&networkTypes, "network-type", nil, "Required. Network type, e.g. VPC/UCVR, one per network-id")
+ req.UGNID = flags.String("ugn-id", "", "Required. Resource ID of the ugn instance")
+
+ cmd.MarkFlagRequired("network-id")
+ cmd.MarkFlagRequired("network-project-id")
+ cmd.MarkFlagRequired("network-region")
+ cmd.MarkFlagRequired("network-type")
+ cmd.MarkFlagRequired("ugn-id")
+
+ return cmd
+}
diff --git a/products/ugn/internal/ugn/network_detach.go b/products/ugn/internal/ugn/network_detach.go
new file mode 100644
index 0000000000..889f59c830
--- /dev/null
+++ b/products/ugn/internal/ugn/network_detach.go
@@ -0,0 +1,62 @@
+package ugn
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ ugnsdk "github.com/ucloud/ucloud-sdk-go/services/ugn"
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// newNetworkDetach ucloud ugn network detach
+func newNetworkDetach(ctx *cli.Context) *cobra.Command {
+ client := cli.NewServiceClient(ctx, ugnsdk.NewClient)
+ req := client.NewDetachUGNNetworksRequest()
+
+ var networkIDs []string
+ var yes bool
+
+ cmd := &cobra.Command{
+ Use: "detach",
+ Short: "Detach network instances from ugn",
+ Long: "Detach network instances from ugn",
+ Run: func(c *cobra.Command, args []string) {
+ ok, err := ctx.Confirm(yes, "Are you sure you want to detach the network instance(s)?")
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ if !ok {
+ return
+ }
+ req.UGNID = sdk.String(ctx.PickResourceID(*req.UGNID))
+ req.Networks = make([]string, 0, len(networkIDs))
+ for _, id := range networkIDs {
+ req.Networks = append(req.Networks, ctx.PickResourceID(id))
+ }
+
+ _, err = client.DetachUGNNetworks(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ fmt.Fprintf(ctx.ProgressWriter(), "networks detached from ugn[%s]\n", *req.UGNID)
+ ctx.EmitResult(cli.OpResultRow{ResourceID: *req.UGNID, Action: "detach-network", Status: "Detached"})
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ flags.StringSliceVar(&networkIDs, "network-id", nil, "Required. Network IDs, e.g. vnet-xxxxx. Repeatable or comma-separated.")
+ req.UGNID = flags.String("ugn-id", "", "Required. Resource ID of the ugn instance")
+ flags.BoolVarP(&yes, "yes", "y", false, "Optional. Skip the confirmation prompt.")
+
+ cmd.MarkFlagRequired("network-id")
+ cmd.MarkFlagRequired("ugn-id")
+
+ return cmd
+}
diff --git a/products/ugn/internal/ugn/region.go b/products/ugn/internal/ugn/region.go
new file mode 100644
index 0000000000..38afcef1f0
--- /dev/null
+++ b/products/ugn/internal/ugn/region.go
@@ -0,0 +1,20 @@
+package ugn
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// newRegion ucloud ugn region
+func newRegion(ctx *cli.Context) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "region",
+ Short: "List and manipulate ugn regions",
+ Long: "List and manipulate ugn regions",
+ }
+
+ cmd.AddCommand(newRegionList(ctx))
+
+ return cmd
+}
diff --git a/products/ugn/internal/ugn/region_list.go b/products/ugn/internal/ugn/region_list.go
new file mode 100644
index 0000000000..71cdd61b85
--- /dev/null
+++ b/products/ugn/internal/ugn/region_list.go
@@ -0,0 +1,47 @@
+package ugn
+
+import (
+ "github.com/spf13/cobra"
+
+ ugnsdk "github.com/ucloud/ucloud-sdk-go/services/ugn"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// regionRow is the table row for `ucloud ugn region list`.
+type regionRow struct {
+ Region string
+ RegionID string
+ IsOnline bool
+ IsOverseas bool
+}
+
+// newRegionList ucloud ugn region list
+func newRegionList(ctx *cli.Context) *cobra.Command {
+ client := cli.NewServiceClient(ctx, ugnsdk.NewClient)
+ req := client.NewListUGNRegionsRequest()
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List ugn regions",
+ Long: "List ugn regions",
+ Run: func(c *cobra.Command, args []string) {
+ resp, err := client.ListUGNRegions(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ rows := make([]regionRow, 0, len(resp.RegionLIst))
+ for _, r := range resp.RegionLIst {
+ rows = append(rows, regionRow{
+ Region: r.Region,
+ RegionID: r.RegIonId,
+ IsOnline: r.IsOnline,
+ IsOverseas: r.IsOverseas,
+ })
+ }
+ ctx.PrintList(rows)
+ },
+ }
+
+ return cmd
+}
diff --git a/products/ugn/internal/ugn/route.go b/products/ugn/internal/ugn/route.go
new file mode 100644
index 0000000000..71cde471e7
--- /dev/null
+++ b/products/ugn/internal/ugn/route.go
@@ -0,0 +1,92 @@
+package ugn
+
+import (
+ "fmt"
+ "strconv"
+
+ "github.com/spf13/cobra"
+
+ ugnsdk "github.com/ucloud/ucloud-sdk-go/services/ugn"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// routeRow is the table row for routes in ugn route.
+type routeRow struct {
+ DstAddr string
+ NextHopID string
+ NextHopType string
+ NextHopRegion string
+ Priority int
+ Conflict string
+ Deny string
+ Restrict string
+}
+
+// newRoute ucloud ugn route
+func newRoute(ctx *cli.Context) *cobra.Command {
+ client := cli.NewServiceClient(ctx, ugnsdk.NewClient)
+ req := client.NewGetUGNRouteTableRequest()
+
+ cmd := &cobra.Command{
+ Use: "route",
+ Short: "Show route table of one ugn instance",
+ Long: "Show route table of one ugn instance",
+ Run: func(c *cobra.Command, args []string) {
+ *req.UGNID = ctx.PickResourceID(*req.UGNID)
+ resp, err := client.GetUGNRouteTable(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+
+ if *req.Type == "Final" {
+ for _, vr := range resp.VRoutes {
+ fmt.Fprintf(ctx.ProgressWriter(), "Network %s (%d routes):\n", vr.NetworkId, len(vr.Routes))
+ printRouteRows(ctx, vr.Routes)
+ fmt.Fprintln(ctx.ProgressWriter())
+ }
+ } else {
+ fmt.Fprintf(ctx.ProgressWriter(), "Route Table (%s, %d routes):\n", *req.Type, len(resp.Routes))
+ printRouteRows(ctx, resp.Routes)
+ }
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ req.UGNID = flags.String("ugn-id", "", "Required. Resource ID of the ugn instance")
+ req.Type = flags.String("type", "Final", "Route table type: Origin/Middle/Final")
+ req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Project-id, see 'ucloud project list'")
+
+ cmd.MarkFlagRequired("ugn-id")
+ ctx.SetCompletion(cmd, "ugn-id", func() []string {
+ return getAllUGNIdNames(ctx, *req.ProjectId)
+ })
+ ctx.SetCompletion(cmd, "project-id", ctx.ProjectList)
+ command.SetFlagValues(cmd, "type", "Origin", "Middle", "Final")
+
+ return cmd
+}
+
+func printRouteRows(ctx *cli.Context, routes []ugnsdk.SimpleRoute) {
+ if len(routes) == 0 {
+ return
+ }
+ rows := make([]routeRow, 0, len(routes))
+ for _, r := range routes {
+ rows = append(rows, routeRow{
+ DstAddr: r.DstAddr,
+ NextHopID: r.NextHopID,
+ NextHopType: r.NextHopType,
+ NextHopRegion: r.NextHopRegion,
+ Priority: r.Priority,
+ Conflict: strconv.FormatBool(r.Conflict),
+ Deny: strconv.FormatBool(r.Deny),
+ Restrict: strconv.FormatBool(r.Restrict),
+ })
+ }
+ ctx.PrintList(rows)
+}
diff --git a/products/ugn/internal/ugn/rows.go b/products/ugn/internal/ugn/rows.go
new file mode 100644
index 0000000000..9c80df6ed3
--- /dev/null
+++ b/products/ugn/internal/ugn/rows.go
@@ -0,0 +1,17 @@
+package ugn
+
+import "github.com/ucloud/ucloud-cli/internal/common"
+
+// UGNRow is the table row for `ucloud ugn list`.
+type UGNRow struct {
+ ResourceID string
+ Name string
+ Remark string
+ NetworkCount int
+ BwPackageCount int
+ CreateTime string
+}
+
+func formatUGNCreateTime(t int) string {
+ return common.FormatDate(t)
+}
diff --git a/products/ugn/product.go b/products/ugn/product.go
new file mode 100644
index 0000000000..1f30a3e129
--- /dev/null
+++ b/products/ugn/product.go
@@ -0,0 +1,22 @@
+package ugn
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ internalugn "github.com/ucloud/ucloud-cli/products/ugn/internal/ugn"
+)
+
+type product struct{}
+
+func New() cli.Product { return product{} }
+
+func (product) Metadata() cli.Metadata {
+ return cli.Metadata{Name: "ugn", Commands: []string{"ugn"}}
+}
+
+func (product) NewCommand(ctx *cli.Context) []*cobra.Command {
+ return []*cobra.Command{internalugn.NewCommand(ctx)}
+}
+
+var _ cli.Product = (*product)(nil)
diff --git a/products/ugn/product.yaml b/products/ugn/product.yaml
new file mode 100644
index 0000000000..b66bd83d0a
--- /dev/null
+++ b/products/ugn/product.yaml
@@ -0,0 +1,6 @@
+name: ugn
+owners:
+ - kyler-67
+commands:
+ - ugn
+enabled: true
diff --git a/products/ugn/testdata/cmdtree.golden b/products/ugn/testdata/cmdtree.golden
new file mode 100644
index 0000000000..1df7fcbe92
--- /dev/null
+++ b/products/ugn/testdata/cmdtree.golden
@@ -0,0 +1,60 @@
+ucloud ugn use=ugn short=List and manipulate ugn instances
+ucloud ugn bw use=bw short=List and manipulate ugn bandwidth packages
+ucloud ugn bw create use=create short=Create a ugn bandwidth package
+ flag=bandwidth short= default=0 required=true
+ flag=charge-type short= default= required=true
+ flag=coupon-id short= default= required=
+ flag=name short= default= required=
+ flag=path short= default=IGP required=
+ flag=pay-mode short= default= required=true
+ flag=project-id short= default= required=
+ flag=qos short= default=Platinum required=
+ flag=quantity short= default=1 required=
+ flag=region-a short= default= required=true
+ flag=region-b short= default= required=true
+ flag=ugn-id short= default= required=true
+ucloud ugn bw delete use=delete short=Delete ugn bandwidth packages
+ flag=bw-package-id short= default= required=true
+ flag=ugn-id short= default= required=true
+ flag=yes short=y default=false required=
+ucloud ugn bw list use=list short=List ugn bandwidth packages
+ flag=limit short= default=50 required=
+ flag=offset short= default=0 required=
+ flag=project-id short= default= required=
+ flag=ugn-id short= default= required=true
+ucloud ugn bw modify-bandwidth use=modify-bandwidth short=Modify ugn bandwidth
+ flag=bandwidth short= default=0 required=true
+ flag=package-id short= default= required=true
+ flag=ugn-id short= default= required=true
+ucloud ugn create use=create short=Create a ugn instance
+ flag=name short= default= required=true
+ flag=project-id short= default= required=
+ flag=remark short= default= required=
+ucloud ugn delete use=delete short=Delete ugn instances
+ flag=project-id short= default= required=
+ flag=ugn-id short= default=[] required=true
+ flag=yes short=y default=false required=
+ucloud ugn get use=get short=Show details of one ugn instance
+ flag=project-id short= default= required=
+ flag=ugn-id short= default= required=true
+ucloud ugn list use=list short=List ugn instances
+ flag=limit short= default=50 required=
+ flag=offset short= default=0 required=
+ flag=project-id short= default= required=
+ucloud ugn network use=network short=Manage ugn network instances
+ucloud ugn network attach use=attach short=Attach network instances to ugn
+ flag=network-id short= default=[] required=true
+ flag=network-project-id short= default=[] required=true
+ flag=network-region short= default=[] required=true
+ flag=network-type short= default=[] required=true
+ flag=ugn-id short= default= required=true
+ucloud ugn network detach use=detach short=Detach network instances from ugn
+ flag=network-id short= default=[] required=true
+ flag=ugn-id short= default= required=true
+ flag=yes short=y default=false required=
+ucloud ugn region use=region short=List and manipulate ugn regions
+ucloud ugn region list use=list short=List ugn regions
+ucloud ugn route use=route short=Show route table of one ugn instance
+ flag=project-id short= default= required=
+ flag=type short= default=Final required=
+ flag=ugn-id short= default= required=true
diff --git a/products/ugn/testdata/completion.golden b/products/ugn/testdata/completion.golden
new file mode 100644
index 0000000000..56aadad966
--- /dev/null
+++ b/products/ugn/testdata/completion.golden
@@ -0,0 +1,16 @@
+ucloud ugn bw create charge-type static Month,Postpay
+ucloud ugn bw create path static Delay,IGP,TCO
+ucloud ugn bw create pay-mode static FixedBw,Max5,Traffic
+ucloud ugn bw create project-id dynamic
+ucloud ugn bw create qos static Diamond,Gold,Platinum
+ucloud ugn bw create ugn-id dynamic
+ucloud ugn bw list project-id dynamic
+ucloud ugn bw list ugn-id dynamic
+ucloud ugn create project-id dynamic
+ucloud ugn delete project-id dynamic
+ucloud ugn delete ugn-id dynamic
+ucloud ugn get project-id dynamic
+ucloud ugn get ugn-id dynamic
+ucloud ugn route project-id dynamic
+ucloud ugn route type static Final,Middle,Origin
+ucloud ugn route ugn-id dynamic
diff --git a/products/uhadoop/TEST.md b/products/uhadoop/TEST.md
new file mode 100644
index 0000000000..27591f55ab
--- /dev/null
+++ b/products/uhadoop/TEST.md
@@ -0,0 +1,241 @@
+## UHadoop CLI 测试清单
+
+### 准备工作
+
+```bash
+# 1. 确认凭据已配置
+ucloud config list
+
+# 2. 查看可用地域
+ucloud region
+
+
+# 3. 替换测试参数
+# --region cn-bj2 --zone cn-bj2-02
+```
+
+---
+
+### 一、查询命令
+
+#### 1.1 list-framework-app — 查询框架和应用
+
+```bash
+go run . uhadoop list-framework-app --region --zone
+go run . uhadoop list-framework-app --region --zone --output json
+```
+
+#### 1.2 list-node-type — 查询可用机型
+
+```bash
+go run . uhadoop list-node-type --region --zone
+go run . uhadoop list-node-type --region --zone --node-role master
+go run . uhadoop list-node-type --region --zone --framework Hadoop --framework-version 3.3.4-udh3.2
+```
+
+#### 1.3 list — 列出集群
+
+```bash
+go run . uhadoop list --region
+go run . uhadoop list --region --zone --limit 5
+go run . uhadoop list --region --id-only
+go run . uhadoop list --all-region --output json
+```
+
+#### 1.4 describe — 查询集群详情
+
+```bash
+go run . uhadoop describe --region --zone
+go run . uhadoop describe --region --zone --output json
+```
+
+---
+
+### 二、修改命令
+
+> ⚠️ 会对真实资源产生修改
+
+#### 2.1 create — 创建集群
+
+| 参数 | 必填 | 说明 |
+|------|:--:|------|
+| `--region` | ✅ | 地域 |
+| `--zone` | ✅ | 可用区 |
+| `--name` | ✅ | 集群名称 |
+| `--framework` | ✅ | Hadoop / HDFS / MR / StarRocks-* |
+| `--framework-version` | ✅ | 如 `3.3.4-udh3.2` |
+| `--password` | ✅ | 登录密码(明文,自动 base64) |
+| `--master-node-type` | | Master 机型,默认 `o.hadoop4m.xlarge` |
+| `--core-node-type` | | Core 机型,默认 `o.hadoop2m.xlarge` |
+| `--task-node-type` | | Task 机型(可选) |
+| `--master-count` | | 默认 2(StarRocks 默认 3) |
+| `--core-count` | | 默认 3 |
+| `--task-count` | | 默认 0 |
+| `--cluster-case` | | Spark / Hbase / Core-Hadoop |
+| `--app-config` | | 手动指定组件,格式 `App#Version` |
+| `--master-boot-disk-size` | | 系统盘 GB,默认 50 |
+| `--master-data-disk-size` | | 数据盘 GB,默认 100 |
+| `--core-boot-disk-size` | | 系统盘 GB,默认 50 |
+| `--core-data-disk-size` | | 数据盘 GB,默认 200 |
+| `--vpc-id` | | 可选 |
+| `--subnet-id` | | 可选 |
+| `--async` | | 异步模式,不等待创建完成 |
+
+```bash
+# Spark 模板创建
+go run . uhadoop create \
+ --region --zone \
+ --name test-spark --framework Hadoop \
+ --framework-version 3.3.4-udh3.2 \
+ --password 'YourPass123!' \
+ --cluster-case Spark
+
+# 手动指定 app-config
+go run . uhadoop create \
+ --region --zone \
+ --name test-manual --framework Hadoop \
+ --framework-version 3.3.4-udh3.2 \
+ --password 'YourPass123!' \
+ --app-config Spark#3.5.3 --app-config Hive#3.1.3
+
+# MR 集群(需要 --storage-cluster-id)
+go run . uhadoop create \
+ --region --zone \
+ --name test-mr --framework MR \
+ --framework-version 3.3.4-udh3.2 \
+ --password 'YourPass123!' \
+ --storage-cluster-id \
+ --cluster-case Spark
+```
+
+#### 2.2 delete — 删除集群
+
+| 参数 | 必填 | 说明 |
+|------|:--:|------|
+| `` | ✅ | 位置参数 |
+| `--region` | ✅ | 地域 |
+| `--zone` | ✅ | 可用区 |
+| `--release-eip` | | 释放 EIP |
+| `--yes` / `-y` | | 跳过确认 |
+
+```bash
+go run . uhadoop delete --region --zone
+go run . uhadoop delete --region --zone --release-eip -y
+```
+
+#### 2.3 add-node — 扩容节点
+
+| 参数 | 必填 | 说明 |
+|------|:--:|------|
+| `--region` | ✅ | 地域 |
+| `--zone` | ✅ | 可用区 |
+| `--instance-id` | ✅ | 集群 ID |
+| `--node-role` | ✅ | core / task / client |
+| `--node-type` | ✅ | 机型 |
+| `--node-count` | | 默认 1 |
+| `--password` | | Client 角色需要(明文) |
+| `--boot-disk-size` | | 系统盘 GB,默认 50 |
+| `--data-disk-size` | | 数据盘 GB,默认 200 |
+| `--async` | | 异步模式 |
+
+```bash
+go run . uhadoop add-node \
+ --region --zone \
+ --instance-id --node-role core \
+ --node-type o.hadoop2m.xlarge
+```
+
+#### 2.4 restart-service — 启停服务
+
+| 参数 | 必填 | 说明 |
+|------|:--:|------|
+| `--region` | ✅ | 地域 |
+| `--zone` | ✅ | 可用区 |
+| `--instance-id` | ✅ | 集群 ID |
+| `--service-name` | ✅ | 服务名 |
+| `--only-start` / `--only-stop` | | 只启/只停 |
+| `--yes` / `-y` | | 跳过确认 |
+
+```bash
+go run . uhadoop restart-service \
+ --region --zone \
+ --instance-id --service-name Hive
+```
+
+#### 2.5 upgrade-node — 升级节点
+
+| 参数 | 必填 | 说明 |
+|------|:--:|------|
+| `--region` | ✅ | 地域 |
+| `--zone` | ✅ | 可用区 |
+| `--instance-id` | ✅ | 集群 ID |
+| `--node-role` | ✅ | master / core / task / client |
+| `--node-type` | ✅ | 新机型 |
+| `--node-name` | | 节点名(非 master 必填) |
+| `--yes` / `-y` | | 跳过确认 |
+
+```bash
+go run . uhadoop upgrade-node \
+ --region --zone \
+ --instance-id --node-role master \
+ --node-type o.hadoop2m.xlarge
+```
+
+#### 2.6 upgrade-disk — 扩容磁盘
+
+| 参数 | 必填 | 说明 |
+|------|:--:|------|
+| `--region` | ✅ | 地域 |
+| `--zone` | ✅ | 可用区 |
+| `--instance-id` | ✅ | 集群 ID |
+| `--node-role` | ✅ | master / core / task / client |
+| `--data-disk-size` | ✅ | 新数据盘大小 GB |
+| `--boot-disk-size` | | 新系统盘大小 GB |
+| `--node-name` | | 节点名(非 master 必填) |
+| `--yes` / `-y` | | 跳过确认 |
+
+```bash
+go run . uhadoop upgrade-disk \
+ --region --zone \
+ --instance-id --node-role core \
+ --data-disk-size 500 --node-name -core1
+```
+
+---
+
+### 三、校验
+
+```bash
+# 编译
+go build ./...
+
+# golden 测试
+GOROOT=/Users/user/.g/go GOTOOLCHAIN=local go test ./hack/snapshot -v -run 'uhadoop'
+
+# 缺参立即报错
+go run . uhadoop create --name test
+# → required flag(s) "framework", "framework-version", "password", "region", "zone" not set
+```
+
+---
+
+### 测试结果
+
+| # | 命令 | 状态 |
+|---|------|------|
+| 1 | `list-framework-app` | ✅ |
+| 2 | `list-node-type` | ✅ |
+| 3 | `list` | ✅ |
+| 4 | `describe` | ✅ |
+| 5 | `create --cluster-case Spark` | ✅ |
+| 6 | `create --app-config ...` | ✅ |
+| 7 | `delete` | ✅ |
+| 8 | `add-node` | ✅ |
+| 9 | `restart-service` | ✅ |
+| 10 | `upgrade-node` | ✅ |
+| 11 | `upgrade-disk` | ✅ |
+| 12 | `create` 缺参 | ✅ |
+| 13 | `go build ./...` | ✅ |
+| 14 | `go test ./hack/snapshot -v` | ✅ |
+
+> ⬜ = 待测试 ✅ = 通过 ❌ = 失败
diff --git a/products/uhadoop/internal/uhadoop/add_node.go b/products/uhadoop/internal/uhadoop/add_node.go
new file mode 100644
index 0000000000..4619f8108c
--- /dev/null
+++ b/products/uhadoop/internal/uhadoop/add_node.go
@@ -0,0 +1,75 @@
+package uhadoop
+
+import (
+ "encoding/base64"
+ "fmt"
+ "time"
+
+ "github.com/spf13/cobra"
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+
+ uhadoopsdk "github.com/ucloud/ucloud-sdk-go/services/uhadoop"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+func newAddNode(ctx *cli.Context) *cobra.Command {
+ var (
+ async *bool
+ rawPassword string
+ )
+ client := cli.NewServiceClient(ctx, uhadoopsdk.NewClient)
+ req := client.NewAddUHadoopInstanceNodeRequest()
+ cmd := &cobra.Command{
+ Use: "add-node",
+ Short: "Add nodes to a UHadoop cluster",
+ Long: `Add a number of nodes to an existing UHadoop cluster`,
+ SilenceUsage: true,
+ Run: func(cmd *cobra.Command, args []string) {
+ w := ctx.ProgressWriter()
+ if rawPassword != "" {
+ req.Password = sdk.String(base64.StdEncoding.EncodeToString([]byte(rawPassword)))
+ }
+ resp, err := client.AddUHadoopInstanceNode(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ if resp.RetCode != 0 {
+ ctx.HandleError(fmt.Errorf("[%d] %s", resp.RetCode, resp.Message))
+ return
+ }
+ text := fmt.Sprintf("uhadoop[%s] adding %d %s node(s)", *req.InstanceId, *req.NodeCount, *req.NodeRole)
+ if *async {
+ fmt.Fprintln(w, text)
+ } else {
+ ctx.PollerTo(w, describeClusterForPoll(ctx, client), cli.WithTimeout(60*time.Minute)).Spoll(*req.InstanceId, text, []string{stateRunning})
+ }
+ ctx.EmitResult(cli.OpResultRow{ResourceID: *req.InstanceId, Action: "add-node", Status: "Scaling"})
+ },
+ }
+ flags := cmd.Flags()
+ flags.SortFlags = false
+ req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region")
+ req.Zone = flags.String("zone", "", "Optional. Assign availability zone")
+ req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id")
+ req.InstanceId = flags.String("instance-id", "", "Required. Cluster instance ID")
+ req.NodeRole = flags.String("node-role", "", "Required. Node role: core|task|client")
+ req.NodeType = flags.String("node-type", "", "Required. Node type")
+ req.NodeCount = flags.Int("node-count", 1, "Number of nodes, default 1")
+ flags.StringVar(&rawPassword, "password", "", "Login password (client role requires)")
+ req.BootDiskSize = flags.String("boot-disk-size", "50", "Boot disk GB, default 50")
+ req.BootDiskType = flags.String("boot-disk-type", "CLOUD_RSSD", "Boot disk type, default CLOUD_RSSD")
+ req.DataDiskSize = flags.String("data-disk-size", "200", "Data disk GB, default 200")
+ req.DataDiskNum = flags.String("data-disk-num", "1", "Data disk num, default 1")
+ req.DataDiskType = flags.String("data-disk-type", "CLOUD_RSSD", "Data disk type, default CLOUD_RSSD")
+ async = flags.Bool("async", false, "Optional. Do not wait for node addition to finish")
+ command.SetFlagValues(cmd, "node-role", "core", "task", "client")
+ cmd.MarkFlagRequired("instance-id")
+ cmd.MarkFlagRequired("node-role")
+ cmd.MarkFlagRequired("node-type")
+ cmd.MarkFlagRequired("region")
+ cmd.MarkFlagRequired("zone")
+ return cmd
+}
diff --git a/products/uhadoop/internal/uhadoop/cmd.go b/products/uhadoop/internal/uhadoop/cmd.go
new file mode 100644
index 0000000000..0694aaa552
--- /dev/null
+++ b/products/uhadoop/internal/uhadoop/cmd.go
@@ -0,0 +1,28 @@
+package uhadoop
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// NewCommand builds the top-level `uhadoop` command.
+func NewCommand(ctx *cli.Context) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "uhadoop",
+ Short: "List,create,delete,describe UHadoop clusters and manage nodes and services",
+ Long: `List,create,delete,describe UHadoop clusters and manage nodes and services`,
+ Args: cobra.NoArgs,
+ }
+ cmd.AddCommand(newList(ctx))
+ cmd.AddCommand(newDescribe(ctx))
+ cmd.AddCommand(newCreate(ctx))
+ cmd.AddCommand(newDelete(ctx))
+ cmd.AddCommand(newAddNode(ctx))
+ cmd.AddCommand(newListNodeType(ctx))
+ cmd.AddCommand(newListFrameworkApp(ctx))
+ cmd.AddCommand(newRestartService(ctx))
+ cmd.AddCommand(newUpgradeNode(ctx))
+ cmd.AddCommand(newUpgradeDisk(ctx))
+ return cmd
+}
diff --git a/products/uhadoop/internal/uhadoop/create.go b/products/uhadoop/internal/uhadoop/create.go
new file mode 100644
index 0000000000..13a12a0f75
--- /dev/null
+++ b/products/uhadoop/internal/uhadoop/create.go
@@ -0,0 +1,296 @@
+package uhadoop
+
+import (
+ "encoding/base64"
+ "fmt"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-sdk-go/ucloud/request"
+ "github.com/ucloud/ucloud-sdk-go/ucloud/response"
+
+ uhadoopsdk "github.com/ucloud/ucloud-sdk-go/services/uhadoop"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+type instanceGroupConfig struct {
+ NodeRole string `json:"NodeRole"`
+ NodeType string `json:"NodeType"`
+ Count int `json:"Count"`
+ DataDiskSize int `json:"DataDiskSize"`
+ DataDiskNum int `json:"DataDiskNum"`
+ DataDiskType string `json:"DataDiskType"`
+ BootDiskSize int `json:"BootDiskSize"`
+ BootDiskType string `json:"BootDiskType"`
+}
+
+type createRequest struct {
+ request.CommonBase
+ InstanceName string `json:"InstanceName"`
+ Framework string `json:"Framework"`
+ FrameworkVersion string `json:"FrameworkVersion"`
+ Password string `json:"Password"`
+ VPCId string `json:"VPCId"`
+ SubnetId string `json:"SubnetId"`
+ ChargeType string `json:"ChargeType,omitempty"`
+ Quantity int `json:"Quantity,omitempty"`
+ BusinessId string `json:"BusinessId,omitempty"`
+ StorgeClusterId string `json:"StorgeClusterId,omitempty"`
+ StandAloneMetaStore string `json:"StandAloneMetaStore,omitempty"`
+ IsSecurityEnabled string `json:"IsSecurityEnabled,omitempty"`
+ SecGroupIds string `json:"SecGroupIds,omitempty"`
+ US3Bucket string `json:"US3Bucket,omitempty"`
+ US3AccessKey string `json:"US3AccessKey,omitempty"`
+ US3SecretKey string `json:"US3SecretKey,omitempty"`
+ US3TokenName string `json:"US3TokenName,omitempty"`
+ AppConfigs []string `json:"AppConfigs"`
+ InstanceGroupConfigs []instanceGroupConfig `json:"InstanceGroupConfigs"`
+}
+
+type createResponse struct {
+ response.CommonBase
+ InstanceId string `json:"InstanceId"`
+}
+
+var clusterCaseApps = map[string]map[string][]string{
+ "3.3.4-udh3.2": {
+ "Spark": {"Spark#3.5.3", "Hive#3.1.3", "Hue#4.11.0", "Zookeeper#3.8.4", "Mysql#8.0.32", "Yarn#3.3.4"},
+ "Hbase": {"Hbase#2.4.18", "Hue#4.11.0", "Zookeeper#3.8.4", "Mysql#8.0.32", "Yarn#3.3.4", "Phoenix#5.2.1"},
+ "Core-Hadoop": {"Hive#3.1.3", "Hue#4.11.0", "Zookeeper#3.8.4", "Yarn#3.3.4"},
+ },
+ "3.3.4-udh3.1": {
+ "Spark": {"Spark#3.5.3", "Hive#3.1.3", "Hue#4.11.0", "Zookeeper#3.8.4", "Mysql#8.0.32", "Yarn#3.3.4"},
+ "Hbase": {"Hbase#2.4.18", "Hue#4.11.0", "Zookeeper#3.8.4", "Mysql#8.0.32", "Yarn#3.3.4", "Phoenix#5.2.1"},
+ "Core-Hadoop": {"Hive#3.1.3", "Hue#4.11.0", "Zookeeper#3.8.4", "Yarn#3.3.4"},
+ },
+ "3.2.1-udh3.0": {
+ "Spark": {"Spark#3.3.0", "Hive#3.1.3", "Hue#4.7.1", "Zookeeper#3.6.3", "Mysql#5.6.47", "Yarn#3.2.1"},
+ "Hbase": {"Hbase#2.2.4", "Hue#4.7.1", "Zookeeper#3.6.3", "Mysql#5.6.47", "Yarn#3.2.1", "Phoenix#5.1.2"},
+ "Core-Hadoop": {"Hive#3.1.3", "Hue#4.7.1", "Zookeeper#3.6.3", "Yarn#3.2.1"},
+ },
+ "2.8.5-udh2.2": {
+ "Spark": {"Spark#2.4.6", "Hive#2.3.6", "Hue#4.7.1", "Zookeeper#3.4.13", "Mysql#5.6.47", "Yarn#2.8.5"},
+ "Hbase": {"Hbase#1.4.10", "Hue#4.7.1", "Zookeeper#3.4.13", "Mysql#5.6.47", "Yarn#2.8.5", "Phoenix#4.14.3"},
+ "Core-Hadoop": {"Hive#2.3.6", "Hue#4.7.1", "Zookeeper#3.4.13", "Yarn#2.8.5"},
+ },
+ "2.6.0-cdh5.13.3": {
+ "Spark": {"Spark#2.4.3", "Hive#2.3.3", "Hue#3.10.0", "Zookeeper#3.4.5", "Mysql#5.1.73", "Yarn#2.6.0"},
+ "Hbase": {"Hbase#1.2.0", "Hue#3.10.0", "Zookeeper#3.4.5", "Mysql#5.1.73", "Yarn#2.6.0", "Phoenix#4.14.0"},
+ "Core-Hadoop": {"Hive#2.3.3", "Hue#3.10.0", "Zookeeper#3.4.5", "Yarn#2.6.0"},
+ },
+ "2.6.0-cdh5.4.9": {
+ "Spark": {"Spark#2.0.1", "Hive#1.2.1", "Hue#3.10.0", "Zookeeper#3.4.5", "Mysql#5.1.73", "Yarn#2.6.0"},
+ "Hbase": {"Hbase#1.0.0", "Hue#3.10.0", "Zookeeper#3.4.5", "Mysql#5.1.73", "Yarn#2.6.0", "Phoenix#4.6.0"},
+ "Core-Hadoop": {"Hive#1.2.1", "Hue#3.10.0", "Zookeeper#3.4.5", "Yarn#2.6.0"},
+ },
+}
+
+var hdfsVersionByFramework = map[string]string{
+ "3.3.4-udh3.2": "Hdfs#3.3.4",
+ "3.3.4-udh3.1": "Hdfs#3.3.4",
+ "3.2.1-udh3.0": "Hdfs#3.2.1",
+ "2.8.5-udh2.2": "Hdfs#2.8.5",
+ "2.6.0-cdh5.13.3": "Hdfs#2.6.0",
+ "2.6.0-cdh5.4.9": "Hdfs#2.6.0",
+}
+
+func newCreate(ctx *cli.Context) *cobra.Command {
+ var (
+ async *bool
+ rawPassword string
+ clusterCase string
+ master instanceGroupConfig
+ core instanceGroupConfig
+ task instanceGroupConfig
+ )
+ client := cli.NewServiceClient(ctx, uhadoopsdk.NewClient)
+ sdkReq := client.NewCreateUHadoopInstanceRequest()
+ cmd := &cobra.Command{
+ Use: "create",
+ Short: "Create a UHadoop cluster",
+ Long: `Create a UHadoop cluster with specified configuration`,
+ SilenceUsage: true,
+ Run: func(cmd *cobra.Command, args []string) {
+ w := ctx.ProgressWriter()
+
+ if sdkReq.Framework != nil && (*sdkReq.Framework == "StarRocks-Shared-Nothing" || *sdkReq.Framework == "StarRocks-Shared-Data") {
+ if !cmd.Flags().Changed("master-count") {
+ master.Count = 3
+ }
+ }
+ groups := buildGroups(master, core, task)
+ if len(groups) == 0 {
+ ctx.HandleError(fmt.Errorf("at least one node group is required"))
+ return
+ }
+
+ appConfigs := sdkReq.AppConfigs
+ if clusterCase != "" {
+ versionMap, ok := clusterCaseApps[*sdkReq.FrameworkVersion]
+ if !ok {
+ ctx.HandleError(fmt.Errorf("unsupported framework-version %q for --cluster-case", *sdkReq.FrameworkVersion))
+ return
+ }
+ template, ok := versionMap[clusterCase]
+ if !ok {
+ ctx.HandleError(fmt.Errorf("no cluster-case %q for framework-version %q", clusterCase, *sdkReq.FrameworkVersion))
+ return
+ }
+ appConfigs = append([]string(nil), template...)
+ if sdkReq.Framework != nil && *sdkReq.Framework == "Hadoop" {
+ if hdfsVer, ok := hdfsVersionByFramework[*sdkReq.FrameworkVersion]; ok {
+ appConfigs = append(appConfigs, hdfsVer)
+ }
+ }
+ }
+
+ req := &createRequest{
+ InstanceName: *sdkReq.InstanceName,
+ Framework: *sdkReq.Framework,
+ FrameworkVersion: *sdkReq.FrameworkVersion,
+ Password: base64.StdEncoding.EncodeToString([]byte(rawPassword)),
+ VPCId: *sdkReq.VPCId,
+ SubnetId: *sdkReq.SubnetId,
+ AppConfigs: appConfigs,
+ InstanceGroupConfigs: groups,
+ }
+ req.Region = sdkReq.Region
+ req.Zone = sdkReq.Zone
+ req.ProjectId = sdkReq.ProjectId
+ if sdkReq.ChargeType != nil {
+ req.ChargeType = *sdkReq.ChargeType
+ }
+ if sdkReq.Quantity != nil {
+ req.Quantity = *sdkReq.Quantity
+ }
+ if sdkReq.BusinessId != nil {
+ req.BusinessId = *sdkReq.BusinessId
+ }
+ if sdkReq.StorgeClusterId != nil {
+ req.StorgeClusterId = *sdkReq.StorgeClusterId
+ }
+ if sdkReq.StandAloneMetaStore != nil {
+ req.StandAloneMetaStore = *sdkReq.StandAloneMetaStore
+ }
+ if sdkReq.IsSecurityEnabled != nil {
+ req.IsSecurityEnabled = *sdkReq.IsSecurityEnabled
+ }
+ if sdkReq.SecGroupIds != nil {
+ req.SecGroupIds = *sdkReq.SecGroupIds
+ }
+ if sdkReq.US3Bucket != nil {
+ req.US3Bucket = *sdkReq.US3Bucket
+ }
+ if sdkReq.US3AccessKey != nil {
+ req.US3AccessKey = *sdkReq.US3AccessKey
+ }
+ if sdkReq.US3SecretKey != nil {
+ req.US3SecretKey = *sdkReq.US3SecretKey
+ }
+ if sdkReq.US3TokenName != nil {
+ req.US3TokenName = *sdkReq.US3TokenName
+ }
+
+ var resp createResponse
+ err := client.InvokeAction("CreateUHadoopInstance", req, &resp)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ if resp.RetCode != 0 {
+ ctx.HandleError(fmt.Errorf("[%d] %s", resp.RetCode, resp.Message))
+ return
+ }
+ text := fmt.Sprintf("uhadoop[%s] is creating", resp.InstanceId)
+ if *async {
+ fmt.Fprintln(w, text)
+ } else {
+ ctx.PollerTo(w, describeClusterForPoll(ctx, client), cli.WithTimeout(60*time.Minute)).Spoll(resp.InstanceId, text, []string{stateRunning})
+ }
+ ctx.EmitResult(cli.OpResultRow{ResourceID: resp.InstanceId, Action: "create", Status: "Creating"})
+ },
+ }
+ flags := cmd.Flags()
+ flags.SortFlags = false
+ sdkReq.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region")
+ sdkReq.Zone = flags.String("zone", "", "Optional. Assign availability zone")
+ sdkReq.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id")
+ sdkReq.InstanceName = flags.String("name", "", "Required. Instance name")
+ sdkReq.Framework = flags.String("framework", "", "Required. Framework")
+ sdkReq.FrameworkVersion = flags.String("framework-version", "", "Required. Framework version")
+ flags.StringVar(&rawPassword, "password", "", "Required. Login password")
+ sdkReq.VPCId = flags.String("vpc-id", "", "Optional. VPC ID")
+ sdkReq.SubnetId = flags.String("subnet-id", "", "Optional. Subnet ID")
+ sdkReq.ChargeType = flags.String("charge-type", "Month", "Optional. Charge type")
+ sdkReq.Quantity = flags.Int("quantity", 1, "Optional. Quantity")
+ sdkReq.BusinessId = flags.String("business-id", "Default", "Optional. Business group")
+ sdkReq.StorgeClusterId = flags.String("storage-cluster-id", "", "Optional. Storage cluster ID (MR framework)")
+ sdkReq.StandAloneMetaStore = flags.String("meta-store", "", "Optional. Meta store type")
+ sdkReq.IsSecurityEnabled = flags.String("security-enabled", "", "Optional. Enable security group")
+ sdkReq.SecGroupIds = flags.String("sec-group-ids", "", "Optional. Security group IDs")
+ sdkReq.US3Bucket = flags.String("us3-bucket", "", "Optional. US3 bucket")
+ sdkReq.US3AccessKey = flags.String("us3-access-key", "", "Optional. US3 access key")
+ sdkReq.US3SecretKey = flags.String("us3-secret-key", "", "Optional. US3 secret key")
+ sdkReq.US3TokenName = flags.String("us3-token-name", "", "Optional. US3 token name")
+ flags.StringVar(&clusterCase, "cluster-case", "", "Cluster use case: Spark|Hbase|Core-Hadoop")
+
+ master.NodeRole = "master"
+ flags.StringVar(&master.NodeType, "master-node-type", "o.hadoop4m.xlarge", "Master node type")
+ flags.IntVar(&master.Count, "master-count", 2, "Master node count")
+ flags.IntVar(&master.DataDiskSize, "master-data-disk-size", 100, "Master data disk GB")
+ flags.IntVar(&master.DataDiskNum, "master-data-disk-num", 1, "Master data disk num")
+ flags.StringVar(&master.DataDiskType, "master-data-disk-type", "CLOUD_RSSD", "Master data disk type")
+ flags.IntVar(&master.BootDiskSize, "master-boot-disk-size", 50, "Master boot disk GB")
+ flags.StringVar(&master.BootDiskType, "master-boot-disk-type", "CLOUD_RSSD", "Master boot disk type")
+
+ core.NodeRole = "core"
+ flags.StringVar(&core.NodeType, "core-node-type", "o.hadoop2m.xlarge", "Core node type")
+ flags.IntVar(&core.Count, "core-count", 3, "Core node count")
+ flags.IntVar(&core.DataDiskSize, "core-data-disk-size", 200, "Core data disk GB")
+ flags.IntVar(&core.DataDiskNum, "core-data-disk-num", 1, "Core data disk num")
+ flags.StringVar(&core.DataDiskType, "core-data-disk-type", "CLOUD_RSSD", "Core data disk type")
+ flags.IntVar(&core.BootDiskSize, "core-boot-disk-size", 50, "Core boot disk GB")
+ flags.StringVar(&core.BootDiskType, "core-boot-disk-type", "CLOUD_RSSD", "Core boot disk type")
+
+ task.NodeRole = "task"
+ flags.StringVar(&task.NodeType, "task-node-type", "o.hadoop2m.xlarge", "Optional. Task node type")
+ flags.IntVar(&task.Count, "task-count", 0, "Task node count")
+ flags.IntVar(&task.DataDiskSize, "task-data-disk-size", 200, "Task data disk GB")
+ flags.IntVar(&task.DataDiskNum, "task-data-disk-num", 1, "Task data disk num")
+ flags.StringVar(&task.DataDiskType, "task-data-disk-type", "CLOUD_RSSD", "Task data disk type")
+ flags.IntVar(&task.BootDiskSize, "task-boot-disk-size", 50, "Task boot disk GB")
+ flags.StringVar(&task.BootDiskType, "task-boot-disk-type", "CLOUD_RSSD", "Task boot disk type")
+
+ async = flags.Bool("async", false, "Optional. Do not wait for creation to finish")
+ flags.StringSliceVar(&sdkReq.AppConfigs, "app-config", nil, "App configs: App#Version")
+
+ command.SetFlagValues(cmd, "cluster-case", "Spark", "Hbase", "Core-Hadoop")
+ command.SetFlagValues(cmd, "charge-type", "Month", "Year", "Dynamic")
+ command.SetFlagValues(cmd, "security-enabled", "true", "false")
+
+ cmd.MarkFlagRequired("name")
+ cmd.MarkFlagRequired("framework")
+ cmd.MarkFlagRequired("framework-version")
+ cmd.MarkFlagRequired("password")
+
+ cmd.MarkFlagRequired("region")
+ cmd.MarkFlagRequired("zone")
+ return cmd
+}
+
+func buildGroups(master, core, task instanceGroupConfig) []instanceGroupConfig {
+ var groups []instanceGroupConfig
+ if master.NodeType != "" {
+ groups = append(groups, master)
+ }
+ if core.NodeType != "" {
+ groups = append(groups, core)
+ }
+ if task.NodeType != "" && task.Count > 0 {
+ groups = append(groups, task)
+ }
+ return groups
+}
diff --git a/products/uhadoop/internal/uhadoop/delete.go b/products/uhadoop/internal/uhadoop/delete.go
new file mode 100644
index 0000000000..d9101de96c
--- /dev/null
+++ b/products/uhadoop/internal/uhadoop/delete.go
@@ -0,0 +1,58 @@
+package uhadoop
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+
+ uhadoopsdk "github.com/ucloud/ucloud-sdk-go/services/uhadoop"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+func newDelete(ctx *cli.Context) *cobra.Command {
+ var yes bool
+ client := cli.NewServiceClient(ctx, uhadoopsdk.NewClient)
+ req := client.NewDeleteUHadoopInstanceRequest()
+ cmd := &cobra.Command{
+ Use: "delete ",
+ Short: "Delete a UHadoop cluster",
+ Long: `Delete a UHadoop cluster by instance ID`,
+ Args: cobra.ExactArgs(1),
+ Run: func(cmd *cobra.Command, args []string) {
+ id := args[0]
+ ok, err := ctx.Confirm(yes, fmt.Sprintf("Are you sure you want to delete cluster %s?", id))
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ if !ok {
+ return
+ }
+ w := ctx.ProgressWriter()
+ req.InstanceId = sdk.String(id)
+ _, err = client.DeleteUHadoopInstance(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ fmt.Fprintf(w, "uhadoop[%s] deleted\n", id)
+ ctx.EmitResult(cli.OpResultRow{ResourceID: id, Action: "delete", Status: "Deleted"})
+ },
+ }
+ flags := cmd.Flags()
+ flags.SortFlags = false
+ flags.BoolVarP(&yes, "yes", "y", false, "Optional. Do not prompt for confirmation")
+ req.ReleaseEIP = flags.Bool("release-eip", false, "Optional. Release bound EIP after deletion")
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+ ctx.BindProjectID(cmd, req)
+
+ command.SetFlagValues(cmd, "release-eip", "true", "false")
+ cmd.MarkFlagRequired("region")
+ cmd.MarkFlagRequired("zone")
+
+ return cmd
+}
diff --git a/products/uhadoop/internal/uhadoop/describe.go b/products/uhadoop/internal/uhadoop/describe.go
new file mode 100644
index 0000000000..22cf12ed6c
--- /dev/null
+++ b/products/uhadoop/internal/uhadoop/describe.go
@@ -0,0 +1,78 @@
+package uhadoop
+
+import (
+ "github.com/spf13/cobra"
+
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+ "github.com/ucloud/ucloud-sdk-go/ucloud/response"
+
+ uhadoopsdk "github.com/ucloud/ucloud-sdk-go/services/uhadoop"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+type describeClusterResponse struct {
+ response.CommonBase
+ ClusterSet []describeClusterInfo `json:"ClusterSet"`
+}
+
+type describeClusterInfo struct {
+ InstanceId string `json:"InstanceId"`
+ ClusterInstanceId string `json:"ClusterInstanceId"`
+ InstanceName string `json:"InstanceName"`
+ ClusterInstanceName string `json:"ClusterInstanceName"`
+ FlinkResourceId string `json:"FlinkResourceId"`
+ Framework string `json:"Framework"`
+ FrameworkVersion string `json:"FrameworkVersion"`
+ ReleaseVersion string `json:"ReleaseVersion"`
+ HadoopVersion string `json:"HadoopVersion"`
+ State string `json:"State"`
+ Zone string `json:"Zone"`
+ VPCId string `json:"VPCId"`
+ SubnetId string `json:"SubnetId"`
+ BusinessId string `json:"BusinessId"`
+ ChargeType string `json:"ChargeType"`
+ Tag string `json:"Tag"`
+ CreateTime int64 `json:"CreateTime"`
+ ExpireTime int64 `json:"ExpireTime"`
+ RunningTime int64 `json:"RunningTime"`
+ MasterCount int `json:"MasterCount"`
+ CoreCount int `json:"CoreCount"`
+ TaskCount int `json:"TaskCount"`
+ NodeCount int `json:"NodeCount"`
+ RedundantCount int `json:"RedundantCount"`
+ AppConfigCount int `json:"AppConfigCount"`
+ IsOpenSecGroup bool `json:"IsOpenSecGroup"`
+ HdfsTotal int `json:"HdfsTotal"`
+ HdfsUsed int `json:"HdfsUsed"`
+ NodeSet []interface{} `json:"NodeSet"`
+ AppConfigSet []interface{} `json:"AppConfigSet"`
+}
+
+func newDescribe(ctx *cli.Context) *cobra.Command {
+ client := cli.NewServiceClient(ctx, uhadoopsdk.NewClient)
+ req := client.NewDescribeUHadoopInstanceRequest()
+ cmd := &cobra.Command{
+ Use: "describe ",
+ Short: "Describe a UHadoop cluster",
+ Long: `Describe a UHadoop cluster with detailed information`,
+ SilenceUsage: true,
+ Args: cobra.ExactArgs(1),
+ Run: func(cmd *cobra.Command, args []string) {
+ req.InstanceId = sdk.String(args[0])
+ var resp describeClusterResponse
+ err := client.InvokeAction("DescribeUHadoopInstance", req, &resp)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ ctx.PrintList(resp.ClusterSet)
+ },
+ }
+ cmd.Flags().SortFlags = false
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+ cmd.MarkFlagRequired("region")
+ cmd.MarkFlagRequired("zone")
+ return cmd
+}
diff --git a/products/uhadoop/internal/uhadoop/list.go b/products/uhadoop/internal/uhadoop/list.go
new file mode 100644
index 0000000000..8218c10e0b
--- /dev/null
+++ b/products/uhadoop/internal/uhadoop/list.go
@@ -0,0 +1,205 @@
+package uhadoop
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-sdk-go/ucloud/response"
+
+ uhadoopsdk "github.com/ucloud/ucloud-sdk-go/services/uhadoop"
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+ sdkerror "github.com/ucloud/ucloud-sdk-go/ucloud/error"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// listClusterResponse mirrors the real API response for ListUHadoopInstance.
+// The SDK's ListClusterInfo has CreateTime/ExpireTime as string but the API
+// returns Unix timestamps (int), and misses fields like AutoRenew/HdfsTotal.
+type listClusterResponse struct {
+ response.CommonBase
+ TotalCount int `json:"TotalCount"`
+ ClusterSet []listClusterInfo `json:"ClusterSet"`
+}
+
+type listClusterInfo struct {
+ Zone string `json:"Zone"`
+ InstanceId string `json:"InstanceId"`
+ ClusterInstanceId string `json:"ClusterInstanceId"`
+ InstanceName string `json:"InstanceName"`
+ ClusterInstanceName string `json:"ClusterInstanceName"`
+ FlinkResourceId string `json:"FlinkResourceId"`
+ Framework string `json:"Framework"`
+ FrameworkVersion string `json:"FrameworkVersion"`
+ Remark string `json:"Remark"`
+ CreateTime int64 `json:"CreateTime"`
+ ExpireTime int64 `json:"ExpireTime"`
+ AutoRenew int `json:"AutoRenew"`
+ ChargeType string `json:"ChargeType"`
+ MasterCount int `json:"MasterCount"`
+ CoreCount int `json:"CoreCount"`
+ TaskCount int `json:"TaskCount"`
+ UHostCount int `json:"UHostCount"`
+ RedundantCount int `json:"RedundantCount"`
+ State string `json:"State"`
+ ReleaseVersion string `json:"ReleaseVersion"`
+ HadoopVersion string `json:"HadoopVersion"`
+ VPCId string `json:"VPCId"`
+ SubnetId string `json:"SubnetId"`
+ BusinessId string `json:"BusinessId"`
+ HdfsTotal int `json:"HdfsTotal"`
+ HdfsUsed int `json:"HdfsUsed"`
+}
+
+// newList ucloud uhadoop list
+func newList(ctx *cli.Context) *cobra.Command {
+ var allRegion, idOnly bool
+ client := cli.NewServiceClient(ctx, uhadoopsdk.NewClient)
+ req := client.NewListUHadoopInstanceRequest()
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List all UHadoop clusters",
+ Long: `List all UHadoop clusters`,
+ SilenceUsage: true,
+ Run: func(cmd *cobra.Command, args []string) {
+ clusters, err := getAllClusters(ctx, client, req, allRegion)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ if idOnly {
+ listClusterID(ctx, clusters)
+ } else {
+ listClusters(ctx, clusters, allRegion)
+ }
+ },
+ }
+ cmd.Flags().SortFlags = false
+
+ ctx.BindProjectID(cmd, req)
+ ctx.BindRegion(cmd, req)
+ req.Zone = cmd.Flags().String("zone", "", "Optional. Assign availability zone")
+ req.Limit = cmd.Flags().Int("limit", 60, "Optional. Limit default 60")
+ req.Offset = cmd.Flags().Int("offset", 0, "Optional. Offset default 0")
+ cmd.Flags().BoolVar(&allRegion, "all-region", false, "Optional. Accept values: true or false. List clusters of all regions when assigned true")
+ cmd.Flags().BoolVar(&idOnly, "id-only", false, "Optional. Just display resource id of clusters")
+
+ command.SetFlagValues(cmd, "all-region", "true", "false")
+ command.SetFlagValues(cmd, "id-only", "true", "false")
+
+ return cmd
+}
+
+func getAllClusters(ctx *cli.Context, client *uhadoopsdk.UHadoopClient, req *uhadoopsdk.ListUHadoopInstanceRequest, allRegion bool) ([]listClusterInfo, error) {
+ if allRegion {
+ result := make([]listClusterInfo, 0)
+ regions, err := ctx.AllRegions()
+ if err != nil {
+ return nil, err
+ }
+ for _, region := range regions {
+ _req := *req
+ _req.Region = sdk.String(region)
+ _req.Zone = nil // clear zone when fanning out across regions
+ clusters, err := fetchClustersPageOff(client, &_req)
+ if e, ok := err.(sdkerror.Error); ok && e.Code() == _RetCodeRegionNoPermission {
+ continue
+ }
+ if err != nil {
+ return nil, err
+ }
+ result = append(result, clusters...)
+ }
+ return result, nil
+ }
+
+ var resp listClusterResponse
+ // Use InvokeAction directly with our custom response struct because the
+ // SDK's ListClusterInfo types CreateTime/ExpireTime as string (wrong: int).
+ err := client.InvokeAction("ListUHadoopInstance", req, &resp)
+ if err != nil {
+ return nil, err
+ }
+ return resp.ClusterSet, nil
+}
+
+func fetchClustersPageOff(client *uhadoopsdk.UHadoopClient, req *uhadoopsdk.ListUHadoopInstanceRequest) ([]listClusterInfo, error) {
+ _req := *req
+ result := make([]listClusterInfo, 0)
+ for limit, offset := 60, 0; ; offset += limit {
+ _req.Offset = sdk.Int(offset)
+ _req.Limit = sdk.Int(limit)
+ var resp listClusterResponse
+ err := client.InvokeAction("ListUHadoopInstance", &_req, &resp)
+ if err != nil {
+ return nil, err
+ }
+ result = append(result, resp.ClusterSet...)
+ if len(resp.ClusterSet) < limit {
+ break
+ }
+ }
+ return result, nil
+}
+
+func listClusters(ctx *cli.Context, clusters []listClusterInfo, listAllRegion bool) {
+ list := make([]listRow, 0, len(clusters))
+ for _, c := range clusters {
+ list = append(list, toListRow(c))
+ }
+
+ if ctx.Format() != cli.OutputTable {
+ ctx.PrintList(list)
+ return
+ }
+
+ rows := make([]listRowDefault, 0, len(list))
+ for _, r := range list {
+ rows = append(rows, listRowDefault{
+ InstanceId: r.InstanceId, InstanceName: r.InstanceName,
+ Framework: r.Framework, ReleaseVersion: r.ReleaseVersion,
+ HadoopVersion: r.HadoopVersion, State: r.State,
+ Zone: r.Zone, CreateTime: r.CreateTime, ExpireTime: r.ExpireTime,
+ })
+ }
+ ctx.PrintList(rows)
+}
+
+func toListRow(c listClusterInfo) listRow {
+ return listRow{
+ InstanceId: c.InstanceId,
+ InstanceName: c.InstanceName,
+ Framework: c.Framework,
+ ReleaseVersion: c.ReleaseVersion,
+ HadoopVersion: c.HadoopVersion,
+ State: c.State,
+ Zone: c.Zone,
+ VPCId: c.VPCId,
+ SubnetId: c.SubnetId,
+ ChargeType: c.ChargeType,
+ CreateTime: formatUnixTime(c.CreateTime),
+ ExpireTime: formatUnixTime(c.ExpireTime),
+ }
+}
+
+func formatUnixTime(ts int64) string {
+ if ts <= 0 {
+ return ""
+ }
+ return time.Unix(ts, 0).Format("2006-01-02")
+}
+
+func listClusterID(ctx *cli.Context, clusters []listClusterInfo) {
+ ids := make([]string, 0, len(clusters))
+ for _, c := range clusters {
+ ids = append(ids, c.InstanceId)
+ }
+ fmt.Fprintln(ctx.Out(), strings.Join(ids, ","))
+}
+
+// _RetCodeRegionNoPermission is the SDK RetCode when account lacks permission in the current region.
+const _RetCodeRegionNoPermission = 230
diff --git a/products/uhadoop/internal/uhadoop/list_framework_app.go b/products/uhadoop/internal/uhadoop/list_framework_app.go
new file mode 100644
index 0000000000..daac63d2af
--- /dev/null
+++ b/products/uhadoop/internal/uhadoop/list_framework_app.go
@@ -0,0 +1,105 @@
+package uhadoop
+
+import (
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-sdk-go/ucloud/response"
+
+ uhadoopsdk "github.com/ucloud/ucloud-sdk-go/services/uhadoop"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// frameworkAppResponse mirrors the real API response for
+// ListUHadoopFrameworkAppByUseCase. The SDK's UseCases type has MustHas as
+// string and is missing the Apps field, but the API returns both as []string.
+// We bypass the SDK-generated response type and unmarshal into our own struct.
+type frameworkAppResponse struct {
+ response.CommonBase
+ AppConfigSet []frameworkAppConfigVersion `json:"AppConfigSet"`
+}
+
+type frameworkAppConfigVersion struct {
+ Framework string `json:"Framework"`
+ FrameworkVersion string `json:"FrameworkVersion"`
+ HadoopVersion string `json:"HadoopVersion"`
+ ReleaseVersion string `json:"ReleaseVersion"`
+ UseCases []frameworkUseCaseRaw `json:"UseCases"`
+}
+
+type frameworkUseCaseRaw struct {
+ ClusterCase string `json:"ClusterCase"`
+ Apps []string `json:"Apps"`
+ MustHas []string `json:"MustHas"`
+ AppVersion []frameworkAppEntry `json:"AppVersion"`
+}
+
+type frameworkAppEntry struct {
+ AppName string `json:"AppName"`
+ AppVersion string `json:"AppVersion"`
+}
+
+// newListFrameworkApp ucloud uhadoop list-framework-app
+func newListFrameworkApp(ctx *cli.Context) *cobra.Command {
+ client := cli.NewServiceClient(ctx, uhadoopsdk.NewClient)
+ req := client.NewListUHadoopFrameworkAppByUseCaseRequest()
+ cmd := &cobra.Command{
+ Use: "list-framework-app",
+ Short: "List UHadoop framework apps by use case",
+ Long: `List available UHadoop frameworks and their applications organized by use case`,
+ SilenceUsage: true,
+ Run: func(cmd *cobra.Command, args []string) {
+
+ var resp frameworkAppResponse
+ // Use InvokeAction directly with our custom response struct because
+ // the SDK's typed response has MustHas as string (wrong: API returns []string).
+ err := client.InvokeAction("ListUHadoopFrameworkAppByUseCase", req, &resp)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ listFrameworkApps(ctx, resp.AppConfigSet)
+ },
+ }
+ cmd.Flags().SortFlags = false
+
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+ ctx.BindProjectID(cmd, req)
+
+ cmd.MarkFlagRequired("region")
+ cmd.MarkFlagRequired("zone")
+ return cmd
+}
+
+func listFrameworkApps(ctx *cli.Context, appConfigs []frameworkAppConfigVersion) {
+ if ctx.Format() != cli.OutputTable {
+ ctx.PrintList(appConfigs)
+ return
+ }
+
+ list := make([]frameworkRow, 0)
+ for _, ac := range appConfigs {
+ for _, uc := range ac.UseCases {
+ var apps []string
+ var versions []string
+ for _, av := range uc.AppVersion {
+ apps = append(apps, av.AppName)
+ versions = append(versions, av.AppName+"#"+av.AppVersion)
+ }
+ list = append(list, frameworkRow{
+ Framework: ac.Framework,
+ FrameworkVersion: ac.FrameworkVersion,
+ ReleaseVersion: ac.ReleaseVersion,
+ HadoopVersion: ac.HadoopVersion,
+ UseCase: uc.ClusterCase,
+ Apps: strings.Join(apps, ","),
+ Versions: strings.Join(versions, ","),
+ MustHas: strings.Join(uc.MustHas, ","),
+ })
+ }
+ }
+ ctx.PrintList(list)
+}
diff --git a/products/uhadoop/internal/uhadoop/list_node_type.go b/products/uhadoop/internal/uhadoop/list_node_type.go
new file mode 100644
index 0000000000..10b5a3c16f
--- /dev/null
+++ b/products/uhadoop/internal/uhadoop/list_node_type.go
@@ -0,0 +1,79 @@
+package uhadoop
+
+import (
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ uhadoopsdk "github.com/ucloud/ucloud-sdk-go/services/uhadoop"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newListNodeType ucloud uhadoop list-node-type
+func newListNodeType(ctx *cli.Context) *cobra.Command {
+ client := cli.NewServiceClient(ctx, uhadoopsdk.NewClient)
+ req := client.NewGetUHadoopNodeTypeRequest()
+ cmd := &cobra.Command{
+ Use: "list-node-type",
+ Short: "List available node types for UHadoop",
+ Long: `List available node/instance types for UHadoop clusters`,
+ SilenceUsage: true,
+ Run: func(cmd *cobra.Command, args []string) {
+
+ resp, err := client.GetUHadoopNodeType(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ listNodeTypes(ctx, resp.InstanceTypeSet)
+ },
+ }
+ cmd.Flags().SortFlags = false
+
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+ req.Framework = cmd.Flags().String("framework", "", "Optional. Filter by framework: Hadoop|HDFS|MR|StarRocks-Shared-Nothing|StarRocks-Shared-Data")
+ req.FrameworkVersion = cmd.Flags().String("framework-version", "", "Optional. Filter by framework version, e.g. 3.3.4-udh3.2")
+ req.NodeRole = cmd.Flags().String("node-role", "", "Optional. Filter by node role: master|core|task")
+ req.NodeType = cmd.Flags().String("node-type", "", "Optional. Filter by node type name")
+
+ command.SetFlagValues(cmd, "node-role", "master", "core", "task", "client")
+
+ cmd.MarkFlagRequired("region")
+ cmd.MarkFlagRequired("zone")
+ return cmd
+}
+
+func listNodeTypes(ctx *cli.Context, types []uhadoopsdk.InstanceType) {
+ list := make([]instanceTypeRow, 0, len(types))
+ for _, t := range types {
+ row := instanceTypeRow{
+ NodeType: t.NodeType,
+ HostType: t.HostType,
+ CPU: t.CPU,
+ Memory: t.Memory,
+ CPUToMemoryRatio: t.CPUToMemoryRatio,
+ SuitableRole: strings.Join(t.SuitableRole, ","),
+ IsUsable: t.IsUsable,
+ GpuType: t.GpuType,
+ GpuCount: t.GpuCount,
+ }
+ if len(t.DiskSet) > 0 {
+ // Find the Data disk info
+ for _, d := range t.DiskSet {
+ if d.Type == "Data" {
+ row.DiskType = strings.Join(d.DiskType, ",")
+ row.DiskMinSize = d.DiskMinSize
+ row.DiskMaxSize = d.DiskMaxSize
+ row.DiskMinNum = d.DiskMinNum
+ row.DiskMaxNum = d.DiskMaxNum
+ break
+ }
+ }
+ }
+ list = append(list, row)
+ }
+ ctx.PrintList(list)
+}
diff --git a/products/uhadoop/internal/uhadoop/poll.go b/products/uhadoop/internal/uhadoop/poll.go
new file mode 100644
index 0000000000..02fe71874b
--- /dev/null
+++ b/products/uhadoop/internal/uhadoop/poll.go
@@ -0,0 +1,26 @@
+package uhadoop
+
+import (
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+ "github.com/ucloud/ucloud-sdk-go/ucloud/request"
+
+ uhadoopsdk "github.com/ucloud/ucloud-sdk-go/services/uhadoop"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+func describeClusterForPoll(ctx *cli.Context, client *uhadoopsdk.UHadoopClient) func(string, *request.CommonBase) (interface{}, error) {
+ return func(id string, _ *request.CommonBase) (interface{}, error) {
+ req := client.NewDescribeUHadoopInstanceRequest()
+ req.InstanceId = sdk.String(id)
+ var resp describeClusterResponse
+ err := client.InvokeAction("DescribeUHadoopInstance", req, &resp)
+ if err != nil {
+ return nil, err
+ }
+ if len(resp.ClusterSet) == 0 {
+ return nil, nil
+ }
+ return resp.ClusterSet[0], nil
+ }
+}
diff --git a/products/uhadoop/internal/uhadoop/restart_service.go b/products/uhadoop/internal/uhadoop/restart_service.go
new file mode 100644
index 0000000000..f336baa353
--- /dev/null
+++ b/products/uhadoop/internal/uhadoop/restart_service.go
@@ -0,0 +1,75 @@
+package uhadoop
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ uhadoopsdk "github.com/ucloud/ucloud-sdk-go/services/uhadoop"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+func newRestartService(ctx *cli.Context) *cobra.Command {
+ var yes bool
+ client := cli.NewServiceClient(ctx, uhadoopsdk.NewClient)
+ req := client.NewRestartUHadoopServiceRequest()
+ var nodeIds []string
+ var nodeRoles []string
+ cmd := &cobra.Command{
+ Use: "restart-service",
+ Short: "Restart/start/stop a UHadoop cluster service",
+ Long: `Restart, start, or stop a service on a UHadoop cluster`,
+ Run: func(cmd *cobra.Command, args []string) {
+ action := "restart"
+ if req.OnlyStart != nil && *req.OnlyStart {
+ action = "start"
+ }
+ if req.OnlyStop != nil && *req.OnlyStop {
+ action = "stop"
+ }
+ ok, err := ctx.Confirm(yes, fmt.Sprintf("Are you sure you want to %s service %s on cluster %s?", action, *req.ServiceName, *req.InstanceId))
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ if !ok {
+ return
+ }
+ w := ctx.ProgressWriter()
+ req.NodeId = nodeIds
+ req.NodeRole = nodeRoles
+ resp, err := client.RestartUHadoopService(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ fmt.Fprintf(w, "uhadoop[%s] service %s %s, state: %s\n", *req.InstanceId, *req.ServiceName, action, resp.State)
+ ctx.EmitResult(cli.OpResultRow{ResourceID: *req.InstanceId, Action: action, Status: resp.State})
+ },
+ }
+ flags := cmd.Flags()
+ flags.SortFlags = false
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+ ctx.BindProjectID(cmd, req)
+ req.InstanceId = flags.String("instance-id", "", "Required. Cluster instance ID")
+ req.ServiceName = flags.String("service-name", "", "Required. Service name")
+ req.ApplicationVersion = flags.String("application-version", "", "Optional. Application version")
+ req.OnlyStart = flags.Bool("only-start", false, "Optional. Only start the service")
+ req.OnlyStop = flags.Bool("only-stop", false, "Optional. Only stop the service")
+ flags.BoolVarP(&yes, "yes", "y", false, "Optional. Do not prompt for confirmation")
+ flags.StringSliceVar(&nodeIds, "node-id", nil, "Optional. Node IDs")
+ flags.StringSliceVar(&nodeRoles, "node-role", nil, "Optional. Node roles: master|core|task")
+
+ command.SetFlagValues(cmd, "only-start", "true", "false")
+ command.SetFlagValues(cmd, "only-stop", "true", "false")
+ command.SetFlagValues(cmd, "node-role", "master", "core", "task")
+ cmd.MarkFlagRequired("instance-id")
+ cmd.MarkFlagRequired("service-name")
+ cmd.MarkFlagRequired("region")
+ cmd.MarkFlagRequired("zone")
+
+ return cmd
+}
diff --git a/products/uhadoop/internal/uhadoop/rows.go b/products/uhadoop/internal/uhadoop/rows.go
new file mode 100644
index 0000000000..cedd142968
--- /dev/null
+++ b/products/uhadoop/internal/uhadoop/rows.go
@@ -0,0 +1,60 @@
+package uhadoop
+
+// listRow is the full row for ListUHadoopInstance output.
+type listRow struct {
+ InstanceId string
+ InstanceName string
+ Framework string
+ ReleaseVersion string
+ HadoopVersion string
+ State string
+ Zone string
+ VPCId string
+ SubnetId string
+ ChargeType string
+ CreateTime string
+ ExpireTime string
+}
+
+// listRowDefault is the default (non-wide) column set for list.
+type listRowDefault struct {
+ InstanceId string
+ InstanceName string
+ Framework string
+ ReleaseVersion string
+ HadoopVersion string
+ State string
+ Zone string
+ CreateTime string
+ ExpireTime string
+}
+
+// instanceTypeRow is the row for GetUHadoopNodeType output.
+type instanceTypeRow struct {
+ NodeType string
+ HostType string
+ CPU string
+ Memory string
+ CPUToMemoryRatio string
+ SuitableRole string
+ IsUsable string
+ GpuType string
+ GpuCount int
+ DiskType string
+ DiskMinSize string
+ DiskMaxSize string
+ DiskMinNum string
+ DiskMaxNum string
+}
+
+// frameworkRow is the row for ListUHadoopFrameworkAppByUseCase output.
+type frameworkRow struct {
+ Framework string
+ FrameworkVersion string
+ ReleaseVersion string
+ HadoopVersion string
+ UseCase string
+ Apps string
+ Versions string
+ MustHas string
+}
diff --git a/products/uhadoop/internal/uhadoop/status.go b/products/uhadoop/internal/uhadoop/status.go
new file mode 100644
index 0000000000..0f6204686e
--- /dev/null
+++ b/products/uhadoop/internal/uhadoop/status.go
@@ -0,0 +1,3 @@
+package uhadoop
+
+const stateRunning = "Running"
diff --git a/products/uhadoop/internal/uhadoop/upgrade_disk.go b/products/uhadoop/internal/uhadoop/upgrade_disk.go
new file mode 100644
index 0000000000..bca5228601
--- /dev/null
+++ b/products/uhadoop/internal/uhadoop/upgrade_disk.go
@@ -0,0 +1,60 @@
+package uhadoop
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ uhadoopsdk "github.com/ucloud/ucloud-sdk-go/services/uhadoop"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+func newUpgradeDisk(ctx *cli.Context) *cobra.Command {
+ var yes *bool
+ client := cli.NewServiceClient(ctx, uhadoopsdk.NewClient)
+ req := client.NewUpgradeUHadoopNodeDiskRequest()
+ var nodeNames []string
+ cmd := &cobra.Command{
+ Use: "upgrade-disk",
+ Short: "Upgrade UHadoop node disk size",
+ Long: `Upgrade UHadoop node data disk (and optionally boot disk) size`,
+ SilenceUsage: true,
+ Run: func(cmd *cobra.Command, args []string) {
+ ok, err := ctx.Confirm(*yes, fmt.Sprintf("Upgrade disk on %s nodes of cluster %s to %d GB?", *req.NodeRole, *req.InstanceId, *req.DataDiskSize))
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ if !ok {
+ return
+ }
+ req.NodeNames = nodeNames
+ _, err = client.UpgradeUHadoopNodeDisk(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ ctx.EmitResult(cli.OpResultRow{ResourceID: *req.InstanceId, Action: "upgrade-disk", Status: "Upgrading"})
+ },
+ }
+ flags := cmd.Flags()
+ flags.SortFlags = false
+ req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region")
+ req.Zone = flags.String("zone", "", "Optional. Assign availability zone")
+ req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id")
+ req.InstanceId = flags.String("instance-id", "", "Required. Cluster instance ID")
+ req.NodeRole = flags.String("node-role", "", "Required. Node role: master|core|task|client")
+ req.DataDiskSize = flags.Int("data-disk-size", 0, "Required. New data disk size in GB")
+ req.BootDiskSize = flags.Int("boot-disk-size", 0, "Optional. New boot disk size in GB")
+ yes = flags.BoolP("yes", "y", false, "Do not prompt for confirmation")
+ flags.StringSliceVar(&nodeNames, "node-name", nil, "Node names, required when NodeRole != master")
+ command.SetFlagValues(cmd, "node-role", "master", "core", "task", "client")
+ cmd.MarkFlagRequired("instance-id")
+ cmd.MarkFlagRequired("node-role")
+ cmd.MarkFlagRequired("data-disk-size")
+ cmd.MarkFlagRequired("region")
+ cmd.MarkFlagRequired("zone")
+ return cmd
+}
diff --git a/products/uhadoop/internal/uhadoop/upgrade_node.go b/products/uhadoop/internal/uhadoop/upgrade_node.go
new file mode 100644
index 0000000000..14aa5fd45b
--- /dev/null
+++ b/products/uhadoop/internal/uhadoop/upgrade_node.go
@@ -0,0 +1,63 @@
+package uhadoop
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ uhadoopsdk "github.com/ucloud/ucloud-sdk-go/services/uhadoop"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+func newUpgradeNode(ctx *cli.Context) *cobra.Command {
+ var yes bool
+ client := cli.NewServiceClient(ctx, uhadoopsdk.NewClient)
+ req := client.NewUpgradeUHadoopNodeRequest()
+ var nodeNames []string
+ cmd := &cobra.Command{
+ Use: "upgrade-node",
+ Short: "Upgrade UHadoop node instance type",
+ Long: `Upgrade UHadoop node to a new instance type`,
+ Run: func(cmd *cobra.Command, args []string) {
+ ok, err := ctx.Confirm(yes, fmt.Sprintf("Upgrade %s nodes on cluster %s to %s?", *req.NodeRole, *req.InstanceId, *req.NodeType))
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ if !ok {
+ return
+ }
+ w := ctx.ProgressWriter()
+ req.NodeNames = nodeNames
+ _, err = client.UpgradeUHadoopNode(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ text := fmt.Sprintf("uhadoop[%s] upgrading %s nodes to %s", *req.InstanceId, *req.NodeRole, *req.NodeType)
+ fmt.Fprintln(w, text)
+ ctx.PollerTo(w, describeClusterForPoll(ctx, client), cli.WithTimeout(40*time.Minute)).Spoll(*req.InstanceId, text, []string{stateRunning})
+ ctx.EmitResult(cli.OpResultRow{ResourceID: *req.InstanceId, Action: "upgrade-node", Status: "Upgrading"})
+ },
+ }
+ flags := cmd.Flags()
+ flags.SortFlags = false
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+ ctx.BindProjectID(cmd, req)
+ req.InstanceId = flags.String("instance-id", "", "Required. Cluster instance ID")
+ req.NodeRole = flags.String("node-role", "", "Required. Node role: master|core|task|client")
+ req.NodeType = flags.String("node-type", "", "Required. New node type")
+ flags.BoolVarP(&yes, "yes", "y", false, "Do not prompt for confirmation")
+ flags.StringSliceVar(&nodeNames, "node-name", nil, "Node names, required when NodeRole != master")
+ command.SetFlagValues(cmd, "node-role", "master", "core", "task", "client")
+ cmd.MarkFlagRequired("instance-id")
+ cmd.MarkFlagRequired("node-role")
+ cmd.MarkFlagRequired("node-type")
+ cmd.MarkFlagRequired("region")
+ cmd.MarkFlagRequired("zone")
+ return cmd
+}
diff --git a/products/uhadoop/product.go b/products/uhadoop/product.go
new file mode 100644
index 0000000000..53ff5cf816
--- /dev/null
+++ b/products/uhadoop/product.go
@@ -0,0 +1,21 @@
+package uhadoop
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ internaluhadoop "github.com/ucloud/ucloud-cli/products/uhadoop/internal/uhadoop"
+)
+
+type product struct{}
+
+// New returns the uhadoop product (registered via hack/gen-products).
+func New() cli.Product { return product{} }
+
+func (product) Metadata() cli.Metadata {
+ return cli.Metadata{Name: "uhadoop", Commands: []string{"uhadoop"}}
+}
+
+func (product) NewCommand(ctx *cli.Context) []*cobra.Command {
+ return []*cobra.Command{internaluhadoop.NewCommand(ctx)}
+}
diff --git a/products/uhadoop/product.yaml b/products/uhadoop/product.yaml
new file mode 100644
index 0000000000..d4caea376f
--- /dev/null
+++ b/products/uhadoop/product.yaml
@@ -0,0 +1,6 @@
+name: uhadoop
+owners:
+ - markwei-ucloud
+commands:
+ - uhadoop
+enabled: true
diff --git a/products/uhadoop/testdata/cmdtree.golden b/products/uhadoop/testdata/cmdtree.golden
new file mode 100644
index 0000000000..955457757c
--- /dev/null
+++ b/products/uhadoop/testdata/cmdtree.golden
@@ -0,0 +1,120 @@
+ucloud uhadoop use=uhadoop short=List,create,delete,describe UHadoop clusters and manage nodes and services
+ucloud uhadoop add-node use=add-node short=Add nodes to a UHadoop cluster
+ flag=async short= default=false required=
+ flag=boot-disk-size short= default=50 required=
+ flag=boot-disk-type short= default=CLOUD_RSSD required=
+ flag=data-disk-num short= default=1 required=
+ flag=data-disk-size short= default=200 required=
+ flag=data-disk-type short= default=CLOUD_RSSD required=
+ flag=instance-id short= default= required=true
+ flag=node-count short= default=1 required=
+ flag=node-role short= default= required=true
+ flag=node-type short= default= required=true
+ flag=password short= default= required=
+ flag=project-id short= default= required=
+ flag=region short= default= required=true
+ flag=zone short= default= required=true
+ucloud uhadoop create use=create short=Create a UHadoop cluster
+ flag=app-config short= default=[] required=
+ flag=async short= default=false required=
+ flag=business-id short= default=Default required=
+ flag=charge-type short= default=Month required=
+ flag=cluster-case short= default= required=
+ flag=core-boot-disk-size short= default=50 required=
+ flag=core-boot-disk-type short= default=CLOUD_RSSD required=
+ flag=core-count short= default=3 required=
+ flag=core-data-disk-num short= default=1 required=
+ flag=core-data-disk-size short= default=200 required=
+ flag=core-data-disk-type short= default=CLOUD_RSSD required=
+ flag=core-node-type short= default=o.hadoop2m.xlarge required=
+ flag=framework short= default= required=true
+ flag=framework-version short= default= required=true
+ flag=master-boot-disk-size short= default=50 required=
+ flag=master-boot-disk-type short= default=CLOUD_RSSD required=
+ flag=master-count short= default=2 required=
+ flag=master-data-disk-num short= default=1 required=
+ flag=master-data-disk-size short= default=100 required=
+ flag=master-data-disk-type short= default=CLOUD_RSSD required=
+ flag=master-node-type short= default=o.hadoop4m.xlarge required=
+ flag=meta-store short= default= required=
+ flag=name short= default= required=true
+ flag=password short= default= required=true
+ flag=project-id short= default= required=
+ flag=quantity short= default=1 required=
+ flag=region short= default= required=true
+ flag=sec-group-ids short= default= required=
+ flag=security-enabled short= default= required=
+ flag=storage-cluster-id short= default= required=
+ flag=subnet-id short= default= required=
+ flag=task-boot-disk-size short= default=50 required=
+ flag=task-boot-disk-type short= default=CLOUD_RSSD required=
+ flag=task-count short= default=0 required=
+ flag=task-data-disk-num short= default=1 required=
+ flag=task-data-disk-size short= default=200 required=
+ flag=task-data-disk-type short= default=CLOUD_RSSD required=
+ flag=task-node-type short= default=o.hadoop2m.xlarge required=
+ flag=us3-access-key short= default= required=
+ flag=us3-bucket short= default= required=
+ flag=us3-secret-key short= default= required=
+ flag=us3-token-name short= default= required=
+ flag=vpc-id short= default= required=
+ flag=zone short= default= required=true
+ucloud uhadoop delete use=delete short=Delete a UHadoop cluster
+ flag=project-id short= default= required=
+ flag=region short= default= required=true
+ flag=release-eip short= default=false required=
+ flag=yes short=y default=false required=
+ flag=zone short= default= required=true
+ucloud uhadoop describe use=describe short=Describe a UHadoop cluster
+ flag=region short= default= required=true
+ flag=zone short= default= required=true
+ucloud uhadoop list use=list short=List all UHadoop clusters
+ flag=all-region short= default=false required=
+ flag=id-only short= default=false 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=zone short= default= required=
+ucloud uhadoop list-framework-app use=list-framework-app short=List UHadoop framework apps by use case
+ flag=project-id short= default= required=
+ flag=region short= default= required=true
+ flag=zone short= default= required=true
+ucloud uhadoop list-node-type use=list-node-type short=List available node types for UHadoop
+ flag=framework short= default= required=
+ flag=framework-version short= default= required=
+ flag=node-role short= default= required=
+ flag=node-type short= default= required=
+ flag=region short= default= required=true
+ flag=zone short= default= required=true
+ucloud uhadoop restart-service use=restart-service short=Restart/start/stop a UHadoop cluster service
+ flag=application-version short= default= required=
+ flag=instance-id short= default= required=true
+ flag=node-id short= default=[] required=
+ flag=node-role short= default=[] required=
+ flag=only-start short= default=false required=
+ flag=only-stop short= default=false required=
+ flag=project-id short= default= required=
+ flag=region short= default= required=true
+ flag=service-name short= default= required=true
+ flag=yes short=y default=false required=
+ flag=zone short= default= required=true
+ucloud uhadoop upgrade-disk use=upgrade-disk short=Upgrade UHadoop node disk size
+ flag=boot-disk-size short= default=0 required=
+ flag=data-disk-size short= default=0 required=true
+ flag=instance-id short= default= required=true
+ flag=node-name short= default=[] required=
+ flag=node-role short= default= required=true
+ flag=project-id short= default= required=
+ flag=region short= default= required=true
+ flag=yes short=y default=false required=
+ flag=zone short= default= required=true
+ucloud uhadoop upgrade-node use=upgrade-node short=Upgrade UHadoop node instance type
+ flag=instance-id short= default= required=true
+ flag=node-name short= default=[] required=
+ flag=node-role short= default= required=true
+ flag=node-type short= default= required=true
+ flag=project-id short= default= required=
+ flag=region short= default= required=true
+ flag=yes short=y default=false required=
+ flag=zone short= default= required=true
diff --git a/products/uhadoop/testdata/completion.golden b/products/uhadoop/testdata/completion.golden
new file mode 100644
index 0000000000..444fa9fe5f
--- /dev/null
+++ b/products/uhadoop/testdata/completion.golden
@@ -0,0 +1,31 @@
+ucloud uhadoop add-node node-role static client,core,task
+ucloud uhadoop create charge-type static Dynamic,Month,Year
+ucloud uhadoop create cluster-case static Core-Hadoop,Hbase,Spark
+ucloud uhadoop create security-enabled static false,true
+ucloud uhadoop delete project-id dynamic
+ucloud uhadoop delete region dynamic
+ucloud uhadoop delete release-eip static false,true
+ucloud uhadoop delete zone dynamic
+ucloud uhadoop describe region dynamic
+ucloud uhadoop describe zone dynamic
+ucloud uhadoop list all-region static false,true
+ucloud uhadoop list id-only static false,true
+ucloud uhadoop list project-id dynamic
+ucloud uhadoop list region dynamic
+ucloud uhadoop list-framework-app project-id dynamic
+ucloud uhadoop list-framework-app region dynamic
+ucloud uhadoop list-framework-app zone dynamic
+ucloud uhadoop list-node-type node-role static client,core,master,task
+ucloud uhadoop list-node-type region dynamic
+ucloud uhadoop list-node-type zone dynamic
+ucloud uhadoop restart-service node-role static core,master,task
+ucloud uhadoop restart-service only-start static false,true
+ucloud uhadoop restart-service only-stop static false,true
+ucloud uhadoop restart-service project-id dynamic
+ucloud uhadoop restart-service region dynamic
+ucloud uhadoop restart-service zone dynamic
+ucloud uhadoop upgrade-disk node-role static client,core,master,task
+ucloud uhadoop upgrade-node node-role static client,core,master,task
+ucloud uhadoop upgrade-node project-id dynamic
+ucloud uhadoop upgrade-node region dynamic
+ucloud uhadoop upgrade-node zone dynamic
diff --git a/products/uhost/internal/uhost/create.go b/products/uhost/internal/uhost/create.go
index 884aaecc44..0d5e8962f9 100644
--- a/products/uhost/internal/uhost/create.go
+++ b/products/uhost/internal/uhost/create.go
@@ -65,11 +65,29 @@ func newCreate(ctx *cli.Context) *cobra.Command {
if concurrent > 50 {
return fmt.Errorf("%q should not be more than 50, current value is %v", "concurrent", concurrent)
}
+
+ // GPU and GpuType must be specified together: if one is set, the other must also be set.
+ gpuVal, _ := cmd.Flags().GetInt("gpu")
+ gpuTypeVal, _ := cmd.Flags().GetString("gpu-type")
+ if gpuVal > 0 && gpuTypeVal == "" {
+ return fmt.Errorf("--gpu requires --gpu-type, e.g. --gpu-type V100")
+ }
+ if gpuTypeVal != "" && gpuVal <= 0 {
+ return fmt.Errorf("--gpu-type requires --gpu, e.g. --gpu 1")
+ }
+
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
*req.Memory *= 1024
+ // If --gpu and --gpu-type are specified, auto-set MachineType to "G"
+ // unless the user explicitly set --machine-type.
+ if *req.GPU > 0 && *req.GpuType != "" {
+ if !cmd.Flags().Changed("machine-type") {
+ req.MachineType = sdk.String("G")
+ }
+ }
if len(password) > 0 {
req.LoginMode = sdk.String("Password")
req.KeyPairId = nil
@@ -257,7 +275,7 @@ func newCreate(ctx *cli.Context) *cobra.Command {
ctx.BindRegion(cmd, req)
ctx.BindZone(cmd, req)
- req.MachineType = flags.String("machine-type", "N", "Optional. Accept values: N, C, G, O, OS. Forward to https://docs.ucloud.cn/api/uhost-api/uhost_type for details")
+ req.MachineType = flags.String("machine-type", "O", "Optional. Accept values: N, C, G, O, OS. Forward to https://docs.ucloud.cn/api/uhost-api/uhost_type for details")
req.MinimalCpuPlatform = flags.String("minimal-cpu-platform", "", "Optional. Accept values: Intel/Auto, Intel/IvyBridge, Intel/Haswell, Intel/Broadwell, Intel/Skylake, Intel/Cascadelake")
req.UHostType = flags.String("type", "", "Optional. Accept values: N1, N2, N3, G1, G2, G3, I1, I2, C1. Forward to https://docs.ucloud.cn/api/uhost-api/uhost_type for details")
req.GPU = flags.Int("gpu", 0, "Optional. The count of GPU cores.")
@@ -273,7 +291,7 @@ func newCreate(ctx *cli.Context) *cobra.Command {
flags.StringSliceVar(&secGroupIds, "security-group-id", nil, "Optional. Security Group Id. Before using security group function, please confirm the account has such permission. When both firewall-id and security-group-id are set, the security-group-id will be ignored")
req.Tag = flags.String("group", "Default", "Optional. Business group")
req.IsolationGroup = flags.String("isolation-group", "", "Optional. Resource ID of isolation group. see 'ucloud uhost isolation-group list")
- req.GpuType = flags.String("gpu-type", "", "Optional. The type of GPU instance. Required if defined the `machine-type` as 'G'. Accept values: 'K80', 'P40', 'V100'. Forward to https://docs.ucloud.cn/api/uhost-api/uhost_type for details.")
+ req.GpuType = flags.String("gpu-type", "", "Optional. The type of GPU instance. Required if defined the `machine-type` as 'G'. Accept values: 'K80','P40','V100','T4','T4S','T4A','2080Ti','2080Ti-4C','1080Ti','V100S','MI100','2080','2080TiS','2080TiPro','3090','A100','A800','3080Ti','4090','4090Pro','4090_48G','4090LD','MR-V100','MetaX-C500','H800','H20','H100','H200','5090','5090D','5090Pro'. Forward to https://docs.ucloud.cn/api/uhost-api/uhost_type for details.")
flags.StringVar(&userData, "user-data", "", "Optional. Conflicts with `user-data-base64`. ConCustomize the startup behaviors when launching the instance. Forward to https://docs.ucloud.cn/uhost/guide/metadata/userdata for details.")
flags.StringVar(&userDataBase64, "user-data-base64", "", "Optional. Conflicts with `user-data`. Customize the startup behaviors when launching the instance. The value must be base64-encode. Forward to https://docs.ucloud.cn/uhost/guide/metadata/userdata for details.")
@@ -291,7 +309,7 @@ func newCreate(ctx *cli.Context) *cobra.Command {
command.SetFlagValues(cmd, "data-disk-backup-type", "NONE", "DATAARK")
command.SetFlagValues(cmd, "create-eip-line", "BGP", "International")
command.SetFlagValues(cmd, "create-eip-traffic-mode", "Bandwidth", "Traffic", "ShareBandwidth")
- command.SetFlagValues(cmd, "gpu-type", "K80", "P40", "V100")
+ command.SetFlagValues(cmd, "gpu-type", "K80", "P40", "V100", "T4", "T4S", "T4A", "2080Ti", "2080Ti-4C", "1080Ti", "V100S", "MI100", "2080", "2080TiS", "2080TiPro", "3090", "A100", "A800", "3080Ti", "4090", "4090Pro", "4090_48G", "4090LD", "MR-V100", "MetaX-C500", "H800", "H20", "H100", "H200", "5090", "5090D", "5090Pro")
command.SetCompletion(cmd, "image-id", func() []string {
return getImageList(ctx, []string{IMAGE_AVAILABLE}, IMAGE_BASE, *req.ProjectId, *req.Region, *req.Zone)
diff --git a/products/uhost/product.yaml b/products/uhost/product.yaml
index 2e066560ab..011194f096 100644
--- a/products/uhost/product.yaml
+++ b/products/uhost/product.yaml
@@ -1,6 +1,6 @@
name: uhost
owners:
- - Episkey-G
+ - calmxkk
commands:
- uhost
enabled: true
diff --git a/products/uhost/testdata/cmdtree.golden b/products/uhost/testdata/cmdtree.golden
index a329dba1bd..463f9f4117 100644
--- a/products/uhost/testdata/cmdtree.golden
+++ b/products/uhost/testdata/cmdtree.golden
@@ -31,7 +31,7 @@ ucloud uhost create use=create short=Create UHost instance
flag=image-id short= default= required=true
flag=isolation-group short= default= required=
flag=key-pair-id short= default= required=
- flag=machine-type short= default=N required=
+ flag=machine-type short= default=O required=
flag=memory-gb short= default=8 required=true
flag=minimal-cpu-platform short= default= required=
flag=name short= default=UHost required=
diff --git a/products/uhost/testdata/completion.golden b/products/uhost/testdata/completion.golden
index e83163b58b..a1094bf87d 100644
--- a/products/uhost/testdata/completion.golden
+++ b/products/uhost/testdata/completion.golden
@@ -7,7 +7,7 @@ ucloud uhost create create-eip-traffic-mode static Bandwidth,ShareBandwidth,Traf
ucloud uhost create data-disk-backup-type static DATAARK,NONE
ucloud uhost create data-disk-type static CLOUD_NORMAL,CLOUD_RSSD,CLOUD_SSD,EXCLUSIVE_LOCAL_DISK,LOCAL_NORMAL,LOCAL_SSD,NONE
ucloud uhost create firewall-id dynamic
-ucloud uhost create gpu-type static K80,P40,V100
+ucloud uhost create gpu-type static 1080Ti,2080,2080Ti,2080Ti-4C,2080TiPro,2080TiS,3080Ti,3090,4090,4090LD,4090Pro,4090_48G,5090,5090D,5090Pro,A100,A800,H100,H20,H200,H800,K80,MI100,MR-V100,MetaX-C500,P40,T4,T4A,T4S,V100,V100S
ucloud uhost create hot-plug static false,true
ucloud uhost create image-id dynamic
ucloud uhost create isolation-group dynamic
diff --git a/products/uk8s/internal/uk8s/cmd.go b/products/uk8s/internal/uk8s/cmd.go
new file mode 100644
index 0000000000..f8ba485cb2
--- /dev/null
+++ b/products/uk8s/internal/uk8s/cmd.go
@@ -0,0 +1,28 @@
+package uk8s
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// NewCommand builds the `uk8s` root command. Per the platform spec (§2.2
+// aggregator role), this file only constructs the top-level command and wires
+// the verb constructors via AddCommand — no business logic, no helpers.
+func NewCommand(ctx *cli.Context) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "uk8s",
+ Short: "Read and manipulate UK8S (UCloud Kubernetes Service) clusters",
+ Long: "Read and manipulate UK8S (UCloud Kubernetes Service) clusters",
+ }
+ cmd.AddCommand(newCreate(ctx))
+ cmd.AddCommand(newDelete(ctx))
+ cmd.AddCommand(newList(ctx))
+ cmd.AddCommand(newDescribe(ctx))
+ cmd.AddCommand(newGetConfig(ctx))
+ cmd.AddCommand(newNodeGroup(ctx))
+ cmd.AddCommand(newNode(ctx))
+ cmd.AddCommand(newImage(ctx))
+ cmd.AddCommand(newVersion(ctx))
+ return cmd
+}
diff --git a/products/uk8s/internal/uk8s/commands_test.go b/products/uk8s/internal/uk8s/commands_test.go
new file mode 100644
index 0000000000..e080532e91
--- /dev/null
+++ b/products/uk8s/internal/uk8s/commands_test.go
@@ -0,0 +1,400 @@
+package uk8s
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "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 setupCommandGateway(t *testing.T) (*cli.Context, *[]url.Values) {
+ t.Helper()
+
+ requests := &[]url.Values{}
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ t.Errorf("parse request form: %v", err)
+ }
+ *requests = append(*requests, r.PostForm)
+ action := r.PostForm.Get("Action")
+ response := map[string]interface{}{
+ "RetCode": 0,
+ "Action": action + "Response",
+ }
+ switch action {
+ case "DescribeUK8SCluster":
+ response["ClusterId"] = "uk8s-a"
+ response["ClusterName"] = "test-cluster"
+ response["KubeProxy"] = map[string]string{"Mode": "iptables"}
+ response["MasterList"] = []map[string]interface{}{{
+ "NodeId": "uhost-master", "Name": "master-0", "IPSet": []map[string]interface{}{{"IP": "10.0.0.1"}},
+ }}
+ response["NodeList"] = []map[string]interface{}{{
+ "NodeId": "uhost-node", "Name": "node-0", "DiskSet": []map[string]interface{}{{"DiskId": "bsi-node"}},
+ }}
+ case "GetClusterConfig":
+ response["KubeConfig"] = "apiVersion: v1\nclusters: []\n"
+ response["ExternalKubeConfig"] = "apiVersion: v1\nclusters: []\n"
+ case "GetUK8SVersions":
+ response["Data"] = []map[string]string{{
+ "K8sVersion": "1.34.5",
+ "ContainerdVersion": "1.7.27",
+ }}
+ case "AddUK8SNodeGroup":
+ response["NodeGroupId"] = "uk8sng-test"
+ case "DescribeUK8SImage":
+ response["CustomImageSet"] = []map[string]interface{}{{
+ "ImageId": "uimage-custom", "ImageName": "custom-ubuntu", "Features": []string{"CloudInit"},
+ }}
+ case "ListUK8SClusterNodeV2":
+ response["NodeSet"] = []map[string]interface{}{{
+ "NodeId": "uk8s-node", "UsedCPU": 87, "UsedMemory": 1052389376, "VKCpu": 0, "VKMem": 0,
+ }}
+ case "AddUK8SUHostNode":
+ response["NodeIds"] = []string{"uk8snode-test"}
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(response)
+ }))
+
+ t.Cleanup(func() {
+ server.Close()
+ })
+
+ var out, errOut bytes.Buffer
+ ctx := cli.NewContext(cli.Deps{
+ In: strings.NewReader(""), Out: &out, Err: &errOut,
+ Format: cli.OutputTable,
+ DefaultsProvider: func() command.Defaults {
+ return command.Defaults{Region: "cn-sh2", ProjectID: "org-test"}
+ },
+ ClientConfig: func() *sdk.Config {
+ return &sdk.Config{Region: "cn-sh2", ProjectId: "org-test", BaseUrl: server.URL}
+ },
+ BuildCredential: func() *auth.Credential {
+ return &auth.Credential{PublicKey: "public", PrivateKey: "private"}
+ },
+ AttachHandlers: func(sdk.ServiceClient) {},
+ })
+ return ctx, requests
+}
+
+func runUK8SCommand(t *testing.T, cmd *cobra.Command, args ...string) {
+ t.Helper()
+ cmd.SetArgs(args)
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("execute %s %v: %v", cmd.Use, args, err)
+ }
+}
+
+func lastRequest(t *testing.T, requests *[]url.Values) url.Values {
+ t.Helper()
+ if len(*requests) == 0 {
+ t.Fatal("command did not call the API")
+ }
+ return (*requests)[len(*requests)-1]
+}
+
+func assertRequest(t *testing.T, got url.Values, want map[string]string) {
+ t.Helper()
+ for key, expected := range want {
+ if actual := got.Get(key); actual != expected {
+ t.Errorf("request %s = %q, want %q", key, actual, expected)
+ }
+ }
+}
+
+func TestClusterCommandsDispatch(t *testing.T) {
+ tests := []struct {
+ name string
+ build func(*cli.Context) *cobra.Command
+ args []string
+ action string
+ want map[string]string
+ }{
+ {
+ name: "delete", build: newDelete,
+ args: []string{"--cluster-id", "uk8s-a/name,uk8s-b/name", "--release-udisk", "--release-eip", "--yes"},
+ action: "DelUK8SCluster", want: map[string]string{"ClusterId": "uk8s-b", "ReleaseUDisk": "true", "ReleaseEIP": "true"},
+ },
+ {
+ name: "list", build: newList,
+ args: []string{"--cluster-id", "uk8s-a/name", "--limit", "25", "--offset", "5"},
+ action: "ListUK8SClusterV2", want: map[string]string{"ClusterId": "uk8s-a", "Limit": "25", "Offset": "5"},
+ },
+ {
+ name: "describe", build: newDescribe,
+ args: []string{"--cluster-id", "uk8s-a/name"},
+ action: "DescribeUK8SCluster", want: map[string]string{"ClusterId": "uk8s-a"},
+ },
+ {
+ name: "get-config", build: newGetConfig,
+ args: []string{"--cluster-id", "uk8s-a/name", "--external"},
+ action: "GetClusterConfig", want: map[string]string{"ClusterId": "uk8s-a"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx, requests := setupCommandGateway(t)
+ runUK8SCommand(t, tt.build(ctx), tt.args...)
+ if tt.name == "delete" && len(*requests) != 2 {
+ t.Fatalf("delete sent %d requests, want 2", len(*requests))
+ }
+ got := lastRequest(t, requests)
+ tt.want["Action"] = tt.action
+ assertRequest(t, got, tt.want)
+ })
+ }
+}
+
+func TestDescribeJSONUsesStructuredResponse(t *testing.T) {
+ ctx, _ := setupCommandGateway(t)
+ ctx.SetFormat(cli.OutputJSON)
+ runUK8SCommand(t, newDescribe(ctx), "--cluster-id", "uk8s-a")
+
+ var got map[string]json.RawMessage
+ if err := json.Unmarshal(ctx.Out().(*bytes.Buffer).Bytes(), &got); err != nil {
+ t.Fatalf("decode JSON output: %v", err)
+ }
+ if _, ok := got["Attribute"]; ok {
+ t.Fatal("describe JSON must be a structured response object, not describe rows")
+ }
+ if string(got["ClusterId"]) != `"uk8s-a"` {
+ t.Fatalf("ClusterId = %s, want uk8s-a", got["ClusterId"])
+ }
+ var kubeProxy struct{ Mode string }
+ if err := json.Unmarshal(got["KubeProxy"], &kubeProxy); err != nil || kubeProxy.Mode != "iptables" {
+ t.Fatalf("KubeProxy = %s, want structured object with iptables mode", got["KubeProxy"])
+ }
+ var masterList []struct{ NodeId string }
+ if err := json.Unmarshal(got["MasterList"], &masterList); err != nil || len(masterList) != 1 || masterList[0].NodeId != "uhost-master" {
+ t.Fatalf("MasterList = %s, want structured node array", got["MasterList"])
+ }
+}
+
+func TestNodeGroupCommandsDispatch(t *testing.T) {
+ tests := []struct {
+ name string
+ args []string
+ action string
+ want map[string]string
+ }{
+ {
+ name: "add",
+ args: []string{
+ "add", "--cluster-id", "uk8s-a/name", "--name", "workers", "--machine-type", "G",
+ "--cpu", "4", "--memory-mb", "8192", "--gpu", "1", "--gpu-type", "V100",
+ "--image-id", "uimage-a/name", "--subnet-id", "subnet-a/name", "--boot-disk-type", "CLOUD_RSSD", "--boot-disk-size-gb", "40",
+ "--charge-type", "Month", "--cpu-platform", "Intel/Cascadelake",
+ },
+ action: "AddUK8SNodeGroup", want: map[string]string{
+ "ClusterId": "uk8s-a", "NodeGroupName": "workers", "MachineType": "G", "CPU": "4", "Mem": "8192",
+ "GPU": "1", "GpuType": "V100", "ImageId": "uimage-a", "SubnetId": "subnet-a",
+ "ChargeType": "Month", "MinimalCpuPlatform": "Intel/Cascadelake",
+ },
+ },
+ {
+ name: "delete",
+ args: []string{"delete", "--cluster-id", "uk8s-a/name", "--nodegroup-id", "uk8sng-a/name", "--yes"},
+ action: "RemoveUK8SNodeGroup", want: map[string]string{"ClusterId": "uk8s-a", "NodeGroupId": "uk8sng-a"},
+ },
+ {
+ name: "list",
+ args: []string{"list", "--cluster-id", "uk8s-a/name"},
+ action: "ListUK8SNodeGroup", want: map[string]string{"ClusterId": "uk8s-a"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx, requests := setupCommandGateway(t)
+ runUK8SCommand(t, newNodeGroup(ctx), tt.args...)
+ got := lastRequest(t, requests)
+ tt.want["Action"] = tt.action
+ assertRequest(t, got, tt.want)
+ })
+ }
+}
+
+func TestNodeGroupAddOmitsUnspecifiedResourceDefaults(t *testing.T) {
+ ctx, requests := setupCommandGateway(t)
+ runUK8SCommand(t, newNodeGroup(ctx), "add",
+ "--cluster-id", "uk8s-a/name", "--name", "workers",
+ "--machine-type", "N", "--cpu", "2", "--memory-mb", "4096",
+ "--image-id", "uimage-a/name", "--subnet-id", "subnet-a/name", "--boot-disk-type", "CLOUD_RSSD", "--boot-disk-size-gb", "40",
+ "--charge-type", "Month", "--cpu-platform", "Intel/Auto")
+ got := lastRequest(t, requests)
+ assertRequest(t, got, map[string]string{
+ "Action": "AddUK8SNodeGroup", "ClusterId": "uk8s-a", "NodeGroupName": "workers",
+ "MachineType": "N", "CPU": "2", "Mem": "4096", "ImageId": "uimage-a", "SubnetId": "subnet-a",
+ "BootDiskType": "CLOUD_RSSD", "BootDiskSize": "40", "ChargeType": "Month",
+ "MinimalCpuPlatform": "Intel/Auto",
+ })
+ for _, key := range []string{"DataDiskType", "DataDiskSize", "GPU", "GpuType"} {
+ if _, ok := got[key]; ok {
+ t.Errorf("unspecified node-group field %s must be omitted", key)
+ }
+ }
+}
+
+func TestNodeCommandsDispatch(t *testing.T) {
+ tests := []struct {
+ name string
+ args []string
+ action string
+ want map[string]string
+ }{
+ {
+ name: "add",
+ args: []string{
+ "add", "--cluster-id", "uk8s-a/name", "--cpu", "2", "--memory-mb", "4096", "--count", "2",
+ "--charge-type", "Dynamic", "--password", "Password1", "--image-id", "uimage-a/name",
+ "--isolation-group-id", "ig-a/name", "--group", "Default", "--user-data", "cloud-init", "--init-script", "echo ready",
+ },
+ action: "AddUK8SUHostNode", want: map[string]string{
+ "ClusterId": "uk8s-a", "CPU": "2", "Mem": "4096", "Count": "2", "ChargeType": "Dynamic",
+ "Password": base64.StdEncoding.EncodeToString([]byte("Password1")), "ImageId": "uimage-a",
+ "IsolationGroup": "ig-a", "Tag": "Default",
+ "UserData": base64.StdEncoding.EncodeToString([]byte("cloud-init")),
+ "InitScript": base64.StdEncoding.EncodeToString([]byte("echo ready")),
+ },
+ },
+ {
+ name: "delete",
+ args: []string{"delete", "--cluster-id", "uk8s-a/name", "--node-id", "node-a/name,node-b/name", "--release-data-udisk=false", "--yes"},
+ action: "DelUK8SClusterNodeV2", want: map[string]string{"ClusterId": "uk8s-a", "NodeId": "node-b", "ReleaseDataUDisk": "false"},
+ },
+ {
+ name: "list",
+ args: []string{"list", "--cluster-id", "uk8s-a/name"},
+ action: "ListUK8SClusterNodeV2", want: map[string]string{"ClusterId": "uk8s-a"},
+ },
+ {
+ name: "describe",
+ args: []string{"describe", "--cluster-id", "uk8s-a/name", "--node-id", "10.0.0.8"},
+ action: "DescribeUK8SNode", want: map[string]string{"ClusterId": "uk8s-a", "Name": "10.0.0.8"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx, requests := setupCommandGateway(t)
+ runUK8SCommand(t, newNode(ctx), tt.args...)
+ if tt.name == "delete" && len(*requests) != 2 {
+ t.Fatalf("delete sent %d requests, want 2", len(*requests))
+ }
+ got := lastRequest(t, requests)
+ tt.want["Action"] = tt.action
+ assertRequest(t, got, tt.want)
+ })
+ }
+}
+
+func TestImageListDispatch(t *testing.T) {
+ ctx, requests := setupCommandGateway(t)
+ runUK8SCommand(t, newImage(ctx), "list", "--zone", "cn-sh2-01")
+ assertRequest(t, lastRequest(t, requests), map[string]string{
+ "Action": "DescribeUK8SImage", "Region": "cn-sh2", "ProjectId": "org-test", "Zone": "cn-sh2-01",
+ })
+}
+
+func TestCompatibilityResponsesKeepUpdatedUK8SFields(t *testing.T) {
+ tests := []struct {
+ name string
+ build func(*cli.Context) *cobra.Command
+ args []string
+ field string
+ }{
+ {
+ name: "cluster describe", build: newDescribe,
+ args: []string{"--cluster-id", "uk8s-a"}, field: "ClusterId",
+ },
+ {
+ name: "image list", build: func(ctx *cli.Context) *cobra.Command { return newImage(ctx) },
+ args: []string{"list"}, field: "CustomImageSet",
+ },
+ {
+ name: "node list", build: func(ctx *cli.Context) *cobra.Command { return newNode(ctx) },
+ args: []string{"list", "--cluster-id", "uk8s-a"}, field: "NodeSet",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx, _ := setupCommandGateway(t)
+ ctx.SetFormat(cli.OutputJSON)
+ runUK8SCommand(t, tt.build(ctx), tt.args...)
+
+ var output map[string]json.RawMessage
+ if err := json.Unmarshal(ctx.Out().(*bytes.Buffer).Bytes(), &output); err != nil {
+ t.Fatalf("decode JSON output: %v", err)
+ }
+ if _, ok := output[tt.field]; !ok {
+ t.Fatalf("output does not contain %q: %s", tt.field, ctx.Out().(*bytes.Buffer).String())
+ }
+ })
+ }
+}
+
+func TestVersionListDispatch(t *testing.T) {
+ ctx, requests := setupCommandGateway(t)
+ runUK8SCommand(t, newVersion(ctx), "list")
+ assertRequest(t, lastRequest(t, requests), map[string]string{
+ "Action": "GetUK8SVersions", "Region": "cn-sh2", "ProjectId": "org-test", "Kind": defaultUK8SKind,
+ })
+}
+
+func TestMutationValidationStopsBeforeAPI(t *testing.T) {
+ tests := []struct {
+ name string
+ cmd func(*cli.Context) *cobra.Command
+ args []string
+ want string
+ }{
+ {
+ name: "node count",
+ cmd: func(ctx *cli.Context) *cobra.Command { return newNode(ctx) },
+ args: []string{"add", "--cluster-id", "uk8s-a", "--cpu", "2", "--memory-mb", "4096", "--count", "0", "--charge-type", "Dynamic", "--password", "Password1"},
+ want: "--count must be between 1 and 50",
+ },
+ {
+ name: "nodegroup gpu",
+ cmd: func(ctx *cli.Context) *cobra.Command { return newNodeGroup(ctx) },
+ args: []string{"add", "--cluster-id", "uk8s-a", "--name", "gpu", "--machine-type", "G", "--cpu", "2", "--memory-mb", "4096", "--image-id", "uimage-a", "--subnet-id", "subnet-a", "--boot-disk-type", "CLOUD_RSSD", "--boot-disk-size-gb", "40"},
+ want: "--gpu and --gpu-type are required",
+ },
+ {
+ name: "nodegroup boot disk type",
+ cmd: func(ctx *cli.Context) *cobra.Command { return newNodeGroup(ctx) },
+ args: []string{"add", "--cluster-id", "uk8s-a", "--name", "workers", "--machine-type", "N", "--cpu", "2", "--memory-mb", "4096", "--image-id", "uimage-a", "--subnet-id", "subnet-a", "--boot-disk-size-gb", "40"},
+ want: "--boot-disk-type is required",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx, requests := setupCommandGateway(t)
+ cmd := tt.cmd(ctx)
+ cmd.SetArgs(tt.args)
+ err := cmd.Execute()
+ if err == nil || !strings.Contains(err.Error(), tt.want) {
+ t.Fatalf("error = %v, want containing %q", err, tt.want)
+ }
+ if len(*requests) != 0 {
+ t.Fatal("invalid mutation must not reach the API")
+ }
+ })
+ }
+}
diff --git a/products/uk8s/internal/uk8s/completion.go b/products/uk8s/internal/uk8s/completion.go
new file mode 100644
index 0000000000..620cbeb0fc
--- /dev/null
+++ b/products/uk8s/internal/uk8s/completion.go
@@ -0,0 +1,179 @@
+package uk8s
+
+import (
+ "slices"
+ "strings"
+
+ uhostsdk "github.com/ucloud/ucloud-sdk-go/services/uhost"
+ uk8ssdk "github.com/ucloud/ucloud-sdk-go/services/uk8s"
+ vpcsdk "github.com/ucloud/ucloud-sdk-go/services/vpc"
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+func derefStr(value *string) string {
+ if value == nil {
+ return ""
+ }
+ return *value
+}
+
+// listClusterIDs returns "ClusterId/Name" completion candidates for any
+// cluster-id flag. Pass states=nil to include all clusters; otherwise filter
+// against ClusterSet[].Status (CLUSTER_RUNNING etc. from status.go).
+//
+// Completion providers must never fail the shell: any error here is swallowed
+// and the provider returns nil (no candidates).
+func listClusterIDs(ctx *cli.Context, states []string, region, projectID string) []string {
+ client := cli.NewServiceClient(ctx, uk8ssdk.NewClient)
+ req := client.NewListUK8SClusterV2Request()
+ req.Region = sdk.String(region)
+ req.ProjectId = sdk.String(projectID)
+ resp, err := client.ListUK8SClusterV2(req)
+ if err != nil {
+ return nil
+ }
+ out := make([]string, 0, len(resp.ClusterSet))
+ for _, c := range resp.ClusterSet {
+ if states != nil && !slices.Contains(states, c.Status) {
+ continue
+ }
+ out = append(out, c.ClusterId+"/"+strings.ReplaceAll(c.ClusterName, " ", "-"))
+ }
+ return out
+}
+
+// listVPCIDs returns "VPCId/Name" candidates. Cross-product resource lookup
+// uses the SDK service package directly (per §8 of the platform spec) — never
+// import another products// tree.
+func listVPCIDs(ctx *cli.Context, projectID, region string) []string {
+ client := cli.NewServiceClient(ctx, vpcsdk.NewClient)
+ req := client.NewDescribeVPCRequest()
+ req.ProjectId = sdk.String(projectID)
+ req.Region = sdk.String(region)
+ resp, err := client.DescribeVPC(req)
+ if err != nil {
+ return nil
+ }
+ out := make([]string, 0, len(resp.DataSet))
+ for _, v := range resp.DataSet {
+ out = append(out, v.VPCId+"/"+strings.ReplaceAll(v.Name, " ", "-"))
+ }
+ return out
+}
+
+// listSubnetIDs returns "SubnetId/Name" candidates for the chosen VPC.
+func listSubnetIDs(ctx *cli.Context, vpcID, projectID, region string) []string {
+ if vpcID == "" {
+ return nil
+ }
+ client := cli.NewServiceClient(ctx, vpcsdk.NewClient)
+ req := client.NewDescribeSubnetRequest()
+ req.VPCId = sdk.String(vpcID)
+ req.ProjectId = sdk.String(projectID)
+ req.Region = sdk.String(region)
+ resp, err := client.DescribeSubnet(req)
+ if err != nil {
+ return nil
+ }
+ out := make([]string, 0, len(resp.DataSet))
+ for _, s := range resp.DataSet {
+ out = append(out, s.SubnetId+"/"+strings.ReplaceAll(s.SubnetName, " ", "-"))
+ }
+ return out
+}
+
+func listUK8SImageIDs(ctx *cli.Context, projectID, region, zone string) []string {
+ client := cli.NewServiceClient(ctx, uk8ssdk.NewClient)
+ req := client.NewDescribeUK8SImageRequest()
+ req.ProjectId = sdk.String(projectID)
+ req.Region = sdk.String(region)
+ req.Zone = sdk.String(zone)
+ resp, err := client.DescribeUK8SImage(req)
+ if err != nil {
+ return nil
+ }
+ out := make([]string, 0, len(resp.ImageSet))
+ for _, image := range resp.ImageSet {
+ out = append(out, image.ImageId+"/"+strings.ReplaceAll(image.ImageName, " ", "-"))
+ }
+ return out
+}
+
+func listUK8SVersions(ctx *cli.Context, projectID, region string) []string {
+ client := cli.NewServiceClient(ctx, uk8ssdk.NewClient)
+ req := client.NewGetUK8SVersionsRequest()
+ req.ProjectId = sdk.String(projectID)
+ req.Region = sdk.String(region)
+ req.Kind = sdk.String(defaultUK8SKind)
+ resp, err := client.GetUK8SVersions(req)
+ if err != nil {
+ return nil
+ }
+ out := make([]string, 0, len(resp.Data))
+ for _, version := range resp.Data {
+ out = append(out, version.K8sVersion)
+ }
+ return out
+}
+
+func listIsolationGroupIDs(ctx *cli.Context, projectID, region string) []string {
+ client := cli.NewServiceClient(ctx, uhostsdk.NewClient)
+ req := client.NewDescribeIsolationGroupRequest()
+ req.ProjectId = sdk.String(projectID)
+ req.Region = sdk.String(region)
+ req.Limit = sdk.Int(100)
+ resp, err := client.DescribeIsolationGroup(req)
+ if err != nil {
+ return nil
+ }
+ out := make([]string, 0, len(resp.IsolationGroupSet))
+ for _, group := range resp.IsolationGroupSet {
+ out = append(out, group.GroupId+"/"+strings.ReplaceAll(group.GroupName, " ", "-"))
+ }
+ return out
+}
+
+func listNodeGroupIDs(ctx *cli.Context, clusterID, projectID, region string) []string {
+ if clusterID == "" {
+ return nil
+ }
+ client := cli.NewServiceClient(ctx, uk8ssdk.NewClient)
+ req := client.NewListUK8SNodeGroupRequest()
+ req.ClusterId = sdk.String(ctx.PickResourceID(clusterID))
+ req.ProjectId = sdk.String(projectID)
+ req.Region = sdk.String(region)
+ resp, err := client.ListUK8SNodeGroup(req)
+ if err != nil {
+ return nil
+ }
+ out := make([]string, 0, len(resp.NodeGroupList))
+ for _, group := range resp.NodeGroupList {
+ out = append(out, group.NodeGroupId+"/"+strings.ReplaceAll(group.NodeGroupName, " ", "-"))
+ }
+ return out
+}
+
+func listNodeIDs(ctx *cli.Context, clusterID, projectID, region string) []string {
+ if clusterID == "" {
+ return nil
+ }
+ client := cli.NewServiceClient(ctx, uk8ssdk.NewClient)
+ req := client.NewListUK8SClusterNodeV2Request()
+ req.ClusterId = sdk.String(ctx.PickResourceID(clusterID))
+ req.ProjectId = sdk.String(projectID)
+ req.Region = sdk.String(region)
+ resp, err := client.ListUK8SClusterNodeV2(req)
+ if err != nil {
+ return nil
+ }
+ out := make([]string, 0, len(resp.NodeSet))
+ for _, node := range resp.NodeSet {
+ if strings.EqualFold(node.NodeRole, "master") {
+ continue
+ }
+ out = append(out, node.NodeId+"/"+strings.ReplaceAll(node.InstanceName, " ", "-"))
+ }
+ return out
+}
diff --git a/products/uk8s/internal/uk8s/create.go b/products/uk8s/internal/uk8s/create.go
new file mode 100644
index 0000000000..585ebe4610
--- /dev/null
+++ b/products/uk8s/internal/uk8s/create.go
@@ -0,0 +1,480 @@
+package uk8s
+
+import (
+ "encoding/base64"
+ "fmt"
+ "net"
+ "regexp"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ uk8ssdk "github.com/ucloud/ucloud-sdk-go/services/uk8s"
+ 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"
+)
+
+// masterCount is fixed at 3 by the platform contract (CreateUK8SClusterV2
+// builds a 3-master HA control plane; the SDK requires 3 Master entries).
+const masterCount = 3
+
+// newCreate implements `ucloud uk8s create`.
+//
+// Platform APIs exercised: cli.NewServiceClient, ctx.BindCommonParams,
+// ctx.PollerTo(...).Spoll (wait path), ctx.ProgressWriter, ctx.EmitResult,
+// ctx.HandleError, command.SetFlagValues, command.SetCompletion,
+// MarkFlagRequired with "Required." descriptions, the --async pattern.
+func newCreate(ctx *cli.Context) *cobra.Command {
+ client := cli.NewServiceClient(ctx, uk8ssdk.NewClient)
+ req := client.NewCreateUK8SClusterV2Request()
+
+ var (
+ async bool
+ masterZones []string
+ nodeZone string
+ kubeProxyMode string
+ chargeType string
+ quantity int
+ userData string
+ userDataB64 string
+ initScript string
+ initScriptB64 string
+ )
+
+ cmd := &cobra.Command{
+ Use: "create",
+ Short: "Create a UK8S cluster",
+ Long: "Create a UK8S (UCloud Kubernetes Service) cluster and, unless --async is set, wait for it to become RUNNING.",
+ SilenceUsage: true,
+ SilenceErrors: false,
+ Args: cobra.NoArgs,
+ PreRunE: func(cmd *cobra.Command, args []string) error {
+ if err := validateCreateCommon(req.Region, req.ProjectId, *req.ServiceCIDR); err != nil {
+ return err
+ }
+ if err := validateCreateShape("master", *req.MasterCPU, *req.MasterMem); err != nil {
+ return err
+ }
+ if err := validateCreateShape("node", *req.Nodes[0].CPU, *req.Nodes[0].Mem); err != nil {
+ return err
+ }
+ if *req.Nodes[0].Count < 1 || *req.Nodes[0].Count > 10 {
+ return fmt.Errorf("--node-count must be between 1 and 10")
+ }
+ if req.Nodes[0].IsolationGroup != nil && *req.Nodes[0].IsolationGroup != "" && *req.Nodes[0].Count > 8 {
+ return fmt.Errorf("--node-count cannot exceed 8 when --node-isolation-group-id is set")
+ }
+ if err := validateCreateOptionalFields(cmd, req); err != nil {
+ return err
+ }
+ if err := validatePassword(*req.Password); err != nil {
+ return err
+ }
+ if cmd.Flags().Changed("charge-type") && !oneOf(chargeType, "Dynamic", "Month", "Year") {
+ return fmt.Errorf("--charge-type must be one of Dynamic, Month, or Year")
+ }
+ if !oneOf(*req.MasterMachineType, "N", "C", "O", "OS") {
+ return fmt.Errorf("--master-machine-type must be one of N, C, O, or OS")
+ }
+ if !oneOf(*req.Nodes[0].MachineType, "N", "C", "G", "O", "OS") {
+ return fmt.Errorf("--node-machine-type must be one of N, C, G, O, or OS")
+ }
+ if *req.Nodes[0].MachineType == "G" {
+ if !cmd.Flags().Changed("node-gpu") || !cmd.Flags().Changed("node-gpu-type") {
+ return fmt.Errorf("--node-gpu and --node-gpu-type are required when --node-machine-type is G")
+ }
+ } else if cmd.Flags().Changed("node-gpu") || cmd.Flags().Changed("node-gpu-type") {
+ return fmt.Errorf("--node-gpu and --node-gpu-type require --node-machine-type G")
+ }
+ if err := bindEncodedValue(cmd, "user-data", userData, "user-data-base64", userDataB64, &req.UserData); err != nil {
+ return err
+ }
+ if err := bindEncodedValue(cmd, "init-script", initScript, "init-script-base64", initScriptB64, &req.InitScript); err != nil {
+ return err
+ }
+ // Pad masterZones to masterCount so the user can supply 1 or 3
+ // zones (single-AZ dev clusters vs. multi-AZ HA).
+ switch len(masterZones) {
+ case 1:
+ masterZones = []string{masterZones[0], masterZones[0], masterZones[0]}
+ case masterCount:
+ // already a triple
+ default:
+ return fmt.Errorf("--master-zone requires exactly 1 or %d entries (got %d)", masterCount, len(masterZones))
+ }
+ if nodeZone == "" {
+ return fmt.Errorf("--node-zone is required")
+ }
+ if cmd.Flags().Changed("charge-type") {
+ req.ChargeType = sdk.String(chargeType)
+ }
+ if cmd.Flags().Changed("quantity") {
+ if chargeType == "Dynamic" {
+ return fmt.Errorf("--quantity must not be set when --charge-type is Dynamic")
+ }
+ req.Quantity = sdk.Int(quantity)
+ }
+ optional := map[string]func(){
+ "external-api-server": func() { req.ExternalApiServer = nil },
+ "cluster-domain": func() { req.ClusterDomain = nil },
+ "master-boot-disk-type": func() { req.MasterBootDiskType = nil },
+ "master-boot-disk-size-gb": func() { req.MasterBootDiskSize = nil },
+ "master-data-disk-type": func() { req.MasterDataDiskType = nil },
+ "master-data-disk-size-gb": func() { req.MasterDataDiskSize = nil },
+ "master-cpu-platform": func() { req.MasterMinimalCpuPlatform = nil },
+ "node-boot-disk-type": func() { req.Nodes[0].BootDiskType = nil },
+ "node-boot-disk-size-gb": func() { req.Nodes[0].BootDiskSIze = nil },
+ "node-data-disk-type": func() { req.Nodes[0].DataDiskType = nil },
+ "node-data-disk-size-gb": func() { req.Nodes[0].DataDiskSize = nil },
+ "node-cpu-platform": func() { req.Nodes[0].MinimalCpuPlatform = nil },
+ "node-max-pods": func() { req.Nodes[0].MaxPods = nil },
+ "node-isolation-group-id": func() { req.Nodes[0].IsolationGroup = nil },
+ "node-labels": func() { req.Nodes[0].Labels = nil },
+ "node-taints": func() { req.Nodes[0].Taints = nil },
+ "node-gpu": func() { req.Nodes[0].GPU = nil },
+ "node-gpu-type": func() { req.Nodes[0].GpuType = nil },
+ "group": func() { req.Tag = nil },
+ }
+ for name, clear := range optional {
+ if !cmd.Flags().Changed(name) {
+ clear()
+ }
+ }
+ return nil
+ },
+ RunE: func(cmd *cobra.Command, args []string) error {
+ // Base64-encode the password: the SDK docstring requires the
+ // caller to base64-encode (echo -n Password1 | base64).
+ if req.Password != nil && *req.Password != "" {
+ encoded := base64.StdEncoding.EncodeToString([]byte(*req.Password))
+ req.Password = sdk.String(encoded)
+ }
+
+ // Build Master slice from --master-zone (pre-padded in PreRunE).
+ masters := make([]uk8ssdk.CreateUK8SClusterV2ParamMaster, 0, masterCount)
+ for _, z := range masterZones {
+ masters = append(masters, uk8ssdk.CreateUK8SClusterV2ParamMaster{Zone: sdk.String(z)})
+ }
+ req.Master = masters
+
+ // Build a single Nodes group; Node.* fields are bound to the
+ // first entry of the slice (count=1 group, multiple VMs).
+ node := uk8ssdk.CreateUK8SClusterV2ParamNodes{
+ Zone: sdk.String(nodeZone),
+ CPU: req.Nodes[0].CPU,
+ Count: req.Nodes[0].Count,
+ Mem: req.Nodes[0].Mem,
+ MachineType: req.Nodes[0].MachineType,
+ BootDiskType: req.Nodes[0].BootDiskType,
+ BootDiskSIze: req.Nodes[0].BootDiskSIze,
+ DataDiskType: req.Nodes[0].DataDiskType,
+ DataDiskSize: req.Nodes[0].DataDiskSize,
+ MinimalCpuPlatform: req.Nodes[0].MinimalCpuPlatform,
+ MaxPods: req.Nodes[0].MaxPods,
+ IsolationGroup: req.Nodes[0].IsolationGroup,
+ Labels: req.Nodes[0].Labels,
+ Taints: req.Nodes[0].Taints,
+ GPU: req.Nodes[0].GPU,
+ GpuType: req.Nodes[0].GpuType,
+ }
+ req.Nodes = []uk8ssdk.CreateUK8SClusterV2ParamNodes{node}
+
+ // Resolve id/name forms for VPC/Subnet/Image (PickResourceID
+ // strips the "/Name" tail that completion hands back).
+ req.VPCId = sdk.String(ctx.PickResourceID(*req.VPCId))
+ req.SubnetId = sdk.String(ctx.PickResourceID(*req.SubnetId))
+ if req.ImageId != nil && *req.ImageId != "" {
+ id := ctx.PickResourceID(*req.ImageId)
+ req.ImageId = sdk.String(id)
+ }
+
+ // Wire kube-proxy from the bound flag.
+ if kubeProxyMode != "" {
+ req.KubeProxy = &uk8ssdk.CreateUK8SClusterV2ParamKubeProxy{Mode: sdk.String(kubeProxyMode)}
+ }
+
+ w := ctx.ProgressWriter()
+ resp, err := client.CreateUK8SClusterV2(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return nil
+ }
+
+ text := fmt.Sprintf("uk8s[%s] is creating", resp.ClusterId)
+ if async {
+ fmt.Fprintln(w, text)
+ } else {
+ ctx.PollerTo(w, describeByID(ctx)).Spoll(resp.ClusterId, text, []string{
+ CLUSTER_RUNNING, CLUSTER_CREATEFAILED, CLUSTER_ERROR, CLUSTER_ABNORMAL,
+ })
+ }
+
+ // json/yaml: structured row on stdout; table: no-op (text above
+ // is the result).
+ ctx.EmitResult(cli.OpResultRow{ResourceID: resp.ClusterId, Action: "create", Status: "Creating"})
+ return nil
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ // Pre-allocate the single Nodes group so flag bindings can write into
+ // its fields (the SDK request constructor returns an empty slice).
+ req.Nodes = []uk8ssdk.CreateUK8SClusterV2ParamNodes{{}}
+
+ // --- Required: cluster identification ---
+ req.ClusterName = flags.String("name", "", "Required. Cluster name.")
+ req.K8sVersion = flags.String("k8s-version", "", "Required. Kubernetes version. Use 'ucloud uk8s version list' to find a supported version.")
+ req.Password = flags.String("password", "", "Required. Password for cluster nodes (Master + Node). 8-30 chars from A-Z, a-z, 0-9 and ()~!@#$%^&*-+=_|{}[]:;'\\<>,.?/; must include at least 2 of {uppercase, lowercase, digit, special symbol}. Base64-encoded automatically before submission.")
+
+ // --- Required: network ---
+ req.VPCId = flags.String("vpc-id", "", "Required. VPC ID. See 'ucloud vpc list'.")
+ req.SubnetId = flags.String("subnet-id", "", "Required. Subnet ID where nodes and pods live. See 'ucloud subnet list'.")
+ req.ServiceCIDR = flags.String("service-cidr", "", "Required. Service CIDR for ClusterIP allocation (e.g. 172.17.0.0/16). Must not overlap with the VPC CIDR.")
+
+ // --- Required: master ---
+ req.MasterCPU = flags.Int("master-cpu", 0, "Required. vCPU cores per Master node. Range [2, 64].")
+ req.MasterMem = flags.Int("master-memory-mb", 0, "Required. Memory per Master node. Unit: MB. Range [4096, 262144], multiple of 1024.")
+ req.MasterMachineType = flags.String("master-machine-type", "", "Required. Master machine type. One of N, C, O, OS.")
+ flags.StringSliceVar(&masterZones, "master-zone", nil, "Required. Availability zone(s) for the 3 Master nodes. Pass 1 zone (replicated 3x) or 3 zones for multi-AZ HA.")
+
+ // --- Required: nodes (first/only group) ---
+ req.Nodes[0].CPU = flags.Int("node-cpu", 0, "Required. vCPU cores per Node. Range [2, 64].")
+ req.Nodes[0].Count = flags.Int("node-count", 0, "Required. Node count per group. Range [1, 10].")
+ req.Nodes[0].Mem = flags.Int("node-memory-mb", 0, "Required. Memory per Node. Unit: MB. Range [4096, 262144], multiple of 1024.")
+ req.Nodes[0].MachineType = flags.String("node-machine-type", "", "Required. Node machine type. One of N, C, G, O, OS.")
+ flags.StringVar(&nodeZone, "node-zone", "", "Required. Availability zone for the node group.")
+
+ // --- Required: image ---
+ req.ImageId = flags.String("image-id", "", "Required. Compatible UK8S UHost image ID for Master and Node. See 'ucloud uk8s image list'.")
+
+ // --- Optional: extras ---
+ req.ExternalApiServer = flags.String("external-api-server", "", "Optional. Expose the API server on the public internet. Accept values: Yes, No.")
+ req.ClusterDomain = flags.String("cluster-domain", "", "Optional. Custom cluster domain.")
+ flags.BoolVar(&async, "async", false, "Optional. Do not wait for the cluster to become RUNNING.")
+
+ // Master disk / platform (optional)
+ req.MasterBootDiskType = flags.String("master-boot-disk-type", "", "Optional. Master system disk type. See uhost disk types.")
+ req.MasterBootDiskSize = flags.Int("master-boot-disk-size-gb", 0, "Optional. Master system disk size in GB. Range [40, 500].")
+ req.MasterDataDiskType = flags.String("master-data-disk-type", "", "Optional. Master data disk type.")
+ req.MasterDataDiskSize = flags.Int("master-data-disk-size-gb", 0, "Optional. Master data disk size in GB. Range [20, 1000].")
+ req.MasterMinimalCpuPlatform = flags.String("master-cpu-platform", "", "Optional. Minimum CPU platform. E.g. Intel/Cascadelake.")
+
+ // Node disk / platform (optional)
+ req.Nodes[0].BootDiskType = flags.String("node-boot-disk-type", "", "Optional. Node system disk type.")
+ req.Nodes[0].BootDiskSIze = flags.Int("node-boot-disk-size-gb", 0, "Optional. Node system disk size in GB. Range [40, 500].")
+ req.Nodes[0].DataDiskType = flags.String("node-data-disk-type", "", "Optional. Node data disk type.")
+ req.Nodes[0].DataDiskSize = flags.Int("node-data-disk-size-gb", 0, "Optional. Node data disk size in GB. Range [20, 1000].")
+ req.Nodes[0].MinimalCpuPlatform = flags.String("node-cpu-platform", "", "Optional. Minimum CPU platform.")
+ req.Nodes[0].MaxPods = flags.Int("node-max-pods", 0, "Optional. Maximum pods per node.")
+ req.Nodes[0].IsolationGroup = flags.String("node-isolation-group-id", "", "Optional. Isolation group ID for Node instances; one group supports at most 8 nodes.")
+ req.Nodes[0].Labels = flags.String("node-labels", "", "Optional. Comma-separated node labels in key=value form, at most 5.")
+ req.Nodes[0].Taints = flags.String("node-taints", "", "Optional. Comma-separated node taints in key=value:effect form, at most 5.")
+ req.Nodes[0].GPU = flags.Int("node-gpu", 0, "Optional. GPU core count; supported only by GPU-capable machine types.")
+ req.Nodes[0].GpuType = flags.String("node-gpu-type", "", "Optional. GPU type: K80, P40, or V100.")
+
+ // Cluster customization. Plain values are base64-encoded by the CLI; the
+ // *-base64 variants accept already encoded data and conflict with plain input.
+ flags.StringVar(&userData, "user-data", "", "Optional. User data; base64-encoded automatically. Maximum decoded size: 16 KiB.")
+ flags.StringVar(&userDataB64, "user-data-base64", "", "Optional. Pre-encoded user data. Conflicts with --user-data.")
+ flags.StringVar(&initScript, "init-script", "", "Optional. Post-install script; base64-encoded automatically. Maximum decoded size: 16 KiB.")
+ flags.StringVar(&initScriptB64, "init-script-base64", "", "Optional. Pre-encoded post-install script. Conflicts with --init-script.")
+ req.Tag = flags.String("group", "", "Optional. Business group.")
+
+ // kube-proxy
+ flags.StringVar(&kubeProxyMode, "kube-proxy-mode", "", "Optional. kube-proxy mode. Accept values: iptables, ipvs.")
+
+ ctx.BindRegion(cmd, req)
+ ctx.BindProjectID(cmd, req)
+ flags.StringVar(&chargeType, "charge-type", "", "Optional. Billing mode: Dynamic, Month, or Year.")
+ flags.IntVar(&quantity, "quantity", 0, "Optional. Purchase duration.")
+
+ // Static enum candidates.
+ command.SetFlagValues(cmd, "master-machine-type", "N", "C", "O", "OS")
+ command.SetFlagValues(cmd, "node-machine-type", "N", "C", "G", "O", "OS")
+ command.SetFlagValues(cmd, "external-api-server", "Yes", "No")
+ command.SetFlagValues(cmd, "kube-proxy-mode", "iptables", "ipvs")
+ command.SetFlagValues(cmd, "master-boot-disk-type", "CLOUD_SSD", "CLOUD_NORMAL", "LOCAL_SSD", "LOCAL_NORMAL", "CLOUD_RSSD", "EXCLUSIVE_LOCAL_DISK")
+ command.SetFlagValues(cmd, "master-data-disk-type", "CLOUD_SSD", "CLOUD_NORMAL", "LOCAL_SSD", "LOCAL_NORMAL", "CLOUD_RSSD", "EXCLUSIVE_LOCAL_DISK", "")
+ command.SetFlagValues(cmd, "node-boot-disk-type", "CLOUD_SSD", "CLOUD_NORMAL", "LOCAL_SSD", "LOCAL_NORMAL", "CLOUD_RSSD", "EXCLUSIVE_LOCAL_DISK")
+ command.SetFlagValues(cmd, "node-data-disk-type", "CLOUD_SSD", "CLOUD_NORMAL", "LOCAL_SSD", "LOCAL_NORMAL", "CLOUD_RSSD", "EXCLUSIVE_LOCAL_DISK", "")
+ command.SetFlagValues(cmd, "master-cpu-platform", "Intel/Auto", "Intel/IvyBridge", "Intel/Haswell", "Intel/Broadwell", "Intel/Skylake", "Intel/Cascadelake")
+ command.SetFlagValues(cmd, "node-cpu-platform", "Intel/Auto", "Intel/IvyBridge", "Intel/Haswell", "Intel/Broadwell", "Intel/Skylake", "Intel/Cascadelake")
+ command.SetFlagValues(cmd, "charge-type", "Dynamic", "Month", "Year")
+ command.SetFlagValues(cmd, "node-gpu-type", "K80", "P40", "V100")
+
+ // Dynamic completion for resource ids.
+ command.SetCompletion(cmd, "vpc-id", func() []string {
+ return listVPCIDs(ctx, derefStr(req.ProjectId), derefStr(req.Region))
+ })
+ command.SetCompletion(cmd, "subnet-id", func() []string {
+ return listSubnetIDs(ctx, ctx.PickResourceID(*req.VPCId), derefStr(req.ProjectId), derefStr(req.Region))
+ })
+ command.SetCompletion(cmd, "k8s-version", func() []string {
+ return listUK8SVersions(ctx, derefStr(req.ProjectId), derefStr(req.Region))
+ })
+ command.SetCompletion(cmd, "image-id", func() []string {
+ return listUK8SImageIDs(ctx, derefStr(req.ProjectId), derefStr(req.Region), nodeZone)
+ })
+ command.SetCompletion(cmd, "node-isolation-group-id", func() []string {
+ return listIsolationGroupIDs(ctx, derefStr(req.ProjectId), derefStr(req.Region))
+ })
+
+ cmd.MarkFlagRequired("name")
+ cmd.MarkFlagRequired("password")
+ cmd.MarkFlagRequired("vpc-id")
+ cmd.MarkFlagRequired("subnet-id")
+ cmd.MarkFlagRequired("service-cidr")
+ cmd.MarkFlagRequired("master-cpu")
+ cmd.MarkFlagRequired("master-memory-mb")
+ cmd.MarkFlagRequired("master-machine-type")
+ cmd.MarkFlagRequired("master-zone")
+ cmd.MarkFlagRequired("node-cpu")
+ cmd.MarkFlagRequired("node-count")
+ cmd.MarkFlagRequired("node-memory-mb")
+ cmd.MarkFlagRequired("node-machine-type")
+ cmd.MarkFlagRequired("node-zone")
+ cmd.MarkFlagRequired("image-id")
+ cmd.MarkFlagRequired("k8s-version")
+
+ return cmd
+}
+
+func validateCreateCommon(region, projectID *string, serviceCIDR string) error {
+ if region == nil || strings.TrimSpace(*region) == "" {
+ return fmt.Errorf("region is required; set --region or configure it in the active profile")
+ }
+ if projectID == nil || strings.TrimSpace(*projectID) == "" {
+ return fmt.Errorf("project ID is required; set --project-id or configure it in the active profile")
+ }
+ if _, _, err := net.ParseCIDR(serviceCIDR); err != nil {
+ return fmt.Errorf("--service-cidr must be a valid CIDR: %w", err)
+ }
+ return nil
+}
+
+func validateCreateShape(prefix string, cpu, memory int) error {
+ if cpu < 2 || cpu > 64 {
+ return fmt.Errorf("--%s-cpu must be between 2 and 64", prefix)
+ }
+ if memory < 4096 || memory > 262144 || memory%1024 != 0 {
+ return fmt.Errorf("--%s-memory-mb must be between 4096 and 262144 and a multiple of 1024", prefix)
+ }
+ return nil
+}
+
+func validateCreateOptionalFields(cmd *cobra.Command, req *uk8ssdk.CreateUK8SClusterV2Request) error {
+ if cmd.Flags().Changed("external-api-server") && !oneOf(*req.ExternalApiServer, "Yes", "No") {
+ return fmt.Errorf("--external-api-server must be Yes or No")
+ }
+ if mode, err := cmd.Flags().GetString("kube-proxy-mode"); err == nil && cmd.Flags().Changed("kube-proxy-mode") && !oneOf(mode, "iptables", "ipvs") {
+ return fmt.Errorf("--kube-proxy-mode must be iptables or ipvs")
+ }
+ if cmd.Flags().Changed("node-gpu-type") && !oneOf(*req.Nodes[0].GpuType, "K80", "P40", "V100") {
+ return fmt.Errorf("--node-gpu-type must be one of K80, P40, or V100")
+ }
+ if cmd.Flags().Changed("node-gpu") && *req.Nodes[0].GPU < 1 {
+ return fmt.Errorf("--node-gpu must be greater than 0")
+ }
+ if cmd.Flags().Changed("node-max-pods") && *req.Nodes[0].MaxPods < 1 {
+ return fmt.Errorf("--node-max-pods must be greater than 0")
+ }
+ for _, item := range []struct {
+ name string
+ value *int
+ min, max int
+ }{
+ {"master-boot-disk-size-gb", req.MasterBootDiskSize, 40, 500},
+ {"node-boot-disk-size-gb", req.Nodes[0].BootDiskSIze, 40, 500},
+ {"master-data-disk-size-gb", req.MasterDataDiskSize, 20, 1000},
+ {"node-data-disk-size-gb", req.Nodes[0].DataDiskSize, 20, 1000},
+ } {
+ if cmd.Flags().Changed(item.name) && (*item.value < item.min || *item.value > item.max) {
+ return fmt.Errorf("--%s must be between %d and %d", item.name, item.min, item.max)
+ }
+ }
+ for _, item := range []struct {
+ name string
+ value *string
+ }{
+ {"node-labels", req.Nodes[0].Labels},
+ {"node-taints", req.Nodes[0].Taints},
+ } {
+ if item.value != nil && *item.value != "" && len(strings.Split(*item.value, ",")) > 5 {
+ return fmt.Errorf("--%s accepts at most 5 comma-separated entries", item.name)
+ }
+ }
+ return nil
+}
+
+func bindEncodedValue(cmd *cobra.Command, plainName, plain, encodedName, encoded string, target **string) error {
+ if plain != "" && encoded != "" {
+ return fmt.Errorf("--%s conflicts with --%s", plainName, encodedName)
+ }
+ if plain != "" {
+ if len([]byte(plain)) > 16*1024 {
+ return fmt.Errorf("--%s must not exceed 16 KiB", plainName)
+ }
+ *target = sdk.String(base64.StdEncoding.EncodeToString([]byte(plain)))
+ return nil
+ }
+ if encoded != "" {
+ if !common.IsBase64Encoded([]byte(encoded)) {
+ return fmt.Errorf("--%s must be base64-encoded", encodedName)
+ }
+ decoded, err := base64.StdEncoding.DecodeString(encoded)
+ if err != nil || len(decoded) > 16*1024 {
+ return fmt.Errorf("--%s decoded value must not exceed 16 KiB", encodedName)
+ }
+ *target = sdk.String(encoded)
+ return nil
+ }
+ if !cmd.Flags().Changed(plainName) && !cmd.Flags().Changed(encodedName) {
+ *target = nil
+ }
+ return nil
+}
+
+func oneOf(value string, allowed ...string) bool {
+ for _, candidate := range allowed {
+ if value == candidate {
+ return true
+ }
+ }
+ return false
+}
+
+// UK8S password policy (server-side UHost/CreateUK8SClusterV2 rule):
+// 8-30 chars from [A-Za-z0-9()~!@#$%^&*-+=_|{}[]:;'\<>,.?/], must include at
+// least 2 of {uppercase, lowercase, digit, special symbol}. Mirrors the
+// check used by UHost/UASGTemplateBuilder so we fail fast at the CLI.
+var (
+ uk8sPwdAllowed = regexp.MustCompile(`^[A-Za-z0-9()~!@#$%^&*\-_+=|{}[\]:;'\\<>,.?/]+$`)
+ uk8sPwdUpper = regexp.MustCompile(`[A-Z]`)
+ uk8sPwdLower = regexp.MustCompile(`[a-z]`)
+ uk8sPwdDigit = regexp.MustCompile(`[0-9]`)
+ uk8sPwdSpecial = regexp.MustCompile(`[^A-Za-z0-9]`)
+)
+
+func validatePassword(raw string) error {
+ if l := len(raw); l < 8 || l > 30 {
+ return fmt.Errorf("--password must be 8-30 characters long (got %d)", l)
+ }
+ if !uk8sPwdAllowed.MatchString(raw) {
+ return fmt.Errorf("--password contains illegal characters; allowed: A-Z, a-z, 0-9 and ()~!@#$%%^&*-_+=|{}[]:;'\\<>,.?/")
+ }
+ classes := 0
+ for _, re := range []*regexp.Regexp{uk8sPwdUpper, uk8sPwdLower, uk8sPwdDigit, uk8sPwdSpecial} {
+ if re.MatchString(raw) {
+ classes++
+ }
+ }
+ if classes < 2 {
+ return fmt.Errorf("--password must contain at least 2 of: uppercase, lowercase, digit, special symbol")
+ }
+ return nil
+}
diff --git a/products/uk8s/internal/uk8s/create_test.go b/products/uk8s/internal/uk8s/create_test.go
new file mode 100644
index 0000000000..1d1cc258b3
--- /dev/null
+++ b/products/uk8s/internal/uk8s/create_test.go
@@ -0,0 +1,271 @@
+package uk8s
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+
+ 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 setupCreateMock(t *testing.T) (*cli.Context, *url.Values, func()) {
+ t.Helper()
+
+ values := &url.Values{}
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ t.Errorf("parse request form: %v", err)
+ }
+ *values = r.PostForm
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]interface{}{
+ "RetCode": 0,
+ "Action": "CreateUK8SClusterV2Response",
+ "ClusterId": "uk8s-test",
+ })
+ }))
+
+ var out, errOut bytes.Buffer
+ ctx := cli.NewContext(cli.Deps{
+ In: strings.NewReader(""),
+ Out: &out,
+ Err: &errOut,
+ Format: cli.OutputTable,
+ DefaultsProvider: func() command.Defaults {
+ return command.Defaults{Region: "cn-sh2", ProjectID: "org-test"}
+ },
+ ClientConfig: func() *sdk.Config {
+ return &sdk.Config{Region: "cn-sh2", ProjectId: "org-test", BaseUrl: server.URL}
+ },
+ BuildCredential: func() *auth.Credential {
+ return &auth.Credential{PublicKey: "public", PrivateKey: "private"}
+ },
+ AttachHandlers: func(sdk.ServiceClient) {},
+ })
+ cleanup := func() {
+ server.Close()
+ }
+ return ctx, values, cleanup
+}
+
+func requiredCreateArgs() []string {
+ return []string{
+ "--name", "demo-uk8s",
+ "--password", "Password1",
+ "--vpc-id", "uvnet-test/vpc-name",
+ "--subnet-id", "subnet-test/subnet-name",
+ "--service-cidr", "172.17.0.0/16",
+ "--master-cpu", "2",
+ "--master-memory-mb", "4096",
+ "--master-machine-type", "N",
+ "--master-zone", "cn-sh2-01",
+ "--node-cpu", "2",
+ "--node-count", "3",
+ "--node-memory-mb", "4096",
+ "--node-machine-type", "N",
+ "--node-zone", "cn-sh2-01",
+ "--image-id", "uimage-test/image-name",
+ "--k8s-version", "1.34.5",
+ "--async",
+ }
+}
+
+func TestCreateRequestMatchesDocument(t *testing.T) {
+ ctx, form, cleanup := setupCreateMock(t)
+ defer cleanup()
+
+ cmd := newCreate(ctx)
+ args := append(requiredCreateArgs(),
+ "--charge-type", "Month",
+ "--quantity", "1",
+ "--node-isolation-group-id", "ig-test",
+ "--node-machine-type", "G",
+ "--node-gpu", "1",
+ "--node-gpu-type", "V100",
+ "--node-labels", "env=test,team=cli",
+ "--node-taints", "dedicated=test:NoSchedule",
+ "--node-max-pods", "110",
+ "--group", "Default",
+ "--user-data", "cloud-init",
+ "--init-script", "echo ready",
+ )
+ cmd.SetArgs(args)
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("execute create: %v", err)
+ }
+
+ want := map[string]string{
+ "Action": "CreateUK8SClusterV2",
+ "Region": "cn-sh2",
+ "ProjectId": "org-test",
+ "ClusterName": "demo-uk8s",
+ "Password": base64.StdEncoding.EncodeToString([]byte("Password1")),
+ "VPCId": "uvnet-test",
+ "SubnetId": "subnet-test",
+ "ServiceCIDR": "172.17.0.0/16",
+ "K8sVersion": "1.34.5",
+ "ImageId": "uimage-test",
+ "MasterCPU": "2",
+ "MasterMem": "4096",
+ "MasterMachineType": "N",
+ "Master.0.Zone": "cn-sh2-01",
+ "Master.1.Zone": "cn-sh2-01",
+ "Master.2.Zone": "cn-sh2-01",
+ "Nodes.0.Zone": "cn-sh2-01",
+ "Nodes.0.CPU": "2",
+ "Nodes.0.Count": "3",
+ "Nodes.0.Mem": "4096",
+ "Nodes.0.MachineType": "G",
+ "Nodes.0.GPU": "1",
+ "Nodes.0.GpuType": "V100",
+ "Nodes.0.IsolationGroup": "ig-test",
+ "Nodes.0.Labels": "env=test,team=cli",
+ "Nodes.0.Taints": "dedicated=test:NoSchedule",
+ "Nodes.0.MaxPods": "110",
+ "ChargeType": "Month",
+ "Quantity": "1",
+ "Tag": "Default",
+ "UserData": base64.StdEncoding.EncodeToString([]byte("cloud-init")),
+ "InitScript": base64.StdEncoding.EncodeToString([]byte("echo ready")),
+ }
+ for key, expected := range want {
+ if got := form.Get(key); got != expected {
+ t.Errorf("request %s = %q, want %q", key, got, expected)
+ }
+ }
+}
+
+func TestCreateOmitsDocumentedOptionalFields(t *testing.T) {
+ ctx, form, cleanup := setupCreateMock(t)
+ defer cleanup()
+
+ cmd := newCreate(ctx)
+ cmd.SetArgs(requiredCreateArgs())
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("execute create: %v", err)
+ }
+
+ for _, key := range []string{
+ "ChargeType", "Quantity", "UserData", "InitScript",
+ "Nodes.0.MaxPods", "Nodes.0.IsolationGroup", "Nodes.0.Labels", "Nodes.0.Taints",
+ } {
+ if _, ok := (*form)[key]; ok {
+ t.Errorf("optional request field %s must be omitted", key)
+ }
+ }
+}
+
+func TestCreateRejectsInvalidShapeBeforeRequest(t *testing.T) {
+ ctx, form, cleanup := setupCreateMock(t)
+ defer cleanup()
+
+ cmd := newCreate(ctx)
+ args := requiredCreateArgs()
+ for i := range args {
+ if args[i] == "--node-memory-mb" {
+ args[i+1] = "5000"
+ }
+ }
+ cmd.SetArgs(args)
+ if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "multiple of 1024") {
+ t.Fatalf("expected memory validation error, got %v", err)
+ }
+ if len(*form) != 0 {
+ t.Fatal("invalid command must not reach the API")
+ }
+}
+
+func TestValidatePassword(t *testing.T) {
+ cases := []struct {
+ name string
+ pwd string
+ wantErr string // substring; "" means accept
+ }{
+ // Valid
+ {name: "9 chars upper+lower+digit", pwd: "Password1", wantErr: ""},
+ {name: "8 chars lower+digit (boundary)", pwd: "abc12345", wantErr: ""},
+ {name: "30 chars all four classes (boundary)", pwd: strings.Repeat("Aa1!", 7) + "Aa", wantErr: ""},
+ {name: "9 chars lower+digit+special no upper", pwd: "password1!", wantErr: ""},
+ {name: "all four classes", pwd: "Abc123!@#", wantErr: ""},
+ {name: "backslash is allowed", pwd: `Pa\ssw0rd`, wantErr: ""},
+
+ // Length
+ {name: "7 chars too short", pwd: "Abc1234", wantErr: "8-30"},
+ {name: "31 chars too long", pwd: strings.Repeat("Aa1!", 7) + "Aa1", wantErr: "8-30"},
+ {name: "empty", pwd: "", wantErr: "8-30"},
+
+ // Illegal chars (not in allowed set)
+ {name: "contains space", pwd: "Pass word1", wantErr: "illegal characters"},
+ {name: "contains tab", pwd: "Pass\tword1", wantErr: "illegal characters"},
+ {name: "contains chinese char", pwd: "Password密1", wantErr: "illegal characters"},
+ {name: "contains double-quote", pwd: `Pass"word1`, wantErr: "illegal characters"},
+ {name: "contains backtick", pwd: "Pass`word1", wantErr: "illegal characters"},
+
+ // Single class is not enough
+ {name: "only digits", pwd: "12345678", wantErr: "at least 2"},
+ {name: "only lowercase", pwd: "abcdefgh", wantErr: "at least 2"},
+ {name: "only uppercase", pwd: "ABCDEFGH", wantErr: "at least 2"},
+ {name: "only specials", pwd: "!@#$%^&*", wantErr: "at least 2"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ err := validatePassword(tc.pwd)
+ if tc.wantErr == "" {
+ if err != nil {
+ t.Fatalf("validatePassword(%q) unexpected error: %v", tc.pwd, err)
+ }
+ return
+ }
+ if err == nil {
+ t.Fatalf("validatePassword(%q) returned nil, want error containing %q", tc.pwd, tc.wantErr)
+ }
+ if !strings.Contains(err.Error(), tc.wantErr) {
+ t.Fatalf("validatePassword(%q) = %v, want substring %q", tc.pwd, err, tc.wantErr)
+ }
+ })
+ }
+}
+
+func TestCreateRejectsBadPasswordBeforeRequest(t *testing.T) {
+ cases := []struct {
+ name string
+ pwd string
+ wantErr string
+ }{
+ {name: "too short", pwd: "Ab1!", wantErr: "8-30"},
+ {name: "illegal char", pwd: "Password 1", wantErr: "illegal characters"},
+ {name: "single class", pwd: "abcdefgh", wantErr: "at least 2"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ ctx, form, cleanup := setupCreateMock(t)
+ defer cleanup()
+
+ cmd := newCreate(ctx)
+ args := requiredCreateArgs()
+ for i := range args {
+ if args[i] == "--password" {
+ args[i+1] = tc.pwd
+ break
+ }
+ }
+ cmd.SetArgs(args)
+ err := cmd.Execute()
+ if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
+ t.Fatalf("expected error containing %q, got %v", tc.wantErr, err)
+ }
+ if len(*form) != 0 {
+ t.Fatal("invalid password must not reach the API")
+ }
+ })
+ }
+}
diff --git a/products/uk8s/internal/uk8s/delete.go b/products/uk8s/internal/uk8s/delete.go
new file mode 100644
index 0000000000..72b209ff64
--- /dev/null
+++ b/products/uk8s/internal/uk8s/delete.go
@@ -0,0 +1,101 @@
+package uk8s
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ uk8ssdk "github.com/ucloud/ucloud-sdk-go/services/uk8s"
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+ "github.com/ucloud/ucloud-sdk-go/ucloud/request"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+func newDelete(ctx *cli.Context) *cobra.Command {
+ client := cli.NewServiceClient(ctx, uk8ssdk.NewClient)
+ req := client.NewDelUK8SClusterRequest()
+
+ var clusterIDs []string
+ var releaseUDisk bool
+ var releaseEIP bool
+ var yes bool
+
+ cmd := &cobra.Command{
+ Use: "delete",
+ Short: "Delete UK8S clusters",
+ Long: "Delete one or more UK8S clusters by cluster ID.",
+ Args: cobra.NoArgs,
+ Run: func(cmd *cobra.Command, args []string) {
+ ok, err := ctx.Confirm(yes, "Are you sure you want to delete the UK8S cluster(s)?")
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ if !ok {
+ return
+ }
+
+ w := ctx.ProgressWriter()
+ results := make([]cli.OpResultRow, 0, len(clusterIDs))
+ for _, idName := range clusterIDs {
+ id := ctx.PickResourceID(idName)
+ req.ClusterId = sdk.String(id)
+ req.ReleaseUDisk = sdk.Bool(releaseUDisk)
+ var err error
+ if releaseEIP {
+ // DelUK8SClusterRequest in older SDK schemas does not expose
+ // ReleaseEIP, but the UK8S API accepts it. Use a local request
+ // shape only when the user explicitly opts in. Preserve CommonBase
+ // so the bound region and project ID are sent with this request.
+ eipReq := &deleteClusterRequest{
+ CommonBase: req.CommonBase,
+ ClusterId: req.ClusterId,
+ ReleaseUDisk: req.ReleaseUDisk,
+ ReleaseEIP: sdk.Bool(true),
+ }
+ client.SetupRequest(eipReq)
+ var resp uk8ssdk.DelUK8SClusterResponse
+ err = client.InvokeAction("DelUK8SCluster", eipReq, &resp)
+ } else {
+ _, err = client.DelUK8SCluster(req)
+ }
+ if err != nil {
+ ctx.HandleError(err)
+ continue
+ }
+ fmt.Fprintf(w, "uk8s[%s] deletion requested\n", id)
+ results = append(results, cli.OpResultRow{
+ ResourceID: id,
+ Action: "delete",
+ Status: "Deleting",
+ })
+ }
+ ctx.EmitResult(results...)
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+ flags.StringSliceVar(&clusterIDs, "cluster-id", nil, "Required. Cluster ID(s) to delete.")
+ flags.BoolVar(&releaseUDisk, "release-udisk", false, "Optional. Release data disks attached to cluster nodes.")
+ flags.BoolVar(&releaseEIP, "release-eip", false, "Optional. Release EIP resources attached to the cluster.")
+ flags.BoolVarP(&yes, "yes", "y", false, "Optional. Skip the confirmation prompt.")
+ ctx.BindRegion(cmd, req)
+ ctx.BindProjectID(cmd, req)
+ cmd.MarkFlagRequired("cluster-id")
+ command.SetCompletion(cmd, "cluster-id", func() []string {
+ return listClusterIDs(ctx, nil, derefStr(req.Region), derefStr(req.ProjectId))
+ })
+ return cmd
+}
+
+// deleteClusterRequest carries the optional ReleaseEIP field that is not yet
+// present in the generated UK8S SDK request type.
+type deleteClusterRequest struct {
+ request.CommonBase
+ ClusterId *string `required:"true"`
+ ReleaseUDisk *bool `required:"false"`
+ ReleaseEIP *bool `required:"false"`
+}
diff --git a/products/uk8s/internal/uk8s/describe.go b/products/uk8s/internal/uk8s/describe.go
new file mode 100644
index 0000000000..6afde75cc9
--- /dev/null
+++ b/products/uk8s/internal/uk8s/describe.go
@@ -0,0 +1,53 @@
+package uk8s
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ uk8ssdk "github.com/ucloud/ucloud-sdk-go/services/uk8s"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+func newDescribe(ctx *cli.Context) *cobra.Command {
+ client := cli.NewServiceClient(ctx, uk8ssdk.NewClient)
+ req := client.NewDescribeUK8SClusterRequest()
+
+ cmd := &cobra.Command{
+ Use: "describe",
+ Short: "Show details of a UK8S cluster",
+ Long: "Show the attributes of one UK8S cluster.",
+ Args: cobra.NoArgs,
+ Run: func(cmd *cobra.Command, args []string) {
+ *req.ClusterId = ctx.PickResourceID(*req.ClusterId)
+ cluster, err := client.DescribeUK8SCluster(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ if cluster.ClusterId == "" {
+ ctx.HandleError(fmt.Errorf("cluster %q not found", *req.ClusterId))
+ return
+ }
+ if ctx.Format() != cli.OutputTable {
+ ctx.PrintList(cluster)
+ return
+ }
+
+ ctx.PrintList(clusterDescribeRows(cluster))
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+ req.ClusterId = flags.String("cluster-id", "", "Required. Cluster ID to describe.")
+ ctx.BindRegion(cmd, req)
+ ctx.BindProjectID(cmd, req)
+ cmd.MarkFlagRequired("cluster-id")
+ command.SetCompletion(cmd, "cluster-id", func() []string {
+ return listClusterIDs(ctx, nil, derefStr(req.Region), derefStr(req.ProjectId))
+ })
+ return cmd
+}
diff --git a/products/uk8s/internal/uk8s/get_config.go b/products/uk8s/internal/uk8s/get_config.go
new file mode 100644
index 0000000000..c04a208910
--- /dev/null
+++ b/products/uk8s/internal/uk8s/get_config.go
@@ -0,0 +1,66 @@
+package uk8s
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ uk8ssdk "github.com/ucloud/ucloud-sdk-go/services/uk8s"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+func newGetConfig(ctx *cli.Context) *cobra.Command {
+ client := cli.NewServiceClient(ctx, uk8ssdk.NewClient)
+ req := client.NewGetClusterConfigRequest()
+ var external bool
+
+ cmd := &cobra.Command{
+ Use: "get-config",
+ Short: "Print a UK8S cluster kubeconfig",
+ Long: "Print the internal kubeconfig, or the external kubeconfig with --external.",
+ Args: cobra.NoArgs,
+ Run: func(cmd *cobra.Command, args []string) {
+ *req.ClusterId = ctx.PickResourceID(*req.ClusterId)
+ resp, err := client.GetClusterConfig(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ config := resp.KubeConfig
+ if external {
+ config = resp.ExternalKubeConfig
+ }
+ if strings.TrimSpace(config) == "" {
+ kind := "internal"
+ if external {
+ kind = "external"
+ }
+ ctx.HandleError(fmt.Errorf("%s kubeconfig is not available for cluster %q", kind, *req.ClusterId))
+ return
+ }
+ if ctx.Format() != cli.OutputTable {
+ ctx.PrintList(resp)
+ return
+ }
+ fmt.Fprint(ctx.Out(), config)
+ if !strings.HasSuffix(config, "\n") {
+ fmt.Fprintln(ctx.Out())
+ }
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+ req.ClusterId = flags.String("cluster-id", "", "Required. Cluster ID whose kubeconfig will be printed.")
+ flags.BoolVar(&external, "external", false, "Optional. Print the external kubeconfig.")
+ ctx.BindRegion(cmd, req)
+ ctx.BindProjectID(cmd, req)
+ cmd.MarkFlagRequired("cluster-id")
+ command.SetCompletion(cmd, "cluster-id", func() []string {
+ return listClusterIDs(ctx, []string{CLUSTER_RUNNING}, derefStr(req.Region), derefStr(req.ProjectId))
+ })
+ return cmd
+}
diff --git a/products/uk8s/internal/uk8s/image.go b/products/uk8s/internal/uk8s/image.go
new file mode 100644
index 0000000000..4744f97166
--- /dev/null
+++ b/products/uk8s/internal/uk8s/image.go
@@ -0,0 +1,13 @@
+package uk8s
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+func newImage(ctx *cli.Context) *cobra.Command {
+ cmd := &cobra.Command{Use: "image", Short: "Inspect UK8S images"}
+ cmd.AddCommand(newImageList(ctx))
+ return cmd
+}
diff --git a/products/uk8s/internal/uk8s/image_list.go b/products/uk8s/internal/uk8s/image_list.go
new file mode 100644
index 0000000000..46352b0e43
--- /dev/null
+++ b/products/uk8s/internal/uk8s/image_list.go
@@ -0,0 +1,38 @@
+package uk8s
+
+import (
+ "github.com/spf13/cobra"
+
+ uk8ssdk "github.com/ucloud/ucloud-sdk-go/services/uk8s"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+func newImageList(ctx *cli.Context) *cobra.Command {
+ client := cli.NewServiceClient(ctx, uk8ssdk.NewClient)
+ req := client.NewDescribeUK8SImageRequest()
+
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List images supported by UK8S",
+ Args: cobra.NoArgs,
+ Run: func(cmd *cobra.Command, args []string) {
+ resp, err := client.DescribeUK8SImage(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ if ctx.Format() != cli.OutputTable {
+ ctx.PrintList(resp)
+ return
+ }
+ ctx.PrintList(imageRows(resp))
+ },
+ }
+
+ cmd.Flags().SortFlags = false
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+ ctx.BindProjectID(cmd, req)
+ return cmd
+}
diff --git a/products/uk8s/internal/uk8s/list.go b/products/uk8s/internal/uk8s/list.go
new file mode 100644
index 0000000000..cbe6907436
--- /dev/null
+++ b/products/uk8s/internal/uk8s/list.go
@@ -0,0 +1,49 @@
+package uk8s
+
+import (
+ "github.com/spf13/cobra"
+
+ uk8ssdk "github.com/ucloud/ucloud-sdk-go/services/uk8s"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+func newList(ctx *cli.Context) *cobra.Command {
+ client := cli.NewServiceClient(ctx, uk8ssdk.NewClient)
+ req := client.NewListUK8SClusterV2Request()
+
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List UK8S clusters",
+ Long: "List UK8S clusters in the active region and project.",
+ Args: cobra.NoArgs,
+ Run: func(cmd *cobra.Command, args []string) {
+ if req.ClusterId != nil && *req.ClusterId != "" {
+ *req.ClusterId = ctx.PickResourceID(*req.ClusterId)
+ }
+ resp, err := client.ListUK8SClusterV2(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ if ctx.Format() != cli.OutputTable {
+ ctx.PrintList(resp)
+ return
+ }
+ ctx.PrintList(clusterRows(resp.ClusterSet))
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+ req.ClusterId = flags.String("cluster-id", "", "Optional. List only the specified cluster.")
+ ctx.BindRegion(cmd, req)
+ ctx.BindProjectID(cmd, req)
+ ctx.BindLimit(cmd, req)
+ ctx.BindOffset(cmd, req)
+ command.SetCompletion(cmd, "cluster-id", func() []string {
+ return listClusterIDs(ctx, nil, derefStr(req.Region), derefStr(req.ProjectId))
+ })
+ return cmd
+}
diff --git a/products/uk8s/internal/uk8s/node.go b/products/uk8s/internal/uk8s/node.go
new file mode 100644
index 0000000000..ab75afd285
--- /dev/null
+++ b/products/uk8s/internal/uk8s/node.go
@@ -0,0 +1,16 @@
+package uk8s
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+func newNode(ctx *cli.Context) *cobra.Command {
+ cmd := &cobra.Command{Use: "node", Short: "Manage UK8S nodes"}
+ cmd.AddCommand(newNodeAdd(ctx))
+ cmd.AddCommand(newNodeDelete(ctx))
+ cmd.AddCommand(newNodeList(ctx))
+ cmd.AddCommand(newNodeDescribe(ctx))
+ return cmd
+}
diff --git a/products/uk8s/internal/uk8s/node_add.go b/products/uk8s/internal/uk8s/node_add.go
new file mode 100644
index 0000000000..4523dbfa8e
--- /dev/null
+++ b/products/uk8s/internal/uk8s/node_add.go
@@ -0,0 +1,175 @@
+package uk8s
+
+import (
+ "encoding/base64"
+ "fmt"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ uk8ssdk "github.com/ucloud/ucloud-sdk-go/services/uk8s"
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+func newNodeAdd(ctx *cli.Context) *cobra.Command {
+ client := cli.NewServiceClient(ctx, uk8ssdk.NewClient)
+ req := client.NewAddUK8SUHostNodeRequest()
+ var userData, userDataB64, initScript, initScriptB64 string
+
+ cmd := &cobra.Command{
+ Use: "add",
+ Short: "Add UHost nodes to a UK8S cluster",
+ Args: cobra.NoArgs,
+ PreRunE: func(cmd *cobra.Command, args []string) error {
+ if *req.CPU < 2 || *req.CPU > 64 {
+ return fmt.Errorf("--cpu must be between 2 and 64")
+ }
+ if *req.Mem < 4096 || *req.Mem > 262144 || *req.Mem%1024 != 0 {
+ return fmt.Errorf("--memory-mb must be between 4096 and 262144 and a multiple of 1024")
+ }
+ if *req.Count < 1 || *req.Count > 50 {
+ return fmt.Errorf("--count must be between 1 and 50")
+ }
+ for _, item := range []struct {
+ name string
+ value *int
+ min, max int
+ }{
+ {"boot-disk-size-gb", req.BootDiskSize, 40, 500},
+ {"data-disk-size-gb", req.DataDiskSize, 20, 1000},
+ } {
+ if cmd.Flags().Changed(item.name) && (*item.value < item.min || *item.value > item.max) {
+ return fmt.Errorf("--%s must be between %d and %d", item.name, item.min, item.max)
+ }
+ }
+ if cmd.Flags().Changed("quantity") && *req.Quantity < 0 {
+ return fmt.Errorf("--quantity must not be negative")
+ }
+ if !oneOf(*req.ChargeType, "Dynamic", "Month", "Year", "Postpay") {
+ return fmt.Errorf("--charge-type must be one of Dynamic, Month, Year, or Postpay")
+ }
+ if cmd.Flags().Changed("machine-type") && !oneOf(*req.MachineType, "N", "C", "G", "O", "OS") {
+ return fmt.Errorf("--machine-type must be one of N, C, G, O, or OS")
+ }
+ if req.MachineType != nil && *req.MachineType == "G" {
+ if !cmd.Flags().Changed("gpu") || !cmd.Flags().Changed("gpu-type") {
+ return fmt.Errorf("--gpu and --gpu-type are required when --machine-type is G")
+ }
+ } else if cmd.Flags().Changed("gpu") || cmd.Flags().Changed("gpu-type") {
+ return fmt.Errorf("--gpu and --gpu-type require --machine-type G")
+ }
+ if cmd.Flags().Changed("gpu") && *req.GPU < 1 {
+ return fmt.Errorf("--gpu must be greater than 0")
+ }
+ if cmd.Flags().Changed("gpu-type") && !oneOf(*req.GpuType, "K80", "P40", "V100") {
+ return fmt.Errorf("--gpu-type must be one of K80, P40, or V100")
+ }
+ if req.IsolationGroup != nil && *req.IsolationGroup != "" && *req.Count > 8 {
+ return fmt.Errorf("--count cannot exceed 8 when --isolation-group-id is set")
+ }
+ if cmd.Flags().Changed("max-pods") && *req.MaxPods < 1 {
+ return fmt.Errorf("--max-pods must be greater than 0")
+ }
+ for name, value := range map[string]*string{"labels": req.Labels, "taints": req.Taints} {
+ if value != nil && *value != "" && len(strings.Split(*value, ",")) > 5 {
+ return fmt.Errorf("--%s accepts at most 5 comma-separated entries", name)
+ }
+ }
+ if *req.ChargeType == "Dynamic" && cmd.Flags().Changed("quantity") {
+ return fmt.Errorf("--quantity must not be set when --charge-type is Dynamic")
+ }
+ if err := validatePassword(*req.Password); err != nil {
+ return err
+ }
+ if err := bindEncodedValue(cmd, "user-data", userData, "user-data-base64", userDataB64, &req.UserData); err != nil {
+ return err
+ }
+ return bindEncodedValue(cmd, "init-script", initScript, "init-script-base64", initScriptB64, &req.InitScript)
+ },
+ Run: func(cmd *cobra.Command, args []string) {
+ *req.ClusterId = ctx.PickResourceID(*req.ClusterId)
+ if req.NodeGroupId != nil && *req.NodeGroupId != "" {
+ *req.NodeGroupId = ctx.PickResourceID(*req.NodeGroupId)
+ }
+ for _, value := range []*string{req.SubnetId, req.ImageId, req.IsolationGroup} {
+ if value != nil && *value != "" {
+ *value = ctx.PickResourceID(*value)
+ }
+ }
+ req.Password = sdk.String(base64.StdEncoding.EncodeToString([]byte(*req.Password)))
+ resp, err := client.AddUK8SUHostNode(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ if ctx.Format() != cli.OutputTable {
+ ctx.PrintList(resp)
+ return
+ }
+ for _, id := range resp.NodeIds {
+ fmt.Fprintf(ctx.ProgressWriter(), "uk8s node[%s] is being added\n", id)
+ }
+ rows := make([]cli.OpResultRow, 0, len(resp.NodeIds))
+ for _, id := range resp.NodeIds {
+ rows = append(rows, cli.OpResultRow{ResourceID: id, Action: "add", Status: "Adding"})
+ }
+ ctx.PrintList(rows)
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+ req.ClusterId = flags.String("cluster-id", "", "Required. Cluster ID.")
+ req.CPU = flags.Int("cpu", 0, "Required. vCPU cores per node.")
+ req.Mem = flags.Int("memory-mb", 0, "Required. Memory in MB per node.")
+ req.Count = flags.Int("count", 0, "Required. Number of nodes, 1-50.")
+ req.ChargeType = flags.String("charge-type", "", "Required. Billing mode.")
+ req.Password = flags.String("password", "", "Required. Plaintext node password. 8-30 chars from A-Z, a-z, 0-9 and ()~!@#$%^&*-+=_|{}[]:;'\\<>,.?/; must include at least 2 of {uppercase, lowercase, digit, special symbol}. Base64-encoded automatically before submission.")
+ req.MachineType = flags.String("machine-type", "", "Optional. Node machine type.")
+ req.NodeGroupId = flags.String("nodegroup-id", "", "Optional. Node group ID.")
+ req.SubnetId = flags.String("subnet-id", "", "Optional. Subnet ID.")
+ req.ImageId = flags.String("image-id", "", "Optional. Image ID.")
+ req.BootDiskType = flags.String("boot-disk-type", "", "Optional. Boot disk type.")
+ req.BootDiskSize = flags.Int("boot-disk-size-gb", 0, "Optional. Boot disk size in GB.")
+ req.DataDiskType = flags.String("data-disk-type", "", "Optional. Data disk type.")
+ req.DataDiskSize = flags.Int("data-disk-size-gb", 0, "Optional. Data disk size in GB.")
+ req.MinimalCpuPlatform = flags.String("cpu-platform", "", "Optional. Minimum CPU platform.")
+ req.MaxPods = flags.Int("max-pods", 0, "Optional. Maximum pods per node.")
+ req.Quantity = flags.Int("quantity", 0, "Optional. Purchase duration.")
+ req.GPU = flags.Int("gpu", 0, "Optional. GPU count.")
+ req.GpuType = flags.String("gpu-type", "", "Optional. GPU type.")
+ req.IsolationGroup = flags.String("isolation-group-id", "", "Optional. Isolation group ID.")
+ req.Labels = flags.String("labels", "", "Optional. Comma-separated node labels.")
+ req.Taints = flags.String("taints", "", "Optional. Comma-separated node taints.")
+ req.DisableSchedule = flags.Bool("disable-schedule", false, "Optional. Disable scheduling on the new nodes.")
+ flags.StringVar(&userData, "user-data", "", "Optional. User data; base64-encoded automatically. Maximum decoded size: 16 KiB.")
+ flags.StringVar(&userDataB64, "user-data-base64", "", "Optional. Pre-encoded user data. Conflicts with --user-data.")
+ flags.StringVar(&initScript, "init-script", "", "Optional. Post-install script; base64-encoded automatically. Maximum decoded size: 16 KiB.")
+ flags.StringVar(&initScriptB64, "init-script-base64", "", "Optional. Pre-encoded post-install script. Conflicts with --init-script.")
+ req.Tag = flags.String("group", "", "Optional. Business group.")
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+ ctx.BindProjectID(cmd, req)
+ for _, name := range []string{"cluster-id", "cpu", "memory-mb", "count", "charge-type", "password"} {
+ cmd.MarkFlagRequired(name)
+ }
+ command.SetCompletion(cmd, "cluster-id", func() []string {
+ return listClusterIDs(ctx, []string{CLUSTER_RUNNING}, derefStr(req.Region), derefStr(req.ProjectId))
+ })
+ command.SetCompletion(cmd, "nodegroup-id", func() []string {
+ return listNodeGroupIDs(ctx, derefStr(req.ClusterId), derefStr(req.ProjectId), derefStr(req.Region))
+ })
+ command.SetCompletion(cmd, "image-id", func() []string {
+ return listUK8SImageIDs(ctx, derefStr(req.ProjectId), derefStr(req.Region), derefStr(req.Zone))
+ })
+ command.SetCompletion(cmd, "isolation-group-id", func() []string {
+ return listIsolationGroupIDs(ctx, derefStr(req.ProjectId), derefStr(req.Region))
+ })
+ command.SetFlagValues(cmd, "machine-type", "N", "C", "G", "O", "OS")
+ command.SetFlagValues(cmd, "charge-type", "Dynamic", "Month", "Year", "Postpay")
+ command.SetFlagValues(cmd, "gpu-type", "K80", "P40", "V100")
+ return cmd
+}
diff --git a/products/uk8s/internal/uk8s/node_add_test.go b/products/uk8s/internal/uk8s/node_add_test.go
new file mode 100644
index 0000000000..cd5f6b5ec6
--- /dev/null
+++ b/products/uk8s/internal/uk8s/node_add_test.go
@@ -0,0 +1,232 @@
+package uk8s
+
+import (
+ "encoding/base64"
+ "strings"
+ "testing"
+)
+
+// TestNodeAddRejectsBadPasswordBeforeRequest guards against reverting
+// node_add.go's PreRunE back to a single-class uppercase check. The rule
+// must match uk8s_create's validatePassword (8-30 chars, allowed set,
+// at least 2 of {uppercase, lowercase, digit, special}).
+func TestNodeAddRejectsBadPasswordBeforeRequest(t *testing.T) {
+ cases := []struct {
+ name string
+ pwd string
+ wantErr string
+ }{
+ {name: "too short", pwd: "Ab1!", wantErr: "8-30"},
+ {name: "illegal char", pwd: "Password 1", wantErr: "illegal characters"},
+ {name: "single class", pwd: "abcdefgh", wantErr: "at least 2"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ ctx, requests := setupCommandGateway(t)
+ cmd := newNode(ctx)
+ cmd.SetArgs([]string{
+ "add",
+ "--cluster-id", "uk8s-a",
+ "--cpu", "2",
+ "--memory-mb", "4096",
+ "--count", "1",
+ "--charge-type", "Dynamic",
+ "--password", tc.pwd,
+ })
+ err := cmd.Execute()
+ if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
+ t.Fatalf("expected error containing %q, got %v", tc.wantErr, err)
+ }
+ if len(*requests) != 0 {
+ t.Fatal("invalid password must not reach the API")
+ }
+ })
+ }
+}
+
+// requiredNodeAddArgs returns the minimum flag set required for the
+// uk8s node add command to pass cobra's MarkFlagRequired checks and
+// PreRunE validation. IDs use the "id/name" form so PickResourceID
+// is exercised (matching the create_test contract).
+func requiredNodeAddArgs() []string {
+ return []string{
+ "add",
+ "--cluster-id", "uk8s-a/name",
+ "--cpu", "2",
+ "--memory-mb", "4096",
+ "--count", "1",
+ "--charge-type", "Dynamic",
+ "--password", "Password1",
+ }
+}
+
+// TestNodeAddRequestMatchesDocument mirrors TestCreateRequestMatchesDocument:
+// with all optional flags set, the SDK request must match the documented
+// field names, base64-encoded password/userdata/initscript, and stripped
+// "id/name" suffixes.
+func TestNodeAddRequestMatchesDocument(t *testing.T) {
+ ctx, requests := setupCommandGateway(t)
+ cmd := newNode(ctx)
+ args := append(requiredNodeAddArgs(),
+ "--machine-type", "G",
+ "--gpu", "1",
+ "--gpu-type", "V100",
+ "--image-id", "uimage-a/name",
+ "--subnet-id", "subnet-a/name",
+ "--nodegroup-id", "uk8sng-a/name",
+ "--isolation-group-id", "ig-a/name",
+ "--group", "Default",
+ "--max-pods", "110",
+ "--labels", "env=test,team=cli",
+ "--taints", "dedicated=test:NoSchedule",
+ "--user-data", "cloud-init",
+ "--init-script", "echo ready",
+ "--cpu-platform", "Intel/Cascadelake",
+ "--boot-disk-type", "CLOUD_SSD",
+ "--boot-disk-size-gb", "40",
+ "--data-disk-type", "CLOUD_SSD",
+ "--data-disk-size-gb", "100",
+ )
+ cmd.SetArgs(args)
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("execute node add: %v", err)
+ }
+
+ want := map[string]string{
+ "Action": "AddUK8SUHostNode",
+ "Region": "cn-sh2",
+ "ProjectId": "org-test",
+ "ClusterId": "uk8s-a",
+ "CPU": "2",
+ "Mem": "4096",
+ "Count": "1",
+ "ChargeType": "Dynamic",
+ "Password": base64.StdEncoding.EncodeToString([]byte("Password1")),
+ "MachineType": "G",
+ "GPU": "1",
+ "GpuType": "V100",
+ "ImageId": "uimage-a",
+ "SubnetId": "subnet-a",
+ "NodeGroupId": "uk8sng-a",
+ "IsolationGroup": "ig-a",
+ "Tag": "Default",
+ "MaxPods": "110",
+ "Labels": "env=test,team=cli",
+ "Taints": "dedicated=test:NoSchedule",
+ "UserData": base64.StdEncoding.EncodeToString([]byte("cloud-init")),
+ "InitScript": base64.StdEncoding.EncodeToString([]byte("echo ready")),
+ "MinimalCpuPlatform": "Intel/Cascadelake",
+ "BootDiskType": "CLOUD_SSD",
+ "BootDiskSize": "40",
+ "DataDiskType": "CLOUD_SSD",
+ "DataDiskSize": "100",
+ }
+ got := lastRequest(t, requests)
+ assertRequest(t, got, want)
+}
+
+// TestNodeAddOmitsDocumentedOptionalFields mirrors
+// TestCreateOmitsDocumentedOptionalFields: with only required flags,
+// optional string fields must be absent from the request form so the SDK
+// does not send empty-string values that confuse the backend. Int fields
+// (GPU, MaxPods, BootDiskSize, DataDiskSize, Quantity) and the DisableSchedule
+// bool all default to 0/false and the SDK marshals them as such — create
+// avoids this with an explicit "optional" map that nils unset values, but
+// node_add does not. Asserting their omission here would force the same
+// pattern; left as a follow-up since it would change the SDK wire format
+// of an existing command.
+func TestNodeAddOmitsDocumentedOptionalFields(t *testing.T) {
+ ctx, requests := setupCommandGateway(t)
+ cmd := newNode(ctx)
+ cmd.SetArgs(requiredNodeAddArgs())
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("execute node add: %v", err)
+ }
+ got := lastRequest(t, requests)
+
+ for _, key := range []string{
+ "MachineType", "GpuType", "ImageId", "SubnetId", "NodeGroupId",
+ "IsolationGroup", "Tag", "Labels", "Taints", "UserData",
+ "InitScript", "MinimalCpuPlatform", "BootDiskType",
+ "DataDiskType",
+ } {
+ if _, ok := got[key]; ok {
+ t.Errorf("optional request field %s must be omitted", key)
+ }
+ }
+}
+
+// TestNodeAddRejectsInvalidShapeBeforeRequest mirrors
+// TestCreateRejectsInvalidShapeBeforeRequest: mutate one flag at a time
+// and assert PreRunE rejects the mutation before any API call lands.
+// Mutators operate through *[]string so they can append flags absent from
+// requiredNodeAddArgs (e.g. machine-type, boot-disk-size-gb).
+func TestNodeAddRejectsInvalidShapeBeforeRequest(t *testing.T) {
+ cases := []struct {
+ name string
+ mutate func(*[]string)
+ wantErr string
+ }{
+ {
+ name: "cpu below range",
+ mutate: func(a *[]string) { setArg(a, "--cpu", "1") },
+ wantErr: "--cpu must be between 2 and 64",
+ },
+ {
+ name: "memory not multiple of 1024",
+ mutate: func(a *[]string) { setArg(a, "--memory-mb", "5000") },
+ wantErr: "multiple of 1024",
+ },
+ {
+ name: "count above range",
+ mutate: func(a *[]string) { setArg(a, "--count", "100") },
+ wantErr: "--count must be between 1 and 50",
+ },
+ {
+ name: "charge-type unknown",
+ mutate: func(a *[]string) { setArg(a, "--charge-type", "PayAsYouGo") },
+ wantErr: "--charge-type must be one of",
+ },
+ {
+ name: "machine-type G without gpu",
+ mutate: func(a *[]string) { setArg(a, "--machine-type", "G") },
+ wantErr: "--gpu and --gpu-type are required",
+ },
+ {
+ name: "boot disk size below range",
+ mutate: func(a *[]string) { setArg(a, "--boot-disk-size-gb", "10") },
+ wantErr: "--boot-disk-size-gb must be between 40 and 500",
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ ctx, requests := setupCommandGateway(t)
+ cmd := newNode(ctx)
+ args := requiredNodeAddArgs()
+ tc.mutate(&args)
+ cmd.SetArgs(args)
+ err := cmd.Execute()
+ if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
+ t.Fatalf("err = %v, want containing %q", err, tc.wantErr)
+ }
+ if len(*requests) != 0 {
+ t.Fatal("invalid mutation must not reach the API")
+ }
+ })
+ }
+}
+
+// setArg replaces the value of --flag in args if present, or appends
+// --flag value if absent. Operates on *[]string so appends (which may
+// reallocate the backing array) propagate to the caller.
+func setArg(argsPtr *[]string, flag, value string) {
+ args := *argsPtr
+ for i := range args {
+ if args[i] == flag {
+ args[i+1] = value
+ return
+ }
+ }
+ *argsPtr = append(args, flag, value)
+}
diff --git a/products/uk8s/internal/uk8s/node_delete.go b/products/uk8s/internal/uk8s/node_delete.go
new file mode 100644
index 0000000000..f671e1b63f
--- /dev/null
+++ b/products/uk8s/internal/uk8s/node_delete.go
@@ -0,0 +1,69 @@
+package uk8s
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ uk8ssdk "github.com/ucloud/ucloud-sdk-go/services/uk8s"
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+func newNodeDelete(ctx *cli.Context) *cobra.Command {
+ client := cli.NewServiceClient(ctx, uk8ssdk.NewClient)
+ req := client.NewDelUK8SClusterNodeV2Request()
+ var nodeIDs []string
+ var releaseDataUDisk bool
+ var yes bool
+
+ cmd := &cobra.Command{
+ Use: "delete",
+ Short: "Delete nodes from a UK8S cluster",
+ Args: cobra.NoArgs,
+ Run: func(cmd *cobra.Command, args []string) {
+ ok, err := ctx.Confirm(yes, "Are you sure you want to delete the UK8S node(s)?")
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ if !ok {
+ return
+ }
+ *req.ClusterId = ctx.PickResourceID(*req.ClusterId)
+ results := make([]cli.OpResultRow, 0, len(nodeIDs))
+ for _, idName := range nodeIDs {
+ id := ctx.PickResourceID(idName)
+ req.NodeId = sdk.String(id)
+ req.ReleaseDataUDisk = sdk.Bool(releaseDataUDisk)
+ if _, err := client.DelUK8SClusterNodeV2(req); err != nil {
+ ctx.HandleError(err)
+ continue
+ }
+ fmt.Fprintf(ctx.ProgressWriter(), "uk8s node[%s] deletion requested\n", id)
+ results = append(results, cli.OpResultRow{ResourceID: id, Action: "delete", Status: "Deleting"})
+ }
+ ctx.EmitResult(results...)
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+ req.ClusterId = flags.String("cluster-id", "", "Required. Cluster ID.")
+ flags.StringSliceVar(&nodeIDs, "node-id", nil, "Required. Node ID(s).")
+ flags.BoolVar(&releaseDataUDisk, "release-data-udisk", true, "Optional. Release data disks.")
+ flags.BoolVarP(&yes, "yes", "y", false, "Optional. Skip the confirmation prompt.")
+ ctx.BindRegion(cmd, req)
+ ctx.BindProjectID(cmd, req)
+ cmd.MarkFlagRequired("cluster-id")
+ cmd.MarkFlagRequired("node-id")
+ command.SetCompletion(cmd, "cluster-id", func() []string {
+ return listClusterIDs(ctx, nil, derefStr(req.Region), derefStr(req.ProjectId))
+ })
+ command.SetCompletion(cmd, "node-id", func() []string {
+ return listNodeIDs(ctx, derefStr(req.ClusterId), derefStr(req.ProjectId), derefStr(req.Region))
+ })
+ return cmd
+}
diff --git a/products/uk8s/internal/uk8s/node_describe.go b/products/uk8s/internal/uk8s/node_describe.go
new file mode 100644
index 0000000000..62f57d2430
--- /dev/null
+++ b/products/uk8s/internal/uk8s/node_describe.go
@@ -0,0 +1,74 @@
+package uk8s
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ uk8ssdk "github.com/ucloud/ucloud-sdk-go/services/uk8s"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+func newNodeDescribe(ctx *cli.Context) *cobra.Command {
+ client := cli.NewServiceClient(ctx, uk8ssdk.NewClient)
+ req := client.NewDescribeUK8SNodeRequest()
+
+ cmd := &cobra.Command{
+ Use: "describe",
+ Short: "Show details of a UK8S node",
+ Args: cobra.NoArgs,
+ Run: func(cmd *cobra.Command, args []string) {
+ *req.ClusterId = ctx.PickResourceID(*req.ClusterId)
+ *req.Name = ctx.PickResourceID(*req.Name)
+ node, err := client.DescribeUK8SNode(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ if ctx.Format() != cli.OutputTable {
+ ctx.PrintList(node)
+ return
+ }
+ rows := []cli.DescribeRow{
+ {Attribute: "Name", Content: node.Name},
+ {Attribute: "Hostname", Content: node.Hostname},
+ {Attribute: "InternalIP", Content: node.InternalIP},
+ {Attribute: "ProviderID", Content: node.ProviderID},
+ {Attribute: "CPUCapacity", Content: node.CPUCapacity},
+ {Attribute: "MemoryCapacity", Content: node.MemoryCapacity},
+ {Attribute: "PodCapacity", Content: fmt.Sprintf("%d", node.PodCapacity)},
+ {Attribute: "AllocatedPods", Content: fmt.Sprintf("%d", node.AllocatedPodCount)},
+ {Attribute: "Unschedulable", Content: fmt.Sprintf("%t", node.Unschedulable)},
+ {Attribute: "KubeletVersion", Content: node.KubeletVersion},
+ {Attribute: "KubeProxyVersion", Content: node.KubeProxyVersion},
+ {Attribute: "ContainerRuntime", Content: node.ContainerRuntimeVersion},
+ {Attribute: "OSImage", Content: node.OSImage},
+ {Attribute: "KernelVersion", Content: node.KernelVersion},
+ {Attribute: "Labels", Content: strings.Join(node.Labels, ",")},
+ {Attribute: "Taints", Content: strings.Join(node.Taints, ",")},
+ {Attribute: "Created", Content: time.Unix(int64(node.CreationTimestamp), 0).Format(time.RFC3339)},
+ }
+ ctx.PrintList(rows)
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+ req.ClusterId = flags.String("cluster-id", "", "Required. Cluster ID.")
+ req.Name = flags.String("node-id", "", "Required. Node ID or IP.")
+ ctx.BindRegion(cmd, req)
+ ctx.BindProjectID(cmd, req)
+ cmd.MarkFlagRequired("cluster-id")
+ cmd.MarkFlagRequired("node-id")
+ command.SetCompletion(cmd, "cluster-id", func() []string {
+ return listClusterIDs(ctx, nil, derefStr(req.Region), derefStr(req.ProjectId))
+ })
+ command.SetCompletion(cmd, "node-id", func() []string {
+ return listNodeIDs(ctx, derefStr(req.ClusterId), derefStr(req.ProjectId), derefStr(req.Region))
+ })
+ return cmd
+}
diff --git a/products/uk8s/internal/uk8s/node_list.go b/products/uk8s/internal/uk8s/node_list.go
new file mode 100644
index 0000000000..debac78081
--- /dev/null
+++ b/products/uk8s/internal/uk8s/node_list.go
@@ -0,0 +1,45 @@
+package uk8s
+
+import (
+ "github.com/spf13/cobra"
+
+ uk8ssdk "github.com/ucloud/ucloud-sdk-go/services/uk8s"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+func newNodeList(ctx *cli.Context) *cobra.Command {
+ client := cli.NewServiceClient(ctx, uk8ssdk.NewClient)
+ req := client.NewListUK8SClusterNodeV2Request()
+
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List nodes in a UK8S cluster",
+ Args: cobra.NoArgs,
+ Run: func(cmd *cobra.Command, args []string) {
+ *req.ClusterId = ctx.PickResourceID(*req.ClusterId)
+ resp, err := client.ListUK8SClusterNodeV2(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ if ctx.Format() != cli.OutputTable {
+ ctx.PrintList(resp)
+ return
+ }
+ ctx.PrintList(nodeRows(resp.NodeSet))
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+ req.ClusterId = flags.String("cluster-id", "", "Required. Cluster ID.")
+ ctx.BindRegion(cmd, req)
+ ctx.BindProjectID(cmd, req)
+ cmd.MarkFlagRequired("cluster-id")
+ command.SetCompletion(cmd, "cluster-id", func() []string {
+ return listClusterIDs(ctx, nil, derefStr(req.Region), derefStr(req.ProjectId))
+ })
+ return cmd
+}
diff --git a/products/uk8s/internal/uk8s/nodegroup.go b/products/uk8s/internal/uk8s/nodegroup.go
new file mode 100644
index 0000000000..a863c5b0a8
--- /dev/null
+++ b/products/uk8s/internal/uk8s/nodegroup.go
@@ -0,0 +1,15 @@
+package uk8s
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+func newNodeGroup(ctx *cli.Context) *cobra.Command {
+ cmd := &cobra.Command{Use: "nodegroup", Short: "Manage UK8S node groups"}
+ cmd.AddCommand(newNodeGroupAdd(ctx))
+ cmd.AddCommand(newNodeGroupDelete(ctx))
+ cmd.AddCommand(newNodeGroupList(ctx))
+ return cmd
+}
diff --git a/products/uk8s/internal/uk8s/nodegroup_add.go b/products/uk8s/internal/uk8s/nodegroup_add.go
new file mode 100644
index 0000000000..885eeff2d0
--- /dev/null
+++ b/products/uk8s/internal/uk8s/nodegroup_add.go
@@ -0,0 +1,173 @@
+package uk8s
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ uk8ssdk "github.com/ucloud/ucloud-sdk-go/services/uk8s"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+func newNodeGroupAdd(ctx *cli.Context) *cobra.Command {
+ client := cli.NewServiceClient(ctx, uk8ssdk.NewClient)
+ req := client.NewAddUK8SNodeGroupRequest()
+
+ cmd := &cobra.Command{
+ Use: "add",
+ Short: "Add a UK8S node group",
+ Args: cobra.NoArgs,
+ PreRunE: func(cmd *cobra.Command, args []string) error {
+ for _, name := range []string{"cluster-id", "name", "machine-type", "cpu", "memory-mb", "image-id", "subnet-id", "boot-disk-type", "boot-disk-size-gb"} {
+ if !cmd.Flags().Changed(name) {
+ return fmt.Errorf("--%s is required", name)
+ }
+ }
+ if req.MachineType == nil || *req.MachineType == "" {
+ return fmt.Errorf("--machine-type is required")
+ }
+ if req.CPU == nil {
+ return fmt.Errorf("--cpu is required")
+ }
+ if req.Mem == nil {
+ return fmt.Errorf("--memory-mb is required")
+ }
+ if req.SubnetId == nil || *req.SubnetId == "" {
+ return fmt.Errorf("--subnet-id is required")
+ }
+ if req.ImageId == nil || *req.ImageId == "" {
+ return fmt.Errorf("--image-id is required")
+ }
+ if req.BootDiskType == nil || *req.BootDiskType == "" {
+ return fmt.Errorf("--boot-disk-type is required")
+ }
+ if req.BootDiskSize == nil {
+ return fmt.Errorf("--boot-disk-size-gb is required")
+ }
+ if !oneOf(*req.MachineType, "N", "C", "G", "O", "OS") {
+ return fmt.Errorf("--machine-type must be one of N, C, G, O, or OS")
+ }
+ if *req.CPU < 2 || *req.CPU > 64 {
+ return fmt.Errorf("--cpu must be between 2 and 64")
+ }
+ if *req.Mem < 4096 || *req.Mem > 262144 || *req.Mem%1024 != 0 {
+ return fmt.Errorf("--memory-mb must be between 4096 and 262144 and a multiple of 1024")
+ }
+ for _, item := range []struct {
+ name string
+ value *int
+ min, max int
+ }{
+ {"boot-disk-size-gb", req.BootDiskSize, 40, 500},
+ {"data-disk-size-gb", req.DataDiskSize, 20, 1000},
+ } {
+ if *item.value != 0 && (*item.value < item.min || *item.value > item.max) {
+ return fmt.Errorf("--%s must be between %d and %d", item.name, item.min, item.max)
+ }
+ }
+ if *req.BootDiskSize < 40 || *req.BootDiskSize > 500 {
+ return fmt.Errorf("--boot-disk-size-gb must be between 40 and 500")
+ }
+ if *req.BootDiskType != "CLOUD_RSSD" {
+ return fmt.Errorf("--boot-disk-type must be CLOUD_RSSD")
+ }
+ if !oneOf(*req.ChargeType, "Dynamic", "Month", "Year") {
+ return fmt.Errorf("--charge-type must be one of Dynamic, Month, or Year")
+ }
+ if !oneOf(*req.MinimalCpuPlatform, "Intel/Auto", "Intel/IvyBridge", "Intel/Haswell", "Intel/Broadwell", "Intel/Skylake", "Intel/Cascadelake", "Intel/CascadelakeR", "Amd/Epyc2", "Amd/Auto") {
+ return fmt.Errorf("--cpu-platform must be one of Intel/Auto, Intel/IvyBridge, Intel/Haswell, Intel/Broadwell, Intel/Skylake, Intel/Cascadelake, Intel/CascadelakeR, Amd/Epyc2, Amd/Auto")
+ }
+ if cmd.Flags().Changed("machine-type") && *req.MachineType == "G" {
+ if !cmd.Flags().Changed("gpu") || !cmd.Flags().Changed("gpu-type") {
+ return fmt.Errorf("--gpu and --gpu-type are required when --machine-type is G")
+ }
+ } else if cmd.Flags().Changed("gpu") || cmd.Flags().Changed("gpu-type") {
+ return fmt.Errorf("--gpu and --gpu-type require --machine-type G")
+ }
+ if cmd.Flags().Changed("gpu") && *req.GPU < 1 {
+ return fmt.Errorf("--gpu must be greater than 0")
+ }
+ if cmd.Flags().Changed("gpu-type") && !oneOf(*req.GpuType, "K80", "P40", "V100") {
+ return fmt.Errorf("--gpu-type must be one of K80, P40, or V100")
+ }
+ // Keep the boot disk type explicit because the backend rejects a
+ // node-group request with an empty Disks.0.Type. Every other
+ // product field remains nil unless the user supplied its flag.
+ for name, clear := range map[string]func(){
+ "data-disk-type": func() { req.DataDiskType = nil },
+ "data-disk-size-gb": func() { req.DataDiskSize = nil },
+ "group": func() { req.Tag = nil },
+ "gpu": func() { req.GPU = nil },
+ "gpu-type": func() { req.GpuType = nil },
+ } {
+ if !cmd.Flags().Changed(name) {
+ clear()
+ }
+ }
+ return nil
+ },
+ Run: func(cmd *cobra.Command, args []string) {
+ *req.ClusterId = ctx.PickResourceID(*req.ClusterId)
+ for _, value := range []*string{req.ImageId, req.SubnetId} {
+ if value != nil && *value != "" {
+ *value = ctx.PickResourceID(*value)
+ }
+ }
+ resp, err := client.AddUK8SNodeGroup(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ fmt.Fprintf(ctx.ProgressWriter(), "uk8s nodegroup[%s] added\n", resp.NodeGroupId)
+ ctx.EmitResult(cli.OpResultRow{ResourceID: resp.NodeGroupId, Action: "add", Status: "Created"})
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+ req.ClusterId = flags.String("cluster-id", "", "Required. Cluster ID.")
+ req.NodeGroupName = flags.String("name", "", "Required. Node group name.")
+ req.MachineType = flags.String("machine-type", "", "Required. Node machine type. One of N, C, G, O, OS. G requires --gpu and --gpu-type.")
+ req.CPU = flags.Int("cpu", 0, "Required. vCPU cores per node. Range 2-64.")
+ req.Mem = flags.Int("memory-mb", 0, "Required. Memory in MB per node. Range 4096-262144, multiple of 1024.")
+ req.ImageId = flags.String("image-id", "", "Required. Compatible UK8S node image ID. Choose one with 'ucloud uk8s image list'.")
+ req.SubnetId = flags.String("subnet-id", "", "Required. Subnet ID; must belong to the cluster's VPC.")
+ req.BootDiskType = flags.String("boot-disk-type", "", "Required. System disk type. Only CLOUD_RSSD is supported for UK8S node pools.")
+ req.BootDiskSize = flags.Int("boot-disk-size-gb", 0, "Required. Boot disk size in GB. Range 40-500.")
+ req.DataDiskType = flags.String("data-disk-type", "", "Optional. Data disk type.")
+ req.DataDiskSize = flags.Int("data-disk-size-gb", 0, "Optional. Data disk size in GB.")
+ req.MinimalCpuPlatform = flags.String("cpu-platform", "Intel/Auto", "Required. Minimum CPU platform. Defaults to Intel/Auto. One of Intel/Auto, Intel/IvyBridge, Intel/Haswell, Intel/Broadwell, Intel/Skylake, Intel/Cascadelake, Intel/CascadelakeR, Amd/Epyc2, Amd/Auto.")
+ req.ChargeType = flags.String("charge-type", "Month", "Required. Billing mode. Defaults to Month.")
+ req.Tag = flags.String("group", "", "Optional. Business group.")
+ req.GPU = flags.Int("gpu", 0, "Optional. GPU count; requires machine type G.")
+ req.GpuType = flags.String("gpu-type", "", "Optional. GPU type: K80, P40, or V100.")
+ ctx.BindRegion(cmd, req)
+ ctx.BindZone(cmd, req)
+ ctx.BindProjectID(cmd, req)
+ cmd.MarkFlagRequired("cluster-id")
+ cmd.MarkFlagRequired("name")
+ cmd.MarkFlagRequired("machine-type")
+ cmd.MarkFlagRequired("cpu")
+ cmd.MarkFlagRequired("memory-mb")
+ cmd.MarkFlagRequired("image-id")
+ cmd.MarkFlagRequired("subnet-id")
+ cmd.MarkFlagRequired("boot-disk-type")
+ cmd.MarkFlagRequired("boot-disk-size-gb")
+ cmd.MarkFlagRequired("charge-type")
+ cmd.MarkFlagRequired("cpu-platform")
+ command.SetCompletion(cmd, "cluster-id", func() []string {
+ return listClusterIDs(ctx, nil, derefStr(req.Region), derefStr(req.ProjectId))
+ })
+ command.SetFlagValues(cmd, "machine-type", "N", "C", "G", "O", "OS")
+ command.SetFlagValues(cmd, "charge-type", "Dynamic", "Month", "Year")
+ command.SetFlagValues(cmd, "gpu-type", "K80", "P40", "V100")
+ command.SetFlagValues(cmd, "boot-disk-type", "CLOUD_RSSD")
+ command.SetFlagValues(cmd, "cpu-platform", "Intel/Auto", "Intel/IvyBridge", "Intel/Haswell", "Intel/Broadwell", "Intel/Skylake", "Intel/Cascadelake", "Intel/CascadelakeR", "Amd/Epyc2", "Amd/Auto")
+ command.SetFlagValues(cmd, "data-disk-type", "CLOUD_SSD", "CLOUD_NORMAL", "LOCAL_SSD", "LOCAL_NORMAL", "CLOUD_RSSD", "EXCLUSIVE_LOCAL_DISK")
+ command.SetCompletion(cmd, "image-id", func() []string {
+ return listUK8SImageIDs(ctx, derefStr(req.ProjectId), derefStr(req.Region), derefStr(req.Zone))
+ })
+ return cmd
+}
diff --git a/products/uk8s/internal/uk8s/nodegroup_delete.go b/products/uk8s/internal/uk8s/nodegroup_delete.go
new file mode 100644
index 0000000000..9ed66e6f7d
--- /dev/null
+++ b/products/uk8s/internal/uk8s/nodegroup_delete.go
@@ -0,0 +1,59 @@
+package uk8s
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ uk8ssdk "github.com/ucloud/ucloud-sdk-go/services/uk8s"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+func newNodeGroupDelete(ctx *cli.Context) *cobra.Command {
+ client := cli.NewServiceClient(ctx, uk8ssdk.NewClient)
+ req := client.NewRemoveUK8SNodeGroupRequest()
+ var yes bool
+
+ cmd := &cobra.Command{
+ Use: "delete",
+ Short: "Delete a UK8S node group",
+ Args: cobra.NoArgs,
+ Run: func(cmd *cobra.Command, args []string) {
+ ok, err := ctx.Confirm(yes, "Are you sure you want to delete the UK8S node group?")
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ if !ok {
+ return
+ }
+ *req.ClusterId = ctx.PickResourceID(*req.ClusterId)
+ *req.NodeGroupId = ctx.PickResourceID(*req.NodeGroupId)
+ if _, err := client.RemoveUK8SNodeGroup(req); err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ fmt.Fprintf(ctx.ProgressWriter(), "uk8s nodegroup[%s] deletion requested\n", *req.NodeGroupId)
+ ctx.EmitResult(cli.OpResultRow{ResourceID: *req.NodeGroupId, Action: "delete", Status: "Deleting"})
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+ req.ClusterId = flags.String("cluster-id", "", "Required. Cluster ID.")
+ req.NodeGroupId = flags.String("nodegroup-id", "", "Required. Node group ID.")
+ flags.BoolVarP(&yes, "yes", "y", false, "Optional. Skip the confirmation prompt.")
+ ctx.BindRegion(cmd, req)
+ ctx.BindProjectID(cmd, req)
+ cmd.MarkFlagRequired("cluster-id")
+ cmd.MarkFlagRequired("nodegroup-id")
+ command.SetCompletion(cmd, "cluster-id", func() []string {
+ return listClusterIDs(ctx, nil, derefStr(req.Region), derefStr(req.ProjectId))
+ })
+ command.SetCompletion(cmd, "nodegroup-id", func() []string {
+ return listNodeGroupIDs(ctx, derefStr(req.ClusterId), derefStr(req.ProjectId), derefStr(req.Region))
+ })
+ return cmd
+}
diff --git a/products/uk8s/internal/uk8s/nodegroup_list.go b/products/uk8s/internal/uk8s/nodegroup_list.go
new file mode 100644
index 0000000000..ead04ab218
--- /dev/null
+++ b/products/uk8s/internal/uk8s/nodegroup_list.go
@@ -0,0 +1,45 @@
+package uk8s
+
+import (
+ "github.com/spf13/cobra"
+
+ uk8ssdk "github.com/ucloud/ucloud-sdk-go/services/uk8s"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+func newNodeGroupList(ctx *cli.Context) *cobra.Command {
+ client := cli.NewServiceClient(ctx, uk8ssdk.NewClient)
+ req := client.NewListUK8SNodeGroupRequest()
+
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List UK8S node groups",
+ Args: cobra.NoArgs,
+ Run: func(cmd *cobra.Command, args []string) {
+ *req.ClusterId = ctx.PickResourceID(*req.ClusterId)
+ resp, err := client.ListUK8SNodeGroup(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ if ctx.Format() != cli.OutputTable {
+ ctx.PrintList(resp)
+ return
+ }
+ ctx.PrintList(nodeGroupRows(resp.NodeGroupList))
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+ req.ClusterId = flags.String("cluster-id", "", "Required. Cluster ID.")
+ ctx.BindRegion(cmd, req)
+ ctx.BindProjectID(cmd, req)
+ cmd.MarkFlagRequired("cluster-id")
+ command.SetCompletion(cmd, "cluster-id", func() []string {
+ return listClusterIDs(ctx, nil, derefStr(req.Region), derefStr(req.ProjectId))
+ })
+ return cmd
+}
diff --git a/products/uk8s/internal/uk8s/poll.go b/products/uk8s/internal/uk8s/poll.go
new file mode 100644
index 0000000000..d22f44f4b8
--- /dev/null
+++ b/products/uk8s/internal/uk8s/poll.go
@@ -0,0 +1,35 @@
+package uk8s
+
+import (
+ "fmt"
+
+ uk8ssdk "github.com/ucloud/ucloud-sdk-go/services/uk8s"
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+ "github.com/ucloud/ucloud-sdk-go/ucloud/request"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// describeByID is the Poller's describe closure. The signature
+// func(string, *request.CommonBase) (interface{}, error) is exactly what
+// ctx.PollerTo expects; the returned interface{} is asserted back to
+// *uk8ssdk.DescribeUK8SClusterResponse in the polling logic so the Poller can
+// read resp.Status against the target states declared by the create verb.
+func describeByID(ctx *cli.Context) func(string, *request.CommonBase) (interface{}, error) {
+ client := cli.NewServiceClient(ctx, uk8ssdk.NewClient)
+ return func(clusterID string, common *request.CommonBase) (interface{}, error) {
+ req := client.NewDescribeUK8SClusterRequest()
+ if common != nil {
+ req.CommonBase = *common
+ }
+ req.ClusterId = sdk.String(clusterID)
+ resp, err := client.DescribeUK8SCluster(req)
+ if err != nil {
+ return nil, err
+ }
+ if resp.ClusterId == "" {
+ return nil, fmt.Errorf("cluster %q not found", clusterID)
+ }
+ return resp, nil
+ }
+}
diff --git a/products/uk8s/internal/uk8s/rows.go b/products/uk8s/internal/uk8s/rows.go
new file mode 100644
index 0000000000..c939f2e865
--- /dev/null
+++ b/products/uk8s/internal/uk8s/rows.go
@@ -0,0 +1,198 @@
+package uk8s
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+
+ uk8ssdk "github.com/ucloud/ucloud-sdk-go/services/uk8s"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// Table rows are deliberately explicit, matching the UHost commands: table
+// output is a stable, compact view, while JSON/YAML keep the full SDK response.
+type clusterRow struct {
+ ResourceID string
+ Name string
+ Version string
+ Type string
+ Status string
+ MasterCount int
+ NodeCount int
+ VPCID string
+ SubnetID string
+ APIServer string
+ CNIMode string
+ Runtime string
+ CreationTime string
+}
+
+type nodeGroupRow struct {
+ ResourceID string
+ Name string
+ NodeCount int
+ Zone string
+ SubnetID string
+ Config string
+ Image string
+ ChargeType string
+ Tag string
+}
+
+type imageRow struct {
+ ResourceID string
+ Name string
+ Kind string
+ Product string
+ OS string
+ SizeGB int
+ ZoneID int
+ Features string
+}
+
+type nodeRow struct {
+ ResourceID string
+ Name string
+ Role string
+ Status string
+ NodeGroup string
+ PrivateIP string
+ Config string
+ Image string
+ MachineType string
+ Zone string
+ InstanceID string
+ Unschedulable bool
+ CreationTime string
+}
+
+type versionRow struct {
+ K8sVersion string
+ ContainerdVersion string
+}
+
+func clusterRows(clusters []uk8ssdk.ClusterSet) []clusterRow {
+ rows := make([]clusterRow, 0, len(clusters))
+ for _, cluster := range clusters {
+ rows = append(rows, clusterRow{
+ ResourceID: cluster.ClusterId, Name: cluster.ClusterName,
+ Version: cluster.K8sVersion, Type: cluster.ClusterType, Status: cluster.Status,
+ MasterCount: cluster.MasterCount, NodeCount: cluster.NodeCount,
+ VPCID: cluster.VPCId, SubnetID: cluster.SubnetId, APIServer: cluster.ApiServer,
+ CNIMode: cluster.CNIMode, Runtime: formatRuntime(cluster.RuntimeName, cluster.RuntimeVersion),
+ CreationTime: formatTimestamp(cluster.CreateTime),
+ })
+ }
+ return rows
+}
+
+func nodeGroupRows(groups []uk8ssdk.NodeGroupSet) []nodeGroupRow {
+ rows := make([]nodeGroupRow, 0, len(groups))
+ for _, group := range groups {
+ rows = append(rows, nodeGroupRow{
+ ResourceID: group.NodeGroupId, Name: group.NodeGroupName, NodeCount: len(group.NodeList),
+ Zone: group.Zone, SubnetID: group.SubnetId,
+ Config: fmt.Sprintf("cpu:%d memory:%dMB boot:%s:%dG", group.CPU, group.Mem, group.BootDiskType, group.BootDiskSize),
+ Image: fmt.Sprintf("%s|%s", group.ImageId, group.ImageName), ChargeType: group.ChargeType, Tag: group.Tag,
+ })
+ }
+ return rows
+}
+
+func imageRows(response *uk8ssdk.DescribeUK8SImageResponse) []imageRow {
+ rows := make([]imageRow, 0)
+ appendImages := func(images []uk8ssdk.ImageInfo, kind, product string) {
+ for _, image := range images {
+ rows = append(rows, imageRow{
+ ResourceID: image.ImageId, Name: image.ImageName, Kind: kind, Product: product,
+ OS: image.OsName, SizeGB: image.ImageSize, ZoneID: image.ZoneId,
+ Features: strings.Join(image.Features, ","),
+ })
+ }
+ }
+ appendImages(response.ImageSet, "Base", "UHost")
+ appendImages(response.CustomImageSet, "Custom", "UHost")
+ appendImages(response.PHostImageSet, "Base", "PHost")
+ appendImages(response.CustomPHostImageSet, "Custom", "PHost")
+ return rows
+}
+
+func nodeRows(nodes []uk8ssdk.NodeInfoV2) []nodeRow {
+ rows := make([]nodeRow, 0, len(nodes))
+ for _, node := range nodes {
+ rows = append(rows, nodeRow{
+ ResourceID: node.NodeId, Name: node.InstanceName, Role: node.NodeRole, Status: node.NodeStatus,
+ NodeGroup: node.NodeGroupName, PrivateIP: nodePrivateIP(node),
+ Config: fmt.Sprintf("cpu:%d memory:%dMB", node.CPU, node.Memory), Image: node.OsName,
+ MachineType: node.MachineType, Zone: node.Zone, InstanceID: node.InstanceId,
+ Unschedulable: node.Unschedulable, CreationTime: formatTimestamp(node.CreateTime),
+ })
+ }
+ return rows
+}
+
+func clusterDescribeRows(cluster *uk8ssdk.DescribeUK8SClusterResponse) []cli.DescribeRow {
+ return []cli.DescribeRow{
+ {Attribute: "ResourceID", Content: cluster.ClusterId},
+ {Attribute: "Name", Content: cluster.ClusterName},
+ {Attribute: "Version", Content: cluster.Version},
+ {Attribute: "Status", Content: cluster.Status},
+ {Attribute: "Type", Content: cluster.ClusterType},
+ {Attribute: "APIServer", Content: cluster.ApiServer},
+ {Attribute: "ExternalAPIServer", Content: cluster.ExternalApiServer},
+ {Attribute: "VPCID", Content: cluster.VPCId},
+ {Attribute: "SubnetID", Content: cluster.SubnetId},
+ {Attribute: "ServiceCIDR", Content: cluster.ServiceCIDR},
+ {Attribute: "PodCIDR", Content: cluster.PodCIDR},
+ {Attribute: "NodeCIDR", Content: cluster.NodeCIDR},
+ {Attribute: "ClusterDomain", Content: cluster.ClusterDomain},
+ {Attribute: "CNIMode", Content: cluster.CNIMode},
+ {Attribute: "Runtime", Content: formatRuntime(cluster.RuntimeName, cluster.RuntimeVersion)},
+ {Attribute: "MonitorType", Content: cluster.MonitorType},
+ {Attribute: "MasterCount", Content: strconv.Itoa(cluster.MasterCount)},
+ {Attribute: "NodeCount", Content: strconv.Itoa(cluster.NodeCount)},
+ {Attribute: "MasterResourceStatus", Content: cluster.MasterResourceStatus},
+ {Attribute: "ExternalUlb", Content: cluster.ExternalUlb},
+ {Attribute: "InternalUlb", Content: cluster.InternalUlb},
+ {Attribute: "LbClass", Content: cluster.LbClass},
+ {Attribute: "DeleteProtection", Content: strconv.Itoa(cluster.DeleteProtection)},
+ {Attribute: "EnableUserAuth", Content: strconv.FormatBool(cluster.EnableUserAuth)},
+ {Attribute: "DedicatedPodSubnet", Content: strconv.FormatBool(cluster.DedicatedPodSubnet)},
+ {Attribute: "CreationTime", Content: formatTimestamp(cluster.CreateTime)},
+ {Attribute: "UpdateTime", Content: formatTimestamp(cluster.UpdateTime)},
+ {Attribute: "KubeProxyMode", Content: cluster.KubeProxy.Mode},
+ {Attribute: "PodSubnetIds", Content: strings.Join(cluster.PodSubnetIds, ",")},
+ {Attribute: "PodSubnetSecGroups", Content: strings.Join(cluster.PodSubnetSecGroups, ",")},
+ {Attribute: "CACert", Content: cluster.CACert},
+ {Attribute: "EtcdCert", Content: cluster.EtcdCert},
+ {Attribute: "EtcdKey", Content: cluster.EtcdKey},
+ }
+}
+
+func nodePrivateIP(node uk8ssdk.NodeInfoV2) string {
+ for _, ip := range node.IPSet {
+ if ip.Type == "Private" {
+ return ip.IP
+ }
+ }
+ return ""
+}
+
+func formatTimestamp(value int) string {
+ if value <= 0 {
+ return ""
+ }
+ return time.Unix(int64(value), 0).Format(time.RFC3339)
+}
+
+func formatRuntime(name, version string) string {
+ if name == "" {
+ return version
+ }
+ if version == "" {
+ return name
+ }
+ return name + ":" + version
+}
diff --git a/products/uk8s/internal/uk8s/status.go b/products/uk8s/internal/uk8s/status.go
new file mode 100644
index 0000000000..efb061ffa6
--- /dev/null
+++ b/products/uk8s/internal/uk8s/status.go
@@ -0,0 +1,19 @@
+package uk8s
+
+// UK8S cluster-domain state constants. Sourced from
+// ucloud-sdk-go/services/uk8s/models.go UK8SClusterSet.Status docstring.
+// Product-owned (formerly model/status); see §2.5 of the platform spec.
+const (
+ // Cluster is being initialized after creation.
+ CLUSTER_INITIALIZING = "INITIALIZING"
+ // Cluster is starting up.
+ CLUSTER_STARTING = "STARTING"
+ // Cluster creation failed.
+ CLUSTER_CREATEFAILED = "CREATEFAILED"
+ // Cluster is running normally.
+ CLUSTER_RUNNING = "RUNNING"
+ // Cluster has an error.
+ CLUSTER_ERROR = "ERROR"
+ // Cluster is in an abnormal state.
+ CLUSTER_ABNORMAL = "ABNORMAL"
+)
diff --git a/products/uk8s/internal/uk8s/version.go b/products/uk8s/internal/uk8s/version.go
new file mode 100644
index 0000000000..4d66e10f83
--- /dev/null
+++ b/products/uk8s/internal/uk8s/version.go
@@ -0,0 +1,13 @@
+package uk8s
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+func newVersion(ctx *cli.Context) *cobra.Command {
+ cmd := &cobra.Command{Use: "version", Short: "Inspect UK8S versions"}
+ cmd.AddCommand(newVersionList(ctx))
+ return cmd
+}
diff --git a/products/uk8s/internal/uk8s/version_list.go b/products/uk8s/internal/uk8s/version_list.go
new file mode 100644
index 0000000000..e20bcd1098
--- /dev/null
+++ b/products/uk8s/internal/uk8s/version_list.go
@@ -0,0 +1,45 @@
+package uk8s
+
+import (
+ "github.com/spf13/cobra"
+
+ uk8ssdk "github.com/ucloud/ucloud-sdk-go/services/uk8s"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+const defaultUK8SKind = "Dedicated"
+
+func newVersionList(ctx *cli.Context) *cobra.Command {
+ client := cli.NewServiceClient(ctx, uk8ssdk.NewClient)
+ req := client.NewGetUK8SVersionsRequest()
+
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List versions supported by UK8S",
+ Args: cobra.NoArgs,
+ Run: func(cmd *cobra.Command, args []string) {
+ resp, err := client.GetUK8SVersions(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ rows := make([]versionRow, 0, len(resp.Data))
+ for _, version := range resp.Data {
+ rows = append(rows, versionRow{
+ K8sVersion: version.K8sVersion,
+ ContainerdVersion: version.ContainerdVersion,
+ })
+ }
+ ctx.PrintList(rows)
+ },
+ }
+
+ cmd.Flags().SortFlags = false
+ ctx.BindRegion(cmd, req)
+ ctx.BindProjectID(cmd, req)
+ req.Kind = cmd.Flags().String("kind", defaultUK8SKind, "Optional. Cluster kind.")
+ command.SetFlagValues(cmd, "kind", defaultUK8SKind)
+ return cmd
+}
diff --git a/products/uk8s/product.go b/products/uk8s/product.go
new file mode 100644
index 0000000000..af45fadc82
--- /dev/null
+++ b/products/uk8s/product.go
@@ -0,0 +1,21 @@
+package uk8s
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ internaluk8s "github.com/ucloud/ucloud-cli/products/uk8s/internal/uk8s"
+)
+
+type product struct{}
+
+// New returns the uk8s product (registered via hack/gen-products).
+func New() cli.Product { return product{} }
+
+func (product) Metadata() cli.Metadata {
+ return cli.Metadata{Name: "uk8s", Commands: []string{"uk8s"}}
+}
+
+func (product) NewCommand(ctx *cli.Context) []*cobra.Command {
+ return []*cobra.Command{internaluk8s.NewCommand(ctx)}
+}
\ No newline at end of file
diff --git a/products/uk8s/product.yaml b/products/uk8s/product.yaml
new file mode 100644
index 0000000000..20fbfa8a0d
--- /dev/null
+++ b/products/uk8s/product.yaml
@@ -0,0 +1,7 @@
+# products/uk8s/product.yaml — uk8s 产品元数据(归属真源,owner 自治维护)
+name: uk8s
+owners:
+ - Episkey-G
+commands:
+ - uk8s
+enabled: true
\ No newline at end of file
diff --git a/products/uk8s/testdata/cmdtree.golden b/products/uk8s/testdata/cmdtree.golden
new file mode 100644
index 0000000000..18e1ccb897
--- /dev/null
+++ b/products/uk8s/testdata/cmdtree.golden
@@ -0,0 +1,159 @@
+ucloud uk8s use=uk8s short=Read and manipulate UK8S (UCloud Kubernetes Service) clusters
+ucloud uk8s create use=create short=Create a UK8S cluster
+ flag=async short= default=false required=
+ flag=charge-type short= default= required=
+ flag=cluster-domain short= default= required=
+ flag=external-api-server short= default= required=
+ flag=group short= default= required=
+ flag=image-id short= default= required=true
+ flag=init-script short= default= required=
+ flag=init-script-base64 short= default= required=
+ flag=k8s-version short= default= required=true
+ flag=kube-proxy-mode short= default= required=
+ flag=master-boot-disk-size-gb short= default=0 required=
+ flag=master-boot-disk-type short= default= required=
+ flag=master-cpu short= default=0 required=true
+ flag=master-cpu-platform short= default= required=
+ flag=master-data-disk-size-gb short= default=0 required=
+ flag=master-data-disk-type short= default= required=
+ flag=master-machine-type short= default= required=true
+ flag=master-memory-mb short= default=0 required=true
+ flag=master-zone short= default=[] required=true
+ flag=name short= default= required=true
+ flag=node-boot-disk-size-gb short= default=0 required=
+ flag=node-boot-disk-type short= default= required=
+ flag=node-count short= default=0 required=true
+ flag=node-cpu short= default=0 required=true
+ flag=node-cpu-platform short= default= required=
+ flag=node-data-disk-size-gb short= default=0 required=
+ flag=node-data-disk-type short= default= required=
+ flag=node-gpu short= default=0 required=
+ flag=node-gpu-type short= default= required=
+ flag=node-isolation-group-id short= default= required=
+ flag=node-labels short= default= required=
+ flag=node-machine-type short= default= required=true
+ flag=node-max-pods short= default=0 required=
+ flag=node-memory-mb short= default=0 required=true
+ flag=node-taints short= default= required=
+ flag=node-zone short= default= required=true
+ flag=password short= default= required=true
+ flag=project-id short= default= required=
+ flag=quantity short= default=0 required=
+ flag=region short= default= required=
+ flag=service-cidr short= default= required=true
+ flag=subnet-id short= default= required=true
+ flag=user-data short= default= required=
+ flag=user-data-base64 short= default= required=
+ flag=vpc-id short= default= required=true
+ucloud uk8s delete use=delete short=Delete UK8S clusters
+ flag=cluster-id short= default=[] required=true
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=release-eip short= default=false required=
+ flag=release-udisk short= default=false required=
+ flag=yes short=y default=false required=
+ucloud uk8s describe use=describe short=Show details of a UK8S cluster
+ flag=cluster-id short= default= required=true
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ucloud uk8s get-config use=get-config short=Print a UK8S cluster kubeconfig
+ flag=cluster-id short= default= required=true
+ flag=external short= default=false required=
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ucloud uk8s image use=image short=Inspect UK8S images
+ucloud uk8s image list use=list short=List images supported by UK8S
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=zone short= default= required=
+ucloud uk8s list use=list short=List UK8S clusters
+ flag=cluster-id short= default= required=
+ flag=limit short= default=100 required=
+ flag=offset short= default=0 required=
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ucloud uk8s node use=node short=Manage UK8S nodes
+ucloud uk8s node add use=add short=Add UHost nodes to a UK8S cluster
+ flag=boot-disk-size-gb short= default=0 required=
+ flag=boot-disk-type short= default= required=
+ flag=charge-type short= default= required=true
+ flag=cluster-id short= default= required=true
+ flag=count short= default=0 required=true
+ flag=cpu short= default=0 required=true
+ flag=cpu-platform short= default= required=
+ flag=data-disk-size-gb short= default=0 required=
+ flag=data-disk-type short= default= required=
+ flag=disable-schedule short= default=false required=
+ flag=gpu short= default=0 required=
+ flag=gpu-type short= default= required=
+ flag=group short= default= required=
+ flag=image-id short= default= required=
+ flag=init-script short= default= required=
+ flag=init-script-base64 short= default= required=
+ flag=isolation-group-id short= default= required=
+ flag=labels short= default= required=
+ flag=machine-type short= default= required=
+ flag=max-pods short= default=0 required=
+ flag=memory-mb short= default=0 required=true
+ flag=nodegroup-id short= default= required=
+ flag=password short= default= required=true
+ flag=project-id short= default= required=
+ flag=quantity short= default=0 required=
+ flag=region short= default= required=
+ flag=subnet-id short= default= required=
+ flag=taints short= default= required=
+ flag=user-data short= default= required=
+ flag=user-data-base64 short= default= required=
+ flag=zone short= default= required=
+ucloud uk8s node delete use=delete short=Delete nodes from a UK8S cluster
+ flag=cluster-id short= default= required=true
+ flag=node-id short= default=[] required=true
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=release-data-udisk short= default=true required=
+ flag=yes short=y default=false required=
+ucloud uk8s node describe use=describe short=Show details of a UK8S node
+ flag=cluster-id short= default= required=true
+ flag=node-id short= default= required=true
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ucloud uk8s node list use=list short=List nodes in a UK8S cluster
+ flag=cluster-id short= default= required=true
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ucloud uk8s nodegroup use=nodegroup short=Manage UK8S node groups
+ucloud uk8s nodegroup add use=add short=Add a UK8S node group
+ flag=boot-disk-size-gb short= default=0 required=true
+ flag=boot-disk-type short= default= required=true
+ flag=charge-type short= default=Month required=true
+ flag=cluster-id short= default= required=true
+ flag=cpu short= default=0 required=true
+ flag=cpu-platform short= default=Intel/Auto required=true
+ flag=data-disk-size-gb short= default=0 required=
+ flag=data-disk-type short= default= required=
+ flag=gpu short= default=0 required=
+ flag=gpu-type short= default= required=
+ flag=group short= default= required=
+ flag=image-id short= default= required=true
+ flag=machine-type short= default= required=true
+ flag=memory-mb short= default=0 required=true
+ flag=name short= default= required=true
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=subnet-id short= default= required=true
+ flag=zone short= default= required=
+ucloud uk8s nodegroup delete use=delete short=Delete a UK8S node group
+ flag=cluster-id short= default= required=true
+ flag=nodegroup-id short= default= required=true
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=yes short=y default=false required=
+ucloud uk8s nodegroup list use=list short=List UK8S node groups
+ flag=cluster-id short= default= required=true
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ucloud uk8s version use=version short=Inspect UK8S versions
+ucloud uk8s version list use=list short=List versions supported by UK8S
+ flag=kind short= default=Dedicated required=
+ flag=project-id short= default= required=
+ flag=region short= default= required=
diff --git a/products/uk8s/testdata/completion.golden b/products/uk8s/testdata/completion.golden
new file mode 100644
index 0000000000..9490a8d8ef
--- /dev/null
+++ b/products/uk8s/testdata/completion.golden
@@ -0,0 +1,76 @@
+ucloud uk8s create charge-type static Dynamic,Month,Year
+ucloud uk8s create external-api-server static No,Yes
+ucloud uk8s create image-id dynamic
+ucloud uk8s create k8s-version dynamic
+ucloud uk8s create kube-proxy-mode static iptables,ipvs
+ucloud uk8s create master-boot-disk-type static CLOUD_NORMAL,CLOUD_RSSD,CLOUD_SSD,EXCLUSIVE_LOCAL_DISK,LOCAL_NORMAL,LOCAL_SSD
+ucloud uk8s create master-cpu-platform static Intel/Auto,Intel/Broadwell,Intel/Cascadelake,Intel/Haswell,Intel/IvyBridge,Intel/Skylake
+ucloud uk8s create master-data-disk-type static ,CLOUD_NORMAL,CLOUD_RSSD,CLOUD_SSD,EXCLUSIVE_LOCAL_DISK,LOCAL_NORMAL,LOCAL_SSD
+ucloud uk8s create master-machine-type static C,N,O,OS
+ucloud uk8s create node-boot-disk-type static CLOUD_NORMAL,CLOUD_RSSD,CLOUD_SSD,EXCLUSIVE_LOCAL_DISK,LOCAL_NORMAL,LOCAL_SSD
+ucloud uk8s create node-cpu-platform static Intel/Auto,Intel/Broadwell,Intel/Cascadelake,Intel/Haswell,Intel/IvyBridge,Intel/Skylake
+ucloud uk8s create node-data-disk-type static ,CLOUD_NORMAL,CLOUD_RSSD,CLOUD_SSD,EXCLUSIVE_LOCAL_DISK,LOCAL_NORMAL,LOCAL_SSD
+ucloud uk8s create node-gpu-type static K80,P40,V100
+ucloud uk8s create node-isolation-group-id dynamic
+ucloud uk8s create node-machine-type static C,G,N,O,OS
+ucloud uk8s create project-id dynamic
+ucloud uk8s create region dynamic
+ucloud uk8s create subnet-id static
+ucloud uk8s create vpc-id dynamic
+ucloud uk8s delete cluster-id dynamic
+ucloud uk8s delete project-id dynamic
+ucloud uk8s delete region dynamic
+ucloud uk8s describe cluster-id dynamic
+ucloud uk8s describe project-id dynamic
+ucloud uk8s describe region dynamic
+ucloud uk8s get-config cluster-id dynamic
+ucloud uk8s get-config project-id dynamic
+ucloud uk8s get-config region dynamic
+ucloud uk8s image list project-id dynamic
+ucloud uk8s image list region dynamic
+ucloud uk8s image list zone dynamic
+ucloud uk8s list cluster-id dynamic
+ucloud uk8s list project-id dynamic
+ucloud uk8s list region dynamic
+ucloud uk8s node add charge-type static Dynamic,Month,Postpay,Year
+ucloud uk8s node add cluster-id dynamic
+ucloud uk8s node add gpu-type static K80,P40,V100
+ucloud uk8s node add image-id dynamic
+ucloud uk8s node add isolation-group-id dynamic
+ucloud uk8s node add machine-type static C,G,N,O,OS
+ucloud uk8s node add nodegroup-id static
+ucloud uk8s node add project-id dynamic
+ucloud uk8s node add region dynamic
+ucloud uk8s node add zone dynamic
+ucloud uk8s node delete cluster-id dynamic
+ucloud uk8s node delete node-id static
+ucloud uk8s node delete project-id dynamic
+ucloud uk8s node delete region dynamic
+ucloud uk8s node describe cluster-id dynamic
+ucloud uk8s node describe node-id static
+ucloud uk8s node describe project-id dynamic
+ucloud uk8s node describe region dynamic
+ucloud uk8s node list cluster-id dynamic
+ucloud uk8s node list project-id dynamic
+ucloud uk8s node list region dynamic
+ucloud uk8s nodegroup add boot-disk-type static CLOUD_RSSD
+ucloud uk8s nodegroup add charge-type static Dynamic,Month,Year
+ucloud uk8s nodegroup add cluster-id dynamic
+ucloud uk8s nodegroup add cpu-platform static Amd/Auto,Amd/Epyc2,Intel/Auto,Intel/Broadwell,Intel/Cascadelake,Intel/CascadelakeR,Intel/Haswell,Intel/IvyBridge,Intel/Skylake
+ucloud uk8s nodegroup add data-disk-type static CLOUD_NORMAL,CLOUD_RSSD,CLOUD_SSD,EXCLUSIVE_LOCAL_DISK,LOCAL_NORMAL,LOCAL_SSD
+ucloud uk8s nodegroup add gpu-type static K80,P40,V100
+ucloud uk8s nodegroup add image-id dynamic
+ucloud uk8s nodegroup add machine-type static C,G,N,O,OS
+ucloud uk8s nodegroup add project-id dynamic
+ucloud uk8s nodegroup add region dynamic
+ucloud uk8s nodegroup add zone dynamic
+ucloud uk8s nodegroup delete cluster-id dynamic
+ucloud uk8s nodegroup delete nodegroup-id static
+ucloud uk8s nodegroup delete project-id dynamic
+ucloud uk8s nodegroup delete region dynamic
+ucloud uk8s nodegroup list cluster-id dynamic
+ucloud uk8s nodegroup list project-id dynamic
+ucloud uk8s nodegroup list region dynamic
+ucloud uk8s version list kind static Dedicated
+ucloud uk8s version list project-id dynamic
+ucloud uk8s version list region dynamic
diff --git a/products/ulhost/internal/image/cmd.go b/products/ulhost/internal/image/cmd.go
new file mode 100644
index 0000000000..7474b4468d
--- /dev/null
+++ b/products/ulhost/internal/image/cmd.go
@@ -0,0 +1,22 @@
+package image
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// NewCommand builds the `image` subcommand nested under `ulhost` (ucloud ulhost
+// image list), mirroring the uhost image subcommand pattern. ULHost uses the
+// same DescribeImage API as UHost but with different defaults.
+func NewCommand(ctx *cli.Context) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "image",
+ Short: "List ULHost images",
+ Long: `List available images for ULHost instances`,
+ Args: cobra.NoArgs,
+ }
+ cmd.AddCommand(newList(ctx))
+
+ return cmd
+}
diff --git a/products/ulhost/internal/image/list.go b/products/ulhost/internal/image/list.go
new file mode 100644
index 0000000000..d0ed69362c
--- /dev/null
+++ b/products/ulhost/internal/image/list.go
@@ -0,0 +1,58 @@
+package image
+
+import (
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ uhostsdk "github.com/ucloud/ucloud-sdk-go/services/uhost"
+
+ "github.com/ucloud/ucloud-cli/internal/common"
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newList ucloud image list (ulhost variant)
+// ULHost uses the same DescribeImage API as UHost but with different defaults.
+func newList(ctx *cli.Context) *cobra.Command {
+ client := cli.NewServiceClient(ctx, uhostsdk.NewClient)
+ req := client.NewDescribeImageRequest()
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List ULHost images",
+ Long: "List available images for ULHost instances",
+ Example: "ucloud image list --image-type Base",
+ Run: func(cmd *cobra.Command, args []string) {
+ resp, err := client.DescribeImage(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ list := make([]ImageRow, 0)
+ for _, image := range resp.ImageSet {
+ row := ImageRow{}
+ row.ImageName = image.ImageName
+ row.ImageID = image.ImageId
+ row.ImageType = image.ImageType
+ row.BasicImage = image.OsName
+ row.ExtensibleFeature = strings.Join(image.Features, ",")
+ row.CreationTime = common.FormatDate(image.CreateTime)
+ row.State = image.State
+ if row.State == imageStateAvailable {
+ list = append(list, row)
+ }
+ }
+ ctx.PrintList(list)
+ },
+ }
+ req.ProjectId = cmd.Flags().String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id")
+ req.Region = cmd.Flags().String("region", ctx.DefaultRegion(), "Optional. Assign region")
+ req.Zone = cmd.Flags().String("zone", "", "Optional. Assign availability zone")
+ req.ImageType = cmd.Flags().String("image-type", "Base", "Optional. 'Base',Standard image; 'Business',image market; 'Custom',custom image")
+ req.OsType = cmd.Flags().String("os-type", "", "Optional. Linux or Windows. Return all types by default")
+ req.ImageId = cmd.Flags().String("image-id", "", "Optional. Resource ID of image")
+ req.Offset = cmd.Flags().Int("offset", 0, "Optional. Offset default 0")
+ req.Limit = cmd.Flags().Int("limit", 500, "Optional. Max count")
+ command.SetFlagValues(cmd, "image-type", "Base", "Business", "Custom")
+ return cmd
+}
diff --git a/products/ulhost/internal/image/rows.go b/products/ulhost/internal/image/rows.go
new file mode 100644
index 0000000000..27963cbf6d
--- /dev/null
+++ b/products/ulhost/internal/image/rows.go
@@ -0,0 +1,19 @@
+package image
+
+// ImageRow 表格行 — mirrors uhost's ImageRow for ulhost image list display.
+type ImageRow struct {
+ ImageName string
+ ImageID string
+ ImageType string
+ BasicImage string
+ ExtensibleFeature string
+ CreationTime string
+ State string
+}
+
+// Image-state constants mirrored from the uhost image product. Used by list to
+// filter to Available images only.
+const (
+ imageStateAvailable = "Available"
+ imageStateUnavailable = "Unavailable"
+)
diff --git a/products/ulhost/internal/ulhost/bundles.go b/products/ulhost/internal/ulhost/bundles.go
new file mode 100644
index 0000000000..7dca4533ca
--- /dev/null
+++ b/products/ulhost/internal/ulhost/bundles.go
@@ -0,0 +1,50 @@
+package ulhost
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ ucompsharesdk "github.com/ucloud/ucloud-sdk-go/services/ucompshare"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newBundles ucloud ulhost bundles
+func newBundles(ctx *cli.Context) *cobra.Command {
+ client := cli.NewServiceClient(ctx, ucompsharesdk.NewClient)
+ req := client.NewDescribeULHostBundlesRequest()
+ cmd := &cobra.Command{
+ Use: "bundles",
+ Short: "List all ULHost bundles",
+ Long: `List all ULHost bundles (套餐列表)`,
+ Run: func(cmd *cobra.Command, args []string) {
+ resp, err := client.DescribeULHostBundles(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ rows := make([]bundleRow, 0, len(resp.Bundles))
+ for _, bundle := range resp.Bundles {
+ row := bundleRow{
+ BundleID: bundle.BundleId,
+ CPU: fmt.Sprintf("%d", bundle.CPU),
+ Memory: fmt.Sprintf("%dG", bundle.Memory/1024),
+ SysDiskSpace: fmt.Sprintf("%dG", bundle.SysDiskSpace),
+ Bandwidth: fmt.Sprintf("%dM", bundle.Bandwidth),
+ TrafficPacket: fmt.Sprintf("%dG", bundle.TrafficPacket),
+ }
+ rows = append(rows, row)
+ }
+ ctx.PrintList(rows)
+ },
+ }
+ cmd.Flags().SortFlags = false
+ req.ProjectId = cmd.Flags().String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id")
+ req.Region = cmd.Flags().String("region", ctx.DefaultRegion(), "Optional. Assign region")
+ command.SetCompletion(cmd, "project-id", ctx.ProjectList)
+ command.SetCompletion(cmd, "region", ctx.RegionList)
+
+ return cmd
+}
diff --git a/products/ulhost/internal/ulhost/cmd.go b/products/ulhost/internal/ulhost/cmd.go
new file mode 100644
index 0000000000..9627b38c8e
--- /dev/null
+++ b/products/ulhost/internal/ulhost/cmd.go
@@ -0,0 +1,47 @@
+package ulhost
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ internalimage "github.com/ucloud/ucloud-cli/products/ulhost/internal/image"
+)
+
+// NewCommand builds the `ulhost` root command and mounts the subcommands in
+// the same AddCommand order as the uhost product: list, create, delete, stop,
+// start, restart, poweroff, reset-password, reinstall-os, modify-attribute,
+// bundles, price. The ulhost-scoped `image list` is mounted as a nested
+// subcommand here rather than a top-level `image` command: the top-level
+// `image` name is already claimed by the uhost product (uhost's image command
+// exposes copy/delete/create; ulhost only lists), so a second top-level
+// `image` would shadow uhost's at runtime (cobra registers top-level commands
+// by name) and break the snapshot partition golden.
+//
+// NOTE: The backend API also exposes UpdateULHostInstanceFirewall,
+// ModifyULHostProxyIp, CheckULHostResourceCapacity, and share-bandwidth
+// management, but the public ucompshare SDK does not yet support these
+// operations. When the SDK adds them, corresponding CLI commands should be
+// added here following the same pattern.
+func NewCommand(ctx *cli.Context) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "ulhost",
+ Short: "List,create,delete,stop,restart,poweroff or resize ULHost instance",
+ Long: `List,create,delete,stop,restart,poweroff or resize ULHost instance`,
+ Args: cobra.NoArgs,
+ }
+ cmd.AddCommand(newList(ctx))
+ cmd.AddCommand(newCreate(ctx))
+ cmd.AddCommand(newDelete(ctx))
+ cmd.AddCommand(newStop(ctx))
+ cmd.AddCommand(newStart(ctx))
+ cmd.AddCommand(newReboot(ctx))
+ cmd.AddCommand(newPoweroff(ctx))
+ cmd.AddCommand(newResetPassword(ctx))
+ cmd.AddCommand(newReinstallOS(ctx))
+ cmd.AddCommand(newModifyAttribute(ctx))
+ cmd.AddCommand(newBundles(ctx))
+ cmd.AddCommand(newPrice(ctx))
+ cmd.AddCommand(internalimage.NewCommand(ctx))
+
+ return cmd
+}
diff --git a/products/ulhost/internal/ulhost/completion.go b/products/ulhost/internal/ulhost/completion.go
new file mode 100644
index 0000000000..2c4aab62c2
--- /dev/null
+++ b/products/ulhost/internal/ulhost/completion.go
@@ -0,0 +1,51 @@
+package ulhost
+
+import (
+ "fmt"
+ "strings"
+
+ ucompsharesdk "github.com/ucloud/ucloud-sdk-go/services/ucompshare"
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// completion.go holds the cross-product completion-data fetchers that ulhost's
+// flags need (--ulhost-id, --bundle-id). Each is a self-contained SDK call
+// (NOT imported — products stay boundary-isolated), with cli.NewServiceClient.
+
+// getULHostList returns "ULHostId/Name" completion candidates filtered by states
+// (nil = all). Mirrors uhost's getUhostList pattern.
+func getULHostList(ctx *cli.Context, states []string, project, region string) []string {
+ client := cli.NewServiceClient(ctx, ucompsharesdk.NewClient)
+ req := client.NewDescribeULHostInstanceRequest()
+ req.ProjectId = sdk.String(project)
+ req.Region = sdk.String(region)
+ req.Limit = sdk.Int(100)
+ resp, err := client.DescribeULHostInstance(req)
+ if err != nil {
+ return nil
+ }
+ list := []string{}
+ for _, host := range resp.ULHostInstanceSets {
+ if states != nil {
+ for _, s := range states {
+ if host.State == s {
+ list = append(list, host.ULHostId+"/"+strings.Replace(host.Name, " ", "-", -1))
+ }
+ }
+ } else {
+ list = append(list, host.ULHostId+"/"+strings.Replace(host.Name, " ", "-", -1))
+ }
+ }
+ return list
+}
+
+// formatBundleInfo returns a human-readable description of a bundle.
+func formatBundleInfo(cpu, memory, sysDiskSpace, bandwidth, trafficPacket int) string {
+ memoryGB := memory / 1024
+ if trafficPacket > 0 {
+ return fmt.Sprintf("cpu:%d memory:%dG disk:%dG bandwidth:%dM traffic:%dG", cpu, memoryGB, sysDiskSpace, bandwidth, trafficPacket)
+ }
+ return fmt.Sprintf("cpu:%d memory:%dG disk:%dG bandwidth:%dM", cpu, memoryGB, sysDiskSpace, bandwidth)
+}
diff --git a/products/ulhost/internal/ulhost/create.go b/products/ulhost/internal/ulhost/create.go
new file mode 100644
index 0000000000..b684105a64
--- /dev/null
+++ b/products/ulhost/internal/ulhost/create.go
@@ -0,0 +1,98 @@
+package ulhost
+
+import (
+ "encoding/base64"
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ ucompsharesdk "github.com/ucloud/ucloud-sdk-go/services/ucompshare"
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newCreate ucloud ulhost create
+func newCreate(ctx *cli.Context) *cobra.Command {
+ var async bool
+ client := cli.NewServiceClient(ctx, ucompsharesdk.NewClient)
+ req := client.NewCreateULHostInstanceRequest()
+ cmd := &cobra.Command{
+ Use: "create",
+ Short: "Create ULHost instance",
+ Long: "Create ULHost instance",
+ SilenceUsage: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ req.ImageId = sdk.String(ctx.PickResourceID(*req.ImageId))
+ req.VPCId = sdk.String(ctx.PickResourceID(*req.VPCId))
+ req.SubnetId = sdk.String(ctx.PickResourceID(*req.SubnetId))
+ req.SecurityGroupId = sdk.String(ctx.PickResourceID(*req.SecurityGroupId))
+ // Encode password to base64
+ if *req.Password != "" {
+ req.Password = sdk.String(base64.StdEncoding.EncodeToString([]byte(*req.Password)))
+ }
+
+ resp, err := client.CreateULHostInstance(req)
+ if err != nil {
+ return err
+ }
+ w := ctx.ProgressWriter()
+ text := fmt.Sprintf("ulhost[%s] is creating", resp.ULHostId)
+ if async {
+ fmt.Fprintln(w, text)
+ } else {
+ prog := ctx.NewProgress()
+ block := prog.NewBlock()
+ prog.Sspoll(sdescribeULHostByID(ctx), resp.ULHostId, text, []string{HOST_RUNNING, HOST_FAIL}, block, &req.CommonBase)
+ }
+ ctx.EmitResult(cli.OpResultRow{ResourceID: resp.ULHostId, Action: "create", Status: "Initializing"})
+ return nil
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.SortFlags = false
+ req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id")
+ ctx.BindRegion(cmd, req)
+ req.BundleId = flags.String("bundle-id", "", "Required. Bundle ID of the ULHost instance, e.g. ulh.c1m1s40b30t800")
+ req.ImageId = flags.String("image-id", "", "Required. Image ID. See 'ucloud ulhost image list'")
+ req.Password = flags.String("password", "", "Required. Password of the ulhost instance")
+ req.Name = flags.String("name", "", "Optional. ULHost instance name")
+ req.ChargeType = flags.String("charge-type", "Month", "Optional. 'Year', pay yearly; 'Month', pay monthly; default: Month")
+ req.Quantity = flags.Int("quantity", 1, "Optional. The duration of the instance. N years/months.")
+ req.SecurityGroupId = flags.String("security-group-id", "", "Optional. Firewall ID, default: Web recommended firewall")
+ req.VPCId = flags.String("vpc-id", "", "Optional. VPC ID. Default VPC will be used if not specified")
+ req.SubnetId = flags.String("subnet-id", "", "Optional. Subnet ID. Default subnet will be used if not specified")
+ req.CouponId = flags.String("coupon-id", "", "Optional. Coupon ID")
+ flags.BoolVar(&async, "async", false, "Optional. Do not wait for the long-running operation to finish.")
+
+ command.SetFlagValues(cmd, "charge-type", "Month", "Year")
+ command.SetCompletion(cmd, "bundle-id", func() []string {
+ return getULHostBundleIDList(ctx, *req.ProjectId, *req.Region)
+ })
+
+ cmd.MarkFlagRequired("bundle-id")
+ cmd.MarkFlagRequired("image-id")
+ cmd.MarkFlagRequired("password")
+
+ return cmd
+}
+
+// getULHostBundleIDList returns bundle ID completion candidates.
+func getULHostBundleIDList(ctx *cli.Context, project, region string) []string {
+ client := cli.NewServiceClient(ctx, ucompsharesdk.NewClient)
+ req := client.NewDescribeULHostBundlesRequest()
+ req.ProjectId = sdk.String(project)
+ req.Region = sdk.String(region)
+ resp, err := client.DescribeULHostBundles(req)
+ if err != nil {
+ return nil
+ }
+ list := []string{}
+ for _, bundle := range resp.Bundles {
+ desc := formatBundleInfo(bundle.CPU, bundle.Memory, bundle.SysDiskSpace, bundle.Bandwidth, bundle.TrafficPacket)
+ list = append(list, bundle.BundleId+"/"+desc)
+ }
+ return list
+}
diff --git a/products/ulhost/internal/ulhost/delete.go b/products/ulhost/internal/ulhost/delete.go
new file mode 100644
index 0000000000..22e38a45d3
--- /dev/null
+++ b/products/ulhost/internal/ulhost/delete.go
@@ -0,0 +1,74 @@
+package ulhost
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ ucompsharesdk "github.com/ucloud/ucloud-sdk-go/services/ucompshare"
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newDelete ucloud ulhost delete
+func newDelete(ctx *cli.Context) *cobra.Command {
+ var ulhostIDs *[]string
+ var yes *bool
+ var releaseUDisk bool
+ client := cli.NewServiceClient(ctx, ucompsharesdk.NewClient)
+ req := client.NewTerminateULHostInstanceRequest()
+ cmd := &cobra.Command{
+ Use: "delete",
+ Short: "Delete ULHost instance",
+ Long: "Delete ULHost instance",
+ SilenceUsage: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ok, err := ctx.Confirm(*yes, "Are you sure you want to delete the ulhost instance(s)?")
+ if err != nil {
+ return err
+ }
+ if !ok {
+ return nil
+ }
+ req.ReleaseUDisk = sdk.Bool(releaseUDisk)
+ w := ctx.ProgressWriter()
+ results := []cli.OpResultRow{}
+ for _, id := range *ulhostIDs {
+ id = ctx.PickResourceID(id)
+ req.ULHostId = sdk.String(id)
+ resp, err := client.TerminateULHostInstance(req)
+ if err != nil {
+ ctx.HandleError(err)
+ continue
+ }
+ status := "Deleted"
+ if resp.InRecycle == "Yes" {
+ status = "Recycled"
+ fmt.Fprintf(w, "ulhost[%s] has been moved to recycle bin\n", resp.ULHostId)
+ } else {
+ fmt.Fprintf(w, "ulhost[%s] deleted\n", resp.ULHostId)
+ }
+ results = append(results, cli.OpResultRow{ResourceID: resp.ULHostId, Action: "delete", Status: status})
+ }
+ ctx.EmitResult(results...)
+ return nil
+ },
+ }
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ ulhostIDs = flags.StringSlice("ulhost-id", nil, "Required. ResourceIDs(ULHostIds) of the ulhost instance")
+ req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id")
+ req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region")
+ flags.BoolVar(&releaseUDisk, "delete-cloud-disk", true, "Optional. false, detach cloud disk only; true, detach cloud disk and delete it")
+ yes = flags.BoolP("yes", "y", false, "Optional. Do not prompt for confirmation.")
+ command.SetFlagValues(cmd, "delete-cloud-disk", "true", "false")
+ command.SetCompletion(cmd, "ulhost-id", func() []string {
+ return getULHostList(ctx, []string{HOST_RUNNING, HOST_STOPPED, HOST_FAIL}, *req.ProjectId, *req.Region)
+ })
+ cmd.MarkFlagRequired("ulhost-id")
+
+ return cmd
+}
diff --git a/products/ulhost/internal/ulhost/describe.go b/products/ulhost/internal/ulhost/describe.go
new file mode 100644
index 0000000000..f785a3d091
--- /dev/null
+++ b/products/ulhost/internal/ulhost/describe.go
@@ -0,0 +1,64 @@
+package ulhost
+
+import (
+ "fmt"
+
+ ucompsharesdk "github.com/ucloud/ucloud-sdk-go/services/ucompshare"
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+ "github.com/ucloud/ucloud-sdk-go/ucloud/request"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+)
+
+// sdescribeULHostByID is the ctx-based concurrent-spoller describe variant
+// (mirrors uhost's sdescribeUHostByID pattern): it uses cli.NewServiceClient
+// instead of base.BizClient. Nil-on-not-found and CommonBase-aware (a non-nil
+// commonBase carries region/project/zone; nil falls back to the client's
+// default-config region, which the SDK marshaler fills when the request region
+// is empty). For use by ulhost product commands (create polling). Returns
+// *ucompsharesdk.ULHostInstanceSet.
+//
+// The legacy base.BizClient variant (SdescribeULHostByID) lived here previously
+// to support cmd/api.go's CreateULHostInstance repeats polling. Product
+// packages must not import the legacy base package (hack/check-product rule2),
+// so that variant now lives on the platform side in cmd/api_repeats_ulhost.go.
+func sdescribeULHostByID(ctx *cli.Context) func(ulhostID string, commonBase *request.CommonBase) (interface{}, error) {
+ return func(ulhostID string, commonBase *request.CommonBase) (interface{}, error) {
+ client := cli.NewServiceClient(ctx, ucompsharesdk.NewClient)
+ req := client.NewDescribeULHostInstanceRequest()
+ if commonBase != nil {
+ req.CommonBase = *commonBase
+ }
+ req.ULHostIds = []string{ulhostID}
+ resp, err := client.DescribeULHostInstance(req)
+ if err != nil {
+ return nil, err
+ }
+ if len(resp.ULHostInstanceSets) < 1 {
+ return nil, nil
+ }
+ return &resp.ULHostInstanceSets[0], nil
+ }
+}
+
+// describeULHostByID mirrors uhost's describeUHostByID (the ERROR-on-not-found
+// variant): it binds projectID/region into the request and returns an error
+// (not nil) when the ulhost does not exist. Used by sequential pollers.
+// Returns *ucompsharesdk.ULHostInstanceSet.
+func describeULHostByID(ctx *cli.Context, projectID, region string) func(ulhostID string, commonBase *request.CommonBase) (interface{}, error) {
+ return func(ulhostID string, _ *request.CommonBase) (interface{}, error) {
+ client := cli.NewServiceClient(ctx, ucompsharesdk.NewClient)
+ req := client.NewDescribeULHostInstanceRequest()
+ req.ULHostIds = []string{ulhostID}
+ req.ProjectId = sdk.String(projectID)
+ req.Region = sdk.String(region)
+ resp, err := client.DescribeULHostInstance(req)
+ if err != nil {
+ return nil, err
+ }
+ if len(resp.ULHostInstanceSets) < 1 {
+ return nil, fmt.Errorf("ulhost [%s] does not exist", ulhostID)
+ }
+ return &resp.ULHostInstanceSets[0], nil
+ }
+}
diff --git a/products/ulhost/internal/ulhost/list.go b/products/ulhost/internal/ulhost/list.go
new file mode 100644
index 0000000000..145157d197
--- /dev/null
+++ b/products/ulhost/internal/ulhost/list.go
@@ -0,0 +1,205 @@
+package ulhost
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ ucompsharesdk "github.com/ucloud/ucloud-sdk-go/services/ucompshare"
+ 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 ucloud ulhost list
+func newList(ctx *cli.Context) *cobra.Command {
+ var allRegion, pageOff, idOnly bool
+ var ulhostIds []string
+ client := cli.NewServiceClient(ctx, ucompsharesdk.NewClient)
+ req := client.NewDescribeULHostInstanceRequest()
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List all ULHost Instances",
+ Long: `List all ULHost Instances`,
+ Run: func(cmd *cobra.Command, args []string) {
+ for _, ulhost := range ulhostIds {
+ req.ULHostIds = append(req.ULHostIds, ctx.PickResourceID(ulhost))
+ }
+
+ ulhosts, err := getAllULHosts(ctx, client, req, pageOff, allRegion)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ if idOnly {
+ listULHostID(ctx, ulhosts)
+ } else {
+ listULHost(ctx, ulhosts, allRegion)
+ }
+ },
+ }
+ cmd.Flags().SortFlags = false
+ req.ProjectId = cmd.Flags().String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id")
+ req.Region = cmd.Flags().String("region", ctx.DefaultRegion(), "Optional. Assign region.")
+ req.Offset = cmd.Flags().Int("offset", 0, "Optional. Offset default 0")
+ req.Limit = cmd.Flags().Int("limit", 50, "Optional. Limit default 50, max value 100")
+ cmd.Flags().StringSliceVar(&ulhostIds, "ulhost-id", make([]string, 0), "Optional. Resource ID of ulhost instances, multiple values separated by comma(without space)")
+ cmd.Flags().BoolVar(&allRegion, "all-region", false, "Optional. Accept values: true or false. List ulhost instances of all regions when assigned true")
+ cmd.Flags().BoolVar(&pageOff, "page-off", false, "Optional. Paging or not. If all-region is specified this flag will be true. Accept values: true or false. If assigned, the limit flag will be disabled and list all ulhost instances")
+ cmd.Flags().BoolVar(&idOnly, "ulhost-id-only", false, "Optional. Just display resource id of ulhost")
+ // NOTE: Unlike uhost, the ucompshare DescribeULHostInstanceRequest does not have
+ // a Tag field, so ctx.BindGroup is not applicable here. Group filtering is not
+ // supported by the ulhost describe API.
+
+ command.SetFlagValues(cmd, "page-off", "true", "false")
+ command.SetFlagValues(cmd, "ulhost-id-only", "true", "false")
+ command.SetCompletion(cmd, "project-id", ctx.ProjectList)
+ command.SetCompletion(cmd, "region", ctx.RegionList)
+ command.SetCompletion(cmd, "ulhost-id", func() []string {
+ return getULHostList(ctx, nil, *req.ProjectId, *req.Region)
+ })
+
+ return cmd
+}
+
+// listULHost renders the ulhost slice via ctx.PrintList, selecting columns per
+// output mode using the per-mode row structs (rows.go).
+func listULHost(ctx *cli.Context, ulhosts []ucompsharesdk.ULHostInstanceSet, listAllRegion bool) {
+ list := make([]ulhostRow, 0)
+ for _, host := range ulhosts {
+ row := ulhostRow{}
+ row.Name = host.Name
+ row.Remark = host.Remark
+ row.ResourceID = host.ULHostId
+ row.Group = host.Tag
+ for _, ip := range host.IPSet {
+ if ip.Type == "Private" {
+ row.PrivateIP = ip.IP
+ } else {
+ if row.PublicIP != "" {
+ row.PublicIP += " | "
+ }
+ row.PublicIP += ip.IP
+ }
+ }
+ memorySize := host.Memory / 1024
+ var disks []string
+ for _, disk := range host.DiskSet {
+ disks = append(disks, fmt.Sprintf("%s:%s:%dG", disk.Type, disk.DiskType, disk.Size))
+ }
+ row.Zone = host.Zone
+ row.DiskSet = strings.Join(disks, "|")
+ row.Config = fmt.Sprintf("cpu:%d memory:%dG", host.CPU, memorySize)
+ row.Image = fmt.Sprintf("%s|%s", host.ImageId, host.ImageName)
+ row.CreationTime = common.FormatDate(host.CreateTime)
+ row.State = host.State
+ row.ChargeType = host.ChargeType
+ row.AutoRenew = host.AutoRenew
+ row.ExpireTime = common.FormatDate(host.ExpireTime)
+ list = append(list, row)
+ }
+
+ // JSON/YAML mode: print the full row set.
+ if ctx.Format() != cli.OutputTable {
+ ctx.PrintList(list)
+ return
+ }
+
+ if listAllRegion {
+ rows := make([]ulhostRowAllRegion, 0, len(list))
+ for _, r := range list {
+ rows = append(rows, ulhostRowAllRegion{
+ Name: r.Name, ResourceID: r.ResourceID, Group: r.Group,
+ PublicIP: r.PublicIP, Config: r.Config,
+ Image: r.Image, State: r.State, ChargeType: r.ChargeType,
+ CreationTime: r.CreationTime, Zone: r.Zone,
+ })
+ }
+ ctx.PrintList(rows)
+ return
+ }
+ rows := make([]ulhostRowDefault, 0, len(list))
+ for _, r := range list {
+ rows = append(rows, ulhostRowDefault{
+ Name: r.Name, ResourceID: r.ResourceID, Group: r.Group,
+ PublicIP: r.PublicIP, Config: r.Config,
+ Image: r.Image, State: r.State, ChargeType: r.ChargeType,
+ CreationTime: r.CreationTime,
+ })
+ }
+ ctx.PrintList(rows)
+}
+
+func listULHostID(ctx *cli.Context, ulhosts []ucompsharesdk.ULHostInstanceSet) {
+ ids := make([]string, 0)
+ for _, h := range ulhosts {
+ ids = append(ids, h.ULHostId)
+ }
+ fmt.Fprintln(ctx.Out(), strings.Join(ids, ","))
+}
+
+func fetchULHosts(client *ucompsharesdk.UCompShareClient, req *ucompsharesdk.DescribeULHostInstanceRequest) ([]ucompsharesdk.ULHostInstanceSet, error) {
+ resp, err := client.DescribeULHostInstance(req)
+ if err != nil {
+ return nil, err
+ }
+ return resp.ULHostInstanceSets, nil
+}
+
+func fetchULHostsPageOff(client *ucompsharesdk.UCompShareClient, req *ucompsharesdk.DescribeULHostInstanceRequest) ([]ucompsharesdk.ULHostInstanceSet, error) {
+ _req := *req
+ result := make([]ucompsharesdk.ULHostInstanceSet, 0)
+ for limit, offset := 50, 0; ; offset += limit {
+ _req.Offset = sdk.Int(offset)
+ _req.Limit = sdk.Int(limit)
+ ulhosts, err := fetchULHosts(client, &_req)
+ if err != nil {
+ return nil, err
+ }
+ result = append(result, ulhosts...)
+ // The ucompshare SDK does not return TotalCount, so we stop when
+ // fewer results than the limit are returned.
+ if len(ulhosts) < limit {
+ break
+ }
+ }
+ return result, nil
+}
+
+func getAllULHosts(ctx *cli.Context, client *ucompsharesdk.UCompShareClient, req *ucompsharesdk.DescribeULHostInstanceRequest, pageOff bool, allRegion bool) ([]ucompsharesdk.ULHostInstanceSet, error) {
+ if allRegion {
+ result := make([]ucompsharesdk.ULHostInstanceSet, 0)
+ regions, err := ctx.AllRegions()
+ if err != nil {
+ return nil, err
+ }
+ for _, region := range regions {
+ _req := *req
+ _req.Region = sdk.String(region)
+ ulhosts, err := fetchULHostsPageOff(client, &_req)
+ if err != nil {
+ continue
+ }
+ result = append(result, ulhosts...)
+ }
+ return result, nil
+ }
+
+ if pageOff {
+ _req := *req
+ ulhosts, err := fetchULHostsPageOff(client, &_req)
+ if err != nil {
+ return nil, err
+ }
+ return ulhosts, nil
+ }
+
+ ulhosts, err := fetchULHosts(client, req)
+ if err != nil {
+ return nil, err
+ }
+ return ulhosts, nil
+}
diff --git a/products/ulhost/internal/ulhost/modify_attribute.go b/products/ulhost/internal/ulhost/modify_attribute.go
new file mode 100644
index 0000000000..f0fe58af36
--- /dev/null
+++ b/products/ulhost/internal/ulhost/modify_attribute.go
@@ -0,0 +1,47 @@
+package ulhost
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ ucompsharesdk "github.com/ucloud/ucloud-sdk-go/services/ucompshare"
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newModifyAttribute ucloud ulhost modify-attribute
+func newModifyAttribute(ctx *cli.Context) *cobra.Command {
+ client := cli.NewServiceClient(ctx, ucompsharesdk.NewClient)
+ req := client.NewModifyULHostAttributeRequest()
+ cmd := &cobra.Command{
+ Use: "modify-attribute",
+ Short: "Modify the attribute of ULHost instance",
+ Long: "Modify the attribute (name or remark) of ULHost instance. At least one of Name or Remark must be specified.",
+ Run: func(cmd *cobra.Command, args []string) {
+ w := ctx.ProgressWriter()
+ req.ULHostId = sdk.String(ctx.PickResourceID(*req.ULHostId))
+ resp, err := client.ModifyULHostAttribute(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ fmt.Fprintf(w, "ulhost[%s] attribute modified\n", resp.ULHostId)
+ ctx.EmitResult(cli.OpResultRow{ResourceID: resp.ULHostId, Action: "modify-attribute", Status: "OK"})
+ },
+ }
+ flags := cmd.Flags()
+ flags.SortFlags = false
+ req.ULHostId = flags.String("ulhost-id", "", "Required. Resource ID of the ulhost instance")
+ req.Name = flags.String("name", "", "Optional. New name of the ulhost instance")
+ req.Remark = flags.String("remark", "", "Optional. New remark of the ulhost instance")
+ 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, "ulhost-id", func() []string {
+ return getULHostList(ctx, nil, *req.ProjectId, *req.Region)
+ })
+ cmd.MarkFlagRequired("ulhost-id")
+ return cmd
+}
diff --git a/products/ulhost/internal/ulhost/poweroff.go b/products/ulhost/internal/ulhost/poweroff.go
new file mode 100644
index 0000000000..668977ec5e
--- /dev/null
+++ b/products/ulhost/internal/ulhost/poweroff.go
@@ -0,0 +1,66 @@
+package ulhost
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ ucompsharesdk "github.com/ucloud/ucloud-sdk-go/services/ucompshare"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newPoweroff ucloud ulhost poweroff
+func newPoweroff(ctx *cli.Context) *cobra.Command {
+ var yes *bool
+ var ulhostIDs *[]string
+ client := cli.NewServiceClient(ctx, ucompsharesdk.NewClient)
+ req := client.NewPoweroffULHostInstanceRequest()
+ cmd := &cobra.Command{
+ Use: "poweroff",
+ Short: "Analog power off ULHost instance",
+ Long: "Analog power off ULHost instance. Danger, it may affect data integrity.",
+ Example: "ucloud ulhost poweroff --ulhost-id ulhost-xxx1,ulhost-xxx2",
+ Run: func(cmd *cobra.Command, args []string) {
+ w := ctx.ProgressWriter()
+ confirmText := "Danger, it may affect data integrity. Are you sure you want to poweroff this ulhost?"
+ if len(*ulhostIDs) > 1 {
+ confirmText = "Danger, it may affect data integrity. Are you sure you want to poweroff those ulhosts?"
+ }
+ ok, err := ctx.Confirm(*yes, confirmText)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ if !ok {
+ return
+ }
+ results := []cli.OpResultRow{}
+ for _, id := range *ulhostIDs {
+ id = ctx.PickResourceID(id)
+ req.ULHostId = &id
+ resp, err := client.PoweroffULHostInstance(req)
+ if err != nil {
+ ctx.HandleError(err)
+ continue
+ }
+ fmt.Fprintf(w, "ulhost[%v] is power off\n", resp.ULHostId)
+ results = append(results, cli.OpResultRow{ResourceID: resp.ULHostId, Action: "poweroff", Status: "Stopped"})
+ }
+ ctx.EmitResult(results...)
+ },
+ }
+ cmd.Flags().SortFlags = false
+ ulhostIDs = cmd.Flags().StringSlice("ulhost-id", nil, "ResourceIDs(ULHostIds) of the ulhost instance")
+ req.ProjectId = cmd.Flags().String("project-id", ctx.DefaultProjectID(), "Assign project-id")
+ req.Region = cmd.Flags().String("region", ctx.DefaultRegion(), "Assign region")
+ yes = cmd.Flags().BoolP("yes", "y", false, "Optional. Do not prompt for confirmation.")
+
+ command.SetCompletion(cmd, "ulhost-id", func() []string {
+ return getULHostList(ctx, []string{HOST_FAIL, HOST_RUNNING, HOST_STOPPED}, *req.ProjectId, *req.Region)
+ })
+ cmd.MarkFlagRequired("ulhost-id")
+
+ return cmd
+}
diff --git a/products/ulhost/internal/ulhost/price.go b/products/ulhost/internal/ulhost/price.go
new file mode 100644
index 0000000000..46b542a5a1
--- /dev/null
+++ b/products/ulhost/internal/ulhost/price.go
@@ -0,0 +1,115 @@
+package ulhost
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ ucompsharesdk "github.com/ucloud/ucloud-sdk-go/services/ucompshare"
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newPrice ucloud ulhost price
+func newPrice(ctx *cli.Context) *cobra.Command {
+ var renew bool
+ client := cli.NewServiceClient(ctx, ucompsharesdk.NewClient)
+ cmd := &cobra.Command{
+ Use: "price",
+ Short: "Get ULHost instance price",
+ Long: `Get ULHost instance price for creating or renewing`,
+ Run: func(cmd *cobra.Command, args []string) {
+ if renew {
+ showRenewPrice(ctx, client, cmd)
+ } else {
+ showCreatePrice(ctx, client, cmd)
+ }
+ },
+ }
+ flags := cmd.Flags()
+ flags.SortFlags = false
+ flags.String("bundle-id", "", "Required for create price. Bundle ID of the ULHost instance")
+ flags.String("charge-type", "", "Optional. 'Year' or 'Month'. If not specified, return all charge types")
+ flags.Int("count", 1, "Optional. Number of instances. Range [1,5]")
+ flags.Int("quantity", 1, "Optional. Purchase duration. Default: 1")
+ flags.String("ulhost-id", "", "Required for renew price. ULHost instance ID")
+ flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id")
+ flags.String("region", ctx.DefaultRegion(), "Optional. Assign region")
+ flags.BoolVar(&renew, "renew", false, "Optional. Get renew price instead of create price")
+ command.SetFlagValues(cmd, "charge-type", "Month", "Year")
+ command.SetCompletion(cmd, "region", ctx.RegionList)
+
+ return cmd
+}
+
+func showCreatePrice(ctx *cli.Context, client *ucompsharesdk.UCompShareClient, cmd *cobra.Command) {
+ req := client.NewGetULHostInstancePriceRequest()
+ bundleID, _ := cmd.Flags().GetString("bundle-id")
+ chargeType, _ := cmd.Flags().GetString("charge-type")
+ count, _ := cmd.Flags().GetInt("count")
+ quantity, _ := cmd.Flags().GetInt("quantity")
+ projectID, _ := cmd.Flags().GetString("project-id")
+ region, _ := cmd.Flags().GetString("region")
+
+ req.BundleId = sdk.String(bundleID)
+ req.ChargeType = sdk.String(chargeType)
+ req.Count = sdk.Int(count)
+ req.Quantity = sdk.Int(quantity)
+ req.ProjectId = sdk.String(projectID)
+ req.Region = sdk.String(region)
+
+ if bundleID == "" {
+ ctx.HandleError(fmt.Errorf("--bundle-id is required for create price"))
+ return
+ }
+
+ resp, err := client.GetULHostInstancePrice(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ rows := make([]priceRow, 0, len(resp.PriceSet))
+ for _, price := range resp.PriceSet {
+ rows = append(rows, priceRow{
+ ChargeType: price.ChargeType,
+ Price: fmt.Sprintf("%.2f", price.Price),
+ OriginalPrice: fmt.Sprintf("%.2f", price.OriginalPrice),
+ })
+ }
+ ctx.PrintList(rows)
+}
+
+func showRenewPrice(ctx *cli.Context, client *ucompsharesdk.UCompShareClient, cmd *cobra.Command) {
+ req := client.NewGetULHostRenewPriceRequest()
+ ulhostID, _ := cmd.Flags().GetString("ulhost-id")
+ chargeType, _ := cmd.Flags().GetString("charge-type")
+ projectID, _ := cmd.Flags().GetString("project-id")
+ region, _ := cmd.Flags().GetString("region")
+
+ req.ULHostId = sdk.String(ulhostID)
+ req.ChargeType = sdk.String(chargeType)
+ req.ProjectId = sdk.String(projectID)
+ req.Region = sdk.String(region)
+
+ if ulhostID == "" {
+ ctx.HandleError(fmt.Errorf("--ulhost-id is required for renew price"))
+ return
+ }
+
+ resp, err := client.GetULHostRenewPrice(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ rows := make([]priceRow, 0, len(resp.PriceSet))
+ for _, price := range resp.PriceSet {
+ rows = append(rows, priceRow{
+ ChargeType: price.ChargeType,
+ Price: fmt.Sprintf("%.2f", price.Price),
+ OriginalPrice: fmt.Sprintf("%.2f", price.OriginalPrice),
+ })
+ }
+ ctx.PrintList(rows)
+}
diff --git a/products/ulhost/internal/ulhost/reinstall_os.go b/products/ulhost/internal/ulhost/reinstall_os.go
new file mode 100644
index 0000000000..7e384542bf
--- /dev/null
+++ b/products/ulhost/internal/ulhost/reinstall_os.go
@@ -0,0 +1,60 @@
+package ulhost
+
+import (
+ "encoding/base64"
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ ucompsharesdk "github.com/ucloud/ucloud-sdk-go/services/ucompshare"
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newReinstallOS ucloud ulhost reinstall-os
+func newReinstallOS(ctx *cli.Context) *cobra.Command {
+ var async *bool
+ client := cli.NewServiceClient(ctx, ucompsharesdk.NewClient)
+ req := client.NewReinstallULHostInstanceRequest()
+ cmd := &cobra.Command{
+ Use: "reinstall-os",
+ Short: "Reinstall the operating system of the ULHost instance",
+ Long: "Reinstall the operating system of the ULHost instance.",
+ Run: func(cmd *cobra.Command, args []string) {
+ w := ctx.ProgressWriter()
+ req.ULHostId = sdk.String(ctx.PickResourceID(*req.ULHostId))
+ req.ImageId = sdk.String(ctx.PickResourceID(*req.ImageId))
+ // Encode password to base64
+ req.Password = sdk.String(base64.StdEncoding.EncodeToString([]byte(*req.Password)))
+ resp, err := client.ReinstallULHostInstance(req)
+ if err != nil {
+ ctx.HandleError(err)
+ return
+ }
+ text := fmt.Sprintf("ulhost[%s] is reinstalling OS", resp.ULHostId)
+ if *async {
+ fmt.Fprintln(w, text)
+ } else {
+ ctx.PollerTo(w, describeULHostByID(ctx, *req.ProjectId, *req.Region)).Spoll(resp.ULHostId, text, []string{HOST_RUNNING, HOST_FAIL})
+ }
+ ctx.EmitResult(cli.OpResultRow{ResourceID: resp.ULHostId, Action: "reinstall-os", Status: "Reinstalling"})
+ },
+ }
+ flags := cmd.Flags()
+ flags.SortFlags = false
+ req.ULHostId = flags.String("ulhost-id", "", "Required. Resource ID of the ulhost to reinstall operating system")
+ req.ImageId = flags.String("image-id", "", "Required. Resource ID of the image to install. See 'ucloud ulhost image list'")
+ req.Password = flags.String("password", "", "Required. Password of the ulhost instance")
+ req.ProjectId = flags.String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id")
+ req.Region = flags.String("region", ctx.DefaultRegion(), "Optional. Assign region")
+ async = flags.BoolP("async", "a", false, "Optional. Do not wait for the long-running operation to finish.")
+ command.SetCompletion(cmd, "ulhost-id", func() []string {
+ return getULHostList(ctx, []string{HOST_RUNNING, HOST_STOPPED}, *req.ProjectId, *req.Region)
+ })
+ cmd.MarkFlagRequired("ulhost-id")
+ cmd.MarkFlagRequired("image-id")
+ cmd.MarkFlagRequired("password")
+ return cmd
+}
diff --git a/products/ulhost/internal/ulhost/reset_password.go b/products/ulhost/internal/ulhost/reset_password.go
new file mode 100644
index 0000000000..f5cbc5a5ba
--- /dev/null
+++ b/products/ulhost/internal/ulhost/reset_password.go
@@ -0,0 +1,57 @@
+package ulhost
+
+import (
+ "encoding/base64"
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ ucompsharesdk "github.com/ucloud/ucloud-sdk-go/services/ucompshare"
+ sdk "github.com/ucloud/ucloud-sdk-go/ucloud"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newResetPassword ucloud ulhost reset-password
+func newResetPassword(ctx *cli.Context) *cobra.Command {
+ var ulhostIDs *[]string
+ client := cli.NewServiceClient(ctx, ucompsharesdk.NewClient)
+ req := client.NewResetULHostInstancePasswordRequest()
+ cmd := &cobra.Command{
+ Use: "reset-password",
+ Short: "Reset the administrator password for the ULHost instances.",
+ Long: "Reset the administrator password for the ULHost instances.",
+ Run: func(cmd *cobra.Command, args []string) {
+ w := ctx.ProgressWriter()
+ // Encode password to base64
+ req.Password = sdk.String(base64.StdEncoding.EncodeToString([]byte(*req.Password)))
+ results := []cli.OpResultRow{}
+ for _, id := range *ulhostIDs {
+ id = ctx.PickResourceID(id)
+ req.ULHostId = &id
+ resp, err := client.ResetULHostInstancePassword(req)
+ if err != nil {
+ ctx.HandleError(err)
+ continue
+ }
+ fmt.Fprintf(w, "ulhost[%s] reset password\n", resp.ULHostId)
+ results = append(results, cli.OpResultRow{ResourceID: resp.ULHostId, Action: "reset-password", Status: "OK"})
+ }
+ ctx.EmitResult(results...)
+ },
+ }
+ flags := cmd.Flags()
+ flags.SortFlags = false
+
+ ulhostIDs = flags.StringSlice("ulhost-id", nil, "Required. Resource IDs of the ulhosts to reset the administrator's password")
+ req.Password = flags.String("password", "", "Required. New Password")
+ 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, "ulhost-id", func() []string {
+ return getULHostList(ctx, []string{HOST_RUNNING, HOST_STOPPED}, *req.ProjectId, *req.Region)
+ })
+ cmd.MarkFlagRequired("ulhost-id")
+ cmd.MarkFlagRequired("password")
+ return cmd
+}
diff --git a/products/ulhost/internal/ulhost/restart.go b/products/ulhost/internal/ulhost/restart.go
new file mode 100644
index 0000000000..79a868a9a7
--- /dev/null
+++ b/products/ulhost/internal/ulhost/restart.go
@@ -0,0 +1,57 @@
+package ulhost
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ ucompsharesdk "github.com/ucloud/ucloud-sdk-go/services/ucompshare"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newReboot ucloud ulhost restart
+func newReboot(ctx *cli.Context) *cobra.Command {
+ var ulhostIDs *[]string
+ var async *bool
+ client := cli.NewServiceClient(ctx, ucompsharesdk.NewClient)
+ req := client.NewRebootULHostInstanceRequest()
+ cmd := &cobra.Command{
+ Use: "restart",
+ Short: "Restart ULHost instance",
+ Long: "Restart ULHost instance",
+ Example: "ucloud ulhost restart --ulhost-id ulhost-xxx1,ulhost-xxx2",
+ Run: func(cmd *cobra.Command, args []string) {
+ w := ctx.ProgressWriter()
+ results := []cli.OpResultRow{}
+ for _, id := range *ulhostIDs {
+ id = ctx.PickResourceID(id)
+ req.ULHostId = &id
+ resp, err := client.RebootULHostInstance(req)
+ if err != nil {
+ ctx.HandleError(err)
+ continue
+ }
+ text := fmt.Sprintf("ulhost[%v] is restarting", resp.ULHostId)
+ if *async {
+ fmt.Fprintln(w, text)
+ } else {
+ ctx.PollerTo(w, describeULHostByID(ctx, *req.ProjectId, *req.Region)).Spoll(resp.ULHostId, text, []string{HOST_RUNNING, HOST_FAIL})
+ }
+ results = append(results, cli.OpResultRow{ResourceID: resp.ULHostId, Action: "restart", Status: "Rebooting"})
+ }
+ ctx.EmitResult(results...)
+ },
+ }
+ cmd.Flags().SortFlags = false
+ ulhostIDs = cmd.Flags().StringSlice("ulhost-id", nil, "Required. ResourceIDs(ULHostIds) of the ulhost instance")
+ req.ProjectId = cmd.Flags().String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id")
+ req.Region = cmd.Flags().String("region", ctx.DefaultRegion(), "Optional. Assign region")
+ async = cmd.Flags().Bool("async", false, "Optional. Do not wait for the long-running operation to finish.")
+ command.SetCompletion(cmd, "ulhost-id", func() []string {
+ return getULHostList(ctx, []string{HOST_FAIL, HOST_RUNNING, HOST_STOPPED}, *req.ProjectId, *req.Region)
+ })
+ cmd.MarkFlagRequired("ulhost-id")
+ return cmd
+}
diff --git a/products/ulhost/internal/ulhost/rows.go b/products/ulhost/internal/ulhost/rows.go
new file mode 100644
index 0000000000..7e6bfb377d
--- /dev/null
+++ b/products/ulhost/internal/ulhost/rows.go
@@ -0,0 +1,71 @@
+package ulhost
+
+// rows.go holds the table-row structs for ulhost list output. The platform
+// printer (ctx.PrintList) derives table columns from a struct's exported fields
+// in declaration order. JSON output uses the full ulhostRow (matching the
+// original convention of marshalling the full row slice in --json mode).
+
+// ulhostRow is the full row (wide mode + json). Field set+order matches the
+// ucompshare SDK ULHostInstanceSet fields relevant for CLI display.
+type ulhostRow struct {
+ Name string
+ ResourceID string
+ Remark string
+ Group string
+ PrivateIP string
+ PublicIP string
+ Config string
+ DiskSet string
+ Zone string
+ Image string
+ State string
+ ChargeType string
+ AutoRenew string
+ ExpireTime string
+ CreationTime string
+}
+
+// ulhostRowDefault is the default (non-all-region) column set:
+// Name, ResourceID, Group, PublicIP, Config, Image, State, ChargeType, CreationTime
+type ulhostRowDefault struct {
+ Name string
+ ResourceID string
+ Group string
+ PublicIP string
+ Config string
+ Image string
+ State string
+ ChargeType string
+ CreationTime string
+}
+
+// ulhostRowAllRegion is the default column set plus a trailing Zone column.
+type ulhostRowAllRegion struct {
+ Name string
+ ResourceID string
+ Group string
+ PublicIP string
+ Config string
+ Image string
+ State string
+ ChargeType string
+ CreationTime string
+ Zone string
+}
+
+// bundleRow mirrors the Bundle struct for table display.
+type bundleRow struct {
+ BundleID string
+ CPU string
+ Memory string
+ SysDiskSpace string
+ Bandwidth string
+ TrafficPacket string
+}
+
+// priceRow mirrors the ULHostPriceSet for table display.
+type priceRow struct {
+ ChargeType string
+ Price string
+ OriginalPrice string
+}
diff --git a/products/ulhost/internal/ulhost/start.go b/products/ulhost/internal/ulhost/start.go
new file mode 100644
index 0000000000..f1ed634245
--- /dev/null
+++ b/products/ulhost/internal/ulhost/start.go
@@ -0,0 +1,57 @@
+package ulhost
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ ucompsharesdk "github.com/ucloud/ucloud-sdk-go/services/ucompshare"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newStart ucloud ulhost start
+func newStart(ctx *cli.Context) *cobra.Command {
+ var async *bool
+ var ulhostIDs *[]string
+ client := cli.NewServiceClient(ctx, ucompsharesdk.NewClient)
+ req := client.NewStartULHostInstanceRequest()
+ cmd := &cobra.Command{
+ Use: "start",
+ Short: "Start ULHost instance",
+ Long: "Start ULHost instance",
+ Example: "ucloud ulhost start --ulhost-id ulhost-xxx1,ulhost-xxx2",
+ Run: func(cmd *cobra.Command, args []string) {
+ w := ctx.ProgressWriter()
+ results := []cli.OpResultRow{}
+ for _, id := range *ulhostIDs {
+ id := ctx.PickResourceID(id)
+ req.ULHostId = &id
+ resp, err := client.StartULHostInstance(req)
+ if err != nil {
+ ctx.HandleError(err)
+ continue
+ }
+ text := fmt.Sprintf("ulhost[%v] is starting", resp.ULHostId)
+ if *async {
+ fmt.Fprintln(w, text)
+ } else {
+ ctx.PollerTo(w, describeULHostByID(ctx, *req.ProjectId, *req.Region)).Spoll(resp.ULHostId, text, []string{HOST_RUNNING, HOST_FAIL})
+ }
+ results = append(results, cli.OpResultRow{ResourceID: resp.ULHostId, Action: "start", Status: "Starting"})
+ }
+ ctx.EmitResult(results...)
+ },
+ }
+ cmd.Flags().SortFlags = false
+ ulhostIDs = cmd.Flags().StringSlice("ulhost-id", nil, "Required. ResourceIDs(ULHostIds) of the ulhost instance")
+ req.ProjectId = cmd.Flags().String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id")
+ req.Region = cmd.Flags().String("region", ctx.DefaultRegion(), "Optional. Assign region")
+ async = cmd.Flags().Bool("async", false, "Optional. Do not wait for the long-running operation to finish.")
+ command.SetCompletion(cmd, "ulhost-id", func() []string {
+ return getULHostList(ctx, []string{HOST_STOPPED}, *req.ProjectId, *req.Region)
+ })
+ cmd.MarkFlagRequired("ulhost-id")
+ return cmd
+}
diff --git a/products/ulhost/internal/ulhost/status.go b/products/ulhost/internal/ulhost/status.go
new file mode 100644
index 0000000000..d6098686ea
--- /dev/null
+++ b/products/ulhost/internal/ulhost/status.go
@@ -0,0 +1,15 @@
+package ulhost
+
+// ULHost-domain state/type constants. Product-owned copies (mirrors uhost
+// status.go pattern). ULHost states follow the ucompshare SDK
+// ULHostInstanceSet.State enumeration. Only the states the CLI polls against
+// (terminal Running/Stopped/Install Fail) are kept; the SDK also reports
+// Initializing/Starting/Stopping/Rebooting, but no ulhost command waits on
+// those, so they are omitted to avoid unused-constant drift.
+const (
+ HOST_RUNNING = "Running"
+ HOST_STOPPED = "Stopped"
+ HOST_FAIL = "Install Fail"
+
+ REGEXP_NAME = "^[A-Za-z0-9-_.一-龥]{1,63}$"
+)
diff --git a/products/ulhost/internal/ulhost/stop.go b/products/ulhost/internal/ulhost/stop.go
new file mode 100644
index 0000000000..33fd98b5d6
--- /dev/null
+++ b/products/ulhost/internal/ulhost/stop.go
@@ -0,0 +1,58 @@
+package ulhost
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ ucompsharesdk "github.com/ucloud/ucloud-sdk-go/services/ucompshare"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ "github.com/ucloud/ucloud-cli/pkg/command"
+)
+
+// newStop ucloud ulhost stop
+func newStop(ctx *cli.Context) *cobra.Command {
+ var ulhostIDs *[]string
+ var async *bool
+ client := cli.NewServiceClient(ctx, ucompsharesdk.NewClient)
+ req := client.NewStopULHostInstanceRequest()
+ cmd := &cobra.Command{
+ Use: "stop",
+ Short: "Shut down ULHost instance",
+ Long: "Shut down ULHost instance",
+ Example: "ucloud ulhost stop --ulhost-id ulhost-xxx1,ulhost-xxx2",
+ Run: func(cmd *cobra.Command, args []string) {
+ w := ctx.ProgressWriter()
+ results := []cli.OpResultRow{}
+ for _, id := range *ulhostIDs {
+ id = ctx.PickResourceID(id)
+ req.ULHostId = &id
+ resp, err := client.StopULHostInstance(req)
+ if err != nil {
+ ctx.HandleError(err)
+ continue
+ }
+ text := fmt.Sprintf("ulhost[%v] is shutting down", resp.ULHostId)
+ if *async {
+ fmt.Fprintln(w, text)
+ } else {
+ ctx.PollerTo(w, describeULHostByID(ctx, *req.ProjectId, *req.Region)).Spoll(resp.ULHostId, text, []string{HOST_STOPPED, HOST_FAIL})
+ }
+ results = append(results, cli.OpResultRow{ResourceID: resp.ULHostId, Action: "stop", Status: "Stopping"})
+ }
+ ctx.EmitResult(results...)
+ },
+ }
+ cmd.Flags().SortFlags = false
+ ulhostIDs = cmd.Flags().StringSlice("ulhost-id", nil, "Required. ResourceIDs(ULHostIds) of the ulhost instances")
+ req.ProjectId = cmd.Flags().String("project-id", ctx.DefaultProjectID(), "Optional. Assign project-id")
+ req.Region = cmd.Flags().String("region", ctx.DefaultRegion(), "Optional. Assign region")
+ async = cmd.Flags().Bool("async", false, "Optional. Do not wait for the long-running operation to finish.")
+ command.SetCompletion(cmd, "ulhost-id", func() []string {
+ return getULHostList(ctx, []string{HOST_RUNNING}, *req.ProjectId, *req.Region)
+ })
+ cmd.MarkFlagRequired("ulhost-id")
+
+ return cmd
+}
diff --git a/products/ulhost/product.go b/products/ulhost/product.go
new file mode 100644
index 0000000000..c5bc46090c
--- /dev/null
+++ b/products/ulhost/product.go
@@ -0,0 +1,23 @@
+package ulhost
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/ucloud/ucloud-cli/pkg/cli"
+ internalulhost "github.com/ucloud/ucloud-cli/products/ulhost/internal/ulhost"
+)
+
+type product struct{}
+
+// New returns the ulhost product (registered via hack/gen-products).
+func New() cli.Product { return product{} }
+
+func (product) Metadata() cli.Metadata {
+ return cli.Metadata{Name: "ulhost", Commands: []string{"ulhost"}}
+}
+
+func (product) NewCommand(ctx *cli.Context) []*cobra.Command {
+ return []*cobra.Command{
+ internalulhost.NewCommand(ctx),
+ }
+}
diff --git a/products/ulhost/product.yaml b/products/ulhost/product.yaml
new file mode 100644
index 0000000000..e189c3cf12
--- /dev/null
+++ b/products/ulhost/product.yaml
@@ -0,0 +1,7 @@
+# products/ulhost/product.yaml — ulhost 产品元数据(归属真源,owner 自治维护)
+name: ulhost
+owners:
+ - calmxkk
+commands:
+ - ulhost
+enabled: true
diff --git a/products/ulhost/testdata/cmdtree.golden b/products/ulhost/testdata/cmdtree.golden
new file mode 100644
index 0000000000..ab40349e28
--- /dev/null
+++ b/products/ulhost/testdata/cmdtree.golden
@@ -0,0 +1,90 @@
+ucloud ulhost use=ulhost short=List,create,delete,stop,restart,poweroff or resize ULHost instance
+ucloud ulhost bundles use=bundles short=List all ULHost bundles
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ucloud ulhost create use=create short=Create ULHost instance
+ flag=async short= default=false required=
+ flag=bundle-id short= default= required=true
+ flag=charge-type short= default=Month required=
+ flag=coupon-id short= default= required=
+ flag=image-id short= default= required=true
+ flag=name short= default= required=
+ flag=password short= default= required=true
+ flag=project-id short= default= required=
+ flag=quantity short= default=1 required=
+ flag=region short= default= required=
+ flag=security-group-id short= default= required=
+ flag=subnet-id short= default= required=
+ flag=vpc-id short= default= required=
+ucloud ulhost delete use=delete short=Delete ULHost instance
+ flag=delete-cloud-disk short= default=true required=
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=ulhost-id short= default=[] required=true
+ flag=yes short=y default=false required=
+ucloud ulhost image use=image short=List ULHost images
+ucloud ulhost image list use=list short=List ULHost images
+ flag=image-id short= default= required=
+ flag=image-type short= default=Base required=
+ flag=limit short= default=500 required=
+ flag=offset short= default=0 required=
+ flag=os-type short= default= required=
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=zone short= default= required=
+ucloud ulhost list use=list short=List all ULHost Instances
+ flag=all-region short= default=false required=
+ flag=limit short= default=50 required=
+ flag=offset short= default=0 required=
+ flag=page-off short= default=false required=
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=ulhost-id short= default=[] required=
+ flag=ulhost-id-only short= default=false required=
+ucloud ulhost modify-attribute use=modify-attribute short=Modify the attribute of ULHost instance
+ flag=name short= default= required=
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=remark short= default= required=
+ flag=ulhost-id short= default= required=true
+ucloud ulhost poweroff use=poweroff short=Analog power off ULHost instance
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=ulhost-id short= default=[] required=true
+ flag=yes short=y default=false required=
+ucloud ulhost price use=price short=Get ULHost instance price
+ flag=bundle-id short= default= required=
+ flag=charge-type short= default= required=
+ flag=count short= default=1 required=
+ flag=project-id short= default= required=
+ flag=quantity short= default=1 required=
+ flag=region short= default= required=
+ flag=renew short= default=false required=
+ flag=ulhost-id short= default= required=
+ucloud ulhost reinstall-os use=reinstall-os short=Reinstall the operating system of the ULHost instance
+ flag=async short=a default=false required=
+ flag=image-id short= default= required=true
+ flag=password short= default= required=true
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=ulhost-id short= default= required=true
+ucloud ulhost reset-password use=reset-password short=Reset the administrator password for the ULHost instances.
+ flag=password short= default= required=true
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=ulhost-id short= default=[] required=true
+ucloud ulhost restart use=restart short=Restart ULHost instance
+ flag=async short= default=false required=
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=ulhost-id short= default=[] required=true
+ucloud ulhost start use=start short=Start ULHost instance
+ flag=async short= default=false required=
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=ulhost-id short= default=[] required=true
+ucloud ulhost stop use=stop short=Shut down ULHost instance
+ flag=async short= default=false required=
+ flag=project-id short= default= required=
+ flag=region short= default= required=
+ flag=ulhost-id short= default=[] required=true
diff --git a/products/ulhost/testdata/completion.golden b/products/ulhost/testdata/completion.golden
new file mode 100644
index 0000000000..df4145568a
--- /dev/null
+++ b/products/ulhost/testdata/completion.golden
@@ -0,0 +1,22 @@
+ucloud ulhost bundles project-id dynamic
+ucloud ulhost bundles region dynamic
+ucloud ulhost create bundle-id dynamic
+ucloud ulhost create charge-type static Month,Year
+ucloud ulhost create region dynamic
+ucloud ulhost delete delete-cloud-disk static false,true
+ucloud ulhost delete ulhost-id dynamic
+ucloud ulhost image list image-type static Base,Business,Custom
+ucloud ulhost list page-off static false,true
+ucloud ulhost list project-id dynamic
+ucloud ulhost list region dynamic
+ucloud ulhost list ulhost-id dynamic
+ucloud ulhost list ulhost-id-only static false,true
+ucloud ulhost modify-attribute ulhost-id dynamic
+ucloud ulhost poweroff ulhost-id dynamic
+ucloud ulhost price charge-type static Month,Year
+ucloud ulhost price region dynamic
+ucloud ulhost reinstall-os ulhost-id dynamic
+ucloud ulhost reset-password ulhost-id dynamic
+ucloud ulhost restart ulhost-id dynamic
+ucloud ulhost start ulhost-id dynamic
+ucloud ulhost stop ulhost-id dynamic