diff --git a/api/build/plan.go b/api/build/plan.go index fcdcd2dff..e8ed0b643 100644 --- a/api/build/plan.go +++ b/api/build/plan.go @@ -57,7 +57,7 @@ func PlanBuild(ctx context.Context, database database.Interface, scm scm.Service } // plan all steps for the build - steps, err := step.PlanSteps(ctx, database, scm, p, b) + steps, err := step.PlanSteps(ctx, database, scm, p, b, r) if err != nil { // clean up the objects from the pipeline in the database CleanBuild(ctx, database, b, services, steps, err) diff --git a/api/step/plan.go b/api/step/plan.go index cfb55652e..02f5546f0 100644 --- a/api/step/plan.go +++ b/api/step/plan.go @@ -20,7 +20,7 @@ import ( // PlanSteps is a helper function to plan all steps // in the build for execution. This creates the steps // for the build. -func PlanSteps(ctx context.Context, database database.Interface, scm scm.Service, p *pipeline.Build, b *types.Build) ([]*library.Step, error) { +func PlanSteps(ctx context.Context, database database.Interface, scm scm.Service, p *pipeline.Build, b *types.Build, r *types.Repo) ([]*library.Step, error) { // variable to store planned steps steps := []*library.Step{} @@ -29,7 +29,7 @@ func PlanSteps(ctx context.Context, database database.Interface, scm scm.Service // iterate through all steps for each pipeline stage for _, step := range stage.Steps { // create the step object - s, err := planStep(ctx, database, scm, b, step, stage.Name) + s, err := planStep(ctx, database, scm, b, r, step, stage.Name) if err != nil { return steps, err } @@ -40,7 +40,7 @@ func PlanSteps(ctx context.Context, database database.Interface, scm scm.Service // iterate through all pipeline steps for _, step := range p.Steps { - s, err := planStep(ctx, database, scm, b, step, "") + s, err := planStep(ctx, database, scm, b, r, step, "") if err != nil { return steps, err } @@ -51,7 +51,7 @@ func PlanSteps(ctx context.Context, database database.Interface, scm scm.Service return steps, nil } -func planStep(ctx context.Context, database database.Interface, scm scm.Service, b *types.Build, c *pipeline.Container, stage string) (*library.Step, error) { +func planStep(ctx context.Context, database database.Interface, scm scm.Service, b *types.Build, r *types.Repo, c *pipeline.Container, stage string) (*library.Step, error) { // create the step object s := new(library.Step) s.SetBuildID(b.GetID()) @@ -64,8 +64,17 @@ func planStep(ctx context.Context, database database.Interface, scm scm.Service, s.SetReportAs(c.ReportAs) s.SetCreated(time.Now().UTC().Unix()) + id, err := scm.CreateChecks(ctx, r, b.GetCommit(), s.GetName()) + if err != nil { + // TODO: make this error more meaningful + return nil, err + } + + // TODO: have to store the check ID somewhere + s.SetCheckID(id) + // send API call to create the step - s, err := database.CreateStep(ctx, s) + s, err = database.CreateStep(ctx, s) if err != nil { return nil, fmt.Errorf("unable to create step %s: %w", s.GetName(), err) } diff --git a/api/step/update.go b/api/step/update.go index c543de2c8..dca493625 100644 --- a/api/step/update.go +++ b/api/step/update.go @@ -154,6 +154,15 @@ func UpdateStep(c *gin.Context) { return } + err = scm.FromContext(c).UpdateChecks(ctx, r, s, b.GetCommit()) + if err != nil { + retErr := fmt.Errorf("unable to set step check %s: %w", entry, err) + + util.HandleError(c, http.StatusInternalServerError, retErr) + + return + } + c.JSON(http.StatusOK, s) // check if the build is in a "final" state diff --git a/cmd/vela-server/scm.go b/cmd/vela-server/scm.go index c8c441442..46115248f 100644 --- a/cmd/vela-server/scm.go +++ b/cmd/vela-server/scm.go @@ -24,6 +24,8 @@ func setupSCM(c *cli.Context) (scm.Service, error) { StatusContext: c.String("scm.context"), WebUIAddress: c.String("webui-addr"), Scopes: c.StringSlice("scm.scopes"), + GithubAppID: c.Int64("scm.app.id"), + GithubAppPrivateKey: c.String("scm.app.private_key"), } // setup the scm diff --git a/go.mod b/go.mod index cb3eb31f1..01e8223bf 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,8 @@ go 1.22.0 toolchain go1.22.4 +replace github.com/go-vela/types => ../types + require ( github.com/Bose/minisentinel v0.0.0-20200130220412-917c5a9223bb github.com/DATA-DOG/go-sqlmock v1.5.2 @@ -12,6 +14,7 @@ require ( github.com/adhocore/gronx v1.8.1 github.com/alicebob/miniredis/v2 v2.33.0 github.com/aws/aws-sdk-go v1.54.1 + github.com/bradleyfalzon/ghinstallation/v2 v2.11.0 github.com/buildkite/yaml v0.0.0-20181016232759-0caa5f0796e3 github.com/distribution/reference v0.6.0 github.com/drone/envsubst v1.0.3 @@ -73,6 +76,7 @@ require ( github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang-jwt/jwt/v4 v4.5.0 // indirect github.com/gomodule/redigo v2.0.0+incompatible // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/gofuzz v1.2.0 // indirect diff --git a/go.sum b/go.sum index df09c67b6..e0c966146 100644 --- a/go.sum +++ b/go.sum @@ -31,6 +31,8 @@ github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd3 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/bradleyfalzon/ghinstallation/v2 v2.11.0 h1:R9d0v+iobRHSaE4wKUnXFiZp53AL4ED5MzgEMwGTZag= +github.com/bradleyfalzon/ghinstallation/v2 v2.11.0/go.mod h1:0LWKQwOHewXO/1acI6TtyE0Xc4ObDb2rFN7eHBAG71M= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -91,12 +93,12 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/go-test/deep v1.0.2 h1:onZX1rnHT3Wv6cqNgYyFOOlgVKJrksuCMCRvJStbMYw= github.com/go-test/deep v1.0.2/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= -github.com/go-vela/types v0.24.0-rc2 h1:sdOtUL+pBjgD+4hFDrHN/8ZFXSICTnl+eYcRP2IZ0Wk= -github.com/go-vela/types v0.24.0-rc2/go.mod h1:YWj6BIapl9Kbj4yHq/fp8jltXdGiwD/gTy1ez32Rzag= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= +github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/gomodule/redigo v1.7.1-0.20190322064113-39e2c31b7ca3/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4= diff --git a/scm/flags.go b/scm/flags.go index 2a64f7e14..fecb944ce 100644 --- a/scm/flags.go +++ b/scm/flags.go @@ -67,4 +67,16 @@ 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: "(optional & experimental) ID for the 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: "(optional & experimental) path to private key for the GitHub App", + }, } diff --git a/scm/github/github.go b/scm/github/github.go index 30e2d2fc3..e33cb5711 100644 --- a/scm/github/github.go +++ b/scm/github/github.go @@ -5,8 +5,12 @@ package github import ( "context" "fmt" + "net/http" "net/url" + "strings" + "github.com/bradleyfalzon/ghinstallation/v2" + api "github.com/go-vela/server/api/types" "github.com/google/go-github/v62/github" "github.com/sirupsen/logrus" "golang.org/x/oauth2" @@ -44,12 +48,16 @@ type config struct { WebUIAddress string // specifies the OAuth scopes to use for the GitHub client Scopes []string + // optional and experimental + GithubAppID int64 + GithubAppPrivateKey string } type client struct { - config *config - OAuth *oauth2.Config - AuthReq *github.AuthorizationRequest + config *config + OAuth *oauth2.Config + AuthReq *github.AuthorizationRequest + AppsTransport *ghinstallation.AppsTransport // https://pkg.go.dev/github.com/sirupsen/logrus#Entry Logger *logrus.Entry } @@ -108,6 +116,17 @@ func New(opts ...ClientOpt) (*client, error) { Scopes: githubScopes, } + if c.config.GithubAppID != 0 && len(c.config.GithubAppPrivateKey) > 0 { + c.Logger.Infof("sourcing private key from path: %s", c.config.GithubAppPrivateKey) + transport, err := ghinstallation.NewAppsTransportKeyFromFile(http.DefaultTransport, c.config.GithubAppID, c.config.GithubAppPrivateKey) + if err != nil { + return nil, err + } + + transport.BaseURL = c.config.API + c.AppsTransport = transport + } + return c, nil } @@ -163,3 +182,40 @@ func (c *client) newClientToken(token string) *github.Client { return github } + +// helper function to return the GitHub App token. +func (c *client) newGithubAppToken(r *api.Repo) *github.Client { + // 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 { + panic(err) + } + + // list all installations (a.k.a. orgs) where the GitHub App is installed + installations, _, err := client.Apps.ListInstallations(context.Background(), &github.ListOptions{}) + if err != nil { + panic(err) + } + + var id int64 + // 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()) { + id = install.GetID() + } + } + + // failsafe in case the repo does not belong to an org where the GitHub App is installed + if id == 0 { + panic(err) + } + + // create installation token for the repo + t, _, err := client.Apps.CreateInstallationToken(context.Background(), id, &github.InstallationTokenOptions{}) + if err != nil { + panic(err) + } + + return c.newClientToken(t.GetToken()) +} diff --git a/scm/github/opts.go b/scm/github/opts.go index df6d46506..4623ce279 100644 --- a/scm/github/opts.go +++ b/scm/github/opts.go @@ -149,3 +149,27 @@ func WithScopes(scopes []string) 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.GithubAppID = 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.GithubAppPrivateKey = key + + return nil + } +} diff --git a/scm/github/repo.go b/scm/github/repo.go index 8c0a58457..c80968390 100644 --- a/scm/github/repo.go +++ b/scm/github/repo.go @@ -660,3 +660,75 @@ func (c *client) GetBranch(ctx context.Context, r *api.Repo, branch string) (str return data.GetName(), data.GetCommit().GetSHA(), nil } + +// CreateChecks defines a function that does stuff... +func (c *client) CreateChecks(ctx context.Context, r *api.Repo, commit, step string) (int64, error) { + // create client from GitHub App + client := c.newGithubAppToken(r) + + opts := github.CreateCheckRunOptions{ + Name: fmt.Sprintf("vela-%s-%s", commit, step), + HeadSHA: commit, + } + + check, _, err := client.Checks.CreateCheckRun(ctx, r.GetOrg(), r.GetName(), opts) + if err != nil { + return 0, err + } + + return check.GetID(), nil +} + +// UpdateChecks defines a function that does stuff... +func (c *client) UpdateChecks(ctx context.Context, r *api.Repo, s *library.Step, commit string) error { + // create client from GitHub App + client := c.newGithubAppToken(r) + + var ( + conclusion string + status string + ) + // set the conclusion and status for the step check depending on what the status of the step is + switch s.GetStatus() { + case constants.StatusPending: + conclusion = "neutral" + status = "queued" + case constants.StatusPendingApproval: + conclusion = "action_required" + status = "queued" + case constants.StatusRunning: + conclusion = "neutral" + status = "in_progress" + case constants.StatusSuccess: + conclusion = "success" + status = "completed" + case constants.StatusFailure: + conclusion = "failure" + status = "completed" + case constants.StatusCanceled: + conclusion = "cancelled" + status = "completed" + case constants.StatusKilled: + conclusion = "cancelled" + status = "completed" + case constants.StatusSkipped: + conclusion = "skipped" + status = "completed" + default: + conclusion = "neutral" + status = "completed" + } + + opts := github.UpdateCheckRunOptions{ + Name: fmt.Sprintf("vela-%s-%s", commit, s.GetName()), + Conclusion: github.String(conclusion), + Status: github.String(status), + } + + _, _, err := client.Checks.UpdateCheckRun(ctx, r.GetOrg(), r.GetName(), s.GetCheckID(), opts) + if err != nil { + return err + } + + return nil +} diff --git a/scm/service.go b/scm/service.go index 88bb5ecf3..08382eb98 100644 --- a/scm/service.go +++ b/scm/service.go @@ -142,6 +142,10 @@ type Service interface { // a repository file's html_url. GetHTMLURL(context.Context, *api.User, string, string, string, string) (string, error) + // TODO: add comments + CreateChecks(context.Context, *api.Repo, string, string) (int64, error) + UpdateChecks(context.Context, *api.Repo, *library.Step, string) error + // Webhook SCM Interface Functions // ProcessWebhook defines a function that diff --git a/scm/setup.go b/scm/setup.go index 3b4082f7f..3af48f8c5 100644 --- a/scm/setup.go +++ b/scm/setup.go @@ -36,6 +36,9 @@ type Setup struct { WebUIAddress string // specifies the OAuth scopes to use for the scm client Scopes []string + // optional and experimental + GithubAppID int64 + GithubAppPrivateKey string } // Github creates and returns a Vela service capable of @@ -55,6 +58,8 @@ func (s *Setup) Github() (Service, error) { github.WithStatusContext(s.StatusContext), github.WithWebUIAddress(s.WebUIAddress), github.WithScopes(s.Scopes), + github.WithGithubAppID(s.GithubAppID), + github.WithGithubPrivateKey(s.GithubAppPrivateKey), ) }