Skip to content

Commit

Permalink
ci: add support for multi-project pipelines
Browse files Browse the repository at this point in the history
So far, `lab` was only able to work with CI pipelines that were under the
"current" project, however, GitLab also allow pipelines that contain jobs of
the "bridge" type, which map that pipeline in specific to another one trigerred
in another GitLab project. This patch enables `lab` to "follow" this bridge
mapping to the downstream pipeline project.

For that, a new persistent flag was added to all `lab ci` commands:

  --follow   Follow downstream pipelines in a multi-projects setup

One thing to keep in mind though, is that in theory the downstream pipeline may
be in another host (e.g. gitlab.com vs gitlab.<company-domain>), however,
considering `lab` doesn't support any kind of "multiple host domains", I also
didn't enable such functionality, meaning that only downstream pipelines under
the same host domain (configured in core.host) is supported.

Signed-off-by: Bruno Meneguele <[email protected]>
  • Loading branch information
bmeneg committed Feb 4, 2021
1 parent fae6d7a commit 40cbc9c
Show file tree
Hide file tree
Showing 8 changed files with 143 additions and 27 deletions.
4 changes: 4 additions & 0 deletions cmd/ci.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import (
"github.com/spf13/cobra"
)

// Hold --follow flag value that is common for all ci command
var followBridge bool

