woodpecker/server/remote/gitlab/gitlab.go
2021-09-23 18:25:51 +02:00

703 lines
18 KiB
Go

// Copyright 2018 Drone.IO Inc.
//
// 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 gitlab
import (
"crypto/tls"
"fmt"
"io/ioutil"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/woodpecker-ci/woodpecker/model"
"github.com/woodpecker-ci/woodpecker/server"
"github.com/woodpecker-ci/woodpecker/server/remote"
"github.com/woodpecker-ci/woodpecker/shared/oauth2"
"github.com/woodpecker-ci/woodpecker/server/remote/gitlab/client"
)
const DefaultScope = "api"
// Opts defines configuration options.
type Opts struct {
URL string // Gogs server url.
Client string // Oauth2 client id.
Secret string // Oauth2 client secret.
Username string // Optional machine account username.
Password string // Optional machine account password.
PrivateMode bool // Gogs is running in private mode.
SkipVerify bool // Skip ssl verification.
}
// New returns a Remote implementation that integrates with Gitlab, an open
// source Git service. See https://gitlab.com
func New(opts Opts) (remote.Remote, error) {
url, err := url.Parse(opts.URL)
if err != nil {
return nil, err
}
host, _, err := net.SplitHostPort(url.Host)
if err == nil {
url.Host = host
}
return &Gitlab{
URL: opts.URL,
Client: opts.Client,
Secret: opts.Secret,
Machine: url.Host,
Username: opts.Username,
Password: opts.Password,
PrivateMode: opts.PrivateMode,
SkipVerify: opts.SkipVerify,
}, nil
}
type Gitlab struct {
URL string
Client string
Secret string
Machine string
Username string
Password string
PrivateMode bool
SkipVerify bool
HideArchives bool
Search bool
}
func Load(config string) *Gitlab {
url_, err := url.Parse(config)
if err != nil {
panic(err)
}
params := url_.Query()
url_.RawQuery = ""
gitlab := Gitlab{}
gitlab.URL = url_.String()
gitlab.Client = params.Get("client_id")
gitlab.Secret = params.Get("client_secret")
// gitlab.AllowedOrgs = params["orgs"]
gitlab.SkipVerify, _ = strconv.ParseBool(params.Get("skip_verify"))
gitlab.HideArchives, _ = strconv.ParseBool(params.Get("hide_archives"))
// gitlab.Open, _ = strconv.ParseBool(params.Get("open"))
// switch params.Get("clone_mode") {
// case "oauth":
// gitlab.CloneMode = "oauth"
// default:
// gitlab.CloneMode = "token"
// }
// this is a temp workaround
gitlab.Search, _ = strconv.ParseBool(params.Get("search"))
return &gitlab
}
// Login authenticates the session and returns the
// remote user details.
func (g *Gitlab) Login(res http.ResponseWriter, req *http.Request) (*model.User, error) {
var config = &oauth2.Config{
ClientId: g.Client,
ClientSecret: g.Secret,
Scope: DefaultScope,
AuthURL: fmt.Sprintf("%s/oauth/authorize", g.URL),
TokenURL: fmt.Sprintf("%s/oauth/token", g.URL),
RedirectURL: fmt.Sprintf("%s/authorize", server.Config.Server.Host),
}
trans_ := &http.Transport{
Proxy: http.ProxyFromEnvironment,
TLSClientConfig: &tls.Config{InsecureSkipVerify: g.SkipVerify},
}
// get the OAuth errors
if err := req.FormValue("error"); err != "" {
return nil, &remote.AuthError{
Err: err,
Description: req.FormValue("error_description"),
URI: req.FormValue("error_uri"),
}
}
// get the OAuth code
var code = req.FormValue("code")
if len(code) == 0 {
http.Redirect(res, req, config.AuthCodeURL("drone"), http.StatusSeeOther)
return nil, nil
}
var trans = &oauth2.Transport{Config: config, Transport: trans_}
var token_, err = trans.Exchange(code)
if err != nil {
return nil, fmt.Errorf("Error exchanging token. %s", err)
}
client := NewClient(g.URL, token_.AccessToken, g.SkipVerify)
login, err := client.CurrentUser()
if err != nil {
return nil, err
}
// if len(g.AllowedOrgs) != 0 {
// groups, err := client.AllGroups()
// if err != nil {
// return nil, fmt.Errorf("Could not check org membership. %s", err)
// }
//
// var member bool
// for _, group := range groups {
// for _, allowedOrg := range g.AllowedOrgs {
// if group.Path == allowedOrg {
// member = true
// break
// }
// }
// }
//
// if !member {
// return nil, false, fmt.Errorf("User does not belong to correct group. Must belong to %v", g.AllowedOrgs)
// }
// }
user := &model.User{}
user.Login = login.Username
user.Email = login.Email
user.Token = token_.AccessToken
user.Secret = token_.RefreshToken
if strings.HasPrefix(login.AvatarUrl, "http") {
user.Avatar = login.AvatarUrl
} else {
user.Avatar = g.URL + "/" + login.AvatarUrl
}
return user, nil
}
func (g *Gitlab) Auth(token, secret string) (string, error) {
client := NewClient(g.URL, token, g.SkipVerify)
login, err := client.CurrentUser()
if err != nil {
return "", err
}
return login.Username, nil
}
func (g *Gitlab) Teams(u *model.User) ([]*model.Team, error) {
client := NewClient(g.URL, u.Token, g.SkipVerify)
groups, err := client.AllGroups()
if err != nil {
return nil, err
}
var teams []*model.Team
for _, group := range groups {
teams = append(teams, &model.Team{
Login: group.Name,
})
}
return teams, nil
}
// Repo fetches the named repository from the remote system.
func (g *Gitlab) Repo(u *model.User, owner, name string) (*model.Repo, error) {
client := NewClient(g.URL, u.Token, g.SkipVerify)
id, err := GetProjectId(g, client, owner, name)
if err != nil {
return nil, err
}
repo_, err := client.Project(id)
if err != nil {
return nil, err
}
repo := &model.Repo{}
repo.Owner = owner
repo.Name = name
repo.FullName = repo_.PathWithNamespace
repo.Link = repo_.Url
repo.Clone = repo_.HttpRepoUrl
repo.Branch = "master"
repo.Avatar = repo_.AvatarUrl
if len(repo.Avatar) != 0 && !strings.HasPrefix(repo.Avatar, "http") {
repo.Avatar = fmt.Sprintf("%s/%s", g.URL, repo.Avatar)
}
if repo_.DefaultBranch != "" {
repo.Branch = repo_.DefaultBranch
}
if g.PrivateMode {
repo.IsPrivate = true
} else {
repo.IsPrivate = !repo_.Public
}
return repo, err
}
// Repos fetches a list of repos from the remote system.
func (g *Gitlab) Repos(u *model.User) ([]*model.Repo, error) {
client := NewClient(g.URL, u.Token, g.SkipVerify)
var repos = []*model.Repo{}
all, err := client.AllProjects(g.HideArchives)
if err != nil {
return repos, err
}
for _, repo_ := range all {
var parts = strings.Split(repo_.PathWithNamespace, "/")
var owner = parts[0]
var name = parts[1]
repo := &model.Repo{}
repo.Owner = owner
repo.Name = name
repo.FullName = repo_.PathWithNamespace
repo.Link = repo_.Url
repo.Clone = repo_.HttpRepoUrl
repo.Branch = "master"
if repo_.DefaultBranch != "" {
repo.Branch = repo_.DefaultBranch
}
if g.PrivateMode {
repo.IsPrivate = true
} else {
repo.IsPrivate = !repo_.Public
}
repos = append(repos, repo)
}
return repos, err
}
// Perm fetches the named repository from the remote system.
func (g *Gitlab) Perm(u *model.User, owner, name string) (*model.Perm, error) {
client := NewClient(g.URL, u.Token, g.SkipVerify)
id, err := GetProjectId(g, client, owner, name)
if err != nil {
return nil, err
}
repo, err := client.Project(id)
if err != nil {
return nil, err
}
// repo owner is granted full access
if repo.Owner != nil && repo.Owner.Username == u.Login {
return &model.Perm{Push: true, Pull: true, Admin: true}, nil
}
// check permission for current user
m := &model.Perm{}
m.Admin = IsAdmin(repo)
m.Pull = IsRead(repo)
m.Push = IsWrite(repo)
return m, nil
}
// File fetches a file from the remote repository and returns in string format.
func (g *Gitlab) File(user *model.User, repo *model.Repo, build *model.Build, f string) ([]byte, error) {
var client = NewClient(g.URL, user.Token, g.SkipVerify)
id, err := GetProjectId(g, client, repo.Owner, repo.Name)
if err != nil {
return nil, err
}
out, err := client.RepoRawFileRef(id, build.Commit, f)
if err != nil {
return nil, err
}
return out, err
}
func (c *Gitlab) Dir(u *model.User, r *model.Repo, b *model.Build, f string) ([]*remote.FileMeta, error) {
return nil, fmt.Errorf("Not implemented")
}
// NOTE Currently gitlab doesn't support status for commits and events,
// also if we want get MR status in gitlab we need implement a special plugin for gitlab,
// gitlab uses API to fetch build status on client side. But for now we skip this.
func (g *Gitlab) Status(u *model.User, repo *model.Repo, b *model.Build, link string, proc *model.Proc) error {
client := NewClient(g.URL, u.Token, g.SkipVerify)
status := getStatus(b.Status)
desc := getDesc(b.Status)
client.SetStatus(
ns(repo.Owner, repo.Name),
b.Commit,
status,
desc,
strings.Replace(b.Ref, "refs/heads/", "", -1),
link,
)
// Gitlab statuses it's a new feature, just ignore error
// if gitlab version not support this
return nil
}
// Netrc returns a .netrc file that can be used to clone
// private repositories from a remote system.
// func (g *Gitlab) Netrc(u *model.User, r *model.Repo) (*model.Netrc, error) {
// url_, err := url.Parse(g.URL)
// if err != nil {
// return nil, err
// }
// netrc := &model.Netrc{}
// netrc.Machine = url_.Host
//
// switch g.CloneMode {
// case "oauth":
// netrc.Login = "oauth2"
// netrc.Password = u.Token
// case "token":
// t := token.New(token.HookToken, r.FullName)
// netrc.Login = "drone-ci-token"
// netrc.Password, err = t.Sign(r.Hash)
// }
// return netrc, err
// }
// Netrc returns a netrc file capable of authenticating Gitlab requests and
// cloning Gitlab repositories. The netrc will use the global machine account
// when configured.
func (g *Gitlab) Netrc(u *model.User, r *model.Repo) (*model.Netrc, error) {
if g.Password != "" {
return &model.Netrc{
Login: g.Username,
Password: g.Password,
Machine: g.Machine,
}, nil
}
return &model.Netrc{
Login: "oauth2",
Password: u.Token,
Machine: g.Machine,
}, nil
}
// Activate activates a repository by adding a Post-commit hook and
// a Public Deploy key, if applicable.
func (g *Gitlab) Activate(user *model.User, repo *model.Repo, link string) error {
var client = NewClient(g.URL, user.Token, g.SkipVerify)
id, err := GetProjectId(g, client, repo.Owner, repo.Name)
if err != nil {
return err
}
uri, err := url.Parse(link)
if err != nil {
return err
}
droneUrl := fmt.Sprintf("%s://%s", uri.Scheme, uri.Host)
droneToken := uri.Query().Get("access_token")
ssl_verify := strconv.FormatBool(!g.SkipVerify)
return client.AddDroneService(id, map[string]string{
"token": droneToken,
"drone_url": droneUrl,
"enable_ssl_verification": ssl_verify,
})
}
// Deactivate removes a repository by removing all the post-commit hooks
// which are equal to link and removing the SSH deploy key.
func (g *Gitlab) Deactivate(user *model.User, repo *model.Repo, link string) error {
var client = NewClient(g.URL, user.Token, g.SkipVerify)
id, err := GetProjectId(g, client, repo.Owner, repo.Name)
if err != nil {
return err
}
return client.DeleteDroneService(id)
}
// ParseHook parses the post-commit hook from the Request body
// and returns the required data in a standard format.
func (g *Gitlab) Hook(req *http.Request) (*model.Repo, *model.Build, error) {
defer req.Body.Close()
var payload, _ = ioutil.ReadAll(req.Body)
var parsed, err = client.ParseHook(payload)
if err != nil {
return nil, nil, err
}
switch parsed.ObjectKind {
case "merge_request":
return mergeRequest(parsed, req)
case "tag_push", "push":
return push(parsed, req)
default:
return nil, nil, nil
}
}
func mergeRequest(parsed *client.HookPayload, req *http.Request) (*model.Repo, *model.Build, error) {
repo := &model.Repo{}
obj := parsed.ObjectAttributes
if obj == nil {
return nil, nil, fmt.Errorf("object_attributes key expected in merge request hook")
}
target := obj.Target
source := obj.Source
if target == nil && source == nil {
return nil, nil, fmt.Errorf("target and source keys expected in merge request hook")
} else if target == nil {
return nil, nil, fmt.Errorf("target key expected in merge request hook")
} else if source == nil {
return nil, nil, fmt.Errorf("source key exptected in merge request hook")
}
if target.PathWithNamespace != "" {
var err error
if repo.Owner, repo.Name, err = ExtractFromPath(target.PathWithNamespace); err != nil {
return nil, nil, err
}
repo.FullName = target.PathWithNamespace
} else {
repo.Owner = req.FormValue("owner")
repo.Name = req.FormValue("name")
repo.FullName = fmt.Sprintf("%s/%s", repo.Owner, repo.Name)
}
repo.Link = target.WebUrl
if target.GitHttpUrl != "" {
repo.Clone = target.GitHttpUrl
} else {
repo.Clone = target.HttpUrl
}
if target.DefaultBranch != "" {
repo.Branch = target.DefaultBranch
} else {
repo.Branch = "master"
}
if target.AvatarUrl != "" {
repo.Avatar = target.AvatarUrl
}
build := &model.Build{}
build.Event = "pull_request"
lastCommit := obj.LastCommit
if lastCommit == nil {
return nil, nil, fmt.Errorf("last_commit key expected in merge request hook")
}
build.Message = lastCommit.Message
build.Commit = lastCommit.Id
//build.Remote = parsed.ObjectAttributes.Source.HttpUrl
build.Ref = fmt.Sprintf("refs/merge-requests/%d/head", obj.IId)
build.Branch = obj.SourceBranch
author := lastCommit.Author
if author == nil {
return nil, nil, fmt.Errorf("author key expected in merge request hook")
}
build.Author = author.Name
build.Email = author.Email
if len(build.Email) != 0 {
build.Avatar = GetUserAvatar(build.Email)
}
build.Title = obj.Title
build.Link = obj.Url
return repo, build, nil
}
func push(parsed *client.HookPayload, req *http.Request) (*model.Repo, *model.Build, error) {
repo := &model.Repo{}
// Since gitlab 8.5, used project instead repository key
// see https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/web_hooks/web_hooks.md#web-hooks
if project := parsed.Project; project != nil {
var err error
if repo.Owner, repo.Name, err = ExtractFromPath(project.PathWithNamespace); err != nil {
return nil, nil, err
}
repo.Avatar = project.AvatarUrl
repo.Link = project.WebUrl
repo.Clone = project.GitHttpUrl
repo.FullName = project.PathWithNamespace
repo.Branch = project.DefaultBranch
switch project.VisibilityLevel {
case 0:
repo.IsPrivate = true
case 10:
repo.IsPrivate = true
case 20:
repo.IsPrivate = false
}
} else if repository := parsed.Repository; repository != nil {
repo.Owner = req.FormValue("owner")
repo.Name = req.FormValue("name")
repo.Link = repository.URL
repo.Clone = repository.GitHttpUrl
repo.Branch = "master"
repo.FullName = fmt.Sprintf("%s/%s", req.FormValue("owner"), req.FormValue("name"))
switch repository.VisibilityLevel {
case 0:
repo.IsPrivate = true
case 10:
repo.IsPrivate = true
case 20:
repo.IsPrivate = false
}
} else {
return nil, nil, fmt.Errorf("No project/repository keys given")
}
build := &model.Build{}
build.Event = model.EventPush
build.Commit = parsed.After
build.Branch = parsed.Branch()
build.Ref = parsed.Ref
// hook.Commit.Remote = cloneUrl
var head = parsed.Head()
build.Message = head.Message
// build.Timestamp = head.Timestamp
// extracts the commit author (ideally email)
// from the post-commit hook
switch {
case head.Author != nil:
build.Email = head.Author.Email
build.Author = parsed.UserName
if len(build.Email) != 0 {
build.Avatar = GetUserAvatar(build.Email)
}
case head.Author == nil:
build.Author = parsed.UserName
}
if strings.HasPrefix(build.Ref, "refs/tags/") {
build.Event = model.EventTag
}
return repo, build, nil
}
// ¯\_(ツ)_/¯
func (g *Gitlab) Oauth2Transport(r *http.Request) *oauth2.Transport {
return &oauth2.Transport{
Config: &oauth2.Config{
ClientId: g.Client,
ClientSecret: g.Secret,
Scope: DefaultScope,
AuthURL: fmt.Sprintf("%s/oauth/authorize", g.URL),
TokenURL: fmt.Sprintf("%s/oauth/token", g.URL),
RedirectURL: fmt.Sprintf("%s/authorize", server.Config.Server.Host),
//settings.Server.Scheme, settings.Server.Hostname),
},
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
TLSClientConfig: &tls.Config{InsecureSkipVerify: g.SkipVerify},
},
}
}
const (
StatusPending = "pending"
StatusRunning = "running"
StatusSuccess = "success"
StatusFailure = "failed"
StatusCanceled = "canceled"
)
const (
DescPending = "the build is pending"
DescRunning = "the buils is running"
DescSuccess = "the build was successful"
DescFailure = "the build failed"
DescCanceled = "the build canceled"
DescBlocked = "the build is pending approval"
DescDeclined = "the build was rejected"
)
// getStatus is a helper functin that converts a Drone
// status to a GitHub status.
func getStatus(status string) string {
switch status {
case model.StatusPending, model.StatusBlocked:
return StatusPending
case model.StatusRunning:
return StatusRunning
case model.StatusSuccess:
return StatusSuccess
case model.StatusFailure, model.StatusError:
return StatusFailure
case model.StatusKilled:
return StatusCanceled
default:
return StatusFailure
}
}
// getDesc is a helper function that generates a description
// message for the build based on the status.
func getDesc(status string) string {
switch status {
case model.StatusPending:
return DescPending
case model.StatusRunning:
return DescRunning
case model.StatusSuccess:
return DescSuccess
case model.StatusFailure, model.StatusError:
return DescFailure
case model.StatusKilled:
return DescCanceled
case model.StatusBlocked:
return DescBlocked
case model.StatusDeclined:
return DescDeclined
default:
return DescFailure
}
}