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..bfacc5136 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,6 +64,16 @@ func planStep(ctx context.Context, database database.Interface, scm scm.Service, s.SetReportAs(c.ReportAs) s.SetCreated(time.Now().UTC().Unix()) + if c.ReportStatus { + id, err := scm.CreateChecks(ctx, r, b.GetCommit(), s.GetName(), b.GetEvent()) + if err != nil { + // TODO: make this error more meaningful + return nil, err + } + + s.SetCheckID(id) + } + // send API call to create the step s, err := database.CreateStep(ctx, s) if err != nil { diff --git a/api/step/update.go b/api/step/update.go index c543de2c8..7d3ebcddd 100644 --- a/api/step/update.go +++ b/api/step/update.go @@ -154,6 +154,19 @@ func UpdateStep(c *gin.Context) { return } + if s.GetCheckID() != 0 { + s.SetReport(input.GetReport()) + + err = scm.FromContext(c).UpdateChecks(ctx, r, s, b.GetCommit(), b.GetEvent()) + 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/api/types/repo.go b/api/types/repo.go index bfad93623..01e9f03ea 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(`{ diff --git a/cmd/vela-server/scm.go b/cmd/vela-server/scm.go index 7124761f0..9edc5fcd6 100644 --- a/cmd/vela-server/scm.go +++ b/cmd/vela-server/scm.go @@ -26,6 +26,8 @@ func setupSCM(c *cli.Context, tc *tracing.Client) (scm.Service, error) { WebUIAddress: c.String("webui-addr"), Scopes: c.StringSlice("scm.scopes"), Tracing: tc, + GithubAppID: c.Int64("scm.app.id"), + GithubAppPrivateKey: c.String("scm.app.private_key"), } // setup the scm diff --git a/database/repo/table.go b/database/repo/table.go index 0b16bae94..0db3c61c1 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/step/table.go b/database/step/table.go index c7560326d..b76733896 100644 --- a/database/step/table.go +++ b/database/step/table.go @@ -30,6 +30,7 @@ steps ( host VARCHAR(250), runtime VARCHAR(250), distribution VARCHAR(250), + check_id INTEGER, report_as VARCHAR(250), UNIQUE(build_id, number) ); @@ -56,6 +57,7 @@ steps ( host TEXT, runtime TEXT, distribution TEXT, + check_id INTEGER, report_as TEXT, UNIQUE(build_id, number) ); diff --git a/go.mod b/go.mod index 53fc2e4b1..dc23e3cf3 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module github.com/go-vela/server go 1.23.1 +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 @@ -10,6 +12,7 @@ require ( github.com/adhocore/gronx v1.19.0 github.com/alicebob/miniredis/v2 v2.33.0 github.com/aws/aws-sdk-go v1.55.5 + github.com/bradleyfalzon/ghinstallation/v2 v2.11.0 github.com/distribution/reference v0.6.0 github.com/drone/envsubst v1.0.3 github.com/ghodss/yaml v1.0.0 @@ -18,6 +21,7 @@ require ( github.com/go-vela/types v0.25.0-rc1 github.com/golang-jwt/jwt/v5 v5.2.1 github.com/google/go-cmp v0.6.0 + github.com/google/go-github/v62 v62.0.0 github.com/google/go-github/v65 v65.0.0 github.com/google/uuid v1.6.0 github.com/goware/urlx v0.3.2 @@ -85,6 +89,7 @@ require ( github.com/go-playground/validator/v10 v10.22.1 // indirect github.com/goccy/go-json v0.10.3 // 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 4f741e0d4..b0369b357 100644 --- a/go.sum +++ b/go.sum @@ -32,6 +32,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= @@ -100,12 +102,12 @@ github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27 github.com/go-playground/validator/v10 v10.22.1/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.25.0-rc1 h1:5pCV4pVt1bm6YYUdkNglRDa3PcFX3qGtf5rrmkUvdOc= -github.com/go-vela/types v0.25.0-rc1/go.mod h1:fLv2pbzIy6puAV6Cgh5ixUcchTUHT4D3xX05zIhkA9I= github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 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= @@ -115,6 +117,8 @@ github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5a github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-github/v62 v62.0.0 h1:/6mGCaRywZz9MuHyw9gD1CwsbmBX8GWsbFkwMmHdhl4= +github.com/google/go-github/v62 v62.0.0/go.mod h1:EMxeUqGJq2xRu9DYBMwel/mr7kZrzUOfQmmpYrZn2a4= github.com/google/go-github/v65 v65.0.0 h1:pQ7BmO3DZivvFk92geC0jB0q2m3gyn8vnYPgV7GSLhQ= github.com/google/go-github/v65 v65.0.0/go.mod h1:DvrqWo5hvsdhJvHd4WyVF9ttANN3BniqjP8uTFMNb60= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 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 dfbffe53a..af7f6f782 100644 --- a/scm/github/github.go +++ b/scm/github/github.go @@ -4,10 +4,18 @@ package github import ( "context" + "crypto/x509" + "encoding/base64" + "encoding/pem" "fmt" + "net/http" "net/http/httptrace" "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/google/go-github/v65/github" "github.com/sirupsen/logrus" "go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace" @@ -49,13 +57,17 @@ 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 - Tracing *tracing.Client + config *config + OAuth *oauth2.Config + AuthReq *github.AuthorizationRequest + Tracing *tracing.Client + AppsTransport *ghinstallation.AppsTransport // https://pkg.go.dev/github.com/sirupsen/logrus#Entry Logger *logrus.Entry } @@ -114,6 +126,30 @@ 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) + + decodedPEM, err := base64.StdEncoding.DecodeString(c.config.GithubAppPrivateKey) + if err != nil { + return nil, fmt.Errorf("error decoding base64: %w", err) + } + + block, _ := pem.Decode(decodedPEM) + if block == nil { + return nil, fmt.Errorf("failed to parse PEM block containing the key") + } + + privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse RSA private key: %w", err) + } + + transport := ghinstallation.NewAppsTransportFromPrivateKey(http.DefaultTransport, c.config.GithubAppID, privateKey) + + transport.BaseURL = c.config.API + c.AppsTransport = transport + } + return c, nil } @@ -179,3 +215,51 @@ func (c *client) newClientToken(ctx context.Context, token string) *github.Clien return github } + +// helper function to return the GitHub App token. +func (c *client) newGithubAppToken(r *api.Repo) (*github.Client, error) { + // 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 + } + + // if repo has an install ID, use it to create an installation token + if r.GetInstallID() != 0 { + // create installation token for the repo + t, _, err := client.Apps.CreateInstallationToken(context.Background(), r.GetInstallID(), &github.InstallationTokenOptions{}) + if err != nil { + panic(err) + } + + return c.newClientToken(t.GetToken()), nil + } + + // 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 { + return nil, 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 { + return nil, 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()), nil +} diff --git a/scm/github/opts.go b/scm/github/opts.go index bb7385827..7856a681e 100644 --- a/scm/github/opts.go +++ b/scm/github/opts.go @@ -160,3 +160,27 @@ 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.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 f608b5199..c253aea0d 100644 --- a/scm/github/repo.go +++ b/scm/github/repo.go @@ -667,3 +667,109 @@ 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, event string) (int64, error) { + // create client from GitHub App + client, err := c.newGithubAppToken(r) + if err != nil { + return 0, err + } + + opts := github.CreateCheckRunOptions{ + Name: fmt.Sprintf("vela-%s-%s", event, 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, event string) error { + // create client from GitHub App + client, err := c.newGithubAppToken(r) + if err != nil { + return err + } + + 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" + } + + var annotations []*github.CheckRunAnnotation + + for _, reportAnnotation := range s.GetReport().GetAnnotations() { + annotation := &github.CheckRunAnnotation{ + Path: github.String(reportAnnotation.GetPath()), + StartLine: github.Int(reportAnnotation.GetStartLine()), + EndLine: github.Int(reportAnnotation.GetEndLine()), + StartColumn: github.Int(reportAnnotation.GetStartColumn()), + EndColumn: github.Int(reportAnnotation.GetEndColumn()), + AnnotationLevel: github.String(reportAnnotation.GetAnnotationLevel()), + Message: github.String(reportAnnotation.GetMessage()), + Title: github.String(reportAnnotation.GetTitle()), + RawDetails: github.String(reportAnnotation.GetRawDetails()), + } + + annotations = append(annotations, annotation) + } + + output := &github.CheckRunOutput{ + Title: github.String(s.GetReport().GetTitle()), + Summary: github.String(s.GetReport().GetSummary()), + Text: github.String(s.GetReport().GetText()), + AnnotationsCount: github.Int(s.GetReport().GetAnnotationsCount()), + AnnotationsURL: github.String(s.GetReport().GetAnnotationsURL()), + Annotations: annotations, + } + + opts := github.UpdateCheckRunOptions{ + Name: fmt.Sprintf("vela-%s-%s", event, s.GetName()), + Conclusion: github.String(conclusion), + Status: github.String(status), + Output: output, + } + + _, _, 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 697bcf8ef..8472b9e85 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, string) (int64, error) + UpdateChecks(context.Context, *api.Repo, *library.Step, string, string) error + // Webhook SCM Interface Functions // ProcessWebhook defines a function that diff --git a/scm/setup.go b/scm/setup.go index c32a0cf66..98a672289 100644 --- a/scm/setup.go +++ b/scm/setup.go @@ -39,6 +39,9 @@ type Setup struct { Scopes []string // specifies OTel tracing configurations Tracing *tracing.Client + // specifies GitHub App installation configurations + GithubAppID int64 + GithubAppPrivateKey string } // Github creates and returns a Vela service capable of @@ -59,6 +62,8 @@ func (s *Setup) Github() (Service, error) { github.WithWebUIAddress(s.WebUIAddress), github.WithScopes(s.Scopes), github.WithTracing(s.Tracing), + github.WithGithubAppID(s.GithubAppID), + github.WithGithubPrivateKey(s.GithubAppPrivateKey), ) }