agola/internal/services/gateway/webhook.go
Simone Gotti 4c30a5af1c runservice: handle jsonnet and json config files
Handle config files with name `config.jsonnet`, `config.json` and
`config.yml` and take the first from the repository in this order

For a jsonnet file execute it and use the generated output as the config
2019-04-16 15:00:37 +02:00

417 lines
14 KiB
Go

// Copyright 2019 Sorint.lab
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied
// See the License for the specific language governing permissions and
// limitations under the License.
package gateway
import (
"context"
"fmt"
"net/http"
"net/url"
"path"
"github.com/sorintlab/agola/internal/config"
gitsource "github.com/sorintlab/agola/internal/gitsources"
"github.com/sorintlab/agola/internal/gitsources/agolagit"
"github.com/sorintlab/agola/internal/runconfig"
csapi "github.com/sorintlab/agola/internal/services/configstore/api"
"github.com/sorintlab/agola/internal/services/gateway/common"
rsapi "github.com/sorintlab/agola/internal/services/runservice/scheduler/api"
rstypes "github.com/sorintlab/agola/internal/services/runservice/types"
"github.com/sorintlab/agola/internal/services/types"
"github.com/sorintlab/agola/internal/util"
"github.com/pkg/errors"
"go.uber.org/zap"
)
const (
defaultSSHPort = "22"
agolaDefaultConfigDir = ".agola"
agolaDefaultJsonnetConfigFile = "config.jsonnet"
agolaDefaultJsonConfigFile = "config.json"
agolaDefaultYamlConfigFile = "config.yml"
// List of runs annotations
AnnotationEventType = "event_type"
AnnotationRunType = "runtype"
AnnotationProjectID = "projectid"
AnnotationUserID = "userid"
// AnnotationVirtualBranch represent a "virtual branch": i.e a normal branch, a pr (with name pr-$prid), a tag (with name tag-tagname)
AnnotationVirtualBranch = "virtual_branch"
AnnotationCommitSHA = "commit_sha"
AnnotationRef = "ref"
AnnotationSender = "sender"
AnnotationMessage = "message"
AnnotationCommitLink = "commit_link"
AnnotationCompareLink = "compare_link"
AnnotationBranch = "branch"
AnnotationBranchLink = "branch_link"
AnnotationTag = "tag"
AnnotationTagLink = "tag_link"
AnnotationPullRequestID = "pull_request_id"
AnnotationPullRequestLink = "pull_request_link"
)
type GroupType string
const (
GroupTypeProject GroupType = "project"
GroupTypeUser GroupType = "user"
GroupTypeBranch GroupType = "branch"
GroupTypeTag GroupType = "tag"
GroupTypePullRequest GroupType = "pr"
)
func genAnnotationVirtualBranch(webhookData *types.WebhookData) string {
switch webhookData.Event {
case types.WebhookEventPush:
return "branch-" + webhookData.Branch
case types.WebhookEventTag:
return "tag-" + webhookData.Tag
case types.WebhookEventPullRequest:
return "pr-" + webhookData.PullRequestID
}
panic(fmt.Errorf("invalid webhook event type: %q", webhookData.Event))
}
func genGroup(baseGroupType GroupType, baseGroupID string, webhookData *types.WebhookData) string {
// we pathescape the branch name to handle branches with slashes and make the
// branch a single path entry
switch webhookData.Event {
case types.WebhookEventPush:
return path.Join("/", string(baseGroupType), baseGroupID, string(GroupTypeBranch), url.PathEscape(webhookData.Branch))
case types.WebhookEventTag:
return path.Join("/", string(baseGroupType), baseGroupID, string(GroupTypeTag), url.PathEscape(webhookData.Tag))
case types.WebhookEventPullRequest:
return path.Join("/", string(baseGroupType), baseGroupID, string(GroupTypePullRequest), url.PathEscape(webhookData.PullRequestID))
}
panic(fmt.Errorf("invalid webhook event type: %q", webhookData.Event))
}
type webhooksHandler struct {
log *zap.SugaredLogger
configstoreClient *csapi.Client
runserviceClient *rsapi.Client
apiExposedURL string
}
func (h *webhooksHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
code, userErr, err := h.handleWebhook(r)
if err != nil {
h.log.Errorf("err: %+v", err)
http.Error(w, userErr, code)
}
}
func (h *webhooksHandler) handleWebhook(r *http.Request) (int, string, error) {
ctx := r.Context()
projectID := r.URL.Query().Get("projectid")
userID := r.URL.Query().Get("userid")
if projectID == "" && userID == "" {
return http.StatusBadRequest, "", errors.Errorf("bad webhook url %q. Missing projectid or userid", r.URL)
}
isUserBuild := false
if projectID == "" {
isUserBuild = true
}
defer r.Body.Close()
var webhookData *types.WebhookData
var sshPrivKey string
var cloneURL string
var skipSSHHostKeyCheck bool
var runType types.RunType
variables := map[string]string{}
var gitSource gitsource.GitSource
if !isUserBuild {
project, _, err := h.configstoreClient.GetProject(ctx, projectID)
if err != nil {
return http.StatusBadRequest, "", errors.Wrapf(err, "failed to get project %s", projectID)
}
h.log.Infof("project: %s", util.Dump(project))
user, _, err := h.configstoreClient.GetUserByLinkedAccount(ctx, project.LinkedAccountID)
if err != nil {
return http.StatusInternalServerError, "", errors.Wrapf(err, "failed to get user by linked account %q", project.LinkedAccountID)
}
la := user.LinkedAccounts[project.LinkedAccountID]
h.log.Infof("la: %s", util.Dump(la))
if la == nil {
return http.StatusInternalServerError, "", errors.Errorf("linked account %q in user %q doesn't exist", project.LinkedAccountID, user.UserName)
}
rs, _, err := h.configstoreClient.GetRemoteSource(ctx, la.RemoteSourceID)
if err != nil {
return http.StatusInternalServerError, "", errors.Wrapf(err, "failed to get remote source %q", la.RemoteSourceID)
}
gitSource, err = common.GetGitSource(rs, la)
if err != nil {
return http.StatusInternalServerError, "", errors.Wrapf(err, "failed to create gitea client")
}
sshPrivKey = project.SSHPrivateKey
skipSSHHostKeyCheck = project.SkipSSHHostKeyCheck
runType = types.RunTypeProject
webhookData, err = gitSource.ParseWebhook(r)
if err != nil {
return http.StatusBadRequest, "", errors.Wrapf(err, "failed to parse webhook")
}
// skip nil webhook data
// TODO(sgotti) report the reason of the skip
if webhookData == nil {
h.log.Infof("skipping webhook")
return 0, "", nil
}
webhookData.ProjectID = projectID
cloneURL = webhookData.SSHURL
// get project variables
pvars, _, err := h.configstoreClient.GetProjectVariables(ctx, project.ID, true)
if err != nil {
return http.StatusInternalServerError, "", errors.Wrapf(err, "failed to get project variables")
}
h.log.Infof("pvars: %v", util.Dump(pvars))
// remove overriden variables
pvars = common.FilterOverridenVariables(pvars)
h.log.Infof("pvars: %v", util.Dump(pvars))
// get project secrets
secrets, _, err := h.configstoreClient.GetProjectSecrets(ctx, project.ID, true)
if err != nil {
return http.StatusInternalServerError, "", errors.Wrapf(err, "failed to get project secrets")
}
h.log.Infof("secrets: %v", util.Dump(secrets))
for _, pvar := range pvars {
// find the value match
var varval types.VariableValue
for _, varval = range pvar.Values {
h.log.Infof("varval: %v", util.Dump(varval))
match := types.MatchWhen(varval.When, webhookData.Branch, webhookData.Tag, webhookData.Ref)
if !match {
continue
}
// get the secret value referenced by the variable, it must be a secret at the same level or a lower level
secret := common.GetVarValueMatchingSecret(varval, pvar.Parent.Path, secrets)
h.log.Infof("secret: %v", util.Dump(secret))
if secret != nil {
varValue, ok := secret.Data[varval.SecretVar]
if ok {
variables[pvar.Name] = varValue
}
}
break
}
}
h.log.Infof("variables: %v", util.Dump(variables))
} else {
gitSource = agolagit.New(h.apiExposedURL + "/repos")
var err error
webhookData, err = gitSource.ParseWebhook(r)
if err != nil {
return http.StatusBadRequest, "", errors.Wrapf(err, "failed to parse webhook")
}
// skip nil webhook data
// TODO(sgotti) report the reason of the skip
if webhookData == nil {
h.log.Infof("skipping webhook")
return 0, "", nil
}
user, _, err := h.configstoreClient.GetUser(ctx, userID)
if err != nil {
return http.StatusBadRequest, "", errors.Wrapf(err, "failed to get user with id %q", userID)
}
h.log.Debugf("user: %s", util.Dump(user))
userID = user.ID
cloneURL = fmt.Sprintf("%s/%s", h.apiExposedURL+"/repos", webhookData.Repo.Path)
runType = types.RunTypeUser
}
h.log.Infof("webhookData: %s", util.Dump(webhookData))
data, filename, err := h.fetchConfigFiles(gitSource, webhookData)
if err != nil {
return http.StatusInternalServerError, "", errors.Wrapf(err, "failed to fetch config file")
}
h.log.Debug("data: %s", data)
gitURL, err := util.ParseGitURL(cloneURL)
if err != nil {
return http.StatusInternalServerError, "", errors.Wrapf(err, "failed to parse clone url")
}
gitHost := gitURL.Hostname()
gitPort := gitURL.Port()
if gitPort == "" {
gitPort = defaultSSHPort
}
// this env vars ovverrides other env vars
env := map[string]string{
"CI": "true",
"AGOLA_SSHPRIVKEY": sshPrivKey,
"AGOLA_REPOSITORY_URL": cloneURL,
"AGOLA_GIT_HOST": gitHost,
"AGOLA_GIT_PORT": gitPort,
"AGOLA_GIT_REF": webhookData.Ref,
"AGOLA_GIT_COMMITSHA": webhookData.CommitSHA,
}
if skipSSHHostKeyCheck {
env["AGOLA_SKIPSSHHOSTKEYCHECK"] = "1"
}
annotations := map[string]string{
AnnotationRunType: string(runType),
AnnotationEventType: string(webhookData.Event),
AnnotationVirtualBranch: genAnnotationVirtualBranch(webhookData),
AnnotationCommitSHA: webhookData.CommitSHA,
AnnotationRef: webhookData.Ref,
AnnotationSender: webhookData.Sender,
AnnotationMessage: webhookData.Message,
AnnotationCommitLink: webhookData.CommitLink,
AnnotationCompareLink: webhookData.CompareLink,
}
if !isUserBuild {
annotations[AnnotationProjectID] = webhookData.ProjectID
} else {
annotations[AnnotationUserID] = userID
}
if webhookData.Event == types.WebhookEventPush {
annotations[AnnotationBranch] = webhookData.Branch
annotations[AnnotationBranchLink] = webhookData.BranchLink
}
if webhookData.Event == types.WebhookEventTag {
annotations[AnnotationTag] = webhookData.Tag
annotations[AnnotationTagLink] = webhookData.TagLink
}
if webhookData.Event == types.WebhookEventPullRequest {
annotations[AnnotationPullRequestID] = webhookData.PullRequestID
annotations[AnnotationPullRequestLink] = webhookData.PullRequestLink
}
var group string
if !isUserBuild {
group = genGroup(GroupTypeProject, webhookData.ProjectID, webhookData)
} else {
group = genGroup(GroupTypeUser, userID, webhookData)
}
if err := h.createRuns(ctx, filename, data, group, annotations, env, variables, webhookData); err != nil {
return http.StatusInternalServerError, "", errors.Wrapf(err, "failed to create run")
}
//if err := gitSource.CreateStatus(webhookData.Repo.Owner, webhookData.Repo.Name, webhookData.CommitSHA, gitsource.CommitStatusPending, "localhost:8080", "build %s", "agola"); err != nil {
// h.log.Errorf("failed to update commit status: %v", err)
//}
return 0, "", nil
}
// fetchConfigFiles tries to fetch a config file in one of the supported formats. The precedence is for jsonnet, then json and then yml
// TODO(sgotti) For jsonnet, if we'll support custom import files inside the configdir, also fetch them.
func (h *webhooksHandler) fetchConfigFiles(gitSource gitsource.GitSource, webhookData *types.WebhookData) ([]byte, string, error) {
var data []byte
var filename string
err := util.ExponentialBackoff(util.FetchFileBackoff, func() (bool, error) {
for _, filename = range []string{agolaDefaultJsonnetConfigFile, agolaDefaultJsonConfigFile, agolaDefaultYamlConfigFile} {
var err error
data, err = gitSource.GetFile(webhookData.Repo.Path, webhookData.CommitSHA, path.Join(agolaDefaultConfigDir, filename))
if err == nil {
return true, nil
}
h.log.Errorf("get file err: %v", err)
}
return false, nil
})
if err != nil {
return nil, "", err
}
return data, filename, nil
}
func (h *webhooksHandler) createRuns(ctx context.Context, filename string, configData []byte, group string, annotations, staticEnv, variables map[string]string, webhookData *types.WebhookData) error {
setupErrors := []string{}
var configFormat config.ConfigFormat
switch path.Ext(filename) {
case ".jsonnet":
configFormat = config.ConfigFormatJsonnet
case ".json":
fallthrough
case ".yml":
configFormat = config.ConfigFormatJSON
}
config, err := config.ParseConfig([]byte(configData), configFormat)
if err != nil {
log.Errorf("failed to parse config: %+v", err)
// create a run (per config file) with a generic error since we cannot parse
// it and know how many runs are defined
setupErrors = append(setupErrors, err.Error())
createRunReq := &rsapi.RunCreateRequest{
RunConfigTasks: nil,
Group: group,
SetupErrors: setupErrors,
Name: rstypes.RunGenericSetupErrorName,
StaticEnvironment: staticEnv,
Annotations: annotations,
}
if _, err := h.runserviceClient.CreateRun(ctx, createRunReq); err != nil {
log.Errorf("failed to create run: %+v", err)
return err
}
return nil
}
for _, run := range config.Runs {
rcts := runconfig.GenRunConfigTasks(util.DefaultUUIDGenerator{}, config, run.Name, variables, webhookData.Branch, webhookData.Tag, webhookData.Ref)
h.log.Debugf("rcts: %s", util.Dump(rcts))
h.log.Infof("group: %s", group)
createRunReq := &rsapi.RunCreateRequest{
RunConfigTasks: rcts,
Group: group,
SetupErrors: setupErrors,
Name: run.Name,
StaticEnvironment: staticEnv,
Annotations: annotations,
}
if _, err := h.runserviceClient.CreateRun(ctx, createRunReq); err != nil {
log.Errorf("failed to create run: %+v", err)
return err
}
}
return nil
}