From f6dd71eae4734d3f917074cb4f0d43f372d7e1a4 Mon Sep 17 00:00:00 2001 From: dave vader <48764154+plyr4@users.noreply.github.com> Date: Thu, 19 Dec 2024 11:05:55 -0600 Subject: [PATCH] feat: opt-in gh app integration (#1217) --- api/auth/get_token.go | 40 +++ api/build/compile_publish.go | 2 + api/repo/create.go | 14 + api/repo/repair.go | 35 +- api/scm/sync.go | 26 ++ api/types/repo.go | 31 +- api/types/repo_test.go | 4 +- api/webhook/post.go | 17 +- cmd/vela-server/scm.go | 8 +- compiler/engine.go | 8 + compiler/native/compile.go | 13 + compiler/native/compile_test.go | 106 +++--- compiler/native/environment.go | 25 +- compiler/native/environment_test.go | 95 +++-- compiler/native/native.go | 33 +- compiler/native/script_test.go | 4 +- compiler/native/transform_test.go | 16 +- compiler/registry/github/github.go | 14 +- compiler/registry/github/template.go | 2 +- compiler/types/pipeline/git.go | 35 ++ compiler/types/pipeline/git_test.go | 31 ++ compiler/types/yaml/build.go | 3 + compiler/types/yaml/git.go | 28 ++ compiler/types/yaml/git_test.go | 60 ++++ constants/app_install.go | 20 ++ constants/event.go | 6 + database/integration_test.go | 2 + database/repo/create_test.go | 6 +- database/repo/table.go | 2 + database/repo/update_test.go | 6 +- database/testutils/api_resources.go | 1 + database/types/repo.go | 3 + database/types/repo_test.go | 3 + database/types/schedule_test.go | 5 +- internal/webhook.go | 22 +- mock/server/repo.go | 11 +- router/middleware/build/build_test.go | 1 + router/middleware/repo/repo_test.go | 1 + scm/flags.go | 25 ++ scm/github/access.go | 10 +- scm/github/app_install.go | 156 +++++++++ scm/github/app_permissions.go | 104 ++++++ scm/github/app_permissions_test.go | 195 +++++++++++ scm/github/app_transport.go | 331 ++++++++++++++++++ scm/github/app_transport_test.go | 169 +++++++++ scm/github/authentication.go | 2 +- scm/github/changeset.go | 4 +- scm/github/deployment.go | 8 +- scm/github/driver_test.go | 2 + scm/github/github.go | 183 +++++++--- scm/github/github_client.go | 159 +++++++++ scm/github/github_client_test.go | 128 +++++++ scm/github/github_test.go | 6 +- scm/github/opts.go | 53 ++- scm/github/opts_test.go | 76 +++- scm/github/org.go | 2 +- scm/github/repo.go | 175 ++++++++- scm/github/repo_test.go | 287 +++++++++++++++ .../testdata/hooks/installation_created.json | 100 ++++++ .../testdata/hooks/installation_deleted.json | 100 ++++++ .../installation_repositories_added.json | 103 ++++++ .../installation_repositories_removed.json | 103 ++++++ .../testdata/installation_repositories.json | 123 +++++++ scm/github/testdata/installations.json | 52 +++ .../testdata/installations_access_tokens.json | 134 +++++++ scm/github/user.go | 2 +- scm/github/webhook.go | 61 +++- scm/github/webhook_test.go | 199 ++++++++++- scm/scm.go | 7 +- scm/scm_test.go | 11 +- scm/service.go | 17 + scm/setup.go | 24 +- scm/setup_test.go | 29 +- 73 files changed, 3594 insertions(+), 285 deletions(-) create mode 100644 compiler/types/pipeline/git.go create mode 100644 compiler/types/pipeline/git_test.go create mode 100644 compiler/types/yaml/git.go create mode 100644 compiler/types/yaml/git_test.go create mode 100644 constants/app_install.go create mode 100644 scm/github/app_install.go create mode 100644 scm/github/app_permissions.go create mode 100644 scm/github/app_permissions_test.go create mode 100644 scm/github/app_transport.go create mode 100644 scm/github/app_transport_test.go create mode 100644 scm/github/github_client.go create mode 100644 scm/github/github_client_test.go create mode 100644 scm/github/testdata/hooks/installation_created.json create mode 100644 scm/github/testdata/hooks/installation_deleted.json create mode 100644 scm/github/testdata/hooks/installation_repositories_added.json create mode 100644 scm/github/testdata/hooks/installation_repositories_removed.json create mode 100644 scm/github/testdata/installation_repositories.json create mode 100644 scm/github/testdata/installations.json create mode 100644 scm/github/testdata/installations_access_tokens.json diff --git a/api/auth/get_token.go b/api/auth/get_token.go index 1379ccea3..6b4b4f26b 100644 --- a/api/auth/get_token.go +++ b/api/auth/get_token.go @@ -5,11 +5,13 @@ package auth import ( "fmt" "net/http" + "strconv" "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" "github.com/go-vela/server/api/types" + "github.com/go-vela/server/constants" "github.com/go-vela/server/database" "github.com/go-vela/server/internal/token" "github.com/go-vela/server/scm" @@ -36,6 +38,14 @@ import ( // name: redirect_uri // description: The URL where the user will be sent after authorization // type: string +// - in: query +// name: setup_action +// description: The specific setup action callback identifier +// type: string +// - in: query +// name: installation_id +// description: The specific installation identifier for a GitHub App integration +// type: integer // responses: // '200': // description: Successfully authenticated @@ -46,6 +56,10 @@ import ( // "$ref": "#/definitions/Token" // '307': // description: Redirected for authentication +// '400': +// description: Bad Request +// schema: +// "$ref": "#/definitions/Error" // '401': // description: Unauthorized // schema: @@ -69,6 +83,32 @@ func GetAuthToken(c *gin.Context) { // capture the OAuth state if present oAuthState := c.Request.FormValue("state") + // handle scm setup events + // setup_action==install represents the GitHub App installation callback redirect + if c.Request.FormValue("setup_action") == constants.AppInstallSetupActionInstall { + installID, err := strconv.ParseInt(c.Request.FormValue("installation_id"), 10, 0) + if err != nil { + retErr := fmt.Errorf("unable to parse installation_id: %w", err) + + util.HandleError(c, http.StatusBadRequest, retErr) + + return + } + + r, err := scm.FromContext(c).FinishInstallation(ctx, c.Request, installID) + if err != nil { + retErr := fmt.Errorf("unable to finish installation: %w", err) + + util.HandleError(c, http.StatusInternalServerError, retErr) + + return + } + + c.Redirect(http.StatusTemporaryRedirect, r) + + return + } + // capture the OAuth code if present code := c.Request.FormValue("code") if len(code) == 0 { diff --git a/api/build/compile_publish.go b/api/build/compile_publish.go index d355488d0..7167d1357 100644 --- a/api/build/compile_publish.go +++ b/api/build/compile_publish.go @@ -269,6 +269,8 @@ func CompileAndPublish( WithRepo(repo). WithUser(u). WithLabels(cfg.Labels). + WithSCM(scm). + WithDatabase(database). Compile(ctx, pipelineFile) if err != nil { // format the error message with extra information diff --git a/api/repo/create.go b/api/repo/create.go index 3e35be3f0..ae766c4c5 100644 --- a/api/repo/create.go +++ b/api/repo/create.go @@ -273,6 +273,18 @@ func CreateRepo(c *gin.Context) { } } + // map this repo to an installation if possible + if r.GetInstallID() == 0 { + r, err = scm.FromContext(c).SyncRepoWithInstallation(ctx, r) + if err != nil { + retErr := fmt.Errorf("unable to sync repo %s with installation: %w", r.GetFullName(), err) + + util.HandleError(c, http.StatusInternalServerError, retErr) + + return + } + } + // if the repo exists but is inactive if len(dbRepo.GetOrg()) > 0 && !dbRepo.GetActive() { // update the repo owner @@ -281,6 +293,8 @@ func CreateRepo(c *gin.Context) { dbRepo.SetBranch(r.GetBranch()) // activate the repo dbRepo.SetActive(true) + // update the install_id + dbRepo.SetInstallID(r.GetInstallID()) // send API call to update the repo // NOTE: not logging modification out separately diff --git a/api/repo/repair.go b/api/repo/repair.go index f48ceea2c..bb15e2734 100644 --- a/api/repo/repair.go +++ b/api/repo/repair.go @@ -62,6 +62,8 @@ import ( // RepairRepo represents the API handler to remove // and then create a webhook for a repo. +// +//nolint:funlen // ignore statement count func RepairRepo(c *gin.Context) { // capture middleware values m := c.MustGet("metadata").(*internal.Metadata) @@ -163,21 +165,48 @@ func RepairRepo(c *gin.Context) { } } + dirty := false + // if the repo was previously inactive, mark it as active if !r.GetActive() { r.SetActive(true) - // send API call to update the repo + dirty = true + + l.Tracef("repo %s repaired - set to active", r.GetFullName()) + } + + // map this repo to an installation, if possible + if r.GetInstallID() == 0 { + r, err = scm.FromContext(c).SyncRepoWithInstallation(ctx, r) + if err != nil { + retErr := fmt.Errorf("unable to sync repo %s with installation: %w", r.GetFullName(), err) + + util.HandleError(c, http.StatusInternalServerError, retErr) + + return + } + + // install_id was synced + if r.GetInstallID() != 0 { + dirty = true + + l.Tracef("repo %s repaired - set install_id to %d", r.GetFullName(), r.GetInstallID()) + } + } + + // update the repo in the database, if necessary + if dirty { _, err := database.FromContext(c).UpdateRepo(ctx, r) if err != nil { - retErr := fmt.Errorf("unable to set repo %s to active: %w", r.GetFullName(), err) + retErr := fmt.Errorf("unable to update repo %s during repair: %w", r.GetFullName(), err) util.HandleError(c, http.StatusInternalServerError, retErr) return } - l.Infof("repo %s updated - set to active", r.GetFullName()) + l.Infof("repo %s repaired - database updated", r.GetFullName()) } c.JSON(http.StatusOK, fmt.Sprintf("repo %s repaired", r.GetFullName())) diff --git a/api/scm/sync.go b/api/scm/sync.go index 66522510a..1f06ab370 100644 --- a/api/scm/sync.go +++ b/api/scm/sync.go @@ -174,5 +174,31 @@ func SyncRepo(c *gin.Context) { } } + // map this repo to an installation, if necessary + installID := r.GetInstallID() + + r, err = scm.FromContext(c).SyncRepoWithInstallation(ctx, r) + if err != nil { + retErr := fmt.Errorf("unable to sync repo %s with installation: %w", r.GetFullName(), err) + + util.HandleError(c, http.StatusInternalServerError, retErr) + + return + } + + // install_id was synced + if r.GetInstallID() != installID { + _, err := database.FromContext(c).UpdateRepo(ctx, r) + if err != nil { + retErr := fmt.Errorf("unable to update repo %s during repair: %w", r.GetFullName(), err) + + util.HandleError(c, http.StatusInternalServerError, retErr) + + return + } + + l.Tracef("repo %s install_id synced to %d", r.GetFullName(), r.GetInstallID()) + } + c.Status(http.StatusNoContent) } diff --git a/api/types/repo.go b/api/types/repo.go index bfad93623..b81a061e9 100644 --- a/api/types/repo.go +++ b/api/types/repo.go @@ -32,6 +32,7 @@ type Repo struct { PipelineType *string `json:"pipeline_type,omitempty"` PreviousName *string `json:"previous_name,omitempty"` ApproveBuild *string `json:"approve_build,omitempty"` + InstallID *int64 `json:"install_id,omitempty"` } // Environment returns a list of environment variables @@ -345,6 +346,19 @@ func (r *Repo) GetApproveBuild() string { return *r.ApproveBuild } +// GetInstallID returns the InstallID field. +// +// When the provided Repo type is nil, or the field within +// the type is nil, it returns the zero value for the field. +func (r *Repo) GetInstallID() int64 { + // return zero value if Repo type or InstallID field is nil + if r == nil || r.InstallID == nil { + return 0 + } + + return *r.InstallID +} + // SetID sets the ID field. // // When the provided Repo type is nil, it @@ -618,6 +632,19 @@ func (r *Repo) SetApproveBuild(v string) { r.ApproveBuild = &v } +// SetInstallID sets the InstallID field. +// +// When the provided Repo type is nil, it +// will set nothing and immediately return. +func (r *Repo) SetInstallID(v int64) { + // return if Repo type is nil + if r == nil { + return + } + + r.InstallID = &v +} + // String implements the Stringer interface for the Repo type. func (r *Repo) String() string { return fmt.Sprintf(`{ @@ -640,7 +667,8 @@ func (r *Repo) String() string { Timeout: %d, Topics: %s, Trusted: %t, - Visibility: %s + Visibility: %s, + InstallID: %d }`, r.GetActive(), r.GetAllowEvents().List(), @@ -662,5 +690,6 @@ func (r *Repo) String() string { r.GetTopics(), r.GetTrusted(), r.GetVisibility(), + r.GetInstallID(), ) } diff --git a/api/types/repo_test.go b/api/types/repo_test.go index c7e20407d..fa8fb2970 100644 --- a/api/types/repo_test.go +++ b/api/types/repo_test.go @@ -303,7 +303,8 @@ func TestTypes_Repo_String(t *testing.T) { Timeout: %d, Topics: %s, Trusted: %t, - Visibility: %s + Visibility: %s, + InstallID: %d }`, r.GetActive(), r.GetAllowEvents().List(), @@ -325,6 +326,7 @@ func TestTypes_Repo_String(t *testing.T) { r.GetTopics(), r.GetTrusted(), r.GetVisibility(), + r.GetInstallID(), ) // run test diff --git a/api/webhook/post.go b/api/webhook/post.go index 52f0d90dd..8ff5b4cc2 100644 --- a/api/webhook/post.go +++ b/api/webhook/post.go @@ -84,6 +84,7 @@ func PostWebhook(c *gin.Context) { // capture middleware values m := c.MustGet("metadata").(*internal.Metadata) l := c.MustGet("logger").(*logrus.Entry) + db := database.FromContext(c) ctx := c.Request.Context() l.Debug("webhook received") @@ -133,6 +134,20 @@ func PostWebhook(c *gin.Context) { return } + if webhook.Installation != nil { + err = scm.FromContext(c).ProcessInstallation(ctx, c.Request, webhook, db) + if err != nil { + retErr := fmt.Errorf("unable to process installation: %w", err) + util.HandleError(c, http.StatusBadRequest, retErr) + + return + } + + c.JSON(http.StatusOK, "installation processed successfully") + + return + } + // check if the hook should be skipped if skip, skipReason := webhook.ShouldSkip(); skip { c.JSON(http.StatusOK, fmt.Sprintf("skipping build: %s", skipReason)) @@ -145,8 +160,6 @@ func PostWebhook(c *gin.Context) { l.Debugf("hook generated from SCM: %v", h) l.Debugf("repo generated from SCM: %v", r) - db := database.FromContext(c) - // if event is repository event, handle separately and return if strings.EqualFold(h.GetEvent(), constants.EventRepository) { r, err = handleRepositoryEvent(ctx, l, db, m, h, r) diff --git a/cmd/vela-server/scm.go b/cmd/vela-server/scm.go index 7124761f0..2efa53bb5 100644 --- a/cmd/vela-server/scm.go +++ b/cmd/vela-server/scm.go @@ -20,16 +20,20 @@ func setupSCM(c *cli.Context, tc *tracing.Client) (scm.Service, error) { Address: c.String("scm.addr"), ClientID: c.String("scm.client"), ClientSecret: c.String("scm.secret"), + AppID: c.Int64("scm.app.id"), + AppPrivateKey: c.String("scm.app.private-key"), + AppPrivateKeyPath: c.String("scm.app.private-key.path"), + AppPermissions: c.StringSlice("scm.app.permissions"), ServerAddress: c.String("server-addr"), ServerWebhookAddress: c.String("scm.webhook.addr"), StatusContext: c.String("scm.context"), WebUIAddress: c.String("webui-addr"), - Scopes: c.StringSlice("scm.scopes"), + OAuthScopes: c.StringSlice("scm.scopes"), Tracing: tc, } // setup the scm // // https://pkg.go.dev/github.com/go-vela/server/scm?tab=doc#New - return scm.New(_setup) + return scm.New(c.Context, _setup) } diff --git a/compiler/engine.go b/compiler/engine.go index 31f85852e..ab4555296 100644 --- a/compiler/engine.go +++ b/compiler/engine.go @@ -10,7 +10,9 @@ import ( "github.com/go-vela/server/compiler/types/pipeline" "github.com/go-vela/server/compiler/types/raw" "github.com/go-vela/server/compiler/types/yaml" + "github.com/go-vela/server/database" "github.com/go-vela/server/internal" + "github.com/go-vela/server/scm" ) // Engine represents an interface for converting a yaml @@ -146,6 +148,12 @@ type Engine interface { // WithLabel defines a function that sets // the label(s) in the Engine. WithLabels([]string) Engine + // WithSCM defines a function that sets + // the scm in the Engine. + WithSCM(scm.Service) Engine + // WithDatabase defines a function that sets + // the database in the Engine. + WithDatabase(database.Interface) Engine // WithPrivateGitHub defines a function that sets // the private github client in the Engine. WithPrivateGitHub(context.Context, string, string) Engine diff --git a/compiler/native/compile.go b/compiler/native/compile.go index 313e24db3..04740f412 100644 --- a/compiler/native/compile.go +++ b/compiler/native/compile.go @@ -44,6 +44,19 @@ func (c *client) Compile(ctx context.Context, v interface{}) (*pipeline.Build, * return nil, nil, err } + // create the netrc using the scm + // this has to occur after Parse because the scm configurations might be set in yaml + // netrc can be provided directly using WithNetrc for situations like local exec + if c.netrc == nil && c.scm != nil { + // get the netrc password from the scm + netrc, err := c.scm.GetNetrcPassword(ctx, c.db, c.repo, c.user, p.Git) + if err != nil { + return nil, nil, err + } + + c.WithNetrc(netrc) + } + // create the API pipeline object from the yaml configuration _pipeline := p.ToPipelineAPI() _pipeline.SetData(data) diff --git a/compiler/native/compile_test.go b/compiler/native/compile_test.go index 4b0214d6f..e887176d7 100644 --- a/compiler/native/compile_test.go +++ b/compiler/native/compile_test.go @@ -54,21 +54,21 @@ func TestNative_Compile_StagesPipeline(t *testing.T) { }, } - initEnv := environment(nil, m, nil, nil) + initEnv := environment(nil, m, nil, nil, nil) initEnv["HELLO"] = "Hello, Global Environment" - stageEnvInstall := environment(nil, m, nil, nil) + stageEnvInstall := environment(nil, m, nil, nil, nil) stageEnvInstall["HELLO"] = "Hello, Global Environment" stageEnvInstall["GRADLE_USER_HOME"] = ".gradle" - stageEnvTest := environment(nil, m, nil, nil) + stageEnvTest := environment(nil, m, nil, nil, nil) stageEnvTest["HELLO"] = "Hello, Global Environment" stageEnvTest["GRADLE_USER_HOME"] = "willBeOverwrittenInStep" - cloneEnv := environment(nil, m, nil, nil) + cloneEnv := environment(nil, m, nil, nil, nil) cloneEnv["HELLO"] = "Hello, Global Environment" - installEnv := environment(nil, m, nil, nil) + installEnv := environment(nil, m, nil, nil, nil) installEnv["GRADLE_OPTS"] = "-Dorg.gradle.daemon=false -Dorg.gradle.workers.max=1 -Dorg.gradle.parallel=false" installEnv["GRADLE_USER_HOME"] = ".gradle" installEnv["HOME"] = "/root" @@ -76,7 +76,7 @@ func TestNative_Compile_StagesPipeline(t *testing.T) { installEnv["VELA_BUILD_SCRIPT"] = generateScriptPosix([]string{"./gradlew downloadDependencies"}) installEnv["HELLO"] = "Hello, Global Environment" - testEnv := environment(nil, m, nil, nil) + testEnv := environment(nil, m, nil, nil, nil) testEnv["GRADLE_OPTS"] = "-Dorg.gradle.daemon=false -Dorg.gradle.workers.max=1 -Dorg.gradle.parallel=false" testEnv["GRADLE_USER_HOME"] = ".gradle" testEnv["HOME"] = "/root" @@ -84,7 +84,7 @@ func TestNative_Compile_StagesPipeline(t *testing.T) { testEnv["VELA_BUILD_SCRIPT"] = generateScriptPosix([]string{"./gradlew check"}) testEnv["HELLO"] = "Hello, Global Environment" - buildEnv := environment(nil, m, nil, nil) + buildEnv := environment(nil, m, nil, nil, nil) buildEnv["GRADLE_OPTS"] = "-Dorg.gradle.daemon=false -Dorg.gradle.workers.max=1 -Dorg.gradle.parallel=false" buildEnv["GRADLE_USER_HOME"] = ".gradle" buildEnv["HOME"] = "/root" @@ -92,7 +92,7 @@ func TestNative_Compile_StagesPipeline(t *testing.T) { buildEnv["VELA_BUILD_SCRIPT"] = generateScriptPosix([]string{"./gradlew build"}) buildEnv["HELLO"] = "Hello, Global Environment" - dockerEnv := environment(nil, m, nil, nil) + dockerEnv := environment(nil, m, nil, nil, nil) dockerEnv["PARAMETER_REGISTRY"] = "index.docker.io" dockerEnv["PARAMETER_REPO"] = "github/octocat" dockerEnv["PARAMETER_TAGS"] = "latest,dev" @@ -479,13 +479,13 @@ func TestNative_Compile_StepsPipeline(t *testing.T) { }, } - initEnv := environment(nil, m, nil, nil) + initEnv := environment(nil, m, nil, nil, nil) initEnv["HELLO"] = "Hello, Global Environment" - cloneEnv := environment(nil, m, nil, nil) + cloneEnv := environment(nil, m, nil, nil, nil) cloneEnv["HELLO"] = "Hello, Global Environment" - installEnv := environment(nil, m, nil, nil) + installEnv := environment(nil, m, nil, nil, nil) installEnv["GRADLE_OPTS"] = "-Dorg.gradle.daemon=false -Dorg.gradle.workers.max=1 -Dorg.gradle.parallel=false" installEnv["GRADLE_USER_HOME"] = ".gradle" installEnv["HOME"] = "/root" @@ -493,7 +493,7 @@ func TestNative_Compile_StepsPipeline(t *testing.T) { installEnv["VELA_BUILD_SCRIPT"] = generateScriptPosix([]string{"./gradlew downloadDependencies"}) installEnv["HELLO"] = "Hello, Global Environment" - testEnv := environment(nil, m, nil, nil) + testEnv := environment(nil, m, nil, nil, nil) testEnv["GRADLE_OPTS"] = "-Dorg.gradle.daemon=false -Dorg.gradle.workers.max=1 -Dorg.gradle.parallel=false" testEnv["GRADLE_USER_HOME"] = ".gradle" testEnv["HOME"] = "/root" @@ -501,7 +501,7 @@ func TestNative_Compile_StepsPipeline(t *testing.T) { testEnv["VELA_BUILD_SCRIPT"] = generateScriptPosix([]string{"./gradlew check"}) testEnv["HELLO"] = "Hello, Global Environment" - buildEnv := environment(nil, m, nil, nil) + buildEnv := environment(nil, m, nil, nil, nil) buildEnv["GRADLE_OPTS"] = "-Dorg.gradle.daemon=false -Dorg.gradle.workers.max=1 -Dorg.gradle.parallel=false" buildEnv["GRADLE_USER_HOME"] = ".gradle" buildEnv["HOME"] = "/root" @@ -509,7 +509,7 @@ func TestNative_Compile_StepsPipeline(t *testing.T) { buildEnv["VELA_BUILD_SCRIPT"] = generateScriptPosix([]string{"./gradlew build"}) buildEnv["HELLO"] = "Hello, Global Environment" - dockerEnv := environment(nil, m, nil, nil) + dockerEnv := environment(nil, m, nil, nil, nil) dockerEnv["PARAMETER_REGISTRY"] = "index.docker.io" dockerEnv["PARAMETER_REPO"] = "github/octocat" dockerEnv["PARAMETER_TAGS"] = "latest,dev" @@ -690,11 +690,11 @@ func TestNative_Compile_StagesPipelineTemplate(t *testing.T) { }, } - setupEnv := environment(nil, m, nil, nil) + setupEnv := environment(nil, m, nil, nil, nil) setupEnv["bar"] = "test4" setupEnv["star"] = "test3" - installEnv := environment(nil, m, nil, nil) + installEnv := environment(nil, m, nil, nil, nil) installEnv["GRADLE_OPTS"] = "-Dorg.gradle.daemon=false -Dorg.gradle.workers.max=1 -Dorg.gradle.parallel=false" installEnv["GRADLE_USER_HOME"] = ".gradle" installEnv["HOME"] = "/root" @@ -703,7 +703,7 @@ func TestNative_Compile_StagesPipelineTemplate(t *testing.T) { installEnv["bar"] = "test4" installEnv["star"] = "test3" - testEnv := environment(nil, m, nil, nil) + testEnv := environment(nil, m, nil, nil, nil) testEnv["GRADLE_OPTS"] = "-Dorg.gradle.daemon=false -Dorg.gradle.workers.max=1 -Dorg.gradle.parallel=false" testEnv["GRADLE_USER_HOME"] = ".gradle" testEnv["HOME"] = "/root" @@ -712,7 +712,7 @@ func TestNative_Compile_StagesPipelineTemplate(t *testing.T) { testEnv["bar"] = "test4" testEnv["star"] = "test3" - buildEnv := environment(nil, m, nil, nil) + buildEnv := environment(nil, m, nil, nil, nil) buildEnv["GRADLE_OPTS"] = "-Dorg.gradle.daemon=false -Dorg.gradle.workers.max=1 -Dorg.gradle.parallel=false" buildEnv["GRADLE_USER_HOME"] = ".gradle" buildEnv["HOME"] = "/root" @@ -721,14 +721,14 @@ func TestNative_Compile_StagesPipelineTemplate(t *testing.T) { buildEnv["bar"] = "test4" buildEnv["star"] = "test3" - dockerEnv := environment(nil, m, nil, nil) + dockerEnv := environment(nil, m, nil, nil, nil) dockerEnv["PARAMETER_REGISTRY"] = "index.docker.io" dockerEnv["PARAMETER_REPO"] = "github/octocat" dockerEnv["PARAMETER_TAGS"] = "latest,dev" dockerEnv["bar"] = "test4" dockerEnv["star"] = "test3" - serviceEnv := environment(nil, m, nil, nil) + serviceEnv := environment(nil, m, nil, nil, nil) serviceEnv["bar"] = "test4" serviceEnv["star"] = "test3" @@ -961,11 +961,11 @@ func TestNative_Compile_StepsPipelineTemplate(t *testing.T) { }, } - setupEnv := environment(nil, m, nil, nil) + setupEnv := environment(nil, m, nil, nil, nil) setupEnv["bar"] = "test4" setupEnv["star"] = "test3" - installEnv := environment(nil, m, nil, nil) + installEnv := environment(nil, m, nil, nil, nil) installEnv["GRADLE_OPTS"] = "-Dorg.gradle.daemon=false -Dorg.gradle.workers.max=1 -Dorg.gradle.parallel=false" installEnv["GRADLE_USER_HOME"] = ".gradle" installEnv["HOME"] = "/root" @@ -974,7 +974,7 @@ func TestNative_Compile_StepsPipelineTemplate(t *testing.T) { installEnv["bar"] = "test4" installEnv["star"] = "test3" - testEnv := environment(nil, m, nil, nil) + testEnv := environment(nil, m, nil, nil, nil) testEnv["GRADLE_OPTS"] = "-Dorg.gradle.daemon=false -Dorg.gradle.workers.max=1 -Dorg.gradle.parallel=false" testEnv["GRADLE_USER_HOME"] = ".gradle" testEnv["HOME"] = "/root" @@ -983,7 +983,7 @@ func TestNative_Compile_StepsPipelineTemplate(t *testing.T) { testEnv["bar"] = "test4" testEnv["star"] = "test3" - buildEnv := environment(nil, m, nil, nil) + buildEnv := environment(nil, m, nil, nil, nil) buildEnv["GRADLE_OPTS"] = "-Dorg.gradle.daemon=false -Dorg.gradle.workers.max=1 -Dorg.gradle.parallel=false" buildEnv["GRADLE_USER_HOME"] = ".gradle" buildEnv["HOME"] = "/root" @@ -992,14 +992,14 @@ func TestNative_Compile_StepsPipelineTemplate(t *testing.T) { buildEnv["bar"] = "test4" buildEnv["star"] = "test3" - dockerEnv := environment(nil, m, nil, nil) + dockerEnv := environment(nil, m, nil, nil, nil) dockerEnv["PARAMETER_REGISTRY"] = "index.docker.io" dockerEnv["PARAMETER_REPO"] = "github/octocat" dockerEnv["PARAMETER_TAGS"] = "latest,dev" dockerEnv["bar"] = "test4" dockerEnv["star"] = "test3" - serviceEnv := environment(nil, m, nil, nil) + serviceEnv := environment(nil, m, nil, nil, nil) serviceEnv["bar"] = "test4" serviceEnv["star"] = "test3" @@ -1195,9 +1195,9 @@ func TestNative_Compile_StepsPipelineTemplate_VelaFunction_TemplateName(t *testi }, } - setupEnv := environment(nil, m, nil, nil) + setupEnv := environment(nil, m, nil, nil, nil) - helloEnv := environment(nil, m, nil, nil) + helloEnv := environment(nil, m, nil, nil, nil) helloEnv["HOME"] = "/root" helloEnv["SHELL"] = "/bin/sh" helloEnv["VELA_BUILD_SCRIPT"] = generateScriptPosix([]string{"echo sample"}) @@ -1316,9 +1316,9 @@ func TestNative_Compile_StepsPipelineTemplate_VelaFunction_TemplateName_Inline(t }, } - setupEnv := environment(nil, m, nil, nil) + setupEnv := environment(nil, m, nil, nil, nil) - helloEnv := environment(nil, m, nil, nil) + helloEnv := environment(nil, m, nil, nil, nil) helloEnv["HOME"] = "/root" helloEnv["SHELL"] = "/bin/sh" helloEnv["VELA_BUILD_SCRIPT"] = generateScriptPosix([]string{"echo inline_templatename"}) @@ -1436,11 +1436,11 @@ func TestNative_Compile_InvalidType(t *testing.T) { }, } - gradleEnv := environment(nil, m, nil, nil) + gradleEnv := environment(nil, m, nil, nil, nil) gradleEnv["GRADLE_OPTS"] = "-Dorg.gradle.daemon=false -Dorg.gradle.workers.max=1 -Dorg.gradle.parallel=false" gradleEnv["GRADLE_USER_HOME"] = ".gradle" - dockerEnv := environment(nil, m, nil, nil) + dockerEnv := environment(nil, m, nil, nil, nil) dockerEnv["PARAMETER_REGISTRY"] = "index.docker.io" dockerEnv["PARAMETER_REPO"] = "github/octocat" dockerEnv["PARAMETER_TAGS"] = "latest,dev" @@ -1493,10 +1493,10 @@ func TestNative_Compile_Clone(t *testing.T) { }, } - fooEnv := environment(nil, m, nil, nil) + fooEnv := environment(nil, m, nil, nil, nil) fooEnv["PARAMETER_REGISTRY"] = "foo" - cloneEnv := environment(nil, m, nil, nil) + cloneEnv := environment(nil, m, nil, nil, nil) cloneEnv["PARAMETER_DEPTH"] = "5" wantFalse := &pipeline.Build{ @@ -1512,7 +1512,7 @@ func TestNative_Compile_Clone(t *testing.T) { &pipeline.Container{ ID: "step___0_init", Directory: "/vela/src/foo//", - Environment: environment(nil, m, nil, nil), + Environment: environment(nil, m, nil, nil, nil), Image: "#init", Name: "init", Number: 1, @@ -1543,7 +1543,7 @@ func TestNative_Compile_Clone(t *testing.T) { &pipeline.Container{ ID: "step___0_init", Directory: "/vela/src/foo//", - Environment: environment(nil, m, nil, nil), + Environment: environment(nil, m, nil, nil, nil), Image: "#init", Name: "init", Number: 1, @@ -1552,7 +1552,7 @@ func TestNative_Compile_Clone(t *testing.T) { &pipeline.Container{ ID: "step___0_clone", Directory: "/vela/src/foo//", - Environment: environment(nil, m, nil, nil), + Environment: environment(nil, m, nil, nil, nil), Image: defaultCloneImage, Name: "clone", Number: 2, @@ -1583,7 +1583,7 @@ func TestNative_Compile_Clone(t *testing.T) { &pipeline.Container{ ID: "step___0_init", Directory: "/vela/src/foo//", - Environment: environment(nil, m, nil, nil), + Environment: environment(nil, m, nil, nil, nil), Image: "#init", Name: "init", Number: 1, @@ -1687,10 +1687,10 @@ func TestNative_Compile_Pipeline_Type(t *testing.T) { }, } - defaultFooEnv := environment(nil, m, nil, nil) + defaultFooEnv := environment(nil, m, nil, nil, nil) defaultFooEnv["PARAMETER_REGISTRY"] = "foo" - defaultEnv := environment(nil, m, nil, nil) + defaultEnv := environment(nil, m, nil, nil, nil) wantDefault := &pipeline.Build{ Version: "1", ID: "__0", @@ -1733,10 +1733,10 @@ func TestNative_Compile_Pipeline_Type(t *testing.T) { goPipelineType := "go" - goFooEnv := environment(nil, m, &api.Repo{PipelineType: &goPipelineType}, nil) + goFooEnv := environment(nil, m, &api.Repo{PipelineType: &goPipelineType}, nil, nil) goFooEnv["PARAMETER_REGISTRY"] = "foo" - defaultGoEnv := environment(nil, m, &api.Repo{PipelineType: &goPipelineType}, nil) + defaultGoEnv := environment(nil, m, &api.Repo{PipelineType: &goPipelineType}, nil, nil) wantGo := &pipeline.Build{ Version: "1", ID: "__0", @@ -1779,10 +1779,10 @@ func TestNative_Compile_Pipeline_Type(t *testing.T) { starPipelineType := "starlark" - starlarkFooEnv := environment(nil, m, &api.Repo{PipelineType: &starPipelineType}, nil) + starlarkFooEnv := environment(nil, m, &api.Repo{PipelineType: &starPipelineType}, nil, nil) starlarkFooEnv["PARAMETER_REGISTRY"] = "foo" - defaultStarlarkEnv := environment(nil, m, &api.Repo{PipelineType: &starPipelineType}, nil) + defaultStarlarkEnv := environment(nil, m, &api.Repo{PipelineType: &starPipelineType}, nil, nil) wantStarlark := &pipeline.Build{ Version: "1", ID: "__0", @@ -2039,13 +2039,13 @@ func Test_client_modifyConfig(t *testing.T) { }, Steps: yaml.StepSlice{ &yaml.Step{ - Environment: environment(nil, m, nil, nil), + Environment: environment(nil, m, nil, nil, nil), Image: "#init", Name: "init", Pull: "not_present", }, &yaml.Step{ - Environment: environment(nil, m, nil, nil), + Environment: environment(nil, m, nil, nil, nil), Image: defaultCloneImage, Name: "clone", Pull: "not_present", @@ -2072,13 +2072,13 @@ func Test_client_modifyConfig(t *testing.T) { }, Steps: yaml.StepSlice{ &yaml.Step{ - Environment: environment(nil, m, nil, nil), + Environment: environment(nil, m, nil, nil, nil), Image: "#init", Name: "init", Pull: "not_present", }, &yaml.Step{ - Environment: environment(nil, m, nil, nil), + Environment: environment(nil, m, nil, nil, nil), Image: defaultCloneImage, Name: "clone", Pull: "not_present", @@ -2255,7 +2255,7 @@ func convertFileToGithubResponse(file string) (github.RepositoryContent, error) } func generateTestEnv(command string, m *internal.Metadata, pipelineType string) map[string]string { - output := environment(nil, m, nil, nil) + output := environment(nil, m, nil, nil, nil) output["VELA_BUILD_SCRIPT"] = generateScriptPosix([]string{command}) output["HOME"] = "/root" output["SHELL"] = "/bin/sh" @@ -2312,15 +2312,15 @@ func Test_Compile_Inline(t *testing.T) { }, } - initEnv := environment(nil, m, nil, nil) - testEnv := environment(nil, m, nil, nil) + initEnv := environment(nil, m, nil, nil, nil) + testEnv := environment(nil, m, nil, nil, nil) testEnv["FOO"] = "Hello, foo!" testEnv["HELLO"] = "Hello, Vela!" - stepEnv := environment(nil, m, nil, nil) + stepEnv := environment(nil, m, nil, nil, nil) stepEnv["FOO"] = "Hello, foo!" stepEnv["HELLO"] = "Hello, Vela!" stepEnv["PARAMETER_FIRST"] = "foo" - golangEnv := environment(nil, m, nil, nil) + golangEnv := environment(nil, m, nil, nil, nil) golangEnv["VELA_REPO_PIPELINE_TYPE"] = "go" type args struct { diff --git a/compiler/native/environment.go b/compiler/native/environment.go index fae7557d1..3f3162137 100644 --- a/compiler/native/environment.go +++ b/compiler/native/environment.go @@ -33,8 +33,9 @@ func (c *client) EnvironmentStages(s yaml.StageSlice, globalEnv raw.StringSliceM func (c *client) EnvironmentStage(s *yaml.Stage, globalEnv raw.StringSliceMap) (*yaml.Stage, error) { // make empty map of environment variables env := make(map[string]string) + // gather set of default environment variables - defaultEnv := environment(c.build, c.metadata, c.repo, c.user) + defaultEnv := environment(c.build, c.metadata, c.repo, c.user, c.netrc) // inject the declared global environment // WARNING: local env can override global @@ -87,8 +88,9 @@ func (c *client) EnvironmentSteps(s yaml.StepSlice, stageEnv raw.StringSliceMap) func (c *client) EnvironmentStep(s *yaml.Step, stageEnv raw.StringSliceMap) (*yaml.Step, error) { // make empty map of environment variables env := make(map[string]string) + // gather set of default environment variables - defaultEnv := environment(c.build, c.metadata, c.repo, c.user) + defaultEnv := environment(c.build, c.metadata, c.repo, c.user, c.netrc) // inject the declared stage environment // WARNING: local env can override global + stage @@ -148,8 +150,9 @@ func (c *client) EnvironmentServices(s yaml.ServiceSlice, globalEnv raw.StringSl for _, service := range s { // make empty map of environment variables env := make(map[string]string) + // gather set of default environment variables - defaultEnv := environment(c.build, c.metadata, c.repo, c.user) + defaultEnv := environment(c.build, c.metadata, c.repo, c.user, c.netrc) // inject the declared global environment // WARNING: local env can override global @@ -188,8 +191,9 @@ func (c *client) EnvironmentSecrets(s yaml.SecretSlice, globalEnv raw.StringSlic // make empty map of environment variables env := make(map[string]string) + // gather set of default environment variables - defaultEnv := environment(c.build, c.metadata, c.repo, c.user) + defaultEnv := environment(c.build, c.metadata, c.repo, c.user, c.netrc) // inject the declared global environment // WARNING: local env can override global @@ -243,11 +247,14 @@ func (c *client) EnvironmentSecrets(s yaml.SecretSlice, globalEnv raw.StringSlic return s, nil } +// EnvironmentBuild injects environment variables +// for the build in a yaml configuration. func (c *client) EnvironmentBuild() map[string]string { // make empty map of environment variables env := make(map[string]string) + // gather set of default environment variables - defaultEnv := environment(c.build, c.metadata, c.repo, c.user) + defaultEnv := environment(c.build, c.metadata, c.repo, c.user, c.netrc) // inject the default environment // variables to the build @@ -281,7 +288,7 @@ func appendMap(originalMap, otherMap map[string]string) map[string]string { } // helper function that creates the standard set of environment variables for a pipeline. -func environment(b *api.Build, m *internal.Metadata, r *api.Repo, u *api.User) map[string]string { +func environment(b *api.Build, m *internal.Metadata, r *api.Repo, u *api.User, netrc *string) map[string]string { // set default workspace workspace := constants.WorkspaceDefault notImplemented := "TODO" @@ -297,7 +304,7 @@ func environment(b *api.Build, m *internal.Metadata, r *api.Repo, u *api.User) m env["VELA_DISTRIBUTION"] = notImplemented env["VELA_HOST"] = notImplemented env["VELA_NETRC_MACHINE"] = notImplemented - env["VELA_NETRC_PASSWORD"] = u.GetToken() + env["VELA_NETRC_PASSWORD"] = notImplemented env["VELA_NETRC_USERNAME"] = "x-oauth-basic" env["VELA_QUEUE"] = notImplemented env["VELA_RUNTIME"] = notImplemented @@ -321,6 +328,10 @@ func environment(b *api.Build, m *internal.Metadata, r *api.Repo, u *api.User) m workspace = fmt.Sprintf("%s/%s/%s/%s", workspace, m.Source.Host, r.GetOrg(), r.GetName()) } + if netrc != nil { + env["VELA_NETRC_PASSWORD"] = *netrc + } + env["VELA_WORKSPACE"] = workspace // populate environment variables from repo api diff --git a/compiler/native/environment_test.go b/compiler/native/environment_test.go index b9ddcdec0..17dce3942 100644 --- a/compiler/native/environment_test.go +++ b/compiler/native/environment_test.go @@ -42,7 +42,7 @@ func TestNative_EnvironmentStages(t *testing.T) { }, } - env := environment(nil, nil, nil, nil) + env := environment(nil, nil, nil, nil, nil) env["HELLO"] = "Hello, Global Message" want := yaml.StageSlice{ @@ -174,7 +174,7 @@ func TestNative_EnvironmentSteps(t *testing.T) { "VELA_DISTRIBUTION": "TODO", "VELA_HOST": "TODO", "VELA_NETRC_MACHINE": "TODO", - "VELA_NETRC_PASSWORD": "", + "VELA_NETRC_PASSWORD": "TODO", "VELA_NETRC_USERNAME": "x-oauth-basic", "VELA_QUEUE": "TODO", "VELA_REPO_ACTIVE": "false", @@ -351,7 +351,7 @@ func TestNative_EnvironmentServices(t *testing.T) { "VELA_DISTRIBUTION": "TODO", "VELA_HOST": "TODO", "VELA_NETRC_MACHINE": "TODO", - "VELA_NETRC_PASSWORD": "", + "VELA_NETRC_PASSWORD": "TODO", "VELA_NETRC_USERNAME": "x-oauth-basic", "VELA_QUEUE": "TODO", "VELA_REPO_ACTIVE": "false", @@ -510,7 +510,7 @@ func TestNative_EnvironmentSecrets(t *testing.T) { "VELA_DISTRIBUTION": "TODO", "VELA_HOST": "TODO", "VELA_NETRC_MACHINE": "TODO", - "VELA_NETRC_PASSWORD": "", + "VELA_NETRC_PASSWORD": "TODO", "VELA_NETRC_USERNAME": "x-oauth-basic", "VELA_QUEUE": "TODO", "VELA_REPO_ACTIVE": "false", @@ -580,56 +580,73 @@ func TestNative_environment(t *testing.T) { // deployment deploy := "deployment" target := "production" + // netrc + netrc := "foo" tests := []struct { - w string - b *api.Build - m *internal.Metadata - r *api.Repo - u *api.User - want map[string]string + w string + b *api.Build + m *internal.Metadata + r *api.Repo + u *api.User + netrc *string + want map[string]string }{ // push { - w: workspace, - b: &api.Build{ID: &num64, Repo: &api.Repo{ID: &num64, Owner: &api.User{ID: &num64, Name: &str, Token: &str, Active: &booL, Admin: &booL}, Org: &str, Name: &str, FullName: &str, Link: &str, Clone: &str, Branch: &str, Topics: &topics, BuildLimit: &num64, Timeout: &num64, Visibility: &str, Private: &booL, Trusted: &booL, Active: &booL}, Number: &num, Parent: &num, Event: &push, Status: &str, Error: &str, Enqueued: &num64, Created: &num64, Started: &num64, Finished: &num64, Deploy: &str, Clone: &str, Source: &str, Title: &str, Message: &str, Commit: &str, Sender: &str, SenderSCMID: &str, Author: &str, Branch: &str, Ref: &str, BaseRef: &str}, - m: &internal.Metadata{Database: &internal.Database{Driver: str, Host: str}, Queue: &internal.Queue{Channel: str, Driver: str, Host: str}, Source: &internal.Source{Driver: str, Host: str}, Vela: &internal.Vela{Address: str, WebAddress: str, OpenIDIssuer: str}}, - r: &api.Repo{ID: &num64, Owner: &api.User{ID: &num64, Name: &str, Token: &str, Active: &booL, Admin: &booL}, Org: &str, Name: &str, FullName: &str, Link: &str, Clone: &str, Branch: &str, Topics: &topics, BuildLimit: &num64, Timeout: &num64, Visibility: &str, Private: &booL, Trusted: &booL, Active: &booL}, - u: &api.User{ID: &num64, Name: &str, Token: &str, Active: &booL, Admin: &booL}, - want: map[string]string{"BUILD_AUTHOR": "foo", "BUILD_AUTHOR_EMAIL": "", "BUILD_BASE_REF": "foo", "BUILD_BRANCH": "foo", "BUILD_CHANNEL": "foo", "BUILD_CLONE": "foo", "BUILD_COMMIT": "foo", "BUILD_CREATED": "1", "BUILD_ENQUEUED": "1", "BUILD_EVENT": "push", "BUILD_HOST": "", "BUILD_LINK": "", "BUILD_MESSAGE": "foo", "BUILD_NUMBER": "1", "BUILD_PARENT": "1", "BUILD_REF": "foo", "BUILD_SENDER": "foo", "BUILD_SOURCE": "foo", "BUILD_STARTED": "1", "BUILD_STATUS": "foo", "BUILD_TITLE": "foo", "BUILD_WORKSPACE": "/vela/src/foo/foo/foo", "CI": "true", "REPOSITORY_ACTIVE": "false", "REPOSITORY_ALLOW_EVENTS": "", "REPOSITORY_BRANCH": "foo", "REPOSITORY_CLONE": "foo", "REPOSITORY_FULL_NAME": "foo", "REPOSITORY_LINK": "foo", "REPOSITORY_NAME": "foo", "REPOSITORY_ORG": "foo", "REPOSITORY_PRIVATE": "false", "REPOSITORY_TIMEOUT": "1", "REPOSITORY_TRUSTED": "false", "REPOSITORY_VISIBILITY": "foo", "VELA": "true", "VELA_ADDR": "foo", "VELA_SERVER_ADDR": "foo", "VELA_OPEN_ID_ISSUER": "foo", "VELA_BUILD_APPROVED_AT": "0", "VELA_BUILD_APPROVED_BY": "", "VELA_BUILD_AUTHOR": "foo", "VELA_BUILD_AUTHOR_EMAIL": "", "VELA_BUILD_BASE_REF": "foo", "VELA_BUILD_BRANCH": "foo", "VELA_BUILD_CHANNEL": "foo", "VELA_BUILD_CLONE": "foo", "VELA_BUILD_COMMIT": "foo", "VELA_BUILD_CREATED": "1", "VELA_BUILD_DISTRIBUTION": "", "VELA_BUILD_ENQUEUED": "1", "VELA_BUILD_EVENT": "push", "VELA_BUILD_EVENT_ACTION": "", "VELA_BUILD_HOST": "", "VELA_BUILD_LINK": "", "VELA_BUILD_MESSAGE": "foo", "VELA_BUILD_NUMBER": "1", "VELA_BUILD_PARENT": "1", "VELA_BUILD_REF": "foo", "VELA_BUILD_RUNTIME": "", "VELA_BUILD_SENDER": "foo", "VELA_BUILD_SENDER_SCM_ID": "foo", "VELA_BUILD_SOURCE": "foo", "VELA_BUILD_STARTED": "1", "VELA_BUILD_STATUS": "foo", "VELA_BUILD_TITLE": "foo", "VELA_BUILD_WORKSPACE": "/vela/src/foo/foo/foo", "VELA_CHANNEL": "foo", "VELA_DATABASE": "foo", "VELA_DISTRIBUTION": "TODO", "VELA_HOST": "foo", "VELA_NETRC_MACHINE": "foo", "VELA_NETRC_PASSWORD": "foo", "VELA_NETRC_USERNAME": "x-oauth-basic", "VELA_QUEUE": "foo", "VELA_REPO_ACTIVE": "false", "VELA_REPO_ALLOW_EVENTS": "", "VELA_REPO_APPROVE_BUILD": "", "VELA_REPO_BRANCH": "foo", "VELA_REPO_TOPICS": "cloud,security", "VELA_REPO_BUILD_LIMIT": "1", "VELA_REPO_CLONE": "foo", "VELA_REPO_FULL_NAME": "foo", "VELA_REPO_LINK": "foo", "VELA_REPO_NAME": "foo", "VELA_REPO_ORG": "foo", "VELA_REPO_OWNER": "foo", "VELA_REPO_PIPELINE_TYPE": "", "VELA_REPO_PRIVATE": "false", "VELA_REPO_TIMEOUT": "1", "VELA_REPO_TRUSTED": "false", "VELA_REPO_VISIBILITY": "foo", "VELA_RUNTIME": "TODO", "VELA_SOURCE": "foo", "VELA_USER_ACTIVE": "false", "VELA_USER_ADMIN": "false", "VELA_USER_FAVORITES": "[]", "VELA_USER_NAME": "foo", "VELA_VERSION": "TODO", "VELA_WORKSPACE": "/vela/src/foo/foo/foo", "VELA_ID_TOKEN_REQUEST_URL": "foo/api/v1/repos/foo/builds/1/id_token"}, + w: workspace, + b: &api.Build{ID: &num64, Repo: &api.Repo{ID: &num64, Owner: &api.User{ID: &num64, Name: &str, Token: &str, Active: &booL, Admin: &booL}, Org: &str, Name: &str, FullName: &str, Link: &str, Clone: &str, Branch: &str, Topics: &topics, BuildLimit: &num64, Timeout: &num64, Visibility: &str, Private: &booL, Trusted: &booL, Active: &booL}, Number: &num, Parent: &num, Event: &push, Status: &str, Error: &str, Enqueued: &num64, Created: &num64, Started: &num64, Finished: &num64, Deploy: &str, Clone: &str, Source: &str, Title: &str, Message: &str, Commit: &str, Sender: &str, SenderSCMID: &str, Author: &str, Branch: &str, Ref: &str, BaseRef: &str}, + m: &internal.Metadata{Database: &internal.Database{Driver: str, Host: str}, Queue: &internal.Queue{Channel: str, Driver: str, Host: str}, Source: &internal.Source{Driver: str, Host: str}, Vela: &internal.Vela{Address: str, WebAddress: str, OpenIDIssuer: str}}, + r: &api.Repo{ID: &num64, Owner: &api.User{ID: &num64, Name: &str, Token: &str, Active: &booL, Admin: &booL}, Org: &str, Name: &str, FullName: &str, Link: &str, Clone: &str, Branch: &str, Topics: &topics, BuildLimit: &num64, Timeout: &num64, Visibility: &str, Private: &booL, Trusted: &booL, Active: &booL}, + u: &api.User{ID: &num64, Name: &str, Token: &str, Active: &booL, Admin: &booL}, + netrc: &netrc, + want: map[string]string{"BUILD_AUTHOR": "foo", "BUILD_AUTHOR_EMAIL": "", "BUILD_BASE_REF": "foo", "BUILD_BRANCH": "foo", "BUILD_CHANNEL": "foo", "BUILD_CLONE": "foo", "BUILD_COMMIT": "foo", "BUILD_CREATED": "1", "BUILD_ENQUEUED": "1", "BUILD_EVENT": "push", "BUILD_HOST": "", "BUILD_LINK": "", "BUILD_MESSAGE": "foo", "BUILD_NUMBER": "1", "BUILD_PARENT": "1", "BUILD_REF": "foo", "BUILD_SENDER": "foo", "BUILD_SOURCE": "foo", "BUILD_STARTED": "1", "BUILD_STATUS": "foo", "BUILD_TITLE": "foo", "BUILD_WORKSPACE": "/vela/src/foo/foo/foo", "CI": "true", "REPOSITORY_ACTIVE": "false", "REPOSITORY_ALLOW_EVENTS": "", "REPOSITORY_BRANCH": "foo", "REPOSITORY_CLONE": "foo", "REPOSITORY_FULL_NAME": "foo", "REPOSITORY_LINK": "foo", "REPOSITORY_NAME": "foo", "REPOSITORY_ORG": "foo", "REPOSITORY_PRIVATE": "false", "REPOSITORY_TIMEOUT": "1", "REPOSITORY_TRUSTED": "false", "REPOSITORY_VISIBILITY": "foo", "VELA": "true", "VELA_ADDR": "foo", "VELA_SERVER_ADDR": "foo", "VELA_OPEN_ID_ISSUER": "foo", "VELA_BUILD_APPROVED_AT": "0", "VELA_BUILD_APPROVED_BY": "", "VELA_BUILD_AUTHOR": "foo", "VELA_BUILD_AUTHOR_EMAIL": "", "VELA_BUILD_BASE_REF": "foo", "VELA_BUILD_BRANCH": "foo", "VELA_BUILD_CHANNEL": "foo", "VELA_BUILD_CLONE": "foo", "VELA_BUILD_COMMIT": "foo", "VELA_BUILD_CREATED": "1", "VELA_BUILD_DISTRIBUTION": "", "VELA_BUILD_ENQUEUED": "1", "VELA_BUILD_EVENT": "push", "VELA_BUILD_EVENT_ACTION": "", "VELA_BUILD_HOST": "", "VELA_BUILD_LINK": "", "VELA_BUILD_MESSAGE": "foo", "VELA_BUILD_NUMBER": "1", "VELA_BUILD_PARENT": "1", "VELA_BUILD_REF": "foo", "VELA_BUILD_RUNTIME": "", "VELA_BUILD_SENDER": "foo", "VELA_BUILD_SENDER_SCM_ID": "foo", "VELA_BUILD_SOURCE": "foo", "VELA_BUILD_STARTED": "1", "VELA_BUILD_STATUS": "foo", "VELA_BUILD_TITLE": "foo", "VELA_BUILD_WORKSPACE": "/vela/src/foo/foo/foo", "VELA_CHANNEL": "foo", "VELA_DATABASE": "foo", "VELA_DISTRIBUTION": "TODO", "VELA_HOST": "foo", "VELA_NETRC_MACHINE": "foo", "VELA_NETRC_PASSWORD": "foo", "VELA_NETRC_USERNAME": "x-oauth-basic", "VELA_QUEUE": "foo", "VELA_REPO_ACTIVE": "false", "VELA_REPO_ALLOW_EVENTS": "", "VELA_REPO_APPROVE_BUILD": "", "VELA_REPO_BRANCH": "foo", "VELA_REPO_TOPICS": "cloud,security", "VELA_REPO_BUILD_LIMIT": "1", "VELA_REPO_CLONE": "foo", "VELA_REPO_FULL_NAME": "foo", "VELA_REPO_LINK": "foo", "VELA_REPO_NAME": "foo", "VELA_REPO_ORG": "foo", "VELA_REPO_OWNER": "foo", "VELA_REPO_PIPELINE_TYPE": "", "VELA_REPO_PRIVATE": "false", "VELA_REPO_TIMEOUT": "1", "VELA_REPO_TRUSTED": "false", "VELA_REPO_VISIBILITY": "foo", "VELA_RUNTIME": "TODO", "VELA_SOURCE": "foo", "VELA_USER_ACTIVE": "false", "VELA_USER_ADMIN": "false", "VELA_USER_FAVORITES": "[]", "VELA_USER_NAME": "foo", "VELA_VERSION": "TODO", "VELA_WORKSPACE": "/vela/src/foo/foo/foo", "VELA_ID_TOKEN_REQUEST_URL": "foo/api/v1/repos/foo/builds/1/id_token"}, }, // tag { - w: workspace, - b: &api.Build{ID: &num64, Repo: &api.Repo{ID: &num64, Owner: &api.User{ID: &num64, Name: &str, Token: &str, Active: &booL, Admin: &booL}, Org: &str, Name: &str, FullName: &str, Link: &str, Clone: &str, Branch: &str, Topics: &topics, BuildLimit: &num64, Timeout: &num64, Visibility: &str, Private: &booL, Trusted: &booL, Active: &booL}, Number: &num, Parent: &num, Event: &tag, Status: &str, Error: &str, Enqueued: &num64, Created: &num64, Started: &num64, Finished: &num64, Deploy: &str, Clone: &str, Source: &str, Title: &str, Message: &str, Commit: &str, Sender: &str, SenderSCMID: &str, Author: &str, Branch: &str, Ref: &tagref, BaseRef: &str}, - m: &internal.Metadata{Database: &internal.Database{Driver: str, Host: str}, Queue: &internal.Queue{Channel: str, Driver: str, Host: str}, Source: &internal.Source{Driver: str, Host: str}, Vela: &internal.Vela{Address: str, WebAddress: str, OpenIDIssuer: str}}, - r: &api.Repo{ID: &num64, Owner: &api.User{ID: &num64, Name: &str, Token: &str, Active: &booL, Admin: &booL}, Org: &str, Name: &str, FullName: &str, Link: &str, Clone: &str, Branch: &str, Topics: &topics, BuildLimit: &num64, Timeout: &num64, Visibility: &str, Private: &booL, Trusted: &booL, Active: &booL}, - u: &api.User{ID: &num64, Name: &str, Token: &str, Active: &booL, Admin: &booL}, - want: map[string]string{"BUILD_AUTHOR": "foo", "BUILD_AUTHOR_EMAIL": "", "BUILD_BASE_REF": "foo", "BUILD_BRANCH": "foo", "BUILD_CHANNEL": "foo", "BUILD_CLONE": "foo", "BUILD_COMMIT": "foo", "BUILD_CREATED": "1", "BUILD_ENQUEUED": "1", "BUILD_EVENT": "tag", "BUILD_HOST": "", "BUILD_LINK": "", "BUILD_MESSAGE": "foo", "BUILD_NUMBER": "1", "BUILD_PARENT": "1", "BUILD_REF": "refs/tags/1", "BUILD_SENDER": "foo", "BUILD_SOURCE": "foo", "BUILD_STARTED": "1", "BUILD_STATUS": "foo", "BUILD_TAG": "1", "BUILD_TITLE": "foo", "BUILD_WORKSPACE": "/vela/src/foo/foo/foo", "CI": "true", "REPOSITORY_ACTIVE": "false", "REPOSITORY_ALLOW_EVENTS": "", "REPOSITORY_BRANCH": "foo", "REPOSITORY_CLONE": "foo", "REPOSITORY_FULL_NAME": "foo", "REPOSITORY_LINK": "foo", "REPOSITORY_NAME": "foo", "REPOSITORY_ORG": "foo", "REPOSITORY_PRIVATE": "false", "REPOSITORY_TIMEOUT": "1", "REPOSITORY_TRUSTED": "false", "REPOSITORY_VISIBILITY": "foo", "VELA": "true", "VELA_ADDR": "foo", "VELA_SERVER_ADDR": "foo", "VELA_OPEN_ID_ISSUER": "foo", "VELA_BUILD_APPROVED_AT": "0", "VELA_BUILD_APPROVED_BY": "", "VELA_BUILD_AUTHOR": "foo", "VELA_BUILD_AUTHOR_EMAIL": "", "VELA_BUILD_BASE_REF": "foo", "VELA_BUILD_BRANCH": "foo", "VELA_BUILD_CHANNEL": "foo", "VELA_BUILD_CLONE": "foo", "VELA_BUILD_COMMIT": "foo", "VELA_BUILD_CREATED": "1", "VELA_BUILD_DISTRIBUTION": "", "VELA_BUILD_ENQUEUED": "1", "VELA_BUILD_EVENT": "tag", "VELA_BUILD_EVENT_ACTION": "", "VELA_BUILD_HOST": "", "VELA_BUILD_LINK": "", "VELA_BUILD_MESSAGE": "foo", "VELA_BUILD_NUMBER": "1", "VELA_BUILD_PARENT": "1", "VELA_BUILD_REF": "refs/tags/1", "VELA_BUILD_RUNTIME": "", "VELA_BUILD_SENDER": "foo", "VELA_BUILD_SENDER_SCM_ID": "foo", "VELA_BUILD_SOURCE": "foo", "VELA_BUILD_STARTED": "1", "VELA_BUILD_STATUS": "foo", "VELA_BUILD_TAG": "1", "VELA_BUILD_TITLE": "foo", "VELA_BUILD_WORKSPACE": "/vela/src/foo/foo/foo", "VELA_CHANNEL": "foo", "VELA_DATABASE": "foo", "VELA_DISTRIBUTION": "TODO", "VELA_HOST": "foo", "VELA_NETRC_MACHINE": "foo", "VELA_NETRC_PASSWORD": "foo", "VELA_NETRC_USERNAME": "x-oauth-basic", "VELA_QUEUE": "foo", "VELA_REPO_ACTIVE": "false", "VELA_REPO_ALLOW_EVENTS": "", "VELA_REPO_APPROVE_BUILD": "", "VELA_REPO_BRANCH": "foo", "VELA_REPO_TOPICS": "cloud,security", "VELA_REPO_BUILD_LIMIT": "1", "VELA_REPO_CLONE": "foo", "VELA_REPO_FULL_NAME": "foo", "VELA_REPO_LINK": "foo", "VELA_REPO_NAME": "foo", "VELA_REPO_ORG": "foo", "VELA_REPO_OWNER": "foo", "VELA_REPO_PIPELINE_TYPE": "", "VELA_REPO_PRIVATE": "false", "VELA_REPO_TIMEOUT": "1", "VELA_REPO_TRUSTED": "false", "VELA_REPO_VISIBILITY": "foo", "VELA_RUNTIME": "TODO", "VELA_SOURCE": "foo", "VELA_USER_ACTIVE": "false", "VELA_USER_ADMIN": "false", "VELA_USER_FAVORITES": "[]", "VELA_USER_NAME": "foo", "VELA_VERSION": "TODO", "VELA_WORKSPACE": "/vela/src/foo/foo/foo", "VELA_ID_TOKEN_REQUEST_URL": "foo/api/v1/repos/foo/builds/1/id_token"}, + w: workspace, + b: &api.Build{ID: &num64, Repo: &api.Repo{ID: &num64, Owner: &api.User{ID: &num64, Name: &str, Token: &str, Active: &booL, Admin: &booL}, Org: &str, Name: &str, FullName: &str, Link: &str, Clone: &str, Branch: &str, Topics: &topics, BuildLimit: &num64, Timeout: &num64, Visibility: &str, Private: &booL, Trusted: &booL, Active: &booL}, Number: &num, Parent: &num, Event: &tag, Status: &str, Error: &str, Enqueued: &num64, Created: &num64, Started: &num64, Finished: &num64, Deploy: &str, Clone: &str, Source: &str, Title: &str, Message: &str, Commit: &str, Sender: &str, SenderSCMID: &str, Author: &str, Branch: &str, Ref: &tagref, BaseRef: &str}, + m: &internal.Metadata{Database: &internal.Database{Driver: str, Host: str}, Queue: &internal.Queue{Channel: str, Driver: str, Host: str}, Source: &internal.Source{Driver: str, Host: str}, Vela: &internal.Vela{Address: str, WebAddress: str, OpenIDIssuer: str}}, + r: &api.Repo{ID: &num64, Owner: &api.User{ID: &num64, Name: &str, Token: &str, Active: &booL, Admin: &booL}, Org: &str, Name: &str, FullName: &str, Link: &str, Clone: &str, Branch: &str, Topics: &topics, BuildLimit: &num64, Timeout: &num64, Visibility: &str, Private: &booL, Trusted: &booL, Active: &booL}, + u: &api.User{ID: &num64, Name: &str, Token: &str, Active: &booL, Admin: &booL}, + netrc: &netrc, + want: map[string]string{"BUILD_AUTHOR": "foo", "BUILD_AUTHOR_EMAIL": "", "BUILD_BASE_REF": "foo", "BUILD_BRANCH": "foo", "BUILD_CHANNEL": "foo", "BUILD_CLONE": "foo", "BUILD_COMMIT": "foo", "BUILD_CREATED": "1", "BUILD_ENQUEUED": "1", "BUILD_EVENT": "tag", "BUILD_HOST": "", "BUILD_LINK": "", "BUILD_MESSAGE": "foo", "BUILD_NUMBER": "1", "BUILD_PARENT": "1", "BUILD_REF": "refs/tags/1", "BUILD_SENDER": "foo", "BUILD_SOURCE": "foo", "BUILD_STARTED": "1", "BUILD_STATUS": "foo", "BUILD_TAG": "1", "BUILD_TITLE": "foo", "BUILD_WORKSPACE": "/vela/src/foo/foo/foo", "CI": "true", "REPOSITORY_ACTIVE": "false", "REPOSITORY_ALLOW_EVENTS": "", "REPOSITORY_BRANCH": "foo", "REPOSITORY_CLONE": "foo", "REPOSITORY_FULL_NAME": "foo", "REPOSITORY_LINK": "foo", "REPOSITORY_NAME": "foo", "REPOSITORY_ORG": "foo", "REPOSITORY_PRIVATE": "false", "REPOSITORY_TIMEOUT": "1", "REPOSITORY_TRUSTED": "false", "REPOSITORY_VISIBILITY": "foo", "VELA": "true", "VELA_ADDR": "foo", "VELA_SERVER_ADDR": "foo", "VELA_OPEN_ID_ISSUER": "foo", "VELA_BUILD_APPROVED_AT": "0", "VELA_BUILD_APPROVED_BY": "", "VELA_BUILD_AUTHOR": "foo", "VELA_BUILD_AUTHOR_EMAIL": "", "VELA_BUILD_BASE_REF": "foo", "VELA_BUILD_BRANCH": "foo", "VELA_BUILD_CHANNEL": "foo", "VELA_BUILD_CLONE": "foo", "VELA_BUILD_COMMIT": "foo", "VELA_BUILD_CREATED": "1", "VELA_BUILD_DISTRIBUTION": "", "VELA_BUILD_ENQUEUED": "1", "VELA_BUILD_EVENT": "tag", "VELA_BUILD_EVENT_ACTION": "", "VELA_BUILD_HOST": "", "VELA_BUILD_LINK": "", "VELA_BUILD_MESSAGE": "foo", "VELA_BUILD_NUMBER": "1", "VELA_BUILD_PARENT": "1", "VELA_BUILD_REF": "refs/tags/1", "VELA_BUILD_RUNTIME": "", "VELA_BUILD_SENDER": "foo", "VELA_BUILD_SENDER_SCM_ID": "foo", "VELA_BUILD_SOURCE": "foo", "VELA_BUILD_STARTED": "1", "VELA_BUILD_STATUS": "foo", "VELA_BUILD_TAG": "1", "VELA_BUILD_TITLE": "foo", "VELA_BUILD_WORKSPACE": "/vela/src/foo/foo/foo", "VELA_CHANNEL": "foo", "VELA_DATABASE": "foo", "VELA_DISTRIBUTION": "TODO", "VELA_HOST": "foo", "VELA_NETRC_MACHINE": "foo", "VELA_NETRC_PASSWORD": "foo", "VELA_NETRC_USERNAME": "x-oauth-basic", "VELA_QUEUE": "foo", "VELA_REPO_ACTIVE": "false", "VELA_REPO_ALLOW_EVENTS": "", "VELA_REPO_APPROVE_BUILD": "", "VELA_REPO_BRANCH": "foo", "VELA_REPO_TOPICS": "cloud,security", "VELA_REPO_BUILD_LIMIT": "1", "VELA_REPO_CLONE": "foo", "VELA_REPO_FULL_NAME": "foo", "VELA_REPO_LINK": "foo", "VELA_REPO_NAME": "foo", "VELA_REPO_ORG": "foo", "VELA_REPO_OWNER": "foo", "VELA_REPO_PIPELINE_TYPE": "", "VELA_REPO_PRIVATE": "false", "VELA_REPO_TIMEOUT": "1", "VELA_REPO_TRUSTED": "false", "VELA_REPO_VISIBILITY": "foo", "VELA_RUNTIME": "TODO", "VELA_SOURCE": "foo", "VELA_USER_ACTIVE": "false", "VELA_USER_ADMIN": "false", "VELA_USER_FAVORITES": "[]", "VELA_USER_NAME": "foo", "VELA_VERSION": "TODO", "VELA_WORKSPACE": "/vela/src/foo/foo/foo", "VELA_ID_TOKEN_REQUEST_URL": "foo/api/v1/repos/foo/builds/1/id_token"}, }, // pull_request { - w: workspace, - b: &api.Build{ID: &num64, Repo: &api.Repo{ID: &num64, Owner: &api.User{ID: &num64, Name: &str, Token: &str, Active: &booL, Admin: &booL}, Org: &str, Name: &str, FullName: &str, Link: &str, Clone: &str, Branch: &str, Topics: &topics, BuildLimit: &num64, Timeout: &num64, Visibility: &str, Private: &booL, Trusted: &booL, Active: &booL}, Number: &num, Parent: &num, Event: &pull, EventAction: &pullact, Status: &str, Error: &str, Enqueued: &num64, Created: &num64, Started: &num64, Finished: &num64, Deploy: &str, Clone: &str, Source: &str, Title: &str, Message: &str, Commit: &str, Sender: &str, SenderSCMID: &str, Fork: &booL, Author: &str, Branch: &str, Ref: &pullref, BaseRef: &str}, - m: &internal.Metadata{Database: &internal.Database{Driver: str, Host: str}, Queue: &internal.Queue{Channel: str, Driver: str, Host: str}, Source: &internal.Source{Driver: str, Host: str}, Vela: &internal.Vela{Address: str, WebAddress: str, OpenIDIssuer: str}}, - r: &api.Repo{ID: &num64, Owner: &api.User{ID: &num64, Name: &str, Token: &str, Active: &booL, Admin: &booL}, Org: &str, Name: &str, FullName: &str, Link: &str, Clone: &str, Branch: &str, Topics: &topics, BuildLimit: &num64, Timeout: &num64, Visibility: &str, Private: &booL, Trusted: &booL, Active: &booL}, - u: &api.User{ID: &num64, Name: &str, Token: &str, Active: &booL, Admin: &booL}, - want: map[string]string{"BUILD_AUTHOR": "foo", "BUILD_AUTHOR_EMAIL": "", "BUILD_BASE_REF": "foo", "BUILD_BRANCH": "foo", "BUILD_CHANNEL": "foo", "BUILD_CLONE": "foo", "BUILD_COMMIT": "foo", "BUILD_CREATED": "1", "BUILD_ENQUEUED": "1", "BUILD_EVENT": "pull_request", "BUILD_HOST": "", "BUILD_LINK": "", "BUILD_MESSAGE": "foo", "BUILD_NUMBER": "1", "BUILD_PARENT": "1", "BUILD_PULL_REQUEST_NUMBER": "1", "BUILD_REF": "refs/pull/1/head", "BUILD_SENDER": "foo", "BUILD_SOURCE": "foo", "BUILD_STARTED": "1", "BUILD_STATUS": "foo", "BUILD_TITLE": "foo", "BUILD_WORKSPACE": "/vela/src/foo/foo/foo", "CI": "true", "REPOSITORY_ACTIVE": "false", "REPOSITORY_ALLOW_EVENTS": "", "REPOSITORY_BRANCH": "foo", "REPOSITORY_CLONE": "foo", "REPOSITORY_FULL_NAME": "foo", "REPOSITORY_LINK": "foo", "REPOSITORY_NAME": "foo", "REPOSITORY_ORG": "foo", "REPOSITORY_PRIVATE": "false", "REPOSITORY_TIMEOUT": "1", "REPOSITORY_TRUSTED": "false", "REPOSITORY_VISIBILITY": "foo", "VELA": "true", "VELA_ADDR": "foo", "VELA_SERVER_ADDR": "foo", "VELA_OPEN_ID_ISSUER": "foo", "VELA_BUILD_APPROVED_AT": "0", "VELA_BUILD_APPROVED_BY": "", "VELA_BUILD_AUTHOR": "foo", "VELA_BUILD_AUTHOR_EMAIL": "", "VELA_BUILD_BASE_REF": "foo", "VELA_BUILD_BRANCH": "foo", "VELA_BUILD_CHANNEL": "foo", "VELA_BUILD_CLONE": "foo", "VELA_BUILD_COMMIT": "foo", "VELA_BUILD_CREATED": "1", "VELA_BUILD_DISTRIBUTION": "", "VELA_BUILD_ENQUEUED": "1", "VELA_BUILD_EVENT": "pull_request", "VELA_BUILD_EVENT_ACTION": "opened", "VELA_BUILD_HOST": "", "VELA_BUILD_LINK": "", "VELA_BUILD_MESSAGE": "foo", "VELA_BUILD_NUMBER": "1", "VELA_BUILD_PARENT": "1", "VELA_BUILD_PULL_REQUEST": "1", "VELA_BUILD_REF": "refs/pull/1/head", "VELA_BUILD_RUNTIME": "", "VELA_BUILD_SENDER": "foo", "VELA_BUILD_SENDER_SCM_ID": "foo", "VELA_BUILD_SOURCE": "foo", "VELA_BUILD_STARTED": "1", "VELA_BUILD_STATUS": "foo", "VELA_BUILD_TITLE": "foo", "VELA_BUILD_WORKSPACE": "/vela/src/foo/foo/foo", "VELA_CHANNEL": "foo", "VELA_DATABASE": "foo", "VELA_DISTRIBUTION": "TODO", "VELA_HOST": "foo", "VELA_NETRC_MACHINE": "foo", "VELA_NETRC_PASSWORD": "foo", "VELA_NETRC_USERNAME": "x-oauth-basic", "VELA_PULL_REQUEST": "1", "VELA_PULL_REQUEST_FORK": "false", "VELA_PULL_REQUEST_SOURCE": "", "VELA_PULL_REQUEST_TARGET": "foo", "VELA_QUEUE": "foo", "VELA_REPO_ACTIVE": "false", "VELA_REPO_ALLOW_EVENTS": "", "VELA_REPO_APPROVE_BUILD": "", "VELA_REPO_BRANCH": "foo", "VELA_REPO_TOPICS": "cloud,security", "VELA_REPO_BUILD_LIMIT": "1", "VELA_REPO_CLONE": "foo", "VELA_REPO_FULL_NAME": "foo", "VELA_REPO_LINK": "foo", "VELA_REPO_NAME": "foo", "VELA_REPO_ORG": "foo", "VELA_REPO_OWNER": "foo", "VELA_REPO_PIPELINE_TYPE": "", "VELA_REPO_PRIVATE": "false", "VELA_REPO_TIMEOUT": "1", "VELA_REPO_TRUSTED": "false", "VELA_REPO_VISIBILITY": "foo", "VELA_RUNTIME": "TODO", "VELA_SOURCE": "foo", "VELA_USER_ACTIVE": "false", "VELA_USER_ADMIN": "false", "VELA_USER_FAVORITES": "[]", "VELA_USER_NAME": "foo", "VELA_VERSION": "TODO", "VELA_WORKSPACE": "/vela/src/foo/foo/foo", "VELA_ID_TOKEN_REQUEST_URL": "foo/api/v1/repos/foo/builds/1/id_token"}, + w: workspace, + b: &api.Build{ID: &num64, Repo: &api.Repo{ID: &num64, Owner: &api.User{ID: &num64, Name: &str, Token: &str, Active: &booL, Admin: &booL}, Org: &str, Name: &str, FullName: &str, Link: &str, Clone: &str, Branch: &str, Topics: &topics, BuildLimit: &num64, Timeout: &num64, Visibility: &str, Private: &booL, Trusted: &booL, Active: &booL}, Number: &num, Parent: &num, Event: &pull, EventAction: &pullact, Status: &str, Error: &str, Enqueued: &num64, Created: &num64, Started: &num64, Finished: &num64, Deploy: &str, Clone: &str, Source: &str, Title: &str, Message: &str, Commit: &str, Sender: &str, SenderSCMID: &str, Author: &str, Branch: &str, Ref: &pullref, BaseRef: &str}, + m: &internal.Metadata{Database: &internal.Database{Driver: str, Host: str}, Queue: &internal.Queue{Channel: str, Driver: str, Host: str}, Source: &internal.Source{Driver: str, Host: str}, Vela: &internal.Vela{Address: str, WebAddress: str, OpenIDIssuer: str}}, + r: &api.Repo{ID: &num64, Owner: &api.User{ID: &num64, Name: &str, Token: &str, Active: &booL, Admin: &booL}, Org: &str, Name: &str, FullName: &str, Link: &str, Clone: &str, Branch: &str, Topics: &topics, BuildLimit: &num64, Timeout: &num64, Visibility: &str, Private: &booL, Trusted: &booL, Active: &booL}, + u: &api.User{ID: &num64, Name: &str, Token: &str, Active: &booL, Admin: &booL}, + netrc: &netrc, + want: map[string]string{"BUILD_AUTHOR": "foo", "BUILD_AUTHOR_EMAIL": "", "BUILD_BASE_REF": "foo", "BUILD_BRANCH": "foo", "BUILD_CHANNEL": "foo", "BUILD_CLONE": "foo", "BUILD_COMMIT": "foo", "BUILD_CREATED": "1", "BUILD_ENQUEUED": "1", "BUILD_EVENT": "pull_request", "BUILD_HOST": "", "BUILD_LINK": "", "BUILD_MESSAGE": "foo", "BUILD_NUMBER": "1", "BUILD_PARENT": "1", "BUILD_PULL_REQUEST_NUMBER": "1", "BUILD_REF": "refs/pull/1/head", "BUILD_SENDER": "foo", "BUILD_SOURCE": "foo", "BUILD_STARTED": "1", "BUILD_STATUS": "foo", "BUILD_TITLE": "foo", "BUILD_WORKSPACE": "/vela/src/foo/foo/foo", "CI": "true", "REPOSITORY_ACTIVE": "false", "REPOSITORY_ALLOW_EVENTS": "", "REPOSITORY_BRANCH": "foo", "REPOSITORY_CLONE": "foo", "REPOSITORY_FULL_NAME": "foo", "REPOSITORY_LINK": "foo", "REPOSITORY_NAME": "foo", "REPOSITORY_ORG": "foo", "REPOSITORY_PRIVATE": "false", "REPOSITORY_TIMEOUT": "1", "REPOSITORY_TRUSTED": "false", "REPOSITORY_VISIBILITY": "foo", "VELA": "true", "VELA_ADDR": "foo", "VELA_SERVER_ADDR": "foo", "VELA_OPEN_ID_ISSUER": "foo", "VELA_BUILD_APPROVED_AT": "0", "VELA_BUILD_APPROVED_BY": "", "VELA_BUILD_AUTHOR": "foo", "VELA_BUILD_AUTHOR_EMAIL": "", "VELA_BUILD_BASE_REF": "foo", "VELA_BUILD_BRANCH": "foo", "VELA_BUILD_CHANNEL": "foo", "VELA_BUILD_CLONE": "foo", "VELA_BUILD_COMMIT": "foo", "VELA_BUILD_CREATED": "1", "VELA_BUILD_DISTRIBUTION": "", "VELA_BUILD_ENQUEUED": "1", "VELA_BUILD_EVENT": "pull_request", "VELA_BUILD_EVENT_ACTION": "opened", "VELA_BUILD_HOST": "", "VELA_BUILD_LINK": "", "VELA_BUILD_MESSAGE": "foo", "VELA_BUILD_NUMBER": "1", "VELA_BUILD_PARENT": "1", "VELA_BUILD_PULL_REQUEST": "1", "VELA_BUILD_REF": "refs/pull/1/head", "VELA_BUILD_RUNTIME": "", "VELA_BUILD_SENDER": "foo", "VELA_BUILD_SENDER_SCM_ID": "foo", "VELA_BUILD_SOURCE": "foo", "VELA_BUILD_STARTED": "1", "VELA_BUILD_STATUS": "foo", "VELA_BUILD_TITLE": "foo", "VELA_BUILD_WORKSPACE": "/vela/src/foo/foo/foo", "VELA_CHANNEL": "foo", "VELA_DATABASE": "foo", "VELA_DISTRIBUTION": "TODO", "VELA_HOST": "foo", "VELA_NETRC_MACHINE": "foo", "VELA_NETRC_PASSWORD": "foo", "VELA_NETRC_USERNAME": "x-oauth-basic", "VELA_PULL_REQUEST": "1", "VELA_PULL_REQUEST_FORK": "false", "VELA_PULL_REQUEST_SOURCE": "", "VELA_PULL_REQUEST_TARGET": "foo", "VELA_QUEUE": "foo", "VELA_REPO_ACTIVE": "false", "VELA_REPO_ALLOW_EVENTS": "", "VELA_REPO_APPROVE_BUILD": "", "VELA_REPO_BRANCH": "foo", "VELA_REPO_TOPICS": "cloud,security", "VELA_REPO_BUILD_LIMIT": "1", "VELA_REPO_CLONE": "foo", "VELA_REPO_FULL_NAME": "foo", "VELA_REPO_LINK": "foo", "VELA_REPO_NAME": "foo", "VELA_REPO_ORG": "foo", "VELA_REPO_OWNER": "foo", "VELA_REPO_PIPELINE_TYPE": "", "VELA_REPO_PRIVATE": "false", "VELA_REPO_TIMEOUT": "1", "VELA_REPO_TRUSTED": "false", "VELA_REPO_VISIBILITY": "foo", "VELA_RUNTIME": "TODO", "VELA_SOURCE": "foo", "VELA_USER_ACTIVE": "false", "VELA_USER_ADMIN": "false", "VELA_USER_FAVORITES": "[]", "VELA_USER_NAME": "foo", "VELA_VERSION": "TODO", "VELA_WORKSPACE": "/vela/src/foo/foo/foo", "VELA_ID_TOKEN_REQUEST_URL": "foo/api/v1/repos/foo/builds/1/id_token"}, }, // deployment { - w: workspace, - b: &api.Build{ID: &num64, Repo: &api.Repo{ID: &num64, Owner: &api.User{ID: &num64, Name: &str, Token: &str, Active: &booL, Admin: &booL}, Org: &str, Name: &str, FullName: &str, Link: &str, Clone: &str, Branch: &str, Topics: &topics, BuildLimit: &num64, Timeout: &num64, Visibility: &str, Private: &booL, Trusted: &booL, Active: &booL}, Number: &num, Parent: &num, Event: &deploy, Status: &str, Error: &str, Enqueued: &num64, Created: &num64, Started: &num64, Finished: &num64, Deploy: &target, Clone: &str, Source: &str, Title: &str, Message: &str, Commit: &str, Sender: &str, SenderSCMID: &str, Author: &str, Branch: &str, Ref: &pullref, BaseRef: &str}, - m: &internal.Metadata{Database: &internal.Database{Driver: str, Host: str}, Queue: &internal.Queue{Channel: str, Driver: str, Host: str}, Source: &internal.Source{Driver: str, Host: str}, Vela: &internal.Vela{Address: str, WebAddress: str, OpenIDIssuer: str}}, - r: &api.Repo{ID: &num64, Owner: &api.User{ID: &num64, Name: &str, Token: &str, Active: &booL, Admin: &booL}, Org: &str, Name: &str, FullName: &str, Link: &str, Clone: &str, Branch: &str, Topics: &topics, BuildLimit: &num64, Timeout: &num64, Visibility: &str, Private: &booL, Trusted: &booL, Active: &booL}, - u: &api.User{ID: &num64, Name: &str, Token: &str, Active: &booL, Admin: &booL}, - want: map[string]string{"BUILD_AUTHOR": "foo", "BUILD_AUTHOR_EMAIL": "", "BUILD_BASE_REF": "foo", "BUILD_BRANCH": "foo", "BUILD_CHANNEL": "foo", "BUILD_CLONE": "foo", "BUILD_COMMIT": "foo", "BUILD_CREATED": "1", "BUILD_ENQUEUED": "1", "BUILD_EVENT": "deployment", "BUILD_HOST": "", "BUILD_LINK": "", "BUILD_MESSAGE": "foo", "BUILD_NUMBER": "1", "BUILD_PARENT": "1", "BUILD_REF": "refs/pull/1/head", "BUILD_SENDER": "foo", "BUILD_SOURCE": "foo", "BUILD_STARTED": "1", "BUILD_STATUS": "foo", "BUILD_TARGET": "production", "BUILD_TITLE": "foo", "BUILD_WORKSPACE": "/vela/src/foo/foo/foo", "CI": "true", "REPOSITORY_ACTIVE": "false", "REPOSITORY_ALLOW_EVENTS": "", "REPOSITORY_BRANCH": "foo", "REPOSITORY_CLONE": "foo", "REPOSITORY_FULL_NAME": "foo", "REPOSITORY_LINK": "foo", "REPOSITORY_NAME": "foo", "REPOSITORY_ORG": "foo", "REPOSITORY_PRIVATE": "false", "REPOSITORY_TIMEOUT": "1", "REPOSITORY_TRUSTED": "false", "REPOSITORY_VISIBILITY": "foo", "VELA": "true", "VELA_ADDR": "foo", "VELA_SERVER_ADDR": "foo", "VELA_OPEN_ID_ISSUER": "foo", "VELA_BUILD_APPROVED_AT": "0", "VELA_BUILD_APPROVED_BY": "", "VELA_BUILD_AUTHOR": "foo", "VELA_BUILD_AUTHOR_EMAIL": "", "VELA_BUILD_BASE_REF": "foo", "VELA_BUILD_BRANCH": "foo", "VELA_BUILD_CHANNEL": "foo", "VELA_BUILD_CLONE": "foo", "VELA_BUILD_COMMIT": "foo", "VELA_BUILD_CREATED": "1", "VELA_BUILD_DISTRIBUTION": "", "VELA_BUILD_ENQUEUED": "1", "VELA_BUILD_EVENT": "deployment", "VELA_BUILD_EVENT_ACTION": "", "VELA_BUILD_HOST": "", "VELA_BUILD_LINK": "", "VELA_BUILD_MESSAGE": "foo", "VELA_BUILD_NUMBER": "1", "VELA_BUILD_PARENT": "1", "VELA_BUILD_REF": "refs/pull/1/head", "VELA_BUILD_RUNTIME": "", "VELA_BUILD_SENDER": "foo", "VELA_BUILD_SENDER_SCM_ID": "foo", "VELA_BUILD_SOURCE": "foo", "VELA_BUILD_STARTED": "1", "VELA_BUILD_STATUS": "foo", "VELA_BUILD_TARGET": "production", "VELA_BUILD_TITLE": "foo", "VELA_BUILD_WORKSPACE": "/vela/src/foo/foo/foo", "VELA_CHANNEL": "foo", "VELA_DATABASE": "foo", "VELA_DEPLOYMENT": "production", "VELA_DEPLOYMENT_NUMBER": "0", "VELA_DISTRIBUTION": "TODO", "VELA_HOST": "foo", "VELA_NETRC_MACHINE": "foo", "VELA_NETRC_PASSWORD": "foo", "VELA_NETRC_USERNAME": "x-oauth-basic", "VELA_QUEUE": "foo", "VELA_REPO_ACTIVE": "false", "VELA_REPO_ALLOW_EVENTS": "", "VELA_REPO_APPROVE_BUILD": "", "VELA_REPO_BRANCH": "foo", "VELA_REPO_TOPICS": "cloud,security", "VELA_REPO_BUILD_LIMIT": "1", "VELA_REPO_CLONE": "foo", "VELA_REPO_FULL_NAME": "foo", "VELA_REPO_LINK": "foo", "VELA_REPO_NAME": "foo", "VELA_REPO_ORG": "foo", "VELA_REPO_OWNER": "foo", "VELA_REPO_PIPELINE_TYPE": "", "VELA_REPO_PRIVATE": "false", "VELA_REPO_TIMEOUT": "1", "VELA_REPO_TRUSTED": "false", "VELA_REPO_VISIBILITY": "foo", "VELA_RUNTIME": "TODO", "VELA_SOURCE": "foo", "VELA_USER_ACTIVE": "false", "VELA_USER_ADMIN": "false", "VELA_USER_FAVORITES": "[]", "VELA_USER_NAME": "foo", "VELA_VERSION": "TODO", "VELA_WORKSPACE": "/vela/src/foo/foo/foo", "VELA_ID_TOKEN_REQUEST_URL": "foo/api/v1/repos/foo/builds/1/id_token"}, + w: workspace, + b: &api.Build{ID: &num64, Repo: &api.Repo{ID: &num64, Owner: &api.User{ID: &num64, Name: &str, Token: &str, Active: &booL, Admin: &booL}, Org: &str, Name: &str, FullName: &str, Link: &str, Clone: &str, Branch: &str, Topics: &topics, BuildLimit: &num64, Timeout: &num64, Visibility: &str, Private: &booL, Trusted: &booL, Active: &booL}, Number: &num, Parent: &num, Event: &deploy, Status: &str, Error: &str, Enqueued: &num64, Created: &num64, Started: &num64, Finished: &num64, Deploy: &target, Clone: &str, Source: &str, Title: &str, Message: &str, Commit: &str, Sender: &str, SenderSCMID: &str, Author: &str, Branch: &str, Ref: &pullref, BaseRef: &str}, + m: &internal.Metadata{Database: &internal.Database{Driver: str, Host: str}, Queue: &internal.Queue{Channel: str, Driver: str, Host: str}, Source: &internal.Source{Driver: str, Host: str}, Vela: &internal.Vela{Address: str, WebAddress: str, OpenIDIssuer: str}}, + r: &api.Repo{ID: &num64, Owner: &api.User{ID: &num64, Name: &str, Token: &str, Active: &booL, Admin: &booL}, Org: &str, Name: &str, FullName: &str, Link: &str, Clone: &str, Branch: &str, Topics: &topics, BuildLimit: &num64, Timeout: &num64, Visibility: &str, Private: &booL, Trusted: &booL, Active: &booL}, + u: &api.User{ID: &num64, Name: &str, Token: &str, Active: &booL, Admin: &booL}, + netrc: &netrc, + want: map[string]string{"BUILD_AUTHOR": "foo", "BUILD_AUTHOR_EMAIL": "", "BUILD_BASE_REF": "foo", "BUILD_BRANCH": "foo", "BUILD_CHANNEL": "foo", "BUILD_CLONE": "foo", "BUILD_COMMIT": "foo", "BUILD_CREATED": "1", "BUILD_ENQUEUED": "1", "BUILD_EVENT": "deployment", "BUILD_HOST": "", "BUILD_LINK": "", "BUILD_MESSAGE": "foo", "BUILD_NUMBER": "1", "BUILD_PARENT": "1", "BUILD_REF": "refs/pull/1/head", "BUILD_SENDER": "foo", "BUILD_SOURCE": "foo", "BUILD_STARTED": "1", "BUILD_STATUS": "foo", "BUILD_TARGET": "production", "BUILD_TITLE": "foo", "BUILD_WORKSPACE": "/vela/src/foo/foo/foo", "CI": "true", "REPOSITORY_ACTIVE": "false", "REPOSITORY_ALLOW_EVENTS": "", "REPOSITORY_BRANCH": "foo", "REPOSITORY_CLONE": "foo", "REPOSITORY_FULL_NAME": "foo", "REPOSITORY_LINK": "foo", "REPOSITORY_NAME": "foo", "REPOSITORY_ORG": "foo", "REPOSITORY_PRIVATE": "false", "REPOSITORY_TIMEOUT": "1", "REPOSITORY_TRUSTED": "false", "REPOSITORY_VISIBILITY": "foo", "VELA": "true", "VELA_ADDR": "foo", "VELA_SERVER_ADDR": "foo", "VELA_OPEN_ID_ISSUER": "foo", "VELA_BUILD_APPROVED_AT": "0", "VELA_BUILD_APPROVED_BY": "", "VELA_BUILD_AUTHOR": "foo", "VELA_BUILD_AUTHOR_EMAIL": "", "VELA_BUILD_BASE_REF": "foo", "VELA_BUILD_BRANCH": "foo", "VELA_BUILD_CHANNEL": "foo", "VELA_BUILD_CLONE": "foo", "VELA_BUILD_COMMIT": "foo", "VELA_BUILD_CREATED": "1", "VELA_BUILD_DISTRIBUTION": "", "VELA_BUILD_ENQUEUED": "1", "VELA_BUILD_EVENT": "deployment", "VELA_BUILD_EVENT_ACTION": "", "VELA_BUILD_HOST": "", "VELA_BUILD_LINK": "", "VELA_BUILD_MESSAGE": "foo", "VELA_BUILD_NUMBER": "1", "VELA_BUILD_PARENT": "1", "VELA_BUILD_REF": "refs/pull/1/head", "VELA_BUILD_RUNTIME": "", "VELA_BUILD_SENDER": "foo", "VELA_BUILD_SENDER_SCM_ID": "foo", "VELA_BUILD_SOURCE": "foo", "VELA_BUILD_STARTED": "1", "VELA_BUILD_STATUS": "foo", "VELA_BUILD_TARGET": "production", "VELA_BUILD_TITLE": "foo", "VELA_BUILD_WORKSPACE": "/vela/src/foo/foo/foo", "VELA_CHANNEL": "foo", "VELA_DATABASE": "foo", "VELA_DEPLOYMENT": "production", "VELA_DEPLOYMENT_NUMBER": "0", "VELA_DISTRIBUTION": "TODO", "VELA_HOST": "foo", "VELA_NETRC_MACHINE": "foo", "VELA_NETRC_PASSWORD": "foo", "VELA_NETRC_USERNAME": "x-oauth-basic", "VELA_QUEUE": "foo", "VELA_REPO_ACTIVE": "false", "VELA_REPO_ALLOW_EVENTS": "", "VELA_REPO_APPROVE_BUILD": "", "VELA_REPO_BRANCH": "foo", "VELA_REPO_TOPICS": "cloud,security", "VELA_REPO_BUILD_LIMIT": "1", "VELA_REPO_CLONE": "foo", "VELA_REPO_FULL_NAME": "foo", "VELA_REPO_LINK": "foo", "VELA_REPO_NAME": "foo", "VELA_REPO_ORG": "foo", "VELA_REPO_OWNER": "foo", "VELA_REPO_PIPELINE_TYPE": "", "VELA_REPO_PRIVATE": "false", "VELA_REPO_TIMEOUT": "1", "VELA_REPO_TRUSTED": "false", "VELA_REPO_VISIBILITY": "foo", "VELA_RUNTIME": "TODO", "VELA_SOURCE": "foo", "VELA_USER_ACTIVE": "false", "VELA_USER_ADMIN": "false", "VELA_USER_FAVORITES": "[]", "VELA_USER_NAME": "foo", "VELA_VERSION": "TODO", "VELA_WORKSPACE": "/vela/src/foo/foo/foo", "VELA_ID_TOKEN_REQUEST_URL": "foo/api/v1/repos/foo/builds/1/id_token"}, + }, + // netrc + { + w: workspace, + b: &api.Build{ID: &num64, Repo: &api.Repo{ID: &num64, Owner: &api.User{ID: &num64, Name: &str, Token: &str, Active: &booL, Admin: &booL}, Org: &str, Name: &str, FullName: &str, Link: &str, Clone: &str, Branch: &str, Topics: &topics, BuildLimit: &num64, Timeout: &num64, Visibility: &str, Private: &booL, Trusted: &booL, Active: &booL}, Number: &num, Parent: &num, Event: &deploy, Status: &str, Error: &str, Enqueued: &num64, Created: &num64, Started: &num64, Finished: &num64, Deploy: &target, Clone: &str, Source: &str, Title: &str, Message: &str, Commit: &str, Sender: &str, SenderSCMID: &str, Author: &str, Branch: &str, Ref: &pullref, BaseRef: &str}, + m: &internal.Metadata{Database: &internal.Database{Driver: str, Host: str}, Queue: &internal.Queue{Channel: str, Driver: str, Host: str}, Source: &internal.Source{Driver: str, Host: str}, Vela: &internal.Vela{Address: str, WebAddress: str, OpenIDIssuer: str}}, + r: &api.Repo{ID: &num64, Owner: &api.User{ID: &num64, Name: &str, Token: &str, Active: &booL, Admin: &booL}, Org: &str, Name: &str, FullName: &str, Link: &str, Clone: &str, Branch: &str, Topics: &topics, BuildLimit: &num64, Timeout: &num64, Visibility: &str, Private: &booL, Trusted: &booL, Active: &booL}, + u: &api.User{ID: &num64, Name: &str, Token: &str, Active: &booL, Admin: &booL}, + netrc: nil, + want: map[string]string{"BUILD_AUTHOR": "foo", "BUILD_AUTHOR_EMAIL": "", "BUILD_BASE_REF": "foo", "BUILD_BRANCH": "foo", "BUILD_CHANNEL": "foo", "BUILD_CLONE": "foo", "BUILD_COMMIT": "foo", "BUILD_CREATED": "1", "BUILD_ENQUEUED": "1", "BUILD_EVENT": "deployment", "BUILD_HOST": "", "BUILD_LINK": "", "BUILD_MESSAGE": "foo", "BUILD_NUMBER": "1", "BUILD_PARENT": "1", "BUILD_REF": "refs/pull/1/head", "BUILD_SENDER": "foo", "BUILD_SOURCE": "foo", "BUILD_STARTED": "1", "BUILD_STATUS": "foo", "BUILD_TARGET": "production", "BUILD_TITLE": "foo", "BUILD_WORKSPACE": "/vela/src/foo/foo/foo", "CI": "true", "REPOSITORY_ACTIVE": "false", "REPOSITORY_ALLOW_EVENTS": "", "REPOSITORY_BRANCH": "foo", "REPOSITORY_CLONE": "foo", "REPOSITORY_FULL_NAME": "foo", "REPOSITORY_LINK": "foo", "REPOSITORY_NAME": "foo", "REPOSITORY_ORG": "foo", "REPOSITORY_PRIVATE": "false", "REPOSITORY_TIMEOUT": "1", "REPOSITORY_TRUSTED": "false", "REPOSITORY_VISIBILITY": "foo", "VELA": "true", "VELA_ADDR": "foo", "VELA_SERVER_ADDR": "foo", "VELA_OPEN_ID_ISSUER": "foo", "VELA_BUILD_APPROVED_AT": "0", "VELA_BUILD_APPROVED_BY": "", "VELA_BUILD_AUTHOR": "foo", "VELA_BUILD_AUTHOR_EMAIL": "", "VELA_BUILD_BASE_REF": "foo", "VELA_BUILD_BRANCH": "foo", "VELA_BUILD_CHANNEL": "foo", "VELA_BUILD_CLONE": "foo", "VELA_BUILD_COMMIT": "foo", "VELA_BUILD_CREATED": "1", "VELA_BUILD_DISTRIBUTION": "", "VELA_BUILD_ENQUEUED": "1", "VELA_BUILD_EVENT": "deployment", "VELA_BUILD_EVENT_ACTION": "", "VELA_BUILD_HOST": "", "VELA_BUILD_LINK": "", "VELA_BUILD_MESSAGE": "foo", "VELA_BUILD_NUMBER": "1", "VELA_BUILD_PARENT": "1", "VELA_BUILD_REF": "refs/pull/1/head", "VELA_BUILD_RUNTIME": "", "VELA_BUILD_SENDER": "foo", "VELA_BUILD_SENDER_SCM_ID": "foo", "VELA_BUILD_SOURCE": "foo", "VELA_BUILD_STARTED": "1", "VELA_BUILD_STATUS": "foo", "VELA_BUILD_TARGET": "production", "VELA_BUILD_TITLE": "foo", "VELA_BUILD_WORKSPACE": "/vela/src/foo/foo/foo", "VELA_CHANNEL": "foo", "VELA_DATABASE": "foo", "VELA_DEPLOYMENT": "production", "VELA_DEPLOYMENT_NUMBER": "0", "VELA_DISTRIBUTION": "TODO", "VELA_HOST": "foo", "VELA_NETRC_MACHINE": "foo", "VELA_NETRC_PASSWORD": "TODO", "VELA_NETRC_USERNAME": "x-oauth-basic", "VELA_QUEUE": "foo", "VELA_REPO_ACTIVE": "false", "VELA_REPO_ALLOW_EVENTS": "", "VELA_REPO_APPROVE_BUILD": "", "VELA_REPO_BRANCH": "foo", "VELA_REPO_TOPICS": "cloud,security", "VELA_REPO_BUILD_LIMIT": "1", "VELA_REPO_CLONE": "foo", "VELA_REPO_FULL_NAME": "foo", "VELA_REPO_LINK": "foo", "VELA_REPO_NAME": "foo", "VELA_REPO_ORG": "foo", "VELA_REPO_OWNER": "foo", "VELA_REPO_PIPELINE_TYPE": "", "VELA_REPO_PRIVATE": "false", "VELA_REPO_TIMEOUT": "1", "VELA_REPO_TRUSTED": "false", "VELA_REPO_VISIBILITY": "foo", "VELA_RUNTIME": "TODO", "VELA_SOURCE": "foo", "VELA_USER_ACTIVE": "false", "VELA_USER_ADMIN": "false", "VELA_USER_FAVORITES": "[]", "VELA_USER_NAME": "foo", "VELA_VERSION": "TODO", "VELA_WORKSPACE": "/vela/src/foo/foo/foo", "VELA_ID_TOKEN_REQUEST_URL": "foo/api/v1/repos/foo/builds/1/id_token"}, }, } // run test for _, test := range tests { - got := environment(test.b, test.m, test.r, test.u) + got := environment(test.b, test.m, test.r, test.u, test.netrc) if diff := cmp.Diff(test.want, got); diff != "" { t.Errorf("environment mismatch (-want +got):\n%s", diff) @@ -694,12 +711,15 @@ func Test_client_EnvironmentBuild(t *testing.T) { // deployment deploy := "deployment" target := "production" + // netrc + netrc := "foo" type fields struct { build *api.Build metadata *internal.Metadata repo *api.Repo user *api.User + netrc *string } tests := []struct { @@ -712,12 +732,14 @@ func Test_client_EnvironmentBuild(t *testing.T) { metadata: &internal.Metadata{Database: &internal.Database{Driver: str, Host: str}, Queue: &internal.Queue{Channel: str, Driver: str, Host: str}, Source: &internal.Source{Driver: str, Host: str}, Vela: &internal.Vela{Address: str, WebAddress: str, OpenIDIssuer: str}}, repo: &api.Repo{ID: &num64, Owner: &api.User{ID: &num64, Name: &str, Token: &str, Active: &booL, Admin: &booL}, Org: &str, Name: &str, FullName: &str, Link: &str, Clone: &str, Branch: &str, Topics: &topics, BuildLimit: &num64, Timeout: &num64, Visibility: &str, Private: &booL, Trusted: &booL, Active: &booL}, user: &api.User{ID: &num64, Name: &str, Token: &str, Active: &booL, Admin: &booL}, + netrc: &netrc, }, map[string]string{"BUILD_AUTHOR": "foo", "BUILD_AUTHOR_EMAIL": "", "BUILD_BASE_REF": "foo", "BUILD_BRANCH": "foo", "BUILD_CHANNEL": "foo", "BUILD_CLONE": "foo", "BUILD_COMMIT": "foo", "BUILD_CREATED": "1", "BUILD_ENQUEUED": "1", "BUILD_EVENT": "push", "BUILD_HOST": "", "BUILD_LINK": "", "BUILD_MESSAGE": "foo", "BUILD_NUMBER": "1", "BUILD_PARENT": "1", "BUILD_REF": "foo", "BUILD_SENDER": "foo", "BUILD_SOURCE": "foo", "BUILD_STARTED": "1", "BUILD_STATUS": "foo", "BUILD_TITLE": "foo", "BUILD_WORKSPACE": "/vela/src/foo/foo/foo", "CI": "true", "REPOSITORY_ACTIVE": "false", "REPOSITORY_ALLOW_EVENTS": "", "REPOSITORY_BRANCH": "foo", "REPOSITORY_CLONE": "foo", "REPOSITORY_FULL_NAME": "foo", "REPOSITORY_LINK": "foo", "REPOSITORY_NAME": "foo", "REPOSITORY_ORG": "foo", "REPOSITORY_PRIVATE": "false", "REPOSITORY_TIMEOUT": "1", "REPOSITORY_TRUSTED": "false", "REPOSITORY_VISIBILITY": "foo", "VELA": "true", "VELA_ADDR": "foo", "VELA_SERVER_ADDR": "foo", "VELA_OPEN_ID_ISSUER": "foo", "VELA_BUILD_APPROVED_AT": "0", "VELA_BUILD_APPROVED_BY": "", "VELA_BUILD_AUTHOR": "foo", "VELA_BUILD_AUTHOR_EMAIL": "", "VELA_BUILD_BASE_REF": "foo", "VELA_BUILD_BRANCH": "foo", "VELA_BUILD_CHANNEL": "foo", "VELA_BUILD_CLONE": "foo", "VELA_BUILD_COMMIT": "foo", "VELA_BUILD_CREATED": "1", "VELA_BUILD_DISTRIBUTION": "", "VELA_BUILD_ENQUEUED": "1", "VELA_BUILD_EVENT": "push", "VELA_BUILD_EVENT_ACTION": "", "VELA_BUILD_HOST": "", "VELA_BUILD_LINK": "", "VELA_BUILD_MESSAGE": "foo", "VELA_BUILD_NUMBER": "1", "VELA_BUILD_PARENT": "1", "VELA_BUILD_REF": "foo", "VELA_BUILD_RUNTIME": "", "VELA_BUILD_SENDER": "foo", "VELA_BUILD_SENDER_SCM_ID": "foo", "VELA_BUILD_SOURCE": "foo", "VELA_BUILD_STARTED": "1", "VELA_BUILD_STATUS": "foo", "VELA_BUILD_TITLE": "foo", "VELA_BUILD_WORKSPACE": "/vela/src/foo/foo/foo", "VELA_CHANNEL": "foo", "VELA_DATABASE": "foo", "VELA_DISTRIBUTION": "TODO", "VELA_HOST": "foo", "VELA_NETRC_MACHINE": "foo", "VELA_NETRC_PASSWORD": "foo", "VELA_NETRC_USERNAME": "x-oauth-basic", "VELA_QUEUE": "foo", "VELA_REPO_ACTIVE": "false", "VELA_REPO_ALLOW_EVENTS": "", "VELA_REPO_APPROVE_BUILD": "", "VELA_REPO_OWNER": "foo", "VELA_REPO_BRANCH": "foo", "VELA_REPO_BUILD_LIMIT": "1", "VELA_REPO_CLONE": "foo", "VELA_REPO_FULL_NAME": "foo", "VELA_REPO_LINK": "foo", "VELA_REPO_NAME": "foo", "VELA_REPO_ORG": "foo", "VELA_REPO_PIPELINE_TYPE": "", "VELA_REPO_PRIVATE": "false", "VELA_REPO_TIMEOUT": "1", "VELA_REPO_TOPICS": "cloud,security", "VELA_REPO_TRUSTED": "false", "VELA_REPO_VISIBILITY": "foo", "VELA_RUNTIME": "TODO", "VELA_SOURCE": "foo", "VELA_USER_ACTIVE": "false", "VELA_USER_ADMIN": "false", "VELA_USER_FAVORITES": "[]", "VELA_USER_NAME": "foo", "VELA_VERSION": "TODO", "VELA_WORKSPACE": "/vela/src/foo/foo/foo", "VELA_ID_TOKEN_REQUEST_URL": "foo/api/v1/repos/foo/builds/1/id_token"}}, {"tag", fields{ build: &api.Build{ID: &num64, Repo: &api.Repo{ID: &num64, Owner: &api.User{ID: &num64, Name: &str, Token: &str, Active: &booL, Admin: &booL}, Org: &str, Name: &str, FullName: &str, Link: &str, Clone: &str, Branch: &str, Topics: &topics, BuildLimit: &num64, Timeout: &num64, Visibility: &str, Private: &booL, Trusted: &booL, Active: &booL}, Number: &num, Parent: &num, Event: &tag, Status: &str, Error: &str, Enqueued: &num64, Created: &num64, Started: &num64, Finished: &num64, Deploy: &str, Clone: &str, Source: &str, Title: &str, Message: &str, Commit: &str, Sender: &str, SenderSCMID: &str, Author: &str, Branch: &str, Ref: &tagref, BaseRef: &str}, metadata: &internal.Metadata{Database: &internal.Database{Driver: str, Host: str}, Queue: &internal.Queue{Channel: str, Driver: str, Host: str}, Source: &internal.Source{Driver: str, Host: str}, Vela: &internal.Vela{Address: str, WebAddress: str, OpenIDIssuer: str}}, repo: &api.Repo{ID: &num64, Owner: &api.User{ID: &num64, Name: &str, Token: &str, Active: &booL, Admin: &booL}, Org: &str, Name: &str, FullName: &str, Link: &str, Clone: &str, Branch: &str, Topics: &topics, BuildLimit: &num64, Timeout: &num64, Visibility: &str, Private: &booL, Trusted: &booL, Active: &booL}, user: &api.User{ID: &num64, Name: &str, Token: &str, Active: &booL, Admin: &booL}, + netrc: &netrc, }, map[string]string{"BUILD_AUTHOR": "foo", "BUILD_AUTHOR_EMAIL": "", "BUILD_BASE_REF": "foo", "BUILD_BRANCH": "foo", "BUILD_CHANNEL": "foo", "BUILD_CLONE": "foo", "BUILD_COMMIT": "foo", "BUILD_CREATED": "1", "BUILD_ENQUEUED": "1", "BUILD_EVENT": "tag", "BUILD_HOST": "", "BUILD_LINK": "", "BUILD_MESSAGE": "foo", "BUILD_NUMBER": "1", "BUILD_PARENT": "1", "BUILD_REF": "refs/tags/1", "BUILD_SENDER": "foo", "BUILD_SOURCE": "foo", "BUILD_STARTED": "1", "BUILD_STATUS": "foo", "BUILD_TAG": "1", "BUILD_TITLE": "foo", "BUILD_WORKSPACE": "/vela/src/foo/foo/foo", "CI": "true", "REPOSITORY_ACTIVE": "false", "REPOSITORY_ALLOW_EVENTS": "", "REPOSITORY_BRANCH": "foo", "REPOSITORY_CLONE": "foo", "REPOSITORY_FULL_NAME": "foo", "REPOSITORY_LINK": "foo", "REPOSITORY_NAME": "foo", "REPOSITORY_ORG": "foo", "REPOSITORY_PRIVATE": "false", "REPOSITORY_TIMEOUT": "1", "REPOSITORY_TRUSTED": "false", "REPOSITORY_VISIBILITY": "foo", "VELA": "true", "VELA_ADDR": "foo", "VELA_SERVER_ADDR": "foo", "VELA_OPEN_ID_ISSUER": "foo", "VELA_BUILD_APPROVED_AT": "0", "VELA_BUILD_APPROVED_BY": "", "VELA_BUILD_AUTHOR": "foo", "VELA_BUILD_AUTHOR_EMAIL": "", "VELA_BUILD_BASE_REF": "foo", "VELA_BUILD_BRANCH": "foo", "VELA_BUILD_CHANNEL": "foo", "VELA_BUILD_CLONE": "foo", "VELA_BUILD_COMMIT": "foo", "VELA_BUILD_CREATED": "1", "VELA_BUILD_DISTRIBUTION": "", "VELA_BUILD_ENQUEUED": "1", "VELA_BUILD_EVENT": "tag", "VELA_BUILD_EVENT_ACTION": "", "VELA_BUILD_HOST": "", "VELA_BUILD_LINK": "", "VELA_BUILD_MESSAGE": "foo", "VELA_BUILD_NUMBER": "1", "VELA_BUILD_PARENT": "1", "VELA_BUILD_REF": "refs/tags/1", "VELA_BUILD_RUNTIME": "", "VELA_BUILD_SENDER": "foo", "VELA_BUILD_SENDER_SCM_ID": "foo", "VELA_BUILD_SOURCE": "foo", "VELA_BUILD_STARTED": "1", "VELA_BUILD_STATUS": "foo", "VELA_BUILD_TAG": "1", "VELA_BUILD_TITLE": "foo", "VELA_BUILD_WORKSPACE": "/vela/src/foo/foo/foo", "VELA_CHANNEL": "foo", "VELA_DATABASE": "foo", "VELA_DISTRIBUTION": "TODO", "VELA_HOST": "foo", "VELA_NETRC_MACHINE": "foo", "VELA_NETRC_PASSWORD": "foo", "VELA_NETRC_USERNAME": "x-oauth-basic", "VELA_QUEUE": "foo", "VELA_REPO_ACTIVE": "false", "VELA_REPO_ALLOW_EVENTS": "", "VELA_REPO_APPROVE_BUILD": "", "VELA_REPO_OWNER": "foo", "VELA_REPO_BRANCH": "foo", "VELA_REPO_BUILD_LIMIT": "1", "VELA_REPO_CLONE": "foo", "VELA_REPO_FULL_NAME": "foo", "VELA_REPO_LINK": "foo", "VELA_REPO_NAME": "foo", "VELA_REPO_ORG": "foo", "VELA_REPO_PIPELINE_TYPE": "", "VELA_REPO_PRIVATE": "false", "VELA_REPO_TIMEOUT": "1", "VELA_REPO_TOPICS": "cloud,security", "VELA_REPO_TRUSTED": "false", "VELA_REPO_VISIBILITY": "foo", "VELA_RUNTIME": "TODO", "VELA_SOURCE": "foo", "VELA_USER_ACTIVE": "false", "VELA_USER_ADMIN": "false", "VELA_USER_FAVORITES": "[]", "VELA_USER_NAME": "foo", "VELA_VERSION": "TODO", "VELA_WORKSPACE": "/vela/src/foo/foo/foo", "VELA_ID_TOKEN_REQUEST_URL": "foo/api/v1/repos/foo/builds/1/id_token"}, }, {"pull_request", fields{ @@ -725,6 +747,7 @@ func Test_client_EnvironmentBuild(t *testing.T) { metadata: &internal.Metadata{Database: &internal.Database{Driver: str, Host: str}, Queue: &internal.Queue{Channel: str, Driver: str, Host: str}, Source: &internal.Source{Driver: str, Host: str}, Vela: &internal.Vela{Address: str, WebAddress: str, OpenIDIssuer: str}}, repo: &api.Repo{ID: &num64, Owner: &api.User{ID: &num64, Name: &str, Token: &str, Active: &booL, Admin: &booL}, Org: &str, Name: &str, FullName: &str, Link: &str, Clone: &str, Branch: &str, Topics: &topics, BuildLimit: &num64, Timeout: &num64, Visibility: &str, Private: &booL, Trusted: &booL, Active: &booL}, user: &api.User{ID: &num64, Name: &str, Token: &str, Active: &booL, Admin: &booL}, + netrc: &netrc, }, map[string]string{"BUILD_AUTHOR": "foo", "BUILD_AUTHOR_EMAIL": "", "BUILD_BASE_REF": "foo", "BUILD_BRANCH": "foo", "BUILD_CHANNEL": "foo", "BUILD_CLONE": "foo", "BUILD_COMMIT": "foo", "BUILD_CREATED": "1", "BUILD_ENQUEUED": "1", "BUILD_EVENT": "pull_request", "BUILD_HOST": "", "BUILD_LINK": "", "BUILD_MESSAGE": "foo", "BUILD_NUMBER": "1", "BUILD_PARENT": "1", "BUILD_PULL_REQUEST_NUMBER": "1", "BUILD_REF": "refs/pull/1/head", "BUILD_SENDER": "foo", "BUILD_SOURCE": "foo", "BUILD_STARTED": "1", "BUILD_STATUS": "foo", "BUILD_TITLE": "foo", "BUILD_WORKSPACE": "/vela/src/foo/foo/foo", "CI": "true", "REPOSITORY_ACTIVE": "false", "REPOSITORY_ALLOW_EVENTS": "", "REPOSITORY_BRANCH": "foo", "REPOSITORY_CLONE": "foo", "REPOSITORY_FULL_NAME": "foo", "REPOSITORY_LINK": "foo", "REPOSITORY_NAME": "foo", "REPOSITORY_ORG": "foo", "REPOSITORY_PRIVATE": "false", "REPOSITORY_TIMEOUT": "1", "REPOSITORY_TRUSTED": "false", "REPOSITORY_VISIBILITY": "foo", "VELA": "true", "VELA_ADDR": "foo", "VELA_SERVER_ADDR": "foo", "VELA_OPEN_ID_ISSUER": "foo", "VELA_BUILD_APPROVED_AT": "0", "VELA_BUILD_APPROVED_BY": "", "VELA_BUILD_AUTHOR": "foo", "VELA_BUILD_AUTHOR_EMAIL": "", "VELA_BUILD_BASE_REF": "foo", "VELA_BUILD_BRANCH": "foo", "VELA_BUILD_CHANNEL": "foo", "VELA_BUILD_CLONE": "foo", "VELA_BUILD_COMMIT": "foo", "VELA_BUILD_CREATED": "1", "VELA_BUILD_DISTRIBUTION": "", "VELA_BUILD_ENQUEUED": "1", "VELA_BUILD_EVENT": "pull_request", "VELA_BUILD_EVENT_ACTION": "opened", "VELA_BUILD_HOST": "", "VELA_BUILD_LINK": "", "VELA_BUILD_MESSAGE": "foo", "VELA_BUILD_NUMBER": "1", "VELA_BUILD_PARENT": "1", "VELA_BUILD_PULL_REQUEST": "1", "VELA_PULL_REQUEST_FORK": "false", "VELA_BUILD_REF": "refs/pull/1/head", "VELA_BUILD_RUNTIME": "", "VELA_BUILD_SENDER": "foo", "VELA_BUILD_SENDER_SCM_ID": "foo", "VELA_BUILD_SOURCE": "foo", "VELA_BUILD_STARTED": "1", "VELA_BUILD_STATUS": "foo", "VELA_BUILD_TITLE": "foo", "VELA_BUILD_WORKSPACE": "/vela/src/foo/foo/foo", "VELA_CHANNEL": "foo", "VELA_DATABASE": "foo", "VELA_DISTRIBUTION": "TODO", "VELA_HOST": "foo", "VELA_NETRC_MACHINE": "foo", "VELA_NETRC_PASSWORD": "foo", "VELA_NETRC_USERNAME": "x-oauth-basic", "VELA_PULL_REQUEST": "1", "VELA_PULL_REQUEST_SOURCE": "", "VELA_PULL_REQUEST_TARGET": "foo", "VELA_QUEUE": "foo", "VELA_REPO_ACTIVE": "false", "VELA_REPO_ALLOW_EVENTS": "", "VELA_REPO_APPROVE_BUILD": "", "VELA_REPO_OWNER": "foo", "VELA_REPO_BRANCH": "foo", "VELA_REPO_BUILD_LIMIT": "1", "VELA_REPO_CLONE": "foo", "VELA_REPO_FULL_NAME": "foo", "VELA_REPO_LINK": "foo", "VELA_REPO_NAME": "foo", "VELA_REPO_ORG": "foo", "VELA_REPO_PIPELINE_TYPE": "", "VELA_REPO_PRIVATE": "false", "VELA_REPO_TIMEOUT": "1", "VELA_REPO_TOPICS": "cloud,security", "VELA_REPO_TRUSTED": "false", "VELA_REPO_VISIBILITY": "foo", "VELA_RUNTIME": "TODO", "VELA_SOURCE": "foo", "VELA_USER_ACTIVE": "false", "VELA_USER_ADMIN": "false", "VELA_USER_FAVORITES": "[]", "VELA_USER_NAME": "foo", "VELA_VERSION": "TODO", "VELA_WORKSPACE": "/vela/src/foo/foo/foo", "VELA_ID_TOKEN_REQUEST_URL": "foo/api/v1/repos/foo/builds/1/id_token"}, }, {"deployment", fields{ @@ -732,6 +755,7 @@ func Test_client_EnvironmentBuild(t *testing.T) { metadata: &internal.Metadata{Database: &internal.Database{Driver: str, Host: str}, Queue: &internal.Queue{Channel: str, Driver: str, Host: str}, Source: &internal.Source{Driver: str, Host: str}, Vela: &internal.Vela{Address: str, WebAddress: str, OpenIDIssuer: str}}, repo: &api.Repo{ID: &num64, Owner: &api.User{ID: &num64, Name: &str, Token: &str, Active: &booL, Admin: &booL}, Org: &str, Name: &str, FullName: &str, Link: &str, Clone: &str, Branch: &str, Topics: &topics, BuildLimit: &num64, Timeout: &num64, Visibility: &str, Private: &booL, Trusted: &booL, Active: &booL}, user: &api.User{ID: &num64, Name: &str, Token: &str, Active: &booL, Admin: &booL}, + netrc: &netrc, }, map[string]string{"BUILD_AUTHOR": "foo", "BUILD_AUTHOR_EMAIL": "", "BUILD_BASE_REF": "foo", "BUILD_BRANCH": "foo", "BUILD_CHANNEL": "foo", "BUILD_CLONE": "foo", "BUILD_COMMIT": "foo", "BUILD_CREATED": "1", "BUILD_ENQUEUED": "1", "BUILD_EVENT": "deployment", "BUILD_HOST": "", "BUILD_LINK": "", "BUILD_MESSAGE": "foo", "BUILD_NUMBER": "1", "BUILD_PARENT": "1", "BUILD_REF": "refs/pull/1/head", "BUILD_SENDER": "foo", "BUILD_SOURCE": "foo", "BUILD_STARTED": "1", "BUILD_STATUS": "foo", "BUILD_TARGET": "production", "BUILD_TITLE": "foo", "BUILD_WORKSPACE": "/vela/src/foo/foo/foo", "CI": "true", "REPOSITORY_ACTIVE": "false", "REPOSITORY_ALLOW_EVENTS": "", "REPOSITORY_BRANCH": "foo", "REPOSITORY_CLONE": "foo", "REPOSITORY_FULL_NAME": "foo", "REPOSITORY_LINK": "foo", "REPOSITORY_NAME": "foo", "REPOSITORY_ORG": "foo", "REPOSITORY_PRIVATE": "false", "REPOSITORY_TIMEOUT": "1", "REPOSITORY_TRUSTED": "false", "REPOSITORY_VISIBILITY": "foo", "VELA": "true", "VELA_ADDR": "foo", "VELA_SERVER_ADDR": "foo", "VELA_OPEN_ID_ISSUER": "foo", "VELA_BUILD_APPROVED_AT": "0", "VELA_BUILD_APPROVED_BY": "", "VELA_BUILD_AUTHOR": "foo", "VELA_BUILD_AUTHOR_EMAIL": "", "VELA_BUILD_BASE_REF": "foo", "VELA_BUILD_BRANCH": "foo", "VELA_BUILD_CHANNEL": "foo", "VELA_BUILD_CLONE": "foo", "VELA_BUILD_COMMIT": "foo", "VELA_BUILD_CREATED": "1", "VELA_BUILD_DISTRIBUTION": "", "VELA_BUILD_ENQUEUED": "1", "VELA_BUILD_EVENT": "deployment", "VELA_BUILD_EVENT_ACTION": "", "VELA_BUILD_HOST": "", "VELA_BUILD_LINK": "", "VELA_BUILD_MESSAGE": "foo", "VELA_BUILD_NUMBER": "1", "VELA_BUILD_PARENT": "1", "VELA_BUILD_REF": "refs/pull/1/head", "VELA_BUILD_RUNTIME": "", "VELA_BUILD_SENDER": "foo", "VELA_BUILD_SENDER_SCM_ID": "foo", "VELA_BUILD_SOURCE": "foo", "VELA_BUILD_STARTED": "1", "VELA_BUILD_STATUS": "foo", "VELA_BUILD_TARGET": "production", "VELA_BUILD_TITLE": "foo", "VELA_BUILD_WORKSPACE": "/vela/src/foo/foo/foo", "VELA_CHANNEL": "foo", "VELA_DATABASE": "foo", "VELA_DEPLOYMENT": "production", "VELA_DEPLOYMENT_NUMBER": "0", "VELA_DISTRIBUTION": "TODO", "VELA_HOST": "foo", "VELA_NETRC_MACHINE": "foo", "VELA_NETRC_PASSWORD": "foo", "VELA_NETRC_USERNAME": "x-oauth-basic", "VELA_QUEUE": "foo", "VELA_REPO_ACTIVE": "false", "VELA_REPO_ALLOW_EVENTS": "", "VELA_REPO_APPROVE_BUILD": "", "VELA_REPO_OWNER": "foo", "VELA_REPO_BRANCH": "foo", "VELA_REPO_BUILD_LIMIT": "1", "VELA_REPO_CLONE": "foo", "VELA_REPO_FULL_NAME": "foo", "VELA_REPO_LINK": "foo", "VELA_REPO_NAME": "foo", "VELA_REPO_ORG": "foo", "VELA_REPO_PIPELINE_TYPE": "", "VELA_REPO_PRIVATE": "false", "VELA_REPO_TIMEOUT": "1", "VELA_REPO_TOPICS": "cloud,security", "VELA_REPO_TRUSTED": "false", "VELA_REPO_VISIBILITY": "foo", "VELA_RUNTIME": "TODO", "VELA_SOURCE": "foo", "VELA_USER_ACTIVE": "false", "VELA_USER_ADMIN": "false", "VELA_USER_FAVORITES": "[]", "VELA_USER_NAME": "foo", "VELA_VERSION": "TODO", "VELA_WORKSPACE": "/vela/src/foo/foo/foo", "VELA_ID_TOKEN_REQUEST_URL": "foo/api/v1/repos/foo/builds/1/id_token"}, }, } @@ -742,6 +766,7 @@ func Test_client_EnvironmentBuild(t *testing.T) { metadata: tt.fields.metadata, repo: tt.fields.repo, user: tt.fields.user, + netrc: tt.fields.netrc, } got := c.EnvironmentBuild() if diff := cmp.Diff(got, tt.want); diff != "" { diff --git a/compiler/native/native.go b/compiler/native/native.go index 1b36e39fb..4a10aae2f 100644 --- a/compiler/native/native.go +++ b/compiler/native/native.go @@ -15,8 +15,10 @@ import ( "github.com/go-vela/server/compiler" "github.com/go-vela/server/compiler/registry" "github.com/go-vela/server/compiler/registry/github" + "github.com/go-vela/server/database" "github.com/go-vela/server/internal" "github.com/go-vela/server/internal/image" + "github.com/go-vela/server/scm" ) type ModificationConfig struct { @@ -27,9 +29,10 @@ type ModificationConfig struct { } type client struct { - Github registry.Service - PrivateGithub registry.Service - UsePrivateGithub bool + Github registry.Service + PrivateGithub registry.Service + UsePrivateGithub bool + ModificationService ModificationConfig TemplateCache map[string][]byte @@ -45,6 +48,9 @@ type client struct { repo *api.Repo user *api.User labels []string + db database.Interface + scm scm.Service + netrc *string } // FromCLIContext returns a Pipeline implementation that integrates with the supported registries. @@ -235,3 +241,24 @@ func (c *client) WithLabels(labels []string) compiler.Engine { return c } + +// WithNetrc sets the netrc in the Engine. +func (c *client) WithNetrc(n string) compiler.Engine { + c.netrc = &n + + return c +} + +// WithSCM sets the scm in the Engine. +func (c *client) WithSCM(_scm scm.Service) compiler.Engine { + c.scm = _scm + + return c +} + +// WithDatabase sets the database in the Engine. +func (c *client) WithDatabase(db database.Interface) compiler.Engine { + c.db = db + + return c +} diff --git a/compiler/native/script_test.go b/compiler/native/script_test.go index e12e6a4b8..410c297c0 100644 --- a/compiler/native/script_test.go +++ b/compiler/native/script_test.go @@ -19,7 +19,7 @@ func TestNative_ScriptStages(t *testing.T) { set.String("clone-image", defaultCloneImage, "doc") c := cli.NewContext(nil, set, nil) - baseEnv := environment(nil, nil, nil, nil) + baseEnv := environment(nil, nil, nil, nil, nil) s := yaml.StageSlice{ &yaml.Stage{ @@ -109,7 +109,7 @@ func TestNative_ScriptSteps(t *testing.T) { set.String("clone-image", defaultCloneImage, "doc") c := cli.NewContext(nil, set, nil) - emptyEnv := environment(nil, nil, nil, nil) + emptyEnv := environment(nil, nil, nil, nil, nil) baseEnv := emptyEnv baseEnv["HOME"] = "/root" diff --git a/compiler/native/transform_test.go b/compiler/native/transform_test.go index 14da2b332..d2906bc8b 100644 --- a/compiler/native/transform_test.go +++ b/compiler/native/transform_test.go @@ -59,7 +59,7 @@ func TestNative_TransformStages(t *testing.T) { Steps: yaml.StepSlice{ &yaml.Step{ Commands: []string{"./gradlew downloadDependencies"}, - Environment: environment(nil, nil, nil, nil), + Environment: environment(nil, nil, nil, nil, nil), Image: "openjdk:latest", Name: "install", Pull: "always", @@ -72,7 +72,7 @@ func TestNative_TransformStages(t *testing.T) { Steps: yaml.StepSlice{ &yaml.Step{ Commands: []string{"./gradlew check"}, - Environment: environment(nil, nil, nil, nil), + Environment: environment(nil, nil, nil, nil, nil), Image: "openjdk:latest", Name: "test", Pull: "always", @@ -138,7 +138,7 @@ func TestNative_TransformStages(t *testing.T) { ID: "__0_install deps_install", Commands: []string{"./gradlew downloadDependencies"}, Directory: "/vela/src", - Environment: environment(nil, nil, nil, nil), + Environment: environment(nil, nil, nil, nil, nil), Image: "openjdk:latest", Name: "install", Number: 1, @@ -194,7 +194,7 @@ func TestNative_TransformStages(t *testing.T) { ID: "localOrg_localRepo_1_install deps_install", Commands: []string{"./gradlew downloadDependencies"}, Directory: "/vela/src", - Environment: environment(nil, nil, nil, nil), + Environment: environment(nil, nil, nil, nil, nil), Image: "openjdk:latest", Name: "install", Number: 1, @@ -297,14 +297,14 @@ func TestNative_TransformSteps(t *testing.T) { Steps: yaml.StepSlice{ &yaml.Step{ Commands: []string{"./gradlew downloadDependencies"}, - Environment: environment(nil, nil, nil, nil), + Environment: environment(nil, nil, nil, nil, nil), Image: "openjdk:latest", Name: "install deps", Pull: "always", }, &yaml.Step{ Commands: []string{"./gradlew check"}, - Environment: environment(nil, nil, nil, nil), + Environment: environment(nil, nil, nil, nil, nil), Image: "openjdk:latest", Name: "test", Pull: "always", @@ -365,7 +365,7 @@ func TestNative_TransformSteps(t *testing.T) { ID: "step___0_install deps", Commands: []string{"./gradlew downloadDependencies"}, Directory: "/vela/src", - Environment: environment(nil, nil, nil, nil), + Environment: environment(nil, nil, nil, nil, nil), Image: "openjdk:latest", Name: "install deps", Number: 1, @@ -416,7 +416,7 @@ func TestNative_TransformSteps(t *testing.T) { ID: "step_localOrg_localRepo_1_install deps", Commands: []string{"./gradlew downloadDependencies"}, Directory: "/vela/src", - Environment: environment(nil, nil, nil, nil), + Environment: environment(nil, nil, nil, nil, nil), Image: "openjdk:latest", Name: "install deps", Number: 1, diff --git a/compiler/registry/github/github.go b/compiler/registry/github/github.go index 09924393c..f5027a5b5 100644 --- a/compiler/registry/github/github.go +++ b/compiler/registry/github/github.go @@ -50,7 +50,7 @@ func New(ctx context.Context, address, token string) (*client, error) { if len(token) > 0 { // create GitHub OAuth client with user's token - gitClient = c.newClientToken(ctx, token) + gitClient = c.newOAuthTokenClient(ctx, token) } // overwrite the github client @@ -59,8 +59,8 @@ func New(ctx context.Context, address, token string) (*client, error) { return c, nil } -// newClientToken is a helper function to return the GitHub oauth2 client. -func (c *client) newClientToken(ctx context.Context, token string) *github.Client { +// newOAuthTokenClient is a helper function to return the GitHub oauth2 client. +func (c *client) newOAuthTokenClient(ctx context.Context, token string) *github.Client { // create the token object for the client ts := oauth2.StaticTokenSource( &oauth2.Token{AccessToken: token}, @@ -68,14 +68,6 @@ func (c *client) newClientToken(ctx context.Context, token string) *github.Clien // create the OAuth client tc := oauth2.NewClient(ctx, ts) - // if c.SkipVerify { - // tc.Transport.(*oauth2.Transport).Base = &http.Transport{ - // Proxy: http.ProxyFromEnvironment, - // TLSClientConfig: &tls.Config{ - // InsecureSkipVerify: true, - // }, - // } - // } // create the GitHub client from the OAuth client github := github.NewClient(tc) diff --git a/compiler/registry/github/template.go b/compiler/registry/github/template.go index 44bc0d1e8..5e1cd927b 100644 --- a/compiler/registry/github/template.go +++ b/compiler/registry/github/template.go @@ -19,7 +19,7 @@ func (c *client) Template(ctx context.Context, u *api.User, s *registry.Source) cli := c.Github if u != nil { // create GitHub OAuth client with user's token - cli = c.newClientToken(ctx, u.GetToken()) + cli = c.newOAuthTokenClient(ctx, u.GetToken()) } // create the options to pass diff --git a/compiler/types/pipeline/git.go b/compiler/types/pipeline/git.go new file mode 100644 index 000000000..a7628abf9 --- /dev/null +++ b/compiler/types/pipeline/git.go @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pipeline + +// Git is the pipeline representation of git configurations for a pipeline. +// +// swagger:model PipelineGit +type Git struct { + Token *Token `json:"token,omitempty" yaml:"token,omitempty"` +} + +// Token is the pipeline representation of git token access configurations for a pipeline. +// +// swagger:model PipelineGitToken +type Token struct { + Repositories []string `json:"repositories,omitempty" yaml:"repositories,omitempty"` + Permissions map[string]string `json:"permissions,omitempty" yaml:"permissions,omitempty"` +} + +// Empty returns true if the provided struct is empty. +func (g *Git) Empty() bool { + // return false if any of the fields are provided + if g.Token != nil { + if g.Token.Repositories != nil { + return false + } + + if g.Token.Permissions != nil { + return false + } + } + + // return true if all fields are empty + return true +} diff --git a/compiler/types/pipeline/git_test.go b/compiler/types/pipeline/git_test.go new file mode 100644 index 000000000..aa7b328e5 --- /dev/null +++ b/compiler/types/pipeline/git_test.go @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pipeline + +import "testing" + +func TestPipeline_Git_Empty(t *testing.T) { + // setup tests + tests := []struct { + git *Git + want bool + }{ + { + git: &Git{&Token{Repositories: []string{}}}, + want: false, + }, + { + git: new(Git), + want: true, + }, + } + + // run tests + for _, test := range tests { + got := test.git.Empty() + + if got != test.want { + t.Errorf("Empty is %v, want %t", got, test.want) + } + } +} diff --git a/compiler/types/yaml/build.go b/compiler/types/yaml/build.go index c0b719c9e..8b9a25a44 100644 --- a/compiler/types/yaml/build.go +++ b/compiler/types/yaml/build.go @@ -19,6 +19,7 @@ type Build struct { Steps StepSlice `yaml:"steps,omitempty" json:"steps,omitempty" jsonschema:"oneof_required=steps,description=Provide sequential execution instructions.\nReference: https://go-vela.github.io/docs/reference/yaml/steps/"` Templates TemplateSlice `yaml:"templates,omitempty" json:"templates,omitempty" jsonschema:"description=Provide the name of templates to expand.\nReference: https://go-vela.github.io/docs/reference/yaml/templates/"` Deployment Deployment `yaml:"deployment,omitempty" json:"deployment,omitempty" jsonschema:"description=Provide deployment configuration.\nReference: https://go-vela.github.io/docs/reference/yaml/deployments/"` + Git Git `yaml:"git,omitempty" json:"git,omitempty" jsonschema:"description=Provide the git access specifications.\nReference: https://go-vela.github.io/docs/reference/yaml/git/"` } // ToPipelineAPI converts the Build type to an API Pipeline type. @@ -75,6 +76,7 @@ func (b *Build) UnmarshalYAML(unmarshal func(interface{}) error) error { Steps StepSlice Templates TemplateSlice Deployment Deployment + Git Git }) // attempt to unmarshal as a build type @@ -89,6 +91,7 @@ func (b *Build) UnmarshalYAML(unmarshal func(interface{}) error) error { } // override the values + b.Git = build.Git b.Version = build.Version b.Metadata = build.Metadata b.Environment = build.Environment diff --git a/compiler/types/yaml/git.go b/compiler/types/yaml/git.go new file mode 100644 index 000000000..7391714e5 --- /dev/null +++ b/compiler/types/yaml/git.go @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: Apache-2.0 + +package yaml + +import "github.com/go-vela/server/compiler/types/pipeline" + +// Git is the yaml representation of git configurations for a pipeline. +type Git struct { + Token `yaml:"token,omitempty" json:"token,omitempty" jsonschema:"description=Provide the git token specifications, primarily used for cloning.\nReference: https://go-vela.github.io/docs/reference/yaml/git/#token"` +} + +// Token is the yaml representation of the git token. +// Only applies when using GitHub App installations. +type Token struct { + Repositories []string `yaml:"repositories,omitempty" json:"repositories,omitempty" jsonschema:"description=Provide a list of repositories to clone.\nReference: https://go-vela.github.io/docs/reference/yaml/git/#repositories"` + Permissions map[string]string `yaml:"permissions,omitempty" json:"permissions,omitempty" jsonschema:"description=Provide a list of repository permissions to apply to the git token.\nReference: https://go-vela.github.io/docs/reference/yaml/git/#permissions"` +} + +// ToPipeline converts the Git type +// to a pipeline Git type. +func (g *Git) ToPipeline() *pipeline.Git { + return &pipeline.Git{ + Token: &pipeline.Token{ + Repositories: g.Repositories, + Permissions: g.Permissions, + }, + } +} diff --git a/compiler/types/yaml/git_test.go b/compiler/types/yaml/git_test.go new file mode 100644 index 000000000..d13d6be48 --- /dev/null +++ b/compiler/types/yaml/git_test.go @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 + +package yaml + +import ( + "reflect" + "testing" + + "github.com/go-vela/server/compiler/types/pipeline" +) + +func TestYaml_Git_ToPipeline(t *testing.T) { + // setup tests + tests := []struct { + git *Git + want *pipeline.Git + }{ + { + git: &Git{ + Token: Token{ + Repositories: []string{"foo", "bar"}, + }, + }, + want: &pipeline.Git{ + Token: &pipeline.Token{ + Repositories: []string{"foo", "bar"}, + }, + }, + }, + { + git: &Git{ + Token: Token{ + Permissions: map[string]string{"foo": "bar"}, + }, + }, + want: &pipeline.Git{ + Token: &pipeline.Token{ + Permissions: map[string]string{"foo": "bar"}, + }, + }, + }, + { + git: &Git{ + Token: Token{}, + }, + want: &pipeline.Git{ + Token: &pipeline.Token{}, + }, + }, + } + + // run tests + for _, test := range tests { + got := test.git.ToPipeline() + + if !reflect.DeepEqual(got, test.want) { + t.Errorf("ToPipeline is %v, want %v", got, test.want) + } + } +} diff --git a/constants/app_install.go b/constants/app_install.go new file mode 100644 index 000000000..4e97cb067 --- /dev/null +++ b/constants/app_install.go @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: Apache-2.0 + +// App Install vars. +package constants + +const ( + // GitHub App install repositories selection when "all" repositories are selected. + AppInstallRepositoriesSelectionAll = "all" + // GitHub App install repositories selection when a subset of repositories are selected. + AppInstallRepositoriesSelectionSelected = "selected" +) + +const ( + // GitHub App install setup_action type 'install'. + AppInstallSetupActionInstall = "install" + // GitHub App install event type 'created'. + AppInstallCreated = "created" + // GitHub App install event type 'deleted'. + AppInstallDeleted = "deleted" +) diff --git a/constants/event.go b/constants/event.go index c2ec26aad..a2ab76e3a 100644 --- a/constants/event.go +++ b/constants/event.go @@ -28,6 +28,12 @@ const ( // EventTag defines the event type for build and repo tag events. EventTag = "tag" + // EventInstallation defines the event type for scm installation events. + EventInstallation = "installation" + + // EventInstallationRepositories defines the event type for scm installation_repositories events. + EventInstallationRepositories = "installation_repositories" + // Alternates for common user inputs that do not match our set constants. // EventPullAlternate defines the alternate event type for build and repo pull_request events. diff --git a/database/integration_test.go b/database/integration_test.go index ce9a3ccfe..9d6258fce 100644 --- a/database/integration_test.go +++ b/database/integration_test.go @@ -2491,6 +2491,7 @@ func newResources() *Resources { repoOne.SetPreviousName("") repoOne.SetApproveBuild(constants.ApproveNever) repoOne.SetAllowEvents(api.NewEventsFromMask(1)) + repoOne.SetInstallID(0) repoTwo := new(api.Repo) repoTwo.SetID(2) @@ -2514,6 +2515,7 @@ func newResources() *Resources { repoTwo.SetPreviousName("") repoTwo.SetApproveBuild(constants.ApproveForkAlways) repoTwo.SetAllowEvents(api.NewEventsFromMask(1)) + repoTwo.SetInstallID(0) buildOne := new(api.Build) buildOne.SetID(1) diff --git a/database/repo/create_test.go b/database/repo/create_test.go index 07b8204ab..939fc186b 100644 --- a/database/repo/create_test.go +++ b/database/repo/create_test.go @@ -34,9 +34,9 @@ func TestRepo_Engine_CreateRepo(t *testing.T) { // ensure the mock expects the query _mock.ExpectQuery(`INSERT INTO "repos" -("user_id","hash","org","name","full_name","link","clone","branch","topics","build_limit","timeout","counter","visibility","private","trusted","active","allow_events","pipeline_type","previous_name","approve_build","id") -VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21) RETURNING "id"`). - WithArgs(1, AnyArgument{}, "foo", "bar", "foo/bar", nil, nil, nil, AnyArgument{}, AnyArgument{}, AnyArgument{}, AnyArgument{}, "public", false, false, false, nil, "yaml", "oldName", nil, 1). +("user_id","hash","org","name","full_name","link","clone","branch","topics","build_limit","timeout","counter","visibility","private","trusted","active","allow_events","pipeline_type","previous_name","approve_build","install_id","id") +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22) RETURNING "id"`). + WithArgs(1, AnyArgument{}, "foo", "bar", "foo/bar", nil, nil, nil, AnyArgument{}, AnyArgument{}, AnyArgument{}, AnyArgument{}, "public", false, false, false, nil, "yaml", "oldName", nil, 0, 1). WillReturnRows(_rows) _sqlite := testSqlite(t) diff --git a/database/repo/table.go b/database/repo/table.go index f49c26933..daba7a367 100644 --- a/database/repo/table.go +++ b/database/repo/table.go @@ -35,6 +35,7 @@ repos ( pipeline_type TEXT, previous_name VARCHAR(100), approve_build VARCHAR(20), + install_id INTEGER, UNIQUE(full_name) ); ` @@ -65,6 +66,7 @@ repos ( pipeline_type TEXT, previous_name TEXT, approve_build TEXT, + install_id INTEGER, UNIQUE(full_name) ); ` diff --git a/database/repo/update_test.go b/database/repo/update_test.go index a447d6973..dab936f9b 100644 --- a/database/repo/update_test.go +++ b/database/repo/update_test.go @@ -35,9 +35,9 @@ func TestRepo_Engine_UpdateRepo(t *testing.T) { // ensure the mock expects the query _mock.ExpectExec(`UPDATE "repos" -SET "user_id"=$1,"hash"=$2,"org"=$3,"name"=$4,"full_name"=$5,"link"=$6,"clone"=$7,"branch"=$8,"topics"=$9,"build_limit"=$10,"timeout"=$11,"counter"=$12,"visibility"=$13,"private"=$14,"trusted"=$15,"active"=$16,"allow_events"=$17,"pipeline_type"=$18,"previous_name"=$19,"approve_build"=$20 -WHERE "id" = $21`). - WithArgs(1, AnyArgument{}, "foo", "bar", "foo/bar", nil, nil, nil, AnyArgument{}, AnyArgument{}, AnyArgument{}, AnyArgument{}, "public", false, false, false, 1, "yaml", "oldName", constants.ApproveForkAlways, 1). +SET "user_id"=$1,"hash"=$2,"org"=$3,"name"=$4,"full_name"=$5,"link"=$6,"clone"=$7,"branch"=$8,"topics"=$9,"build_limit"=$10,"timeout"=$11,"counter"=$12,"visibility"=$13,"private"=$14,"trusted"=$15,"active"=$16,"allow_events"=$17,"pipeline_type"=$18,"previous_name"=$19,"approve_build"=$20,"install_id"=$21 +WHERE "id" = $22`). + WithArgs(1, AnyArgument{}, "foo", "bar", "foo/bar", nil, nil, nil, AnyArgument{}, AnyArgument{}, AnyArgument{}, AnyArgument{}, "public", false, false, false, 1, "yaml", "oldName", constants.ApproveForkAlways, 0, 1). WillReturnResult(sqlmock.NewResult(1, 1)) _sqlite := testSqlite(t) diff --git a/database/testutils/api_resources.go b/database/testutils/api_resources.go index 70cc45d65..738602534 100644 --- a/database/testutils/api_resources.go +++ b/database/testutils/api_resources.go @@ -127,6 +127,7 @@ func APIRepo() *api.Repo { AllowEvents: APIEvents(), Topics: new([]string), ApproveBuild: new(string), + InstallID: new(int64), } } diff --git a/database/types/repo.go b/database/types/repo.go index 96f45caf9..f02949f2e 100644 --- a/database/types/repo.go +++ b/database/types/repo.go @@ -67,6 +67,7 @@ type Repo struct { PipelineType sql.NullString `sql:"pipeline_type"` PreviousName sql.NullString `sql:"previous_name"` ApproveBuild sql.NullString `sql:"approve_build"` + InstallID sql.NullInt64 `sql:"install_id"` Owner User `gorm:"foreignKey:UserID"` } @@ -250,6 +251,7 @@ func (r *Repo) ToAPI() *api.Repo { repo.SetPipelineType(r.PipelineType.String) repo.SetPreviousName(r.PreviousName.String) repo.SetApproveBuild(r.ApproveBuild.String) + repo.SetInstallID(r.InstallID.Int64) return repo } @@ -342,6 +344,7 @@ func RepoFromAPI(r *api.Repo) *Repo { PipelineType: sql.NullString{String: r.GetPipelineType(), Valid: true}, PreviousName: sql.NullString{String: r.GetPreviousName(), Valid: true}, ApproveBuild: sql.NullString{String: r.GetApproveBuild(), Valid: true}, + InstallID: sql.NullInt64{Int64: r.GetInstallID(), Valid: true}, } return repo.Nullify() diff --git a/database/types/repo_test.go b/database/types/repo_test.go index c9a8b6afd..1027e5fc2 100644 --- a/database/types/repo_test.go +++ b/database/types/repo_test.go @@ -193,6 +193,7 @@ func TestTypes_Repo_ToAPI(t *testing.T) { want.SetPipelineType("yaml") want.SetPreviousName("oldName") want.SetApproveBuild(constants.ApproveNever) + want.SetInstallID(0) // run test got := testRepo().ToAPI() @@ -345,6 +346,7 @@ func TestTypes_RepoFromAPI(t *testing.T) { r.SetPipelineType("yaml") r.SetPreviousName("oldName") r.SetApproveBuild(constants.ApproveNever) + r.SetInstallID(0) want := testRepo() want.Owner = User{} @@ -382,6 +384,7 @@ func testRepo() *Repo { PipelineType: sql.NullString{String: "yaml", Valid: true}, PreviousName: sql.NullString{String: "oldName", Valid: true}, ApproveBuild: sql.NullString{String: constants.ApproveNever, Valid: true}, + InstallID: sql.NullInt64{Int64: 0, Valid: true}, Owner: *testUser(), } diff --git a/database/types/schedule_test.go b/database/types/schedule_test.go index 10b4da233..2937c6c0a 100644 --- a/database/types/schedule_test.go +++ b/database/types/schedule_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/adhocore/gronx" + "github.com/google/go-cmp/cmp" api "github.com/go-vela/server/api/types" "github.com/go-vela/server/constants" @@ -116,8 +117,8 @@ func TestTypes_Schedule_ToAPI(t *testing.T) { // run test got := testSchedule().ToAPI() - if !reflect.DeepEqual(got, want) { - t.Errorf("ToAPI is %v, want %v", got, want) + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("ScheduleToAPI() mismatch (-want +got):\n%s", diff) } } diff --git a/internal/webhook.go b/internal/webhook.go index 6020d2ab1..7baab3701 100644 --- a/internal/webhook.go +++ b/internal/webhook.go @@ -21,15 +21,27 @@ type PullRequest struct { Labels []string } +// Installation defines the data pulled from an installation +// while processing a webhook. +// Only applies to GitHub Apps. +type Installation struct { + Action string + ID int64 + Org string + RepositoriesAdded []string + RepositoriesRemoved []string +} + // Webhook defines a struct that is used to return // the required data when processing webhook event // a for a source provider event. type Webhook struct { - Hook *api.Hook - Repo *api.Repo - Build *api.Build - PullRequest PullRequest - Deployment *api.Deployment + Hook *api.Hook + Repo *api.Repo + Build *api.Build + PullRequest PullRequest + Deployment *api.Deployment + Installation *Installation } // ShouldSkip uses the build information diff --git a/mock/server/repo.go b/mock/server/repo.go index 9b156669e..1db1fc733 100644 --- a/mock/server/repo.go +++ b/mock/server/repo.go @@ -59,8 +59,9 @@ const ( } }, "approve_build": "fork-always", - "previous_name": "" -}` + "previous_name": "", + "install_id": 0 + }` // ReposResp represents a JSON return for one to many repos. ReposResp = `[ @@ -78,7 +79,8 @@ const ( "visibility": "public", "private": false, "trusted": true, - "active": true + "active": true, + "install_id": 0 }, { "id": 2, @@ -94,7 +96,8 @@ const ( "visibility": "public", "private": false, "trusted": true, - "active": true + "active": true, + "install_id": 0 } ]` ) diff --git a/router/middleware/build/build_test.go b/router/middleware/build/build_test.go index 8db7c8000..42288f017 100644 --- a/router/middleware/build/build_test.go +++ b/router/middleware/build/build_test.go @@ -53,6 +53,7 @@ func TestBuild_Establish(t *testing.T) { r.SetName("bar") r.SetFullName("foo/bar") r.SetVisibility("public") + r.SetInstallID(0) want := new(api.Build) want.SetID(1) diff --git a/router/middleware/repo/repo_test.go b/router/middleware/repo/repo_test.go index c20067c62..a331b5ae8 100644 --- a/router/middleware/repo/repo_test.go +++ b/router/middleware/repo/repo_test.go @@ -66,6 +66,7 @@ func TestRepo_Establish(t *testing.T) { want.SetPipelineType("yaml") want.SetPreviousName("") want.SetApproveBuild("") + want.SetInstallID(0) got := new(api.Repo) diff --git a/scm/flags.go b/scm/flags.go index 0d95ebcb5..ff83d24f6 100644 --- a/scm/flags.go +++ b/scm/flags.go @@ -67,4 +67,29 @@ var Flags = []cli.Flag{ "is behind a Firewall or NAT, or when using something like ngrok to forward webhooks. " + "(defaults to VELA_ADDR).", }, + &cli.Int64Flag{ + EnvVars: []string{"VELA_SCM_APP_ID", "SCM_APP_ID"}, + FilePath: "/vela/scm/app_id", + Name: "scm.app.id", + Usage: "set ID for the SCM App integration (GitHub App)", + }, + &cli.StringFlag{ + EnvVars: []string{"VELA_SCM_APP_PRIVATE_KEY", "SCM_APP_PRIVATE_KEY"}, + FilePath: "/vela/scm/app_private_key", + Name: "scm.app.private-key", + Usage: "set value of base64 encoded SCM App integration (GitHub App) private key", + }, + &cli.StringFlag{ + EnvVars: []string{"VELA_SCM_APP_PRIVATE_KEY_PATH", "SCM_APP_PRIVATE_KEY_PATH"}, + FilePath: "/vela/scm/app_private_key_path", + Name: "scm.app.private-key.path", + Usage: "set filepath to the SCM App integration (GitHub App) private key", + }, + &cli.StringSliceFlag{ + EnvVars: []string{"VELA_SCM_APP_PERMISSIONS", "SCM_APP_PERMISSIONS", "VELA_SOURCE_APP_PERMISSIONS", "SOURCE_APP_PERMISSIONS"}, + FilePath: "/vela/scm/app/permissions", + Name: "scm.app.permissions", + Usage: "SCM App integration (GitHub App) permissions to be used as the allowed set of possible installation token permissions", + Value: cli.NewStringSlice("contents:read", "checks:write"), + }, } diff --git a/scm/github/access.go b/scm/github/access.go index a1e7f5d4d..1bd4dd2e3 100644 --- a/scm/github/access.go +++ b/scm/github/access.go @@ -31,7 +31,7 @@ func (c *client) OrgAccess(ctx context.Context, u *api.User, org string) (string } // create GitHub OAuth client with user's token - client := c.newClientToken(ctx, *u.Token) + client := c.newOAuthTokenClient(ctx, *u.Token) // send API call to capture org access level for user membership, _, err := client.Organizations.GetOrgMembership(ctx, *u.Name, org) @@ -67,7 +67,7 @@ func (c *client) RepoAccess(ctx context.Context, name, token, org, repo string) } // create github oauth client with the given token - client := c.newClientToken(ctx, token) + client := c.newOAuthTokenClient(ctx, token) // send API call to capture repo access level for user perm, _, err := client.Repositories.GetPermissionLevel(ctx, org, repo, name) @@ -98,7 +98,7 @@ func (c *client) TeamAccess(ctx context.Context, u *api.User, org, team string) } // create GitHub OAuth client with user's token - client := c.newClientToken(ctx, u.GetToken()) + client := c.newOAuthTokenClient(ctx, u.GetToken()) teams := []*github.Team{} // set the max per page for the options to capture the list of repos @@ -148,7 +148,7 @@ func (c *client) ListUsersTeamsForOrg(ctx context.Context, u *api.User, org stri }).Tracef("capturing %s team membership for org %s", u.GetName(), org) // create GitHub OAuth client with user's token - client := c.newClientToken(ctx, u.GetToken()) + client := c.newOAuthTokenClient(ctx, u.GetToken()) teams := []*github.Team{} // set the max per page for the options to capture the list of repos @@ -193,7 +193,7 @@ func (c *client) RepoContributor(ctx context.Context, owner *api.User, sender, o }).Tracef("capturing %s contributor status for repo %s/%s", sender, org, repo) // create GitHub OAuth client with repo owner's token - client := c.newClientToken(ctx, owner.GetToken()) + client := c.newOAuthTokenClient(ctx, owner.GetToken()) // set the max per page for the options to capture the list of repos opts := github.ListContributorsOptions{ diff --git a/scm/github/app_install.go b/scm/github/app_install.go new file mode 100644 index 000000000..16c03924c --- /dev/null +++ b/scm/github/app_install.go @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: Apache-2.0 + +package github + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/sirupsen/logrus" + "gorm.io/gorm" + + "github.com/go-vela/server/api/types" + "github.com/go-vela/server/constants" + "github.com/go-vela/server/database" + "github.com/go-vela/server/internal" +) + +// ProcessInstallation takes a GitHub installation and processes the changes. +func (c *client) ProcessInstallation(ctx context.Context, _ *http.Request, webhook *internal.Webhook, db database.Interface) error { + c.Logger.Tracef("processing GitHub App installation") + + errs := []string{} + + // set install_id for repos added to the installation + for _, repo := range webhook.Installation.RepositoriesAdded { + r, err := db.GetRepoForOrg(ctx, webhook.Installation.Org, repo) + if err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + errs = append(errs, fmt.Sprintf("%s:%s", repo, err.Error())) + } + + // skip repos that dont exist in vela + continue + } + + err = updateRepoInstallationID(ctx, webhook, r, db, webhook.Installation.ID) + if err != nil { + errs = append(errs, fmt.Sprintf("%s:%s", repo, err.Error())) + } + } + + // set install_id for repos removed from the installation + for _, repo := range webhook.Installation.RepositoriesRemoved { + r, err := db.GetRepoForOrg(ctx, webhook.Installation.Org, repo) + if err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + errs = append(errs, fmt.Sprintf("%s:%s", repo, err.Error())) + } + + // skip repos that dont exist in vela + continue + } + + err = updateRepoInstallationID(ctx, webhook, r, db, 0) + if err != nil { + errs = append(errs, fmt.Sprintf("%s:%s", repo, err.Error())) + } + } + + // combine all errors + if len(errs) > 0 { + return errors.New(strings.Join(errs, ", ")) + } + + return nil +} + +// updateRepoInstallationID updates the installation ID for a repo. +func updateRepoInstallationID(ctx context.Context, webhook *internal.Webhook, r *types.Repo, db database.Interface, installID int64) error { + r.SetInstallID(installID) + + h := new(types.Hook) + h.SetNumber(webhook.Hook.GetNumber()) + h.SetSourceID(webhook.Hook.GetSourceID()) + h.SetWebhookID(webhook.Hook.GetWebhookID()) + h.SetCreated(webhook.Hook.GetCreated()) + h.SetHost(webhook.Hook.GetHost()) + h.SetEvent(constants.EventInstallation) + h.SetStatus(webhook.Hook.GetStatus()) + + r, err := db.UpdateRepo(ctx, r) + if err != nil { + h.SetStatus(constants.StatusFailure) + h.SetError(err.Error()) + } + + h.Repo = r + + // number of times to retry + retryLimit := 3 + // implement a loop to process asynchronous operations with a retry limit + // + // Some operations taken during the webhook workflow can lead to race conditions + // failing to successfully process the request. This logic ensures we attempt our + // best efforts to handle these cases gracefully. + for i := 0; i < retryLimit; i++ { + // check if we're on the first iteration of the loop + if i > 0 { + // incrementally sleep in between retries + time.Sleep(time.Duration(i) * time.Second) + } + + // send API call to capture the last hook for the repo + lastHook, err := db.LastHookForRepo(ctx, r) + if err != nil { + // log the error for traceability + logrus.Error(err.Error()) + + // check if the retry limit has been exceeded + if i < retryLimit { + // continue to the next iteration of the loop + continue + } + + return err + } + + // set the Number field + if lastHook != nil { + h.SetNumber( + lastHook.GetNumber() + 1, + ) + } + + // send hook update to db + _, err = db.CreateHook(ctx, h) + if err != nil { + return err + } + + break + } + + return nil +} + +// FinishInstallation completes the web flow for a GitHub App installation, returning a redirect to the app installation page. +func (c *client) FinishInstallation(ctx context.Context, _ *http.Request, installID int64) (string, error) { + c.Logger.Tracef("finishing GitHub App installation for ID %d", installID) + + client, err := c.newGithubAppClient() + if err != nil { + return "", err + } + + install, _, err := client.Apps.GetInstallation(ctx, installID) + if err != nil { + return "", err + } + + return install.GetHTMLURL(), nil +} diff --git a/scm/github/app_permissions.go b/scm/github/app_permissions.go new file mode 100644 index 000000000..411ee3d4a --- /dev/null +++ b/scm/github/app_permissions.go @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: Apache-2.0 + +package github + +import ( + "fmt" + "strings" + + "github.com/google/go-github/v65/github" +) + +// see: https://docs.github.com/en/rest/authentication/permissions-required-for-github-apps?apiVersion=2022-11-28 +const ( + // GitHub App install permission 'none'. + AppInstallPermissionNone = "none" + // GitHub App install permission 'read'. + AppInstallPermissionRead = "read" + // GitHub App install permission 'write'. + AppInstallPermissionWrite = "write" +) + +const ( + // GitHub App install contents resource. + AppInstallResourceContents = "contents" + // GitHub App install checks resource. + AppInstallResourceChecks = "checks" + // GitHub App install packages resource. + AppInstallResourcePackages = "packages" + // add more supported resources as needed. +) + +// GetInstallationPermission takes permissions and returns the permission level if valid. +func GetInstallationPermission(resource string, appPermissions *github.InstallationPermissions) (string, error) { + switch resource { + case AppInstallResourceContents: + return appPermissions.GetContents(), nil + case AppInstallResourceChecks: + return appPermissions.GetChecks(), nil + case AppInstallResourcePackages: + return appPermissions.GetPackages(), nil + // add more supported resources as needed. + default: + return "", fmt.Errorf("given permission resource not supported: %s", resource) + } +} + +// ApplyInstallationPermissions takes permissions and applies a new permission if valid. +func ApplyInstallationPermissions(resource, perm string, perms *github.InstallationPermissions) (*github.InstallationPermissions, error) { + // convert permissions from string + switch strings.ToLower(perm) { + case AppInstallPermissionNone: + case AppInstallPermissionRead: + case AppInstallPermissionWrite: + break + default: + return perms, fmt.Errorf("invalid permission level given for : in %s:%s", resource, perm) + } + + // convert resource from string + switch strings.ToLower(resource) { + case AppInstallResourceContents: + perms.Contents = github.String(perm) + case AppInstallResourceChecks: + perms.Checks = github.String(perm) + case AppInstallResourcePackages: + perms.Packages = github.String(perm) + // add more supported resources as needed. + default: + return perms, fmt.Errorf("invalid permission resource given for : in %s:%s", resource, perm) + } + + return perms, nil +} + +// InstallationHasPermission takes a resource:perm pair and checks if the actual permission matches the expected permission or is supersceded by a higher permission. +func InstallationHasPermission(resource, requiredPerm, actualPerm string) error { + if len(actualPerm) == 0 { + return fmt.Errorf("github app missing permission %s:%s", resource, requiredPerm) + } + + permitted := false + + switch requiredPerm { + case AppInstallPermissionNone: + permitted = true + case AppInstallPermissionRead: + if actualPerm == AppInstallPermissionRead || + actualPerm == AppInstallPermissionWrite { + permitted = true + } + case AppInstallPermissionWrite: + if actualPerm == AppInstallPermissionWrite { + permitted = true + } + default: + return fmt.Errorf("invalid required permission type: %s", requiredPerm) + } + + if !permitted { + return fmt.Errorf("github app requires permission %s:%s, found: %s", AppInstallResourceContents, AppInstallPermissionRead, actualPerm) + } + + return nil +} diff --git a/scm/github/app_permissions_test.go b/scm/github/app_permissions_test.go new file mode 100644 index 000000000..74b94a071 --- /dev/null +++ b/scm/github/app_permissions_test.go @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: Apache-2.0 + +package github + +import ( + "testing" + + "github.com/google/go-github/v65/github" +) + +func TestGetInstallationPermission(t *testing.T) { + tests := []struct { + name string + resource string + permissions *github.InstallationPermissions + expectedPerm string + expectedError bool + }{ + { + name: "valid contents permission", + resource: AppInstallResourceContents, + permissions: &github.InstallationPermissions{Contents: github.String(AppInstallPermissionRead)}, + expectedPerm: AppInstallPermissionRead, + }, + { + name: "valid checks permission", + resource: AppInstallResourceChecks, + permissions: &github.InstallationPermissions{Checks: github.String(AppInstallPermissionWrite)}, + expectedPerm: AppInstallPermissionWrite, + }, + { + name: "valid packages permission", + resource: AppInstallResourcePackages, + permissions: &github.InstallationPermissions{Packages: github.String(AppInstallPermissionNone)}, + expectedPerm: AppInstallPermissionNone, + }, + { + name: "invalid resource", + resource: "invalid_resource", + permissions: &github.InstallationPermissions{}, + expectedError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + perm, err := GetInstallationPermission(tt.resource, tt.permissions) + if (err != nil) != tt.expectedError { + t.Errorf("GetInstallationPermission() error = %v, expectedError %v", err, tt.expectedError) + return + } + if perm != tt.expectedPerm { + t.Errorf("GetInstallationPermission() = %v, expected %v", perm, tt.expectedPerm) + } + }) + } +} + +func TestApplyInstallationPermissions(t *testing.T) { + tests := []struct { + name string + resource string + perm string + initialPerms *github.InstallationPermissions + expectedPerms *github.InstallationPermissions + expectedError bool + }{ + { + name: "apply read permission to contents", + resource: AppInstallResourceContents, + perm: AppInstallPermissionRead, + initialPerms: &github.InstallationPermissions{}, + expectedPerms: &github.InstallationPermissions{ + Contents: github.String(AppInstallPermissionRead), + }, + }, + { + name: "apply write permission to checks", + resource: AppInstallResourceChecks, + perm: AppInstallPermissionWrite, + initialPerms: &github.InstallationPermissions{}, + expectedPerms: &github.InstallationPermissions{ + Checks: github.String(AppInstallPermissionWrite), + }, + }, + { + name: "apply none permission to packages", + resource: AppInstallResourcePackages, + perm: AppInstallPermissionNone, + initialPerms: &github.InstallationPermissions{}, + expectedPerms: &github.InstallationPermissions{ + Packages: github.String(AppInstallPermissionNone), + }, + }, + { + name: "invalid permission level", + resource: AppInstallResourceContents, + perm: "invalid_perm", + initialPerms: &github.InstallationPermissions{}, + expectedError: true, + }, + { + name: "invalid resource", + resource: "invalid_resource", + perm: AppInstallPermissionRead, + initialPerms: &github.InstallationPermissions{}, + expectedError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + perms, err := ApplyInstallationPermissions(tt.resource, tt.perm, tt.initialPerms) + if (err != nil) != tt.expectedError { + t.Errorf("ApplyInstallationPermissions() error = %v, expectedError %v", err, tt.expectedError) + return + } + if !tt.expectedError && !comparePermissions(perms, tt.expectedPerms) { + t.Errorf("ApplyInstallationPermissions() = %v, expected %v", perms, tt.expectedPerms) + } + }) + } +} + +func TestInstallationHasPermission(t *testing.T) { + tests := []struct { + name string + resource string + requiredPerm string + actualPerm string + expectedError bool + }{ + { + name: "valid read permission", + resource: AppInstallResourceContents, + requiredPerm: AppInstallPermissionRead, + actualPerm: AppInstallPermissionRead, + }, + { + name: "valid write permission", + resource: AppInstallResourceChecks, + requiredPerm: AppInstallPermissionWrite, + actualPerm: AppInstallPermissionWrite, + }, + { + name: "valid none permission", + resource: AppInstallResourcePackages, + requiredPerm: AppInstallPermissionNone, + actualPerm: AppInstallPermissionNone, + }, + { + name: "read permission superseded by write", + resource: AppInstallResourceContents, + requiredPerm: AppInstallPermissionRead, + actualPerm: AppInstallPermissionWrite, + }, + { + name: "missing permission", + resource: AppInstallResourceChecks, + requiredPerm: AppInstallPermissionWrite, + actualPerm: "", + expectedError: true, + }, + { + name: "invalid required permission", + resource: AppInstallResourcePackages, + requiredPerm: "invalid_perm", + actualPerm: AppInstallPermissionRead, + expectedError: true, + }, + { + name: "insufficient permission", + resource: AppInstallResourceContents, + requiredPerm: AppInstallPermissionWrite, + actualPerm: AppInstallPermissionRead, + expectedError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := InstallationHasPermission(tt.resource, tt.requiredPerm, tt.actualPerm) + if (err != nil) != tt.expectedError { + t.Errorf("InstallationHasPermission() error = %v, expectedError %v", err, tt.expectedError) + } + }) + } +} + +func comparePermissions(a, b *github.InstallationPermissions) bool { + if a == nil || b == nil { + return a == b + } + return github.Stringify(a) == github.Stringify(b) +} diff --git a/scm/github/app_transport.go b/scm/github/app_transport.go new file mode 100644 index 000000000..b0db1f5f8 --- /dev/null +++ b/scm/github/app_transport.go @@ -0,0 +1,331 @@ +// SPDX-License-Identifier: Apache-2.0 + +package github + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/rsa" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptrace" + "strconv" + "strings" + "sync" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/go-github/v65/github" + "go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" +) + +const ( + acceptHeader = "application/vnd.github.v3+json" +) + +// AppsTransport provides a http.RoundTripper by wrapping an existing +// http.RoundTripper and provides GitHub Apps authentication as a GitHub App. +// +// Client can also be overwritten, and is useful to change to one which +// provides retry logic if you do experience retryable errors. +// +// See https://developer.github.com/apps/building-integrations/setting-up-and-registering-github-apps/about-authentication-options-for-github-apps/ +type AppsTransport struct { + BaseURL string // BaseURL is the scheme and host for GitHub API, defaults to https://api.github.com + Client Client // Client to use to refresh tokens, defaults to http.Client with provided transport + tr http.RoundTripper // tr is the underlying roundtripper being wrapped + signer Signer // signer signs JWT tokens. + appID int64 // appID is the GitHub App's ID +} + +// newGitHubAppTransport creates a new GitHub App transport for authenticating as the GitHub App. +func (c *client) newGitHubAppTransport(appID int64, baseURL string, privateKey *rsa.PrivateKey) *AppsTransport { + transport := c.newAppsTransportFromPrivateKey(http.DefaultTransport, appID, privateKey) + transport.BaseURL = baseURL + + // apply tracing to the transport + if c.Tracing.Config.EnableTracing { + transport.tr = otelhttp.NewTransport( + transport.tr, + otelhttp.WithClientTrace(func(ctx context.Context) *httptrace.ClientTrace { + return otelhttptrace.NewClientTrace(ctx, otelhttptrace.WithoutSubSpans()) + }), + ) + } + + return transport +} + +// newAppsTransportFromPrivateKey returns an AppsTransport using a crypto/rsa.(*PrivateKey). +func (c *client) newAppsTransportFromPrivateKey(tr http.RoundTripper, appID int64, key *rsa.PrivateKey) *AppsTransport { + return &AppsTransport{ + BaseURL: defaultAPI, + Client: &http.Client{Transport: tr}, + tr: tr, + signer: NewRSASigner(jwt.SigningMethodRS256, key), + appID: appID, + } +} + +// RoundTrip implements http.RoundTripper interface. +func (t *AppsTransport) RoundTrip(req *http.Request) (*http.Response, error) { + // GitHub rejects expiry and issue timestamps that are not an integer, + // while the jwt-go library serializes to fractional timestamps + // then truncate them before passing to jwt-go. + iss := time.Now().Add(-30 * time.Second).Truncate(time.Second) + exp := iss.Add(2 * time.Minute) + claims := &jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(iss), + ExpiresAt: jwt.NewNumericDate(exp), + Issuer: strconv.FormatInt(t.appID, 10), + } + + ss, err := t.signer.Sign(claims) + if err != nil { + return nil, fmt.Errorf("could not sign jwt: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+ss) + req.Header.Add("Accept", acceptHeader) + + return t.tr.RoundTrip(req) +} + +// Transport provides a http.RoundTripper by wrapping an existing +// http.RoundTripper and provides GitHub Apps authentication as an installation. +// +// Client can also be overwritten, and is useful to change to one which +// provides retry logic if you do experience retryable errors. +// +// See https://developer.github.com/apps/building-integrations/setting-up-and-registering-github-apps/about-authentication-options-for-github-apps/ +type Transport struct { + BaseURL string // BaseURL is the scheme and host for GitHub API, defaults to https://api.github.com + Client Client // Client to use to refresh tokens, defaults to http.Client with provided transport + tr http.RoundTripper // tr is the underlying roundtripper being wrapped + installationID int64 // installationID is the GitHub App Installation ID + InstallationTokenOptions *github.InstallationTokenOptions // parameters restrict a token's access + appsTransport *AppsTransport + + mu *sync.Mutex + token *accessToken // the installation's access token +} + +// accessToken is an installation access token response from GitHub. +type accessToken struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` + Permissions github.InstallationPermissions `json:"permissions,omitempty"` + Repositories []github.Repository `json:"repositories,omitempty"` +} + +var _ http.RoundTripper = &Transport{} + +// Client is a HTTP client which sends a http.Request and returns a http.Response +// or an error. +type Client interface { + Do(*http.Request) (*http.Response, error) +} + +// RoundTrip implements http.RoundTripper interface. +func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { + reqBodyClosed := false + + if req.Body != nil { + defer func() { + if !reqBodyClosed { + req.Body.Close() + } + }() + } + + token, err := t.Token(req.Context()) + if err != nil { + return nil, err + } + + creq := cloneRequest(req) + creq.Header.Set("Authorization", "token "+token) + + if creq.Header.Get("Accept") == "" { + creq.Header.Add("Accept", acceptHeader) + } + + reqBodyClosed = true + + return t.tr.RoundTrip(creq) +} + +// getRefreshTime returns the time when the token should be refreshed. +func (at *accessToken) getRefreshTime() time.Time { + return at.ExpiresAt.Add(-time.Minute) +} + +// isExpired checks if the access token is expired. +func (at *accessToken) isExpired() bool { + return at == nil || at.getRefreshTime().Before(time.Now()) +} + +// Token checks the active token expiration and renews if necessary. Token returns +// a valid access token. If renewal fails an error is returned. +func (t *Transport) Token(ctx context.Context) (string, error) { + t.mu.Lock() + + defer t.mu.Unlock() + + if t.token.isExpired() { + // token is not set or expired/nearly expired, so refresh + if err := t.refreshToken(ctx); err != nil { + return "", fmt.Errorf("could not refresh installation id %v's token: %w", t.installationID, err) + } + } + + return t.token.Token, nil +} + +// Expiry returns a transport token's expiration time and refresh time. There is a small grace period +// built in where a token will be refreshed before it expires. expiresAt is the actual token expiry, +// and refreshAt is when a call to Token() will cause it to be refreshed. +func (t *Transport) Expiry() (expiresAt time.Time, refreshAt time.Time, err error) { + if t.token == nil { + return time.Time{}, time.Time{}, errors.New("Expiry() = unknown, err: nil token") + } + + return t.token.ExpiresAt, t.token.getRefreshTime(), nil +} + +func (t *Transport) refreshToken(ctx context.Context) error { + // convert InstallationTokenOptions into a ReadWriter to pass as an argument to http.NewRequest + body, err := GetReadWriter(t.InstallationTokenOptions) + if err != nil { + return fmt.Errorf("could not convert installation token parameters into json: %w", err) + } + + requestURL := fmt.Sprintf("%s/app/installations/%v/access_tokens", strings.TrimRight(t.BaseURL, "/"), t.installationID) + + req, err := http.NewRequest("POST", requestURL, body) + if err != nil { + return fmt.Errorf("could not create request: %w", err) + } + + // set Content and Accept headers + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + req.Header.Set("Accept", acceptHeader) + + if ctx != nil { + req = req.WithContext(ctx) + } + + t.appsTransport.BaseURL = t.BaseURL + t.appsTransport.Client = t.Client + + resp, err := t.appsTransport.RoundTrip(req) + if err != nil { + return fmt.Errorf("could not get access_tokens from GitHub API for installation ID %v: %w", t.installationID, err) + } + + if resp.StatusCode/100 != 2 { + return fmt.Errorf("received non 2xx response status %q when fetching %v", resp.Status, req.URL) + } + + // closing body late, to provide caller a chance to inspect body in an error / non-200 response status situation + defer resp.Body.Close() + + return json.NewDecoder(resp.Body).Decode(&t.token) +} + +// GetReadWriter converts a body interface into an io.ReadWriter object. +func GetReadWriter(i interface{}) (io.ReadWriter, error) { + var buf io.ReadWriter + + if i != nil { + buf = new(bytes.Buffer) + + enc := json.NewEncoder(buf) + + err := enc.Encode(i) + if err != nil { + return nil, err + } + } + + return buf, nil +} + +// cloneRequest returns a clone of the provided *http.Request. +// The clone is a shallow copy of the struct and its Header map. +func cloneRequest(r *http.Request) *http.Request { + // shallow copy of the struct + _r := new(http.Request) + + *_r = *r + + // deep copy of the Header + _r.Header = make(http.Header, len(r.Header)) + + for k, s := range r.Header { + _r.Header[k] = append([]string(nil), s...) + } + + return _r +} + +// Signer is a JWT token signer. This is a wrapper around [jwt.SigningMethod] with predetermined +// key material. +type Signer interface { + // sign the given claims and returns a JWT token string, as specified + // by [jwt.Token.SignedString] + Sign(claims jwt.Claims) (string, error) +} + +// RSASigner signs JWT tokens using RSA keys. +type RSASigner struct { + method *jwt.SigningMethodRSA + key *rsa.PrivateKey +} + +// NewRSASigner creates a new RSASigner with the given RSA key. +func NewRSASigner(method *jwt.SigningMethodRSA, key *rsa.PrivateKey) *RSASigner { + return &RSASigner{ + method: method, + key: key, + } +} + +// Sign signs the JWT claims with the RSA key. +func (s *RSASigner) Sign(claims jwt.Claims) (string, error) { + return jwt.NewWithClaims(s.method, claims).SignedString(s.key) +} + +// AppsTransportOption is a func option for configuring an AppsTransport. +type AppsTransportOption func(*AppsTransport) + +// WithSigner configures the AppsTransport to use the given Signer for generating JWT tokens. +func WithSigner(signer Signer) AppsTransportOption { + return func(at *AppsTransport) { + at.signer = signer + } +} + +// NewTestAppsTransport creates a new AppsTransport for testing purposes. +func NewTestAppsTransport(baseURL string) *AppsTransport { + pk, _ := rsa.GenerateKey(rand.Reader, 2048) + + return &AppsTransport{ + BaseURL: baseURL, + Client: &http.Client{Transport: http.DefaultTransport}, + tr: http.DefaultTransport, + signer: &RSASigner{ + method: jwt.SigningMethodRS256, + key: pk, + }, + appID: 1, + } +} diff --git a/scm/github/app_transport_test.go b/scm/github/app_transport_test.go new file mode 100644 index 000000000..3bf27443c --- /dev/null +++ b/scm/github/app_transport_test.go @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: Apache-2.0 + +package github + +import ( + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/go-cmp/cmp" +) + +func TestGitHub_cloneRequest(t *testing.T) { + tests := []struct { + name string + request *http.Request + }{ + { + name: "basic request", + request: &http.Request{ + Method: "GET", + URL: &url.URL{ + Scheme: "https", + Path: "/", + }, + Header: http.Header{ + "Accept": []string{"application/json"}, + }, + }, + }, + { + name: "request with body", + request: &http.Request{ + Method: "POST", + URL: &url.URL{ + Scheme: "https", + Path: "/", + }, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + Body: io.NopCloser(strings.NewReader(`{"key":"value"}`)), + }, + }, + { + name: "request with multiple headers", + request: &http.Request{ + Method: "GET", + URL: &url.URL{ + Scheme: "https", + Path: "/", + }, + Header: http.Header{ + "Accept": []string{"application/json"}, + "Authorization": []string{"Bearer token"}, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clonedReq := cloneRequest(tt.request) + + if clonedReq == tt.request { + t.Errorf("cloneRequest() = %v, want different instance", clonedReq) + } + + if diff := cmp.Diff(clonedReq.Header, tt.request.Header); diff != "" { + t.Errorf("cloneRequest() headers mismatch (-want +got):\n%s", diff) + } + + if clonedReq.Method != tt.request.Method { + t.Errorf("cloneRequest() method = %v, want %v", clonedReq.Method, tt.request.Method) + } + + if clonedReq.URL.String() != tt.request.URL.String() { + t.Errorf("cloneRequest() URL = %v, want %v", clonedReq.URL, tt.request.URL) + } + }) + } +} + +func TestAppsTransport_RoundTrip(t *testing.T) { + gin.SetMode(gin.TestMode) + + resp := httptest.NewRecorder() + _, engine := gin.CreateTestContext(resp) + + // setup mock server + engine.GET("/", func(c *gin.Context) { + c.Header("Content-Type", "application/json") + c.Status(http.StatusOK) + }) + + s := httptest.NewServer(engine) + defer s.Close() + + _url, _ := url.Parse(s.URL) + + tests := []struct { + name string + transport *AppsTransport + request *http.Request + wantHeader string + wantErr bool + }{ + { + name: "valid GET request", + transport: NewTestAppsTransport(s.URL), + request: &http.Request{ + Method: "GET", + URL: _url, + Header: http.Header{ + "Accept": []string{"application/json"}, + }, + }, + wantHeader: "Bearer ", + wantErr: false, + }, + { + name: "valid POST request", + transport: NewTestAppsTransport(s.URL), + request: &http.Request{ + Method: "POST", + URL: _url, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + Body: io.NopCloser(strings.NewReader(`{"key":"value"}`)), + }, + wantHeader: "Bearer ", + wantErr: false, + }, + { + name: "request with invalid URL", + transport: NewTestAppsTransport(s.URL), + request: &http.Request{ + Method: "GET", + URL: &url.URL{Path: "://invalid-url"}, + Header: http.Header{}, + }, + wantHeader: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp, err := tt.transport.RoundTrip(tt.request) + if (err != nil) != tt.wantErr { + t.Errorf("RoundTrip() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + if got := tt.request.Header.Get("Authorization"); !strings.HasPrefix(got, tt.wantHeader) { + t.Errorf("RoundTrip() Authorization header = %v, want prefix %v", got, tt.wantHeader) + } + } + if resp != nil { + resp.Body.Close() + } + }) + } +} diff --git a/scm/github/authentication.go b/scm/github/authentication.go index 30376991f..bd1f4d62c 100644 --- a/scm/github/authentication.go +++ b/scm/github/authentication.go @@ -21,7 +21,7 @@ func (c *client) Authorize(ctx context.Context, token string) (string, error) { c.Logger.Trace("authorizing user with token") // create GitHub OAuth client with user's token - client := c.newClientToken(ctx, token) + client := c.newOAuthTokenClient(ctx, token) // send API call to capture the current user making the call u, _, err := client.Users.Get(ctx, "") diff --git a/scm/github/changeset.go b/scm/github/changeset.go index 2aa07a445..7a9732fc4 100644 --- a/scm/github/changeset.go +++ b/scm/github/changeset.go @@ -21,7 +21,7 @@ func (c *client) Changeset(ctx context.Context, r *api.Repo, sha string) ([]stri }).Tracef("capturing commit changeset for %s/commit/%s", r.GetFullName(), sha) // create GitHub OAuth client with user's token - client := c.newClientToken(ctx, r.GetOwner().GetToken()) + client := c.newOAuthTokenClient(ctx, r.GetOwner().GetToken()) s := []string{} // set the max per page for the options to capture the commit @@ -50,7 +50,7 @@ func (c *client) ChangesetPR(ctx context.Context, r *api.Repo, number int) ([]st }).Tracef("capturing pull request changeset for %s/pull/%d", r.GetFullName(), number) // create GitHub OAuth client with user's token - client := c.newClientToken(ctx, r.GetOwner().GetToken()) + client := c.newOAuthTokenClient(ctx, r.GetOwner().GetToken()) s := []string{} f := []*github.CommitFile{} diff --git a/scm/github/deployment.go b/scm/github/deployment.go index e7f8ed0b4..f1c32db88 100644 --- a/scm/github/deployment.go +++ b/scm/github/deployment.go @@ -22,7 +22,7 @@ func (c *client) GetDeployment(ctx context.Context, u *api.User, r *api.Repo, id }).Tracef("capturing deployment %d for repo %s", id, r.GetFullName()) // create GitHub OAuth client with user's token - client := c.newClientToken(ctx, *u.Token) + client := c.newOAuthTokenClient(ctx, *u.Token) // send API call to capture the deployment deployment, _, err := client.Repositories.GetDeployment(ctx, r.GetOrg(), r.GetName(), id) @@ -63,7 +63,7 @@ func (c *client) GetDeploymentCount(ctx context.Context, u *api.User, r *api.Rep }).Tracef("counting deployments for repo %s", r.GetFullName()) // create GitHub OAuth client with user's token - client := c.newClientToken(ctx, *u.Token) + client := c.newOAuthTokenClient(ctx, *u.Token) // create variable to track the deployments deployments := []*github.Deployment{} @@ -105,7 +105,7 @@ func (c *client) GetDeploymentList(ctx context.Context, u *api.User, r *api.Repo }).Tracef("listing deployments for repo %s", r.GetFullName()) // create GitHub OAuth client with user's token - client := c.newClientToken(ctx, *u.Token) + client := c.newOAuthTokenClient(ctx, *u.Token) // set pagination options for listing deployments opts := &github.DeploymentsListOptions{ @@ -164,7 +164,7 @@ func (c *client) CreateDeployment(ctx context.Context, u *api.User, r *api.Repo, }).Tracef("creating deployment for repo %s", r.GetFullName()) // create GitHub OAuth client with user's token - client := c.newClientToken(ctx, *u.Token) + client := c.newOAuthTokenClient(ctx, *u.Token) var payload interface{} if d.Payload == nil { diff --git a/scm/github/driver_test.go b/scm/github/driver_test.go index 7ea6656d8..9315cb626 100644 --- a/scm/github/driver_test.go +++ b/scm/github/driver_test.go @@ -3,6 +3,7 @@ package github import ( + "context" "reflect" "testing" @@ -14,6 +15,7 @@ func TestGitHub_Driver(t *testing.T) { want := constants.DriverGithub _service, err := New( + context.Background(), WithAddress("https://github.com/"), WithClientID("foo"), WithClientSecret("bar"), diff --git a/scm/github/github.go b/scm/github/github.go index dfbffe53a..b8a3082df 100644 --- a/scm/github/github.go +++ b/scm/github/github.go @@ -4,14 +4,16 @@ package github import ( "context" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" "fmt" - "net/http/httptrace" - "net/url" + "os" + "strings" "github.com/google/go-github/v65/github" "github.com/sirupsen/logrus" - "go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace" - "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "golang.org/x/oauth2" "github.com/go-vela/server/tracing" @@ -39,6 +41,14 @@ type config struct { ClientID string // specifies the OAuth client secret from GitHub to use for the GitHub client ClientSecret string + // specifies the ID for the Vela GitHub App + AppID int64 + // specifies the App private key to use for the GitHub client when interacting with App resources + AppPrivateKey string + // specifies the App private key to use for the GitHub client when interacting with App resources + AppPrivateKeyPath string + // specifics the App permissions set + AppPermissions []string // specifies the Vela server address to use for the GitHub client ServerAddress string // specifies the Vela server address that the scm provider should use to send Vela webhooks @@ -48,14 +58,15 @@ type config struct { // specifies the Vela web UI address to use for the GitHub client WebUIAddress string // specifies the OAuth scopes to use for the GitHub client - Scopes []string + OAuthScopes []string } type client struct { - config *config - OAuth *oauth2.Config - AuthReq *github.AuthorizationRequest - Tracing *tracing.Client + config *config + OAuth *oauth2.Config + AuthReq *github.AuthorizationRequest + Tracing *tracing.Client + AppsTransport *AppsTransport // https://pkg.go.dev/github.com/sirupsen/logrus#Entry Logger *logrus.Entry } @@ -64,7 +75,7 @@ type client struct { // a GitHub or a GitHub Enterprise instance. // //nolint:revive // ignore returning unexported client -func New(opts ...ClientOpt) (*client, error) { +func New(ctx context.Context, opts ...ClientOpt) (*client, error) { // create new GitHub client c := new(client) @@ -95,28 +106,133 @@ func New(opts ...ClientOpt) (*client, error) { c.OAuth = &oauth2.Config{ ClientID: c.config.ClientID, ClientSecret: c.config.ClientSecret, - Scopes: c.config.Scopes, + Scopes: c.config.OAuthScopes, Endpoint: oauth2.Endpoint{ AuthURL: fmt.Sprintf("%s/login/oauth/authorize", c.config.Address), TokenURL: fmt.Sprintf("%s/login/oauth/access_token", c.config.Address), }, } - var githubScopes []github.Scope - for _, scope := range c.config.Scopes { - githubScopes = append(githubScopes, github.Scope(scope)) + var oauthScopes []github.Scope + for _, scope := range c.config.OAuthScopes { + oauthScopes = append(oauthScopes, github.Scope(scope)) } // create the GitHub authorization object c.AuthReq = &github.AuthorizationRequest{ ClientID: &c.config.ClientID, ClientSecret: &c.config.ClientSecret, - Scopes: githubScopes, + Scopes: oauthScopes, + } + + var err error + + if c.config.AppID != 0 { + c.Logger.Infof("configurating github app integration for app_id %d", c.config.AppID) + + var privateKeyPEM []byte + + if len(c.config.AppPrivateKey) == 0 && len(c.config.AppPrivateKeyPath) == 0 { + return nil, errors.New("GitHub App ID provided but no valid private key was provided in either VELA_SCM_APP_PRIVATE_KEY or VELA_SCM_APP_PRIVATE_KEY_PATH") + } + + if len(c.config.AppPrivateKey) > 0 { + privateKeyPEM, err = base64.StdEncoding.DecodeString(c.config.AppPrivateKey) + if err != nil { + return nil, fmt.Errorf("error decoding base64: %w", err) + } + } else { + // try reading from path if necessary + c.Logger.Infof("no VELA_SCM_APP_PRIVATE_KEY provided, reading github app private key from path %s", c.config.AppPrivateKeyPath) + + privateKeyPEM, err = os.ReadFile(c.config.AppPrivateKeyPath) + if err != nil { + return nil, err + } + } + + if len(privateKeyPEM) == 0 { + return nil, errors.New("GitHub App ID provided but no valid private key was provided in either VELA_SCM_APP_PRIVATE_KEY or VELA_SCM_APP_PRIVATE_KEY_PATH") + } + + block, _ := pem.Decode(privateKeyPEM) + if block == nil { + return nil, fmt.Errorf("failed to parse GitHub App private key PEM block containing the key") + } + + parsedPrivateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse GitHub App RSA private key: %w", err) + } + + c.AppsTransport = c.newGitHubAppTransport(c.config.AppID, c.config.API, parsedPrivateKey) + + err = c.ValidateGitHubApp(ctx) + if err != nil { + return nil, err + } } return c, nil } +// ValidateGitHubApp ensures the GitHub App configuration is valid. +func (c *client) ValidateGitHubApp(ctx context.Context) error { + client, err := c.newGithubAppClient() + if err != nil { + return fmt.Errorf("error creating github app client: %w", err) + } + + app, _, err := client.Apps.Get(ctx, "") + if err != nil { + return fmt.Errorf("error getting github app: %w", err) + } + + appPermissions := app.GetPermissions() + + type perm struct { + resource string + requiredPermission string + actualPermission string + } + + // the GitHub App installation requires the same permissions as provided at runtime + requiredPermissions := []perm{} + + // retrieve the required permissions for checking + for _, permission := range c.config.AppPermissions { + splitPerm := strings.Split(permission, ":") + if len(splitPerm) != 2 { + return fmt.Errorf("invalid app permission format %s, expected resource:permission", permission) + } + + resource := splitPerm[0] + requiredPermission := splitPerm[1] + + actual, err := GetInstallationPermission(resource, appPermissions) + if err != nil { + return err + } + + perm := perm{ + resource: resource, + requiredPermission: requiredPermission, + actualPermission: actual, + } + requiredPermissions = append(requiredPermissions, perm) + } + + // verify the app permissions + for _, p := range requiredPermissions { + err := InstallationHasPermission(p.resource, p.requiredPermission, p.actualPermission) + if err != nil { + return err + } + } + + return nil +} + // NewTest returns a SCM implementation that integrates with the provided // mock server. Only the url from the mock server is required. // @@ -133,6 +249,7 @@ func NewTest(urls ...string) (*client, error) { } return New( + context.Background(), WithAddress(address), WithClientID("foo"), WithClientSecret("bar"), @@ -143,39 +260,3 @@ func NewTest(urls ...string) (*client, error) { WithTracing(&tracing.Client{Config: tracing.Config{EnableTracing: false}}), ) } - -// helper function to return the GitHub OAuth client. -func (c *client) newClientToken(ctx context.Context, token string) *github.Client { - // create the token object for the client - ts := oauth2.StaticTokenSource( - &oauth2.Token{AccessToken: token}, - ) - - // create the OAuth client - tc := oauth2.NewClient(ctx, ts) - // if c.SkipVerify { - // tc.Transport.(*oauth2.Transport).Base = &http.Transport{ - // Proxy: http.ProxyFromEnvironment, - // TLSClientConfig: &tls.Config{ - // InsecureSkipVerify: true, - // }, - // } - // } - - if c.Tracing.Config.EnableTracing { - tc.Transport = otelhttp.NewTransport( - tc.Transport, - otelhttp.WithClientTrace(func(ctx context.Context) *httptrace.ClientTrace { - return otelhttptrace.NewClientTrace(ctx, otelhttptrace.WithoutSubSpans()) - }), - ) - } - - // create the GitHub client from the OAuth client - github := github.NewClient(tc) - - // ensure the proper URL is set in the GitHub client - github.BaseURL, _ = url.Parse(c.config.API) - - return github -} diff --git a/scm/github/github_client.go b/scm/github/github_client.go new file mode 100644 index 000000000..ac79436c8 --- /dev/null +++ b/scm/github/github_client.go @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: Apache-2.0 + +package github + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptrace" + "net/url" + "strings" + + "github.com/google/go-github/v65/github" + "go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "golang.org/x/oauth2" + + api "github.com/go-vela/server/api/types" + "github.com/go-vela/server/constants" +) + +// newOAuthTokenClient returns the GitHub OAuth client. +func (c *client) newOAuthTokenClient(ctx context.Context, token string) *github.Client { + // create the token object for the client + ts := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: token}, + ) + + // create the OAuth client + tc := oauth2.NewClient(ctx, ts) + + if c.Tracing.Config.EnableTracing { + tc.Transport = otelhttp.NewTransport( + tc.Transport, + otelhttp.WithClientTrace(func(ctx context.Context) *httptrace.ClientTrace { + return otelhttptrace.NewClientTrace(ctx, otelhttptrace.WithoutSubSpans()) + }), + ) + } + + // create the GitHub client from the OAuth client + github := github.NewClient(tc) + + // ensure the proper URL is set in the GitHub client + github.BaseURL, _ = url.Parse(c.config.API) + + return github +} + +// newGithubAppClient returns the GitHub App client for authenticating as the GitHub App itself using the RoundTripper. +func (c *client) newGithubAppClient() (*github.Client, error) { + if c.AppsTransport == nil { + return nil, errors.New("unable to create github app client: no AppsTransport configured") + } + + // create a github client based off the existing GitHub App configuration + client, err := github.NewClient( + &http.Client{ + Transport: c.AppsTransport, + }). + WithEnterpriseURLs(c.config.API, c.config.API) + if err != nil { + return nil, err + } + + return client, nil +} + +// newGithubAppInstallationRepoToken returns the GitHub App installation token for a particular repo with granular permissions. +func (c *client) newGithubAppInstallationRepoToken(ctx context.Context, r *api.Repo, repos []string, permissions *github.InstallationPermissions) (*github.InstallationToken, int64, error) { + // create a github client based off the existing GitHub App configuration + client, err := c.newGithubAppClient() + if err != nil { + return nil, 0, err + } + + opts := &github.InstallationTokenOptions{ + Repositories: repos, + Permissions: permissions, + } + + id := r.GetInstallID() + + // if the source scm repo has an install ID but the Vela db record does not + // then use the source repo to create an installation token + if id == 0 { + // list all installations (a.k.a. orgs) where the GitHub App is installed + installations, _, err := client.Apps.ListInstallations(ctx, &github.ListOptions{}) + if err != nil { + return nil, 0, err + } + + // iterate through the list of installations + for _, install := range installations { + // find the installation that matches the org for the repo + if strings.EqualFold(install.GetAccount().GetLogin(), r.GetOrg()) { + if install.GetRepositorySelection() == constants.AppInstallRepositoriesSelectionSelected { + installationCanReadRepo, err := c.installationCanReadRepo(ctx, r, install) + if err != nil { + return nil, 0, fmt.Errorf("installation for org %s exists but unable to check if it can read repo %s: %w", install.GetAccount().GetLogin(), r.GetFullName(), err) + } + + if !installationCanReadRepo { + return nil, 0, fmt.Errorf("installation for org %s exists but does not have access to repo %s", install.GetAccount().GetLogin(), r.GetFullName()) + } + } + + id = install.GetID() + } + } + } + + // failsafe in case the repo does not belong to an org where the GitHub App is installed + if id == 0 { + return nil, 0, errors.New("unable to find installation ID for repo") + } + + // create installation token for the repo + t, _, err := client.Apps.CreateInstallationToken(ctx, id, opts) + if err != nil { + return nil, 0, err + } + + return t, id, nil +} + +// installationCanReadRepo checks if the installation can read the repo. +func (c *client) installationCanReadRepo(ctx context.Context, r *api.Repo, installation *github.Installation) (bool, error) { + installationCanReadRepo := false + + if installation.GetRepositorySelection() == constants.AppInstallRepositoriesSelectionSelected { + client, err := c.newGithubAppClient() + if err != nil { + return false, err + } + + t, _, err := client.Apps.CreateInstallationToken(ctx, installation.GetID(), &github.InstallationTokenOptions{}) + if err != nil { + return false, err + } + + client = c.newOAuthTokenClient(ctx, t.GetToken()) + + repos, _, err := client.Apps.ListRepos(ctx, &github.ListOptions{}) + if err != nil { + return false, err + } + + for _, repo := range repos.Repositories { + if strings.EqualFold(repo.GetFullName(), r.GetFullName()) { + installationCanReadRepo = true + break + } + } + } + + return installationCanReadRepo, nil +} diff --git a/scm/github/github_client_test.go b/scm/github/github_client_test.go new file mode 100644 index 000000000..57de6f7d7 --- /dev/null +++ b/scm/github/github_client_test.go @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: Apache-2.0 + +package github + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/go-github/v65/github" + + api "github.com/go-vela/server/api/types" + "github.com/go-vela/server/constants" +) + +func TestClient_installationCanReadRepo(t *testing.T) { + // setup types + accessibleRepo := new(api.Repo) + accessibleRepo.SetOrg("octocat") + accessibleRepo.SetName("Hello-World") + accessibleRepo.SetFullName("octocat/Hello-World") + accessibleRepo.SetInstallID(0) + + inaccessibleRepo := new(api.Repo) + inaccessibleRepo.SetOrg("octocat") + inaccessibleRepo.SetName("Hello-World") + inaccessibleRepo.SetFullName("octocat/Hello-World2") + inaccessibleRepo.SetInstallID(4) + + gin.SetMode(gin.TestMode) + + resp := httptest.NewRecorder() + _, engine := gin.CreateTestContext(resp) + + // setup mock server + engine.POST("/api/v3/app/installations/:id/access_tokens", func(c *gin.Context) { + c.Header("Content-Type", "application/json") + c.Status(http.StatusOK) + c.File("testdata/installations_access_tokens.json") + }) + engine.GET("/api/v3/installation/repositories", func(c *gin.Context) { + c.Header("Content-Type", "application/json") + c.Status(http.StatusOK) + c.File("testdata/installation_repositories.json") + }) + + s := httptest.NewServer(engine) + defer s.Close() + + oauthClient, _ := NewTest(s.URL) + + appsClient, err := NewTest(s.URL) + if err != nil { + t.Errorf("unable to create GitHub App client: %v", err) + } + + appsClient.AppsTransport = NewTestAppsTransport("") + + // setup tests + tests := []struct { + name string + client *client + repo *api.Repo + installation *github.Installation + appsTransport bool + want bool + wantErr bool + }{ + { + name: "installation can read repo", + client: appsClient, + repo: accessibleRepo, + installation: &github.Installation{ + ID: github.Int64(1), + Account: &github.User{ + Login: github.String("github"), + }, + RepositorySelection: github.String(constants.AppInstallRepositoriesSelectionSelected), + }, + want: true, + wantErr: false, + }, + { + name: "installation cannot read repo", + client: appsClient, + repo: inaccessibleRepo, + installation: &github.Installation{ + ID: github.Int64(2), + Account: &github.User{ + Login: github.String("github"), + }, + RepositorySelection: github.String(constants.AppInstallRepositoriesSelectionSelected), + }, + want: false, + wantErr: false, + }, + { + name: "no GitHub App client", + client: oauthClient, + repo: accessibleRepo, + installation: &github.Installation{ + ID: github.Int64(1), + Account: &github.User{ + Login: github.String("github"), + }, + RepositorySelection: github.String(constants.AppInstallRepositoriesSelectionSelected), + }, + want: false, + wantErr: true, + }, + } + + // run tests + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.client.installationCanReadRepo(context.Background(), tt.repo, tt.installation) + if (err != nil) != tt.wantErr { + t.Errorf("installationCanReadRepo() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("installationCanReadRepo() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/scm/github/github_test.go b/scm/github/github_test.go index 963aa8076..3ce90df3a 100644 --- a/scm/github/github_test.go +++ b/scm/github/github_test.go @@ -32,14 +32,14 @@ func TestGithub_New(t *testing.T) { // run tests for _, test := range tests { - _, err := New( + _, err := New(context.Background(), WithAddress("https://github.com/"), WithClientID(test.id), WithClientSecret("bar"), WithServerAddress("https://vela-server.example.com"), WithStatusContext("continuous-integration/vela"), WithWebUIAddress("https://vela.example.com"), - WithScopes([]string{"repo", "repo:status", "user:email", "read:user", "read:org"}), + WithOAuthScopes([]string{"repo", "repo:status", "user:email", "read:user", "read:org"}), ) if test.failure { @@ -72,7 +72,7 @@ func TestGithub_newClientToken(t *testing.T) { client, _ := NewTest(s.URL) // run test - got := client.newClientToken(context.Background(), "foobar") + got := client.newOAuthTokenClient(context.Background(), "foobar") //nolint:staticcheck // ignore false positive if got == nil { diff --git a/scm/github/opts.go b/scm/github/opts.go index bb7385827..ad64cae0b 100644 --- a/scm/github/opts.go +++ b/scm/github/opts.go @@ -135,8 +135,8 @@ func WithWebUIAddress(address string) ClientOpt { } } -// WithScopes sets the OAuth scopes in the scm client for GitHub. -func WithScopes(scopes []string) ClientOpt { +// WithOAuthScopes sets the OAuth scopes in the scm client for GitHub. +func WithOAuthScopes(scopes []string) ClientOpt { return func(c *client) error { c.Logger.Trace("configuring oauth scopes in github scm client") @@ -146,7 +146,7 @@ func WithScopes(scopes []string) ClientOpt { } // set the scopes in the github client - c.config.Scopes = scopes + c.config.OAuthScopes = scopes return nil } @@ -160,3 +160,50 @@ func WithTracing(tracing *tracing.Client) ClientOpt { return nil } } + +// WithGithubAppID sets the ID for the GitHub App in the scm client. +func WithGithubAppID(id int64) ClientOpt { + return func(c *client) error { + c.Logger.Trace("configuring ID for GitHub App in github scm client") + + // set the ID for the GitHub App in the github client + c.config.AppID = id + + return nil + } +} + +// WithGithubPrivateKey sets the private key for the GitHub App in the scm client. +func WithGithubPrivateKey(key string) ClientOpt { + return func(c *client) error { + c.Logger.Trace("configuring private key for GitHub App in github scm client") + + // set the private key for the GitHub App in the github client + c.config.AppPrivateKey = key + + return nil + } +} + +// WithGithubPrivateKeyPath sets the private key path for the GitHub App in the scm client. +func WithGithubPrivateKeyPath(path string) ClientOpt { + return func(c *client) error { + c.Logger.Trace("configuring private key path for GitHub App in github scm client") + + // set the private key for the GitHub App in the github client + c.config.AppPrivateKeyPath = path + + return nil + } +} + +// WithGitHubAppPermissions sets the App permissions in the scm client for GitHub. +func WithGitHubAppPermissions(permissions []string) ClientOpt { + return func(c *client) error { + c.Logger.Trace("configuring app permissions in github scm client") + + c.config.AppPermissions = permissions + + return nil + } +} diff --git a/scm/github/opts_test.go b/scm/github/opts_test.go index 8a6a2617e..a2aac20bc 100644 --- a/scm/github/opts_test.go +++ b/scm/github/opts_test.go @@ -3,9 +3,12 @@ package github import ( + "context" "reflect" "testing" + "github.com/google/go-cmp/cmp" + "github.com/go-vela/server/tracing" ) @@ -33,7 +36,7 @@ func TestGithub_ClientOpt_WithAddress(t *testing.T) { // run tests for _, test := range tests { - _service, err := New( + _service, err := New(context.Background(), WithAddress(test.address), ) @@ -72,7 +75,7 @@ func TestGithub_ClientOpt_WithClientID(t *testing.T) { // run tests for _, test := range tests { - _service, err := New( + _service, err := New(context.Background(), WithClientID(test.id), ) @@ -115,7 +118,7 @@ func TestGithub_ClientOpt_WithClientSecret(t *testing.T) { // run tests for _, test := range tests { - _service, err := New( + _service, err := New(context.Background(), WithClientSecret(test.secret), ) @@ -158,7 +161,7 @@ func TestGithub_ClientOpt_WithServerAddress(t *testing.T) { // run tests for _, test := range tests { - _service, err := New( + _service, err := New(context.Background(), WithServerAddress(test.address), ) @@ -210,7 +213,7 @@ func TestGithub_ClientOpt_WithServerWebhookAddress(t *testing.T) { // run tests for _, test := range tests { - _service, err := New( + _service, err := New(context.Background(), WithServerAddress(test.address), WithServerWebhookAddress(test.webhookAddress), ) @@ -254,7 +257,7 @@ func TestGithub_ClientOpt_WithStatusContext(t *testing.T) { // run tests for _, test := range tests { - _service, err := New( + _service, err := New(context.Background(), WithStatusContext(test.context), ) @@ -294,7 +297,7 @@ func TestGithub_ClientOpt_WithWebUIAddress(t *testing.T) { // run tests for _, test := range tests { - _service, err := New( + _service, err := New(context.Background(), WithWebUIAddress(test.address), ) @@ -308,7 +311,7 @@ func TestGithub_ClientOpt_WithWebUIAddress(t *testing.T) { } } -func TestGithub_ClientOpt_WithScopes(t *testing.T) { +func TestGithub_ClientOpt_WithOAuthScopes(t *testing.T) { // setup tests tests := []struct { failure bool @@ -329,24 +332,24 @@ func TestGithub_ClientOpt_WithScopes(t *testing.T) { // run tests for _, test := range tests { - _service, err := New( - WithScopes(test.scopes), + _service, err := New(context.Background(), + WithOAuthScopes(test.scopes), ) if test.failure { if err == nil { - t.Errorf("WithScopes should have returned err") + t.Errorf("WithOAuthScopes should have returned err") } continue } if err != nil { - t.Errorf("WithScopes returned err: %v", err) + t.Errorf("WithOAuthScopes returned err: %v", err) } - if !reflect.DeepEqual(_service.config.Scopes, test.want) { - t.Errorf("WithScopes is %v, want %v", _service.config.Scopes, test.want) + if !reflect.DeepEqual(_service.config.OAuthScopes, test.want) { + t.Errorf("WithOAuthScopes is %v, want %v", _service.config.OAuthScopes, test.want) } } } @@ -367,7 +370,7 @@ func TestGithub_ClientOpt_WithTracing(t *testing.T) { // run tests for _, test := range tests { - _service, err := New( + _service, err := New(context.Background(), WithTracing(test.tracing), ) @@ -388,3 +391,46 @@ func TestGithub_ClientOpt_WithTracing(t *testing.T) { } } } + +func TestGithub_ClientOpt_WithGitHubAppPermissions(t *testing.T) { + // setup tests + tests := []struct { + failure bool + permissions []string + want []string + }{ + { + failure: false, + permissions: []string{"contents:read"}, + want: []string{"contents:read"}, + }, + { + failure: false, + permissions: []string{}, + want: []string{}, + }, + } + + // run tests + for _, test := range tests { + _service, err := New(context.Background(), + WithGitHubAppPermissions(test.permissions), + ) + + if test.failure { + if err == nil { + t.Errorf("WithGitHubAppPermissions should have returned err") + } + + continue + } + + if err != nil { + t.Errorf("WithGitHubAppPermissions returned err: %v", err) + } + + if diff := cmp.Diff(test.want, _service.config.AppPermissions); diff != "" { + t.Errorf("WithGitHubAppPermissions mismatch (-want +got):\n%s", diff) + } + } +} diff --git a/scm/github/org.go b/scm/github/org.go index 8c9e95986..ec1f8e314 100644 --- a/scm/github/org.go +++ b/scm/github/org.go @@ -19,7 +19,7 @@ func (c *client) GetOrgName(ctx context.Context, u *api.User, o string) (string, }).Tracef("retrieving org information for %s", o) // create GitHub OAuth client with user's token - client := c.newClientToken(ctx, u.GetToken()) + client := c.newOAuthTokenClient(ctx, u.GetToken()) // send an API call to get the org info orgInfo, resp, err := client.Organizations.Get(ctx, o) diff --git a/scm/github/repo.go b/scm/github/repo.go index 3cf9dc760..d1648caa6 100644 --- a/scm/github/repo.go +++ b/scm/github/repo.go @@ -14,7 +14,9 @@ import ( "github.com/sirupsen/logrus" api "github.com/go-vela/server/api/types" + "github.com/go-vela/server/compiler/types/yaml" "github.com/go-vela/server/constants" + "github.com/go-vela/server/database" ) // ConfigBackoff is a wrapper for Config that will retry five times if the function @@ -55,7 +57,7 @@ func (c *client) Config(ctx context.Context, u *api.User, r *api.Repo, ref strin }).Tracef("capturing configuration file for %s/commit/%s", r.GetFullName(), ref) // create GitHub OAuth client with user's token - client := c.newClientToken(ctx, *u.Token) + client := c.newOAuthTokenClient(ctx, *u.Token) // default pipeline file names files := []string{".vela.yml", ".vela.yaml"} @@ -95,6 +97,11 @@ func (c *client) Config(ctx context.Context, u *api.User, r *api.Repo, ref strin // Disable deactivates a repo by deleting the webhook. func (c *client) Disable(ctx context.Context, u *api.User, org, name string) error { + return c.DestroyWebhook(ctx, u, org, name) +} + +// DestroyWebhook deletes a repo's webhook. +func (c *client) DestroyWebhook(ctx context.Context, u *api.User, org, name string) error { c.Logger.WithFields(logrus.Fields{ "org": org, "repo": name, @@ -102,7 +109,7 @@ func (c *client) Disable(ctx context.Context, u *api.User, org, name string) err }).Tracef("deleting repository webhooks for %s/%s", org, name) // create GitHub OAuth client with user's token - client := c.newClientToken(ctx, *u.Token) + client := c.newOAuthTokenClient(ctx, *u.Token) // send API call to capture the hooks for the repo hooks, _, err := client.Repositories.ListHooks(ctx, org, name, nil) @@ -150,6 +157,11 @@ func (c *client) Disable(ctx context.Context, u *api.User, org, name string) err // Enable activates a repo by creating the webhook. func (c *client) Enable(ctx context.Context, u *api.User, r *api.Repo, h *api.Hook) (*api.Hook, string, error) { + return c.CreateWebhook(ctx, u, r, h) +} + +// CreateWebhook creates a repo's webhook. +func (c *client) CreateWebhook(ctx context.Context, u *api.User, r *api.Repo, h *api.Hook) (*api.Hook, string, error) { c.Logger.WithFields(logrus.Fields{ "org": r.GetOrg(), "repo": r.GetName(), @@ -157,7 +169,7 @@ func (c *client) Enable(ctx context.Context, u *api.User, r *api.Repo, h *api.Ho }).Tracef("creating repository webhook for %s/%s", r.GetOrg(), r.GetName()) // create GitHub OAuth client with user's token - client := c.newClientToken(ctx, *u.Token) + client := c.newOAuthTokenClient(ctx, *u.Token) // always listen to repository events in case of repo name change events := []string{eventRepository} @@ -231,7 +243,7 @@ func (c *client) Update(ctx context.Context, u *api.User, r *api.Repo, hookID in }).Tracef("updating repository webhook for %s/%s", r.GetOrg(), r.GetName()) // create GitHub OAuth client with user's token - client := c.newClientToken(ctx, *u.Token) + client := c.newOAuthTokenClient(ctx, *u.Token) // always listen to repository events in case of repo name change events := []string{eventRepository} @@ -295,7 +307,7 @@ func (c *client) Status(ctx context.Context, u *api.User, b *api.Build, org, nam } // create GitHub OAuth client with user's token - client := c.newClientToken(ctx, *u.Token) + client := c.newOAuthTokenClient(ctx, *u.Token) context := fmt.Sprintf("%s/%s", c.config.StatusContext, b.GetEvent()) url := fmt.Sprintf("%s/%s/%s/%d", c.config.WebUIAddress, org, name, b.GetNumber()) @@ -414,7 +426,7 @@ func (c *client) StepStatus(ctx context.Context, u *api.User, b *api.Build, s *a } // create GitHub OAuth client with user's token - client := c.newClientToken(ctx, *u.Token) + client := c.newOAuthTokenClient(ctx, *u.Token) context := fmt.Sprintf("%s/%s/%s", c.config.StatusContext, b.GetEvent(), s.GetReportAs()) url := fmt.Sprintf("%s/%s/%s/%d#%d", c.config.WebUIAddress, org, name, b.GetNumber(), s.GetNumber()) @@ -477,7 +489,7 @@ func (c *client) GetRepo(ctx context.Context, u *api.User, r *api.Repo) (*api.Re }).Tracef("retrieving repository information for %s", r.GetFullName()) // create GitHub OAuth client with user's token - client := c.newClientToken(ctx, u.GetToken()) + client := c.newOAuthTokenClient(ctx, u.GetToken()) // send an API call to get the repo info repo, resp, err := client.Repositories.Get(ctx, r.GetOrg(), r.GetName()) @@ -497,7 +509,7 @@ func (c *client) GetOrgAndRepoName(ctx context.Context, u *api.User, o string, r }).Tracef("retrieving repository information for %s/%s", o, r) // create GitHub OAuth client with user's token - client := c.newClientToken(ctx, u.GetToken()) + client := c.newOAuthTokenClient(ctx, u.GetToken()) // send an API call to get the repo info repo, _, err := client.Repositories.Get(ctx, o, r) @@ -515,7 +527,7 @@ func (c *client) ListUserRepos(ctx context.Context, u *api.User) ([]*api.Repo, e }).Tracef("listing source repositories for %s", u.GetName()) // create GitHub OAuth client with user's token - client := c.newClientToken(ctx, u.GetToken()) + client := c.newOAuthTokenClient(ctx, u.GetToken()) r := []*github.Repository{} f := []*api.Repo{} @@ -595,7 +607,7 @@ func (c *client) GetPullRequest(ctx context.Context, r *api.Repo, number int) (s }).Tracef("retrieving pull request %d for repo %s", number, r.GetFullName()) // create GitHub OAuth client with user's token - client := c.newClientToken(ctx, r.GetOwner().GetToken()) + client := c.newOAuthTokenClient(ctx, r.GetOwner().GetToken()) pull, _, err := client.PullRequests.Get(ctx, r.GetOrg(), r.GetName(), number) if err != nil { @@ -619,7 +631,7 @@ func (c *client) GetHTMLURL(ctx context.Context, u *api.User, org, repo, name, r }).Tracef("capturing html_url for %s/%s/%s@%s", org, repo, name, ref) // create GitHub OAuth client with user's token - client := c.newClientToken(ctx, *u.Token) + client := c.newOAuthTokenClient(ctx, *u.Token) // set the reference for the options to capture the repository contents opts := &github.RepositoryContentGetOptions{ @@ -651,7 +663,7 @@ func (c *client) GetBranch(ctx context.Context, r *api.Repo, branch string) (str }).Tracef("retrieving branch %s for repo %s", branch, r.GetFullName()) // create GitHub OAuth client with user's token - client := c.newClientToken(ctx, r.GetOwner().GetToken()) + client := c.newOAuthTokenClient(ctx, r.GetOwner().GetToken()) maxRedirects := 3 @@ -662,3 +674,142 @@ func (c *client) GetBranch(ctx context.Context, r *api.Repo, branch string) (str return data.GetName(), data.GetCommit().GetSHA(), nil } + +// GetNetrcPassword returns a clone token using the repo's github app installation if it exists. +// If not, it defaults to the user OAuth token. +func (c *client) GetNetrcPassword(ctx context.Context, db database.Interface, r *api.Repo, u *api.User, g yaml.Git) (string, error) { + l := c.Logger.WithFields(logrus.Fields{ + "org": r.GetOrg(), + "repo": r.GetName(), + }) + + l.Tracef("getting netrc password for %s/%s", r.GetOrg(), r.GetName()) + + // no GitHub App configured, use legacy oauth token + if c.AppsTransport == nil { + return u.GetToken(), nil + } + + var err error + + // repos that the token has access to + // providing no repos, nil, or empty slice will default the token permissions to the list + // of repos added to the installation + repos := g.Repositories + + // use triggering repo as a restrictive default + if repos == nil { + repos = []string{r.GetName()} + } + + // convert repo fullname org/name to just name for usability + for i, repo := range repos { + split := strings.Split(repo, "/") + if len(split) == 2 { + repos[i] = split[1] + } + } + + // permissions that are applied to the token for every repo provided + // providing no permissions, nil, or empty map will default to the permissions + // of the GitHub App installation + // + // the Vela compiler follows a least-privileged-defaults model where + // the list contains only the triggering repo, unless provided in the git yaml block + // + // the default is contents:read and checks:write + ghPermissions := &github.InstallationPermissions{ + Contents: github.String(AppInstallPermissionRead), + Checks: github.String(AppInstallPermissionWrite), + } + + permissions := g.Permissions + if permissions == nil { + permissions = map[string]string{} + } + + for resource, perm := range permissions { + ghPermissions, err = ApplyInstallationPermissions(resource, perm, ghPermissions) + if err != nil { + return u.GetToken(), err + } + } + + // the app might not be installed therefore we retain backwards compatibility via the user oauth token + // https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation + // the optional list of repos and permissions are driven by yaml + installToken, installID, err := c.newGithubAppInstallationRepoToken(ctx, r, repos, ghPermissions) + if err != nil { + // return the legacy token along with no error for backwards compatibility + // todo: return an error based based on app installation requirements + l.Tracef("unable to create github app installation token for repos %v with permissions %v: %v", repos, permissions, err) + + return u.GetToken(), nil + } + + if installToken != nil && len(installToken.GetToken()) != 0 { + l.Tracef("using github app installation token for %s/%s", r.GetOrg(), r.GetName()) + + // (optional) sync the install ID with the repo + if db != nil && r.GetInstallID() != installID { + r.SetInstallID(installID) + + _, err = db.UpdateRepo(ctx, r) + if err != nil { + c.Logger.Tracef("unable to update repo with install ID %d: %v", installID, err) + } + } + + return installToken.GetToken(), nil + } + + l.Tracef("using user oauth token for %s/%s", r.GetOrg(), r.GetName()) + + return u.GetToken(), nil +} + +// SyncRepoWithInstallation ensures the repo is synchronized with the scm installation, if it exists. +func (c *client) SyncRepoWithInstallation(ctx context.Context, r *api.Repo) (*api.Repo, error) { + c.Logger.WithFields(logrus.Fields{ + "org": r.GetOrg(), + "repo": r.GetName(), + }).Tracef("syncing app installation for repo %s/%s", r.GetOrg(), r.GetName()) + + // no GitHub App configured, skip + if c.AppsTransport == nil { + return r, nil + } + + client, err := c.newGithubAppClient() + if err != nil { + return r, err + } + + installations, _, err := client.Apps.ListInstallations(ctx, &github.ListOptions{}) + if err != nil { + return r, err + } + + var installation *github.Installation + + for _, install := range installations { + if strings.EqualFold(install.GetAccount().GetLogin(), r.GetOrg()) { + installation = install + } + } + + if installation == nil { + return r, nil + } + + installationCanReadRepo, err := c.installationCanReadRepo(ctx, r, installation) + if err != nil { + return r, err + } + + if installationCanReadRepo { + r.SetInstallID(installation.GetID()) + } + + return r, nil +} diff --git a/scm/github/repo_test.go b/scm/github/repo_test.go index 502c1cc7c..036639674 100644 --- a/scm/github/repo_test.go +++ b/scm/github/repo_test.go @@ -13,8 +13,11 @@ import ( "testing" "github.com/gin-gonic/gin" + "github.com/google/go-cmp/cmp" + "github.com/google/go-github/v65/github" api "github.com/go-vela/server/api/types" + "github.com/go-vela/server/compiler/types/yaml" "github.com/go-vela/server/constants" ) @@ -1621,3 +1624,287 @@ func TestGithub_GetBranch(t *testing.T) { t.Errorf("Commit is %v, want %v", gotCommit, wantCommit) } } + +func TestGithub_GetNetrcPassword(t *testing.T) { + // setup context + gin.SetMode(gin.TestMode) + + resp := httptest.NewRecorder() + _, engine := gin.CreateTestContext(resp) + + // setup mock server + engine.GET("/api/v3/app/installations", func(c *gin.Context) { + c.Header("Content-Type", "application/json") + c.Status(http.StatusOK) + c.File("testdata/installations.json") + }) + engine.POST("/api/v3/app/installations/:id/access_tokens", func(c *gin.Context) { + c.Header("Content-Type", "application/json") + c.Status(http.StatusOK) + c.File("testdata/installations_access_tokens.json") + }) + + s := httptest.NewServer(engine) + defer s.Close() + + installedRepo := new(api.Repo) + installedRepo.SetOrg("octocat") + installedRepo.SetName("Hello-World") + installedRepo.SetInstallID(1) + + oauthRepo := new(api.Repo) + oauthRepo.SetOrg("octocat") + oauthRepo.SetName("Hello-World2") + oauthRepo.SetInstallID(0) + + u := new(api.User) + u.SetName("foo") + u.SetToken("bar") + + tests := []struct { + name string + repo *api.Repo + user *api.User + git yaml.Git + appsTransport bool + wantToken string + wantErr bool + }{ + { + name: "installation token", + repo: installedRepo, + user: u, + git: yaml.Git{ + Token: yaml.Token{ + Repositories: []string{"Hello-World"}, + Permissions: map[string]string{"contents": "read"}, + }, + }, + appsTransport: true, + wantToken: "ghs_16C7e42F292c6912E7710c838347Ae178B4a", + wantErr: false, + }, + { + name: "no app configured returns user oauth token", + repo: installedRepo, + user: u, + git: yaml.Git{ + Token: yaml.Token{ + Repositories: []string{"Hello-World"}, + Permissions: map[string]string{"contents": "read"}, + }, + }, + appsTransport: false, + wantToken: "bar", + wantErr: false, + }, + { + name: "repo not installed returns user oauth token", + repo: oauthRepo, + user: u, + git: yaml.Git{ + Token: yaml.Token{ + Repositories: []string{"Hello-World"}, + Permissions: map[string]string{"contents": "read"}, + }, + }, + appsTransport: true, + wantToken: "bar", + wantErr: false, + }, + { + name: "invalid permission resource", + repo: installedRepo, + user: u, + git: yaml.Git{ + Token: yaml.Token{ + Repositories: []string{"Hello-World"}, + Permissions: map[string]string{"invalid": "read"}, + }, + }, + appsTransport: true, + wantToken: "bar", + wantErr: true, + }, + { + name: "invalid permission level", + repo: installedRepo, + user: u, + git: yaml.Git{ + Token: yaml.Token{ + Repositories: []string{"Hello-World"}, + Permissions: map[string]string{"contents": "invalid"}, + }, + }, + appsTransport: true, + wantToken: "bar", + wantErr: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + client, _ := NewTest(s.URL) + if test.appsTransport { + client.AppsTransport = NewTestAppsTransport(s.URL) + } + + got, err := client.GetNetrcPassword(context.TODO(), nil, test.repo, test.user, test.git) + if (err != nil) != test.wantErr { + t.Errorf("GetNetrcPassword() error = %v, wantErr %v", err, test.wantErr) + return + } + if got != test.wantToken { + t.Errorf("GetNetrcPassword() = %v, want %v", got, test.wantToken) + } + }) + } +} + +func TestGithub_SyncRepoWithInstallation(t *testing.T) { + // setup context + gin.SetMode(gin.TestMode) + + resp := httptest.NewRecorder() + _, engine := gin.CreateTestContext(resp) + + // setup mock server + engine.GET("/api/v3/app/installations", func(c *gin.Context) { + c.Header("Content-Type", "application/json") + c.Status(http.StatusOK) + c.File("testdata/installations.json") + }) + engine.POST("/api/v3/app/installations/:id/access_tokens", func(c *gin.Context) { + c.Header("Content-Type", "application/json") + c.Status(http.StatusOK) + c.File("testdata/installations_access_tokens.json") + }) + engine.GET("/api/v3/installation/repositories", func(c *gin.Context) { + c.Header("Content-Type", "application/json") + c.Status(http.StatusOK) + c.File("testdata/installation_repositories.json") + }) + + s := httptest.NewServer(engine) + defer s.Close() + + tests := []struct { + name string + org string + repo string + wantInstallID int64 + wantStatusCode int + }{ + { + name: "match", + org: "octocat", + repo: "Hello-World", + wantInstallID: 1, + wantStatusCode: http.StatusOK, + }, + { + name: "no match", + repo: "octocat/Hello-World2", + wantInstallID: 0, + wantStatusCode: http.StatusOK, + }, + } + for _, test := range tests { + // setup types + r := new(api.Repo) + r.SetOrg(test.org) + r.SetName(test.repo) + r.SetFullName(fmt.Sprintf("%s/%s", test.org, test.repo)) + + client, _ := NewTest(s.URL) + client.AppsTransport = NewTestAppsTransport(s.URL) + + // run test + got, err := client.SyncRepoWithInstallation(context.TODO(), r) + + if resp.Code != test.wantStatusCode { + t.Errorf("SyncRepoWithInstallation %s returned %v, want %v", test.name, resp.Code, http.StatusOK) + } + + if err != nil { + t.Errorf("SyncRepoWithInstallation %s returned err: %v", test.name, err) + } + + if got.GetInstallID() != test.wantInstallID { + t.Errorf("SyncRepoWithInstallation %s returned %v, want %v", test.name, got.GetInstallID(), test.wantInstallID) + } + } +} + +func TestGithub_applyGitHubInstallationPermission(t *testing.T) { + tests := []struct { + name string + perms *github.InstallationPermissions + resource string + perm string + wantPerms *github.InstallationPermissions + wantErr bool + }{ + { + name: "valid read permission for contents", + perms: &github.InstallationPermissions{ + Contents: github.String(AppInstallPermissionNone), + }, + resource: AppInstallResourceContents, + perm: AppInstallPermissionRead, + wantPerms: &github.InstallationPermissions{ + Contents: github.String(AppInstallPermissionRead), + }, + wantErr: false, + }, + { + name: "valid write permission for checks", + perms: &github.InstallationPermissions{ + Checks: github.String(AppInstallPermissionNone), + }, + resource: AppInstallResourceChecks, + perm: AppInstallPermissionWrite, + wantPerms: &github.InstallationPermissions{ + Checks: github.String(AppInstallPermissionWrite), + }, + wantErr: false, + }, + { + name: "invalid permission value", + perms: &github.InstallationPermissions{ + Contents: github.String(AppInstallPermissionNone), + }, + resource: AppInstallResourceContents, + perm: "invalid", + wantPerms: &github.InstallationPermissions{ + Contents: github.String(AppInstallPermissionNone), + }, + wantErr: true, + }, + { + name: "invalid permission key", + perms: &github.InstallationPermissions{ + Contents: github.String(AppInstallPermissionNone), + }, + resource: "invalid", + perm: AppInstallPermissionRead, + wantPerms: &github.InstallationPermissions{ + Contents: github.String(AppInstallPermissionNone), + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ApplyInstallationPermissions(tt.resource, tt.perm, tt.perms) + if (err != nil) != tt.wantErr { + t.Errorf("ToGitHubAppInstallationPermissions() error = %v, wantErr %v", err, tt.wantErr) + return + } + if diff := cmp.Diff(tt.wantPerms, got); diff != "" { + t.Errorf("ToGitHubAppInstallationPermissions() mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/scm/github/testdata/hooks/installation_created.json b/scm/github/testdata/hooks/installation_created.json new file mode 100644 index 000000000..c3904b286 --- /dev/null +++ b/scm/github/testdata/hooks/installation_created.json @@ -0,0 +1,100 @@ +{ + "action": "created", + "installation": { + "id": 1, + "account": { + "login": "Codertocat", + "id": 4, + "node_id": "MDQ6VXNlcjQ=", + "avatar_url": "https://octocoders.github.io/avatars/u/4?", + "gravatar_id": "", + "url": "https://octocoders.github.io/api/v3/users/Codertocat", + "html_url": "https://octocoders.github.io/Codertocat", + "followers_url": "https://octocoders.github.io/api/v3/users/Codertocat/followers", + "following_url": "https://octocoders.github.io/api/v3/users/Codertocat/following{/other_user}", + "gists_url": "https://octocoders.github.io/api/v3/users/Codertocat/gists{/gist_id}", + "starred_url": "https://octocoders.github.io/api/v3/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://octocoders.github.io/api/v3/users/Codertocat/subscriptions", + "organizations_url": "https://octocoders.github.io/api/v3/users/Codertocat/orgs", + "repos_url": "https://octocoders.github.io/api/v3/users/Codertocat/repos", + "events_url": "https://octocoders.github.io/api/v3/users/Codertocat/events{/privacy}", + "received_events_url": "https://octocoders.github.io/api/v3/users/Codertocat/received_events", + "type": "User", + "site_admin": false + }, + "repository_selection": "selected", + "access_tokens_url": "https://octocoders.github.io/api/v3/app/installations/1/access_tokens", + "repositories_url": "https://octocoders.github.io/api/v3/installation/repositories", + "html_url": "https://octocoders.github.io/settings/installations/1", + "app_id": 282, + "app_slug": "vela", + "target_id": 10919, + "target_type": "User", + "permissions": { + "checks": "write", + "contents": "read", + "metadata": "read" + }, + "events": [ + + ], + "created_at": "2024-10-22T08:50:39.000-05:00", + "updated_at": "2024-10-22T08:50:39.000-05:00", + "single_file_name": null, + "has_multiple_single_files": false, + "single_file_paths": [ + + ], + "suspended_by": null, + "suspended_at": null + }, + "repositories": [ + { + "id": 1, + "node_id": "MDEwOlJlcG9zaXRvcnkxMTg=", + "name": "Hello-World", + "full_name": "Codertocat/Hello-World", + "private": true + }, + { + "id": 2, + "node_id": "MDEwOlJlcG9zaXRvcnk0MjI0MzE=", + "name": "Hello-World2", + "full_name": "Codertocat/Hello-World2", + "private": false + } + ], + "requester": null, + "enterprise": { + "id": 1, + "slug": "github", + "name": "GitHub", + "node_id": "MDEwOkVudGVycHJpc2Ux", + "avatar_url": "https://octocoders.github.io/avatars/b/1?", + "description": null, + "website_url": null, + "html_url": "https://octocoders.github.io/businesses/github", + "created_at": "2018-10-24T21:19:19Z", + "updated_at": "2023-06-01T21:03:12Z" + }, + "sender": { + "login": "Codertocat", + "id": 4, + "node_id": "MDQ6VXNlcjQ=", + "avatar_url": "https://octocoders.github.io/avatars/u/4?", + "gravatar_id": "", + "url": "https://octocoders.github.io/api/v3/users/Codertocat", + "html_url": "https://octocoders.github.io/Codertocat", + "followers_url": "https://octocoders.github.io/api/v3/users/Codertocat/followers", + "following_url": "https://octocoders.github.io/api/v3/users/Codertocat/following{/other_user}", + "gists_url": "https://octocoders.github.io/api/v3/users/Codertocat/gists{/gist_id}", + "starred_url": "https://octocoders.github.io/api/v3/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://octocoders.github.io/api/v3/users/Codertocat/subscriptions", + "organizations_url": "https://octocoders.github.io/api/v3/users/Codertocat/orgs", + "repos_url": "https://octocoders.github.io/api/v3/users/Codertocat/repos", + "events_url": "https://octocoders.github.io/api/v3/users/Codertocat/events{/privacy}", + "received_events_url": "https://octocoders.github.io/api/v3/users/Codertocat/received_events", + "type": "User", + "site_admin": false + } +} \ No newline at end of file diff --git a/scm/github/testdata/hooks/installation_deleted.json b/scm/github/testdata/hooks/installation_deleted.json new file mode 100644 index 000000000..9972e0cf9 --- /dev/null +++ b/scm/github/testdata/hooks/installation_deleted.json @@ -0,0 +1,100 @@ +{ + "action": "deleted", + "installation": { + "id": 1, + "account": { + "login": "Codertocat", + "id": 4, + "node_id": "MDQ6VXNlcjQ=", + "avatar_url": "https://octocoders.github.io/avatars/u/4?", + "gravatar_id": "", + "url": "https://octocoders.github.io/api/v3/users/Codertocat", + "html_url": "https://octocoders.github.io/Codertocat", + "followers_url": "https://octocoders.github.io/api/v3/users/Codertocat/followers", + "following_url": "https://octocoders.github.io/api/v3/users/Codertocat/following{/other_user}", + "gists_url": "https://octocoders.github.io/api/v3/users/Codertocat/gists{/gist_id}", + "starred_url": "https://octocoders.github.io/api/v3/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://octocoders.github.io/api/v3/users/Codertocat/subscriptions", + "organizations_url": "https://octocoders.github.io/api/v3/users/Codertocat/orgs", + "repos_url": "https://octocoders.github.io/api/v3/users/Codertocat/repos", + "events_url": "https://octocoders.github.io/api/v3/users/Codertocat/events{/privacy}", + "received_events_url": "https://octocoders.github.io/api/v3/users/Codertocat/received_events", + "type": "User", + "site_admin": false + }, + "repository_selection": "selected", + "access_tokens_url": "https://octocoders.github.io/api/v3/app/installations/1/access_tokens", + "repositories_url": "https://octocoders.github.io/api/v3/installation/repositories", + "html_url": "https://octocoders.github.io/settings/installations/1", + "app_id": 282, + "app_slug": "vela", + "target_id": 10919, + "target_type": "User", + "permissions": { + "checks": "write", + "contents": "read", + "metadata": "read" + }, + "events": [ + + ], + "created_at": "2024-10-22T08:50:39.000-05:00", + "updated_at": "2024-10-22T08:50:39.000-05:00", + "single_file_name": null, + "has_multiple_single_files": false, + "single_file_paths": [ + + ], + "suspended_by": null, + "suspended_at": null + }, + "repositories": [ + { + "id": 1, + "node_id": "MDEwOlJlcG9zaXRvcnkxMTg=", + "name": "Hello-World", + "full_name": "Codertocat/Hello-World", + "private": true + }, + { + "id": 2, + "node_id": "MDEwOlJlcG9zaXRvcnk0MjI0MzE=", + "name": "Hello-World2", + "full_name": "Codertocat/Hello-World2", + "private": false + } + ], + "requester": null, + "enterprise": { + "id": 1, + "slug": "github", + "name": "GitHub", + "node_id": "MDEwOkVudGVycHJpc2Ux", + "avatar_url": "https://octocoders.github.io/avatars/b/1?", + "description": null, + "website_url": null, + "html_url": "https://octocoders.github.io/businesses/github", + "created_at": "2018-10-24T21:19:19Z", + "updated_at": "2023-06-01T21:03:12Z" + }, + "sender": { + "login": "Codertocat", + "id": 4, + "node_id": "MDQ6VXNlcjQ=", + "avatar_url": "https://octocoders.github.io/avatars/u/4?", + "gravatar_id": "", + "url": "https://octocoders.github.io/api/v3/users/Codertocat", + "html_url": "https://octocoders.github.io/Codertocat", + "followers_url": "https://octocoders.github.io/api/v3/users/Codertocat/followers", + "following_url": "https://octocoders.github.io/api/v3/users/Codertocat/following{/other_user}", + "gists_url": "https://octocoders.github.io/api/v3/users/Codertocat/gists{/gist_id}", + "starred_url": "https://octocoders.github.io/api/v3/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://octocoders.github.io/api/v3/users/Codertocat/subscriptions", + "organizations_url": "https://octocoders.github.io/api/v3/users/Codertocat/orgs", + "repos_url": "https://octocoders.github.io/api/v3/users/Codertocat/repos", + "events_url": "https://octocoders.github.io/api/v3/users/Codertocat/events{/privacy}", + "received_events_url": "https://octocoders.github.io/api/v3/users/Codertocat/received_events", + "type": "User", + "site_admin": false + } +} \ No newline at end of file diff --git a/scm/github/testdata/hooks/installation_repositories_added.json b/scm/github/testdata/hooks/installation_repositories_added.json new file mode 100644 index 000000000..f75fedcd1 --- /dev/null +++ b/scm/github/testdata/hooks/installation_repositories_added.json @@ -0,0 +1,103 @@ +{ + "action": "added", + "installation": { + "id": 1, + "account": { + "login": "Codertocat", + "id": 4, + "node_id": "MDQ6VXNlcjQ=", + "avatar_url": "https://octocoders.github.io/avatars/u/4?", + "gravatar_id": "", + "url": "https://octocoders.github.io/api/v3/users/Codertocat", + "html_url": "https://octocoders.github.io/Codertocat", + "followers_url": "https://octocoders.github.io/api/v3/users/Codertocat/followers", + "following_url": "https://octocoders.github.io/api/v3/users/Codertocat/following{/other_user}", + "gists_url": "https://octocoders.github.io/api/v3/users/Codertocat/gists{/gist_id}", + "starred_url": "https://octocoders.github.io/api/v3/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://octocoders.github.io/api/v3/users/Codertocat/subscriptions", + "organizations_url": "https://octocoders.github.io/api/v3/users/Codertocat/orgs", + "repos_url": "https://octocoders.github.io/api/v3/users/Codertocat/repos", + "events_url": "https://octocoders.github.io/api/v3/users/Codertocat/events{/privacy}", + "received_events_url": "https://octocoders.github.io/api/v3/users/Codertocat/received_events", + "type": "User", + "site_admin": false + }, + "repository_selection": "selected", + "access_tokens_url": "https://octocoders.github.io/api/v3/app/installations/1/access_tokens", + "repositories_url": "https://octocoders.github.io/api/v3/installation/repositories", + "html_url": "https://octocoders.github.io/settings/installations/1", + "app_id": 282, + "app_slug": "vela", + "target_id": 10919, + "target_type": "User", + "permissions": { + "checks": "write", + "contents": "read", + "metadata": "read" + }, + "events": [ + + ], + "created_at": "2024-10-22T08:50:39.000-05:00", + "updated_at": "2024-10-22T08:50:39.000-05:00", + "single_file_name": null, + "has_multiple_single_files": false, + "single_file_paths": [ + + ], + "suspended_by": null, + "suspended_at": null + }, + "repositories_added": [ + { + "id": 1, + "node_id": "MDEwOlJlcG9zaXRvcnkxMTg=", + "name": "Hello-World", + "full_name": "Codertocat/Hello-World", + "private": true + }, + { + "id": 2, + "node_id": "MDEwOlJlcG9zaXRvcnk0MjI0MzE=", + "name": "Hello-World2", + "full_name": "Codertocat/Hello-World2", + "private": false + } + ], + "repositories_removed": [ + + ], + "requester": null, + "enterprise": { + "id": 1, + "slug": "github", + "name": "GitHub", + "node_id": "MDEwOkVudGVycHJpc2Ux", + "avatar_url": "https://octocoders.github.io/avatars/b/1?", + "description": null, + "website_url": null, + "html_url": "https://octocoders.github.io/businesses/github", + "created_at": "2018-10-24T21:19:19Z", + "updated_at": "2023-06-01T21:03:12Z" + }, + "sender": { + "login": "Codertocat", + "id": 4, + "node_id": "MDQ6VXNlcjQ=", + "avatar_url": "https://octocoders.github.io/avatars/u/4?", + "gravatar_id": "", + "url": "https://octocoders.github.io/api/v3/users/Codertocat", + "html_url": "https://octocoders.github.io/Codertocat", + "followers_url": "https://octocoders.github.io/api/v3/users/Codertocat/followers", + "following_url": "https://octocoders.github.io/api/v3/users/Codertocat/following{/other_user}", + "gists_url": "https://octocoders.github.io/api/v3/users/Codertocat/gists{/gist_id}", + "starred_url": "https://octocoders.github.io/api/v3/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://octocoders.github.io/api/v3/users/Codertocat/subscriptions", + "organizations_url": "https://octocoders.github.io/api/v3/users/Codertocat/orgs", + "repos_url": "https://octocoders.github.io/api/v3/users/Codertocat/repos", + "events_url": "https://octocoders.github.io/api/v3/users/Codertocat/events{/privacy}", + "received_events_url": "https://octocoders.github.io/api/v3/users/Codertocat/received_events", + "type": "User", + "site_admin": false + } + } \ No newline at end of file diff --git a/scm/github/testdata/hooks/installation_repositories_removed.json b/scm/github/testdata/hooks/installation_repositories_removed.json new file mode 100644 index 000000000..b7a82ec5b --- /dev/null +++ b/scm/github/testdata/hooks/installation_repositories_removed.json @@ -0,0 +1,103 @@ +{ + "action": "removed", + "installation": { + "id": 1, + "account": { + "login": "Codertocat", + "id": 4, + "node_id": "MDQ6VXNlcjQ=", + "avatar_url": "https://octocoders.github.io/avatars/u/4?", + "gravatar_id": "", + "url": "https://octocoders.github.io/api/v3/users/Codertocat", + "html_url": "https://octocoders.github.io/Codertocat", + "followers_url": "https://octocoders.github.io/api/v3/users/Codertocat/followers", + "following_url": "https://octocoders.github.io/api/v3/users/Codertocat/following{/other_user}", + "gists_url": "https://octocoders.github.io/api/v3/users/Codertocat/gists{/gist_id}", + "starred_url": "https://octocoders.github.io/api/v3/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://octocoders.github.io/api/v3/users/Codertocat/subscriptions", + "organizations_url": "https://octocoders.github.io/api/v3/users/Codertocat/orgs", + "repos_url": "https://octocoders.github.io/api/v3/users/Codertocat/repos", + "events_url": "https://octocoders.github.io/api/v3/users/Codertocat/events{/privacy}", + "received_events_url": "https://octocoders.github.io/api/v3/users/Codertocat/received_events", + "type": "User", + "site_admin": false + }, + "repository_selection": "selected", + "access_tokens_url": "https://octocoders.github.io/api/v3/app/installations/1/access_tokens", + "repositories_url": "https://octocoders.github.io/api/v3/installation/repositories", + "html_url": "https://octocoders.github.io/settings/installations/1", + "app_id": 282, + "app_slug": "vela", + "target_id": 10919, + "target_type": "User", + "permissions": { + "checks": "write", + "contents": "read", + "metadata": "read" + }, + "events": [ + + ], + "created_at": "2024-10-22T08:50:39.000-05:00", + "updated_at": "2024-10-22T08:50:39.000-05:00", + "single_file_name": null, + "has_multiple_single_files": false, + "single_file_paths": [ + + ], + "suspended_by": null, + "suspended_at": null + }, + "repositories_added": [ + + ], + "repositories_removed": [ + { + "id": 1, + "node_id": "MDEwOlJlcG9zaXRvcnkxMTg=", + "name": "Hello-World", + "full_name": "Codertocat/Hello-World", + "private": true + }, + { + "id": 2, + "node_id": "MDEwOlJlcG9zaXRvcnk0MjI0MzE=", + "name": "Hello-World2", + "full_name": "Codertocat/Hello-World2", + "private": false + } + ], + "requester": null, + "enterprise": { + "id": 1, + "slug": "github", + "name": "GitHub", + "node_id": "MDEwOkVudGVycHJpc2Ux", + "avatar_url": "https://octocoders.github.io/avatars/b/1?", + "description": null, + "website_url": null, + "html_url": "https://octocoders.github.io/businesses/github", + "created_at": "2018-10-24T21:19:19Z", + "updated_at": "2023-06-01T21:03:12Z" + }, + "sender": { + "login": "Codertocat", + "id": 4, + "node_id": "MDQ6VXNlcjQ=", + "avatar_url": "https://octocoders.github.io/avatars/u/4?", + "gravatar_id": "", + "url": "https://octocoders.github.io/api/v3/users/Codertocat", + "html_url": "https://octocoders.github.io/Codertocat", + "followers_url": "https://octocoders.github.io/api/v3/users/Codertocat/followers", + "following_url": "https://octocoders.github.io/api/v3/users/Codertocat/following{/other_user}", + "gists_url": "https://octocoders.github.io/api/v3/users/Codertocat/gists{/gist_id}", + "starred_url": "https://octocoders.github.io/api/v3/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://octocoders.github.io/api/v3/users/Codertocat/subscriptions", + "organizations_url": "https://octocoders.github.io/api/v3/users/Codertocat/orgs", + "repos_url": "https://octocoders.github.io/api/v3/users/Codertocat/repos", + "events_url": "https://octocoders.github.io/api/v3/users/Codertocat/events{/privacy}", + "received_events_url": "https://octocoders.github.io/api/v3/users/Codertocat/received_events", + "type": "User", + "site_admin": false + } + } \ No newline at end of file diff --git a/scm/github/testdata/installation_repositories.json b/scm/github/testdata/installation_repositories.json new file mode 100644 index 000000000..9eb501cb5 --- /dev/null +++ b/scm/github/testdata/installation_repositories.json @@ -0,0 +1,123 @@ +{ + "total_count": 1, + "repositories": [ + { + "id": 1296269, + "node_id": "MDEwOlJlcG9zaXRvcnkxMjk2MjY5", + "name": "Hello-World", + "full_name": "octocat/Hello-World", + "owner": { + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": false + }, + "private": false, + "html_url": "https://github.com/octocat/Hello-World", + "description": "This your first repo!", + "fork": false, + "url": "https://api.github.com/repos/octocat/Hello-World", + "archive_url": "https://api.github.com/repos/octocat/Hello-World/{archive_format}{/ref}", + "assignees_url": "https://api.github.com/repos/octocat/Hello-World/assignees{/user}", + "blobs_url": "https://api.github.com/repos/octocat/Hello-World/git/blobs{/sha}", + "branches_url": "https://api.github.com/repos/octocat/Hello-World/branches{/branch}", + "collaborators_url": "https://api.github.com/repos/octocat/Hello-World/collaborators{/collaborator}", + "comments_url": "https://api.github.com/repos/octocat/Hello-World/comments{/number}", + "commits_url": "https://api.github.com/repos/octocat/Hello-World/commits{/sha}", + "compare_url": "https://api.github.com/repos/octocat/Hello-World/compare/{base}...{head}", + "contents_url": "https://api.github.com/repos/octocat/Hello-World/contents/{+path}", + "contributors_url": "https://api.github.com/repos/octocat/Hello-World/contributors", + "deployments_url": "https://api.github.com/repos/octocat/Hello-World/deployments", + "downloads_url": "https://api.github.com/repos/octocat/Hello-World/downloads", + "events_url": "https://api.github.com/repos/octocat/Hello-World/events", + "forks_url": "https://api.github.com/repos/octocat/Hello-World/forks", + "git_commits_url": "https://api.github.com/repos/octocat/Hello-World/git/commits{/sha}", + "git_refs_url": "https://api.github.com/repos/octocat/Hello-World/git/refs{/sha}", + "git_tags_url": "https://api.github.com/repos/octocat/Hello-World/git/tags{/sha}", + "git_url": "git:github.com/octocat/Hello-World.git", + "issue_comment_url": "https://api.github.com/repos/octocat/Hello-World/issues/comments{/number}", + "issue_events_url": "https://api.github.com/repos/octocat/Hello-World/issues/events{/number}", + "issues_url": "https://api.github.com/repos/octocat/Hello-World/issues{/number}", + "keys_url": "https://api.github.com/repos/octocat/Hello-World/keys{/key_id}", + "labels_url": "https://api.github.com/repos/octocat/Hello-World/labels{/name}", + "languages_url": "https://api.github.com/repos/octocat/Hello-World/languages", + "merges_url": "https://api.github.com/repos/octocat/Hello-World/merges", + "milestones_url": "https://api.github.com/repos/octocat/Hello-World/milestones{/number}", + "notifications_url": "https://api.github.com/repos/octocat/Hello-World/notifications{?since,all,participating}", + "pulls_url": "https://api.github.com/repos/octocat/Hello-World/pulls{/number}", + "releases_url": "https://api.github.com/repos/octocat/Hello-World/releases{/id}", + "ssh_url": "git@github.com:octocat/Hello-World.git", + "stargazers_url": "https://api.github.com/repos/octocat/Hello-World/stargazers", + "statuses_url": "https://api.github.com/repos/octocat/Hello-World/statuses/{sha}", + "subscribers_url": "https://api.github.com/repos/octocat/Hello-World/subscribers", + "subscription_url": "https://api.github.com/repos/octocat/Hello-World/subscription", + "tags_url": "https://api.github.com/repos/octocat/Hello-World/tags", + "teams_url": "https://api.github.com/repos/octocat/Hello-World/teams", + "trees_url": "https://api.github.com/repos/octocat/Hello-World/git/trees{/sha}", + "clone_url": "https://github.com/octocat/Hello-World.git", + "mirror_url": "git:git.example.com/octocat/Hello-World", + "hooks_url": "https://api.github.com/repos/octocat/Hello-World/hooks", + "svn_url": "https://svn.github.com/octocat/Hello-World", + "homepage": "https://github.com", + "language": null, + "forks_count": 9, + "stargazers_count": 80, + "watchers_count": 80, + "size": 108, + "default_branch": "master", + "open_issues_count": 0, + "is_template": true, + "topics": [ + "octocat", + "atom", + "electron", + "api" + ], + "has_issues": true, + "has_projects": true, + "has_wiki": true, + "has_pages": false, + "has_downloads": true, + "archived": false, + "disabled": false, + "visibility": "public", + "pushed_at": "2011-01-26T19:06:43Z", + "created_at": "2011-01-26T19:01:12Z", + "updated_at": "2011-01-26T19:14:43Z", + "allow_rebase_merge": true, + "template_repository": null, + "temp_clone_token": "ABTLWHOULUVAXGTRYU7OC2876QJ2O", + "allow_squash_merge": true, + "allow_auto_merge": false, + "delete_branch_on_merge": true, + "allow_merge_commit": true, + "subscribers_count": 42, + "network_count": 0, + "license": { + "key": "mit", + "name": "MIT License", + "url": "https://api.github.com/licenses/mit", + "spdx_id": "MIT", + "node_id": "MDc6TGljZW5zZW1pdA==", + "html_url": "https://github.com/licenses/mit" + }, + "forks": 1, + "open_issues": 1, + "watchers": 1 + } + ] + } \ No newline at end of file diff --git a/scm/github/testdata/installations.json b/scm/github/testdata/installations.json new file mode 100644 index 000000000..736849702 --- /dev/null +++ b/scm/github/testdata/installations.json @@ -0,0 +1,52 @@ +[ + { + "id": 1, + "account": { + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": false + }, + "access_tokens_url": "https://api.github.com/app/installations/1/access_tokens", + "repositories_url": "https://api.github.com/installation/repositories", + "html_url": "https://github.com/organizations/github/settings/installations/1", + "app_id": 1, + "target_id": 1, + "target_type": "Organization", + "permissions": { + "checks": "write", + "metadata": "read", + "contents": "read" + }, + "events": [ + "push", + "pull_request" + ], + "single_file_name": "config.yaml", + "has_multiple_single_files": true, + "single_file_paths": [ + "config.yml", + ".github/issue_TEMPLATE.md" + ], + "repository_selection": "selected", + "created_at": "2017-07-08T16:18:44-04:00", + "updated_at": "2017-07-08T16:18:44-04:00", + "app_slug": "github-actions", + "suspended_at": null, + "suspended_by": null + } +] \ No newline at end of file diff --git a/scm/github/testdata/installations_access_tokens.json b/scm/github/testdata/installations_access_tokens.json new file mode 100644 index 000000000..86b705880 --- /dev/null +++ b/scm/github/testdata/installations_access_tokens.json @@ -0,0 +1,134 @@ +{ + "token": "ghs_16C7e42F292c6912E7710c838347Ae178B4a", + "expires_at": "2016-07-11T22:14:10Z", + "permissions": { + "issues": "write", + "contents": "read" + }, + "repository_selection": "selected", + "repositories": [ + { + "id": 1296269, + "node_id": "MDEwOlJlcG9zaXRvcnkxMjk2MjY5", + "name": "Hello-World", + "full_name": "octocat/Hello-World", + "owner": { + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": false + }, + "private": false, + "html_url": "https://github.com/octocat/Hello-World", + "description": "This your first repo!", + "fork": false, + "url": "https://api.github.com/repos/octocat/Hello-World", + "archive_url": "https://api.github.com/repos/octocat/Hello-World/{archive_format}{/ref}", + "assignees_url": "https://api.github.com/repos/octocat/Hello-World/assignees{/user}", + "blobs_url": "https://api.github.com/repos/octocat/Hello-World/git/blobs{/sha}", + "branches_url": "https://api.github.com/repos/octocat/Hello-World/branches{/branch}", + "collaborators_url": "https://api.github.com/repos/octocat/Hello-World/collaborators{/collaborator}", + "comments_url": "https://api.github.com/repos/octocat/Hello-World/comments{/number}", + "commits_url": "https://api.github.com/repos/octocat/Hello-World/commits{/sha}", + "compare_url": "https://api.github.com/repos/octocat/Hello-World/compare/{base}...{head}", + "contents_url": "https://api.github.com/repos/octocat/Hello-World/contents/{+path}", + "contributors_url": "https://api.github.com/repos/octocat/Hello-World/contributors", + "deployments_url": "https://api.github.com/repos/octocat/Hello-World/deployments", + "downloads_url": "https://api.github.com/repos/octocat/Hello-World/downloads", + "events_url": "https://api.github.com/repos/octocat/Hello-World/events", + "forks_url": "https://api.github.com/repos/octocat/Hello-World/forks", + "git_commits_url": "https://api.github.com/repos/octocat/Hello-World/git/commits{/sha}", + "git_refs_url": "https://api.github.com/repos/octocat/Hello-World/git/refs{/sha}", + "git_tags_url": "https://api.github.com/repos/octocat/Hello-World/git/tags{/sha}", + "git_url": "git:github.com/octocat/Hello-World.git", + "issue_comment_url": "https://api.github.com/repos/octocat/Hello-World/issues/comments{/number}", + "issue_events_url": "https://api.github.com/repos/octocat/Hello-World/issues/events{/number}", + "issues_url": "https://api.github.com/repos/octocat/Hello-World/issues{/number}", + "keys_url": "https://api.github.com/repos/octocat/Hello-World/keys{/key_id}", + "labels_url": "https://api.github.com/repos/octocat/Hello-World/labels{/name}", + "languages_url": "https://api.github.com/repos/octocat/Hello-World/languages", + "merges_url": "https://api.github.com/repos/octocat/Hello-World/merges", + "milestones_url": "https://api.github.com/repos/octocat/Hello-World/milestones{/number}", + "notifications_url": "https://api.github.com/repos/octocat/Hello-World/notifications{?since,all,participating}", + "pulls_url": "https://api.github.com/repos/octocat/Hello-World/pulls{/number}", + "releases_url": "https://api.github.com/repos/octocat/Hello-World/releases{/id}", + "ssh_url": "git@github.com:octocat/Hello-World.git", + "stargazers_url": "https://api.github.com/repos/octocat/Hello-World/stargazers", + "statuses_url": "https://api.github.com/repos/octocat/Hello-World/statuses/{sha}", + "subscribers_url": "https://api.github.com/repos/octocat/Hello-World/subscribers", + "subscription_url": "https://api.github.com/repos/octocat/Hello-World/subscription", + "tags_url": "https://api.github.com/repos/octocat/Hello-World/tags", + "teams_url": "https://api.github.com/repos/octocat/Hello-World/teams", + "trees_url": "https://api.github.com/repos/octocat/Hello-World/git/trees{/sha}", + "clone_url": "https://github.com/octocat/Hello-World.git", + "mirror_url": "git:git.example.com/octocat/Hello-World", + "hooks_url": "https://api.github.com/repos/octocat/Hello-World/hooks", + "svn_url": "https://svn.github.com/octocat/Hello-World", + "homepage": "https://github.com", + "language": null, + "forks_count": 9, + "stargazers_count": 80, + "watchers_count": 80, + "size": 108, + "default_branch": "master", + "open_issues_count": 0, + "is_template": true, + "topics": [ + "octocat", + "atom", + "electron", + "api" + ], + "has_issues": true, + "has_projects": true, + "has_wiki": true, + "has_pages": false, + "has_downloads": true, + "archived": false, + "disabled": false, + "visibility": "public", + "pushed_at": "2011-01-26T19:06:43Z", + "created_at": "2011-01-26T19:01:12Z", + "updated_at": "2011-01-26T19:14:43Z", + "permissions": { + "admin": false, + "push": false, + "pull": true + }, + "allow_rebase_merge": true, + "template_repository": null, + "temp_clone_token": "ABTLWHOULUVAXGTRYU7OC2876QJ2O", + "allow_squash_merge": true, + "allow_auto_merge": false, + "delete_branch_on_merge": true, + "allow_merge_commit": true, + "subscribers_count": 42, + "network_count": 0, + "license": { + "key": "mit", + "name": "MIT License", + "url": "https://api.github.com/licenses/mit", + "spdx_id": "MIT", + "node_id": "MDc6TGljZW5zZW1pdA==", + "html_url": "https://github.com/licenses/mit" + }, + "forks": 1, + "open_issues": 1, + "watchers": 1 + } + ] + } \ No newline at end of file diff --git a/scm/github/user.go b/scm/github/user.go index d014256e4..3ceaf75ac 100644 --- a/scm/github/user.go +++ b/scm/github/user.go @@ -16,7 +16,7 @@ func (c *client) GetUserID(ctx context.Context, name string, token string) (stri }).Tracef("capturing SCM user id for %s", name) // create GitHub OAuth client with user's token - client := c.newClientToken(ctx, token) + client := c.newOAuthTokenClient(ctx, token) // send API call to capture user user, _, err := client.Users.Get(ctx, name) diff --git a/scm/github/webhook.go b/scm/github/webhook.go index 61bdd2135..a23c6be1b 100644 --- a/scm/github/webhook.go +++ b/scm/github/webhook.go @@ -60,7 +60,6 @@ func (c *client) ProcessWebhook(ctx context.Context, request *http.Request) (*in // parse the payload from the webhook event, err := github.ParseWebHook(github.WebHookType(request), payload) - if err != nil { return &internal.Webhook{Hook: h}, nil } @@ -77,6 +76,10 @@ func (c *client) ProcessWebhook(ctx context.Context, request *http.Request) (*in return c.processIssueCommentEvent(h, event) case *github.RepositoryEvent: return c.processRepositoryEvent(h, event) + case *github.InstallationEvent: + return c.processInstallationEvent(ctx, h, event) + case *github.InstallationRepositoriesEvent: + return c.processInstallationRepositoriesEvent(ctx, h, event) } return &internal.Webhook{Hook: h}, nil @@ -100,7 +103,7 @@ func (c *client) VerifyWebhook(_ context.Context, request *http.Request, r *api. // RedeliverWebhook redelivers webhooks from GitHub. func (c *client) RedeliverWebhook(ctx context.Context, u *api.User, h *api.Hook) error { // create GitHub OAuth client with user's token - client := c.newClientToken(ctx, u.GetToken()) + client := c.newOAuthTokenClient(ctx, u.GetToken()) // capture the delivery ID of the hook using GitHub API deliveryID, err := c.getDeliveryID(ctx, client, h) @@ -512,7 +515,6 @@ func (c *client) processIssueCommentEvent(h *api.Hook, payload *github.IssueComm } // processRepositoryEvent is a helper function to process the repository event. - func (c *client) processRepositoryEvent(h *api.Hook, payload *github.RepositoryEvent) (*internal.Webhook, error) { logrus.Tracef("processing repository event GitHub webhook for %s", payload.GetRepo().GetFullName()) @@ -543,6 +545,59 @@ func (c *client) processRepositoryEvent(h *api.Hook, payload *github.RepositoryE }, nil } +// processInstallationEvent is a helper function to process the installation event. +func (c *client) processInstallationEvent(_ context.Context, h *api.Hook, payload *github.InstallationEvent) (*internal.Webhook, error) { + h.SetEvent(constants.EventInstallation) + h.SetEventAction(payload.GetAction()) + + install := new(internal.Installation) + + install.Action = payload.GetAction() + install.ID = payload.GetInstallation().GetID() + install.Org = payload.GetInstallation().GetAccount().GetLogin() + + switch payload.GetAction() { + case constants.AppInstallCreated: + for _, repo := range payload.Repositories { + install.RepositoriesAdded = append(install.RepositoriesAdded, repo.GetName()) + } + case constants.AppInstallDeleted: + for _, repo := range payload.Repositories { + install.RepositoriesRemoved = append(install.RepositoriesRemoved, repo.GetName()) + } + } + + return &internal.Webhook{ + Hook: h, + Installation: install, + }, nil +} + +// processInstallationRepositoriesEvent is a helper function to process the installation repositories event. +func (c *client) processInstallationRepositoriesEvent(_ context.Context, h *api.Hook, payload *github.InstallationRepositoriesEvent) (*internal.Webhook, error) { + h.SetEvent(constants.EventInstallationRepositories) + h.SetEventAction(payload.GetAction()) + + install := new(internal.Installation) + + install.Action = payload.GetAction() + install.ID = payload.GetInstallation().GetID() + install.Org = payload.GetInstallation().GetAccount().GetLogin() + + for _, repo := range payload.RepositoriesAdded { + install.RepositoriesAdded = append(install.RepositoriesAdded, repo.GetName()) + } + + for _, repo := range payload.RepositoriesRemoved { + install.RepositoriesRemoved = append(install.RepositoriesRemoved, repo.GetName()) + } + + return &internal.Webhook{ + Hook: h, + Installation: install, + }, nil +} + // getDeliveryID gets the last 100 webhook deliveries for a repo and // finds the matching delivery id with the source id in the hook. func (c *client) getDeliveryID(ctx context.Context, ghClient *github.Client, h *api.Hook) (int64, error) { diff --git a/scm/github/webhook_test.go b/scm/github/webhook_test.go index c736a94d2..b9a118baa 100644 --- a/scm/github/webhook_test.go +++ b/scm/github/webhook_test.go @@ -1200,8 +1200,8 @@ func TestGitHub_ProcessWebhook_RepositoryRename(t *testing.T) { t.Errorf("ProcessWebhook returned err: %v", err) } - if !reflect.DeepEqual(got, want) { - t.Errorf("ProcessWebhook is %v, want %v", got, want) + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("ProcessWebhook() mismatch (-want +got):\n%s", diff) } } @@ -1263,8 +1263,8 @@ func TestGitHub_ProcessWebhook_RepositoryTransfer(t *testing.T) { t.Errorf("ProcessWebhook returned err: %v", err) } - if !reflect.DeepEqual(got, want) { - t.Errorf("ProcessWebhook is %v, want %v", got, want) + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("ProcessWebhook() mismatch (-want +got):\n%s", diff) } } @@ -1326,8 +1326,8 @@ func TestGitHub_ProcessWebhook_RepositoryArchived(t *testing.T) { t.Errorf("ProcessWebhook returned err: %v", err) } - if !reflect.DeepEqual(got, want) { - t.Errorf("ProcessWebhook is %v, want %v", got, want) + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("ProcessWebhook() mismatch (-want +got):\n%s", diff) } } @@ -1389,8 +1389,8 @@ func TestGitHub_ProcessWebhook_RepositoryEdited(t *testing.T) { t.Errorf("ProcessWebhook returned err: %v", err) } - if !reflect.DeepEqual(got, want) { - t.Errorf("ProcessWebhook is %v, want %v", got, want) + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("ProcessWebhook() mismatch (-want +got):\n%s", diff) } } @@ -1452,8 +1452,8 @@ func TestGitHub_ProcessWebhook_Repository(t *testing.T) { t.Errorf("ProcessWebhook returned err: %v", err) } - if !reflect.DeepEqual(got, want) { - t.Errorf("ProcessWebhook is %v, want %v", got, want) + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("ProcessWebhook() mismatch (-want +got):\n%s", diff) } } @@ -1545,7 +1545,7 @@ func TestGithub_GetDeliveryID(t *testing.T) { client, _ := NewTest(s.URL, "https://foo.bar.com") - ghClient := client.newClientToken(context.Background(), *u.Token) + ghClient := client.newOAuthTokenClient(context.Background(), *u.Token) // run test got, err := client.getDeliveryID(context.TODO(), ghClient, _hook) @@ -1558,3 +1558,180 @@ func TestGithub_GetDeliveryID(t *testing.T) { t.Errorf("getDeliveryID returned: %v; want: %v", got, want) } } + +func TestGitHub_ProcessWebhook_Installation(t *testing.T) { + // setup tests + var createdHook api.Hook + createdHook.SetNumber(1) + createdHook.SetSourceID("7bd477e4-4415-11e9-9359-0d41fdf9567e") + createdHook.SetWebhookID(123456) + createdHook.SetCreated(time.Now().UTC().Unix()) + createdHook.SetHost("github.com") + createdHook.SetEvent(constants.EventInstallation) + createdHook.SetEventAction(constants.AppInstallCreated) + createdHook.SetStatus(constants.StatusSuccess) + + deletedHook := createdHook + deletedHook.SetEventAction(constants.AppInstallDeleted) + + tests := []struct { + name string + file string + wantHook *api.Hook + wantInstall *internal.Installation + }{ + { + name: "installation created", + file: "testdata/hooks/installation_created.json", + wantHook: &createdHook, + wantInstall: &internal.Installation{ + Action: constants.AppInstallCreated, + ID: 1, + RepositoriesAdded: []string{"Hello-World", "Hello-World2"}, + Org: "Codertocat", + }, + }, + { + name: "installation deleted", + file: "testdata/hooks/installation_deleted.json", + wantHook: &deletedHook, + wantInstall: &internal.Installation{ + Action: constants.AppInstallDeleted, + ID: 1, + RepositoriesRemoved: []string{"Hello-World", "Hello-World2"}, + Org: "Codertocat", + }, + }, + } + + // setup router + s := httptest.NewServer(http.NotFoundHandler()) + defer s.Close() + + for _, tt := range tests { + // setup request + body, err := os.Open(tt.file) + if err != nil { + t.Errorf("unable to open file: %v", err) + } + + defer body.Close() + + request, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "/test", body) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("User-Agent", "GitHub-Hookshot/a22606a") + request.Header.Set("X-GitHub-Delivery", "7bd477e4-4415-11e9-9359-0d41fdf9567e") + request.Header.Set("X-GitHub-Hook-ID", "123456") + request.Header.Set("X-GitHub-Event", "installation") + + // setup client + client, _ := NewTest(s.URL) + + want := &internal.Webhook{ + Hook: tt.wantHook, + Installation: tt.wantInstall, + } + + got, err := client.ProcessWebhook(context.TODO(), request) + + if err != nil { + t.Errorf("ProcessWebhook returned err: %v", err) + } + + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("ProcessWebhook() mismatch (-want +got):\n%s", diff) + } + } +} + +const ( + // GitHub App install event type 'added'. + AppInstallRepositoriesAdded = "added" + // GitHub App install event type 'removed'. + AppInstallRepositoriesRemoved = "removed" +) + +func TestGitHub_ProcessWebhook_InstallationRepositories(t *testing.T) { + // setup tests + var reposAddedHook api.Hook + reposAddedHook.SetNumber(1) + reposAddedHook.SetSourceID("7bd477e4-4415-11e9-9359-0d41fdf9567e") + reposAddedHook.SetWebhookID(123456) + reposAddedHook.SetCreated(time.Now().UTC().Unix()) + reposAddedHook.SetHost("github.com") + reposAddedHook.SetEvent(constants.EventInstallationRepositories) + reposAddedHook.SetEventAction(AppInstallRepositoriesAdded) + reposAddedHook.SetStatus(constants.StatusSuccess) + + reposRemovedHook := reposAddedHook + reposRemovedHook.SetEventAction(AppInstallRepositoriesRemoved) + + tests := []struct { + name string + file string + wantHook *api.Hook + wantInstall *internal.Installation + }{ + { + name: "installation_repositories repos added", + file: "testdata/hooks/installation_repositories_added.json", + wantHook: &reposAddedHook, + wantInstall: &internal.Installation{ + Action: AppInstallRepositoriesAdded, + ID: 1, + RepositoriesAdded: []string{"Hello-World", "Hello-World2"}, + Org: "Codertocat", + }, + }, + { + name: "installation_repositories repos removed", + file: "testdata/hooks/installation_repositories_removed.json", + wantHook: &reposRemovedHook, + wantInstall: &internal.Installation{ + Action: AppInstallRepositoriesRemoved, + ID: 1, + RepositoriesRemoved: []string{"Hello-World", "Hello-World2"}, + Org: "Codertocat", + }, + }, + } + + // setup router + s := httptest.NewServer(http.NotFoundHandler()) + defer s.Close() + + for _, tt := range tests { + // setup request + body, err := os.Open(tt.file) + if err != nil { + t.Errorf("unable to open file: %v", err) + } + + defer body.Close() + + request, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "/test", body) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("User-Agent", "GitHub-Hookshot/a22606a") + request.Header.Set("X-GitHub-Delivery", "7bd477e4-4415-11e9-9359-0d41fdf9567e") + request.Header.Set("X-GitHub-Hook-ID", "123456") + request.Header.Set("X-GitHub-Event", "installation_repositories") + + // setup client + client, _ := NewTest(s.URL) + + want := &internal.Webhook{ + Hook: tt.wantHook, + Installation: tt.wantInstall, + } + + got, err := client.ProcessWebhook(context.TODO(), request) + + if err != nil { + t.Errorf("ProcessWebhook returned err: %v", err) + } + + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("ProcessWebhook() mismatch (-want +got):\n%s", diff) + } + } +} diff --git a/scm/scm.go b/scm/scm.go index b93bde906..037a5d835 100644 --- a/scm/scm.go +++ b/scm/scm.go @@ -3,6 +3,7 @@ package scm import ( + "context" "fmt" "github.com/sirupsen/logrus" @@ -17,7 +18,7 @@ import ( // // * Github // . -func New(s *Setup) (Service, error) { +func New(ctx context.Context, s *Setup) (Service, error) { // validate the setup being provided // // https://pkg.go.dev/github.com/go-vela/server/scm?tab=doc#Setup.Validate @@ -33,12 +34,12 @@ func New(s *Setup) (Service, error) { // handle the Github scm driver being provided // // https://pkg.go.dev/github.com/go-vela/server/scm?tab=doc#Setup.Github - return s.Github() + return s.Github(ctx) case constants.DriverGitlab: // handle the Gitlab scm driver being provided // // https://pkg.go.dev/github.com/go-vela/server/scm?tab=doc#Setup.Gitlab - return s.Gitlab() + return s.Gitlab(ctx) default: // handle an invalid scm driver being provided return nil, fmt.Errorf("invalid scm driver provided: %s", s.Driver) diff --git a/scm/scm_test.go b/scm/scm_test.go index 233108758..974c85b45 100644 --- a/scm/scm_test.go +++ b/scm/scm_test.go @@ -3,6 +3,7 @@ package scm import ( + "context" "testing" ) @@ -23,7 +24,7 @@ func TestSCM_New(t *testing.T) { ServerWebhookAddress: "", StatusContext: "continuous-integration/vela", WebUIAddress: "https://vela.example.com", - Scopes: []string{"repo", "repo:status", "user:email", "read:user", "read:org"}, + OAuthScopes: []string{"repo", "repo:status", "user:email", "read:user", "read:org"}, }, }, { @@ -37,7 +38,7 @@ func TestSCM_New(t *testing.T) { ServerWebhookAddress: "", StatusContext: "continuous-integration/vela", WebUIAddress: "https://vela.example.com", - Scopes: []string{"repo", "repo:status", "user:email", "read:user", "read:org"}, + OAuthScopes: []string{"repo", "repo:status", "user:email", "read:user", "read:org"}, }, }, { @@ -51,7 +52,7 @@ func TestSCM_New(t *testing.T) { ServerWebhookAddress: "", StatusContext: "continuous-integration/vela", WebUIAddress: "https://vela.example.com", - Scopes: []string{"repo", "repo:status", "user:email", "read:user", "read:org"}, + OAuthScopes: []string{"repo", "repo:status", "user:email", "read:user", "read:org"}, }, }, { @@ -65,14 +66,14 @@ func TestSCM_New(t *testing.T) { ServerWebhookAddress: "", StatusContext: "continuous-integration/vela", WebUIAddress: "https://vela.example.com", - Scopes: []string{"repo", "repo:status", "user:email", "read:user", "read:org"}, + OAuthScopes: []string{"repo", "repo:status", "user:email", "read:user", "read:org"}, }, }, } // run tests for _, test := range tests { - _, err := New(test.setup) + _, err := New(context.Background(), test.setup) if test.failure { if err == nil { diff --git a/scm/service.go b/scm/service.go index bc917ce9a..7d1d12ec6 100644 --- a/scm/service.go +++ b/scm/service.go @@ -7,6 +7,8 @@ import ( "net/http" api "github.com/go-vela/server/api/types" + "github.com/go-vela/server/compiler/types/yaml" + "github.com/go-vela/server/database" "github.com/go-vela/server/internal" ) @@ -140,6 +142,12 @@ type Service interface { // GetHTMLURL defines a function that retrieves // a repository file's html_url. GetHTMLURL(context.Context, *api.User, string, string, string, string) (string, error) + // GetNetrcPassword defines a function that returns the netrc + // password injected into build steps. + GetNetrcPassword(context.Context, database.Interface, *api.Repo, *api.User, yaml.Git) (string, error) + // SyncRepoWithInstallation defines a function that syncs + // a repo with the installation, if it exists. + SyncRepoWithInstallation(context.Context, *api.Repo) (*api.Repo, error) // Webhook SCM Interface Functions @@ -153,5 +161,14 @@ type Service interface { // redelivers the webhook from the SCM. RedeliverWebhook(context.Context, *api.User, *api.Hook) error + // App Integration SCM Interface Functions + + // ProcessInstallation defines a function that + // processes an installation event. + ProcessInstallation(context.Context, *http.Request, *internal.Webhook, database.Interface) error + // FinishInstallation defines a function that + // finishes an installation event and returns a web redirect. + FinishInstallation(context.Context, *http.Request, int64) (string, error) + // TODO: Add convert functions to interface? } diff --git a/scm/setup.go b/scm/setup.go index dc3a3e698..44704fffd 100644 --- a/scm/setup.go +++ b/scm/setup.go @@ -3,6 +3,7 @@ package scm import ( + "context" "fmt" "strings" @@ -27,6 +28,14 @@ type Setup struct { ClientID string // specifies the OAuth client secret from the scm system to use for the scm client ClientSecret string + // specifies App integration id + AppID int64 + // specifies App integration private key + AppPrivateKey string + // specifies App integration path to private key + AppPrivateKeyPath string + // specifies App integration permissions set + AppPermissions []string // specifies the Vela server address to use for the scm client ServerAddress string // specifies the Vela server address that the scm provider should use to send Vela webhooks @@ -36,20 +45,21 @@ type Setup struct { // specifies the Vela web UI address to use for the scm client WebUIAddress string // specifies the OAuth scopes to use for the scm client - Scopes []string + OAuthScopes []string // specifies OTel tracing configurations Tracing *tracing.Client } // Github creates and returns a Vela service capable of // integrating with a Github scm system. -func (s *Setup) Github() (Service, error) { +func (s *Setup) Github(ctx context.Context) (Service, error) { logrus.Trace("creating github scm client from setup") // create new Github scm service // // https://pkg.go.dev/github.com/go-vela/server/scm/github?tab=doc#New return github.New( + ctx, github.WithAddress(s.Address), github.WithClientID(s.ClientID), github.WithClientSecret(s.ClientSecret), @@ -57,14 +67,18 @@ func (s *Setup) Github() (Service, error) { github.WithServerWebhookAddress(s.ServerWebhookAddress), github.WithStatusContext(s.StatusContext), github.WithWebUIAddress(s.WebUIAddress), - github.WithScopes(s.Scopes), + github.WithOAuthScopes(s.OAuthScopes), github.WithTracing(s.Tracing), + github.WithGithubAppID(s.AppID), + github.WithGithubPrivateKey(s.AppPrivateKey), + github.WithGithubPrivateKeyPath(s.AppPrivateKeyPath), + github.WithGitHubAppPermissions(s.AppPermissions), ) } // Gitlab creates and returns a Vela service capable of // integrating with a Gitlab scm system. -func (s *Setup) Gitlab() (Service, error) { +func (s *Setup) Gitlab(_ context.Context) (Service, error) { logrus.Trace("creating gitlab scm client from setup") return nil, fmt.Errorf("unsupported scm driver: %s", constants.DriverGitlab) @@ -110,7 +124,7 @@ func (s *Setup) Validate() error { return fmt.Errorf("no scm status context provided") } - if len(s.Scopes) == 0 { + if len(s.OAuthScopes) == 0 { return fmt.Errorf("no scm scopes provided") } diff --git a/scm/setup_test.go b/scm/setup_test.go index f3ad759a4..3cb381598 100644 --- a/scm/setup_test.go +++ b/scm/setup_test.go @@ -3,6 +3,7 @@ package scm import ( + "context" "reflect" "testing" ) @@ -18,10 +19,10 @@ func TestSCM_Setup_Github(t *testing.T) { ServerWebhookAddress: "", StatusContext: "continuous-integration/vela", WebUIAddress: "https://vela.example.com", - Scopes: []string{"repo", "repo:status", "user:email", "read:user", "read:org"}, + OAuthScopes: []string{"repo", "repo:status", "user:email", "read:user", "read:org"}, } - _github, err := _setup.Github() + _github, err := _setup.Github(context.Background()) if err != nil { t.Errorf("unable to setup scm: %v", err) } @@ -46,7 +47,7 @@ func TestSCM_Setup_Github(t *testing.T) { // run tests for _, test := range tests { - got, err := test.setup.Github() + got, err := test.setup.Github(context.Background()) if test.failure { if err == nil { @@ -80,7 +81,7 @@ func TestSCM_Setup_Gitlab(t *testing.T) { } // run test - got, err := _setup.Gitlab() + got, err := _setup.Gitlab(context.Background()) if err == nil { t.Errorf("Gitlab should have returned err") } @@ -107,7 +108,7 @@ func TestSCM_Setup_Validate(t *testing.T) { ServerWebhookAddress: "", StatusContext: "continuous-integration/vela", WebUIAddress: "https://vela.example.com", - Scopes: []string{"repo", "repo:status", "user:email", "read:user", "read:org"}, + OAuthScopes: []string{"repo", "repo:status", "user:email", "read:user", "read:org"}, }, }, { @@ -121,7 +122,7 @@ func TestSCM_Setup_Validate(t *testing.T) { ServerWebhookAddress: "", StatusContext: "continuous-integration/vela", WebUIAddress: "https://vela.example.com", - Scopes: []string{"repo", "repo:status", "user:email", "read:user", "read:org"}, + OAuthScopes: []string{"repo", "repo:status", "user:email", "read:user", "read:org"}, }, }, { @@ -135,7 +136,7 @@ func TestSCM_Setup_Validate(t *testing.T) { ServerWebhookAddress: "", StatusContext: "continuous-integration/vela", WebUIAddress: "https://vela.example.com", - Scopes: []string{"repo", "repo:status", "user:email", "read:user", "read:org"}, + OAuthScopes: []string{"repo", "repo:status", "user:email", "read:user", "read:org"}, }, }, { @@ -149,7 +150,7 @@ func TestSCM_Setup_Validate(t *testing.T) { ServerWebhookAddress: "", StatusContext: "continuous-integration/vela", WebUIAddress: "https://vela.example.com", - Scopes: []string{"repo", "repo:status", "user:email", "read:user", "read:org"}, + OAuthScopes: []string{"repo", "repo:status", "user:email", "read:user", "read:org"}, }, }, { @@ -163,7 +164,7 @@ func TestSCM_Setup_Validate(t *testing.T) { ServerWebhookAddress: "", StatusContext: "continuous-integration/vela", WebUIAddress: "https://vela.example.com", - Scopes: []string{"repo", "repo:status", "user:email", "read:user", "read:org"}, + OAuthScopes: []string{"repo", "repo:status", "user:email", "read:user", "read:org"}, }, }, { @@ -177,7 +178,7 @@ func TestSCM_Setup_Validate(t *testing.T) { ServerWebhookAddress: "", StatusContext: "continuous-integration/vela", WebUIAddress: "https://vela.example.com", - Scopes: []string{"repo", "repo:status", "user:email", "read:user", "read:org"}, + OAuthScopes: []string{"repo", "repo:status", "user:email", "read:user", "read:org"}, }, }, { @@ -191,7 +192,7 @@ func TestSCM_Setup_Validate(t *testing.T) { ServerWebhookAddress: "", StatusContext: "continuous-integration/vela", WebUIAddress: "https://vela.example.com", - Scopes: []string{"repo", "repo:status", "user:email", "read:user", "read:org"}, + OAuthScopes: []string{"repo", "repo:status", "user:email", "read:user", "read:org"}, }, }, { @@ -205,7 +206,7 @@ func TestSCM_Setup_Validate(t *testing.T) { ServerWebhookAddress: "", StatusContext: "continuous-integration/vela", WebUIAddress: "https://vela.example.com", - Scopes: []string{"repo", "repo:status", "user:email", "read:user", "read:org"}, + OAuthScopes: []string{"repo", "repo:status", "user:email", "read:user", "read:org"}, }, }, { @@ -219,7 +220,7 @@ func TestSCM_Setup_Validate(t *testing.T) { ServerWebhookAddress: "", StatusContext: "", WebUIAddress: "https://vela.example.com", - Scopes: []string{"repo", "repo:status", "user:email", "read:user", "read:org"}, + OAuthScopes: []string{"repo", "repo:status", "user:email", "read:user", "read:org"}, }, }, { @@ -233,7 +234,7 @@ func TestSCM_Setup_Validate(t *testing.T) { ServerWebhookAddress: "", StatusContext: "continuous-integration/vela", WebUIAddress: "https://vela.example.com", - Scopes: []string{}, + OAuthScopes: []string{}, }, }, }