Skip to content

Commit cfadd43

Browse files
authored
feat(cli): add pipeline build and view commands with authentication (#6762)
* feat(cli): add pipeline build and view commands with authentication support - Add build command to create and run pipelines from pipeline.yml files - Add view command to monitor pipeline status with watch and list options - Implement authentication context with session management and token handling - Add configuration management for global and project-level settings - Integrate with Erda API endpoints for pipeline operations and metadata - Provide branch detection and validation for pipeline execution context - Support verbose output mode for debugging API requests and responses * test(cli): add comprehensive unit tests for CLI components - Add TestNormalizePipelineYmlName for pipeline name normalization logic - Add TestGlobalConfigResolvedHost for config host resolution - Add TestSetAndGetGlobalConfigFromFile for config file operations - Add TestGetGlobalConfigFromSupportsLegacyServerField for legacy field handling - Add TestDecodeLoginStatusWithTokenResponse for token response decoding - Add TestDecodeLoginStatusWithLegacySessionResponse for session response decoding - Add TestContextCurrentAuthInfoFallsBackToOpenapiHost for auth fallback logic - Add multiple tests for resolveBaseHost function with different configuration sources - Add TestLatestPipelineID for pipeline ID selection logic - Add TestDeleteSessionInfoFromMap for session management - Add TestParseGitRemoteURLWithHTTPS for HTTPS URL parsing - Add TestParseGitRemoteURLWithSSHScpStyle for SSH URL parsing - Add TestParseGitRemoteURLRejectsInvalidRemote for invalid URL rejection * feat(cli): add authentication and session management features - Implement login command with persistent host configuration - Add logout command to remove authentication information - Create whoami command to display current user session details - Integrate session management with automatic token refresh - Support environment variable and config file based host resolution - Add interactive authentication with username/password prompts - Implement session storage and retrieval mechanisms - Add support * test(cli): add comprehensive test coverage for CLI components - Add TestNormalizePipelineYmlName for pipeline YAML name normalization - Implement TestGlobalConfigResolvedHost and related config file tests - Add TestDecodeLoginStatus with token and legacy session response tests - Include host resolution fallback mechanism tests in root command - Add TestLatestPipelineID for pipeline ID selection logic - Implement TestDeleteSessionInfoFromMap for session management - Add Git remote URL parsing tests with HTTPS and SSH formats
1 parent 30d9bf7 commit cfadd43

File tree

21 files changed

+1176
-85
lines changed

21 files changed

+1176
-85
lines changed

apistructs/pipeline.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,26 @@ func PostHandlePBQueryString(req *pipelinepb.PipelinePagingRequest) error {
565565
if req.MustMatchLabelsJSON == nil {
566566
req.MustMatchLabelsJSON = make(map[string]*structpb.Value)
567567
}
568+
// Legacy query keys appID and branch: PageListPipelines only constrains app/branch via
569+
// mustMatchLabelsJSON. Without folding these fields, GET /api/pipelines?appID=&branch=
570+
// would ignore them and return arbitrary pipelines matching source/status only.
571+
if req.AppID > 0 {
572+
v, err := appendStructValue(req.MustMatchLabelsJSON[LabelAppID], strconv.FormatUint(req.AppID, 10))
573+
if err != nil {
574+
return err
575+
}
576+
req.MustMatchLabelsJSON[LabelAppID] = v
577+
}
578+
for _, b := range req.Branch {
579+
if b == "" {
580+
continue
581+
}
582+
v, err := appendStructValue(req.MustMatchLabelsJSON[LabelBranch], b)
583+
if err != nil {
584+
return err
585+
}
586+
req.MustMatchLabelsJSON[LabelBranch] = v
587+
}
568588
if req.MustMatchLabels != "" {
569589
mustMatchLabels := make(map[string]string)
570590
if err := json.Unmarshal([]byte(req.MustMatchLabels), &mustMatchLabels); err != nil {

apistructs/pipeline_test.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,25 @@ func TestPostHandlePBQueryString(t *testing.T) {
140140
}
141141
err := PostHandlePBQueryString(req)
142142
assert.NoError(t, err)
143-
assert.Equal(t, 5, len(req.MustMatchLabelsJSON))
143+
// mustMatch: JSON keys + mustMatchLabel keys + branch folded from Branches
144+
assert.Equal(t, 6, len(req.MustMatchLabelsJSON))
144145
assert.Equal(t, 4, len(req.AnyMatchLabelsJSON))
145146
}
147+
148+
func TestPostHandlePBQueryString_LegacyAppIDAndBranchBecomeMustMatchLabels(t *testing.T) {
149+
req := &pipelinepb.PipelinePagingRequest{
150+
AppID: 1003418,
151+
Branch: []string{"master"},
152+
Source: []string{"dice"},
153+
}
154+
err := PostHandlePBQueryString(req)
155+
assert.NoError(t, err)
156+
assert.Contains(t, req.MustMatchLabelsJSON, LabelAppID)
157+
assert.Contains(t, req.MustMatchLabelsJSON, LabelBranch)
158+
appVals := req.MustMatchLabelsJSON[LabelAppID].GetListValue().GetValues()
159+
assert.Len(t, appVals, 1)
160+
assert.Equal(t, "1003418", appVals[0].GetStringValue())
161+
brVals := req.MustMatchLabelsJSON[LabelBranch].GetListValue().GetValues()
162+
assert.Len(t, brVals, 1)
163+
assert.Equal(t, "master", brVals[0].GetStringValue())
164+
}

tools/cli/cmd/build.go

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
package cmd
1616

1717
import (
18+
"encoding/json"
1819
"os"
1920
"os/exec"
2021
"path"
@@ -32,7 +33,8 @@ import (
3233
var PIPELINERUN = command.Command{
3334
Name: "build",
3435
ShortHelp: "create a pipeline and run it",
35-
Example: "$ erda-cli build <path-to/pipeline.yml>",
36+
LongHelp: `With global -V (--verbose): prints the JSON body sent to POST /api/cicds and the full HTTP response body when the call fails or returns success=false, in addition to the HTTP client debug log.`,
37+
Example: "$ erda-cli build <path-to/pipeline.yml>\n$ erda-cli -V build <path-to/pipeline.yml>",
3638
Args: []command.Arg{
3739
command.StringArg{}.Name("filename"),
3840
},
@@ -138,7 +140,7 @@ func PipelineRun(ctx *command.Context, filename, branch string, watch bool) erro
138140
return err
139141
}
140142

141-
repoStats, err := common.GetRepoStats(ctx, org.ID, info.Project, info.Application)
143+
_, applicationID, err := common.ResolveWorkspaceApplication(ctx, org.ID, info.Project, info.Application)
142144
if err != nil {
143145
return err
144146
}
@@ -147,27 +149,39 @@ func PipelineRun(ctx *command.Context, filename, branch string, watch bool) erro
147149
request apistructs.PipelineCreateRequest
148150
pipelineResp apistructs.PipelineCreateResponse
149151
)
150-
request.AppID = uint64(repoStats.ApplicationID)
152+
request.AppID = uint64(applicationID)
151153
request.Branch = branch
152154
request.Source = apistructs.PipelineSourceDice
153155
request.PipelineYmlSource = apistructs.PipelineYmlSourceGittar
154-
request.PipelineYmlName = filename
156+
request.PipelineYmlName = normalizePipelineYmlName(filename)
155157
request.AutoRun = true
156158

159+
if ctx.Debug {
160+
if reqJSON, err := json.Marshal(request); err == nil {
161+
ctx.Info("debug: cicds request body: %s", string(reqJSON))
162+
}
163+
}
164+
157165
// create pipeline
158166
response, err := ctx.Post().Path("/api/cicds").JSONBody(request).Do().JSON(&pipelineResp)
159167
if err != nil {
160168
return err
161169
}
162170
if !response.IsOK() {
171+
if ctx.Debug {
172+
ctx.Info("debug: cicds response status=%d body=%s", response.StatusCode(), string(response.Body()))
173+
}
163174
return errors.Errorf("build fail, status code: %d, err: %+v", response.StatusCode(), pipelineResp.Error)
164175
}
165176
if !pipelineResp.Success {
177+
if ctx.Debug {
178+
ctx.Info("debug: cicds response status=%d body=%s", response.StatusCode(), string(response.Body()))
179+
}
166180
return errors.Errorf("build fail: %+v", pipelineResp.Error)
167181
}
168182

169183
if watch {
170-
err = PipelineView(ctx, branch, pipelineResp.Data.ID, true)
184+
err = PipelineView(ctx, branch, pipelineResp.Data.ID, true, false, 0, 0, "", "", "")
171185
if err != nil {
172186
ctx.Fail("failed to watch status of pipeline %d", pipelineResp.Data.ID)
173187
}
@@ -178,3 +192,9 @@ func PipelineRun(ctx *command.Context, filename, branch string, watch bool) erro
178192

179193
return nil
180194
}
195+
196+
func normalizePipelineYmlName(filename string) string {
197+
filename = strings.TrimSpace(filename)
198+
filename = strings.TrimPrefix(filename, "./")
199+
return path.Clean(filename)
200+
}

tools/cli/cmd/build_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Copyright (c) 2021 Terminus, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package cmd
16+
17+
import "testing"
18+
19+
func TestNormalizePipelineYmlName(t *testing.T) {
20+
tests := []struct {
21+
name string
22+
in string
23+
want string
24+
}{
25+
{name: "plain root pipeline", in: "pipeline.yml", want: "pipeline.yml"},
26+
{name: "dot slash root pipeline", in: "./pipeline.yml", want: "pipeline.yml"},
27+
{name: "nested pipeline", in: ".erda/pipelines/java-demo.yml", want: ".erda/pipelines/java-demo.yml"},
28+
{name: "dot slash nested pipeline", in: "./.erda/pipelines/java-demo.yml", want: ".erda/pipelines/java-demo.yml"},
29+
}
30+
31+
for _, tt := range tests {
32+
t.Run(tt.name, func(t *testing.T) {
33+
if got := normalizePipelineYmlName(tt.in); got != tt.want {
34+
t.Fatalf("normalizePipelineYmlName(%q) = %q, want %q", tt.in, got, tt.want)
35+
}
36+
})
37+
}
38+
}

tools/cli/cmd/login.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Copyright (c) 2021 Terminus, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package cmd
16+
17+
import "github.com/erda-project/erda/tools/cli/command"
18+
19+
var LOGIN = command.Command{
20+
Name: "login",
21+
ShortHelp: "login and persist the default erda host",
22+
Example: `
23+
$ erda-cli login -u yourname
24+
$ erda-cli login --host https://erda.cloud -u yourname
25+
$ ERDA_HOST=https://erda.cloud erda-cli login
26+
$ erda-cli login --host https://openapi.erda.cloud -u yourname
27+
`,
28+
Run: RunLogin,
29+
}
30+
31+
func RunLogin(ctx *command.Context) error {
32+
if err := command.Login(); err != nil {
33+
return err
34+
}
35+
36+
if authInfo, ok := command.GetContext().CurrentAuthInfo(); ok {
37+
command.GetContext().Succ("logged in to %s as %s", command.GetContext().CurrentHost, authInfo.NickName)
38+
}
39+
return nil
40+
}

tools/cli/cmd/logout.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Copyright (c) 2021 Terminus, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package cmd
16+
17+
import "github.com/erda-project/erda/tools/cli/command"
18+
19+
var LOGOUT = command.Command{
20+
Name: "logout",
21+
ShortHelp: "remove current erda authentication info",
22+
Example: `
23+
$ erda-cli logout
24+
$ ERDA_HOST=https://erda.cloud erda-cli logout
25+
`,
26+
Run: RunLogout,
27+
}
28+
29+
func RunLogout(ctx *command.Context) error {
30+
if err := command.Logout(); err != nil {
31+
return err
32+
}
33+
command.GetContext().Succ("logged out from %s", command.GetContext().CurrentHost)
34+
return nil
35+
}

tools/cli/cmd/view.go

Lines changed: 80 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -37,26 +37,25 @@ import (
3737
var VIEW = command.Command{
3838
Name: "view",
3939
ShortHelp: "View pipeline status",
40-
Example: "$ erda-cli view -i <pipelineId>",
40+
Example: ` $ erda-cli view
41+
$ erda-cli view -i <pipelineId>
42+
$ erda-cli view --list
43+
$ erda-cli view --list --page 2 --page-size 10 --statuses Running --sources dice`,
4144
Flags: []command.Flag{
42-
command.StringFlag{Short: "b", Name: "branch", Doc: "specify branch to show pipeline status, default is current branch", DefaultValue: ""},
45+
command.StringFlag{Short: "b", Name: "branch", Doc: "branch filter: single branch, or comma-separated branches (matches /api/cicds branches); default is current git branch", DefaultValue: ""},
4346
command.Uint64Flag{Short: "i", Name: "pipelineID", Doc: "specify pipeline id to show pipeline status", DefaultValue: 0},
44-
command.BoolFlag{Short: "w", Name: "watch", Doc: "watch the status", DefaultValue: false},
47+
command.BoolFlag{Short: "w", Name: "watch", Doc: "watch the status (not with --list)", DefaultValue: false},
48+
command.BoolFlag{Short: "l", Name: "list", Doc: "list pipelines for the workspace app via /api/cicds", DefaultValue: false},
49+
command.IntFlag{Short: "", Name: "page", Doc: "for --list: page number (default 1)", DefaultValue: 0},
50+
command.IntFlag{Short: "", Name: "page-size", Doc: "for --list: page size (default 20)", DefaultValue: 0},
51+
command.StringFlag{Short: "", Name: "sources", Doc: "for --list: pipeline sources, comma-separated (default dice)", DefaultValue: ""},
52+
command.StringFlag{Short: "", Name: "statuses", Doc: "for --list: filter by pipeline status", DefaultValue: ""},
53+
command.StringFlag{Short: "", Name: "yml-names", Doc: "for --list: filter by pipeline yml name(s), comma-separated (query ymlNames)", DefaultValue: ""},
4554
},
4655
Run: PipelineView,
4756
}
4857

49-
func PipelineView(ctx *command.Context, branch string, pipelineID uint64, watch bool) (err error) {
50-
if pipelineID <= 0 {
51-
return errors.New("Invalid pipeline id.")
52-
}
53-
54-
if watch {
55-
if err = common.BuildCheckLoop(ctx, pipelineID); err != nil {
56-
fmt.Println("[Warn]", err)
57-
}
58-
}
59-
58+
func PipelineView(ctx *command.Context, branch string, pipelineID uint64, watch bool, list bool, page int, pageSize int, sources string, statuses string, ymlNames string) (err error) {
6059
if _, err := os.Stat(".git"); err != nil {
6160
return errors.New("Not a valid git repository directory.")
6261
}
@@ -79,15 +78,79 @@ func PipelineView(ctx *command.Context, branch string, pipelineID uint64, watch
7978
return err
8079
}
8180

82-
repoStats, err := common.GetRepoStats(ctx, org.ID, info.Project, info.Application)
81+
projectID, applicationID, err := common.ResolveWorkspaceApplication(ctx, org.ID, info.Project, info.Application)
8382
if err != nil {
8483
return errors.Wrapf(err, "orgID: %v, projectName: %s, appName: %s", org.ID, info.Project, info.Application)
8584
}
8685

86+
if list {
87+
if watch {
88+
return errors.New("cannot use --watch with --list")
89+
}
90+
if pipelineID > 0 {
91+
ctx.Info("ignoring --pipelineID when using --list")
92+
}
93+
data, err := common.ListPipelinesCICD(ctx, uint64(applicationID), branch, sources, statuses, ymlNames, page, pageSize)
94+
if err != nil {
95+
return err
96+
}
97+
pn := page
98+
if pn <= 0 {
99+
pn = 1
100+
}
101+
ps := pageSize
102+
if ps <= 0 {
103+
ps = 20
104+
}
105+
fmt.Printf("pipelines (appID=%d, total=%d, page=%d, pageSize=%d)\n",
106+
applicationID, data.Total, pn, ps)
107+
var rows [][]string
108+
for _, p := range data.Pipelines {
109+
br := p.FilterLabels[apistructs.LabelBranch]
110+
commit := p.Commit
111+
if len(commit) > 7 {
112+
commit = commit[:7]
113+
}
114+
tb := ""
115+
if p.TimeBegin != nil {
116+
tb = p.TimeBegin.Format("2006-01-02 15:04:05")
117+
}
118+
rows = append(rows, []string{
119+
strconv.FormatUint(p.ID, 10),
120+
string(p.Status),
121+
br,
122+
commit,
123+
p.YmlName,
124+
tb,
125+
})
126+
}
127+
if err = table.NewTable().Header([]string{
128+
"pipelineID", "status", "branch", "commit", "ymlName", "startedAt",
129+
}).Data(rows).Flush(); err != nil {
130+
return err
131+
}
132+
fmt.Printf("view one: erda-cli view -i <pipelineID>\n")
133+
return nil
134+
}
135+
136+
if pipelineID <= 0 {
137+
pipelineID, err = common.GetLatestPipelineID(ctx, uint64(applicationID), branch)
138+
if err != nil {
139+
return err
140+
}
141+
ctx.Info("no pipeline id provided, using latest pipeline on branch %s: %d", branch, pipelineID)
142+
}
143+
144+
if watch {
145+
if err = common.BuildCheckLoop(ctx, pipelineID); err != nil {
146+
fmt.Println("[Warn]", err)
147+
}
148+
}
149+
87150
// fetch ymlName path
88151
var pipelineCombResp apistructs.PipelineInvokedComboResponse
89152
response, err := ctx.Get().Path("/api/cicds/actions/app-invoked-combos").
90-
Param("appID", strconv.FormatInt(repoStats.ApplicationID, 10)).
153+
Param("appID", strconv.FormatInt(applicationID, 10)).
91154
Do().JSON(&pipelineCombResp)
92155
if err != nil {
93156
return err
@@ -145,7 +208,7 @@ func PipelineView(ctx *command.Context, branch string, pipelineID uint64, watch
145208
return err
146209
}
147210
printMetadata(pipelineInfo.PipelineStages)
148-
seeMore(ctx, info.Org, int(repoStats.ProjectID), int(repoStats.ApplicationID), branch, pipelineID, ymlName)
211+
seeMore(ctx, info.Org, int(projectID), int(applicationID), branch, pipelineID, ymlName)
149212
return nil
150213
}
151214

0 commit comments

Comments
 (0)