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