diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index d2a363755b..c4c0b22a74 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -256,7 +256,7 @@ jobs: env: { GOPROXY: 'https://goproxy.cn,direct' } - run: go vet ./... env: { GOPROXY: 'https://goproxy.cn,direct' } - - run: go test ./pkg/... ./base/ ./hack/... ./ux/... -count=1 + - run: go test ./pkg/... ./cmd/internal/... ./hack/... -count=1 env: { GOPROXY: 'https://goproxy.cn,direct' } build-matrix: diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 9633aa0681..31275f7578 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -14,7 +14,7 @@ builds: - amd64 - arm64 ldflags: - - -s -w -X github.com/ucloud/ucloud-cli/base.Version={{.Version}} + - -s -w -X github.com/ucloud/ucloud-cli/cmd/internal/version.Version={{.Version}} archives: - id: ucloud diff --git a/Makefile b/Makefile index 6cbbaf2803..4b71425727 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,12 @@ GOFMT_FILES?=$$(find . -name '*.go') +LDFLAGS=-s -w -X github.com/ucloud/ucloud-cli/cmd/internal/version.Version=$(shell git describe --tags --always --dirty) + +.PHONY: build +build: + go build -ldflags "$(LDFLAGS)" -o out/ucloud . .PHONY: install -install: - go build -ldflags "-s -w -X github.com/ucloud/ucloud-cli/base.Version=$(shell git describe --tags --always --dirty)" -o out/ucloud . +install: build cp out/ucloud /usr/local/bin .PHONY: fmt diff --git a/ansi/code.go b/ansi/code.go deleted file mode 100644 index 22956a2525..0000000000 --- a/ansi/code.go +++ /dev/null @@ -1,34 +0,0 @@ -// Package ansi reference https://github.com/sindresorhus/ansi-escapes -package ansi - -import ( - "fmt" -) - -const csi = "\x1b[" - -const sep = ";" - -// CursorLeft move cursor to the left side -var CursorLeft = fmt.Sprintf("%sG", csi) - -// EraseDown Erase the screen from the current line down to the bottom of the -var EraseDown = fmt.Sprintf("%sJ", csi) - -// EraseUp Erase the screen from the current line up to the top of the screen -var EraseUp = fmt.Sprintf("%s1J", csi) - -// CursorUp Move cursor up a specific amount of rows. -func CursorUp(count int) string { - return fmt.Sprintf("%s%dA", csi, count) -} - -// CursorPrevLine Move cursor up a specific amount of rows. -func CursorPrevLine(count int) string { - return fmt.Sprintf("%s%dF", csi, count) -} - -// CursorTo Set the absolute position of the cursor. `x` `y` is the top left of the screen. -func CursorTo(x, y int) string { - return fmt.Sprintf("%s%d;%dH", csi, y+1, x+1) -} diff --git a/base/spoll_test.go b/base/spoll_test.go deleted file mode 100644 index 5b0cf44a25..0000000000 --- a/base/spoll_test.go +++ /dev/null @@ -1,65 +0,0 @@ -package base - -import ( - "bytes" - "strings" - "testing" - "time" - - "github.com/ucloud/ucloud-sdk-go/ucloud/request" -) - -// instDone is a minimal struct whose State field signals completion. -type instDone struct { - State string -} - -func TestSpollNonTTY_Done(t *testing.T) { - describeFunc := func(resourceID string, _ *request.CommonBase) (interface{}, error) { - return &instDone{State: "DONE"}, nil - } - - buf := &bytes.Buffer{} - p := NewSpoller(describeFunc, buf) - p.Timeout = 30 * time.Second - - p.Spoll("res-001", "creating", []string{"DONE"}) - - out := buf.String() - - if !strings.Contains(out, "creating...done\n") { - t.Errorf("expected 'creating...done\\n' in output, got: %q", out) - } - // Spinner frames should NOT be present (non-TTY suppression). - if strings.ContainsRune(out, '⣾') { - t.Errorf("spinner frame rune '⣾' leaked into non-TTY output: %q", out) - } -} - -// TestSpollNonTTY_NoSpinnerFrames verifies that no ANSI spinner frames are -// emitted to a non-TTY writer regardless of outcome. We keep a separate -// done-path test here with an immediate target-state match so the test is -// fast and deterministic. -func TestSpollNonTTY_NoSpinnerFrames(t *testing.T) { - calls := 0 - describeFunc := func(resourceID string, _ *request.CommonBase) (interface{}, error) { - calls++ - return &instDone{State: "ACTIVE"}, nil - } - - buf := &bytes.Buffer{} - p := NewSpoller(describeFunc, buf) - p.Timeout = 30 * time.Second - - p.Spoll("res-003", "activating", []string{"ACTIVE"}) - - out := buf.String() - if !strings.Contains(out, "activating...done\n") { - t.Errorf("expected 'activating...done\\n' in output, got: %q", out) - } - for _, r := range []rune{'⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'} { - if strings.ContainsRune(out, r) { - t.Errorf("spinner frame rune %q leaked into non-TTY output: %q", r, out) - } - } -} diff --git a/base/sspoll_test.go b/base/sspoll_test.go deleted file mode 100644 index b9b1791da9..0000000000 --- a/base/sspoll_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package base - -import ( - "bytes" - "strings" - "testing" - "time" - - "github.com/ucloud/ucloud-sdk-go/ucloud/request" - - "github.com/ucloud/ucloud-cli/ux" -) - -// Sspoll (concurrent poller) must suppress spinner animation on a non-TTY -// writer, exactly like Spoll, emitting a single terminal-state line instead. -func TestSspollNonTTY_Done(t *testing.T) { - describeFunc := func(resourceID string, _ *request.CommonBase) (interface{}, error) { - return &instDone{State: "DONE"}, nil - } - - buf := &bytes.Buffer{} - p := NewSpoller(describeFunc, buf) - p.Timeout = 30 * time.Second - - ret := p.Sspoll("res-001", "creating", []string{"DONE"}, ux.NewBlock(), &request.CommonBase{}) - - if ret == nil || !ret.Done { - t.Fatalf("Sspoll non-TTY: want Done=true, got %+v", ret) - } - out := buf.String() - if !strings.Contains(out, "creating...done\n") { - t.Errorf("expected 'creating...done\\n' in output, got: %q", out) - } - for _, r := range []rune{'⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'} { - if strings.ContainsRune(out, r) { - t.Errorf("spinner frame %q leaked into non-TTY Sspoll output: %q", r, out) - } - } -} diff --git a/cmd/api.go b/cmd/api.go index 10441ad347..bd44fdefe0 100644 --- a/cmd/api.go +++ b/cmd/api.go @@ -9,25 +9,33 @@ import ( "strconv" "strings" "sync" - "sync/atomic" "time" "github.com/spf13/cobra" + "github.com/ucloud/ucloud-sdk-go/services/uaccount" "github.com/ucloud/ucloud-sdk-go/ucloud" "github.com/ucloud/ucloud-sdk-go/ucloud/request" - "github.com/ucloud/ucloud-cli/base" + "github.com/ucloud/ucloud-cli/cmd/internal/platform" "github.com/ucloud/ucloud-cli/model/status" - "github.com/ucloud/ucloud-cli/ux" + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/ui" ) type RepeatsConfig struct { - Poller *base.Poller + Poller cli.Poller IDInResp string } -var RepeatsSupportedAPI = map[string]RepeatsConfig{ - "CreateULHostInstance": {Poller: ulhostSpoller, IDInResp: "ULHostId"}, +type repeatResult struct { + success bool + err error +} + +func repeatsSupportedAPI(out io.Writer) map[string]RepeatsConfig { + return map[string]RepeatsConfig{ + "CreateULHostInstance": {Poller: newULHostPoller(out), IDInResp: "ULHostId"}, + } } const ActionField = "Action" @@ -48,77 +56,81 @@ func NewCmdAPI(out io.Writer) *cobra.Command { Use: "api", Short: "Call API", Long: "Call API", - Run: func(c *cobra.Command, args []string) { + RunE: func(c *cobra.Command, args []string) error { if containHelp(args) { fmt.Fprintln(out, HelpInfo) - return + return nil } params, err := parseParamsFromCmdLine(args) if err != nil { fmt.Fprintln(out, err) - return + return err } if params["local-file"] != nil { file, ok := params["local-file"].(string) if !ok { - fmt.Fprintf(out, "local-file should be a string\n") + err := fmt.Errorf("local-file should be a string") + fmt.Fprintln(out, err) + return err } params, err = parseParamsFromJSONFile(file) if err != nil { fmt.Fprintln(out, err) - return + return err } } if action, actionOK := params[ActionField].(string); actionOK { - if repeatsConfig, repeatsSupported := RepeatsSupportedAPI[action]; repeatsSupported { + if repeatsConfig, repeatsSupported := repeatsSupportedAPI(out)[action]; repeatsSupported { if repeats, repeatsOK := params[RepeatsField].(string); repeatsOK { var repeatsNum int var concurrentNum int repeatsNum, err = strconv.Atoi(repeats) if err != nil { fmt.Fprintf(out, "error: %v\n", err) - return + return err } if concurrent, concurrentOK := params[ConcurrentField].(string); concurrentOK { concurrentNum, err = strconv.Atoi(concurrent) if err != nil { fmt.Fprintf(out, "error: %v\n", err) - return + return err } } else { concurrentNum = DefaultConcurrent } delete(params, RepeatsField) delete(params, ConcurrentField) - err = genericInvokeRepeatWrapper(&repeatsConfig, params, action, repeatsNum, concurrentNum) + err = genericInvokeRepeatWrapper(&repeatsConfig, params, action, repeatsNum, concurrentNum, out) if err != nil { fmt.Fprintf(out, "error: %v\n", err) - return + return err } - return + return nil } } } - req := base.BizClient.UAccountClient.NewGenericRequest() + client := newServiceClient(uaccount.NewClient) + req := client.NewGenericRequest() err = req.SetPayload(params) if err != nil { fmt.Fprintf(out, "error: %v\n", err) - return + return err } - resp, err := base.BizClient.UAccountClient.GenericInvoke(req) + resp, err := client.GenericInvoke(req) if err != nil { fmt.Fprintf(out, "error: %v\n", err) - return + return err } data, err := json.MarshalIndent(resp.GetPayload(), "", " ") if err != nil { fmt.Fprintf(out, "error: %v\n", err) - return + return err } fmt.Fprintln(out, string(data)) + return nil }, } } @@ -150,7 +162,7 @@ func parseParamsFromCmdLine(args []string) (map[string]interface{}, error) { return params, nil } -func genericInvokeRepeatWrapper(repeatsConfig *RepeatsConfig, params map[string]interface{}, action string, repeats int, concurrent int) error { +func genericInvokeRepeatWrapper(repeatsConfig *RepeatsConfig, params map[string]interface{}, action string, repeats int, concurrent int, out io.Writer) error { if repeatsConfig == nil { return fmt.Errorf("error: repeatsConfig is nil") } @@ -162,92 +174,107 @@ func genericInvokeRepeatWrapper(repeatsConfig *RepeatsConfig, params map[string] } wg := &sync.WaitGroup{} tokens := make(chan struct{}, concurrent) - retCh := make(chan bool, repeats) + retCh := make(chan repeatResult, repeats) wg.Add(repeats) - //ux.Doc.Disable() - refresh := ux.NewRefresh() + doc := ui.NewDocument(out) + refresh := ui.NewRefresh(out) + printBlockLine := func(block *ui.Block, line string) { + block.Append(line) + if !ui.IsTTY(out) { + fmt.Fprintln(out, line) + } + } - req := base.BizClient.UAccountClient.NewGenericRequest() + client := newServiceClient(uaccount.NewClient) + req := client.NewGenericRequest() err := req.SetPayload(params) if err != nil { return fmt.Errorf("fail to set payload: %w", err) } - go func(req request.GenericRequest) { - for i := 0; i < repeats; i++ { - go func(req request.GenericRequest, idx int) { - tokens <- struct{}{} - defer func() { - <-tokens - //设置延时,使报错能渲染出来 - time.Sleep(time.Second / 5) - wg.Done() - }() - success := true - resp, err := base.BizClient.UAccountClient.GenericInvoke(req) - block := ux.NewBlock() - ux.Doc.Append(block) - logs := []string{"=================================================="} - logs = append(logs, fmt.Sprintf("api:%v, request:%v", action, base.ToQueryMap(req))) - if err != nil { - logs = append(logs, fmt.Sprintf("err:%v", err)) - block.Append(base.ParseError(err)) + for i := 0; i < repeats; i++ { + go func(req request.GenericRequest, idx int) { + tokens <- struct{}{} + defer func() { + <-tokens + //设置延时,使报错能渲染出来 + time.Sleep(time.Second / 5) + wg.Done() + }() + success := true + var resultErr error + resp, err := client.GenericInvoke(req) + block := ui.NewBlock() + doc.Append(block) + logs := []string{"=================================================="} + logs = append(logs, fmt.Sprintf("api:%v, request:%v", action, platform.ToQueryMap(req))) + if err != nil { + logs = append(logs, fmt.Sprintf("err:%v", err)) + printBlockLine(block, platform.ParseError(err)) + success = false + resultErr = err + } else { + logs = append(logs, fmt.Sprintf("resp:%#v", resp)) + resourceId, ok := resp.GetPayload()[repeatsConfig.IDInResp].(string) + if !ok { + resultErr = fmt.Errorf("expect %v in response, but not found", repeatsConfig.IDInResp) + printBlockLine(block, resultErr.Error()) success = false } else { - logs = append(logs, fmt.Sprintf("resp:%#v", resp)) - resourceId, ok := resp.GetPayload()[repeatsConfig.IDInResp].(string) - if !ok { - block.Append(fmt.Sprintf("expect %v in response, but not found", repeatsConfig.IDInResp)) + text := fmt.Sprintf("the resource[%s] is initializing", resourceId) + result := repeatsConfig.Poller.Sspoll(resourceId, text, []string{status.HOST_RUNNING, status.HOST_FAIL}, block, &request.CommonBase{ + Region: ucloud.String(req.GetRegion()), + Zone: ucloud.String(req.GetZone()), + ProjectId: ucloud.String(req.GetProjectId()), + }) + if result.Err != nil { success = false - } else { - text := fmt.Sprintf("the resource[%s] is initializing", resourceId) - result := repeatsConfig.Poller.Sspoll(resourceId, text, []string{status.HOST_RUNNING, status.HOST_FAIL}, block, &request.CommonBase{ - Region: ucloud.String(req.GetRegion()), - Zone: ucloud.String(req.GetZone()), - ProjectId: ucloud.String(req.GetProjectId()), - }) - if result.Err != nil { - success = false - block.Append(result.Err.Error()) - } + resultErr = result.Err + printBlockLine(block, result.Err.Error()) } - retCh <- success - logs = append(logs, fmt.Sprintf("index:%d, result:%t", idx, success)) - base.LogInfo(logs...) } - }(req, i) - } - }(req) - - var success, fail atomic.Int32 - go func() { - block := ux.NewBlock() - ux.Doc.Append(block) - block.Append(fmt.Sprintf("creating, total:%d, success:%d, fail:%d", repeats, success.Load(), fail.Load())) - blockCount := ux.Doc.GetBlockCount() - for ret := range retCh { - if ret { - success.Add(1) - } else { - fail.Add(1) } - text := fmt.Sprintf("creating, total:%d, success:%d, fail:%d", repeats, success.Load(), fail.Load()) - if blockCount != ux.Doc.GetBlockCount() { - block = ux.NewBlock() - ux.Doc.Append(block) - block.Append(text) - blockCount = ux.Doc.GetBlockCount() - } else { - block.Update(text, 0) - } - if repeats == int(success.Load())+int(fail.Load()) && fail.Load() > 0 { - fmt.Printf("Check logs in %s\n", base.GetLogFilePath()) + retCh <- repeatResult{success: success, err: resultErr} + logs = append(logs, fmt.Sprintf("index:%d, result:%t", idx, success)) + platform.LogInfo(logs...) + }(req, i) + } + + var success, fail int + var firstErr error + block := ui.NewBlock() + doc.Append(block) + block.Append(fmt.Sprintf("creating, total:%d, success:%d, fail:%d", repeats, success, fail)) + blockCount := doc.GetBlockCount() + for i := 0; i < repeats; i++ { + ret := <-retCh + if ret.success { + success++ + } else { + fail++ + if firstErr == nil { + firstErr = ret.err } } - }() + text := fmt.Sprintf("creating, total:%d, success:%d, fail:%d", repeats, success, fail) + if blockCount != doc.GetBlockCount() { + block = ui.NewBlock() + doc.Append(block) + block.Append(text) + blockCount = doc.GetBlockCount() + } else { + block.Update(text, 0) + } + } wg.Wait() - refresh.Do(fmt.Sprintf("finally, total:%d, success:%d, fail:%d", repeats, success.Load(), repeats-int(success.Load()))) + if fail > 0 { + fmt.Fprintf(out, "Check logs in %s\n", platform.GetLogFilePath()) + } + refresh.Do(fmt.Sprintf("finally, total:%d, success:%d, fail:%d", repeats, success, fail)) + if firstErr != nil { + return fmt.Errorf("repeat API %s failed: %w", action, firstErr) + } return nil } diff --git a/cmd/api_repeats_ulhost.go b/cmd/api_repeats_ulhost.go new file mode 100644 index 0000000000..ef694054ba --- /dev/null +++ b/cmd/api_repeats_ulhost.go @@ -0,0 +1,33 @@ +package cmd + +import ( + "io" + + "github.com/ucloud/ucloud-sdk-go/services/ucompshare" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newULHostPoller supports `ucloud api --Action CreateULHostInstance --repeats`. +func newULHostPoller(out io.Writer) cli.Poller { + return cli.NewPoller(sdescribeULHostByID, out) +} + +func sdescribeULHostByID(ulhostID string, common *request.CommonBase) (interface{}, error) { + client := newServiceClient(ucompshare.NewClient) + req := client.NewDescribeULHostInstanceRequest() + req.ULHostIds = []string{ulhostID} + if common != nil { + req.CommonBase = *common + } + resp, err := client.DescribeULHostInstance(req) + if err != nil { + return nil, err + } + if len(resp.ULHostInstanceSets) < 1 { + return nil, nil + } + + return &resp.ULHostInstanceSets[0], nil +} diff --git a/cmd/api_test.go b/cmd/api_test.go new file mode 100644 index 0000000000..a12cebe3a7 --- /dev/null +++ b/cmd/api_test.go @@ -0,0 +1,100 @@ +package cmd + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/ucloud/ucloud-cli/cmd/internal/platform" +) + +func testIntPtr(i int) *int { return &i } + +func withTestRuntime(t *testing.T, baseURL string) { + t.Helper() + t.Setenv("COMP_LINE", "1") + + oldRuntime, oldAutoStub := activeRuntime, runtimeAutoStub + oldConfig, oldClientConfig, oldCredential := platform.ConfigIns, platform.ClientConfig, platform.AuthCredential + t.Cleanup(func() { + activeRuntime, runtimeAutoStub = oldRuntime, oldAutoStub + platform.ConfigIns, platform.ClientConfig, platform.AuthCredential = oldConfig, oldClientConfig, oldCredential + }) + + ac := &platform.AggConfig{ + Profile: "test", + Active: true, + ProjectID: "org-test", + Region: "cn-bj2", + Zone: "cn-bj2-03", + BaseURL: baseURL, + Timeout: platform.DefaultTimeoutSec, + MaxRetryTimes: testIntPtr(0), + PublicKey: "pub", + PrivateKey: "pri", + } + sdkConfig, credConfig, err := platform.BuildClientRuntime(ac) + if err != nil { + t.Fatalf("BuildClientRuntime returned error: %v", err) + } + activeRuntime = &runtimeState{ + Config: ac, + SDKConfig: sdkConfig, + Credential: credConfig, + } + runtimeAutoStub = false +} + +func TestGenericInvokeRepeatWrapperReturnsErrorOnCreateFailure(t *testing.T) { + gateway := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"Action":"CreateULHostInstanceResponse","RetCode":8010,"Message":"boom"}`) + })) + t.Cleanup(gateway.Close) + withTestRuntime(t, gateway.URL) + + var out bytes.Buffer + err := genericInvokeRepeatWrapper(&RepeatsConfig{IDInResp: "ULHostId"}, map[string]interface{}{ + ActionField: "CreateULHostInstance", + "Region": "cn-bj2", + }, "CreateULHostInstance", 1, 1, &out) + if err == nil { + t.Fatalf("expected repeat wrapper to return an error on create failure, output: %s", out.String()) + } + if !strings.Contains(out.String(), "boom") { + t.Fatalf("expected output to include API error detail, got: %s", out.String()) + } + if !strings.Contains(out.String(), "finally, total:1, success:0, fail:1") { + t.Fatalf("expected final summary to report one failure, got: %s", out.String()) + } +} + +func TestNewCmdAPIReturnsErrorOnRepeatFailure(t *testing.T) { + gateway := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"Action":"CreateULHostInstanceResponse","RetCode":8010,"Message":"boom"}`) + })) + t.Cleanup(gateway.Close) + withTestRuntime(t, gateway.URL) + + var out bytes.Buffer + cmd := NewCmdAPI(&out) + if cmd.RunE == nil { + t.Fatal("api command must expose RunE so direct-run callers can preserve a non-zero exit code") + } + err := cmd.RunE(cmd, []string{ + "--Action", "CreateULHostInstance", + "--Region", "cn-bj2", + "--repeats", "1", + "--concurrent", "1", + }) + if err == nil { + t.Fatalf("expected api RunE to return repeat failure, output: %s", out.String()) + } + if !strings.Contains(out.String(), "boom") { + t.Fatalf("expected output to include API error detail, got: %s", out.String()) + } +} diff --git a/cmd/callback.go b/cmd/callback.go index 0fc4ce1fdc..9142099198 100644 --- a/cmd/callback.go +++ b/cmd/callback.go @@ -7,12 +7,12 @@ import ( "net/http" "sync" - "github.com/ucloud/ucloud-cli/base" + "github.com/ucloud/ucloud-cli/cmd/internal/platform" ) // allocateLoopbackListener 在回环地址上取一个内核分配的空闲端口(>=1024),返回 listener 与端口。 func allocateLoopbackListener() (net.Listener, int, error) { - ln, err := net.Listen("tcp", base.LoopbackListenHost+":0") + ln, err := net.Listen("tcp", platform.LoopbackListenHost+":0") if err != nil { return nil, 0, fmt.Errorf("cannot open a local callback port: %v", err) } @@ -47,7 +47,7 @@ func startCallbackServer(ln net.Listener, expectState string) (*http.Server, <-c srv := &http.Server{} mux := http.NewServeMux() - mux.HandleFunc(base.OAuthRedirectPath, func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc(platform.OAuthRedirectPath, func(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() if e := q.Get("error"); e != "" { diff --git a/cmd/configure.go b/cmd/configure.go index 26f0c740f7..9bd82c6273 100644 --- a/cmd/configure.go +++ b/cmd/configure.go @@ -24,7 +24,7 @@ import ( sdk "github.com/ucloud/ucloud-sdk-go/ucloud" uerr "github.com/ucloud/ucloud-sdk-go/ucloud/error" - "github.com/ucloud/ucloud-cli/base" + "github.com/ucloud/ucloud-cli/cmd/internal/platform" "github.com/ucloud/ucloud-cli/pkg/command" ) @@ -47,19 +47,19 @@ func NewCmdInit() *cobra.Command { Short: "Initialize UCloud CLI options", Long: `Initialize UCloud CLI options such as private-key,public-key,default region,zone and project.`, Run: func(cmd *cobra.Command, args []string) { - fromOAuth := base.ConfigIns.AuthMode == base.AuthModeOAuth + fromOAuth := platform.ConfigIns.AuthMode == platform.AuthModeOAuth if fromOAuth { - ok := base.Confirm(false, fmt.Sprintf("Profile '%s' currently uses OAuth login (auth_mode=oauth). Continue with AK/SK setup and switch this profile to key-based auth? (y/n):", base.ConfigIns.Profile)) + ok := platform.Confirm(false, fmt.Sprintf("Profile '%s' currently uses OAuth login (auth_mode=oauth). Continue with AK/SK setup and switch this profile to key-based auth? (y/n):", platform.ConfigIns.Profile)) if !ok { return } - clearOAuthState(base.ConfigIns) + clearOAuthState(platform.ConfigIns) } - if base.ConfigIns.PrivateKey != "" && base.ConfigIns.PublicKey != "" { + if platform.ConfigIns.PrivateKey != "" && platform.ConfigIns.PublicKey != "" { if fromOAuth { - if err := switchProfileToAKSK(base.ConfigIns); err != nil { - base.HandleError(err) + if err := switchProfileToAKSK(platform.ConfigIns); err != nil { + platform.HandleError(err) return } } @@ -68,11 +68,11 @@ func NewCmdInit() *cobra.Command { } fmt.Println(configDesc) - base.ConfigIns.ConfigPublicKey() - base.ConfigIns.ConfigPrivateKey() - base.ConfigIns.ConfigBaseURL() + platform.ConfigIns.ConfigPublicKey() + platform.ConfigIns.ConfigPrivateKey() + platform.ConfigIns.ConfigBaseURL() - region, err := fetchRegionWithConfig(base.ConfigIns) + region, err := fetchRegionWithConfig(platform.ConfigIns) if err != nil { if uErr, ok := err.(uerr.Error); ok { if uErr.Code() == 172 { @@ -83,35 +83,35 @@ func NewCmdInit() *cobra.Command { fmt.Println(err) return } - base.ConfigIns.Region = region.DefaultRegion - base.ConfigIns.Zone = region.DefaultZone + platform.ConfigIns.Region = region.DefaultRegion + platform.ConfigIns.Zone = region.DefaultZone fmt.Printf("Configured default region:%s zone:%s\n", region.DefaultRegion, region.DefaultZone) - projectID, projectName, err := getDefaultProjectWithConfig(base.ConfigIns) + projectID, projectName, err := getDefaultProjectWithConfig(platform.ConfigIns) if err != nil && !errors.Is(err, errNoDefaultProject) { - base.HandleError(err) + platform.HandleError(err) return } if projectID != "" && projectName != "" { - base.ConfigIns.ProjectID = projectID + platform.ConfigIns.ProjectID = projectID fmt.Printf("Configured default project:%s %s\n", projectID, projectName) } else { fmt.Println("No default project, skip.") } - base.ConfigIns.Timeout = base.DefaultTimeoutSec - base.ConfigIns.BaseURL = base.DefaultBaseURL - base.ConfigIns.MaxRetryTimes = sdk.Int(base.DefaultMaxRetryTimes) - base.ConfigIns.Active = true - fmt.Printf("Configured default base url:%s\n", base.ConfigIns.BaseURL) - fmt.Printf("Configured default timeout_sec:%ds\n", base.ConfigIns.Timeout) - fmt.Printf("Active profile name:%s\n", base.ConfigIns.Profile) + 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) + fmt.Printf("Configured default timeout_sec:%ds\n", platform.ConfigIns.Timeout) + fmt.Printf("Active profile name:%s\n", platform.ConfigIns.Profile) fmt.Println("You can change the default settings by running 'ucloud config update'") - base.ConfigIns.ConfigUploadLog() - err = saveInitProfile(base.ConfigIns) + platform.ConfigIns.ConfigUploadLog() + err = saveInitProfile(platform.ConfigIns) if err != nil { - base.HandleError(fmt.Errorf("Error: %v", err)) + platform.HandleError(fmt.Errorf("Error: %v", err)) } else { - base.InitConfig() + platform.InitConfig() printHello() } }, @@ -121,12 +121,12 @@ func NewCmdInit() *cobra.Command { // saveInitProfile 持久化 init 完整配置流程的结果;profile 已存在时(OAuth-only profile // 切回 AK/SK 的场景)覆盖保存——依赖 ConfigIns 即 manager map 内的同一指针(InitConfig 保证) -func saveInitProfile(cfg *base.AggConfig) error { - return base.AggConfigListIns.UpdateAggConfig(cfg) +func saveInitProfile(cfg *platform.AggConfig) error { + return platform.AggConfigListIns.UpdateAggConfig(cfg) } // clearOAuthState 清除 profile 的 oauth 状态(口径与 'ucloud auth logout' 一致),不落盘 -func clearOAuthState(cfg *base.AggConfig) { +func clearOAuthState(cfg *platform.AggConfig) { cfg.AuthMode = "" cfg.AccessToken = "" cfg.RefreshToken = "" @@ -134,27 +134,27 @@ func clearOAuthState(cfg *base.AggConfig) { } // switchProfileToAKSK 把 OAuth profile 切回 AK/SK:清除 oauth 状态并落盘 -func switchProfileToAKSK(cfg *base.AggConfig) error { +func switchProfileToAKSK(cfg *platform.AggConfig) error { clearOAuthState(cfg) - return base.AggConfigListIns.UpdateAggConfig(cfg) + return platform.AggConfigListIns.UpdateAggConfig(cfg) } func printHello() { userInfo, err := getUserInfo() if err != nil { - base.Cxt.PrintErr(err) + platform.Cxt.PrintErr(err) return } - base.Cxt.Printf("You are logged in as: [%s]\n", userInfo.UserEmail) + platform.Cxt.Printf("You are logged in as: [%s]\n", userInfo.UserEmail) certified := isUserCertified(userInfo) if !certified { - base.Cxt.Println("\nWarning: Please authenticate the account with your valid documentation at 'https://accountv2.ucloud.cn/authentication'.") + platform.Cxt.Println("\nWarning: Please authenticate the account with your valid documentation at 'https://accountv2.ucloud.cn/authentication'.") } - base.Cxt.Println(helloUcloud) + platform.Cxt.Println(helloUcloud) } // 根据用户设置的region和zone,检查其合法性,补上缺失的部分,给出一个合理的符合用户本意设置的region和zone -func getReasonableRegionZone(cfg *base.AggConfig) (string, string, error) { +func getReasonableRegionZone(cfg *platform.AggConfig) (string, string, error) { userRegion := cfg.Region userZone := cfg.Zone //如果zone设置了,region不能为空,因为这种情况较难判断给出一个合理的region @@ -197,7 +197,7 @@ func getReasonableRegionZone(cfg *base.AggConfig) (string, string, error) { // NewCmdConfig ucloud config func NewCmdConfig() *cobra.Command { var active, upload string - cfg := base.AggConfig{} + cfg := platform.AggConfig{} cmd := &cobra.Command{ Use: "config", Short: "add or update configurations", @@ -210,15 +210,15 @@ func NewCmdConfig() *cobra.Command { } if cfg.Timeout < 0 { - base.HandleError(fmt.Errorf("timeout_sec must be greater than 0, accept %d", cfg.Timeout)) + platform.HandleError(fmt.Errorf("timeout_sec must be greater than 0, accept %d", cfg.Timeout)) return } //cacheConfig AggConfig read from $HOME/.ucloud/config.json+credential.json or empty shell - cacheConfig, ok := base.AggConfigListIns.GetAggConfigByProfile(cfg.Profile) + cacheConfig, ok := platform.AggConfigListIns.GetAggConfigByProfile(cfg.Profile) //如果配置文件中找不到该profile 则添加配置 if !ok { - cacheConfig = &base.AggConfig{ + cacheConfig = &platform.AggConfig{ PrivateKey: cfg.PrivateKey, PublicKey: cfg.PublicKey, Profile: cfg.Profile, @@ -241,7 +241,7 @@ func NewCmdConfig() *cobra.Command { if cfg.BaseURL == "" { if cacheConfig.BaseURL == "" { - cacheConfig.BaseURL = base.DefaultBaseURL + cacheConfig.BaseURL = platform.DefaultBaseURL } } else { cacheConfig.BaseURL = cfg.BaseURL @@ -249,7 +249,7 @@ func NewCmdConfig() *cobra.Command { if cfg.Timeout == 0 { if cacheConfig.Timeout == 0 { - cacheConfig.Timeout = base.DefaultTimeoutSec + cacheConfig.Timeout = platform.DefaultTimeoutSec } } else { cacheConfig.Timeout = cfg.Timeout @@ -257,7 +257,7 @@ func NewCmdConfig() *cobra.Command { if *cfg.MaxRetryTimes == 0 { if *cacheConfig.MaxRetryTimes == 0 { - cacheConfig.MaxRetryTimes = sdk.Int(base.DefaultMaxRetryTimes) + cacheConfig.MaxRetryTimes = sdk.Int(platform.DefaultMaxRetryTimes) } } else { cacheConfig.MaxRetryTimes = cfg.MaxRetryTimes @@ -273,7 +273,7 @@ func NewCmdConfig() *cobra.Command { //确保设置的Region和Zone真实存在 region, zone, err := getReasonableRegionZone(cacheConfig) if err != nil { - base.HandleError(fmt.Errorf("verify region failed: %v", err)) + platform.HandleError(fmt.Errorf("verify region failed: %v", err)) } else { cacheConfig.Region = region cacheConfig.Zone = zone @@ -285,13 +285,13 @@ func NewCmdConfig() *cobra.Command { if cacheConfig.ProjectID == "" { id, _, err := getDefaultProjectWithConfig(cacheConfig) if err != nil { - base.HandleError(fmt.Errorf("fetch default project failed: %v", err)) + platform.HandleError(fmt.Errorf("fetch default project failed: %v", err)) } else { cacheConfig.ProjectID = id } } } else { - cfg.ProjectID = base.PickResourceID(cfg.ProjectID) + cfg.ProjectID = platform.PickResourceID(cfg.ProjectID) projects, err := fetchProjectWithConfig(cacheConfig) if err != nil { cacheConfig.ProjectID = cfg.ProjectID @@ -299,9 +299,9 @@ func NewCmdConfig() *cobra.Command { if ok := projects[cfg.ProjectID]; ok { cacheConfig.ProjectID = cfg.ProjectID } else { - base.HandleError(fmt.Errorf("project %s you assigned not exists", cfg.ProjectID)) + platform.HandleError(fmt.Errorf("project %s you assigned not exists", cfg.ProjectID)) if ok := projects[cacheConfig.ProjectID]; !ok { - base.HandleError(fmt.Errorf("project %s not exists, assign another one please", cacheConfig.ProjectID)) + platform.HandleError(fmt.Errorf("project %s not exists, assign another one please", cacheConfig.ProjectID)) } } } @@ -313,7 +313,7 @@ func NewCmdConfig() *cobra.Command { } else if active == "false" { cacheConfig.Active = false } else { - base.HandleError(fmt.Errorf("flag active should be true or false. received %s", active)) + platform.HandleError(fmt.Errorf("flag active should be true or false. received %s", active)) } } @@ -323,13 +323,13 @@ func NewCmdConfig() *cobra.Command { } else if upload == "false" { cacheConfig.AgreeUploadLog = false } else { - base.HandleError(fmt.Errorf("flag agree-upload-log should be true or false. received %s", active)) + platform.HandleError(fmt.Errorf("flag agree-upload-log should be true or false. received %s", active)) } } - err = base.AggConfigListIns.UpdateAggConfig(cacheConfig) + err = platform.AggConfigListIns.UpdateAggConfig(cacheConfig) if err != nil { - base.HandleError(err) + platform.HandleError(err) } }, } @@ -349,7 +349,7 @@ func NewCmdConfig() *cobra.Command { command.SetFlagValues(cmd, "active", "true", "false") command.SetFlagValues(cmd, "agree-upload-log", "true", "false") - command.SetCompletion(cmd, "profile", func() []string { return base.AggConfigListIns.GetProfileNameList() }) + command.SetCompletion(cmd, "profile", func() []string { return platform.AggConfigListIns.GetProfileNameList() }) command.SetCompletion(cmd, "region", getRegionList) command.SetCompletion(cmd, "project-id", getProjectList) command.SetCompletion(cmd, "zone", func() []string { @@ -366,7 +366,7 @@ func NewCmdConfig() *cobra.Command { // NewCmdConfigAdd ucloud config add func NewCmdConfigAdd() *cobra.Command { var active, upload string - cfg := &base.AggConfig{} + cfg := &platform.AggConfig{} cmd := &cobra.Command{ Use: "add", Short: "add configuration", @@ -374,19 +374,19 @@ func NewCmdConfigAdd() *cobra.Command { Run: func(c *cobra.Command, args []string) { region, zone, err := getReasonableRegionZone(cfg) if err != nil { - base.HandleError(err) + platform.HandleError(err) } cfg.Region = region cfg.Zone = zone project, err := getReasonableProject(cfg) if err != nil { - base.HandleError(err) + platform.HandleError(err) } cfg.ProjectID = project if cfg.Timeout <= 0 { - base.HandleError(fmt.Errorf("timeout_sec must be greater than 0, accept %d", cfg.Timeout)) + platform.HandleError(fmt.Errorf("timeout_sec must be greater than 0, accept %d", cfg.Timeout)) return } @@ -406,9 +406,9 @@ func NewCmdConfigAdd() *cobra.Command { fmt.Printf("agree-upload-log should be true or false, received %s\n", active) } - err = base.AggConfigListIns.Append(cfg) + err = platform.AggConfigListIns.Append(cfg) if err != nil { - base.HandleError(err) + platform.HandleError(err) return } }, @@ -422,15 +422,15 @@ func NewCmdConfigAdd() *cobra.Command { flags.StringVar(&cfg.Region, "region", "", "Optional. Set default region. For instance 'cn-bj2' See 'ucloud region'") 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", base.DefaultBaseURL, "Optional. Set default base url. For instance 'https://api.ucloud.cn/'") - flags.IntVar(&cfg.Timeout, "timeout-sec", base.DefaultTimeoutSec, "Optional. Set default timeout for requesting API. Unit: seconds") - cfg.MaxRetryTimes = flags.Int("max-retry-times", base.DefaultMaxRetryTimes, "Optional. Set default max-retry-times for idempotent APIs which can be called many times without side effect, for example 'ReleaseEIP'") + flags.StringVar(&cfg.BaseURL, "base-url", platform.DefaultBaseURL, "Optional. Set default base url. For instance 'https://api.ucloud.cn/'") + 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") flags.StringVar(&upload, "agree-upload-log", "false", "Optional. Agree to upload log in local file ~/.ucloud/cli.log or not. Accept valeus: true or false") command.SetFlagValues(cmd, "active", "true", "false") command.SetFlagValues(cmd, "agree-upload-log", "true", "false") - command.SetCompletion(cmd, "profile", func() []string { return base.AggConfigListIns.GetProfileNameList() }) + command.SetCompletion(cmd, "profile", func() []string { return platform.AggConfigListIns.GetProfileNameList() }) command.SetCompletion(cmd, "region", getRegionList) command.SetCompletion(cmd, "project-id", getProjectList) command.SetCompletion(cmd, "zone", func() []string { @@ -447,16 +447,16 @@ func NewCmdConfigAdd() *cobra.Command { // NewCmdConfigUpdate ucloud config update func NewCmdConfigUpdate() *cobra.Command { var timeout, active, maxRetries, upload string - cfg := &base.AggConfig{} + cfg := &platform.AggConfig{} cmd := &cobra.Command{ Use: "update", Short: "update configurations", Long: "update configurations", Run: func(c *cobra.Command, args []string) { //cacheConfig AggConfig read from $HOME/.ucloud/config.json+credential.json or empty shell - cacheConfig, ok := base.AggConfigListIns.GetAggConfigByProfile(cfg.Profile) + cacheConfig, ok := platform.AggConfigListIns.GetAggConfigByProfile(cfg.Profile) if !ok { - base.HandleError(fmt.Errorf("profile %s not exist", cfg.Profile)) + platform.HandleError(fmt.Errorf("profile %s not exist", cfg.Profile)) return } @@ -469,7 +469,7 @@ func NewCmdConfigUpdate() *cobra.Command { //如果配置了公私钥,则先更新让其生效, 为接下来拉取Region,Zone做准备 if cfg.PrivateKey != "" || cfg.PublicKey != "" { - base.AggConfigListIns.UpdateAggConfig(cacheConfig) + platform.AggConfigListIns.UpdateAggConfig(cacheConfig) } //先应用连接类参数(base-url/timeout-sec/max-retry-times),确保接下来的远程校验 @@ -481,28 +481,28 @@ func NewCmdConfigUpdate() *cobra.Command { if timeout != "" { seconds, err := strconv.Atoi(timeout) if err != nil { - base.HandleError(fmt.Errorf("parse timeout-sec failed: %v", err)) + platform.HandleError(fmt.Errorf("parse timeout-sec failed: %v", err)) return } cacheConfig.Timeout = seconds } if cacheConfig.Timeout <= 0 { - base.HandleError(fmt.Errorf("timeout-sec must be greater than 0, accept %d", cfg.Timeout)) + platform.HandleError(fmt.Errorf("timeout-sec must be greater than 0, accept %d", cfg.Timeout)) return } if maxRetries != "" { times, err := strconv.Atoi(maxRetries) if err != nil { - base.HandleError(fmt.Errorf("parse max-retry-times failed: %v", err)) + platform.HandleError(fmt.Errorf("parse max-retry-times failed: %v", err)) return } cacheConfig.MaxRetryTimes = × } if *cacheConfig.MaxRetryTimes < 0 { - base.HandleError(fmt.Errorf("max-retry-timesc must be greater than or equal to 0, accept %d", cfg.MaxRetryTimes)) + platform.HandleError(fmt.Errorf("max-retry-timesc must be greater than or equal to 0, accept %d", cfg.MaxRetryTimes)) return } @@ -516,7 +516,7 @@ func NewCmdConfigUpdate() *cobra.Command { region, zone, err := getReasonableRegionZone(cacheConfig) if err != nil { - base.HandleError(err) + platform.HandleError(err) return } @@ -524,12 +524,12 @@ func NewCmdConfigUpdate() *cobra.Command { cacheConfig.Zone = zone if cfg.ProjectID != "" { - cacheConfig.ProjectID = base.PickResourceID(cfg.ProjectID) + cacheConfig.ProjectID = platform.PickResourceID(cfg.ProjectID) } project, err := getReasonableProject(cacheConfig) if err != nil { - base.HandleError(err) + platform.HandleError(err) } cacheConfig.ProjectID = project @@ -545,9 +545,9 @@ func NewCmdConfigUpdate() *cobra.Command { cacheConfig.AgreeUploadLog = false } - err = base.AggConfigListIns.UpdateAggConfig(cacheConfig) + err = platform.AggConfigListIns.UpdateAggConfig(cacheConfig) if err != nil { - base.HandleError(err) + platform.HandleError(err) } }, } @@ -566,7 +566,7 @@ func NewCmdConfigUpdate() *cobra.Command { flags.StringVar(&active, "active", "", "Optional. Mark the profile to be effective") flags.StringVar(&upload, "agree-upload-log", "", "Optional. Agree to upload log in local file ~/.ucloud/cli.log or not. Accept valeus: true or false") - command.SetCompletion(cmd, "profile", func() []string { return base.AggConfigListIns.GetProfileNameList() }) + command.SetCompletion(cmd, "profile", func() []string { return platform.AggConfigListIns.GetProfileNameList() }) command.SetCompletion(cmd, "region", getRegionList) command.SetCompletion(cmd, "project-id", getProjectList) command.SetCompletion(cmd, "zone", func() []string { @@ -587,7 +587,7 @@ func NewCmdConfigList() *cobra.Command { Short: "list all configurations", Long: `list all configurations`, Run: func(c *cobra.Command, args []string) { - base.ListAggConfig(global.JSON) + platform.ListAggConfig(global.JSON) }, } return cmd @@ -602,7 +602,7 @@ func NewCmdConfigDelete() *cobra.Command { Long: "delete configurations by profile name", Example: "ucloud config delete --profile test", Run: func(c *cobra.Command, args []string) { - profiles := base.AggConfigListIns.GetProfileNameList() + profiles := platform.AggConfigListIns.GetProfileNameList() allProfileMap := make(map[string]bool) for _, p := range profiles { allProfileMap[p] = true @@ -610,18 +610,18 @@ func NewCmdConfigDelete() *cobra.Command { for _, p := range profileList { if allProfileMap[p] { - err := base.AggConfigListIns.DeleteByProfile(p) + err := platform.AggConfigListIns.DeleteByProfile(p) if err != nil { - base.HandleError(err) + platform.HandleError(err) } } else { - base.HandleError(fmt.Errorf("profile %s does not exist", p)) + platform.HandleError(fmt.Errorf("profile %s does not exist", p)) } } }, } cmd.Flags().StringSliceVar(&profileList, "profile", nil, "Required. Name of settings item") cmd.MarkFlagRequired("profile") - command.SetCompletion(cmd, "profile", func() []string { return base.AggConfigListIns.GetProfileNameList() }) + command.SetCompletion(cmd, "profile", func() []string { return platform.AggConfigListIns.GetProfileNameList() }) return cmd } diff --git a/cmd/configure_test.go b/cmd/configure_test.go index 508049f3ce..e90cd82d9b 100644 --- a/cmd/configure_test.go +++ b/cmd/configure_test.go @@ -10,7 +10,7 @@ import ( "strings" "testing" - "github.com/ucloud/ucloud-cli/base" + "github.com/ucloud/ucloud-cli/cmd/internal/platform" ) // 回归:oauth profile 已存 AK/SK 时(auth login 保留密钥的常见形态), @@ -21,14 +21,14 @@ func TestSwitchProfileToAKSKPersistsToDisk(t *testing.T) { credPath := filepath.Join(dir, "credential.json") cliJSON := `[{"profile":"oa","active":true,"region":"cn-bj2","zone":"cn-bj2-04","base_url":"https://api.ucloud.cn/","timeout_sec":15,"max_retry_times":3}]` credJSON := `[{"public_key":"pub","private_key":"pri","profile":"oa","auth_mode":"oauth","access_token":"at","refresh_token":"rt","expires_at":1234567890}]` - if err := ioutil.WriteFile(cfgPath, []byte(cliJSON), base.LocalFileMode); err != nil { + if err := ioutil.WriteFile(cfgPath, []byte(cliJSON), platform.LocalFileMode); err != nil { t.Fatal(err) } - if err := ioutil.WriteFile(credPath, []byte(credJSON), base.LocalFileMode); err != nil { + if err := ioutil.WriteFile(credPath, []byte(credJSON), platform.LocalFileMode); err != nil { t.Fatal(err) } - m, err := base.NewAggConfigManager(cfgPath, credPath) + m, err := platform.NewAggConfigManager(cfgPath, credPath) if err != nil { t.Fatal(err) } @@ -37,16 +37,16 @@ func TestSwitchProfileToAKSKPersistsToDisk(t *testing.T) { t.Fatal("profile oa missing") } - oldM, oldC := base.AggConfigListIns, base.ConfigIns - base.AggConfigListIns, base.ConfigIns = m, cfg - defer func() { base.AggConfigListIns, base.ConfigIns = oldM, oldC }() + oldM, oldC := platform.AggConfigListIns, platform.ConfigIns + platform.AggConfigListIns, platform.ConfigIns = m, cfg + defer func() { platform.AggConfigListIns, platform.ConfigIns = oldM, oldC }() if err := switchProfileToAKSK(cfg); err != nil { t.Fatal(err) } // 重新读盘验证持久化,而非只看内存 - m2, err := base.NewAggConfigManager(cfgPath, credPath) + m2, err := platform.NewAggConfigManager(cfgPath, credPath) if err != nil { t.Fatal(err) } @@ -93,23 +93,23 @@ func TestConfigUpdateAppliesBaseURLBeforeValidation(t *testing.T) { // 存量 base_url 指向必然连不通的地址,复现坏网关现场 cliJSON := `[{"profile":"up","active":true,"project_id":"org-123","region":"cn-bj2","zone":"cn-bj2-04","base_url":"http://127.0.0.1:1/","timeout_sec":3,"max_retry_times":0}]` credJSON := `[{"public_key":"pub","private_key":"pri","profile":"up"}]` - if err := ioutil.WriteFile(cfgPath, []byte(cliJSON), base.LocalFileMode); err != nil { + if err := ioutil.WriteFile(cfgPath, []byte(cliJSON), platform.LocalFileMode); err != nil { t.Fatal(err) } - if err := ioutil.WriteFile(credPath, []byte(credJSON), base.LocalFileMode); err != nil { + if err := ioutil.WriteFile(credPath, []byte(credJSON), platform.LocalFileMode); err != nil { t.Fatal(err) } - m, err := base.NewAggConfigManager(cfgPath, credPath) + m, err := platform.NewAggConfigManager(cfgPath, credPath) if err != nil { t.Fatal(err) } // GetBizClient 会改写包级全局 ClientConfig/AuthCredential,恢复现场避免测试顺序耦合 - oldM, oldCC, oldAC := base.AggConfigListIns, base.ClientConfig, base.AuthCredential - base.AggConfigListIns = m + oldM, oldCC, oldAC := platform.AggConfigListIns, platform.ClientConfig, platform.AuthCredential + platform.AggConfigListIns = m defer func() { - base.AggConfigListIns, base.ClientConfig, base.AuthCredential = oldM, oldCC, oldAC + platform.AggConfigListIns, platform.ClientConfig, platform.AuthCredential = oldM, oldCC, oldAC }() cmd := NewCmdConfigUpdate() @@ -122,7 +122,7 @@ func TestConfigUpdateAppliesBaseURLBeforeValidation(t *testing.T) { cmd.Run(cmd, nil) // 重新读盘验证持久化,而非只看内存 - m2, err := base.NewAggConfigManager(cfgPath, credPath) + m2, err := platform.NewAggConfigManager(cfgPath, credPath) if err != nil { t.Fatal(err) } @@ -144,14 +144,14 @@ func TestInitSaveOverwritesExistingOAuthOnlyProfile(t *testing.T) { credPath := filepath.Join(dir, "credential.json") cliJSON := `[{"profile":"oa","active":true,"base_url":"https://api.ucloud.cn/","timeout_sec":15,"max_retry_times":3}]` credJSON := `[{"public_key":"","private_key":"","profile":"oa","auth_mode":"oauth","access_token":"at","refresh_token":"rt","expires_at":1234567890}]` - if err := ioutil.WriteFile(cfgPath, []byte(cliJSON), base.LocalFileMode); err != nil { + if err := ioutil.WriteFile(cfgPath, []byte(cliJSON), platform.LocalFileMode); err != nil { t.Fatal(err) } - if err := ioutil.WriteFile(credPath, []byte(credJSON), base.LocalFileMode); err != nil { + if err := ioutil.WriteFile(credPath, []byte(credJSON), platform.LocalFileMode); err != nil { t.Fatal(err) } - m, err := base.NewAggConfigManager(cfgPath, credPath) + m, err := platform.NewAggConfigManager(cfgPath, credPath) if err != nil { t.Fatal(err) } @@ -160,9 +160,9 @@ func TestInitSaveOverwritesExistingOAuthOnlyProfile(t *testing.T) { t.Fatal("profile oa missing") } - oldM, oldC := base.AggConfigListIns, base.ConfigIns - base.AggConfigListIns, base.ConfigIns = m, cfg - defer func() { base.AggConfigListIns, base.ConfigIns = oldM, oldC }() + oldM, oldC := platform.AggConfigListIns, platform.ConfigIns + platform.AggConfigListIns, platform.ConfigIns = m, cfg + defer func() { platform.AggConfigListIns, platform.ConfigIns = oldM, oldC }() // 模拟 NewCmdInit Run 完整配置路径对 ConfigIns(即 manager map 内同一指针)的写入 clearOAuthState(cfg) @@ -171,8 +171,8 @@ func TestInitSaveOverwritesExistingOAuthOnlyProfile(t *testing.T) { cfg.Region = "cn-bj2" cfg.Zone = "cn-bj2-04" cfg.ProjectID = "org-new" - cfg.Timeout = base.DefaultTimeoutSec - cfg.BaseURL = base.DefaultBaseURL + cfg.Timeout = platform.DefaultTimeoutSec + cfg.BaseURL = platform.DefaultBaseURL cfg.Active = true if err := saveInitProfile(cfg); err != nil { @@ -180,7 +180,7 @@ func TestInitSaveOverwritesExistingOAuthOnlyProfile(t *testing.T) { } // 重新读盘验证持久化,而非只看内存 - m2, err := base.NewAggConfigManager(cfgPath, credPath) + m2, err := platform.NewAggConfigManager(cfgPath, credPath) if err != nil { t.Fatal(err) } diff --git a/cmd/doc_md.go b/cmd/doc_md.go index 484751298b..f689609346 100644 --- a/cmd/doc_md.go +++ b/cmd/doc_md.go @@ -24,7 +24,7 @@ import ( "github.com/ucloud/ucloud-sdk-go/ucloud/log" - "github.com/ucloud/ucloud-cli/base" + "github.com/ucloud/ucloud-cli/cmd/internal/platform" "github.com/ucloud/ucloud-cli/internal/common" "github.com/ucloud/ucloud-cli/pkg/command" ) @@ -37,9 +37,9 @@ func NewCmdDoc(out io.Writer) *cobra.Command { Short: "Generate documents for all commands", Long: "Generate documents for all commands. Support markdown, rst and douku", Run: func(c *cobra.Command, args []string) { - base.ConfigIns.Region = "" - base.ConfigIns.ProjectID = "" - base.ConfigIns.Zone = "" + platform.ConfigIns.Region = "" + platform.ConfigIns.ProjectID = "" + platform.ConfigIns.Zone = "" rootCmd := NewCmdRoot() addChildren(rootCmd) switch format { diff --git a/cmd/ext.go b/cmd/ext.go deleted file mode 100644 index 46ac7d36fc..0000000000 --- a/cmd/ext.go +++ /dev/null @@ -1,344 +0,0 @@ -// Copyright © 2018 NAME HERE tony.li@ucloud.cn -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package cmd - -import ( - "errors" - "fmt" - "net" - "strings" - - "github.com/spf13/cobra" - - "github.com/ucloud/ucloud-cli/base" - "github.com/ucloud/ucloud-cli/model/status" - "github.com/ucloud/ucloud-cli/pkg/command" - "github.com/ucloud/ucloud-sdk-go/services/uhost" - "github.com/ucloud/ucloud-sdk-go/services/unet" - sdk "github.com/ucloud/ucloud-sdk-go/ucloud" -) - -// NewCmdExt ucloud ext -func NewCmdExt() *cobra.Command { - cmd := &cobra.Command{ - Use: "ext", - Short: "extended commands of UCloud CLI", - Long: "extended commands of UCloud CLI", - } - cmd.AddCommand(NewCmdExtUHost()) - return cmd -} - -// NewCmdExtUHost ucloud ext uhost -func NewCmdExtUHost() *cobra.Command { - cmd := &cobra.Command{ - Use: "uhost", - Short: "extended uhost commands", - Long: "extended uhost commands", - } - cmd.AddCommand(NewCmdExtUHostSwitchEIP()) - return cmd -} - -// NewCmdExtUHostSwitchEIP ucloud ext uhost switch-eip -func NewCmdExtUHostSwitchEIP() *cobra.Command { - var project, region, zone, chargeType, trafficMode, shareBandwidthID string - var uhostIDs, eipAddrs []string - var eipBandwidth, quntity int - var unbind, release bool - - cmd := &cobra.Command{ - Use: "switch-eip", - Short: "Switch EIP for UHost instances", - Long: "Switch EIP for UHost instances", - Example: "ucloud ext uhost switch-eip --uhost-id uhost-1n1sxx2,uhost-li4jxx1 --create-eip-bandwidth-mb 2", - Run: func(c *cobra.Command, args []string) { - project = base.PickResourceID(project) - eipAddrMap := make(map[string]bool) - for _, addr := range eipAddrs { - eipAddrMap[addr] = true - } - logs := make([]string, 0) - for _, idname := range uhostIDs { - uhostID := base.PickResourceID(idname) - logs = append(logs, fmt.Sprintf("describe uhost instance by uhostID %s", uhostID)) - ins, err := extDescribeUHostByID(uhostID, project, region, zone) - if err != nil { - errStr := fmt.Sprintf("describe uhost %s failed: %v", uhostID, err) - base.HandleError(errors.New(errStr)) - logs = append(logs, errStr) - continue - } - uhostIns, ok := ins.(*uhost.UHostInstanceSet) - if !ok { - errStr := fmt.Sprintf("uhost %s does not exist", uhostID) - base.HandleError(errors.New(errStr)) - logs = append(logs, errStr) - continue - } - for _, ip := range uhostIns.IPSet { - if ip.IPId == "" { - continue - } - if len(eipAddrs) > 0 && eipAddrMap[ip.IP] == false { - continue - } - //申请EIP - req := base.BizClient.NewAllocateEIPRequest() - req.Region = ®ion - req.ProjectId = &project - if strings.HasPrefix(region, "cn") { - req.OperatorName = sdk.String("BGP") - } else { - req.OperatorName = sdk.String("International") - } - req.Bandwidth = &eipBandwidth - req.ChargeType = &chargeType - req.Quantity = &quntity - req.PayMode = &trafficMode - if trafficMode == "ShareBandwidth" { - if shareBandwidthID != "" { - req.ShareBandwidthId = &shareBandwidthID - } else { - errStr := "create-eip-share-bandwidth-id should not be empty when create-eip-traffic-mode is assigned 'ShareBandwidth'" - logs = append(logs, errStr) - base.HandleError(errors.New(errStr)) - return - } - } - logs = append(logs, fmt.Sprintf("api AllocateEIP, request:%v", base.ToQueryMap(req))) - resp, err := base.BizClient.AllocateEIP(req) - if err != nil { - errStr := fmt.Sprintf("allocate EIP failed: %v", err) - logs = append(logs, errStr) - base.HandleError(errors.New(errStr)) - continue - } - if len(resp.EIPSet) != 1 { - errStr := fmt.Sprintf("allocate EIP failed, length of eip set is not 1") - base.HandleError(errors.New(errStr)) - logs = append(logs, errStr) - continue - } - eipID := resp.EIPSet[0].EIPId - eipRet := fmt.Sprintf("allocated new eip %s|%s", eipID, resp.EIPSet[0].EIPAddr[0].IP) - logs = append(logs, eipRet) - fmt.Println(eipRet) - - //绑定新EIP - slogs, err2 := extAttachEIPWithLogs(&uhostID, sdk.String("uhost"), &eipID, &project, ®ion) - logs = append(logs, slogs...) - if err2 != nil { - base.HandleError(fmt.Errorf("bind new eip %s failed: %v", eipID, err2)) - continue - } - fmt.Printf("bound eip %s with uhost %s\n", eipID, uhostID) - - if unbind { - slogs, err := extDetachEIPWithLogs(uhostID, "uhost", ip.IPId, project, region) - logs = append(logs, slogs...) - if err != nil { - base.HandleError(fmt.Errorf("unbind eip %s failed: %v", ip.IPId, err)) - continue - } - fmt.Printf("unbound eip %s|%s with uhost %s\n", ip.IPId, ip.IP, uhostID) - } - - if release { - req := base.BizClient.NewReleaseEIPRequest() - req.ProjectId = &project - req.Region = ®ion - req.EIPId = sdk.String(ip.IPId) - logs = append(logs, fmt.Sprintf("api ReleaseEIP, request:%v", base.ToQueryMap(req))) - _, err := base.BizClient.ReleaseEIP(req) - if err != nil { - errStr := fmt.Sprintf("release eip %s failed: %v", ip.IPId, err) - logs = append(logs, errStr) - base.HandleError(errors.New(errStr)) - continue - } - releaseRet := fmt.Sprintf("released eip %s|%s", ip.IPId, ip.IP) - logs = append(logs, releaseRet) - fmt.Println(releaseRet) - } - base.LogInfo(logs...) - } - } - }, - } - - flags := cmd.Flags() - flags.SortFlags = false - - flags.StringSliceVar(&uhostIDs, "uhost-id", nil, "Required. Resource ID of uhost instances to switch EIP") - flags.StringSliceVar(&eipAddrs, "eip-addr", nil, "Optional. Address of EIP instances to be replaced. if eip-id is empty, replace all of the EIPs bound with the uhost ") - flags.BoolVar(&unbind, "unbind-all", true, "Optional. Unbind all EIP instances that has been replaced. Accept values:true or false") - flags.BoolVar(&release, "release-all", true, "Optional. Release all EIP instances that has been replaced. Accept values:true or false") - flags.IntVar(&eipBandwidth, "create-eip-bandwidth-mb", 1, "Optional. Bandwidth of EIP instance to be create with. Unit:Mb") - flags.StringVar(&trafficMode, "create-eip-traffic-mode", "Bandwidth", "Optional. traffic-mode is an enumeration value. 'Traffic','Bandwidth' or 'ShareBandwidth'") - flags.StringVar(&shareBandwidthID, "create-eip-share-bandwidth-id", "", "Optional. ShareBandwidthId, required only when traffic-mode is 'ShareBandwidth'") - flags.StringVar(&chargeType, "create-eip-charge-type", "Month", "Optional. Enumeration value.'Year',pay yearly;'Month',pay monthly;'Dynamic', pay hourly") - flags.IntVar(&quntity, "create-eip-quantity", 1, "Optional. The duration of the instance. N years/months.") - - command.SetFlagValues(cmd, "create-eip-traffic-mode", "Bandwidth", "Traffic", "ShareBandwidth") - command.SetFlagValues(cmd, "create-eip-charge-type", "Month", "Year", "Dynamic", "Trial") - - bindProjectIDS(&project, cmd) - bindRegionS(®ion, cmd) - bindZoneEmptyS(&zone, ®ion, cmd) - - command.SetCompletion(cmd, "uhost-id", func() []string { - return extUHostList([]string{status.HOST_RUNNING, status.HOST_STOPPED, status.HOST_FAIL}, project, region, zone) - }) - - cmd.MarkFlagRequired("uhost-id") - - return cmd -} - -func extDescribeUHostByID(uhostID, projectID, region, zone string) (interface{}, error) { - req := base.BizClient.NewDescribeUHostInstanceRequest() - req.UHostIds = []string{uhostID} - req.ProjectId = &projectID - req.Region = ®ion - req.Zone = &zone - - resp, err := base.BizClient.DescribeUHostInstance(req) - if err != nil { - return nil, err - } - if len(resp.UHostSet) < 1 { - return nil, fmt.Errorf("uhost [%s] does not exist", uhostID) - } - - return &resp.UHostSet[0], nil -} - -func extUHostList(states []string, project, region, zone string) []string { - req := base.BizClient.NewDescribeUHostInstanceRequest() - req.ProjectId = sdk.String(project) - req.Region = sdk.String(region) - req.Zone = sdk.String(zone) - req.Limit = sdk.Int(50) - resp, err := base.BizClient.DescribeUHostInstance(req) - if err != nil { - return nil - } - list := []string{} - for _, host := range resp.UHostSet { - if states != nil { - for _, s := range states { - if host.State == s { - list = append(list, host.UHostId+"/"+strings.Replace(host.Name, " ", "-", -1)) - } - } - } else { - list = append(list, host.UHostId+"/"+strings.Replace(host.Name, " ", "-", -1)) - } - } - return list -} - -func extEIPIDByIP(ip net.IP, projectID, region string) (string, error) { - eipList, err := extFetchAllEIP(projectID, region) - if err != nil { - return "", err - } - for _, eip := range eipList { - for _, addr := range eip.EIPAddr { - if addr.IP == ip.String() { - return eip.EIPId, nil - } - } - } - return "", fmt.Errorf("IP[%s] not exist", ip.String()) -} - -func extFetchAllEIP(projectID, region string) ([]unet.UnetEIPSet, error) { - req := base.BizClient.NewDescribeEIPRequest() - list := []unet.UnetEIPSet{} - req.ProjectId = sdk.String(projectID) - req.Region = sdk.String(region) - for offset, step := 0, 100; ; offset += step { - req.Offset = &offset - req.Limit = &step - resp, err := base.BizClient.DescribeEIP(req) - if err != nil { - return nil, err - } - for i, size := 0, len(resp.EIPSet); i < size; i++ { - list = append(list, resp.EIPSet[i]) - } - if resp.TotalCount <= offset+step { - break - } - } - return list, nil -} - -func extAttachEIPWithLogs(resourceID, resourceType, eipID, projectID, region *string) ([]string, error) { - logs := make([]string, 0) - ip := net.ParseIP(*eipID) - if ip != nil { - id, err := extEIPIDByIP(ip, *projectID, *region) - if err != nil { - base.HandleError(err) - } else { - *eipID = id - } - } - req := base.BizClient.NewBindEIPRequest() - req.ResourceId = resourceID - req.ResourceType = resourceType - req.EIPId = sdk.String(base.PickResourceID(*eipID)) - req.ProjectId = sdk.String(base.PickResourceID(*projectID)) - req.Region = region - logs = append(logs, fmt.Sprintf("api: BindEIP, request: %v", base.ToQueryMap(req))) - _, err := base.BizClient.BindEIP(req) - if err != nil { - logs = append(logs, fmt.Sprintf("bind eip failed: %v", err)) - return logs, err - } - logs = append(logs, fmt.Sprintf("bind eip[%s] with %s[%s] successfully", *req.EIPId, *req.ResourceType, *req.ResourceId)) - return logs, nil -} - -func extDetachEIPWithLogs(resourceID, resourceType, eipID, projectID, region string) ([]string, error) { - logs := make([]string, 0) - eipID = base.PickResourceID(eipID) - ip := net.ParseIP(eipID) - if ip != nil { - id, err := extEIPIDByIP(ip, projectID, region) - if err != nil { - base.HandleError(err) - } else { - eipID = id - } - } - req := base.BizClient.NewUnBindEIPRequest() - req.ResourceId = &resourceID - req.ResourceType = &resourceType - req.EIPId = &eipID - req.ProjectId = sdk.String(base.PickResourceID(projectID)) - req.Region = ®ion - logs = append(logs, fmt.Sprintf("api: UnBindEIP, request: %v", base.ToQueryMap(req))) - _, err := base.BizClient.UnBindEIP(req) - if err != nil { - logs = append(logs, fmt.Sprintf("unbind eip failed: %v", err)) - return logs, err - } - logs = append(logs, fmt.Sprintf("unbind eip[%s] with %s[%s] successfully", *req.EIPId, *req.ResourceType, *req.ResourceId)) - return logs, nil -} diff --git a/cmd/ext_compat_test.go b/cmd/ext_compat_test.go index bf65af6ea6..0c4209a560 100644 --- a/cmd/ext_compat_test.go +++ b/cmd/ext_compat_test.go @@ -6,21 +6,25 @@ import ( "testing" ) -func TestExtCommandDoesNotDependOnCompatShims(t *testing.T) { - src, err := os.ReadFile("ext.go") +func TestExtCommandMigratedOutOfPlatformCmd(t *testing.T) { + if _, err := os.Stat("ext.go"); err == nil { + t.Fatal("cmd/ext.go must be removed after ext migrates to products/eip/internal/ext") + } else if !os.IsNotExist(err) { + t.Fatalf("stat ext.go: %v", err) + } + + src, err := os.ReadFile("root.go") if err != nil { - t.Fatalf("read ext.go: %v", err) + t.Fatalf("read root.go: %v", err) } - for _, helper := range []string{ - "describeUHostByID(", - "getUhostList(", - "sbindEIP(", - "unbindEIP(", - } { - if strings.Contains(string(src), helper) { - t.Fatalf("ext.go must not call compat helper %s", helper) - } + if contains := string(src); contains == "" { + t.Fatal("root.go is unexpectedly empty") + } else if strings.Contains(contains, "NewCmdExt(") { + t.Fatal("cmd/root.go must not register NewCmdExt after ext migrates to products/eip") } +} + +func TestExtCommandDoesNotDependOnCompatShims(t *testing.T) { for _, path := range []string{"eip_compat.go", "uhost_compat.go"} { if _, err := os.Stat(path); err == nil { t.Fatalf("%s must be removed after ext owns its SDK helpers", path) diff --git a/base/client.go b/cmd/internal/platform/client.go similarity index 65% rename from base/client.go rename to cmd/internal/platform/client.go index 8b822b20bc..99cd966a4a 100644 --- a/base/client.go +++ b/cmd/internal/platform/client.go @@ -1,4 +1,4 @@ -package base +package platform import ( "encoding/json" @@ -6,66 +6,21 @@ import ( "net/url" "github.com/ucloud/ucloud-sdk-go/private/protocol/http" - ppathx "github.com/ucloud/ucloud-sdk-go/private/services/pathx" - pudb "github.com/ucloud/ucloud-sdk-go/private/services/udb" - puhost "github.com/ucloud/ucloud-sdk-go/private/services/uhost" - pumem "github.com/ucloud/ucloud-sdk-go/private/services/umem" - "github.com/ucloud/ucloud-sdk-go/services/pathx" - "github.com/ucloud/ucloud-sdk-go/services/uaccount" - "github.com/ucloud/ucloud-sdk-go/services/ucompshare" - "github.com/ucloud/ucloud-sdk-go/services/udb" - "github.com/ucloud/ucloud-sdk-go/services/udisk" - "github.com/ucloud/ucloud-sdk-go/services/udpn" - "github.com/ucloud/ucloud-sdk-go/services/uhost" - "github.com/ucloud/ucloud-sdk-go/services/ulb" - "github.com/ucloud/ucloud-sdk-go/services/umem" - "github.com/ucloud/ucloud-sdk-go/services/unet" - "github.com/ucloud/ucloud-sdk-go/services/uphost" - "github.com/ucloud/ucloud-sdk-go/services/vpc" sdk "github.com/ucloud/ucloud-sdk-go/ucloud" "github.com/ucloud/ucloud-sdk-go/ucloud/auth" uerr "github.com/ucloud/ucloud-sdk-go/ucloud/error" "github.com/ucloud/ucloud-sdk-go/ucloud/request" ) -// PrivateUHostClient 私有模块的uhost client 即未在官网开放的接口 -type PrivateUHostClient = puhost.UHostClient - -// PrivateUDBClient 私有模块的udb client 即未在官网开放的接口 -type PrivateUDBClient = pudb.UDBClient - -// PrivateUMemClient 私有模块的umem client 即未在官网开放的接口 -type PrivateUMemClient = pumem.UMemClient - -// PrivatePathxClient 私有模块的pathx client 即未在官网开放的接口 -type PrivatePathxClient = ppathx.PathXClient - -// Client aggregate client for business -type Client struct { - uaccount.UAccountClient - uhost.UHostClient - unet.UNetClient - vpc.VPCClient - udpn.UDPNClient - pathx.PathXClient - udisk.UDiskClient - ulb.ULBClient - udb.UDBClient - umem.UMemClient - uphost.UPHostClient - PrivateUHostClient - PrivateUDBClient - PrivateUMemClient PrivateUMemClient - PrivatePathxClient - ucompshare.UCompShareClient -} - // newCredHeaderInjector 返回凭据头注入 handler。 // aksk/CloudShell 行为与历史完全一致(Cookie/Csrf-Token 始终 set,含空值); // auth_mode==oauth 时剥离 SDK 编码器无条件附加的签名参数(Credential.Apply 即使 // 空密钥也会算出 Signature),并在 token 非空时追加 Authorization: Bearer, // 保证 oauth 请求只携带 Bearer 一种凭据机制(凭据模型见 spec §2)。 func newCredHeaderInjector(credConfig *CredentialConfig) sdk.HttpRequestHandler { + if credConfig == nil { + credConfig = &CredentialConfig{} + } return func(c *sdk.Client, req *http.HttpRequest) (*http.HttpRequest, error) { if err := req.SetHeader("Cookie", credConfig.Cookie); err != nil { return req, err @@ -143,7 +98,7 @@ func isAuthFailure(resp *http.HttpResponse, err error) bool { // (GetAggConfigByProfile/Append 直接存取同一指针),refreshAndSave 的写回因此可靠。 // req 的 Authorization 由 SetHeader 以 map 赋值覆盖(不会叠加重复头),且 body 中 // 的签名参数已被 newCredHeaderInjector 剥离,重放仍满足「oauth 请求只带 Bearer」不变式。 -func newOAuthRetryHandler(credConfig *CredentialConfig, ac *AggConfig) sdk.HttpResponseHandler { +func newOAuthRetryHandler(credConfig *CredentialConfig, ac *AggConfig, manager *AggConfigManager) sdk.HttpResponseHandler { return func(c *sdk.Client, req *http.HttpRequest, resp *http.HttpResponse, err error) (*http.HttpResponse, error) { if ac == nil || credConfig.AuthMode != AuthModeOAuth || credConfig.AccessToken == "" { return resp, err @@ -151,8 +106,12 @@ func newOAuthRetryHandler(credConfig *CredentialConfig, ac *AggConfig) sdk.HttpR if !isAuthFailure(resp, err) { return resp, err } + if manager == nil { + LogWarn("oauth reactive refresh skipped: config manager is not initialized") + return resp, err + } // 刷新(flock 串行化 + 拿锁后重读,见 refreshAndSave) - if rerr := refreshAndSave(ac, AggConfigListIns); rerr != nil { + if rerr := refreshAndSave(ac, manager); rerr != nil { LogWarn(fmt.Sprintf("oauth reactive refresh failed: %v", Redact(rerr.Error()))) return resp, err } @@ -178,6 +137,9 @@ func newOAuthRetryHandler(credConfig *CredentialConfig, ac *AggConfig) sdk.HttpR // 注意 SDK 编码器对空密钥仍会附加 Signature 参数,由 newCredHeaderInjector // 剥离,最终 Bearer 是唯一凭据。AK/SK 模式填真实公私钥,SDK 签名器据此签名。 func buildCredential(credConfig *CredentialConfig) *auth.Credential { + if credConfig == nil { + credConfig = &CredentialConfig{} + } credential := &auth.Credential{} if credConfig.AuthMode != AuthModeOAuth { credential.PublicKey = credConfig.PublicKey @@ -186,11 +148,17 @@ func buildCredential(credConfig *CredentialConfig) *auth.Credential { return credential } -// BuildCredential 从包级 AuthCredential(由 InitConfig/GetBizClient 填充)构造签名凭据。 +// BuildCredential 从包级 AuthCredential(由 InitConfig/InitClientRuntime 填充)构造签名凭据。 // 供 cli.NewServiceClient 使用——与 NewClient 走完全相同的 buildCredential 逻辑/分支, // oauth 与 AK/SK profile 共用一条代码路径(不分叉,§9 无鉴权回归)。 func BuildCredential() *auth.Credential { - return buildCredential(AuthCredential) + return BuildCredentialFrom(AuthCredential) +} + +// BuildCredentialFrom constructs an SDK signing credential from an explicit +// credential config, without reading package-level runtime state. +func BuildCredentialFrom(credConfig *CredentialConfig) *auth.Credential { + return buildCredential(credConfig) } // attachHandlers 把三个平台 handler 挂到 service client 上: @@ -199,7 +167,10 @@ func BuildCredential() *auth.Credential { // 重试目标必须是构造本 client 的 profile,而非包级 ConfigIns // (详见 newOAuthRetryHandler 的注释:os.Args 扫描识别不了 -p X/--profile=X, // ConfigIns 可能指向另一个 profile,错刷会把别人的 Bearer 重放到当前请求)。 -func attachHandlers(sc sdk.ServiceClient, credConfig *CredentialConfig, ac *AggConfig) { +func attachHandlersWithManager(sc sdk.ServiceClient, credConfig *CredentialConfig, ac *AggConfig, manager *AggConfigManager) { + if credConfig == nil { + credConfig = &CredentialConfig{} + } sc.AddRequestHandler(func(c *sdk.Client, req request.Common) (request.Common, error) { err := req.SetProjectId(PickResourceID(req.GetProjectId())) return req, err @@ -214,72 +185,18 @@ func attachHandlers(sc sdk.ServiceClient, credConfig *CredentialConfig, ac *AggC return req, nil }) sc.AddHttpRequestHandler(newCredHeaderInjector(credConfig)) - sc.AddHttpResponseHandler(newOAuthRetryHandler(credConfig, ac)) + sc.AddHttpResponseHandler(newOAuthRetryHandler(credConfig, ac, manager)) } // AttachHandlers 用包级 AuthCredential/ConfigIns(由 InitConfig 填充)把平台 handler // 挂到 sc 上。供 cli.NewServiceClient 使用——此时活动 profile 就是 ConfigIns, // 它正是正确的反应式刷新目标。 func AttachHandlers(sc sdk.ServiceClient) { - attachHandlers(sc, AuthCredential, ConfigIns) + AttachHandlersWith(sc, AuthCredential, ConfigIns, AggConfigListIns) } -// NewClient will return a aggregate client. -// ac 是构造来源 profile(oauth 401 反应式刷新的对象),允许为 nil(此时不重放)。 -func NewClient(config *sdk.Config, credConfig *CredentialConfig, ac *AggConfig) *Client { - credential := buildCredential(credConfig) - var ( - uaccountClient = *uaccount.NewClient(config, credential) - uhostClient = *uhost.NewClient(config, credential) - unetClient = *unet.NewClient(config, credential) - vpcClient = *vpc.NewClient(config, credential) - udpnClient = *udpn.NewClient(config, credential) - pathxClient = *pathx.NewClient(config, credential) - udiskClient = *udisk.NewClient(config, credential) - ulbClient = *ulb.NewClient(config, credential) - udbClient = *udb.NewClient(config, credential) - umemClient = *umem.NewClient(config, credential) - uphostClient = *uphost.NewClient(config, credential) - puhostClient = *puhost.NewClient(config, credential) - pudbClient = *pudb.NewClient(config, credential) - pumemClient = *pumem.NewClient(config, credential) - ppathxClient = *ppathx.NewClient(config, credential) - ulhostClient = *ucompshare.NewClient(config, credential) - ) - - attachHandlers(&uaccountClient, credConfig, ac) - attachHandlers(&uhostClient, credConfig, ac) - attachHandlers(&unetClient, credConfig, ac) - attachHandlers(&vpcClient, credConfig, ac) - attachHandlers(&udpnClient, credConfig, ac) - attachHandlers(&pathxClient, credConfig, ac) - attachHandlers(&udiskClient, credConfig, ac) - attachHandlers(&ulbClient, credConfig, ac) - attachHandlers(&udbClient, credConfig, ac) - attachHandlers(&umemClient, credConfig, ac) - attachHandlers(&uphostClient, credConfig, ac) - attachHandlers(&puhostClient, credConfig, ac) - attachHandlers(&pudbClient, credConfig, ac) - attachHandlers(&pumemClient, credConfig, ac) - attachHandlers(&ppathxClient, credConfig, ac) - attachHandlers(&ulhostClient, credConfig, ac) - - return &Client{ - uaccountClient, - uhostClient, - unetClient, - vpcClient, - udpnClient, - pathxClient, - udiskClient, - ulbClient, - udbClient, - umemClient, - uphostClient, - puhostClient, - pudbClient, - pumemClient, - ppathxClient, - ulhostClient, - } +// AttachHandlersWith attaches platform handlers using explicit runtime state, +// so callers do not need the old aggregate base client singleton. +func AttachHandlersWith(sc sdk.ServiceClient, credConfig *CredentialConfig, ac *AggConfig, manager *AggConfigManager) { + attachHandlersWithManager(sc, credConfig, ac, manager) } diff --git a/base/client_test.go b/cmd/internal/platform/client_test.go similarity index 93% rename from base/client_test.go rename to cmd/internal/platform/client_test.go index 530cf5dadd..fdf114efc9 100644 --- a/base/client_test.go +++ b/cmd/internal/platform/client_test.go @@ -1,5 +1,5 @@ // base/client_test.go -package base +package platform import ( "encoding/json" @@ -12,6 +12,7 @@ import ( "time" uhttp "github.com/ucloud/ucloud-sdk-go/private/protocol/http" + "github.com/ucloud/ucloud-sdk-go/services/uaccount" ) func injectorHeaders(t *testing.T, cred *CredentialConfig) map[string]string { @@ -82,16 +83,17 @@ func bizRecorderServer(t *testing.T, rec *recordedRequest) *httptest.Server { func callGetRegion(t *testing.T, ac *AggConfig, rec *recordedRequest) { t.Helper() - // GetBizClient 会改写包级全局 ClientConfig/AuthCredential,恢复现场避免测试顺序耦合 + // InitClientRuntime 会改写包级全局 ClientConfig/AuthCredential,恢复现场避免测试顺序耦合 oldClientConfig, oldAuthCredential := ClientConfig, AuthCredential t.Cleanup(func() { ClientConfig, AuthCredential = oldClientConfig, oldAuthCredential }) - bc, err := GetBizClient(ac) - if err != nil { + if err := InitClientRuntime(ac); err != nil { t.Fatal(err) } - if _, err := bc.GetRegion(bc.NewGetRegionRequest()); err != nil { + client := uaccount.NewClient(ClientConfig, BuildCredential()) + AttachHandlersWith(client, AuthCredential, ac, AggConfigListIns) + if _, err := client.GetRegion(client.NewGetRegionRequest()); err != nil { t.Fatalf("GetRegion failed: %v", err) } if rec.params == nil { @@ -99,6 +101,16 @@ func callGetRegion(t *testing.T, ac *AggConfig, rec *recordedRequest) { } } +func newTestUAccountClient(t *testing.T, ac *AggConfig) *uaccount.UAccountClient { + t.Helper() + if err := InitClientRuntime(ac); err != nil { + t.Fatal(err) + } + client := uaccount.NewClient(ClientConfig, BuildCredential()) + AttachHandlersWith(client, AuthCredential, ac, AggConfigListIns) + return client +} + // CRITICAL 缺陷回归(RetCode 171):oauth profile 残留 AK/SK(供 logout 恢复)时, // 请求必须只携带 Bearer 一种凭据,绝不能同时出现 SDK 签名参数。 func TestOAuthProfileWithRetainedKeysDoesNotSign(t *testing.T) { @@ -159,10 +171,7 @@ func TestOAuthRetryHandler(t *testing.T) { ClientConfig, AuthCredential = prevCC, prevAC }) - client, err := GetBizClient(ac) - if err != nil { - t.Fatal(err) - } + client := newTestUAccountClient(t, ac) resp, err := client.GetRegion(client.NewGetRegionRequest()) if err != nil { t.Fatalf("replay should succeed: %v", err) @@ -223,10 +232,7 @@ func TestOAuthRetryHandlerRetCode174(t *testing.T) { ClientConfig, AuthCredential = prevCC, prevAC }) - client, err := GetBizClient(ac) - if err != nil { - t.Fatal(err) - } + client := newTestUAccountClient(t, ac) resp, err := client.GetRegion(client.NewGetRegionRequest()) if err != nil { t.Fatalf("replay should succeed: %v", err) @@ -277,10 +283,7 @@ func TestOAuthRetryHandlerRetCode174Persists(t *testing.T) { AggConfigListIns = m t.Cleanup(func() { AggConfigListIns, ClientConfig, AuthCredential = prevList, prevCC, prevAC }) - client, err := GetBizClient(ac) - if err != nil { - t.Fatal(err) - } + client := newTestUAccountClient(t, ac) if _, err := client.GetRegion(client.NewGetRegionRequest()); err == nil { t.Error("persistent RetCode 174 must surface an error after single replay") } @@ -315,10 +318,7 @@ func TestOAuthRetryHandlerRefreshFails(t *testing.T) { AggConfigListIns = m t.Cleanup(func() { AggConfigListIns, ClientConfig, AuthCredential = prevList, prevCC, prevAC }) - client, err := GetBizClient(ac) - if err != nil { - t.Fatal(err) - } + client := newTestUAccountClient(t, ac) if _, err := client.GetRegion(client.NewGetRegionRequest()); err == nil { t.Error("refresh failure must surface the original 401 error") } @@ -352,10 +352,7 @@ func TestOAuthRetryHandlerReplayStill401(t *testing.T) { AggConfigListIns = m t.Cleanup(func() { AggConfigListIns, ClientConfig, AuthCredential = prevList, prevCC, prevAC }) - client, err := GetBizClient(ac) - if err != nil { - t.Fatal(err) - } + client := newTestUAccountClient(t, ac) if _, err := client.GetRegion(client.NewGetRegionRequest()); err == nil { t.Error("replay still 401 must surface error") } @@ -379,10 +376,7 @@ func TestOAuthRetryHandlerSkipsAksk(t *testing.T) { _ = newTestManager(t, ac) prevCC, prevAC := ClientConfig, AuthCredential t.Cleanup(func() { ClientConfig, AuthCredential = prevCC, prevAC }) - client, err := GetBizClient(ac) - if err != nil { - t.Fatal(err) - } + client := newTestUAccountClient(t, ac) if _, err := client.GetRegion(client.NewGetRegionRequest()); err == nil { t.Error("aksk 401 should surface error, not replay-refresh") } diff --git a/base/config.go b/cmd/internal/platform/config.go similarity index 95% rename from base/config.go rename to cmd/internal/platform/config.go index c62504297a..4570bbef29 100644 --- a/base/config.go +++ b/cmd/internal/platform/config.go @@ -1,4 +1,4 @@ -package base +package platform import ( "encoding/json" @@ -13,6 +13,8 @@ import ( sdk "github.com/ucloud/ucloud-sdk-go/ucloud" "github.com/ucloud/ucloud-sdk-go/ucloud/auth" "github.com/ucloud/ucloud-sdk-go/ucloud/log" + + "github.com/ucloud/ucloud-cli/cmd/internal/version" ) // ConfigFilePath path of config.json @@ -65,13 +67,6 @@ const ( OAuthRedirectPath = "/authorization" ) -// Version 版本号。var(非 const)以便 release 构建用 -// -ldflags "-X github.com/ucloud/ucloud-cli/base.Version=" 注入; -// 本地/dev 构建保持 "dev"。 -var Version = "dev" - -var UserAgent = fmt.Sprintf("UCloud-CLI/%s", Version) - var InCloudShell = os.Getenv("CLOUD_SHELL") == "true" // ConfigIns 配置实例, 程序加载时生成 @@ -91,9 +86,6 @@ var ClientConfig *sdk.Config // AuthCredential 创建sdk client参数 var AuthCredential *CredentialConfig -// BizClient 用于调用业务接口 -var BizClient *Client - // Global 全局flag var Global GlobalFlag @@ -704,8 +696,10 @@ func GetUserInfo() (*uaccount.UserInfo, error) { return user, nil } - req := BizClient.NewGetUserInfoRequest() - resp, err := BizClient.GetUserInfo(req) + client := uaccount.NewClient(ClientConfig, BuildCredential()) + AttachHandlers(client) + req := client.NewGetUserInfoRequest() + resp, err := client.GetUserInfo(req) if err != nil { return nil, err @@ -781,32 +775,23 @@ func adaptOldConfig() error { return AggConfigListIns.Append(ac) } -// GetBizClient 初始化BizClient -func GetBizClient(ac *AggConfig) (*Client, error) { +// BuildClientRuntime builds SDK config and credential config for a profile +// without creating an aggregate business client. +func BuildClientRuntime(ac *AggConfig) (*sdk.Config, *CredentialConfig, error) { timeout, err := time.ParseDuration(fmt.Sprintf("%ds", ac.Timeout)) if err != nil { err = fmt.Errorf("parse timeout %ds failed: %v", ac.Timeout, err) } - ClientConfig = &sdk.Config{ + cfg := &sdk.Config{ BaseUrl: ac.BaseURL, Timeout: timeout, - UserAgent: UserAgent, + UserAgent: version.UserAgent(), LogLevel: log.FatalLevel, Region: ac.Region, ProjectId: ac.ProjectID, MaxRetries: *ac.MaxRetryTimes, } - // AuthCredential must keep a STABLE pointer identity for the whole process: - // product service clients (cli.NewServiceClient) capture this pointer at - // command-tree registration and read AccessToken lazily per request, so a - // token refresh here must be visible to them. Overwrite the pointed-to object - // in place instead of replacing the pointer — otherwise those already-built - // clients keep sending the pre-refresh (possibly expired) Bearer and only - // recover via the reactive retry handler at the cost of a wasted round-trip. - if AuthCredential == nil { - AuthCredential = &CredentialConfig{} - } - *AuthCredential = CredentialConfig{ + cred := &CredentialConfig{ PublicKey: ac.PublicKey, PrivateKey: ac.PrivateKey, Cookie: ac.Cookie, @@ -816,7 +801,27 @@ func GetBizClient(ac *AggConfig) (*Client, error) { RefreshToken: ac.RefreshToken, ExpiresAt: ac.ExpiresAt, } - return NewClient(ClientConfig, AuthCredential, ac), err + return cfg, cred, err +} + +// InitClientRuntime initializes package-level SDK config and credential +// pointers for legacy callers, while keeping AuthCredential pointer identity +// stable for service clients that captured it at command registration time. +func InitClientRuntime(ac *AggConfig) error { + cfg, cred, err := BuildClientRuntime(ac) + ClientConfig = cfg + // AuthCredential must keep a STABLE pointer identity for the whole process: + // service clients (cli.NewServiceClient/runtime clients) capture this pointer at + // command-tree registration and read AccessToken lazily per request, so a + // token refresh here must be visible to them. Overwrite the pointed-to object + // in place instead of replacing the pointer — otherwise those already-built + // clients keep sending the pre-refresh (possibly expired) Bearer and only + // recover via the reactive retry handler at the cost of a wasted round-trip. + if AuthCredential == nil { + AuthCredential = &CredentialConfig{} + } + *AuthCredential = *cred + return err } func InitConfigInCloudShell() error { @@ -850,11 +855,9 @@ func InitConfigInCloudShell() error { return err } ConfigIns = ins - bc, err := GetBizClient(ConfigIns) - if err != nil { + if err := InitClientRuntime(ConfigIns); err != nil { return err } - BizClient = bc return AggConfigM.Save() } @@ -884,11 +887,8 @@ func InitConfig() { mergeConfigIns(ConfigIns) logCmd() - bc, err := GetBizClient(ConfigIns) - if err != nil { + if err := InitClientRuntime(ConfigIns); err != nil { HandleError(err) - } else { - BizClient = bc } } diff --git a/base/config_test.go b/cmd/internal/platform/config_test.go similarity index 99% rename from base/config_test.go rename to cmd/internal/platform/config_test.go index 1f906ce617..7d2a556322 100644 --- a/base/config_test.go +++ b/cmd/internal/platform/config_test.go @@ -1,4 +1,4 @@ -package base +package platform import ( "io/ioutil" diff --git a/base/credential_test.go b/cmd/internal/platform/credential_test.go similarity index 99% rename from base/credential_test.go rename to cmd/internal/platform/credential_test.go index 82937874b4..121597a961 100644 --- a/base/credential_test.go +++ b/cmd/internal/platform/credential_test.go @@ -1,5 +1,5 @@ // base/credential_test.go -package base +package platform import ( "testing" diff --git a/base/getbizclient_identity_test.go b/cmd/internal/platform/getbizclient_identity_test.go similarity index 73% rename from base/getbizclient_identity_test.go rename to cmd/internal/platform/getbizclient_identity_test.go index 84991c43b0..04ff3675ee 100644 --- a/base/getbizclient_identity_test.go +++ b/cmd/internal/platform/getbizclient_identity_test.go @@ -1,17 +1,17 @@ -package base +package platform import "testing" -// TestGetBizClientKeepsAuthCredentialIdentity locks the invariant that a token -// refresh via GetBizClient overwrites the existing *AuthCredential in place +// TestInitClientRuntimeKeepsAuthCredentialIdentity locks the invariant that a token +// refresh via InitClientRuntime overwrites the existing *AuthCredential in place // instead of swapping the package pointer. // // Product service clients (cli.NewServiceClient) capture the AuthCredential // pointer at command-tree registration and read AccessToken lazily per request. -// If GetBizClient replaced the pointer, those already-built clients would keep +// If InitClientRuntime replaced the pointer, those already-built clients would keep // sending the pre-refresh (expired) Bearer on the first request and only recover // through the reactive retry handler at the cost of a wasted round-trip. -func TestGetBizClientKeepsAuthCredentialIdentity(t *testing.T) { +func TestInitClientRuntimeKeepsAuthCredentialIdentity(t *testing.T) { savedCred, savedCfg := AuthCredential, ClientConfig t.Cleanup(func() { AuthCredential, ClientConfig = savedCred, savedCfg }) @@ -28,8 +28,8 @@ func TestGetBizClientKeepsAuthCredentialIdentity(t *testing.T) { AuthMode: AuthModeOAuth, AccessToken: "fresh-token", } - if _, err := GetBizClient(ac); err != nil { - t.Fatalf("GetBizClient returned error: %v", err) + if err := InitClientRuntime(ac); err != nil { + t.Fatalf("InitClientRuntime returned error: %v", err) } if AuthCredential != captured { diff --git a/base/log.go b/cmd/internal/platform/log.go similarity index 97% rename from base/log.go rename to cmd/internal/platform/log.go index e5422e8f6b..b21d243c55 100644 --- a/base/log.go +++ b/cmd/internal/platform/log.go @@ -1,4 +1,4 @@ -package base +package platform import ( "bytes" @@ -16,8 +16,9 @@ import ( log "github.com/sirupsen/logrus" "github.com/ucloud/ucloud-sdk-go/ucloud/request" - "github.com/ucloud/ucloud-sdk-go/ucloud/version" + sdkversion "github.com/ucloud/ucloud-sdk-go/ucloud/version" + cliversion "github.com/ucloud/ucloud-cli/cmd/internal/version" "github.com/ucloud/ucloud-cli/internal/common" ) @@ -345,7 +346,7 @@ func (t Tracer) Send(logs []string) error { } client := &http.Client{} - ua := fmt.Sprintf("GO/%s GO-SDK/%s %s", runtime.Version(), version.Version, UserAgent) + ua := fmt.Sprintf("GO/%s GO-SDK/%s %s", runtime.Version(), sdkversion.Version, cliversion.UserAgent()) req, err := http.NewRequest("POST", t.DasUrl, bytes.NewReader(body)) req.Header.Add("Origin", "https://sdk.ucloud.cn") req.Header.Add("User-Agent", ua) diff --git a/base/log_test.go b/cmd/internal/platform/log_test.go similarity index 99% rename from base/log_test.go rename to cmd/internal/platform/log_test.go index 34acda29b5..79e912f81c 100644 --- a/base/log_test.go +++ b/cmd/internal/platform/log_test.go @@ -1,5 +1,5 @@ // base/log_test.go -package base +package platform import ( "bytes" diff --git a/base/logtofile_test.go b/cmd/internal/platform/logtofile_test.go similarity index 97% rename from base/logtofile_test.go rename to cmd/internal/platform/logtofile_test.go index df3a7bc41d..bbc99da2bd 100644 --- a/base/logtofile_test.go +++ b/cmd/internal/platform/logtofile_test.go @@ -1,4 +1,4 @@ -package base +package platform import ( "bytes" diff --git a/base/oauth.go b/cmd/internal/platform/oauth.go similarity index 99% rename from base/oauth.go rename to cmd/internal/platform/oauth.go index f9fe7c25d9..2d267b0130 100644 --- a/base/oauth.go +++ b/cmd/internal/platform/oauth.go @@ -1,5 +1,5 @@ // base/oauth.go -package base +package platform import ( "context" diff --git a/base/oauth_http_test.go b/cmd/internal/platform/oauth_http_test.go similarity index 99% rename from base/oauth_http_test.go rename to cmd/internal/platform/oauth_http_test.go index 2c6e1c6b44..2c8c0381dc 100644 --- a/base/oauth_http_test.go +++ b/cmd/internal/platform/oauth_http_test.go @@ -1,5 +1,5 @@ // base/oauth_http_test.go -package base +package platform import ( "fmt" diff --git a/base/oauth_refresh_test.go b/cmd/internal/platform/oauth_refresh_test.go similarity index 99% rename from base/oauth_refresh_test.go rename to cmd/internal/platform/oauth_refresh_test.go index 112c23a1dc..cc99946145 100644 --- a/base/oauth_refresh_test.go +++ b/cmd/internal/platform/oauth_refresh_test.go @@ -1,5 +1,5 @@ // base/oauth_refresh_test.go -package base +package platform import ( "encoding/json" diff --git a/base/oauth_test.go b/cmd/internal/platform/oauth_test.go similarity index 99% rename from base/oauth_test.go rename to cmd/internal/platform/oauth_test.go index 4d19aabb07..66ae353117 100644 --- a/base/oauth_test.go +++ b/cmd/internal/platform/oauth_test.go @@ -1,5 +1,5 @@ // base/oauth_test.go -package base +package platform import ( "os" diff --git a/base/requestlog_test.go b/cmd/internal/platform/requestlog_test.go similarity index 97% rename from base/requestlog_test.go rename to cmd/internal/platform/requestlog_test.go index 7f39dcf0e3..65e5b42cc4 100644 --- a/base/requestlog_test.go +++ b/cmd/internal/platform/requestlog_test.go @@ -1,4 +1,4 @@ -package base +package platform import ( "strings" diff --git a/base/util.go b/cmd/internal/platform/util.go similarity index 60% rename from base/util.go rename to cmd/internal/platform/util.go index 44f572de60..f5cec419e4 100644 --- a/base/util.go +++ b/cmd/internal/platform/util.go @@ -1,4 +1,4 @@ -package base +package platform import ( "encoding/json" @@ -11,20 +11,16 @@ import ( "runtime" "strconv" "strings" - "time" "unicode" + "github.com/ucloud/ucloud-sdk-go/services/uaccount" sdk "github.com/ucloud/ucloud-sdk-go/ucloud" uerr "github.com/ucloud/ucloud-sdk-go/ucloud/error" - "github.com/ucloud/ucloud-sdk-go/ucloud/helpers/waiter" - "github.com/ucloud/ucloud-sdk-go/ucloud/log" - "github.com/ucloud/ucloud-sdk-go/ucloud/request" "github.com/ucloud/ucloud-sdk-go/ucloud/response" "github.com/ucloud/ucloud-cli/internal/common" "github.com/ucloud/ucloud-cli/model" "github.com/ucloud/ucloud-cli/pkg/ui" - "github.com/ucloud/ucloud-cli/ux" ) // ConfigPath 配置文件路径 @@ -275,257 +271,6 @@ var RegionLabel = map[string]string{ "afr-nigeria": "Lagos", } -// Poller 轮询器 -type Poller struct { - stateFields []string - DescribeFunc func(string, string, string, string) (interface{}, error) - Out io.Writer - Timeout time.Duration - SdescribeFunc func(string, *request.CommonBase) (interface{}, error) - SdescribeWithCommonConfigFunc func(string) (interface{}, error) -} - -type pollResult struct { - Done bool - Timeout bool - Err error -} - -// Sspoll 简化版, 支持并发 -func (p *Poller) Sspoll(resourceID, pollText string, targetStates []string, block *ux.Block, commonBase *request.CommonBase) *pollResult { - w := waiter.StateWaiter{ - Pending: []string{"pending"}, - Target: []string{"avaliable"}, - Refresh: func() (interface{}, string, error) { - inst, err := p.SdescribeFunc(resourceID, commonBase) - if err != nil { - return nil, "", err - } - - if inst == nil { - return nil, "pending", nil - } - instValue := reflect.ValueOf(inst) - instValue = reflect.Indirect(instValue) - instType := instValue.Type() - if instValue.Kind() != reflect.Struct { - return nil, "", fmt.Errorf("Instance is not struct") - } - state := "" - for i := 0; i < instValue.NumField(); i++ { - for _, sf := range p.stateFields { - if instType.Field(i).Name == sf { - state = instValue.Field(i).String() - } - } - } - if state != "" { - for _, t := range targetStates { - if t == state { - return inst, "avaliable", nil - } - } - } - return nil, "pending", nil - - }, - Timeout: p.Timeout, - } - - pollRetChan := make(chan pollResult) - go func() { - ret := pollResult{ - Done: true, - } - if _, err := w.Wait(); err != nil { - ret.Done = false - ret.Err = err - if _, ok := err.(*waiter.TimeoutError); ok { - ret.Timeout = true - } - } - pollRetChan <- ret - }() - - if !ui.IsTTY(p.Out) { - // non-TTY (piped/redirected): no spinner animation, single terminal-state - // line — mirrors Spoll's suppression so machine output stays clean. - ret := <-pollRetChan - if ret.Timeout { - fmt.Fprintf(p.Out, "%s...timeout\n", pollText) - } else { - fmt.Fprintf(p.Out, "%s...done\n", pollText) - } - return &ret - } - - spin := ux.NewDotSpin(p.Out, pollText) - block.SetSpin(spin) - - ret := <-pollRetChan - - if ret.Timeout { - spin.Timeout() - } else { - spin.Stop() - } - return &ret -} - -// Spoll 简化版 -func (p *Poller) Spoll(resourceID, pollText string, targetStates []string) { - w := waiter.StateWaiter{ - Pending: []string{"pending"}, - Target: []string{"avaliable"}, - Refresh: func() (interface{}, string, error) { - inst, err := p.SdescribeFunc(resourceID, nil) - if err != nil { - return nil, "", err - } - - if inst == nil { - return nil, "pending", nil - } - instValue := reflect.ValueOf(inst) - instValue = reflect.Indirect(instValue) - instType := instValue.Type() - if instValue.Kind() != reflect.Struct { - return nil, "", fmt.Errorf("Instance is not struct") - } - state := "" - for i := 0; i < instValue.NumField(); i++ { - for _, sf := range p.stateFields { - if instType.Field(i).Name == sf { - state = instValue.Field(i).String() - } - } - } - if state != "" { - for _, t := range targetStates { - if t == state { - return inst, "avaliable", nil - } - } - } - return nil, "pending", nil - - }, - Timeout: p.Timeout, - } - - done := make(chan bool) - go func() { - if _, err := w.Wait(); err != nil { - log.Error(err) - if _, ok := err.(*waiter.TimeoutError); ok { - done <- false - return - } - } - done <- true - }() - - if !ui.IsTTY(p.Out) { - // non-TTY (piped/redirected): no spinner animation, single terminal-state line - if <-done { - fmt.Fprintf(p.Out, "%s...done\n", pollText) - } else { - fmt.Fprintf(p.Out, "%s...timeout\n", pollText) - } - return - } - spinner := ux.NewDotSpinner(p.Out) - spinner.Start(pollText) - ret := <-done - if ret { - spinner.Stop() - } else { - spinner.Timeout() - } -} - -// Poll function -func (p *Poller) Poll(resourceID, projectID, region, zone, pollText string, targetState []string) bool { - w := waiter.StateWaiter{ - Pending: []string{"pending"}, - Target: []string{"avaliable"}, - Refresh: func() (interface{}, string, error) { - inst, err := p.DescribeFunc(resourceID, projectID, region, zone) - if err != nil { - return nil, "", err - } - - if inst == nil { - return nil, "pending", nil - } - instValue := reflect.ValueOf(inst) - instValue = reflect.Indirect(instValue) - instType := instValue.Type() - if instValue.Kind() != reflect.Struct { - return nil, "", fmt.Errorf("Instance is not struct") - } - state := "" - for i := 0; i < instValue.NumField(); i++ { - for _, sf := range p.stateFields { - if instType.Field(i).Name == sf { - state = instValue.Field(i).String() - } - } - } - if state != "" { - for _, t := range targetState { - if t == state { - return inst, "avaliable", nil - } - } - } - return nil, "pending", nil - - }, - Timeout: p.Timeout, - } - - var err error - done := make(chan bool) - go func() { - if _, err = w.Wait(); err != nil { - done <- false - return - } - done <- true - }() - - spinner := ux.NewDotSpinner(p.Out) - spinner.Start(pollText) - ret := <-done - if err != nil { - spinner.Fail(err) - } else { - spinner.Stop() - } - return ret -} - -// NewSpoller simple -func NewSpoller(describeFunc func(string, *request.CommonBase) (interface{}, error), out io.Writer) *Poller { - return &Poller{ - SdescribeFunc: describeFunc, - Out: out, - stateFields: []string{"State", "Status"}, - Timeout: 10 * time.Minute, - } -} - -// NewPoller 轮询 -func NewPoller(describeFunc func(string, string, string, string) (interface{}, error), out io.Writer) *Poller { - return &Poller{ - DescribeFunc: describeFunc, - Out: out, - stateFields: []string{"State", "Status"}, - Timeout: 10 * time.Minute, - } -} - // PickResourceID uhost-xxx/uhost-name => uhost-xxx func PickResourceID(str string) string { if strings.Index(str, "/") > -1 { @@ -569,7 +314,7 @@ func Confirm(yes bool, text string) bool { if yes { return true } - sure, err := ux.Prompt(text) + sure, err := ui.Prompt(text) if err != nil { LogError(err.Error()) return false @@ -601,12 +346,12 @@ func getDefaultRegion(cookie, csrfToken string) (string, string, error) { Timeout: DefaultTimeoutSec, MaxRetryTimes: sdk.Int(DefaultMaxRetryTimes), } - bc, err := GetBizClient(cfg) - req := bc.NewGetRegionRequest() + client, err := newUAccountClientForConfig(cfg) if err != nil { return "", "", err } - resp, err := bc.GetRegion(req) + req := client.NewGetRegionRequest() + resp, err := client.GetRegion(req) if err != nil { return "", "", err } @@ -626,13 +371,13 @@ func getDefaultProject(cookie, csrfToken string) (string, string, error) { Timeout: DefaultTimeoutSec, MaxRetryTimes: sdk.Int(DefaultMaxRetryTimes), } - bc, err := GetBizClient(cfg) + client, err := newUAccountClientForConfig(cfg) if err != nil { return "", "", err } - req := bc.NewGetProjectListRequest() - resp, err := bc.GetProjectList(req) + req := client.NewGetProjectListRequest() + resp, err := client.GetProjectList(req) if err != nil { return "", "", err } @@ -643,3 +388,10 @@ func getDefaultProject(cookie, csrfToken string) (string, string, error) { } return "", "", fmt.Errorf("default project not found") } + +func newUAccountClientForConfig(cfg *AggConfig) (*uaccount.UAccountClient, error) { + sdkConfig, credConfig, err := BuildClientRuntime(cfg) + client := uaccount.NewClient(sdkConfig, BuildCredentialFrom(credConfig)) + AttachHandlersWith(client, credConfig, cfg, AggConfigListIns) + return client, err +} diff --git a/base/util_test.go b/cmd/internal/platform/util_test.go similarity index 98% rename from base/util_test.go rename to cmd/internal/platform/util_test.go index d9b0988fbf..38a21ff6f9 100644 --- a/base/util_test.go +++ b/cmd/internal/platform/util_test.go @@ -1,4 +1,4 @@ -package base +package platform import ( "io/ioutil" diff --git a/cmd/internal/version/version.go b/cmd/internal/version/version.go new file mode 100644 index 0000000000..624e471e7e --- /dev/null +++ b/cmd/internal/version/version.go @@ -0,0 +1,9 @@ +package version + +import "fmt" + +var Version = "dev" + +func UserAgent() string { + return fmt.Sprintf("UCloud-CLI/%s", Version) +} diff --git a/cmd/login.go b/cmd/login.go index 0c4476959d..f408833f8a 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -12,7 +12,7 @@ import ( "github.com/ucloud/ucloud-sdk-go/services/uaccount" - "github.com/ucloud/ucloud-cli/base" + "github.com/ucloud/ucloud-cli/cmd/internal/platform" ) const loginLongHelp = `Log in to UCloud via your browser (OAuth authorization code flow). @@ -74,11 +74,11 @@ func NewCmdLogin() *cobra.Command { // resolveLoginOAuthBase 决定登录使用的 OAuth 域:--oauth-base-url flag 最优先, // 给定时写回 cfg.OAuthBaseURL 以便登录成功后随 profile 持久化(后续刷新沿用); // 未给定则回退到 profile 配置或内置默认(GetOAuthBaseURL)。 -func resolveLoginOAuthBase(cfg *base.AggConfig, flagVal string) (string, error) { +func resolveLoginOAuthBase(cfg *platform.AggConfig, flagVal string) (string, error) { if flagVal != "" { cfg.OAuthBaseURL = strings.TrimSuffix(flagVal, "/") } - oauthBase, err := base.GetOAuthBaseURL(cfg) + oauthBase, err := platform.GetOAuthBaseURL(cfg) if err == nil && cfg.OAuthBaseURL == "" { cfg.OAuthBaseURL = oauthBase } @@ -87,18 +87,18 @@ func resolveLoginOAuthBase(cfg *base.AggConfig, flagVal string) (string, error) func runLogin(noBrowser bool, oauthBaseURL string) { // AP-1:非 TTY fail-fast - if !base.IsStdinTTY() { + if !platform.IsStdinTTY() { fmt.Fprintln(os.Stderr, "'ucloud auth login' requires an interactive terminal. For automation/CI, use an AK/SK profile: ucloud config --profile --public-key --private-key ") os.Exit(1) } - cfg := base.ConfigIns + cfg := platform.ConfigIns oauthBase, err := resolveLoginOAuthBase(cfg, oauthBaseURL) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } - state, err := base.GenerateState() + state, err := platform.GenerateState() if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) @@ -111,7 +111,7 @@ func runLogin(noBrowser bool, oauthBaseURL string) { code, redirectURI = runLoginAuto(oauthBase, state) } - tr, err := base.ExchangeToken(oauthBase, redirectURI, code) + tr, err := platform.ExchangeToken(oauthBase, redirectURI, code) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) @@ -122,12 +122,12 @@ func runLogin(noBrowser bool, oauthBaseURL string) { fmt.Printf("Note: profile '%s' had AK/SK configured; it now switches to OAuth (auth_mode=oauth). AK/SK keys are kept; to switch back, run 'ucloud auth logout' then 'ucloud init'\n", cfg.Profile) } - base.ApplyTokenResponse(cfg, tr) + platform.ApplyTokenResponse(cfg, tr) cfg.Active = true - if _, ok := base.AggConfigListIns.GetAggConfigByProfile(cfg.Profile); ok { - err = base.AggConfigListIns.UpdateAggConfig(cfg) + if _, ok := platform.AggConfigListIns.GetAggConfigByProfile(cfg.Profile); ok { + err = platform.AggConfigListIns.UpdateAggConfig(cfg) } else { - err = base.AggConfigListIns.Append(cfg) + err = platform.AggConfigListIns.Append(cfg) } if err != nil { fmt.Fprintf(os.Stderr, "save credential failed: %v\n", err) @@ -157,13 +157,13 @@ func runLogin(noBrowser bool, oauthBaseURL string) { fmt.Println(notice) } } - if err := base.AggConfigListIns.UpdateAggConfig(cfg); err != nil { + if err := platform.AggConfigListIns.UpdateAggConfig(cfg); err != nil { fmt.Printf("Warning: saving default region/project failed (%v). Set them later: ucloud config update --profile %s --region --zone --project-id \n", err, cfg.Profile) } // ⑥ 输出 email + 过期时间(id_token 仅解析不落盘) until := time.Unix(cfg.ExpiresAt, 0).Format("15:04") - if email, eerr := base.ParseIDTokenEmail(tr.IDToken); eerr == nil && email != "" { + if email, eerr := platform.ParseIDTokenEmail(tr.IDToken); eerr == nil && email != "" { fmt.Printf("Logged in as %s, token valid until %s\n", email, until) } else { fmt.Printf("Logged in, token valid until %s\n", until) @@ -206,8 +206,8 @@ func runLoginManual(oauthBase, state string) (string, string) { os.Exit(1) } ln.Close() - redirectURI := base.BuildLoopbackRedirectURI(port) - authorizeURL := base.BuildAuthorizeURL(oauthBase, redirectURI, state) + redirectURI := platform.BuildLoopbackRedirectURI(port) + authorizeURL := platform.BuildAuthorizeURL(oauthBase, redirectURI, state) fmt.Println("Logging in via browser (manual paste). 3 steps:") fmt.Println(" 1. Open the URL below and finish login & authorization.") @@ -233,8 +233,8 @@ func runLoginAuto(oauthBase, state string) (string, string) { fmt.Fprintln(os.Stderr, err) os.Exit(1) } - redirectURI := base.BuildLoopbackRedirectURI(port) - authorizeURL := base.BuildAuthorizeURL(oauthBase, redirectURI, state) + redirectURI := platform.BuildLoopbackRedirectURI(port) + authorizeURL := platform.BuildAuthorizeURL(oauthBase, redirectURI, state) srv, ch := startCallbackServer(ln, state) @@ -271,7 +271,7 @@ func readCallbackCode(state string) (string, error) { if err != nil { return "", fmt.Errorf("read input failed: %v", err) } - code, perr := base.ParseCallbackURL(raw, state) + code, perr := platform.ParseCallbackURL(raw, state) if perr == nil { return code, nil } @@ -305,14 +305,14 @@ func NewCmdLogout() *cobra.Command { Args: cobra.NoArgs, Example: "ucloud auth logout", Run: func(cmd *cobra.Command, args []string) { - cfg := base.ConfigIns - if cfg.AuthMode != base.AuthModeOAuth && cfg.AccessToken == "" { + cfg := platform.ConfigIns + if cfg.AuthMode != platform.AuthModeOAuth && cfg.AccessToken == "" { fmt.Printf("Profile '%s' is not logged in via OAuth, nothing to do\n", cfg.Profile) return } clearOAuthState(cfg) - if err := base.AggConfigListIns.UpdateAggConfig(cfg); err != nil { - base.HandleError(err) + if err := platform.AggConfigListIns.UpdateAggConfig(cfg); err != nil { + platform.HandleError(err) return } // AP-4:不加服务端有效期提示(用户裁定,spec 风险 #5) diff --git a/cmd/login_test.go b/cmd/login_test.go index b6aa303991..63744d93ff 100644 --- a/cmd/login_test.go +++ b/cmd/login_test.go @@ -6,14 +6,14 @@ import ( "github.com/ucloud/ucloud-sdk-go/services/uaccount" - "github.com/ucloud/ucloud-cli/base" + "github.com/ucloud/ucloud-cli/cmd/internal/platform" ) // resolveLoginOAuthBase 决定登录使用的 OAuth 域:--oauth-base-url flag 最优先(去尾斜杠后 // 写回 cfg.OAuthBaseURL 以便登录成功后随 profile 持久化),未给定则回退到 profile 配置或内置默认。 func TestResolveLoginOAuthBase(t *testing.T) { // case 1: 给了 flag → 去尾斜杠后返回,并写回 cfg(证明持久化接线) - cfg := &base.AggConfig{} + cfg := &platform.AggConfig{} got, err := resolveLoginOAuthBase(cfg, "https://oauth-global.example/") if err != nil { t.Fatalf("flag given: unexpected error: %v", err) @@ -26,7 +26,7 @@ func TestResolveLoginOAuthBase(t *testing.T) { } // case 2: flag 为空,cfg 预置 → 返回 profile 值,cfg 不变 - cfg = &base.AggConfig{OAuthBaseURL: "https://oauth-profile.example"} + cfg = &platform.AggConfig{OAuthBaseURL: "https://oauth-profile.example"} got, err = resolveLoginOAuthBase(cfg, "") if err != nil { t.Fatalf("flag empty, cfg preset: unexpected error: %v", err) @@ -39,12 +39,12 @@ func TestResolveLoginOAuthBase(t *testing.T) { } // case 3: flag 为空,cfg 为空 → 返回内置默认,并写回 cfg 以便随 profile 显式落盘 - cfg = &base.AggConfig{} + cfg = &platform.AggConfig{} got, err = resolveLoginOAuthBase(cfg, "") if err != nil { t.Fatalf("flag empty, cfg empty: unexpected error: %v", err) } - want, _ := base.GetOAuthBaseURL(&base.AggConfig{}) + want, _ := platform.GetOAuthBaseURL(&platform.AggConfig{}) if want == "" { t.Fatal("flag empty, cfg empty: built-in default is empty, test precondition broken") } diff --git a/cmd/output_format_test.go b/cmd/output_format_test.go index da55bbd16a..c06218c3ab 100644 --- a/cmd/output_format_test.go +++ b/cmd/output_format_test.go @@ -82,6 +82,40 @@ func TestDecideOutputFormat(t *testing.T) { } } +func TestSyncLegacyJSONFlag(t *testing.T) { + origOutput := global.Output + origJSON := global.JSON + t.Cleanup(func() { + global.Output = origOutput + global.JSON = origJSON + }) + + tests := []struct { + name string + outputFlag string + jsonFlag bool + wantJSON bool + }{ + {name: "--output json enables legacy JSON", outputFlag: "json", wantJSON: true}, + {name: "--output table disables legacy JSON", outputFlag: "table", jsonFlag: true, wantJSON: false}, + {name: "--json still enables legacy JSON", jsonFlag: true, wantJSON: true}, + {name: "non-TTY default enables legacy JSON", wantJSON: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + global.Output = tc.outputFlag + global.JSON = tc.jsonFlag + + syncLegacyJSONFlag(&bytes.Buffer{}) + + if global.JSON != tc.wantJSON { + t.Errorf("global.JSON = %v, want %v", global.JSON, tc.wantJSON) + } + }) + } +} + // TestProductCtxFormatFinalize is a regression test for the bug where the // product cli.Context's output format was frozen at command-tree construction // time (buildContext, before cobra parses --output), so an explicit diff --git a/cmd/platform_runtime_guard_test.go b/cmd/platform_runtime_guard_test.go new file mode 100644 index 0000000000..da4b6c4901 --- /dev/null +++ b/cmd/platform_runtime_guard_test.go @@ -0,0 +1,137 @@ +package cmd + +import ( + "go/ast" + "go/parser" + "go/token" + "io/fs" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +const guardModuleRoot = "github.com/ucloud/ucloud-cli" + +func TestProductionCodeDoesNotUseAggregateBaseClient(t *testing.T) { + repoRoot := ".." + var violations []string + err := filepath.WalkDir(repoRoot, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + switch d.Name() { + case ".git", "docs", "vendor": + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + rel, err := filepath.Rel(repoRoot, path) + if err != nil { + return err + } + if strings.HasPrefix(rel, "ux/") { + return nil + } + + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, path, nil, 0) + if err != nil { + return err + } + inPlatformPackage := strings.HasPrefix(rel, "cmd/internal/platform/") + ast.Inspect(file, func(n ast.Node) bool { + switch x := n.(type) { + case *ast.SelectorExpr: + if ident, ok := x.X.(*ast.Ident); ok && ident.Name == "base" && x.Sel.Name == "BizClient" { + violations = append(violations, fset.Position(x.Pos()).String()+": platform.BizClient is forbidden") + } + case *ast.TypeSpec: + if inPlatformPackage && x.Name.Name == "Client" { + violations = append(violations, fset.Position(x.Pos()).String()+": platform.Client aggregate type is forbidden") + } + case *ast.ValueSpec: + if inPlatformPackage { + for _, name := range x.Names { + if name.Name == "BizClient" { + violations = append(violations, fset.Position(name.Pos()).String()+": platform.BizClient global is forbidden") + } + } + } + case *ast.FuncDecl: + if inPlatformPackage && (x.Name.Name == "NewClient" || x.Name.Name == "GetBizClient") { + violations = append(violations, fset.Position(x.Pos()).String()+": aggregate client constructor is forbidden") + } + } + return true + }) + return nil + }) + if err != nil { + t.Fatal(err) + } + if len(violations) > 0 { + t.Fatalf("aggregate base client usage remains:\n%s", strings.Join(violations, "\n")) + } +} + +func TestProductionCodeDoesNotImportLegacyTopLevelPackages(t *testing.T) { + repoRoot := ".." + forbidden := map[string]string{ + guardModuleRoot + "/base": "legacy top-level base package is forbidden", + guardModuleRoot + "/ux": "legacy top-level ux package is forbidden", + guardModuleRoot + "/ansi": "legacy top-level ansi package is forbidden", + } + + var violations []string + err := filepath.WalkDir(repoRoot, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + switch d.Name() { + case ".git", "docs", "vendor": + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly) + if err != nil { + return err + } + for _, imp := range file.Imports { + importPath, err := strconv.Unquote(imp.Path.Value) + if err != nil { + return err + } + if msg, ok := forbidden[importPath]; ok { + violations = append(violations, fset.Position(imp.Path.Pos()).String()+": "+msg) + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } + if len(violations) > 0 { + t.Fatalf("legacy top-level imports remain:\n%s", strings.Join(violations, "\n")) + } +} + +func TestLegacyTopLevelPackageDirectoriesDoNotExist(t *testing.T) { + for _, dir := range []string{"../base", "../ux", "../ansi"} { + if _, err := os.Stat(dir); err == nil { + t.Fatalf("legacy top-level package directory still exists: %s", dir) + } + } +} diff --git a/cmd/product_registration_test.go b/cmd/product_registration_test.go index 633f9afb86..f10fef9365 100644 --- a/cmd/product_registration_test.go +++ b/cmd/product_registration_test.go @@ -37,7 +37,7 @@ func TestRegisteredProductsUseCommandDirectoryProducts(t *testing.T) { commands []string }{ {product: "sharedbw", commands: []string{"bw"}}, - {product: "eip", commands: []string{"eip"}}, + {product: "eip", commands: []string{"eip", "ext"}}, {product: "firewall", commands: []string{"firewall"}}, {product: "globalssh", commands: []string{"gssh"}}, {product: "image", commands: []string{"image"}}, @@ -91,6 +91,7 @@ func TestAddPlatformCommandsExcludesMigratedProductCommands(t *testing.T) { "NewCmdULB(", "NewCmdSubnet(", "NewCmdVpc(", + "NewCmdExt(", } { if strings.Contains(string(src), constructor) { t.Fatalf("addPlatformCommands must not register %s after product migration", constructor) diff --git a/cmd/project.go b/cmd/project.go index 1b36152a81..8c59271ba7 100644 --- a/cmd/project.go +++ b/cmd/project.go @@ -21,7 +21,7 @@ import ( "github.com/ucloud/ucloud-sdk-go/services/uaccount" - "github.com/ucloud/ucloud-cli/base" + "github.com/ucloud/ucloud-cli/cmd/internal/platform" ) // NewCmdProject ucloud project @@ -32,7 +32,7 @@ func NewCmdProject() *cobra.Command { Long: "List,create,update and delete project", Example: "ucloud project", } - out := base.Cxt.GetWriter() + out := platform.Cxt.GetWriter() cmd.AddCommand(NewCmdProjectList(out)) cmd.AddCommand(NewCmdProjectCreate()) cmd.AddCommand(NewCmdProjectUpdate()) @@ -56,21 +56,22 @@ func NewCmdProjectList(out io.Writer) *cobra.Command { // NewCmdProjectCreate ucloud project create func NewCmdProjectCreate() *cobra.Command { - req := base.BizClient.NewCreateProjectRequest() + client := newServiceClient(uaccount.NewClient) + req := client.NewCreateProjectRequest() cmd := &cobra.Command{ Use: "create", Short: "Create project", Long: "Create project", Example: "ucloud project create --name xxx", Run: func(cmd *cobra.Command, args []string) { - resp, err := base.BizClient.CreateProject(req) + resp, err := client.CreateProject(req) if err != nil { - base.Cxt.PrintErr(err) + platform.Cxt.PrintErr(err) } else { if resp.RetCode != 0 { - base.HandleBizError(resp) + platform.HandleBizError(resp) } else { - base.Cxt.Printf("Project:%q created\n", resp.ProjectId) + platform.Cxt.Printf("Project:%q created\n", resp.ProjectId) } } }, @@ -83,21 +84,22 @@ func NewCmdProjectCreate() *cobra.Command { // NewCmdProjectUpdate ucloud project update func NewCmdProjectUpdate() *cobra.Command { - req := base.BizClient.NewModifyProjectRequest() + client := newServiceClient(uaccount.NewClient) + req := client.NewModifyProjectRequest() cmd := &cobra.Command{ Use: "update", Short: "Update project name", Long: "Update project name", Example: "ucloud project update --id org-xxx --name new_name", Run: func(cmd *cobra.Command, args []string) { - resp, err := base.BizClient.ModifyProject(req) + resp, err := client.ModifyProject(req) if err != nil { - base.Cxt.PrintErr(err) + platform.Cxt.PrintErr(err) } else { if resp.RetCode != 0 { - base.HandleBizError(resp) + platform.HandleBizError(resp) } else { - base.Cxt.Printf("Project:%s updated\n", *req.ProjectId) + platform.Cxt.Printf("Project:%s updated\n", *req.ProjectId) } } }, @@ -111,21 +113,22 @@ func NewCmdProjectUpdate() *cobra.Command { // NewCmdProjectDelete ucloud project delete func NewCmdProjectDelete() *cobra.Command { - req := base.BizClient.NewTerminateProjectRequest() + client := newServiceClient(uaccount.NewClient) + req := client.NewTerminateProjectRequest() cmd := &cobra.Command{ Use: "delete", Short: "Delete project", Long: "Delete project", Example: "ucloud project delete --id org-xxx", Run: func(cmd *cobra.Command, args []string) { - resp, err := base.BizClient.TerminateProject(req) + resp, err := client.TerminateProject(req) if err != nil { - base.Cxt.PrintErr(err) + platform.Cxt.PrintErr(err) } else { if resp.RetCode != 0 { - base.HandleBizError(resp) + platform.HandleBizError(resp) } else { - base.Cxt.Printf("Project:%s deleted\n", *req.ProjectId) + platform.Cxt.Printf("Project:%s deleted\n", *req.ProjectId) } } }, @@ -136,24 +139,26 @@ func NewCmdProjectDelete() *cobra.Command { } func listProject(out io.Writer) error { - req := &uaccount.GetProjectListRequest{} - resp, err := base.BizClient.GetProjectList(req) + client := newServiceClient(uaccount.NewClient) + req := client.NewGetProjectListRequest() + resp, err := client.GetProjectList(req) if err != nil { return err } if resp.RetCode != 0 { - return base.HandleBizError(resp) + return platform.HandleBizError(resp) } if global.JSON { - return base.PrintJSON(resp.ProjectSet, out) + return platform.PrintJSON(resp.ProjectSet, out) } - base.PrintTable(resp.ProjectSet, []string{"ProjectId", "ProjectName"}) + platform.PrintTable(resp.ProjectSet, []string{"ProjectId", "ProjectName"}) return nil } func getProjectList() []string { - req := &uaccount.GetProjectListRequest{} - resp, err := base.BizClient.GetProjectList(req) + client := newServiceClient(uaccount.NewClient) + req := client.NewGetProjectListRequest() + resp, err := client.GetProjectList(req) if err != nil { return nil } diff --git a/cmd/region.go b/cmd/region.go index 42f7b51c05..4755293c2b 100644 --- a/cmd/region.go +++ b/cmd/region.go @@ -26,7 +26,7 @@ import ( "github.com/ucloud/ucloud-sdk-go/services/uaccount" - "github.com/ucloud/ucloud-cli/base" + "github.com/ucloud/ucloud-cli/cmd/internal/platform" ) // NewCmdRegion ucloud region @@ -39,14 +39,14 @@ func NewCmdRegion(out io.Writer) *cobra.Command { Run: func(cmd *cobra.Command, args []string) { regionIns, err := fetchRegion() if err != nil { - base.HandleError(err) + platform.HandleError(err) return } regionList := make([]RegionTable, 0) for region, zones := range regionIns.Labels { regionList = append(regionList, RegionTable{region, strings.Join(zones, ", ")}) } - base.PrintList(regionList, out) + platform.PrintList(regionList, out) }, } return cmd @@ -59,8 +59,9 @@ type RegionTable struct { } func getDefaultRegion() (string, string, error) { - req := &uaccount.GetRegionRequest{} - resp, err := base.BizClient.GetRegion(req) + client := newServiceClient(uaccount.NewClient) + req := client.NewGetRegionRequest() + resp, err := client.GetRegion(req) if err != nil { return "", "", err } @@ -83,8 +84,9 @@ type Region struct { } func fetchRegion() (*Region, error) { - req := base.BizClient.NewGetRegionRequest() - resp, err := base.BizClient.GetRegion(req) + client := newServiceClient(uaccount.NewClient) + req := client.NewGetRegionRequest() + resp, err := client.GetRegion(req) if err != nil { return nil, err } @@ -101,13 +103,13 @@ func fetchRegion() (*Region, error) { return region, nil } -func fetchRegionWithConfig(cfg *base.AggConfig) (*Region, error) { - bc, err := base.GetBizClient(cfg) - req := bc.NewGetRegionRequest() +func fetchRegionWithConfig(cfg *platform.AggConfig) (*Region, error) { + client, err := newServiceClientForConfig(cfg, uaccount.NewClient) if err != nil { return nil, err } - resp, err := bc.GetRegion(req) + req := client.NewGetRegionRequest() + resp, err := client.GetRegion(req) if err != nil { return nil, err } @@ -168,9 +170,10 @@ func getZoneList(region string) []string { var errNoDefaultProject = errors.New("No default project") func getDefaultProject() (string, string, error) { - req := base.BizClient.NewGetProjectListRequest() + client := newServiceClient(uaccount.NewClient) + req := client.NewGetProjectListRequest() - resp, err := base.BizClient.GetProjectList(req) + resp, err := client.GetProjectList(req) if err != nil { return "", "", err } @@ -182,14 +185,14 @@ func getDefaultProject() (string, string, error) { return "", "", errNoDefaultProject } -func getDefaultProjectWithConfig(cfg *base.AggConfig) (string, string, error) { - bc, err := base.GetBizClient(cfg) +func getDefaultProjectWithConfig(cfg *platform.AggConfig) (string, string, error) { + client, err := newServiceClientForConfig(cfg, uaccount.NewClient) if err != nil { return "", "", err } - req := bc.NewGetProjectListRequest() - resp, err := bc.GetProjectList(req) + req := client.NewGetProjectListRequest() + resp, err := client.GetProjectList(req) if err != nil { return "", "", err } @@ -202,28 +205,28 @@ func getDefaultProjectWithConfig(cfg *base.AggConfig) (string, string, error) { } // fetchProjectListWithConfig 用指定 profile 的凭证拉取完整项目列表(含默认标记) -func fetchProjectListWithConfig(cfg *base.AggConfig) ([]uaccount.ProjectListInfo, error) { - bc, err := base.GetBizClient(cfg) +func fetchProjectListWithConfig(cfg *platform.AggConfig) ([]uaccount.ProjectListInfo, error) { + client, err := newServiceClientForConfig(cfg, uaccount.NewClient) if err != nil { return nil, err } - req := bc.NewGetProjectListRequest() - resp, err := bc.GetProjectList(req) + req := client.NewGetProjectListRequest() + resp, err := client.GetProjectList(req) if err != nil { return nil, err } return resp.ProjectSet, nil } -func fetchProjectWithConfig(cfg *base.AggConfig) (map[string]bool, error) { - bc, err := base.GetBizClient(cfg) +func fetchProjectWithConfig(cfg *platform.AggConfig) (map[string]bool, error) { + client, err := newServiceClientForConfig(cfg, uaccount.NewClient) if err != nil { return nil, err } - req := bc.NewGetProjectListRequest() - resp, err := bc.GetProjectList(req) + req := client.NewGetProjectListRequest() + resp, err := client.GetProjectList(req) if err != nil { return nil, err } @@ -235,7 +238,7 @@ func fetchProjectWithConfig(cfg *base.AggConfig) (map[string]bool, error) { return projects, nil } -func getReasonableProject(cfg *base.AggConfig) (string, error) { +func getReasonableProject(cfg *platform.AggConfig) (string, error) { if cfg.ProjectID == "" { id, _, err := getDefaultProjectWithConfig(cfg) if err != nil { @@ -260,9 +263,10 @@ func isUserCertified(userInfo *uaccount.UserInfo) bool { } func getUserInfo() (*uaccount.UserInfo, error) { - req := base.BizClient.NewGetUserInfoRequest() + client := newServiceClient(uaccount.NewClient) + req := client.NewGetUserInfoRequest() var userInfo uaccount.UserInfo - resp, err := base.BizClient.GetUserInfo(req) + resp, err := client.GetUserInfo(req) if err != nil { return nil, err @@ -277,7 +281,7 @@ func getUserInfo() (*uaccount.UserInfo, error) { if err != nil { return nil, err } - fileFullPath := base.GetConfigDir() + "/user.json" + fileFullPath := platform.GetConfigDir() + "/user.json" err = ioutil.WriteFile(fileFullPath, bytes, 0600) if err != nil { return nil, err diff --git a/cmd/root.go b/cmd/root.go index ee20193f66..8b92d4eba0 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -24,20 +24,21 @@ import ( "github.com/spf13/cobra" "github.com/spf13/pflag" - "github.com/ucloud/ucloud-cli/base" + "github.com/ucloud/ucloud-cli/cmd/internal/platform" + "github.com/ucloud/ucloud-cli/cmd/internal/version" "github.com/ucloud/ucloud-cli/pkg/cli" "github.com/ucloud/ucloud-cli/pkg/command" "github.com/ucloud/ucloud-cli/pkg/ui" "github.com/ucloud/ucloud-sdk-go/ucloud/log" ) -var global = &base.Global +var global = &platform.Global // NewCmdRoot 创建rootCmd rootCmd represents the base command when called without any subcommands func NewCmdRoot() *cobra.Command { cmd := &cobra.Command{ Use: "ucloud", - Short: "UCloud CLI v" + base.Version, + Short: "UCloud CLI v" + version.Version, Long: `UCloud CLI - manage UCloud resources and developer workflow`, DisableAutoGenTag: true, // PersistentPreRun runs the per-invocation auth/config init for the @@ -50,12 +51,13 @@ func NewCmdRoot() *cobra.Command { initialize(c) }, Run: func(cmd *cobra.Command, args []string) { + syncLegacyJSONFlag(os.Stdout) if global.Version { - base.Cxt.Printf("ucloud cli %s\n", base.Version) + platform.Cxt.Printf("ucloud cli %s\n", version.Version) } else if global.Completion { NewCmdCompletion().Run(cmd, args) } else if global.Config { - base.ListAggConfig(global.JSON) + platform.ListAggConfig(global.JSON) } else if global.Signup { NewCmdSignup().Run(cmd, args) } else { @@ -73,7 +75,7 @@ func NewCmdRoot() *cobra.Command { cmd.Flags().BoolVar(&global.Config, "config", false, "Display configuration") cmd.Flags().BoolVar(&global.Signup, "signup", false, "Launch UCloud sign up page in browser") - command.SetPersistentCompletion(cmd, "profile", func() []string { return base.AggConfigListIns.GetProfileNameList() }) + command.SetPersistentCompletion(cmd, "profile", func() []string { return platform.AggConfigListIns.GetProfileNameList() }) command.SetPersistentCompletion(cmd, "output", func() []string { return []string{"json", "table", "yaml"} }) cmd.SetHelpTemplate(helpTmpl) cmd.SetUsageTemplate(usageTmpl) @@ -129,10 +131,10 @@ func newSchemaCmd() *cobra.Command { Run: func(c *cobra.Command, args []string) { out, err := cli.RenderSchemaJSON(c.Root()) if err != nil { - base.HandleError(err) + platform.HandleError(err) return } - fmt.Fprintln(base.Cxt.GetWriter(), out) + fmt.Fprintln(platform.Cxt.GetWriter(), out) }, } } @@ -154,7 +156,7 @@ func addChildren(root *cobra.Command) { // The set and order of AddCommand calls must stay identical to preserve // the command-tree golden (hack/snapshot/testdata/cmdtree.golden). func addPlatformCommands(root *cobra.Command) { - out := base.Cxt.GetWriter() + out := platform.Cxt.GetWriter() root.AddCommand(NewCmdInit()) root.AddCommand(NewCmdAuth()) root.AddCommand(NewCmdDoc(out)) @@ -162,7 +164,6 @@ func addPlatformCommands(root *cobra.Command) { root.AddCommand(NewCmdRegion(out)) root.AddCommand(NewCmdProject()) // uhost migrated to products/uhost (Part 6); registered via products.gen.go. - root.AddCommand(NewCmdExt()) root.AddCommand(NewCmdAPI(out)) root.AddCommand(NewCmdSignature()) root.AddCommand(newSchemaCmd()) @@ -199,15 +200,25 @@ func applyGlobalOverrideFlags(root *cobra.Command) { // (post-InitConfig) and AddChildrenForSnapshot (stubbed values). func buildContext() *cli.Context { return cli.NewContext(cli.Deps{ - In: os.Stdin, - Out: os.Stdout, - Err: os.Stderr, - Format: decideOutputFormat(os.Stdout), - Config: base.ConfigIns, - RegionList: getRegionList, - ZoneList: getZoneList, - ProjectList: getProjectList, - AllRegions: getAllRegions, + In: os.Stdin, + Out: os.Stdout, + Err: os.Stderr, + Format: decideOutputFormat(os.Stdout), + DefaultsProvider: runtimeDefaults, + RegionList: getRegionList, + ZoneList: getZoneList, + ProjectList: getProjectList, + AllRegions: getAllRegions, + ClientConfig: runtimeClientConfig, + BuildCredential: runtimeCredential, + AttachHandlers: attachRuntimeHandlers, + HandleError: platform.HandleErrorTo, + LogInfo: platform.LogInfo, + LogPrint: platform.LogPrintTo, + LogWarn: platform.LogWarnTo, + LogError: platform.LogErrorTo, + LogFilePath: platform.GetLogFilePath, + NewPoller: cli.NewPoller, }) } @@ -217,23 +228,25 @@ func Execute() { // Phase 3 脱敏扩面:panic 路径兜底,避免 panic 消息(可能含 token/header)原样落到 stderr defer func() { if r := recover(); r != nil { - fmt.Fprintln(os.Stderr, base.Redact(fmt.Sprintf("panic: %v", r))) + fmt.Fprintln(os.Stderr, platform.Redact(fmt.Sprintf("panic: %v", r))) os.Exit(1) } }() cmd := NewCmdRoot() - if base.InCloudShell { - err := base.InitConfigInCloudShell() + if platform.InCloudShell { + err := platform.InitConfigInCloudShell() if err != nil { - base.HandleError(err) + platform.HandleError(err) return } } - base.InitConfig() + platform.InitConfig() + setActiveRuntimeFromBaseGlobals() mode := os.Getenv("UCLOUD_CLI_DEBUG") if mode == "on" || global.Debug { - base.ClientConfig.LogLevel = log.DebugLevel - base.BizClient = base.NewClient(base.ClientConfig, base.AuthCredential, base.ConfigIns) + if rt := ensureRuntime(); rt.SDKConfig != nil { + rt.SDKConfig.LogLevel = log.DebugLevel + } } addChildren(cmd) @@ -241,7 +254,15 @@ func Execute() { targetCmd, flags, err := cmd.Find(os.Args[1:]) if err == nil { if targetCmd.Use == "api" { - targetCmd.Run(targetCmd, flags) + if targetCmd.RunE != nil { + if err := targetCmd.RunE(targetCmd, flags); err != nil { + os.Exit(1) + } + return + } + if targetCmd.Run != nil { + targetCmd.Run(targetCmd, flags) + } return } } @@ -319,6 +340,8 @@ func resetHelpFunc(cmd *cobra.Command) { } func initialize(cmd *cobra.Command) { + syncLegacyJSONFlag(os.Stdout) + // Finalize the product output format now that cobra has parsed --output. // buildContext() ran before flag parsing, so the format it set was // provisional (always JSON for non-TTY stdout, ignoring an explicit @@ -330,66 +353,70 @@ func initialize(cmd *cobra.Command) { flags := cmd.Flags() project, err := flags.GetString("project-id") if err == nil { - base.ClientConfig.ProjectId = project + if rt := ensureRuntime(); rt.SDKConfig != nil { + rt.SDKConfig.ProjectId = project + } } region, err := flags.GetString("region") if err == nil { - base.ClientConfig.Region = region + if rt := ensureRuntime(); rt.SDKConfig != nil { + rt.SDKConfig.Region = region + } } zone, err := flags.GetString("zone") if err == nil { - base.ClientConfig.Zone = zone + if rt := ensureRuntime(); rt.SDKConfig != nil { + rt.SDKConfig.Zone = zone + } } if isAuthSkippedCmd(cmd) { return } - if base.InCloudShell { + if platform.InCloudShell { return } - if base.ConfigIns.AuthMode == base.AuthModeOAuth { + rt := ensureRuntime() + if rt.Config.AuthMode == platform.AuthModeOAuth { // AP-1:oauth 凭据缺失/失效 → stderr + 非零退出(不复制下方 aksk 路径的 exit 0 反模式) - isTTY := base.IsStdinTTY() - if msg, ok := base.CheckOAuthRunnable(base.ConfigIns, isTTY); !ok { + isTTY := platform.IsStdinTTY() + if msg, ok := platform.CheckOAuthRunnable(rt.Config, isTTY); !ok { fmt.Fprintln(os.Stderr, msg) os.Exit(1) } - if err := base.EnsureFreshToken(base.ConfigIns, base.AggConfigListIns); err != nil { - fmt.Fprintln(os.Stderr, base.OAuthRefreshFailedHint(base.ConfigIns.Profile, isTTY, err)) + if err := platform.EnsureFreshToken(rt.Config, rt.Configs); err != nil { + fmt.Fprintln(os.Stderr, platform.OAuthRefreshFailedHint(rt.Config.Profile, isTTY, err)) os.Exit(1) } - // 刷新可能换了 token,重建 client 让新 Bearer 生效。 - // GetBizClient 会重建 ClientConfig 并硬编码 FatalLevel,若 Execute 已按 - // UCLOUD_CLI_DEBUG 设了 DebugLevel,需在重建后恢复(SDK logger 在 - // NewClient 时捕获 LogLevel,必须再 rebuild 一次才生效)。 - debugOn := base.ClientConfig.LogLevel == log.DebugLevel - bc, err := base.GetBizClient(base.ConfigIns) - if err != nil { - base.HandleError(err) - } else { - base.BizClient = bc + debugOn := rt.SDKConfig != nil && rt.SDKConfig.LogLevel == log.DebugLevel + if err := platform.InitClientRuntime(rt.Config); err != nil { + platform.HandleError(err) } + setActiveRuntimeFromBaseGlobals() if debugOn { - base.ClientConfig.LogLevel = log.DebugLevel - base.BizClient = base.NewClient(base.ClientConfig, base.AuthCredential, base.ConfigIns) + ensureRuntime().SDKConfig.LogLevel = log.DebugLevel } return } // 既有 AK/SK 检查,原样保留(CRITICAL 回归约束:行为与文案零变化) - if base.ConfigIns.PrivateKey == "" { - base.Cxt.Println("private-key is empty. Execute command 'ucloud init|config' to configure it or run 'ucloud config list' to check your configurations") + if rt.Config.PrivateKey == "" { + platform.Cxt.Println("private-key is empty. Execute command 'ucloud init|config' to configure it or run 'ucloud config list' to check your configurations") os.Exit(0) } - if base.ConfigIns.PublicKey == "" { - base.Cxt.Println("public-key is empty. Execute command 'ucloud init|config' to configure it or run 'ucloud config list' to check your configurations") + if rt.Config.PublicKey == "" { + platform.Cxt.Println("public-key is empty. Execute command 'ucloud init|config' to configure it or run 'ucloud config list' to check your configurations") os.Exit(0) } } +func syncLegacyJSONFlag(out io.Writer) { + global.JSON = decideOutputFormat(out) == cli.OutputJSON +} + // decideOutputFormat resolves the effective output format: explicit --output // wins; then legacy --json; otherwise JSON for non-TTY stdout, Table for TTY. func decideOutputFormat(out io.Writer) cli.OutputFormat { diff --git a/cmd/runtime.go b/cmd/runtime.go new file mode 100644 index 0000000000..694b12b8ed --- /dev/null +++ b/cmd/runtime.go @@ -0,0 +1,111 @@ +package cmd + +import ( + "fmt" + + "github.com/ucloud/ucloud-cli/cmd/internal/platform" + "github.com/ucloud/ucloud-cli/pkg/command" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/auth" +) + +type runtimeState struct { + Configs *platform.AggConfigManager + Config *platform.AggConfig + SDKConfig *sdk.Config + Credential *platform.CredentialConfig +} + +var activeRuntime *runtimeState +var runtimeAutoStub = true + +func buildRuntimeFromBaseGlobals() *runtimeState { + return &runtimeState{ + Configs: platform.AggConfigListIns, + Config: platform.ConfigIns, + SDKConfig: platform.ClientConfig, + Credential: platform.AuthCredential, + } +} + +func ensureRuntime() *runtimeState { + if activeRuntime == nil { + activeRuntime = buildRuntimeFromBaseGlobals() + } + if runtimeAutoStub && activeRuntime.SDKConfig == nil { + activeRuntime.SDKConfig = &sdk.Config{BaseUrl: platform.DefaultBaseURL} + platform.ClientConfig = activeRuntime.SDKConfig + } + if runtimeAutoStub && activeRuntime.Credential == nil { + activeRuntime.Credential = &platform.CredentialConfig{} + platform.AuthCredential = activeRuntime.Credential + } + return activeRuntime +} + +func setActiveRuntimeFromBaseGlobals() { + runtimeAutoStub = true + activeRuntime = buildRuntimeFromBaseGlobals() +} + +func runtimeDefaults() command.Defaults { + rt := ensureRuntime() + if rt == nil || rt.Config == nil { + return command.Defaults{} + } + return command.Defaults{Region: rt.Config.Region, Zone: rt.Config.Zone, ProjectID: rt.Config.ProjectID} +} + +func runtimeClientConfig() *sdk.Config { + rt := ensureRuntime() + if rt == nil { + return nil + } + if !runtimeAutoStub && rt.SDKConfig == nil { + panic("cmd runtime disabled for snapshot completion") + } + return rt.SDKConfig +} + +func runtimeCredential() *auth.Credential { + rt := ensureRuntime() + if rt == nil { + return platform.BuildCredentialFrom(nil) + } + return platform.BuildCredentialFrom(rt.Credential) +} + +func attachRuntimeHandlers(sc sdk.ServiceClient) { + rt := ensureRuntime() + if rt == nil { + platform.AttachHandlersWith(sc, nil, nil, nil) + return + } + platform.AttachHandlersWith(sc, rt.Credential, rt.Config, rt.Configs) +} + +func newServiceClient[T sdk.ServiceClient](ctor func(*sdk.Config, *auth.Credential) T) T { + rt := ensureRuntime() + if rt == nil || rt.SDKConfig == nil { + panic("cmd runtime is not initialized") + } + client := ctor(rt.SDKConfig, platform.BuildCredentialFrom(rt.Credential)) + platform.AttachHandlersWith(client, rt.Credential, rt.Config, rt.Configs) + return client +} + +func newServiceClientForConfig[T sdk.ServiceClient](cfg *platform.AggConfig, ctor func(*sdk.Config, *auth.Credential) T) (T, error) { + var zero T + sdkConfig, credConfig, err := platform.BuildClientRuntime(cfg) + if sdkConfig == nil { + return zero, fmt.Errorf("build sdk config failed") + } + client := ctor(sdkConfig, platform.BuildCredentialFrom(credConfig)) + rt := ensureRuntime() + var manager *platform.AggConfigManager + if rt != nil { + manager = rt.Configs + } + platform.AttachHandlersWith(client, credConfig, cfg, manager) + return client, err +} diff --git a/cmd/snapshot_export.go b/cmd/snapshot_export.go index a2a000743b..a699333b52 100644 --- a/cmd/snapshot_export.go +++ b/cmd/snapshot_export.go @@ -4,29 +4,39 @@ import ( "github.com/spf13/cobra" sdk "github.com/ucloud/ucloud-sdk-go/ucloud" - "github.com/ucloud/ucloud-cli/base" + "github.com/ucloud/ucloud-cli/cmd/internal/platform" "github.com/ucloud/ucloud-cli/pkg/cli" ) // AddChildrenForSnapshot builds the full command tree for the structure golden, // without InitConfig/network side effects. Test-only helper. // -// Some NewCmdXxx constructors call base.BizClient.NewXxxRequest() and -// base.ClientConfig fields at construction time, so both must be non-nil. -// We initialise them with zero-credential stubs when InitConfig was skipped. +// Some NewCmdXxx constructors create service-specific SDK requests at +// construction time, so runtime SDK config and credential must be non-nil. We +// initialise them with zero-credential stubs when InitConfig was skipped. func AddChildrenForSnapshot(root *cobra.Command) { - if base.ClientConfig == nil { - base.ClientConfig = &sdk.Config{BaseUrl: base.DefaultBaseURL} + runtimeAutoStub = true + if platform.ClientConfig == nil { + platform.ClientConfig = &sdk.Config{BaseUrl: platform.DefaultBaseURL} } - if base.AuthCredential == nil { - base.AuthCredential = &base.CredentialConfig{} - } - if base.BizClient == nil { - base.BizClient = base.NewClient(base.ClientConfig, base.AuthCredential, nil) + if platform.AuthCredential == nil { + platform.AuthCredential = &platform.CredentialConfig{} } + setActiveRuntimeFromBaseGlobals() addChildren(root) } +// DisableRuntimeForSnapshotCompletion poisons runtime-backed dynamic +// completions after command construction, so snapshot rendering does not issue +// real network calls. It mirrors the old test behavior of nil-ing platform.BizClient +// after AddChildrenForSnapshot. +func DisableRuntimeForSnapshotCompletion() { + platform.ClientConfig = nil + platform.AuthCredential = nil + runtimeAutoStub = false + activeRuntime = buildRuntimeFromBaseGlobals() +} + // ProductsForSnapshot exposes the registered product list to the snapshot // golden tests (hack/snapshot): each product's subtree is rendered against // the golden the product team owns (products//testdata/). Test-only. diff --git a/cmd/test_helpers_test.go b/cmd/test_helpers_test.go new file mode 100644 index 0000000000..030a324ea6 --- /dev/null +++ b/cmd/test_helpers_test.go @@ -0,0 +1,29 @@ +package cmd + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func subCmd(t *testing.T, root *cobra.Command, name string) *cobra.Command { + t.Helper() + for _, c := range root.Commands() { + if c.Use == name { + return c + } + } + t.Fatalf("uhost subcommand %q not found", name) + return nil +} + +func topLevelCmd(t *testing.T, commands []*cobra.Command, name string) *cobra.Command { + t.Helper() + for _, c := range commands { + if c.Use == name { + return c + } + } + t.Fatalf("product top-level command %q not found", name) + return nil +} diff --git a/cmd/uhost_create_image_test.go b/cmd/uhost_create_image_test.go index a0bedcd4cc..b94f88ed2e 100644 --- a/cmd/uhost_create_image_test.go +++ b/cmd/uhost_create_image_test.go @@ -9,19 +9,20 @@ import ( "strings" "testing" - "github.com/ucloud/ucloud-cli/base" + "github.com/ucloud/ucloud-cli/cmd/internal/platform" "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" productuhost "github.com/ucloud/ucloud-cli/products/uhost" sdk "github.com/ucloud/ucloud-sdk-go/ucloud" ) func saveBaseGlobalsForCreateImage(t *testing.T) { t.Helper() - oldClientConfig, oldAuthCredential, oldConfigIns := base.ClientConfig, base.AuthCredential, base.ConfigIns + oldClientConfig, oldAuthCredential, oldConfigIns := platform.ClientConfig, platform.AuthCredential, platform.ConfigIns t.Cleanup(func() { - base.ClientConfig = oldClientConfig - base.AuthCredential = oldAuthCredential - base.ConfigIns = oldConfigIns + platform.ClientConfig = oldClientConfig + platform.AuthCredential = oldAuthCredential + platform.ConfigIns = oldConfigIns }) } @@ -46,16 +47,21 @@ func TestUhostCreateImageJSONEmitsStructuredResult(t *testing.T) { cfg.Region = "cn-bj2" cfg.Zone = "cn-bj2-03" cfg.ProjectId = "org-test" - base.ClientConfig = &cfg - base.AuthCredential = &base.CredentialConfig{PublicKey: "public", PrivateKey: "private"} - base.ConfigIns = &base.AggConfig{ProjectID: "org-test", Region: "cn-bj2", Zone: "cn-bj2-03"} + platform.ClientConfig = &cfg + platform.AuthCredential = &platform.CredentialConfig{PublicKey: "public", PrivateKey: "private"} + platform.ConfigIns = &platform.AggConfig{ProjectID: "org-test", Region: "cn-bj2", Zone: "cn-bj2-03"} var stdout, stderr bytes.Buffer ctx := cli.NewContext(cli.Deps{ Out: &stdout, Err: &stderr, Format: cli.OutputJSON, - Config: base.ConfigIns, + DefaultsProvider: func() command.Defaults { + return command.Defaults{ProjectID: platform.ConfigIns.ProjectID, Region: platform.ConfigIns.Region, Zone: platform.ConfigIns.Zone} + }, + ClientConfig: func() *sdk.Config { return platform.ClientConfig }, + BuildCredential: platform.BuildCredential, + AttachHandlers: platform.AttachHandlers, }) root := topLevelCmd(t, productuhost.New().NewCommand(ctx), "uhost") root.SetArgs([]string{ diff --git a/cmd/uhost_test.go b/cmd/uhost_test.go index 0fe747a8f0..10ad51accd 100644 --- a/cmd/uhost_test.go +++ b/cmd/uhost_test.go @@ -1,3 +1,6 @@ +//go:build live +// +build live + package cmd import ( @@ -8,51 +11,33 @@ import ( "testing" "time" - "github.com/spf13/cobra" - + svcuhost "github.com/ucloud/ucloud-sdk-go/services/uhost" sdk "github.com/ucloud/ucloud-sdk-go/ucloud" - "github.com/ucloud/ucloud-cli/base" + "github.com/ucloud/ucloud-cli/cmd/internal/platform" "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" "github.com/ucloud/ucloud-cli/products/uhost" ) // uhost_test.go drives the live UHost flow through the migrated products/uhost -// command tree (uhost moved out of cmd in Part 6). It hits the real API and -// needs valid credentials, so it is gated by name — run the rest of the suite -// with `go test ./... -skip TestUhost`. The image-id lookup the old test did via +// command tree (uhost moved out of cmd in Part 6). It hits the real API, +// creates paid resources, and needs valid credentials, so it is gated behind +// the `live` build tag. Run it explicitly with: +// `go test -tags live ./cmd -run '^TestUhost$' -count=1`. +// The image-id lookup the old test did via // the cmd-local NewCmdUImageList/ImageRow shim is now a direct DescribeImage SDK // call (image is served by the uhost SDK). create/delete narration now flows // through ctx.NewProgress → ctx.ProgressWriter (the ctx Out buffer in table -// mode) instead of the global ux.Doc, so the test captures the ctx Out buffer. - -// subCmd returns the child of root whose Use matches name. -func subCmd(t *testing.T, root *cobra.Command, name string) *cobra.Command { - for _, c := range root.Commands() { - if c.Use == name { - return c - } - } - t.Fatalf("uhost subcommand %q not found", name) - return nil -} - -func topLevelCmd(t *testing.T, commands []*cobra.Command, name string) *cobra.Command { - t.Helper() - for _, c := range commands { - if c.Use == name { - return c - } - } - t.Fatalf("product top-level command %q not found", name) - return nil -} +// mode) instead of the old global progress document, so the test captures the +// ctx Out buffer. // fetchLiveImageID returns the first Available Base image id via DescribeImage. func fetchLiveImageID(t *testing.T) string { - req := base.BizClient.NewDescribeImageRequest() + client := newServiceClient(svcuhost.NewClient) + req := client.NewDescribeImageRequest() req.ImageType = sdk.String("Base") - resp, err := base.BizClient.DescribeImage(req) + resp, err := client.DescribeImage(req) if err != nil { t.Fatalf("unexpected error fetching image list: %v", err) } @@ -66,34 +51,46 @@ func fetchLiveImageID(t *testing.T) string { } func TestUhost(t *testing.T) { - base.InitConfig() + platform.InitConfig() var out bytes.Buffer // Buffer-backed ctx (table mode): create/delete narration via ctx.NewProgress // routes to ProgressWriter == Out; the cmd-package completion providers + real // config preserve the live behaviour. ctx := cli.NewContext(cli.Deps{ - In: strings.NewReader(""), - Out: &out, - Err: &out, - Format: cli.OutputTable, - Config: base.ConfigIns, - RegionList: getRegionList, - ZoneList: getZoneList, - ProjectList: getProjectList, - AllRegions: getAllRegions, + In: strings.NewReader(""), + Out: &out, + Err: &out, + Format: cli.OutputTable, + DefaultsProvider: func() command.Defaults { + return command.Defaults{Region: platform.ConfigIns.Region, Zone: platform.ConfigIns.Zone, ProjectID: platform.ConfigIns.ProjectID} + }, + RegionList: getRegionList, + ZoneList: getZoneList, + ProjectList: getProjectList, + AllRegions: getAllRegions, + ClientConfig: func() *sdk.Config { return platform.ClientConfig }, + BuildCredential: platform.BuildCredential, + AttachHandlers: platform.AttachHandlers, }) root := topLevelCmd(t, uhost.New().NewCommand(ctx), "uhost") imageID := fetchLiveImageID(t) - run := func(name string, flags []string) string { + runE := func(name string, flags []string) (string, error) { out.Reset() subCmd(t, root, name) root.SetArgs(append([]string{name}, flags...)) if err := root.Execute(); err != nil { - t.Fatalf("unexpected error executing %s: %v, flags: %v", name, err, flags) + return out.String(), fmt.Errorf("unexpected error executing %s: %w, flags: %v", name, err, flags) + } + return out.String(), nil + } + run := func(name string, flags []string) string { + content, err := runE(name, flags) + if err != nil { + t.Fatalf("%v, output: %s", err, content) } - return out.String() + return content } createOut := run("create", []string{ @@ -113,6 +110,18 @@ func TestUhost(t *testing.T) { } uhostID := m[1] idFlag := fmt.Sprintf("--uhost-id=%s", uhostID) + deleted := false + defer func() { + if deleted { + return + } + content, err := runE("delete", []string{"--yes", "--destroy", idFlag}) + if err != nil { + t.Logf("cleanup delete failed for %s: %v, output: %s", uhostID, err, content) + return + } + t.Logf("cleanup delete succeeded for %s: %s", uhostID, content) + }() assertRun := func(name string, flags []string, re *regexp.Regexp) { content := run(name, flags) @@ -128,4 +137,5 @@ func TestUhost(t *testing.T) { assertRun("start", []string{idFlag}, regexp.MustCompile(`uhost\[([\w-]+)\] is starting\.\.\.done`)) assertRun("stop", []string{idFlag}, regexp.MustCompile(`uhost\[([\w-]+)\] is shutting down\.\.\.done`)) run("delete", []string{"--yes", "--destroy", idFlag}) + deleted = true } diff --git a/cmd/ulhost.go b/cmd/ulhost.go deleted file mode 100644 index 9344e944d5..0000000000 --- a/cmd/ulhost.go +++ /dev/null @@ -1,26 +0,0 @@ -package cmd - -import ( - "github.com/ucloud/ucloud-sdk-go/ucloud/request" - - "github.com/ucloud/ucloud-cli/base" -) - -var ulhostSpoller = base.NewSpoller(sdescribeULHostByID, base.Cxt.GetWriter()) - -func sdescribeULHostByID(ulhostID string, common *request.CommonBase) (interface{}, error) { - req := base.BizClient.UCompShareClient.NewDescribeULHostInstanceRequest() - req.ULHostIds = []string{ulhostID} - if common != nil { - req.CommonBase = *common - } - resp, err := base.BizClient.UCompShareClient.DescribeULHostInstance(req) - if err != nil { - return nil, err - } - if len(resp.ULHostInstanceSets) < 1 { - return nil, nil - } - - return &resp.ULHostInstanceSets[0], nil -} diff --git a/cmd/util.go b/cmd/util.go index 8dd86b2c91..2cf64d59d4 100644 --- a/cmd/util.go +++ b/cmd/util.go @@ -11,27 +11,30 @@ import ( "github.com/ucloud/ucloud-sdk-go/ucloud/request" - "github.com/ucloud/ucloud-cli/base" + "github.com/ucloud/ucloud-cli/cmd/internal/platform" "github.com/ucloud/ucloud-cli/pkg/command" - "github.com/ucloud/ucloud-cli/ux" + "github.com/ucloud/ucloud-cli/pkg/ui" ) func bindRegion(req request.Common, cmd *cobra.Command) { var region string - cmd.Flags().StringVar(®ion, "region", base.ConfigIns.Region, "Optional. Override default region for this command invocation, see 'ucloud region'") + def := runtimeDefaults() + cmd.Flags().StringVar(®ion, "region", def.Region, "Optional. Override default region for this command invocation, see 'ucloud region'") command.SetCompletion(cmd, "region", getRegionList) req.SetRegionRef(®ion) } func bindRegionS(region *string, cmd *cobra.Command) { - *region = base.ConfigIns.Region - cmd.Flags().StringVar(region, "region", base.ConfigIns.Region, "Optional. Override default region for this command invocation, see 'ucloud region'") + def := runtimeDefaults() + *region = def.Region + cmd.Flags().StringVar(region, "region", def.Region, "Optional. Override default region for this command invocation, see 'ucloud region'") command.SetCompletion(cmd, "region", getRegionList) } func bindZone(req request.Common, cmd *cobra.Command) { var zone string - cmd.Flags().StringVar(&zone, "zone", base.ConfigIns.Zone, "Optional. Override default availability zone for this command invocation, see 'ucloud region'") + def := runtimeDefaults() + cmd.Flags().StringVar(&zone, "zone", def.Zone, "Optional. Override default availability zone for this command invocation, see 'ucloud region'") command.SetCompletion(cmd, "zone", func() []string { return getZoneList(req.GetRegion()) }) @@ -55,8 +58,9 @@ func bindZoneEmptyS(zone, region *string, cmd *cobra.Command) { } func bindZoneS(zone, region *string, cmd *cobra.Command) { - *zone = base.ConfigIns.Zone - cmd.Flags().StringVar(zone, "zone", base.ConfigIns.Zone, "Optional. Override default availability zone for this command invocation, see 'ucloud region'") + def := runtimeDefaults() + *zone = def.Zone + cmd.Flags().StringVar(zone, "zone", def.Zone, "Optional. Override default availability zone for this command invocation, see 'ucloud region'") command.SetCompletion(cmd, "zone", func() []string { return getZoneList(*region) }) @@ -64,14 +68,16 @@ func bindZoneS(zone, region *string, cmd *cobra.Command) { func bindProjectID(req request.Common, cmd *cobra.Command) { var project string - cmd.Flags().StringVar(&project, "project-id", base.ConfigIns.ProjectID, "Optional. Override default project-id for this command invocation, see 'ucloud project list'") + def := runtimeDefaults() + cmd.Flags().StringVar(&project, "project-id", def.ProjectID, "Optional. Override default project-id for this command invocation, see 'ucloud project list'") command.SetCompletion(cmd, "project-id", getProjectList) req.SetProjectIdRef(&project) } func bindProjectIDS(project *string, cmd *cobra.Command) { - *project = base.ConfigIns.ProjectID - cmd.Flags().StringVar(project, "project-id", base.ConfigIns.ProjectID, "Optional. Override default project-id for this command invocation, see 'ucloud project list'") + def := runtimeDefaults() + *project = def.ProjectID + cmd.Flags().StringVar(project, "project-id", def.ProjectID, "Optional. Override default project-id for this command invocation, see 'ucloud project list'") command.SetCompletion(cmd, "project-id", getProjectList) } @@ -137,7 +143,7 @@ func (c *concurrentAction) actionFuncWrapper(req request.Common) { success, logs := c.actionFunc(req) c.result <- success logs = append([]string{"========================================"}, logs...) - base.LogInfo(logs...) + platform.LogInfo(logs...) <-c.tokens time.Sleep(time.Second / 5) c.wg.Done() @@ -146,10 +152,12 @@ func (c *concurrentAction) actionFuncWrapper(req request.Common) { func (c *concurrentAction) Do() { count := len(c.reqs) success, fail := 0, 0 - refresh := ux.NewRefresh() + progressOut := platform.Cxt.GetWriter() + refresh := ui.NewRefresh(progressOut) //同时执行任务数量大于5时,不再单独显示每一个任务的进行情况,而是聚合显示 if count > 5 { - ux.Doc.Disable() + doc := ui.NewDocument(progressOut) + doc.Disable() refresh.Do(fmt.Sprintf("total:%d, doing:%d, success:%d, fail:%d", count, len(c.tokens), success, fail)) } go func() { @@ -164,7 +172,7 @@ func (c *concurrentAction) Do() { case <-time.Tick(time.Second / 30): if count == (success+fail) && fail > 0 { - fmt.Printf("Check logs in %s\n", base.GetLogFilePath()) + fmt.Printf("Check logs in %s\n", platform.GetLogFilePath()) return } if count > 5 { diff --git a/hack/check-product/main.go b/hack/check-product/main.go index 5956cb0157..a8a7a8477f 100644 --- a/hack/check-product/main.go +++ b/hack/check-product/main.go @@ -19,8 +19,8 @@ // // 1. No cross-product imports: a file under products/A/... must not import // github.com/ucloud/ucloud-cli/products/B (for any B != A). -// 2. No cmd or base imports: product files must not import -// github.com/ucloud/ucloud-cli/cmd or .../base. +// 2. No platform-internal or legacy imports: product files must not import +// github.com/ucloud/ucloud-cli/cmd, .../base, .../ux, or .../ansi. // 3. No bare SDK NewClient calls (best-effort AST): flag svc.NewClient(...) // where svc is not the identifier "cli" (products must use // cli.NewServiceClient). @@ -246,20 +246,26 @@ func checkFile(path, productName string) []string { // stay in sync, so classify each import path exactly once. isCmdImport := importPath == moduleRoot+"/cmd" || strings.HasPrefix(importPath, moduleRoot+"/cmd/") - isBaseImport := importPath == moduleRoot+"/base" || - strings.HasPrefix(importPath, moduleRoot+"/base/") + legacyPlatformPackage := "" + for _, name := range []string{"base", "ux", "ansi"} { + prefix := moduleRoot + "/" + name + if importPath == prefix || strings.HasPrefix(importPath, prefix+"/") { + legacyPlatformPackage = name + break + } + } isProductsImport := strings.HasPrefix(importPath, productsPrefix) - // Rule 2: no cmd or base imports. + // Rule 2: no platform-internal or legacy imports. if isCmdImport { violations = append(violations, fmt.Sprintf("%s: rule2: product must not import cmd package %q", pos(imp.Path), importPath)) } - if isBaseImport { + if legacyPlatformPackage != "" { violations = append(violations, - fmt.Sprintf("%s: rule2: product must not import base package %q", - pos(imp.Path), importPath)) + fmt.Sprintf("%s: rule2: product must not import legacy %s package %q", + pos(imp.Path), legacyPlatformPackage, importPath)) } // Rule 1: no cross-product imports. @@ -279,9 +285,9 @@ func checkFile(path, productName string) []string { } } - // Rule 9: §6.1 whitelist. cmd/base and products/ prefixes are owned by + // Rule 9: §6.1 whitelist. cmd, legacy platform, and products/ prefixes are owned by // rules 1-2 above (more specific messages) — rule 9 covers the rest. - isRule12Territory := isCmdImport || isBaseImport || isProductsImport + isRule12Territory := isCmdImport || legacyPlatformPackage != "" || isProductsImport if !isRule12Territory && !importAllowed(importPath, productName) { violations = append(violations, fmt.Sprintf("%s: rule9: import %q is outside the §6.1 product import whitelist (allowed: stdlib, ucloud-sdk-go, spf13/cobra|pflag, pkg/cli|command|ui, internal/common, own product); extending the whitelist is a platform PR", @@ -386,7 +392,6 @@ var reservedCommands = map[string]bool{ "region": true, // NewCmdRegion "project": true, // NewCmdProject // uhost migrated to products/uhost (Part 6) — no longer platform-reserved. - "ext": true, // NewCmdExt "api": true, // NewCmdAPI "signature": true, // NewCmdSignature "__schema": true, // newSchemaCmd (hidden) diff --git a/hack/check-product/main_test.go b/hack/check-product/main_test.go index 696efcd51f..d1f21bcdec 100644 --- a/hack/check-product/main_test.go +++ b/hack/check-product/main_test.go @@ -84,14 +84,12 @@ var _ = cmd.Root func TestCheckFile_Rule2_BaseImport(t *testing.T) { dir := t.TempDir() - src := `package cmd + src := "package cmd\n\n" + + "import (\n" + + "\t\"" + moduleRoot + "/base\"\n" + + ")\n\n" + + "var _ = base.Foo\n" -import ( - "github.com/ucloud/ucloud-cli/base" -) - -var _ = base.Foo -` path := writeFile(t, dir, "udb/cmd.go", src) got := checkFile(path, "udb") if len(got) == 0 { @@ -102,6 +100,43 @@ var _ = base.Foo } } +func TestCheckFile_Rule2_LegacyPlatformImports(t *testing.T) { + cases := []struct { + name string + src string + }{ + { + name: "ux", + src: "package cmd\n\nimport \"" + moduleRoot + "/ux\"\n\nvar _ = ux.Doc\n", + }, + { + name: "ansi", + src: "package cmd\n\nimport \"" + moduleRoot + "/ansi\"\n\nvar _ = ansi.CursorLeft\n", + }, + { + name: "cmd/internal", + src: `package cmd + +import "github.com/ucloud/ucloud-cli/cmd/internal/runtime" + +var _ = runtime.Active +`, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + path := writeFile(t, t.TempDir(), "udb/cmd.go", tc.src) + got := checkFile(path, "udb") + if len(got) == 0 { + t.Fatalf("expected violation for %s import, got none", tc.name) + } + if !strings.Contains(got[0], "rule2") { + t.Errorf("expected rule2 in violation, got: %v", got) + } + }) + } +} + func TestCheckFile_Rule3_BareNewClient(t *testing.T) { dir := t.TempDir() src := `package cmd @@ -377,6 +412,12 @@ func TestReservedCommands_ExcludesMigratedSubnetProductCommand(t *testing.T) { } } +func TestReservedCommands_ExcludesMigratedExtProductCommand(t *testing.T) { + if reservedCommands["ext"] { + t.Fatal("reservedCommands must not include ext after it migrates to products/eip") + } +} + // -------------------------------------------------------------------------- // checkCommandCollisions tests (rule7) // -------------------------------------------------------------------------- @@ -556,13 +597,11 @@ func TestCheckFile_Rule9_ImportWhitelist(t *testing.T) { } func TestCheckFile_Rule9_DoesNotDuplicateRules12(t *testing.T) { - src := `package p - -import ( - _ "github.com/ucloud/ucloud-cli/base" - _ "github.com/ucloud/ucloud-cli/products/vpc/internal/vpc" -) -` + src := "package p\n\n" + + "import (\n" + + "\t_ \"" + moduleRoot + "/base\"\n" + + "\t_ \"" + moduleRoot + "/products/vpc/internal/vpc\"\n" + + ")\n" joined := strings.Join(checkFile(writeFile(t, t.TempDir(), "x.go", src), "udb"), "\n") if strings.Count(joined, "ucloud-cli/base") != 1 || strings.Count(joined, "products/vpc") != 1 { t.Fatalf("rule1/2 territory must be flagged exactly once (by rule1/2, not rule9):\n%s", joined) diff --git a/hack/snapshot/completion.go b/hack/snapshot/completion.go index 2ff7ad631c..e6a7d74bbd 100644 --- a/hack/snapshot/completion.go +++ b/hack/snapshot/completion.go @@ -21,9 +21,9 @@ type completionResult struct { // // Dynamic detection: SetCompletion closures touch the network-backing globals // which the test nils after tree construction, causing a nil-pointer panic. -// Platform (cmd) closures dereference base.BizClient; product (products/mysql) +// Platform (cmd) closures dereference platform.BizClient; product (products/mysql) // closures go through cli.NewServiceClient, which builds an SDK client from -// base.ClientConfig — so the test nils both (see TestWriteCompletionBaseline). +// platform.ClientConfig — so the test nils both (see TestWriteCompletionBaseline). // We recover from the panic and mark the flag dynamic. A closure may also // signal dynamic explicitly by returning cobra.ShellCompDirectiveError. // SetFlagValues closures return a fixed slice and never touch those globals, so diff --git a/hack/snapshot/snapshot_test.go b/hack/snapshot/snapshot_test.go index 4fa1c563be..8bea4ad5cf 100644 --- a/hack/snapshot/snapshot_test.go +++ b/hack/snapshot/snapshot_test.go @@ -4,17 +4,19 @@ import ( "fmt" "os" "path/filepath" + "regexp" "sort" "strings" "testing" "github.com/spf13/cobra" - "github.com/ucloud/ucloud-cli/base" "github.com/ucloud/ucloud-cli/cmd" ) const goldenPath = "testdata/cmdtree.golden" +var rootVersionLine = regexp.MustCompile(`UCloud CLI v[^\n]+`) + func TestWriteBaseline(t *testing.T) { root := cmd.NewCmdRoot() cmd.AddChildrenForSnapshot(root) @@ -22,10 +24,8 @@ func TestWriteBaseline(t *testing.T) { // The version string (root's Short: "UCloud CLI vX.Y.Z") is an intended, // separately-managed value — not part of the command-tree structure we guard. // Normalize it to a stable placeholder so the golden is version-insensitive - // (base.Version may be a const "0.3.3", "dev", or an ldflags-injected tag). - if base.Version != "" { - got = strings.ReplaceAll(got, "v"+base.Version, "v{VERSION}") - } + // without importing platform-internal version state from outside cmd/. + got = rootVersionLine.ReplaceAllString(got, "UCloud CLI v{VERSION}") compareOrWrite(t, goldenPath, got, "WRITE_CMDTREE_GOLDEN") } @@ -35,20 +35,11 @@ const completionGoldenPath = "testdata/completion.golden" func TestWriteCompletionBaseline(t *testing.T) { root := cmd.NewCmdRoot() cmd.AddChildrenForSnapshot(root) - // Nil out the network-backing globals so dynamic completions panic-on-invoke - // (SetFlagValues closures are immune; SetCompletion closures dereference them). - // - // Platform (cmd) dynamic completions dereference base.BizClient directly, so - // nil-ing it makes them panic. Product (products/mysql) dynamic completions go - // through cli.NewServiceClient → ctor(base.ClientConfig, base.BuildCredential()) - // → SDK call, which ignores base.BizClient; nil-ing base.ClientConfig makes the - // SDK request build panic instead of issuing a real (non-deterministic, slow) - // network call. AuthCredential is nil'd alongside for symmetry. This must run - // AFTER AddChildrenForSnapshot, since some constructors build requests at - // construction time and need the non-nil stubs. - base.BizClient = nil - base.ClientConfig = nil - base.AuthCredential = nil + // Disable network-backing runtime state after command construction so + // dynamic completions panic-on-invoke instead of issuing real network calls. + // SetFlagValues closures are immune; SetCompletion closures dereference the + // runtime-backed clients. + cmd.DisableRuntimeForSnapshotCompletion() got := RenderCompletionPlatform(root, productSkipSet()) compareOrWrite(t, completionGoldenPath, got, "WRITE_COMPLETION_GOLDEN") @@ -176,9 +167,7 @@ func TestProductGoldens(t *testing.T) { func TestProductCompletionGoldens(t *testing.T) { root := cmd.NewCmdRoot() cmd.AddChildrenForSnapshot(root) - base.BizClient = nil - base.ClientConfig = nil - base.AuthCredential = nil + cmd.DisableRuntimeForSnapshotCompletion() for _, p := range cmd.ProductsForSnapshot() { meta := p.Metadata() t.Run(meta.Name, func(t *testing.T) { @@ -207,9 +196,7 @@ func TestGoldenPartition(t *testing.T) { root2 := cmd.NewCmdRoot() cmd.AddChildrenForSnapshot(root2) - base.BizClient = nil - base.ClientConfig = nil - base.AuthCredential = nil + cmd.DisableRuntimeForSnapshotCompletion() fullC := RenderCompletion(root2) partsC := RenderCompletionPlatform(root2, productSkipSet()) for _, p := range cmd.ProductsForSnapshot() { diff --git a/hack/snapshot/testdata/cmdtree.golden b/hack/snapshot/testdata/cmdtree.golden index 440b159f99..193adca1c3 100644 --- a/hack/snapshot/testdata/cmdtree.golden +++ b/hack/snapshot/testdata/cmdtree.golden @@ -49,21 +49,6 @@ ucloud config update use=update short=update configurations flag=region short= default= required= flag=timeout-sec short= default= required= flag=zone short= default= required= -ucloud ext use=ext short=extended commands of UCloud CLI -ucloud ext uhost use=uhost short=extended uhost commands -ucloud ext uhost switch-eip use=switch-eip short=Switch EIP for UHost instances - flag=create-eip-bandwidth-mb short= default=1 required= - flag=create-eip-charge-type short= default=Month required= - flag=create-eip-quantity short= default=1 required= - flag=create-eip-share-bandwidth-id short= default= required= - flag=create-eip-traffic-mode short= default=Bandwidth required= - flag=eip-addr short= default=[] required= - flag=project-id short= default= required= - flag=region short= default= required= - flag=release-all short= default=true required= - flag=uhost-id short= default=[] required=true - flag=unbind-all short= default=true required= - flag=zone short= default= required= ucloud gendoc use=gendoc short=Generate documents for all commands flag=dir short= default= required=true flag=format short= default=douku required= diff --git a/hack/snapshot/testdata/completion.golden b/hack/snapshot/testdata/completion.golden index 0e9e97807f..35d00ce53f 100644 --- a/hack/snapshot/testdata/completion.golden +++ b/hack/snapshot/testdata/completion.golden @@ -17,11 +17,5 @@ ucloud config update profile static ucloud config update project-id dynamic ucloud config update region dynamic ucloud config update zone dynamic -ucloud ext uhost switch-eip create-eip-charge-type static Dynamic,Month,Trial,Year -ucloud ext uhost switch-eip create-eip-traffic-mode static Bandwidth,ShareBandwidth,Traffic -ucloud ext uhost switch-eip project-id dynamic -ucloud ext uhost switch-eip region dynamic -ucloud ext uhost switch-eip uhost-id dynamic -ucloud ext uhost switch-eip zone dynamic ucloud gendoc dir static ucloud gendoc format static douku,markdown,rst diff --git a/pkg/cli/context.go b/pkg/cli/context.go index 58b64de3ad..09834a58a8 100644 --- a/pkg/cli/context.go +++ b/pkg/cli/context.go @@ -1,10 +1,15 @@ package cli import ( + "fmt" "io" "sync/atomic" - "github.com/ucloud/ucloud-cli/base" + "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/auth" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/command" ) // OutputFormat controls how command results are rendered. @@ -24,11 +29,11 @@ const ( // common configuration. Heavy methods (NewServiceClient, PrintList, // Confirm, Poller, flag binding) are added in later tasks (B2, B5, D1). type Context struct { - in io.Reader - out io.Writer - err io.Writer - format OutputFormat - config *base.AggConfig + in io.Reader + out io.Writer + err io.Writer + format OutputFormat + defaultsProvider func() command.Defaults // Completion candidate providers injected by the host so that bind // helpers can register dynamic completion without pkg/command importing @@ -40,6 +45,18 @@ type Context struct { // completion providers) for non-standard flags like uhost --all-region. allRegions func() ([]string, error) + clientConfig func() *ucloud.Config + buildCredential func() *auth.Credential + attachHandlers func(ucloud.ServiceClient) + + handleError func(io.Writer, error) + logInfo func(...string) + logPrint func(io.Writer, ...string) + logWarn func(io.Writer, ...string) + logError func(io.Writer, ...string) + logFilePath func() string + newPoller func(func(string, *request.CommonBase) (interface{}, error), io.Writer) Poller + // errCount tallies HandleError calls this invocation so the host (cmd) can // set a non-zero exit code when any product error occurred (aws/gcloud // convention). Atomic because product commands can call HandleError from @@ -54,26 +71,86 @@ type Deps struct { Out io.Writer Err io.Writer Format OutputFormat - Config *base.AggConfig - RegionList func() []string - ZoneList func(region string) []string - ProjectList func() []string - AllRegions func() ([]string, error) + DefaultsProvider func() command.Defaults + RegionList func() []string + ZoneList func(region string) []string + ProjectList func() []string + AllRegions func() ([]string, error) + + ClientConfig func() *ucloud.Config + BuildCredential func() *auth.Credential + AttachHandlers func(ucloud.ServiceClient) + + HandleError func(io.Writer, error) + LogInfo func(...string) + LogPrint func(io.Writer, ...string) + LogWarn func(io.Writer, ...string) + LogError func(io.Writer, ...string) + LogFilePath func() string + NewPoller func(func(string, *request.CommonBase) (interface{}, error), io.Writer) Poller } // NewContext constructs a Context from the provided Deps. func NewContext(d Deps) *Context { + if d.Out == nil { + d.Out = io.Discard + } + if d.Err == nil { + d.Err = io.Discard + } + if d.DefaultsProvider == nil { + d.DefaultsProvider = func() command.Defaults { return command.Defaults{} } + } + if d.HandleError == nil { + d.HandleError = func(w io.Writer, err error) { + if err != nil { + fmt.Fprintln(w, err) + } + } + } + if d.LogInfo == nil { + d.LogInfo = func(...string) {} + } + if d.LogPrint == nil { + d.LogPrint = func(w io.Writer, logs ...string) { + for _, line := range logs { + fmt.Fprintln(w, line) + } + } + } + if d.LogWarn == nil { + d.LogWarn = d.LogPrint + } + if d.LogError == nil { + d.LogError = d.LogPrint + } + if d.LogFilePath == nil { + d.LogFilePath = func() string { return "" } + } + if d.NewPoller == nil { + d.NewPoller = NewPoller + } return &Context{ - in: d.In, - out: d.Out, - err: d.Err, - format: d.Format, - config: d.Config, - regionList: d.RegionList, - zoneList: d.ZoneList, - projectList: d.ProjectList, - allRegions: d.AllRegions, + in: d.In, + out: d.Out, + err: d.Err, + format: d.Format, + defaultsProvider: d.DefaultsProvider, + regionList: d.RegionList, + zoneList: d.ZoneList, + projectList: d.ProjectList, + allRegions: d.AllRegions, + clientConfig: d.ClientConfig, + buildCredential: d.BuildCredential, + attachHandlers: d.AttachHandlers, + handleError: d.HandleError, + logInfo: d.LogInfo, + logPrint: d.LogPrint, + logWarn: d.LogWarn, + logError: d.LogError, + logFilePath: d.LogFilePath, + newPoller: d.NewPoller, } } diff --git a/pkg/cli/context_getters_test.go b/pkg/cli/context_getters_test.go index ceb569e6e8..87e0424bed 100644 --- a/pkg/cli/context_getters_test.go +++ b/pkg/cli/context_getters_test.go @@ -3,8 +3,8 @@ package cli_test import ( "testing" - "github.com/ucloud/ucloud-cli/base" "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" ) func TestRegionZoneProjectListGetters(t *testing.T) { @@ -34,28 +34,27 @@ func TestRegionZoneProjectListGetters(t *testing.T) { func TestDefaultRegionProjectIDGetters(t *testing.T) { tests := []struct { name string - config *base.AggConfig + defaults command.Defaults wantRegion string wantZone string wantProjectID string }{ { name: "nil config is nil-safe", - config: nil, wantRegion: "", wantZone: "", wantProjectID: "", }, { name: "empty config returns empty", - config: &base.AggConfig{}, + defaults: command.Defaults{}, wantRegion: "", wantZone: "", wantProjectID: "", }, { name: "populated config returns configured values", - config: &base.AggConfig{Region: "cn-bj2", Zone: "cn-bj2-04", ProjectID: "org-x"}, + defaults: command.Defaults{Region: "cn-bj2", Zone: "cn-bj2-04", ProjectID: "org-x"}, wantRegion: "cn-bj2", wantZone: "cn-bj2-04", wantProjectID: "org-x", @@ -64,7 +63,9 @@ func TestDefaultRegionProjectIDGetters(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - ctx := cli.NewContext(cli.Deps{Config: tt.config}) + ctx := cli.NewContext(cli.Deps{ + DefaultsProvider: func() command.Defaults { return tt.defaults }, + }) if got := ctx.DefaultRegion(); got != tt.wantRegion { t.Errorf("DefaultRegion() = %q, want %q", got, tt.wantRegion) } diff --git a/pkg/cli/context_log_test.go b/pkg/cli/context_log_test.go index 8438068fe8..2b2c7c3fb8 100644 --- a/pkg/cli/context_log_test.go +++ b/pkg/cli/context_log_test.go @@ -1,6 +1,9 @@ package cli_test import ( + "bytes" + "fmt" + "io" "strings" "testing" @@ -8,17 +11,39 @@ import ( ) func TestContextLogForwarders(t *testing.T) { - // base.Log* early-returns under COMP_LINE without the initialized global - // logger, so the forwarders are exercised without a panic. - t.Setenv("COMP_LINE", "1") - ctx := cli.NewContext(cli.Deps{}) + var err bytes.Buffer + var infoCalls, printCalls, warnCalls, errorCalls int + ctx := cli.NewContext(cli.Deps{ + Err: &err, + LogInfo: func(logs ...string) { + infoCalls += len(logs) + }, + LogPrint: func(w io.Writer, logs ...string) { + printCalls += len(logs) + fmt.Fprint(w, strings.Join(logs, "\n")) + }, + LogWarn: func(w io.Writer, logs ...string) { + warnCalls += len(logs) + fmt.Fprint(w, strings.Join(logs, "\n")) + }, + LogError: func(w io.Writer, logs ...string) { + errorCalls += len(logs) + fmt.Fprint(w, strings.Join(logs, "\n")) + }, + LogFilePath: func() string { return "/tmp/cli.log" }, + }) - // Non-request product diagnostics: must not panic. ctx.LogInfo("info") ctx.LogPrint("print") ctx.LogWarn("warn") ctx.LogError("err") + if infoCalls != 1 || printCalls != 1 || warnCalls != 1 || errorCalls != 1 { + t.Fatalf("log provider calls = %d/%d/%d/%d, want all 1", infoCalls, printCalls, warnCalls, errorCalls) + } + if got := err.String(); !strings.Contains(got, "print") || !strings.Contains(got, "warn") || !strings.Contains(got, "err") { + t.Fatalf("stderr log output = %q, want print/warn/err", got) + } if !strings.Contains(ctx.LogFilePath(), "cli.log") { t.Fatalf("LogFilePath = %q, want it to contain cli.log", ctx.LogFilePath()) } diff --git a/pkg/cli/forward.go b/pkg/cli/forward.go index 71e6604de5..1cef34995e 100644 --- a/pkg/cli/forward.go +++ b/pkg/cli/forward.go @@ -8,17 +8,16 @@ import ( "github.com/ucloud/ucloud-sdk-go/ucloud/request" - "github.com/ucloud/ucloud-cli/base" "github.com/ucloud/ucloud-cli/pkg/command" "github.com/ucloud/ucloud-cli/pkg/ui" ) -// defaults reads the per-invocation region/zone/project defaults from the agg config. +// defaults reads the per-invocation region/zone/project defaults from the host provider. func (c *Context) defaults() command.Defaults { - if c.config == nil { + if c.defaultsProvider == nil { return command.Defaults{} } - return command.Defaults{Region: c.config.Region, Zone: c.config.Zone, ProjectID: c.config.ProjectID} + return c.defaultsProvider() } // SetCompletion forwards to command.SetCompletion. @@ -100,28 +99,19 @@ func (c *Context) ProjectList() []string { // default region/project as a flag default but must NOT register region/project // completion (mirrors the RegionList rationale). Nil-safe: empty when no config. func (c *Context) DefaultRegion() string { - if c.config == nil { - return "" - } - return c.config.Region + return c.defaults().Region } // DefaultProjectID returns the per-invocation default project id from config. func (c *Context) DefaultProjectID() string { - if c.config == nil { - return "" - } - return c.config.ProjectID + return c.defaults().ProjectID } // DefaultZone returns the per-invocation default availability zone from config, // for hand-written --zone flags that must NOT register zone completion (same // rationale as DefaultRegion/DefaultProjectID). Nil-safe: empty when no config. func (c *Context) DefaultZone() string { - if c.config == nil { - return "" - } - return c.config.Zone + return c.defaults().Zone } // AllRegions returns every region the account can see, propagating the @@ -164,7 +154,7 @@ func (c *Context) Confirm(yes bool, text string) (bool, error) { // cli.log file / telemetry. func (c *Context) HandleError(err error) { atomic.AddInt32(&c.errCount, 1) - base.HandleErrorTo(c.err, err) + c.handleError(c.err, err) } // LogInfo / LogPrint / LogWarn / LogError forward to the platform logger @@ -175,26 +165,26 @@ func (c *Context) HandleError(err error) { // LogInfo writes to the log file only (no console). LogPrint/LogWarn/LogError // send their console copy to stderr (ctx.Err), never stdout, so machine output // on stdout stays clean; all four still record to cli.log / telemetry. -func (c *Context) LogInfo(logs ...string) { base.LogInfo(logs...) } -func (c *Context) LogPrint(logs ...string) { base.LogPrintTo(c.err, logs...) } -func (c *Context) LogWarn(logs ...string) { base.LogWarnTo(c.err, logs...) } -func (c *Context) LogError(logs ...string) { base.LogErrorTo(c.err, logs...) } +func (c *Context) LogInfo(logs ...string) { c.logInfo(logs...) } +func (c *Context) LogPrint(logs ...string) { c.logPrint(c.err, logs...) } +func (c *Context) LogWarn(logs ...string) { c.logWarn(c.err, logs...) } +func (c *Context) LogError(logs ...string) { c.logError(c.err, logs...) } // LogFilePath returns the path of the CLI log file (e.g. for "check logs in …"). -func (c *Context) LogFilePath() string { return base.GetLogFilePath() } +func (c *Context) LogFilePath() string { return c.logFilePath() } // PickResourceID extracts the resource ID from a "resourceID/name" string. func (c *Context) PickResourceID(s string) string { return PickResourceID(s) } -// Poller wraps base.NewSpoller bound to ctx's writer. -func (c *Context) Poller(describeFunc func(string, *request.CommonBase) (interface{}, error)) *base.Poller { - return base.NewSpoller(describeFunc, c.out) +// Poller returns a platform poller bound to ctx's writer. +func (c *Context) Poller(describeFunc func(string, *request.CommonBase) (interface{}, error)) Poller { + return c.newPoller(describeFunc, c.out) } // PollerTo wraps base.NewSpoller bound to an explicit writer, so callers can // route progress narration to stderr (e.g. in json/yaml mode) while keeping // machine output on stdout. Products cannot import base directly, so this // exposes the writer-parameterized poller through the Context. -func (c *Context) PollerTo(w io.Writer, describeFunc func(string, *request.CommonBase) (interface{}, error)) *base.Poller { - return base.NewSpoller(describeFunc, w) +func (c *Context) PollerTo(w io.Writer, describeFunc func(string, *request.CommonBase) (interface{}, error)) Poller { + return c.newPoller(describeFunc, w) } diff --git a/pkg/cli/forward_test.go b/pkg/cli/forward_test.go index ca4aab0ce1..c68d4c655e 100644 --- a/pkg/cli/forward_test.go +++ b/pkg/cli/forward_test.go @@ -2,6 +2,7 @@ package cli_test import ( "bytes" + "io" "strings" "testing" @@ -9,8 +10,8 @@ import ( "github.com/ucloud/ucloud-sdk-go/ucloud/request" - "github.com/ucloud/ucloud-cli/base" "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" ) func TestContextForwarders(t *testing.T) { @@ -42,7 +43,9 @@ type ctxFakeReq struct { func TestContextBindCommonParams(t *testing.T) { ctx := cli.NewContext(cli.Deps{ - Config: &base.AggConfig{Region: "cn-bj2", Zone: "cn-bj2-02", ProjectID: "org-x"}, + DefaultsProvider: func() command.Defaults { + return command.Defaults{Region: "cn-bj2", Zone: "cn-bj2-02", ProjectID: "org-x"} + }, RegionList: func() []string { return []string{"cn-bj2"} }, ZoneList: func(region string) []string { return []string{region} }, ProjectList: func() []string { return []string{"org-x"} }, @@ -71,3 +74,15 @@ func TestContextBindCommonParams(t *testing.T) { } } } + +func TestContextPollerToReturnsProductCompatiblePoller(t *testing.T) { + ctx := cli.NewContext(cli.Deps{ + NewPoller: func(describe func(string, *request.CommonBase) (interface{}, error), out io.Writer) cli.Poller { + return cli.NewPoller(describe, out) + }, + }) + + ctx.PollerTo(io.Discard, func(string, *request.CommonBase) (interface{}, error) { + return struct{ State string }{State: "RUNNING"}, nil + }).Spoll("res-1", "creating", []string{"RUNNING"}) +} diff --git a/pkg/cli/poller.go b/pkg/cli/poller.go new file mode 100644 index 0000000000..5f2640f26d --- /dev/null +++ b/pkg/cli/poller.go @@ -0,0 +1,153 @@ +package cli + +import ( + "fmt" + "io" + "reflect" + "time" + + "github.com/ucloud/ucloud-sdk-go/ucloud/helpers/waiter" + "github.com/ucloud/ucloud-sdk-go/ucloud/log" + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/ui" +) + +type PollResult struct { + Done bool + Timeout bool + Err error +} + +type Poller interface { + Spoll(resourceID, pollText string, targetStates []string) + Sspoll(resourceID, pollText string, targetStates []string, block *Block, common *request.CommonBase) *PollResult +} + +type poller struct { + describe func(string, *request.CommonBase) (interface{}, error) + out io.Writer + stateFields []string + timeout time.Duration +} + +func NewPoller(describe func(string, *request.CommonBase) (interface{}, error), out io.Writer) Poller { + return &poller{ + describe: describe, + out: out, + stateFields: []string{"State", "Status"}, + timeout: 10 * time.Minute, + } +} + +func (p *poller) Spoll(resourceID, pollText string, targetStates []string) { + done := make(chan bool) + go func() { + if _, err := p.wait(resourceID, targetStates, nil); err != nil { + log.Error(err) + if _, ok := err.(*waiter.TimeoutError); ok { + done <- false + return + } + } + done <- true + }() + + if !ui.IsTTY(p.out) { + if <-done { + fmt.Fprintf(p.out, "%s...done\n", pollText) + } else { + fmt.Fprintf(p.out, "%s...timeout\n", pollText) + } + return + } + spinner := ui.NewDotSpinner(p.out) + spinner.Start(pollText) + ret := <-done + if ret { + spinner.Stop() + } else { + spinner.Timeout() + } +} + +func (p *poller) Sspoll(resourceID, pollText string, targetStates []string, block *Block, common *request.CommonBase) *PollResult { + pollRetChan := make(chan PollResult) + go func() { + ret := PollResult{Done: true} + if _, err := p.wait(resourceID, targetStates, common); err != nil { + ret.Done = false + ret.Err = err + if _, ok := err.(*waiter.TimeoutError); ok { + ret.Timeout = true + } + } + pollRetChan <- ret + }() + + if !ui.IsTTY(p.out) { + ret := <-pollRetChan + if ret.Timeout { + fmt.Fprintf(p.out, "%s...timeout\n", pollText) + } else { + fmt.Fprintf(p.out, "%s...done\n", pollText) + } + return &ret + } + + spin := ui.NewDotSpin(p.out, pollText) + if block != nil { + _ = block.SetSpin(spin) + } + ret := <-pollRetChan + if ret.Timeout { + spin.Timeout() + } else { + spin.Stop() + } + return &ret +} + +func (p *poller) wait(resourceID string, targetStates []string, common *request.CommonBase) (interface{}, error) { + w := waiter.StateWaiter{ + Pending: []string{"pending"}, + Target: []string{"avaliable"}, + Refresh: func() (interface{}, string, error) { + inst, err := p.describe(resourceID, common) + if err != nil { + return nil, "", err + } + if inst == nil { + return nil, "pending", nil + } + state, err := p.state(inst) + if err != nil { + return nil, "", err + } + for _, target := range targetStates { + if target == state { + return inst, "avaliable", nil + } + } + return nil, "pending", nil + }, + Timeout: p.timeout, + } + return w.Wait() +} + +func (p *poller) state(inst interface{}) (string, error) { + instValue := reflect.Indirect(reflect.ValueOf(inst)) + if instValue.Kind() != reflect.Struct { + return "", fmt.Errorf("Instance is not struct") + } + instType := instValue.Type() + for i := 0; i < instValue.NumField(); i++ { + for _, sf := range p.stateFields { + if instType.Field(i).Name == sf { + return instValue.Field(i).String(), nil + } + } + } + return "", nil +} diff --git a/pkg/cli/poller_test.go b/pkg/cli/poller_test.go new file mode 100644 index 0000000000..c69803a3a1 --- /dev/null +++ b/pkg/cli/poller_test.go @@ -0,0 +1,73 @@ +package cli + +import ( + "bytes" + "strings" + "testing" + + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + + "github.com/ucloud/ucloud-cli/pkg/ui" +) + +type pollerDoneInstance struct { + State string +} + +func TestPollerSpollNonTTYDone(t *testing.T) { + describeFunc := func(resourceID string, _ *request.CommonBase) (interface{}, error) { + return &pollerDoneInstance{State: "DONE"}, nil + } + + buf := &bytes.Buffer{} + NewPoller(describeFunc, buf).Spoll("res-001", "creating", []string{"DONE"}) + + out := buf.String() + if !strings.Contains(out, "creating...done\n") { + t.Errorf("expected 'creating...done\\n' in output, got: %q", out) + } + if strings.ContainsRune(out, '⣾') { + t.Errorf("spinner frame rune '⣾' leaked into non-TTY output: %q", out) + } +} + +func TestPollerSpollNonTTYNoSpinnerFrames(t *testing.T) { + describeFunc := func(resourceID string, _ *request.CommonBase) (interface{}, error) { + return &pollerDoneInstance{State: "ACTIVE"}, nil + } + + buf := &bytes.Buffer{} + NewPoller(describeFunc, buf).Spoll("res-003", "activating", []string{"ACTIVE"}) + + out := buf.String() + if !strings.Contains(out, "activating...done\n") { + t.Errorf("expected 'activating...done\\n' in output, got: %q", out) + } + for _, r := range []rune{'⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'} { + if strings.ContainsRune(out, r) { + t.Errorf("spinner frame rune %q leaked into non-TTY output: %q", r, out) + } + } +} + +func TestPollerSspollNonTTYDone(t *testing.T) { + describeFunc := func(resourceID string, _ *request.CommonBase) (interface{}, error) { + return &pollerDoneInstance{State: "DONE"}, nil + } + + buf := &bytes.Buffer{} + ret := NewPoller(describeFunc, buf).Sspoll("res-001", "creating", []string{"DONE"}, ui.NewBlock(), &request.CommonBase{}) + + if ret == nil || !ret.Done { + t.Fatalf("Sspoll non-TTY: want Done=true, got %+v", ret) + } + out := buf.String() + if !strings.Contains(out, "creating...done\n") { + t.Errorf("expected 'creating...done\\n' in output, got: %q", out) + } + for _, r := range []rune{'⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'} { + if strings.ContainsRune(out, r) { + t.Errorf("spinner frame %q leaked into non-TTY Sspoll output: %q", r, out) + } + } +} diff --git a/pkg/cli/progress.go b/pkg/cli/progress.go index b64f52c1ca..acc711823b 100644 --- a/pkg/cli/progress.go +++ b/pkg/cli/progress.go @@ -8,27 +8,25 @@ import ( "github.com/ucloud/ucloud-sdk-go/ucloud/request" - "github.com/ucloud/ucloud-cli/base" - "github.com/ucloud/ucloud-cli/ux" + "github.com/ucloud/ucloud-cli/pkg/ui" ) -// Block is the platform alias for ux.Block, so product packages get the -// concurrent-progress block type without ever importing ux. -type Block = ux.Block +// Block is the platform alias for ui.Block, so product packages get the +// concurrent-progress block type without importing platform internals. +type Block = ui.Block // Progress is a per-invocation concurrent-progress session bound to the ctx -// progress writer (stdout in table mode, stderr in json/yaml). It wraps -// ux.Document/Block/Refresh so products never import ux or base. +// progress writer (stdout in table mode, stderr in json/yaml). type Progress struct { out io.Writer - doc *ux.Document + doc *ui.Document } // NewProgress builds a Progress bound to the ctx progress writer. Non-TTY -// writers suppress animation (handled inside ux.NewDocument). +// writers suppress animation (handled inside ui.NewDocument). func (c *Context) NewProgress() *Progress { w := c.ProgressWriter() - return &Progress{out: w, doc: ux.NewDocument(w)} + return &Progress{out: w, doc: ui.NewDocument(w)} } // Disable switches off per-block animation (the count>5 aggregate path). @@ -42,31 +40,30 @@ func (p *Progress) Animated() bool { return !p.doc.Disabled() } // NewBlock appends a fresh block to the document and returns it. func (p *Progress) NewBlock() *Block { - b := ux.NewBlock() + b := ui.NewBlock() p.doc.Append(b) return b } // Refresh prints an aggregate counter line to the progress writer. -func (p *Progress) Refresh(text string) { ux.NewRefreshTo(p.out).Do(text) } +func (p *Progress) Refresh(text string) { ui.NewRefresh(p.out).Do(text) } // Sspoll runs the concurrent poller into block, bound to the progress writer. func (p *Progress) Sspoll(describe func(string, *request.CommonBase) (interface{}, error), resourceID, text string, targetStates []string, block *Block, common *request.CommonBase) { - base.NewSpoller(describe, p.out).Sspoll(resourceID, text, targetStates, block, common) + NewPoller(describe, p.out).Sspoll(resourceID, text, targetStates, block, common) } // ConcurrentAction runs actionFunc over reqs with bounded concurrency (limit), // aggregating a refresh counter when count>5. It is a verbatim port of -// cmd/util.go concurrentAction, rebound to the ctx progress writer: the global -// ux.Doc / ux.NewRefresh become the per-ctx writer; base logging (which is a -// platform concern) stays in base. Products call this instead of touching base. +// cmd/util.go concurrentAction, rebound to the ctx progress writer. Products +// call this instead of touching platform internals. func (c *Context) ConcurrentAction(reqs []request.Common, limit int, actionFunc func(request.Common) (bool, []string)) { if limit <= 0 { limit = 10 } w := c.ProgressWriter() - refresh := ux.NewRefreshTo(w) + refresh := ui.NewRefresh(w) count := len(reqs) var wg sync.WaitGroup result := make(chan bool) @@ -88,7 +85,7 @@ func (c *Context) ConcurrentAction(reqs []request.Common, limit int, actionFunc } case <-time.Tick(time.Second / 30): if count == (success+fail) && fail > 0 { - fmt.Fprintf(w, "Check logs in %s\n", base.GetLogFilePath()) + fmt.Fprintf(w, "Check logs in %s\n", c.LogFilePath()) return } if count > 5 { @@ -105,7 +102,7 @@ func (c *Context) ConcurrentAction(reqs []request.Common, limit int, actionFunc ok, logs := actionFunc(req) result <- ok logs = append([]string{"========================================"}, logs...) - base.LogInfo(logs...) + c.LogInfo(logs...) <-tokens time.Sleep(time.Second / 5) wg.Done() diff --git a/pkg/cli/progress_test.go b/pkg/cli/progress_test.go index e7d0b68661..13a2eceac9 100644 --- a/pkg/cli/progress_test.go +++ b/pkg/cli/progress_test.go @@ -39,7 +39,13 @@ func TestProgressNewBlock(t *testing.T) { func TestConcurrentActionRunsAllReqs(t *testing.T) { t.Setenv("COMP_LINE", "1") // base.LogInfo becomes a no-op (uninitialized global logger otherwise panics) var out bytes.Buffer - ctx := cli.NewContext(cli.Deps{Out: &out, Err: &out, Format: cli.OutputJSON}) + ctx := cli.NewContext(cli.Deps{ + Out: &out, + Err: &out, + Format: cli.OutputJSON, + LogInfo: func(...string) {}, + LogFilePath: func() string { return "/tmp/cli.log" }, + }) var n int32 actionFunc := func(req request.Common) (bool, []string) { diff --git a/pkg/cli/serviceclient.go b/pkg/cli/serviceclient.go index 68ba2fb432..612a75c4dc 100644 --- a/pkg/cli/serviceclient.go +++ b/pkg/cli/serviceclient.go @@ -1,23 +1,21 @@ package cli import ( - "github.com/ucloud/ucloud-cli/base" ucloud "github.com/ucloud/ucloud-sdk-go/ucloud" "github.com/ucloud/ucloud-sdk-go/ucloud/auth" ) // NewServiceClient returns an authed SDK service client for the active profile. -// It reuses base's exact credential + handler path (BuildCredential + AttachHandlers), -// so oauth and AK/SK profiles share one code path (§9: no auth regression). ctor is -// e.g. udb.NewClient. -// -// ctx is kept in the signature per the platform contract — products call -// cli.NewServiceClient(ctx, udb.NewClient). It is intentionally unused for now. +// The host injects the credential + handler path, so oauth and AK/SK profiles +// still share one code path (§9: no auth regression). ctor is e.g. udb.NewClient. // // Go methods cannot have type parameters, so this is a package-level generic // function rather than a *Context method. func NewServiceClient[T ucloud.ServiceClient](ctx *Context, ctor func(*ucloud.Config, *auth.Credential) T) T { - c := ctor(base.ClientConfig, base.BuildCredential()) - base.AttachHandlers(c) + if ctx == nil || ctx.clientConfig == nil || ctx.buildCredential == nil || ctx.attachHandlers == nil { + panic("cli.NewServiceClient called without service-client dependencies") + } + c := ctor(ctx.clientConfig(), ctx.buildCredential()) + ctx.attachHandlers(c) return c } diff --git a/pkg/cli/serviceclient_test.go b/pkg/cli/serviceclient_test.go index 872007560c..d89bb458f8 100644 --- a/pkg/cli/serviceclient_test.go +++ b/pkg/cli/serviceclient_test.go @@ -3,53 +3,39 @@ package cli_test import ( "testing" - "github.com/ucloud/ucloud-cli/base" "github.com/ucloud/ucloud-cli/pkg/cli" "github.com/ucloud/ucloud-sdk-go/services/udb" - sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/auth" ) -// saveBaseVars 保存并恢复 NewServiceClient 读取的包级全局,避免测试顺序耦合。 -func saveBaseVars(t *testing.T) { - t.Helper() - oldCfg, oldCred, oldIns := base.ClientConfig, base.AuthCredential, base.ConfigIns - t.Cleanup(func() { - base.ClientConfig, base.AuthCredential, base.ConfigIns = oldCfg, oldCred, oldIns +func TestNewServiceClientUsesInjectedProviders(t *testing.T) { + attached := false + ctx := cli.NewContext(cli.Deps{ + ClientConfig: func() *ucloud.Config { return &ucloud.Config{} }, + BuildCredential: func() *auth.Credential { + return &auth.Credential{PublicKey: "pk", PrivateKey: "sk"} + }, + AttachHandlers: func(sc ucloud.ServiceClient) { + attached = true + }, }) -} - -// AK/SK profile:NewServiceClient 返回非 nil client,且 BuildCredential 暴露真实密钥 -// (凭据与 base.NewClient 等价是 §9 的签名正确性保证)。 -func TestNewServiceClientAksk(t *testing.T) { - saveBaseVars(t) - base.ClientConfig = &sdk.Config{} - base.AuthCredential = &base.CredentialConfig{AuthMode: "", PublicKey: "pk", PrivateKey: "sk"} - base.ConfigIns = &base.AggConfig{} - c := cli.NewServiceClient(cli.NewContext(cli.Deps{}), udb.NewClient) + c := cli.NewServiceClient(ctx, udb.NewClient) if c == nil { t.Fatal("NewServiceClient returned nil") } - cred := base.BuildCredential() - if cred.PublicKey != "pk" || cred.PrivateKey != "sk" { - t.Errorf("aksk signing credential = {%q,%q}, want {pk,sk}", cred.PublicKey, cred.PrivateKey) + if !attached { + t.Fatal("NewServiceClient did not call AttachHandlers provider") } } -// oauth profile:NewServiceClient 返回非 nil client,且 BuildCredential 返回空凭据 -// (残留 AK/SK 不得进入签名器,Bearer 唯一凭据,§9 防 RetCode 171)。 -func TestNewServiceClientOAuth(t *testing.T) { - saveBaseVars(t) - base.ClientConfig = &sdk.Config{} - base.AuthCredential = &base.CredentialConfig{AuthMode: base.AuthModeOAuth, AccessToken: "tok", PublicKey: "pk", PrivateKey: "sk"} - base.ConfigIns = &base.AggConfig{} +func TestNewServiceClientRequiresInjectedProviders(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("NewServiceClient without providers did not panic") + } + }() - c := cli.NewServiceClient(cli.NewContext(cli.Deps{}), udb.NewClient) - if c == nil { - t.Fatal("NewServiceClient returned nil") - } - cred := base.BuildCredential() - if cred.PublicKey != "" || cred.PrivateKey != "" { - t.Errorf("oauth signing credential = {%q,%q}, want empty", cred.PublicKey, cred.PrivateKey) - } + _ = cli.NewServiceClient(cli.NewContext(cli.Deps{}), udb.NewClient) } diff --git a/pkg/ui/ansi.go b/pkg/ui/ansi.go new file mode 100644 index 0000000000..2f61855fb2 --- /dev/null +++ b/pkg/ui/ansi.go @@ -0,0 +1,18 @@ +package ui + +import "fmt" + +const ansiCSI = "\x1b[" + +var ( + ansiCursorLeft = fmt.Sprintf("%sG", ansiCSI) + ansiEraseDown = fmt.Sprintf("%sJ", ansiCSI) +) + +func ansiCursorUp(count int) string { + return fmt.Sprintf("%s%dA", ansiCSI, count) +} + +func ansiCursorPrevLine(count int) string { + return fmt.Sprintf("%s%dF", ansiCSI, count) +} diff --git a/ux/document.go b/pkg/ui/document.go similarity index 51% rename from ux/document.go rename to pkg/ui/document.go index 16dc60f114..8d3f1b95ee 100644 --- a/ux/document.go +++ b/pkg/ui/document.go @@ -1,21 +1,14 @@ -package ux +package ui import ( "fmt" "io" - "os" "sync" "time" - - "github.com/mattn/go-isatty" - - "github.com/ucloud/ucloud-cli/ansi" ) -var width, rows, _ = terminalSize() - -// Document 当前进程在打印的内容 -type document struct { +// Document is a writer-bound live rendering surface for progress blocks. +type Document struct { blocks []*Block mux sync.RWMutex framesPerSecond int @@ -25,7 +18,7 @@ type document struct { disable bool } -func (d *document) reset() { +func (d *Document) reset() { size := 0 d.mux.RLock() for _, block := range d.blocks { @@ -33,27 +26,23 @@ func (d *document) reset() { } d.mux.RUnlock() if size != 0 { - fmt.Fprint(d.out, ansi.CursorLeft+ansi.CursorPrevLine(size)+ansi.EraseDown) + fmt.Fprint(d.out, ansiCursorLeft+ansiCursorPrevLine(size)+ansiEraseDown) } } -func (d *document) Disable() { +func (d *Document) Disable() { d.disable = true } -// Disabled reports whether live rendering is off — either because Disable() was -// called (the aggregate count>5 path) or because NewDocument bound a non-TTY -// writer. Callers use this to know block content will NOT be shown, so they must -// surface errors to stderr themselves (aws/gcloud: errors always on stderr). -func (d *document) Disabled() bool { +func (d *Document) Disabled() bool { return d.disable } -func (d *document) SetWriter(out io.Writer) { +func (d *Document) SetWriter(out io.Writer) { d.out = out } -func (d *document) Content() []string { +func (d *Document) Content() []string { var lines []string for _, block := range d.blocks { for _, line := range <-block.getLines { @@ -63,7 +52,7 @@ func (d *document) Content() []string { return lines } -func (d *document) Render() { +func (d *Document) Render() { if d.disable { return } @@ -76,12 +65,7 @@ func (d *document) Render() { block.printLineNum = 0 for _, line := range <-block.getLines { fmt.Fprintln(d.out, line) - if width != 0 { - lineNum := len(line)/width + 1 - block.printLineNum += lineNum - } else { - block.printLineNum++ - } + block.printLineNum++ } fmt.Fprintf(d.out, "\n") block.printLineNum++ @@ -92,14 +76,14 @@ func (d *document) Render() { }) } -func (d *document) Append(b *Block) { +func (d *Document) Append(b *Block) { d.Render() d.mux.Lock() defer d.mux.Unlock() d.blocks = append(d.blocks, b) } -func (d *document) GetLastBlock() *Block { +func (d *Document) GetLastBlock() *Block { d.mux.Lock() defer d.mux.Unlock() if len(d.blocks) == 0 { @@ -108,69 +92,40 @@ func (d *document) GetLastBlock() *Block { return d.blocks[len(d.blocks)-1] } -func (d *document) GetBlockCount() int { +func (d *Document) GetBlockCount() int { d.mux.Lock() defer d.mux.Unlock() return len(d.blocks) } -func newDocument(out io.Writer) *document { - doc := &document{ +func NewDocument(out io.Writer) *Document { + doc := &Document{ out: out, framesPerSecond: 20, + disable: !IsTTY(out), } doc.ticker = time.NewTicker(time.Second / time.Duration(doc.framesPerSecond)) return doc } -// Doc global document -var Doc = newDocument(os.Stdout) - -// Document is the exported alias of the internal document type so platform -// packages (pkg/cli) can hold a writer-bound document without products ever -// naming ux. Products use ctx.NewProgress instead (batch-1 plan D-A / Task 0.4). -type Document = document - -// NewDocument returns a document bound to out. When out is not a TTY (pipe, -// file, or json/yaml mode) rendering is auto-disabled, so no spinner frames leak -// into machine output — mirroring pkg/ui.IsTTY / base.Poller.Spoll suppression. -// The global Doc keeps its legacy always-render behavior (built via newDocument). -func NewDocument(out io.Writer) *Document { - doc := newDocument(out) - if !isTTYWriter(out) { - doc.disable = true - } - return doc -} - -// isTTYWriter reports whether out is a real terminal. Kept local (using -// go-isatty directly) so ux need not import pkg/ui; mirrors pkg/ui.IsTTY. -func isTTYWriter(out io.Writer) bool { - f, ok := out.(*os.File) - return ok && isatty.IsTerminal(f.Fd()) -} - -// Block in document, including a spinner and some text +// Block is one progress document block, including an optional spinner and text. type Block struct { spinner *Spin spinnerIndex int - printLineNum int //已打印到屏幕上的行数 + printLineNum int lines []string updateLine chan updateBlockLine getLines chan []string } -// Update lines in Block func (b *Block) Update(text string, index int) { b.updateLine <- updateBlockLine{text, index} } -// Append text to Block func (b *Block) Append(text string) { b.updateLine <- updateBlockLine{text, -1} } -// SetSpin set spin for block func (b *Block) SetSpin(s *Spin) error { if b.spinner != nil { return fmt.Errorf("block has spinner already") @@ -195,7 +150,6 @@ type updateBlockLine struct { index int } -// NewSpinBlock create a new Block with spinner func NewSpinBlock(s *Spin) *Block { block := NewBlock() if s != nil { @@ -204,12 +158,11 @@ func NewSpinBlock(s *Spin) *Block { return block } -// NewBlock create a new Block without spinner. block.Stable closed func NewBlock() *Block { block := &Block{ lines: []string{}, - updateLine: make(chan updateBlockLine, 0), - getLines: make(chan []string, 0), + updateLine: make(chan updateBlockLine), + getLines: make(chan []string), } go func() { @@ -222,7 +175,6 @@ func NewBlock() *Block { } else { block.lines[index] = line } - case block.getLines <- block.lines: } } diff --git a/ux/prompt.go b/pkg/ui/prompt.go similarity index 72% rename from ux/prompt.go rename to pkg/ui/prompt.go index 938429d703..23e7fedd77 100644 --- a/ux/prompt.go +++ b/pkg/ui/prompt.go @@ -1,11 +1,11 @@ -package ux +package ui import ( "fmt" "strings" ) -// Prompt confirm +// Prompt asks for y/n confirmation on the process stdin/stdout. func Prompt(text string) (bool, error) { if !strings.HasSuffix(text, "(y/n):") { text += " (y/n):" @@ -19,8 +19,5 @@ func Prompt(text string) (bool, error) { agreeClose = strings.Trim(agreeClose, " ") agreeClose = strings.ToLower(agreeClose) - if agreeClose == "y" || agreeClose == "yes" { - return true, nil - } - return false, nil + return agreeClose == "y" || agreeClose == "yes", nil } diff --git a/pkg/ui/refresh.go b/pkg/ui/refresh.go new file mode 100644 index 0000000000..7ea199ddba --- /dev/null +++ b/pkg/ui/refresh.go @@ -0,0 +1,25 @@ +package ui + +import ( + "fmt" + "io" +) + +// Refresh rewrites a single progress line on each Do call. +type Refresh struct { + out io.Writer + reset bool +} + +func (r *Refresh) Do(text string) { + if r.reset { + fmt.Fprint(r.out, ansiCursorLeft+ansiCursorUp(1)+ansiEraseDown) + } else { + r.reset = true + } + fmt.Fprintln(r.out, text) +} + +func NewRefresh(out io.Writer) *Refresh { + return &Refresh{out: out} +} diff --git a/pkg/ui/spinner.go b/pkg/ui/spinner.go new file mode 100644 index 0000000000..e3c7fc29ee --- /dev/null +++ b/pkg/ui/spinner.go @@ -0,0 +1,180 @@ +package ui + +import ( + "fmt" + "io" + "runtime" + "sync" + "time" +) + +const windows = "windows" + +var spinnerFrames = []rune{'⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'} + +// Spinner renders an animated single-line spinner to a writer. +type Spinner struct { + out io.Writer + frames []rune + framesPerSecond int + DoingText string + DoneText string + TimeoutText string + ticker *time.Ticker + output string +} + +func (s *Spinner) Start(doingText string) { + if doingText != "" { + s.DoingText = doingText + } + s.ticker = time.NewTicker(time.Second / time.Duration(s.framesPerSecond)) + s.render() +} + +func (s *Spinner) Stop() { + s.ticker.Stop() + s.reset() + fmt.Fprintf(s.out, "%s...%s\n", s.DoingText, s.DoneText) +} + +func (s *Spinner) Timeout() { + s.ticker.Stop() + s.reset() + fmt.Fprintf(s.out, "%s...%s\n", s.DoingText, s.TimeoutText) +} + +func (s *Spinner) Fail(err error) { + s.ticker.Stop() + s.reset() + fmt.Fprintf(s.out, "%s...fail: %v\n", s.DoingText, err) +} + +func (s *Spinner) reset() { + if s.output == "" { + return + } + fmt.Fprint(s.out, ansiCursorLeft+ansiCursorUp(1)+ansiEraseDown) + s.output = "" +} + +func (s *Spinner) render() { + nextFrame := s.newFrameFactory() + go func() { + send := false + for range s.ticker.C { + if runtime.GOOS == windows { + if !send { + fmt.Fprintf(s.out, "%s...\n", s.DoingText) + send = true + } + continue + } + frame := nextFrame() + s.reset() + s.output = fmt.Sprintf("%s...%c\n", s.DoingText, frame) + fmt.Fprint(s.out, s.output) + } + }() +} + +func (s *Spinner) newFrameFactory() func() rune { + index := 0 + size := len(s.frames) + return func() rune { + char := s.frames[index%size] + index++ + return char + } +} + +func NewDotSpinner(out io.Writer) *Spinner { + return &Spinner{ + out: out, + frames: spinnerFrames, + framesPerSecond: 12, + DoingText: "running", + DoneText: "done", + TimeoutText: "timeout", + } +} + +// Spin renders spinner frames as strings for a Document block. +type Spin struct { + out io.Writer + frames []rune + framesPerSecond int + DoingText string + DoneText string + TimeoutText string + ticker *time.Ticker + output string + textChan chan string + wg sync.WaitGroup +} + +func (s *Spin) Stop() { + s.ticker.Stop() + s.reset() + s.textChan <- fmt.Sprintf("%s...%s", s.DoingText, s.DoneText) + <-time.After(time.Millisecond * 100) + close(s.textChan) +} + +func (s *Spin) Timeout() { + s.ticker.Stop() + s.reset() + s.textChan <- fmt.Sprintf("%s...%s", s.DoingText, s.TimeoutText) + <-time.After(time.Millisecond * 100) + close(s.textChan) +} + +func (s *Spin) reset() { + if s.output == "" { + return + } + fmt.Fprint(s.out, ansiCursorLeft+ansiCursorUp(1)+ansiEraseDown) + s.output = "" +} + +func (s *Spin) renderToString() chan string { + nextFrame := s.newFrameFactory() + go func() { + send := false + for range s.ticker.C { + if runtime.GOOS == windows { + if !send { + s.textChan <- fmt.Sprintf("%s...", s.DoingText) + send = true + } + continue + } + s.textChan <- fmt.Sprintf("%s...%c", s.DoingText, nextFrame()) + } + }() + return s.textChan +} + +func (s *Spin) newFrameFactory() func() rune { + index := 0 + size := len(s.frames) + return func() rune { + char := s.frames[index%size] + index++ + return char + } +} + +func NewDotSpin(out io.Writer, doingText string) *Spin { + s := &Spin{ + out: out, + frames: spinnerFrames, + framesPerSecond: 12, + DoingText: doingText, + DoneText: "done", + TimeoutText: "timeout", + textChan: make(chan string), + } + s.ticker = time.NewTicker(time.Second / time.Duration(s.framesPerSecond)) + return s +} diff --git a/products/eip/internal/ext/cmd.go b/products/eip/internal/ext/cmd.go new file mode 100644 index 0000000000..9240f10f14 --- /dev/null +++ b/products/eip/internal/ext/cmd.go @@ -0,0 +1,19 @@ +package ext + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// NewCommand builds the `ext` root command owned by products/eip. +func NewCommand(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "ext", + Short: "extended commands of UCloud CLI", + Long: "extended commands of UCloud CLI", + Args: cobra.NoArgs, + } + cmd.AddCommand(newUHost(ctx)) + return cmd +} diff --git a/products/eip/internal/ext/completion.go b/products/eip/internal/ext/completion.go new file mode 100644 index 0000000000..eb6dab0892 --- /dev/null +++ b/products/eip/internal/ext/completion.go @@ -0,0 +1,43 @@ +package ext + +import ( + "strings" + + uhostsdk "github.com/ucloud/ucloud-sdk-go/services/uhost" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func listUHostIDs(ctx *cli.Context, states []string, project, region, zone string) []string { + client := cli.NewServiceClient(ctx, uhostsdk.NewClient) + req := client.NewDescribeUHostInstanceRequest() + req.ProjectId = sdk.String(project) + req.Region = sdk.String(region) + req.Zone = sdk.String(zone) + req.Limit = sdk.Int(50) + resp, err := client.DescribeUHostInstance(req) + if err != nil { + return nil + } + list := []string{} + for _, host := range resp.UHostSet { + if !hostStateAllowed(host.State, states) { + continue + } + list = append(list, host.UHostId+"/"+strings.Replace(host.Name, " ", "-", -1)) + } + return list +} + +func hostStateAllowed(state string, states []string) bool { + if states == nil { + return true + } + for _, s := range states { + if state == s { + return true + } + } + return false +} diff --git a/products/eip/internal/ext/describe.go b/products/eip/internal/ext/describe.go new file mode 100644 index 0000000000..c74a9ba6ad --- /dev/null +++ b/products/eip/internal/ext/describe.go @@ -0,0 +1,27 @@ +package ext + +import ( + "fmt" + + uhostsdk "github.com/ucloud/ucloud-sdk-go/services/uhost" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +func describeUHostByID(ctx *cli.Context, uhostID, projectID, region, zone string) (*uhostsdk.UHostInstanceSet, error) { + client := cli.NewServiceClient(ctx, uhostsdk.NewClient) + req := client.NewDescribeUHostInstanceRequest() + req.UHostIds = []string{uhostID} + req.ProjectId = &projectID + req.Region = ®ion + req.Zone = &zone + + resp, err := client.DescribeUHostInstance(req) + if err != nil { + return nil, err + } + if len(resp.UHostSet) < 1 { + return nil, fmt.Errorf("uhost [%s] does not exist", uhostID) + } + return &resp.UHostSet[0], nil +} diff --git a/products/eip/internal/ext/status.go b/products/eip/internal/ext/status.go new file mode 100644 index 0000000000..e4ea6bbe4e --- /dev/null +++ b/products/eip/internal/ext/status.go @@ -0,0 +1,7 @@ +package ext + +const ( + hostRunning = "Running" + hostStopped = "Stopped" + hostFail = "Install Fail" +) diff --git a/products/eip/internal/ext/uhost.go b/products/eip/internal/ext/uhost.go new file mode 100644 index 0000000000..ba06661cd0 --- /dev/null +++ b/products/eip/internal/ext/uhost.go @@ -0,0 +1,19 @@ +package ext + +import ( + "github.com/spf13/cobra" + + "github.com/ucloud/ucloud-cli/pkg/cli" +) + +// newUHost builds `ucloud ext uhost`. +func newUHost(ctx *cli.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "uhost", + Short: "extended uhost commands", + Long: "extended uhost commands", + Args: cobra.NoArgs, + } + cmd.AddCommand(newUHostSwitchEIP(ctx)) + return cmd +} diff --git a/products/eip/internal/ext/uhost_switch_eip.go b/products/eip/internal/ext/uhost_switch_eip.go new file mode 100644 index 0000000000..06e269e185 --- /dev/null +++ b/products/eip/internal/ext/uhost_switch_eip.go @@ -0,0 +1,274 @@ +package ext + +import ( + "fmt" + "net" + "strings" + + "github.com/spf13/cobra" + uhostsdk "github.com/ucloud/ucloud-sdk-go/services/uhost" + "github.com/ucloud/ucloud-sdk-go/services/unet" + sdk "github.com/ucloud/ucloud-sdk-go/ucloud" + + "github.com/ucloud/ucloud-cli/pkg/cli" + "github.com/ucloud/ucloud-cli/pkg/command" +) + +// newUHostSwitchEIP builds `ucloud ext uhost switch-eip`. +func newUHostSwitchEIP(ctx *cli.Context) *cobra.Command { + var eipAddrs []string + var eipBandwidth, quantity int + var chargeType, trafficMode, shareBandwidthID string + var uhostIDs []string + var unbind, release bool + + uhostClient := cli.NewServiceClient(ctx, uhostsdk.NewClient) + describeReq := uhostClient.NewDescribeUHostInstanceRequest() + + cmd := &cobra.Command{ + Use: "switch-eip", + Short: "Switch EIP for UHost instances", + Long: "Switch EIP for UHost instances", + Example: "ucloud ext uhost switch-eip --uhost-id uhost-1n1sxx2,uhost-li4jxx1 --create-eip-bandwidth-mb 2", + Run: func(c *cobra.Command, args []string) { + project := ctx.PickResourceID(*describeReq.ProjectId) + region := *describeReq.Region + zone := *describeReq.Zone + unetClient := cli.NewServiceClient(ctx, unet.NewClient) + eipAddrMap := make(map[string]bool) + for _, addr := range eipAddrs { + eipAddrMap[addr] = true + } + results := []cli.OpResultRow{} + + for _, idName := range uhostIDs { + uhostID := ctx.PickResourceID(idName) + logs := []string{fmt.Sprintf("describe uhost instance by uhostID %s", uhostID)} + uhostIns, err := describeUHostByID(ctx, uhostID, project, region, zone) + if err != nil { + errStr := fmt.Sprintf("describe uhost %s failed: %v", uhostID, err) + ctx.HandleError(fmt.Errorf("%s", errStr)) + ctx.LogInfo(append(logs, errStr)...) + continue + } + + for _, ip := range uhostIns.IPSet { + if ip.IPId == "" { + continue + } + if len(eipAddrs) > 0 && !eipAddrMap[ip.IP] { + continue + } + + req := unetClient.NewAllocateEIPRequest() + req.Region = ®ion + req.ProjectId = &project + req.OperatorName = sdk.String(defaultEIPLine(region)) + req.Bandwidth = &eipBandwidth + req.ChargeType = &chargeType + req.Quantity = &quantity + req.PayMode = &trafficMode + if trafficMode == "ShareBandwidth" { + if shareBandwidthID == "" { + errStr := "create-eip-share-bandwidth-id should not be empty when create-eip-traffic-mode is assigned 'ShareBandwidth'" + ctx.HandleError(fmt.Errorf("%s", errStr)) + ctx.LogInfo(append(logs, errStr)...) + return + } + req.ShareBandwidthId = &shareBandwidthID + } + + resp, err := unetClient.AllocateEIP(req) + if err != nil { + errStr := fmt.Sprintf("allocate EIP failed: %v", err) + ctx.HandleError(fmt.Errorf("%s", errStr)) + ctx.LogInfo(append(logs, errStr)...) + continue + } + if len(resp.EIPSet) != 1 { + errStr := "allocate EIP failed, length of eip set is not 1" + ctx.HandleError(fmt.Errorf("%s", errStr)) + ctx.LogInfo(append(logs, errStr)...) + continue + } + + eipID := resp.EIPSet[0].EIPId + eipIP := "" + if len(resp.EIPSet[0].EIPAddr) > 0 { + eipIP = resp.EIPSet[0].EIPAddr[0].IP + } + allocRet := fmt.Sprintf("allocated new eip %s|%s", eipID, eipIP) + logs = append(logs, allocRet) + fmt.Fprintln(ctx.ProgressWriter(), allocRet) + results = append(results, cli.OpResultRow{ResourceID: eipID, Action: "allocate", Status: "Allocated"}) + + bindLogs, bindErr := bindEIPWithLogs(ctx, &uhostID, sdk.String("uhost"), &eipID, &project, ®ion) + logs = append(logs, bindLogs...) + if bindErr != nil { + ctx.HandleError(fmt.Errorf("bind new eip %s failed: %v", eipID, bindErr)) + ctx.LogInfo(logs...) + continue + } + fmt.Fprintf(ctx.ProgressWriter(), "bound eip %s with uhost %s\n", eipID, uhostID) + results = append(results, cli.OpResultRow{ResourceID: eipID, Action: "bind", Status: "Bound"}) + + if unbind { + unbindLogs, err := unbindEIPWithLogs(ctx, uhostID, "uhost", ip.IPId, project, region) + logs = append(logs, unbindLogs...) + if err != nil { + ctx.HandleError(fmt.Errorf("unbind eip %s failed: %v", ip.IPId, err)) + ctx.LogInfo(logs...) + continue + } + fmt.Fprintf(ctx.ProgressWriter(), "unbound eip %s|%s with uhost %s\n", ip.IPId, ip.IP, uhostID) + results = append(results, cli.OpResultRow{ResourceID: ip.IPId, Action: "unbind", Status: "Unbound"}) + } + + if release { + req := unetClient.NewReleaseEIPRequest() + req.ProjectId = &project + req.Region = ®ion + req.EIPId = sdk.String(ip.IPId) + _, err := unetClient.ReleaseEIP(req) + if err != nil { + errStr := fmt.Sprintf("release eip %s failed: %v", ip.IPId, err) + ctx.HandleError(fmt.Errorf("%s", errStr)) + ctx.LogInfo(append(logs, errStr)...) + continue + } + releaseRet := fmt.Sprintf("released eip %s|%s", ip.IPId, ip.IP) + logs = append(logs, releaseRet) + fmt.Fprintln(ctx.ProgressWriter(), releaseRet) + results = append(results, cli.OpResultRow{ResourceID: ip.IPId, Action: "release", Status: "Released"}) + } + ctx.LogInfo(logs...) + } + } + ctx.EmitResult(results...) + }, + } + + flags := cmd.Flags() + flags.SortFlags = false + flags.StringSliceVar(&uhostIDs, "uhost-id", nil, "Required. Resource ID of uhost instances to switch EIP") + flags.StringSliceVar(&eipAddrs, "eip-addr", nil, "Optional. Address of EIP instances to be replaced. if eip-id is empty, replace all of the EIPs bound with the uhost ") + flags.BoolVar(&unbind, "unbind-all", true, "Optional. Unbind all EIP instances that has been replaced. Accept values:true or false") + flags.BoolVar(&release, "release-all", true, "Optional. Release all EIP instances that has been replaced. Accept values:true or false") + flags.IntVar(&eipBandwidth, "create-eip-bandwidth-mb", 1, "Optional. Bandwidth of EIP instance to be create with. Unit:Mb") + flags.StringVar(&trafficMode, "create-eip-traffic-mode", "Bandwidth", "Optional. traffic-mode is an enumeration value. 'Traffic','Bandwidth' or 'ShareBandwidth'") + flags.StringVar(&shareBandwidthID, "create-eip-share-bandwidth-id", "", "Optional. ShareBandwidthId, required only when traffic-mode is 'ShareBandwidth'") + flags.StringVar(&chargeType, "create-eip-charge-type", "Month", "Optional. Enumeration value.'Year',pay yearly;'Month',pay monthly;'Dynamic', pay hourly") + flags.IntVar(&quantity, "create-eip-quantity", 1, "Optional. The duration of the instance. N years/months.") + + command.SetFlagValues(cmd, "create-eip-traffic-mode", "Bandwidth", "Traffic", "ShareBandwidth") + command.SetFlagValues(cmd, "create-eip-charge-type", "Month", "Year", "Dynamic", "Trial") + ctx.BindProjectID(cmd, describeReq) + ctx.BindRegion(cmd, describeReq) + ctx.BindZoneEmpty(cmd, describeReq) + command.SetCompletion(cmd, "uhost-id", func() []string { + return listUHostIDs(ctx, []string{hostRunning, hostStopped, hostFail}, *describeReq.ProjectId, *describeReq.Region, *describeReq.Zone) + }) + cmd.MarkFlagRequired("uhost-id") + + return cmd +} + +func defaultEIPLine(region string) string { + if strings.HasPrefix(region, "cn") { + return "BGP" + } + return "International" +} + +func getEIPIDByIP(ctx *cli.Context, ip net.IP, projectID, region string) (string, error) { + eipList, err := fetchAllEIP(ctx, projectID, region) + if err != nil { + return "", err + } + for _, eip := range eipList { + for _, addr := range eip.EIPAddr { + if addr.IP == ip.String() { + return eip.EIPId, nil + } + } + } + return "", fmt.Errorf("IP[%s] not exist", ip.String()) +} + +func fetchAllEIP(ctx *cli.Context, projectID, region string) ([]unet.UnetEIPSet, error) { + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewDescribeEIPRequest() + list := []unet.UnetEIPSet{} + req.ProjectId = sdk.String(projectID) + req.Region = sdk.String(region) + for offset, step := 0, 100; ; offset += step { + req.Offset = &offset + req.Limit = &step + resp, err := client.DescribeEIP(req) + if err != nil { + return nil, err + } + for i, size := 0, len(resp.EIPSet); i < size; i++ { + list = append(list, resp.EIPSet[i]) + } + if resp.TotalCount <= offset+step { + break + } + } + return list, nil +} + +func bindEIPWithLogs(ctx *cli.Context, resourceID, resourceType, eipID, projectID, region *string) ([]string, error) { + logs := make([]string, 0) + ip := net.ParseIP(*eipID) + if ip != nil { + id, err := getEIPIDByIP(ctx, ip, *projectID, *region) + if err != nil { + ctx.HandleError(err) + } else { + *eipID = id + } + } + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewBindEIPRequest() + req.ResourceId = resourceID + req.ResourceType = resourceType + req.EIPId = sdk.String(ctx.PickResourceID(*eipID)) + req.ProjectId = sdk.String(ctx.PickResourceID(*projectID)) + req.Region = region + _, err := client.BindEIP(req) + if err != nil { + logs = append(logs, fmt.Sprintf("bind eip failed: %v", err)) + return logs, err + } + logs = append(logs, fmt.Sprintf("bind eip[%s] with %s[%s] successfully", *req.EIPId, *req.ResourceType, *req.ResourceId)) + return logs, nil +} + +func unbindEIPWithLogs(ctx *cli.Context, resourceID, resourceType, eipID, projectID, region string) ([]string, error) { + logs := make([]string, 0) + eipID = ctx.PickResourceID(eipID) + ip := net.ParseIP(eipID) + if ip != nil { + id, err := getEIPIDByIP(ctx, ip, projectID, region) + if err != nil { + ctx.HandleError(err) + } else { + eipID = id + } + } + client := cli.NewServiceClient(ctx, unet.NewClient) + req := client.NewUnBindEIPRequest() + req.ResourceId = &resourceID + req.ResourceType = &resourceType + req.EIPId = &eipID + req.ProjectId = sdk.String(ctx.PickResourceID(projectID)) + req.Region = ®ion + _, err := client.UnBindEIP(req) + if err != nil { + logs = append(logs, fmt.Sprintf("unbind eip failed: %v", err)) + return logs, err + } + logs = append(logs, fmt.Sprintf("unbind eip[%s] with %s[%s] successfully", *req.EIPId, *req.ResourceType, *req.ResourceId)) + return logs, nil +} diff --git a/products/eip/product.go b/products/eip/product.go index fe90d88059..66d1d5ab38 100644 --- a/products/eip/product.go +++ b/products/eip/product.go @@ -5,6 +5,7 @@ import ( "github.com/ucloud/ucloud-cli/pkg/cli" internaleip "github.com/ucloud/ucloud-cli/products/eip/internal/eip" + internalext "github.com/ucloud/ucloud-cli/products/eip/internal/ext" ) type product struct{} @@ -12,9 +13,9 @@ type product struct{} func New() cli.Product { return product{} } func (product) Metadata() cli.Metadata { - return cli.Metadata{Name: "eip", Commands: []string{"eip"}} + return cli.Metadata{Name: "eip", Commands: []string{"eip", "ext"}} } func (product) NewCommand(ctx *cli.Context) []*cobra.Command { - return []*cobra.Command{internaleip.NewCommand(ctx)} + return []*cobra.Command{internaleip.NewCommand(ctx), internalext.NewCommand(ctx)} } diff --git a/products/eip/product.yaml b/products/eip/product.yaml index 33fd319721..d9cd4e358a 100644 --- a/products/eip/product.yaml +++ b/products/eip/product.yaml @@ -3,4 +3,5 @@ owners: - Episkey-G commands: - eip + - ext enabled: true diff --git a/products/eip/testdata/cmdtree.golden b/products/eip/testdata/cmdtree.golden index 1cb6ab5372..3068ac38f2 100644 --- a/products/eip/testdata/cmdtree.golden +++ b/products/eip/testdata/cmdtree.golden @@ -55,3 +55,18 @@ ucloud eip unbind use=unbind short=Unbind EIP with uhost flag=eip-id short= default=[] required=true flag=project-id short= default= required= flag=region short= default= required= +ucloud ext use=ext short=extended commands of UCloud CLI +ucloud ext uhost use=uhost short=extended uhost commands +ucloud ext uhost switch-eip use=switch-eip short=Switch EIP for UHost instances + flag=create-eip-bandwidth-mb short= default=1 required= + flag=create-eip-charge-type short= default=Month required= + flag=create-eip-quantity short= default=1 required= + flag=create-eip-share-bandwidth-id short= default= required= + flag=create-eip-traffic-mode short= default=Bandwidth required= + flag=eip-addr short= default=[] required= + flag=project-id short= default= required= + flag=region short= default= required= + flag=release-all short= default=true required= + flag=uhost-id short= default=[] required=true + flag=unbind-all short= default=true required= + flag=zone short= default= required= diff --git a/products/eip/testdata/completion.golden b/products/eip/testdata/completion.golden index d2f9868b60..78fe9631d0 100644 --- a/products/eip/testdata/completion.golden +++ b/products/eip/testdata/completion.golden @@ -22,3 +22,9 @@ ucloud eip release region dynamic ucloud eip unbind eip-id dynamic ucloud eip unbind project-id dynamic ucloud eip unbind region dynamic +ucloud ext uhost switch-eip create-eip-charge-type static Dynamic,Month,Trial,Year +ucloud ext uhost switch-eip create-eip-traffic-mode static Bandwidth,ShareBandwidth,Traffic +ucloud ext uhost switch-eip project-id dynamic +ucloud ext uhost switch-eip region dynamic +ucloud ext uhost switch-eip uhost-id dynamic +ucloud ext uhost switch-eip zone dynamic diff --git a/ux/document_test.go b/ux/document_test.go deleted file mode 100644 index f4a6dee10f..0000000000 --- a/ux/document_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package ux - -import ( - "bytes" - "testing" -) - -func TestNewDocumentBindsWriterAndAutoDisablesNonTTY(t *testing.T) { - var buf bytes.Buffer - - d := NewDocument(&buf) - - if d == nil { - t.Fatal("NewDocument returned nil") - } - if d.out != &buf { - t.Fatal("NewDocument did not bind the provided writer") - } - // A *bytes.Buffer is not a TTY → rendering must be auto-disabled so the - // 20fps spinner goroutine never starts and machine output stays clean. - if !d.disable { - t.Fatal("non-TTY writer must auto-disable rendering") - } -} diff --git a/ux/spinner.go b/ux/spinner.go deleted file mode 100644 index b80166d712..0000000000 --- a/ux/spinner.go +++ /dev/null @@ -1,145 +0,0 @@ -//Inspaired by https://github.com/oclif/cli-ux - -package ux - -import ( - "fmt" - "io" - "os" - "runtime" - "time" - - "github.com/ucloud/ucloud-cli/ansi" -) - -const windows = "windows" - -// Spinner type -type Spinner struct { - out io.Writer - frames []rune - framesPerSecond int - DoingText string - DoneText string - TimeoutText string - ticker *time.Ticker - output string -} - -// Start start render -func (s *Spinner) Start(doingText string) { - if doingText != "" { - s.DoingText = doingText - } - s.ticker = time.NewTicker(time.Second / time.Duration(s.framesPerSecond)) - s.render() -} - -// Stop stop render -func (s *Spinner) Stop() { - s.ticker.Stop() - s.reset() - output := fmt.Sprintf("%s...%s\n", s.DoingText, s.DoneText) - fmt.Fprint(s.out, output) -} - -// Timeout stop render -func (s *Spinner) Timeout() { - s.ticker.Stop() - s.reset() - output := fmt.Sprintf("%s...%s\n", s.DoingText, s.TimeoutText) - fmt.Fprint(s.out, output) -} - -// Fail stop render -func (s *Spinner) Fail(err error) { - s.ticker.Stop() - s.reset() - output := fmt.Sprintf("%s...fail: %v\n", s.DoingText, err) - fmt.Fprint(s.out, output) -} - -func (s *Spinner) reset() { - if s.output == "" { - return - } - fmt.Fprint(s.out, ansi.CursorLeft+ansi.CursorUp(1)+ansi.EraseDown) - s.output = "" -} - -func (s *Spinner) render() { - nextFrame := s.newFrameFactory() - go func() { - send := false - for range s.ticker.C { - if runtime.GOOS == windows { - if !send { - fmt.Fprintf(s.out, "%s...\n", s.DoingText) - send = true - } - continue - } - frame := nextFrame() - s.reset() - s.output = fmt.Sprintf("%s...%c\n", s.DoingText, frame) - fmt.Fprint(s.out, s.output) - } - }() -} - -func (s *Spinner) newFrameFactory() func() rune { - index := 0 - size := len(s.frames) - return func() rune { - char := s.frames[index%size] - index++ - return char - } -} - -var spinnerFrames = []rune{'⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'} - -// DotSpinner dot spinner -var DotSpinner = NewDotSpinner(os.Stdout) - -// NewDotSpinner get new DotSpinner instance -func NewDotSpinner(out io.Writer) *Spinner { - return &Spinner{ - out: out, - frames: spinnerFrames, - framesPerSecond: 12, - DoingText: "running", - DoneText: "done", - TimeoutText: "timeout", - } -} - -// Refresh 刷新显示文本 -type Refresh struct { - out io.Writer - reset bool -} - -// Do 刷新显示 -func (r *Refresh) Do(text string) { - if r.reset { - fmt.Fprint(r.out, ansi.CursorLeft+ansi.CursorUp(1)+ansi.EraseDown) - } else { - r.reset = true - } - fmt.Fprintln(r.out, text) -} - -// NewRefresh create a new Refresh instance -func NewRefresh() *Refresh { - return &Refresh{ - out: os.Stdout, - } -} - -// NewRefreshTo create a new Refresh writing to the given writer -func NewRefreshTo(out io.Writer) *Refresh { - return &Refresh{ - out: out, - } -} diff --git a/ux/spinnerv2.go b/ux/spinnerv2.go deleted file mode 100644 index f99f6eedca..0000000000 --- a/ux/spinnerv2.go +++ /dev/null @@ -1,123 +0,0 @@ -//Inspaired by https://github.com/oclif/cli-ux - -package ux - -import ( - "fmt" - "io" - "runtime" - "sync" - "time" - - "github.com/ucloud/ucloud-cli/ansi" -) - -// Spin type -type Spin struct { - out io.Writer - frames []rune - framesPerSecond int - DoingText string - DoneText string - TimeoutText string - ticker *time.Ticker - output string - textChan chan string - wg sync.WaitGroup -} - -// Stop stop render -func (s *Spin) Stop() { - s.ticker.Stop() - s.reset() - output := fmt.Sprintf("%s...%s", s.DoingText, s.DoneText) - s.textChan <- output - //等待最后一帧渲染 - <-time.After(time.Millisecond * 100) - close(s.textChan) -} - -// Timeout stop render -func (s *Spin) Timeout() { - s.ticker.Stop() - s.reset() - output := fmt.Sprintf("%s...%s", s.DoingText, s.TimeoutText) - s.textChan <- output - //等待最后一帧渲染 - <-time.After(time.Millisecond * 100) - close(s.textChan) -} - -func (s *Spin) reset() { - if s.output == "" { - return - } - fmt.Fprint(s.out, ansi.CursorLeft+ansi.CursorUp(1)+ansi.EraseDown) - s.output = "" -} - -func (s *Spin) renderToString() chan string { - nextFrame := s.newFrameFactory() - go func() { - send := false - for range s.ticker.C { - if runtime.GOOS == windows { - if !send { - s.textChan <- fmt.Sprintf("%s...", s.DoingText) - send = true - } - continue - } - frame := nextFrame() - s.textChan <- fmt.Sprintf("%s...%c", s.DoingText, frame) - } - }() - return s.textChan -} - -func (s *Spin) renderToScreen() { - nextFrame := s.newFrameFactory() - go func() { - send := false - for range s.ticker.C { - if runtime.GOOS == windows { - if !send { - fmt.Fprintf(s.out, "%s...\n", s.DoingText) - send = true - } - continue - } - frame := nextFrame() - s.reset() - s.output = fmt.Sprintf("%s...%c\n", s.DoingText, frame) - fmt.Fprint(s.out, s.output) - } - }() -} - -func (s *Spin) newFrameFactory() func() rune { - index := 0 - size := len(s.frames) - return func() rune { - char := s.frames[index%size] - index++ - return char - } -} - -var spinFrames = []rune{'⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'} - -// NewDotSpin get new DotSpinner instance -func NewDotSpin(out io.Writer, doingText string) *Spin { - s := &Spin{ - out: out, - frames: spinnerFrames, - framesPerSecond: 12, - DoingText: doingText, - DoneText: "done", - TimeoutText: "timeout", - textChan: make(chan string), - } - s.ticker = time.NewTicker(time.Second / time.Duration(s.framesPerSecond)) - return s -} diff --git a/ux/terminal_win.go b/ux/terminal_win.go deleted file mode 100644 index ba9b9d5171..0000000000 --- a/ux/terminal_win.go +++ /dev/null @@ -1,77 +0,0 @@ -//go:build windows -// +build windows - -package ux - -import ( - "os" - "syscall" - "unsafe" -) - -var tty = os.Stdin - -var ( - kernel32 = syscall.NewLazyDLL("kernel32.dll") - - // GetConsoleScreenBufferInfo retrieves information about the - // specified console screen buffer. - // http://msdn.microsoft.com/en-us/library/windows/desktop/ms683171(v=vs.85).aspx - procGetConsoleScreenBufferInfo = kernel32.NewProc("GetConsoleScreenBufferInfo") - - // GetConsoleMode retrieves the current input mode of a console's - // input buffer or the current output mode of a console screen buffer. - // https://msdn.microsoft.com/en-us/library/windows/desktop/ms683167(v=vs.85).aspx - getConsoleMode = kernel32.NewProc("GetConsoleMode") - - // SetConsoleMode sets the input mode of a console's input buffer - // or the output mode of a console screen buffer. - // https://msdn.microsoft.com/en-us/library/windows/desktop/ms686033(v=vs.85).aspx - setConsoleMode = kernel32.NewProc("SetConsoleMode") - - // SetConsoleCursorPosition sets the cursor position in the - // specified console screen buffer. - // https://msdn.microsoft.com/en-us/library/windows/desktop/ms686025(v=vs.85).aspx - setConsoleCursorPosition = kernel32.NewProc("SetConsoleCursorPosition") -) - -type ( - // Defines the coordinates of the upper left and lower right corners - // of a rectangle. - // See - // http://msdn.microsoft.com/en-us/library/windows/desktop/ms686311(v=vs.85).aspx - smallRect struct { - Left, Top, Right, Bottom int16 - } - - // Defines the coordinates of a character cell in a console screen - // buffer. The origin of the coordinate system (0,0) is at the top, left cell - // of the buffer. - // See - // http://msdn.microsoft.com/en-us/library/windows/desktop/ms682119(v=vs.85).aspx - coordinates struct { - X, Y int16 - } - - word int16 - - // Contains information about a console screen buffer. - // http://msdn.microsoft.com/en-us/library/windows/desktop/ms682093(v=vs.85).aspx - consoleScreenBufferInfo struct { - dwSize coordinates - dwCursorPosition coordinates - wAttributes word - srWindow smallRect - dwMaximumWindowSize coordinates - } -) - -// terminalSize returns width ans rows of the terminal. -func terminalSize() (int, int, error) { - var info consoleScreenBufferInfo - _, _, e := syscall.Syscall(procGetConsoleScreenBufferInfo.Addr(), 2, uintptr(syscall.Stdout), uintptr(unsafe.Pointer(&info)), 0) - if e != 0 { - return 0, 0, error(e) - } - return int(info.dwSize.X) - 1, int(info.dwSize.Y) - 1, nil -} diff --git a/ux/terminal_x.go b/ux/terminal_x.go deleted file mode 100644 index 3d71bcc4be..0000000000 --- a/ux/terminal_x.go +++ /dev/null @@ -1,50 +0,0 @@ -//go:build linux || darwin || freebsd || netbsd || openbsd || solaris || dragonfly -// +build linux darwin freebsd netbsd openbsd solaris dragonfly - -package ux - -import ( - "errors" - "os" - "sync" - - "golang.org/x/sys/unix" -) - -var ( - echoLockMutex sync.Mutex - origTermStatePtr *unix.Termios - tty *os.File - istty bool -) - -func init() { - echoLockMutex.Lock() - defer echoLockMutex.Unlock() - - var err error - tty, err = os.Open("/dev/tty") - istty = true - if err != nil { - tty = os.Stdin - istty = false - } -} - -// terminalSize returns width and rows of the terminal. -func terminalSize() (int, int, error) { - if !istty { - return 0, 0, errors.New("Not Supported") - } - echoLockMutex.Lock() - defer echoLockMutex.Unlock() - - fd := int(tty.Fd()) - - ws, err := unix.IoctlGetWinsize(fd, unix.TIOCGWINSZ) - if err != nil { - return 0, 0, err - } - - return int(ws.Col), int(ws.Row), nil -}