// ciCmd represents the ci command
var ciCmd = &cobra.Command{
Use: "ci",
Expand All @@ -12,5 +15,6 @@ var ciCmd = &cobra.Command{
}

func init() {
ciCmd.PersistentFlags().Bool("follow", false, "Follow downstream pipelines in a multi-projects setup")
RootCmd.AddCommand(ciCmd)
}
7 changes: 6 additions & 1 deletion cmd/ci_artifacts.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ var ciArtifactsCmd = &cobra.Command{
log.Fatal(err)
}

followBridge, err = cmd.Flags().GetBool("follow")
if err != nil {
log.Fatal(err)
}

path, err := cmd.Flags().GetString("artifact-path")
if err != nil {
log.Fatal(err)
Expand All @@ -49,7 +54,7 @@ var ciArtifactsCmd = &cobra.Command{
}
projectID := project.ID

r, outpath, err := lab.CIArtifacts(projectID, pipelineID, jobName, path)
r, outpath, err := lab.CIArtifacts(projectID, pipelineID, jobName, path, followBridge)
if err != nil {
log.Fatal(err)
}
Expand Down
14 changes: 12 additions & 2 deletions cmd/ci_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ lab ci status --wait`,
log.Fatal(err)
}

followBridge, err = cmd.Flags().GetBool("follow")
if err != nil {
log.Fatal(err)
}

rn, pipelineID, err := getPipelineFromArgs(args, forMR)
if err != nil {
log.Fatal(err)
Expand All @@ -53,16 +58,21 @@ lab ci status --wait`,
log.Fatal(err)
}

var jobs []*gitlab.Job
var jobStructList []lab.JobStruct
jobs := make([]*gitlab.Job, 0)

fmt.Fprintln(w, "Stage:\tName\t-\tStatus")
for {
// fetch all of the CI Jobs from the API
jobs, err = lab.CIJobs(pid, pipelineID)
jobStructList, err = lab.CIJobs(pid, pipelineID, followBridge)
if err != nil {
log.Fatal(errors.Wrap(err, "failed to find ci jobs"))
}

for _, jobStruct := range jobStructList {
jobs = append(jobs, jobStruct.Job)
}

// filter out old jobs
jobs = latestJobs(jobs)

Expand Down
7 changes: 6 additions & 1 deletion cmd/ci_trace.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ var ciTraceCmd = &cobra.Command{
log.Fatal(err)
}

followBridge, err = cmd.Flags().GetBool("follow")
if err != nil {
log.Fatal(err)
}

rn, pipelineID, err := getPipelineFromArgs(branchArgs, forMR)
if err != nil {
log.Fatal(err)
Expand Down Expand Up @@ -72,7 +77,7 @@ func doTrace(ctx context.Context, w io.Writer, pid interface{}, pipelineID int,
if ctx.Err() == context.Canceled {
break
}
trace, job, err := lab.CITrace(pid, pipelineID, name)
trace, job, err := lab.CITrace(pid, pipelineID, name, followBridge)
if err != nil || job == nil || trace == nil {
return errors.Wrap(err, "failed to find job")
}
Expand Down
15 changes: 13 additions & 2 deletions cmd/ci_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ Feedback Encouraged!: https://github.com/zaquestion/lab/issues`,
log.Fatal(err)
}

followBridge, err = cmd.Flags().GetBool("follow")
if err != nil {
log.Fatal(err)
}

rn, pipelineID, err = getPipelineFromArgs(args, forMR)
if err != nil {
log.Fatal(err)
Expand Down Expand Up @@ -483,11 +488,17 @@ func updateJobs(app *tview.Application, jobsCh chan []*gitlab.Job) {
time.Sleep(time.Second * 1)
continue
}
jobs, err := lab.CIJobs(projectID, pipelineID)
if len(jobs) == 0 || err != nil {
jobStructList, err := lab.CIJobs(projectID, pipelineID, followBridge)
if len(jobStructList) == 0 || err != nil {
app.Stop()
log.Fatal(errors.Wrap(err, "failed to find ci jobs"))
}

jobs := make([]*gitlab.Job, 0)
for _, jobStruct := range jobStructList {
jobs = append(jobs, jobStruct.Job)
}

jobsCh <- latestJobs(jobs)
time.Sleep(time.Second * 5)
}
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ require (
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
github.com/stretchr/testify v1.6.1
github.com/tcnksm/go-gitconfig v0.1.2
github.com/xanzy/go-gitlab v0.39.0
github.com/xanzy/go-gitlab v0.43.0
github.com/xanzy/ssh-agent v0.3.0 // indirect
github.com/yuin/goldmark v1.2.1 // indirect
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897
Expand Down
8 changes: 2 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -488,12 +488,8 @@ github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGr
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
github.com/wadey/gocovmerge v0.0.0-20160331181800-b5bfa59ec0ad/go.mod h1:Hy8o65+MXnS6EwGElrSRjUzQDLXreJlzYLlWiHtt8hM=
github.com/xanzy/go-gitlab v0.33.1-0.20200713191942-71ea998bed24 h1:WmX8fdQSu32qQpsPZ6/h90HK930NhUmoh+yLDItvmYw=
github.com/xanzy/go-gitlab v0.33.1-0.20200713191942-71ea998bed24/go.mod h1:sPLojNBn68fMUWSxIJtdVVIP8uSBYqesTfDUseX11Ug=
github.com/xanzy/go-gitlab v0.38.1 h1:st5/Ag4h8CqVfp3LpOWW0Jd4jYHTGETwu0KksYDPnYE=
github.com/xanzy/go-gitlab v0.38.1/go.mod h1:sPLojNBn68fMUWSxIJtdVVIP8uSBYqesTfDUseX11Ug=
github.com/xanzy/go-gitlab v0.39.0 h1:7aiZ03fJfCdqoHFhsZq/SoVYp2lR91hfYWmiXLOU5Qo=
github.com/xanzy/go-gitlab v0.39.0/go.mod h1:sPLojNBn68fMUWSxIJtdVVIP8uSBYqesTfDUseX11Ug=
github.com/xanzy/go-gitlab v0.43.0 h1:rpOZQjxVJGW/ch+Jy4j7W4o7BB1mxkXJNVGuplZ7PUs=
github.com/xanzy/go-gitlab v0.43.0/go.mod h1:sPLojNBn68fMUWSxIJtdVVIP8uSBYqesTfDUseX11Ug=
github.com/xanzy/ssh-agent v0.2.1 h1:TCbipTQL2JiiCprBWx9frJ2eJlCYT00NmctrHxVAr70=
github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4=
github.com/xanzy/ssh-agent v0.3.0 h1:wUMzuKtKilRgBAD1sUb8gOwwRr2FGoBVumcjoOACClI=
Expand Down
113 changes: 99 additions & 14 deletions internal/gitlab/gitlab.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (

"github.com/pkg/errors"
gitlab "github.com/xanzy/go-gitlab"
"github.com/zaquestion/lab/internal/config"
"github.com/zaquestion/lab/internal/git"
)

Expand Down Expand Up @@ -1052,12 +1053,21 @@ func ProjectList(opts gitlab.ListProjectsOptions, n int) ([]*gitlab.Project, err
return list, nil
}

type JobSorter struct{ Jobs []*gitlab.Job }
// JobStruct maps the project ID to which a certain job belongs to.
// It's needed due to multi-projects pipeline, which allows jobs from
// different projects be triggered by the current project.
// CIJob() is currently the function handling the mapping.
type JobStruct struct {
Job *gitlab.Job
// A project ID can either be a string or an integer
ProjectID interface{}
}
type JobSorter struct{ Jobs []JobStruct }

func (s JobSorter) Len() int { return len(s.Jobs) }
func (s JobSorter) Swap(i, j int) { s.Jobs[i], s.Jobs[j] = s.Jobs[j], s.Jobs[i] }
func (s JobSorter) Less(i, j int) bool {
return time.Time(*s.Jobs[i].CreatedAt).Before(time.Time(*s.Jobs[j].CreatedAt))
return time.Time(*s.Jobs[i].Job.CreatedAt).Before(time.Time(*s.Jobs[j].Job.CreatedAt))
}

// GroupSearch searches for a namespace on GitLab
Expand Down Expand Up @@ -1096,27 +1106,96 @@ func GroupSearch(query string) (*gitlab.Group, error) {
return nil, errors.Errorf("Group '%s' not found", query)
}

// CIJobs returns a list of jobs in the pipeline with given id. The jobs are
// returned sorted by their CreatedAt time
func CIJobs(pid interface{}, id int) ([]*gitlab.Job, error) {
// CIJobs returns a list of jobs in the pipeline with given id.
// This function by default doesn't follow bridge jobs.
// The jobs are returned sorted by their CreatedAt time
func CIJobs(pid interface{}, id int, followBridge bool) ([]JobStruct, error) {
opts := &gitlab.ListJobsOptions{
ListOptions: gitlab.ListOptions{
PerPage: 500,
},
}
list := make([]*gitlab.Job, 0)

// First we get the jobs with direct relation to the actual project
list := make([]JobStruct, 0)
for {
jobs, resp, err := lab.Jobs.ListPipelineJobs(pid, id, opts)
if err != nil {
return nil, err
}

for _, job := range jobs {
list = append(list, JobStruct{job, pid})
}

opts.Page = resp.NextPage
list = append(list, jobs...)
if resp.CurrentPage == resp.TotalPages {
break
}
}

// It's also possible the pipelines are bridges to other project's
// pipelines (multi-project pipeline).
// Reference:
// https://docs.gitlab.com/ee/ci/multi_project_pipelines.html
if followBridge {
// A project can have multiple bridge jobs
bridgeList := make([]*gitlab.Bridge, 0)
for {
bridges, resp, err := lab.Jobs.ListPipelineBridges(pid, id, opts)
if err != nil {
return nil, err
}

opts.Page = resp.NextPage
bridgeList = append(bridgeList, bridges...)
if resp.CurrentPage == resp.TotalPages {
break
}
}

for _, bridge := range bridgeList {
// Unfortunately the GitLab API doesn't exposes the project ID nor name that the
// bridge job points to, since it might be extarnal to the config core.host
// hostname, hence the WebURL is exposed.
// With that, and considering we don't want to support anything outside the
// core.host, we need to massage the WebURL to get the project name that we can
// search for.
// WebURL format:
// <core.host>/<bridged-project-name-with-namespace>/-/pipelines/<id>
host := config.MainConfig.GetString("core.host")
projectName := strings.Replace(bridge.DownstreamPipeline.WebURL, host+"/", "", 1)
pipelineText := fmt.Sprintf("/-/pipelines/%d", bridge.DownstreamPipeline.ID)
projectName = strings.Replace(projectName, pipelineText, "", 1)

p, err := FindProject(projectName)
if err != nil {
continue
}

// Switch to the new project name and downstream pipeline id
pid = p.PathWithNamespace
id = bridge.DownstreamPipeline.ID

for {
// Get the list of bridged jobs and append to the original list
jobs, resp, err := lab.Jobs.ListPipelineJobs(pid, id, opts)
if err != nil {
return nil, err
}

for _, job := range jobs {
list = append(list, JobStruct{job, pid})
}

opts.Page = resp.NextPage
if resp.CurrentPage == resp.TotalPages {
break
}
}
}
}

// ListPipelineJobs returns jobs sorted by ID in descending order,
// while we want them to be ordered chronologically
sort.Sort(JobSorter{list})
Expand All @@ -1130,8 +1209,8 @@ func CIJobs(pid interface{}, id int) ([]*gitlab.Job, error) {
// 1. Last Running Job
// 2. First Pending Job
// 3. Last Job in Pipeline
func CITrace(pid interface{}, id int, name string) (io.Reader, *gitlab.Job, error) {
jobs, err := CIJobs(pid, id)
func CITrace(pid interface{}, id int, name string, followBridge bool) (io.Reader, *gitlab.Job, error) {
jobs, err := CIJobs(pid, id, followBridge)
if len(jobs) == 0 || err != nil {
return nil, nil, err
}
Expand All @@ -1141,7 +1220,10 @@ func CITrace(pid interface{}, id int, name string) (io.Reader, *gitlab.Job, erro
firstPending *gitlab.Job
)

for _, j := range jobs {
for _, jobStruct := range jobs {
// Switch to the project ID that owns the job (for a bridge case)
pid = jobStruct.ProjectID
j := jobStruct.Job
if j.Status == "running" {
lastRunning = j
}
Expand All @@ -1160,7 +1242,7 @@ func CITrace(pid interface{}, id int, name string) (io.Reader, *gitlab.Job, erro
job = firstPending
}
if job == nil {
job = jobs[len(jobs)-1]
job = jobs[len(jobs)-1].Job
}

r, _, err := lab.Jobs.GetTraceFile(pid, job.ID)
Expand All @@ -1175,8 +1257,8 @@ func CITrace(pid interface{}, id int, name string) (io.Reader, *gitlab.Job, erro
// together with the upstream filename. If path is specified and refers to
// a single file within the artifacts archive, that file is returned instead.
// If no name is provided, the last job with an artifacts file is picked.
func CIArtifacts(pid interface{}, id int, name, path string) (io.Reader, string, error) {
jobs, err := CIJobs(pid, id)
func CIArtifacts(pid interface{}, id int, name, path string, followBridge bool) (io.Reader, string, error) {
jobs, err := CIJobs(pid, id, followBridge)
if len(jobs) == 0 || err != nil {
return nil, "", err
}
Expand All @@ -1185,7 +1267,10 @@ func CIArtifacts(pid interface{}, id int, name, path string) (io.Reader, string,
lastWithArtifacts *gitlab.Job
)

for _, j := range jobs {
for _, jobStruct := range jobs {
// Switch to the project ID that owns the job (for a bridge case)
pid = jobStruct.ProjectID
j := jobStruct.Job
if j.ArtifactsFile.Filename != "" {
lastWithArtifacts = j
}
Expand Down

0 comments on commit 40cbc9c

Please sign in to comment.