func/pkg/pipelines/tekton/gitlab_test.go

599 lines
15 KiB
Go

//go:build integration
// +build integration
package tekton_test
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"net/http"
"net/http/cookiejar"
"net/url"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/xanzy/go-gitlab"
"golang.org/x/crypto/ssh"
"golang.org/x/net/html"
pipelinev1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/uuid"
"knative.dev/pkg/apis"
"knative.dev/func/pkg/builders/buildpacks"
"knative.dev/func/pkg/docker"
fn "knative.dev/func/pkg/functions"
"knative.dev/func/pkg/k8s"
"knative.dev/func/pkg/pipelines"
"knative.dev/func/pkg/pipelines/tekton"
"knative.dev/func/pkg/random"
)
func TestGitlab(t *testing.T) {
var err error
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
gitlabHostname, gitlabRootPassword, pacCtrHostname, err := parseEnv(t)
if err != nil {
t.Fatal(err)
}
glabEnv := setupGitlabEnv(ctx, t, "http://"+gitlabHostname, "root", gitlabRootPassword)
tempHome := t.TempDir()
projDir := filepath.Join(t.TempDir(), "fn")
err = os.MkdirAll(projDir, 0755)
if err != nil {
t.Fatal(err)
}
ns := usingNamespace(t)
t.Logf("testing in namespace: %q", ns)
funcImg := fmt.Sprintf("ttl.sh/func/fn-%s:5m", uuid.NewUUID())
f := fn.Function{
Root: projDir,
Name: glabEnv.ProjectName,
Runtime: "test-runtime",
Template: "test-template",
Image: funcImg,
Created: time.Now(),
Invoke: "none",
Build: fn.BuildSpec{
Git: fn.Git{
URL: strings.TrimSuffix(glabEnv.HTTPProjectURL, ".git"),
Revision: "devel",
},
BuilderImages: map[string]string{"pack": buildpacks.DefaultTinyBuilder},
Builder: "pack",
PVCSize: "256Mi",
},
Deploy: fn.DeploySpec{
Remote: true,
Namespace: ns,
},
}
f = fn.NewFunctionWith(f)
err = f.Write()
if err != nil {
t.Fatal(err)
}
err = os.WriteFile(filepath.Join(projDir, "Procfile"), []byte("web: non-existent-app\n"), 0644)
if err != nil {
t.Fatal(err)
}
credentialsProvider := func(ctx context.Context, image string) (docker.Credentials, error) {
return docker.Credentials{
Username: "",
Password: "",
}, nil
}
pp := tekton.NewPipelinesProvider(
tekton.WithCredentialsProvider(credentialsProvider),
tekton.WithNamespace(ns),
tekton.WithPacURLCallback(func() (string, error) {
return "http://" + pacCtrHostname, nil
}))
metadata := pipelines.PacMetadata{
PersonalAccessToken: glabEnv.UserToken,
ConfigureLocalResources: true,
ConfigureClusterResources: true,
ConfigureRemoteResources: true,
}
err = pp.ConfigurePAC(context.Background(), f, metadata)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = pp.RemovePAC(context.Background(), f, metadata)
})
buildDoneCh := awaitBuildCompletion(t, glabEnv.ProjectName, ns)
gitCommands := `export GIT_TERMINAL_PROMPT=0 && \
cd "${PROJECT_DIR}" && \
git config --global user.name "John Doe" && \
git config --global user.email "jdoe@example.com" && \
git config --global core.sshCommand "ssh -i ${SSH_IDENTITY_FILE} -o UserKnownHostsFile=${HOME}/known_hosts -o StrictHostKeyChecking=no" && \
git init --initial-branch=devel && \
git remote add origin "${REPO_URL}" && \
git add . && \
git commit -m "commit message" && \
git push -u origin devel
`
cmd := exec.Command("sh", "-c", gitCommands)
cmd.Env = []string{
"PROJECT_DIR=" + projDir,
"SSH_IDENTITY_FILE=" + glabEnv.UserIdentityFile,
"REPO_URL=" + glabEnv.SSHProjectURL,
"HOME=" + tempHome,
}
out, err := cmd.CombinedOutput()
if err != nil {
t.Log(string(out))
t.Fatal(err)
}
select {
case <-buildDoneCh:
t.Log("build done on time")
case <-time.After(time.Minute * 10):
t.Error("build has not been done in time")
case <-ctx.Done():
t.Error("cancelled")
}
}
func parseEnv(t *testing.T) (gitlabHostname string, gitlabRootPassword string, pacCtrHostname string, err error) {
if enabled, _ := strconv.ParseBool(os.Getenv("GITLAB_TESTS_ENABLED")); !enabled {
t.Skip("GitLab tests are disabled")
}
envs := map[string]*string{
"GITLAB_HOSTNAME": &gitlabHostname,
"GITLAB_ROOT_PASSWORD": &gitlabRootPassword,
"PAC_CONTROLLER_HOSTNAME": &pacCtrHostname,
}
var missing []string
gitlabHostname = os.Getenv("GITLAB_HOSTNAME")
for name, ptr := range envs {
val := os.Getenv(name)
if val == "" {
missing = append(missing, name)
continue
}
*ptr = val
}
if len(missing) > 0 {
err = fmt.Errorf("required environment variables are not set: %+v", strings.Join(missing, ", "))
}
return
}
type gitlabEnv struct {
ProjectName string
HTTPProjectURL string
SSHProjectURL string
GroupName string
UserName string
UserToken string
UserIdentityFile string
}
func setupGitlabEnv(ctx context.Context, t *testing.T, baseURL, username, password string) gitlabEnv {
t.Log("setting up gitlab env")
randStr := strings.ToLower(random.AlphaString(5))
userName := "func_user_" + randStr
userPassword := "1ddqd1dkf@"
groupName := "func-grp-" + randStr
projectName := "func-project-" + randStr
//region Initialize Root's Gitlab client
rootToken, err := getAPIToken(baseURL, username, password)
if err != nil {
t.Fatal(err)
}
glabCli, err := gitlab.NewClient(rootToken, gitlab.WithBaseURL(baseURL))
if err != nil {
t.Fatal(err)
}
pat, _, err := glabCli.PersonalAccessTokens.GetSinglePersonalAccessToken()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = glabCli.PersonalAccessTokens.RevokePersonalAccessToken(pat.ID)
})
//endregion
//region Enable Webhooks to non-public IP.
newSettings := &gitlab.UpdateSettingsOptions{
AllowLocalRequestsFromWebHooksAndServices: p(true),
}
_, _, err = glabCli.Settings.UpdateSettings(newSettings)
if err != nil {
t.Fatal(err)
}
// For some reason the setting update does not kick in immediately.
select {
case <-ctx.Done():
t.Fatal("cancelled")
case <-time.After(time.Minute):
}
//endregion
//region Create test user
userOpts := &gitlab.CreateUserOptions{
Name: p("John Doe"),
Username: p(userName),
Password: p(userPassword),
ForceRandomPassword: p(false),
Email: p(fmt.Sprintf("%s@example.com", userName)),
}
u, _, err := glabCli.Users.CreateUser(userOpts)
if err != nil {
t.Fatal(err)
}
t.Logf("created user %q", u.Username)
t.Cleanup(func() {
_, _ = glabCli.Users.DeleteUser(u.ID)
})
//endregion
//region Create test group
groupOpts := &gitlab.CreateGroupOptions{
Name: p(groupName),
Path: p(groupName),
Description: p("group for `func` testing"),
Visibility: p(gitlab.PublicVisibility),
RequireTwoFactorAuth: p(false),
ProjectCreationLevel: p(gitlab.DeveloperProjectCreation),
LFSEnabled: p(true),
RequestAccessEnabled: p(true),
}
g, _, err := glabCli.Groups.CreateGroup(groupOpts)
if err != nil {
t.Fatal(err)
}
t.Logf("created group: %q", g.Name)
t.Cleanup(func() {
_, _ = glabCli.Groups.DeleteGroup(g.ID)
})
//endregion
//region Add test user to the test group
grpMemOpts := &gitlab.AddGroupMemberOptions{
UserID: p(u.ID),
AccessLevel: p(gitlab.MaintainerPermissions),
}
_, _, err = glabCli.GroupMembers.AddGroupMember(g.ID, grpMemOpts)
if err != nil {
t.Fatal(err)
}
//endregion
//region Create test project
creatProjOpts := &gitlab.CreateProjectOptions{
Name: p(projectName),
NamespaceID: p(g.ID),
Path: p(projectName),
Visibility: p(gitlab.PublicVisibility),
InitializeWithReadme: p(true),
DefaultBranch: p("main"),
}
project, _, err := glabCli.Projects.CreateProject(creatProjOpts)
if err != nil {
t.Fatal(err)
}
t.Logf("created project: %q", project.Name)
t.Cleanup(func() {
_, _ = glabCli.Projects.DeleteProject(project.ID)
})
//endregion
//region Add public SSK key for test user
sshPrivateKeyPath := generateSSHKeys(t)
sshPublicKeyBytes, err := os.ReadFile(sshPrivateKeyPath + ".pub")
if err != nil {
t.Fatal(err)
}
userToken, err := getAPIToken(baseURL, userName, userPassword)
if err != nil {
t.Fatal(err)
}
glabCliUser, err := gitlab.NewClient(userToken, gitlab.WithBaseURL(baseURL))
if err != nil {
t.Fatal(err)
}
addSshKeyOpts := &gitlab.AddSSHKeyOptions{
Title: p("func-ssh-key"),
Key: p(string(sshPublicKeyBytes)),
}
_, _, err = glabCliUser.Users.AddSSHKey(addSshKeyOpts)
if err != nil {
t.Fatal(err)
}
//endregion
return gitlabEnv{
ProjectName: projectName,
HTTPProjectURL: project.HTTPURLToRepo,
SSHProjectURL: project.SSHURLToRepo,
GroupName: groupName,
UserName: userName,
UserToken: userToken,
UserIdentityFile: sshPrivateKeyPath,
}
}
func getAPIToken(baseURL, username, password string) (string, error) {
jar, err := cookiejar.New(nil)
if err != nil {
return "", fmt.Errorf("cannot create a cookie jar: %w", err)
}
c := http.Client{
Jar: jar,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
signInURL := baseURL + "/users/sign_in"
resp, err := c.Get(signInURL)
if err != nil {
return "", fmt.Errorf("cannot get sign in page: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("cannot get sign in page, unexpected status: %d", resp.StatusCode)
}
node, err := html.Parse(resp.Body)
if err != nil {
return "", fmt.Errorf("cannot parse sign in page: %w", err)
}
csrfToken := getCSRFToken(node)
form := url.Values{}
form.Add("authenticity_token", csrfToken)
form.Add("user[login]", username)
form.Add("user[password]", password)
form.Add("user[remember_me]", "0")
req, err := http.NewRequest("POST", signInURL, strings.NewReader(form.Encode()))
if err != nil {
return "", fmt.Errorf("cannot create sign in request: %w", err)
}
req.Header.Add("Origin", baseURL)
req.Header.Add("Referer", signInURL)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
resp, err = c.Do(req)
if err != nil {
return "", fmt.Errorf("cannot sign in: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 302 {
return "", fmt.Errorf("cannot sign in, unexpected status: %d", resp.StatusCode)
}
personalAccessTokensURL := baseURL + "/-/profile/personal_access_tokens"
resp, err = c.Get(personalAccessTokensURL)
if err != nil {
return "", fmt.Errorf("cannot get personal access tokens: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("cannot get personal access tokens, unexpected status: %d", resp.StatusCode)
}
node, err = html.Parse(resp.Body)
if err != nil {
return "", fmt.Errorf("cannot parse personal access tokens page: %w", err)
}
csrfToken = getCSRFToken(node)
form = url.Values{
"personal_access_token[name]": {"test-2"},
"personal_access_token[expires_at]": {time.Now().Add(time.Hour * 25).Format("2006-01-02")},
"personal_access_token[scopes][]": {"api", "read_api", "read_user", "read_repository", "write_repository", "sudo"},
}
req, err = http.NewRequest("POST", personalAccessTokensURL, strings.NewReader(form.Encode()))
if err != nil {
return "", fmt.Errorf("cannot create new personal access token request: %w", err)
}
req.Header.Add("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
req.Header.Add("X-CSRF-Token", csrfToken)
resp, err = c.Do(req)
if err != nil {
return "", fmt.Errorf("cannot create new personal access token: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("cannot create new personal access token, unexpected status: %d", resp.StatusCode)
}
data := struct {
NewToken string `json:"new_token,omitempty"`
}{}
e := json.NewDecoder(resp.Body)
err = e.Decode(&data)
if err != nil {
return "", fmt.Errorf("cannot parse token form a response: %w", err)
}
return data.NewToken, nil
}
func getCSRFToken(n *html.Node) string {
var match bool
var token string
for _, a := range n.Attr {
if a.Key == "name" && (a.Val == "authenticity_token" || a.Val == "csrf-token") {
match = true
}
if a.Key == "value" || a.Key == "content" {
token = a.Val
}
}
if match {
return token
}
for n = n.FirstChild; n != nil; n = n.NextSibling {
token = getCSRFToken(n)
if token != "" {
return token
}
}
return ""
}
func generateSSHKeys(t *testing.T) string {
tmp := t.TempDir()
privateKeyPath := filepath.Join(tmp, "id_ecdsa")
publicKeyPath := privateKeyPath + ".pub"
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
privateKeyBytes, err := x509.MarshalECPrivateKey(privateKey)
if err != nil {
t.Fatal(err)
}
privateKeyBlock := &pem.Block{
Type: "EC PRIVATE KEY",
Bytes: privateKeyBytes,
}
privateKeyFile, err := os.OpenFile(privateKeyPath, os.O_CREATE|os.O_WRONLY, 0400)
if err != nil {
t.Fatal(err)
}
err = pem.Encode(privateKeyFile, privateKeyBlock)
if err != nil {
t.Fatal(err)
}
publicKey, err := ssh.NewPublicKey(&privateKey.PublicKey)
if err != nil {
t.Fatal(err)
}
publicKeyBytes := ssh.MarshalAuthorizedKey(publicKey)
err = os.WriteFile(publicKeyPath, publicKeyBytes, 0444)
if err != nil {
t.Fatal(err)
}
return privateKeyPath
}
func usingNamespace(t *testing.T) string {
name := "gitlab-test-" + strings.ToLower(random.AlphaString(5))
k8sClient, err := k8s.NewKubernetesClientset()
if err != nil {
t.Fatal(err)
}
ns := &v1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: name,
},
}
createOpts := metav1.CreateOptions{}
ns, err = k8sClient.CoreV1().Namespaces().Create(context.Background(), ns, createOpts)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
deleteOpts := metav1.DeleteOptions{}
_ = k8sClient.CoreV1().Namespaces().Delete(context.Background(), name, deleteOpts)
})
return name
}
func awaitBuildCompletion(t *testing.T, name, ns string) <-chan struct{} {
clis, err := tekton.NewTektonClients()
if err != nil {
t.Fatal(err)
}
listOpts := metav1.ListOptions{
LabelSelector: "tekton.dev/pipelineTask=build",
Watch: true,
}
w, err := clis.Tekton.TektonV1().TaskRuns(ns).Watch(context.Background(), listOpts)
if err != nil {
t.Fatal(err)
}
ch := make(chan struct{}, 1)
go func() {
defer w.Stop()
for event := range w.ResultChan() {
taskRun, ok := event.Object.(*pipelinev1.TaskRun)
if !ok {
continue
}
if !strings.HasPrefix(taskRun.Name, name) {
continue
}
for _, condition := range taskRun.Status.Conditions {
if condition.Type == apis.ConditionSucceeded && condition.IsTrue() {
ch <- struct{}{}
break
}
}
}
}()
return ch
}
func p[T any](t T) *T {
return &t
}