diff --git a/docs/configuration/environment_variables.md b/docs/configuration/environment_variables.md index e96f0b8..eb4c0d3 100644 --- a/docs/configuration/environment_variables.md +++ b/docs/configuration/environment_variables.md @@ -5,56 +5,59 @@ The helm chart populate them using [values.yaml](https://github.com/quickube/pip ### Git -* GIT_PROVIDER - The git provider that Piper will use, possible variables: GitHub (will support bitbucket and gitlab) +- GIT_PROVIDER + The git provider that Piper will use, possible variables: GitHub | Gitlab (will support bitbucket) -* GIT_TOKEN +- GIT_TOKEN The git token that will be used. -* GIT_ORG_NAME +- GIT_URL + the git url that will be used, only relevant when running gitlab self hosted + +- GIT_ORG_NAME The organization name. -* GIT_ORG_LEVEL_WEBHOOK +- GIT_ORG_LEVEL_WEBHOOK Boolean variable, whether to config webhook in organization level. default `false` -* GIT_WEBHOOK_REPO_LIST +- GIT_WEBHOOK_REPO_LIST List of repositories to configure webhooks to. -* GIT_WEBHOOK_URL +- GIT_WEBHOOK_URL URL of piper ingress, to configure webhooks. -* GIT_WEBHOOK_AUTO_CLEANUP - Will cleanup all webhook that were created with piper. - Notice that there will be a race conditions between pod that being terminated and the new one. +- GIT_WEBHOOK_AUTO_CLEANUP + Will cleanup all webhook that were created with piper. + Notice that there will be a race conditions between pod that being terminated and the new one. -* GIT_ENFORCE_ORG_BELONGING +- GIT_ENFORCE_ORG_BELONGING Boolean variable, whether to enforce organizational belonging of git event creator. default `false` -* GIT_FULL_HEALTH_CHECK +- GIT_FULL_HEALTH_CHECK Enables full health check of webhook. Full health check contains expecting and validating ping event from a webhook. Doesn't work for bitbucket, because the API call doesn't exists. - ### Argo Workflows Server -* ARGO_WORKFLOWS_TOKEN + +- ARGO_WORKFLOWS_TOKEN The token of Argo Workflows server. -* ARGO_WORKFLOWS_ADDRESS +- ARGO_WORKFLOWS_ADDRESS The address of Argo Workflows Server. - -* ARGO_WORKFLOWS_CREATE_CRD +- ARGO_WORKFLOWS_CREATE_CRD Whether to directly send Workflows instruction or create a CRD in the Cluster. -* ARGO_WORKFLOWS_NAMESPACE +- ARGO_WORKFLOWS_NAMESPACE The namespace of Workflows creation for Argo Workflows. -* KUBE_CONFIG +- KUBE_CONFIG Used to configure Argo Workflows client with local kube configurations. ### Rookout -* ROOKOUT_TOKEN + +- ROOKOUT_TOKEN The token used to configure Rookout agent. If not provided, will not start the agent. -* ROOKOUT_LABELS +- ROOKOUT_LABELS The labels to label instances at Rookout, default to "service:piper" -* ROOKOUT_REMOTE_ORIGIN - The repo URL for source code fetching, default:"https://github.com/quickube/piper.git". \ No newline at end of file +- ROOKOUT_REMOTE_ORIGIN + The repo URL for source code fetching, default:"https://github.com/quickube/piper.git". diff --git a/docs/getting_started/installation.md b/docs/getting_started/installation.md index 5ab16c0..2eab676 100644 --- a/docs/getting_started/installation.md +++ b/docs/getting_started/installation.md @@ -1,16 +1,18 @@ ## Instalation -Piper should be deployed in the cluster with Argo Workflows. -Piper will create a CRD that Argo Workflows will pick, so install or configure Piper to create those CRDs in the right namespace. +Piper should be deployed in the cluster with Argo Workflows. +Piper will create a CRD that Argo Workflows will pick, so install or configure Piper to create those CRDs in the right namespace. Please check out [values.yaml](https://github.com/quickube/piper/tree/main/helm-chart/values.yaml) file of the helm chart configurations. To add piper helm repo run: + ```bash helm repo add piper https://piper.quickube.com ``` After configuring Piper [values.yaml](https://github.com/quickube/piper/tree/main/helm-chart/values.yaml), run the following command for installation: + ```bash helm upgrade --install piper piper/piper \ -f YOUR_VALUES_FILE.yaml @@ -22,7 +24,7 @@ helm upgrade --install piper piper/piper \ ### Ingress -Piper should listen to webhooks from your git provider. +Piper should listen to webhooks from your git provider. Expose it using ingress or service, then provide the address to `piper.webhook.url` as followed: `https://PIPER_EXPOESED_URL/webhook` @@ -32,9 +34,9 @@ Checkout [values.yaml](https://github.com/quickube/piper/tree/main/helm-chart/va Piper will use git for fetching `.workflows` folder and receiving events using webhooks. -To pick which git provider you are using provide `gitProvider.name` configuration in helm chart (Now only supports GitHub and Bitbucket). +To pick which git provider you are using provide `gitProvider.name` configuration in helm chart (supports GitHub, Bitbucket and Gitlab). -Also configure you organization (Github) or workspace (Bitbucket) name using `gitProvider.organization.name` in helm chart. +Also configure you organization (Github), workspace (Bitbucket) or group (Gitlab) name using `gitProvider.organization.name` in helm chart. #### Git Token Permissions @@ -44,11 +46,12 @@ For Bitbucket configure `Repositories:read`, `Webhooks:read and write` and `Pull #### Token -The git token should be passed as secret in the helm chart at `gitProvider.token`. +The git token should be passed as secret in the helm chart at `gitProvider.token`. Can be passed as parameter in helm install command using `--set piper.gitProvider.token=YOUR_GIT_TOKEN` Alternatively, you can consume already existing secret and fill up `piper.gipProvider.existingSecret`. -The key should be name `token`. Can be created using +The key should be name `token`. Can be created using + ```bash kubectl create secret generic piper-git-token --from-literal=token=YOUR_GIT_OKEN ``` @@ -61,13 +64,13 @@ Configure `piper.webhook.url` the address of piper that exposed with ingress wit For organization level configure: `gitProvider.webhook.orgLevel` to `true`. -For granular repo webhook provide list of repos at: `gitProvider.webhook.repoList`. +For granular repo webhook provide list of repos at: `gitProvider.webhook.repoList`. -Piper implements graceful shutdown, it will delete all the webhooks when terminated. +Piper implements graceful shutdown, it will delete all the webhooks when terminated. #### Status check -Piper will handle status checks for you. +Piper will handle status checks for you. It will notify the GitProvider for the status of Workflow for specific commit that triggered Piper. For linking provide valid URL of your Argo Workflows server address at: `argoWorkflows.server.address` @@ -81,4 +84,4 @@ To lint the workflow before submitting it, please configure the internal address #### Skip CRD Creation (On development) -Piper can communicate directly to Argo Workflow using ARGO_WORKFLOWS_CREATE_CRD environment variable, if you want to skip the creation of CRD change `argoWorkflows.crdCreation` to `false`. \ No newline at end of file +Piper can communicate directly to Argo Workflow using ARGO_WORKFLOWS_CREATE_CRD environment variable, if you want to skip the creation of CRD change `argoWorkflows.crdCreation` to `false`. diff --git a/go.mod b/go.mod index 264a837..245bbc6 100644 --- a/go.mod +++ b/go.mod @@ -45,6 +45,8 @@ require ( github.com/google/uuid v1.3.0 // indirect github.com/gorilla/websocket v1.5.0 // indirect github.com/grpc-ecosystem/grpc-gateway v1.16.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.2 // indirect github.com/hashicorp/golang-lru v0.5.4 // indirect github.com/imdario/mergo v0.3.13 // indirect github.com/josharian/intern v1.0.0 // indirect @@ -67,6 +69,7 @@ require ( github.com/tidwall/pretty v1.2.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.11 // indirect + github.com/xanzy/go-gitlab v0.105.0 // indirect github.com/yhirose/go-peg v0.0.0-20210804202551-de25d6753cf1 // indirect golang.org/x/arch v0.3.0 // indirect golang.org/x/crypto v0.17.0 // indirect diff --git a/go.sum b/go.sum index c788bdd..b22342d 100644 --- a/go.sum +++ b/go.sum @@ -879,6 +879,11 @@ github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4 github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0/go.mod h1:hgWBS7lorOAVIJEQMi4ZsPv9hVvWI6+ch50m39Pf2Ks= github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3/go.mod h1:o//XUCC/F+yRGJoPO/VU0GSB0f8Nhgmxx0VIRUvaC0w= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= +github.com/hashicorp/go-retryablehttp v0.7.2 h1:AcYqCvkpalPnPF2pn0KamgwamS42TqUDDYFRKq/RAd0= +github.com/hashicorp/go-retryablehttp v0.7.2/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= @@ -1032,6 +1037,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/xanzy/go-gitlab v0.105.0 h1:3nyLq0ESez0crcaM19o5S//SvezOQguuIHZ3wgX64hM= +github.com/xanzy/go-gitlab v0.105.0/go.mod h1:ETg8tcj4OhrB84UEgeE8dSuV/0h4BBL1uOV/qK0vlyI= github.com/yhirose/go-peg v0.0.0-20210804202551-de25d6753cf1 h1:7iTmQ0lZwTtfm4XMgP5ezzWMDCjo7GTS0ZgCj6jpVzM= github.com/yhirose/go-peg v0.0.0-20210804202551-de25d6753cf1/go.mod h1:q2QWLflHsZxT6ixYcXveTYicEvxGh5Uv6CnI7f7BfjQ= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/helm-chart/templates/deployment.yaml b/helm-chart/templates/deployment.yaml index e26b84e..10959fa 100644 --- a/helm-chart/templates/deployment.yaml +++ b/helm-chart/templates/deployment.yaml @@ -100,6 +100,8 @@ spec: key: token - name: GIT_ORG_NAME value: {{ .Values.piper.gitProvider.organization.name | quote }} + - name: GIT_URL + value: {{ .Values.piper.gitProvider.url | quote }} - name: GIT_WEBHOOK_URL value: {{ .Values.piper.gitProvider.webhook.url | quote }} - name: GIT_WEBHOOK_SECRET diff --git a/helm-chart/values.yaml b/helm-chart/values.yaml index acf5809..7d1e21b 100644 --- a/helm-chart/values.yaml +++ b/helm-chart/values.yaml @@ -4,7 +4,7 @@ # Map of Piper configurations. piper: gitProvider: - # -- Name of your git provider (github/bitbucket). + # -- Name of your git provider (github/bitbucket/gitlab). name: github # -- The token for authentication with the Git provider. # -- This will create a secret named -git-token and with the key 'token' @@ -13,9 +13,12 @@ piper: # -- Reference to existing token with 'token' key. # -- can be created with `kubectl create secret generic piper-git-token --from-literal=token=YOUR_TOKEN` existingSecret: #piper-git-token + # -- git provider url + # -- relevant when using gitlab self hosted + url: "" # Map of organization configurations. organization: - # -- Name of your Git Organization (GitHub) or Workspace (Bitbucket) + # -- Name of your Git Organization (GitHub) / Workspace (Bitbucket) or Group (Gitlab) name: "" # Map of webhook configurations. webhook: @@ -31,7 +34,7 @@ piper: url: "" #https://piper.example.local/webhook # -- Whether config webhook on org level (GitHub) or at workspace level (Bitbucket - not supported yet) orgLevel: false - # -- (Github) Used of orgLevel=false, to configure webhook for each of the repos provided. + # -- (Github/Gitlab) Used of orgLevel=false, to configure webhook for each of the repos provided. repoList: [] # Map of Argo Workflows configurations. @@ -51,8 +54,9 @@ piper: existingSecret: #piper-argo-token # -- Whether create Workflow CRD or send direct commands to Argo Workflows server. crdCreation: true - - workflowsConfig: {} + + workflowsConfig: + {} # default: | # spec: # volumes: @@ -126,22 +130,21 @@ podAnnotations: {} # -- Security Context to set on the pod level podSecurityContext: - fsGroup: 1001 - runAsUser: 1001 - runAsGroup: 1001 + fsGroup: 1001 + runAsUser: 1001 + runAsGroup: 1001 # -- Security Context to set on the container level securityContext: runAsUser: 1001 capabilities: drop: - - ALL + - ALL readOnlyRootFilesystem: true runAsNonRoot: true - service: - # -- Sets the type of the Service + # -- Sets the type of the Service type: ClusterIP # -- Service port # For TLS mode change the port to 443 @@ -158,10 +161,11 @@ ingress: # -- Piper ingress class name className: "" # -- Piper ingress annotations - annotations: {} + annotations: + {} # kubernetes.io/ingress.class: nginx # kubernetes.io/tls-acme: "true" - + # -- Piper ingress hosts ## Hostnames must be provided if Ingress is enabled. hosts: @@ -215,4 +219,4 @@ volumes: [] volumeMounts: [] # -- Specify postStart and preStop lifecycle hooks for Piper container -lifecycle: {} \ No newline at end of file +lifecycle: {} diff --git a/pkg/conf/git_provider.go b/pkg/conf/git_provider.go index 9d6a0ad..5bf3d9a 100644 --- a/pkg/conf/git_provider.go +++ b/pkg/conf/git_provider.go @@ -1,7 +1,7 @@ package conf import ( - "fmt" + "fmt" "github.com/kelseyhightower/envconfig" ) @@ -9,13 +9,14 @@ import ( type GitProviderConfig struct { Provider string `envconfig:"GIT_PROVIDER" required:"true"` Token string `envconfig:"GIT_TOKEN" required:"true"` - OrgName string `envconfig:"GIT_ORG_NAME" required:"true"` + Url string `envconfig:"GIT_URL" required:"false"` + OrgName string `envconfig:"GIT_ORG_NAME" required:"true"` OrgLevelWebhook bool `envconfig:"GIT_ORG_LEVEL_WEBHOOK" default:"false" required:"false"` RepoList string `envconfig:"GIT_WEBHOOK_REPO_LIST" required:"false"` WebhookURL string `envconfig:"GIT_WEBHOOK_URL" required:"false"` WebhookSecret string `envconfig:"GIT_WEBHOOK_SECRET" required:"false"` WebhookAutoCleanup bool `envconfig:"GIT_WEBHOOK_AUTO_CLEANUP" default:"false" required:"false"` - EnforceOrgBelonging bool `envconfig:"GIT_ENFORCE_ORG_BELONGING" default:"false" required:"false"` + EnforceOrgBelonging bool `envconfig:"GIT_ENFORCE_ORG_BELONGING" default:"false" required:"false"` OrgID int64 FullHealthCheck bool `envconfig:"GIT_FULL_HEALTH_CHECK" default:"false" required:"false"` } diff --git a/pkg/git_provider/github.go b/pkg/git_provider/github.go index ec9e290..72467de 100644 --- a/pkg/git_provider/github.go +++ b/pkg/git_provider/github.go @@ -22,6 +22,7 @@ func NewGithubClient(cfg *conf.GlobalConfig) (Client, error) { ctx := context.Background() client := github.NewTokenClient(ctx, cfg.GitProviderConfig.Token) + err := ValidatePermissions(ctx, client, cfg) if err != nil { return nil, fmt.Errorf("failed to validate permissions: %v", err) diff --git a/pkg/git_provider/gitlab.go b/pkg/git_provider/gitlab.go new file mode 100644 index 0000000..53d04e5 --- /dev/null +++ b/pkg/git_provider/gitlab.go @@ -0,0 +1,340 @@ +package git_provider + +import ( + "context" + "fmt" + "io" + "log" + "net/http" + "strings" + + "github.com/quickube/piper/pkg/conf" + "github.com/quickube/piper/pkg/utils" + + "github.com/xanzy/go-gitlab" +) + +type GitlabClientImpl struct { + client *gitlab.Client + cfg *conf.GlobalConfig +} + +func NewGitlabClient(cfg *conf.GlobalConfig) (Client, error) { + var options []gitlab.ClientOptionFunc + ctx := context.Background() + + if cfg.GitProviderConfig.Url != "" { + options = append(options, gitlab.WithBaseURL(cfg.GitProviderConfig.Url)) + } + + client, err := gitlab.NewClient(cfg.GitProviderConfig.Token, options...) + if err != nil { + return nil, err + } + + err = ValidateGitlabPermissions(ctx, client, cfg) + if err != nil { + return nil, fmt.Errorf("failed to validate permissions: %v", err) + } + + group, resp, err := client.Groups.GetGroup(cfg.GitProviderConfig.OrgName, nil) + if err != nil { + return nil, fmt.Errorf("failed to get organization: %v", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to get organization data %s", resp.Status) + } + + cfg.GitProviderConfig.OrgID = int64(group.ID) + log.Printf("Org ID is: %d\n", cfg.OrgID) + + return &GitlabClientImpl{ + client: client, + cfg: cfg, + }, err +} + +func (c *GitlabClientImpl) ListFiles(ctx *context.Context, repo string, branch string, path string) ([]string, error) { + var files []string + opt := &gitlab.ListTreeOptions{ + Ref: &branch, + Path: &path} + + projectName := fmt.Sprintf("%s/%s", c.cfg.GitProviderConfig.OrgName, repo) + dirFiles, resp, err := c.client.Repositories.ListTree(projectName, opt, gitlab.WithContext(*ctx)) + + if err != nil { + return nil, err + } + if resp.StatusCode != 200 { + return nil, fmt.Errorf("gitlab provider returned %d: failed to get contents of %s/%s%s", resp.StatusCode, repo, branch, path) + } + for _, file := range dirFiles { + files = append(files, file.Name) + } + return files, nil +} + +func (c *GitlabClientImpl) GetFile(ctx *context.Context, repo string, branch string, path string) (*CommitFile, error) { + var commitFile CommitFile + + fileContent, resp, err := c.client.RepositoryFiles.GetFile(repo, path, &gitlab.GetFileOptions{Ref: &branch}, gitlab.WithContext(*ctx)) + if err != nil { + return &commitFile, err + } + if resp.StatusCode != 200 { + return &commitFile, err + } + commitFile.Path = &fileContent.FilePath + commitFile.Content = &fileContent.Content + + return &commitFile, nil +} + +func (c *GitlabClientImpl) GetFiles(ctx *context.Context, repo string, branch string, paths []string) ([]*CommitFile, error) { + var commitFiles []*CommitFile + for _, path := range paths { + file, err := c.GetFile(ctx, repo, branch, path) + if err != nil { + return nil, err + } + if file == nil { + log.Printf("file %s not found in repo %s branch %s", path, repo, branch) + continue + } + commitFiles = append(commitFiles, file) + } + return commitFiles, nil +} + +func (c *GitlabClientImpl) SetWebhook(ctx *context.Context, repo *string) (*HookWithStatus, error) { + if c.cfg.OrgLevelWebhook && repo != nil { + return nil, fmt.Errorf("trying to set project scope. project: %s", *repo) + } + var gitlabHook gitlab.Hook + + if repo == nil { + respHook, ok := IsGroupWebhookEnabled(ctx, c) + + if !ok { + groupHookOptions := gitlab.AddGroupHookOptions{ + URL: gitlab.Ptr(c.cfg.GitProviderConfig.WebhookURL), + Token: gitlab.Ptr(c.cfg.GitProviderConfig.WebhookSecret), + MergeRequestsEvents: gitlab.Ptr(true), + PushEvents: gitlab.Ptr(true), + ReleasesEvents: gitlab.Ptr(true), + TagPushEvents: gitlab.Ptr(true), + } + + gitlabHook, resp, err := c.client.Groups.AddGroupHook(c.cfg.GitProviderConfig.OrgName, &groupHookOptions, gitlab.WithContext(*ctx)) + if err != nil { + return nil, err + } + if resp.StatusCode != 201 { + return nil, fmt.Errorf("failed to create group level webhhok, API returned %d", resp.StatusCode) + } + log.Printf("added webhook for %s name: %s\n", c.cfg.GitProviderConfig.OrgName, gitlabHook.URL) + } else { + editedGroupHookOpt := gitlab.EditGroupHookOptions{ + URL: gitlab.Ptr(c.cfg.GitProviderConfig.WebhookURL), + Token: gitlab.Ptr(c.cfg.GitProviderConfig.WebhookSecret), + MergeRequestsEvents: gitlab.Ptr(true), + PushEvents: gitlab.Ptr(true), + ReleasesEvents: gitlab.Ptr(true), + TagPushEvents: gitlab.Ptr(true), + } + gitlabHook, resp, err := c.client.Groups.EditGroupHook(c.cfg.GitProviderConfig.OrgName, respHook.ID, &editedGroupHookOpt, gitlab.WithContext(*ctx)) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf( + "failed to update group level webhook for %s, API returned %d", + c.cfg.GitProviderConfig.OrgName, + resp.StatusCode, + ) + } + log.Printf("edited webhook for %s: %s\n", c.cfg.GitProviderConfig.OrgName, gitlabHook.URL) + } + } else { + respHook, ok := IsProjectWebhookEnabled(ctx, c, *repo) + if !ok { + addProjectHookOpts := gitlab.AddProjectHookOptions{ + URL: gitlab.Ptr(c.cfg.GitProviderConfig.WebhookURL), + Token: gitlab.Ptr(c.cfg.GitProviderConfig.WebhookSecret), + MergeRequestsEvents: gitlab.Ptr(true), + PushEvents: gitlab.Ptr(true), + ReleasesEvents: gitlab.Ptr(true), + TagPushEvents: gitlab.Ptr(true), + } + + gitlabHook, resp, err := c.client.Projects.AddProjectHook(*repo, &addProjectHookOpts, gitlab.WithContext(*ctx)) + if err != nil { + return nil, err + } + if resp.StatusCode != 201 { + return nil, fmt.Errorf("failed to create repo level webhhok for %s, API returned %d", *repo, resp.StatusCode) + } + log.Printf("created webhook for %s: %s\n", *repo, gitlabHook.URL) + } else { + editProjectHookOpts := gitlab.EditProjectHookOptions{ + URL: gitlab.Ptr(c.cfg.GitProviderConfig.WebhookURL), + Token: gitlab.Ptr(c.cfg.GitProviderConfig.WebhookSecret), + MergeRequestsEvents: gitlab.Ptr(true), + PushEvents: gitlab.Ptr(true), + ReleasesEvents: gitlab.Ptr(true), + TagPushEvents: gitlab.Ptr(true), + } + gitlabHook, resp, err := c.client.Projects.EditProjectHook(*repo, respHook.ID, &editProjectHookOpts, gitlab.WithContext(*ctx)) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to update repo level webhhok for %s, API returned %d", *repo, resp.StatusCode) + } + log.Printf("edited webhook for %s: %s\n", *repo, gitlabHook.URL) + } + + } + + hookID := int64(gitlabHook.ID) + return &HookWithStatus{HookID: hookID, HealthStatus: true, RepoName: repo}, nil +} + +func (c *GitlabClientImpl) UnsetWebhook(ctx *context.Context, hook *HookWithStatus) error { + + if hook.RepoName == nil { + resp, err := c.client.Groups.DeleteGroupHook(c.cfg.GitProviderConfig.OrgName, int(hook.HookID), gitlab.WithContext(*ctx)) + if err != nil { + return err + } + + if resp.StatusCode != 204 { + return fmt.Errorf("failed to delete group level webhhok, API call returned %d", resp.StatusCode) + } + log.Printf("removed group webhook, hookID :%d\n", hook.HookID) + } else { + resp, err := c.client.Projects.DeleteProjectHook(*hook.RepoName, int(hook.HookID), gitlab.WithContext(*ctx)) + + if err != nil { + return fmt.Errorf("failed to delete project level webhhok for %s, API call returned %d. %s", *hook.RepoName, resp.StatusCode, err) + } + + if resp.StatusCode != 204 { + return fmt.Errorf("failed to delete project level webhhok for %s, API call returned %d", *hook.RepoName, resp.StatusCode) + } + log.Printf("removed project webhook, project:%s hookID :%d\n", *hook.RepoName, hook.HookID) // INFO + } + + return nil +} + +func (c *GitlabClientImpl) HandlePayload(ctx *context.Context, request *http.Request, secret []byte) (*WebhookPayload, error) { + var webhookPayload *WebhookPayload + payload, err := io.ReadAll(request.Body) + if err != nil { + return nil, fmt.Errorf("error reading request body: %v", err) + } + event, err := gitlab.ParseWebhook(gitlab.WebhookEventType(request), payload) + if err != nil { + return nil, err + } + + switch e := event.(type) { + case gitlab.PushEvent: + webhookPayload = &WebhookPayload{ + Event: "push", + Action: e.EventName, + Repo: e.Project.Name, + Branch: strings.TrimPrefix(e.Ref, "refs/heads/"), + Commit: e.CheckoutSHA, + User: e.UserName, + UserEmail: e.UserEmail, + OwnerID: int64(e.UserID), + } + case gitlab.MergeEvent: + webhookPayload = &WebhookPayload{ + Event: "merge_request", + Action: e.ObjectAttributes.Action, + Repo: e.Repository.Name, + Branch: e.ObjectAttributes.SourceBranch, + Commit: e.ObjectAttributes.LastCommit.ID, + User: e.User.Name, + UserEmail: e.User.Email, + PullRequestTitle: e.ObjectAttributes.Title, + PullRequestURL: e.ObjectAttributes.URL, + DestBranch: e.ObjectAttributes.TargetBranch, + Labels: ExtractLabelsId(e.Labels), + OwnerID: int64(e.User.ID), + } + case gitlab.ReleaseEvent: + webhookPayload = &WebhookPayload{ + Event: "release", + Action: e.Action, // "create" | "update" | "delete" + Repo: e.Project.Name, + Branch: e.Tag, + Commit: e.Commit.ID, + User: e.Commit.Author.Name, + UserEmail: e.Commit.Author.Email, + } + } + + if (webhookPayload.OwnerID == 0) || (webhookPayload.OwnerID != c.cfg.OrgID) { + return nil, fmt.Errorf("webhook send from non organizational member") + } + return webhookPayload, nil +} + +func (c *GitlabClientImpl) SetStatus(ctx *context.Context, repo *string, commit *string, linkURL *string, status *string, message *string) error { + if !utils.ValidateHTTPFormat(*linkURL) { + return fmt.Errorf("invalid linkURL") + } + + repoStatus := &gitlab.SetCommitStatusOptions{ + State: gitlab.BuildStateValue(*status), // pending, success, error, or failure. + Ref: commit, + TargetURL: linkURL, + Description: gitlab.Ptr(fmt.Sprintf("Workflow %s %s", *status, *message)), + Context: gitlab.Ptr("Piper/ArgoWorkflows"), + } + + _, resp, err := c.client.Commits.SetCommitStatus(*repo, *commit, repoStatus, gitlab.WithContext(*ctx)) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusCreated { + return fmt.Errorf("failed to set status on repo:%s, commit:%s, API call returned %d", *repo, *commit, resp.StatusCode) + } + + log.Printf("successfully set status on repo:%s commit: %s to status: %s\n", *repo, *commit, *status) + return nil +} + +func (c *GitlabClientImpl) PingHook(ctx *context.Context, hook *HookWithStatus) error { + if c.cfg.OrgLevelWebhook && hook.RepoName != nil { + return fmt.Errorf("trying o ping repo scope webhook while configured for org level webhook. repo: %s", *hook.RepoName) + } + if hook.RepoName == nil { + _, resp, err := c.client.Groups.GetGroupHook(c.cfg.OrgName, int(hook.HookID), nil, gitlab.WithContext(*ctx)) + if err != nil { + return err + } + + if resp.StatusCode == http.StatusNotFound { + return fmt.Errorf("unable to find organization webhook for hookID: %d", hook.HookID) + } + } else { + _, resp, err := c.client.Projects.GetProjectHook(*hook.RepoName, int(hook.HookID), nil, gitlab.WithContext(*ctx)) + if err != nil { + return err + } + + if resp.StatusCode == http.StatusNotFound { + return fmt.Errorf("unable to find repo webhook for repo:%s hookID: %d", *hook.RepoName, hook.HookID) + } + } + + return nil +} diff --git a/pkg/git_provider/gitlab_test.go b/pkg/git_provider/gitlab_test.go new file mode 100644 index 0000000..900b284 --- /dev/null +++ b/pkg/git_provider/gitlab_test.go @@ -0,0 +1,463 @@ +package git_provider + +import ( + "errors" + "fmt" + "net/http" + "testing" + + "github.com/quickube/piper/pkg/conf" + "github.com/quickube/piper/pkg/utils" + assertion "github.com/stretchr/testify/assert" + "github.com/xanzy/go-gitlab" + "golang.org/x/net/context" +) + +func TestGitlabListFiles(t *testing.T) { + // Prepare + mux, client := setupGitlab(t) + + repoContent := &gitlab.TreeNode{ + Type: "file", + Name: "exit.yaml", + Path: ".workflows/exit.yaml", + } + + repoContent2 := &gitlab.TreeNode{ + Type: "file", + Name: "main.yaml", + Path: ".workflows/main.yaml", + } + + treeNodes := []gitlab.TreeNode{*repoContent, *repoContent2} + expectedRef := "branch1" + project := "project1" + + c := GitlabClientImpl{ + client: client, + cfg: &conf.GlobalConfig{ + GitProviderConfig: conf.GitProviderConfig{ + OrgLevelWebhook: true, + OrgName: "group1", + RepoList: project, + }, + }, + } + projectUrl := fmt.Sprintf("/api/v4/projects/%s/%s/repository/tree",c.cfg.GitProviderConfig.OrgName, project) + mux.HandleFunc(projectUrl, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + ref := r.URL.Query().Get("ref") + + // Check if the ref value matches the expected value + if ref != expectedRef { + http.Error(w, "Invalid ref value", http.StatusBadRequest) + return + } + mockHTTPResponse(t, w, treeNodes) + }) + + + ctx := context.Background() + + // Execute + actualContent, err := c.ListFiles(&ctx, project, expectedRef, ".workflows") + + var expectedFilesNames []string + for _, file := range treeNodes{ + expectedFilesNames = append(expectedFilesNames, file.Name) + } + + // Assert + assert := assertion.New(t) + assert.NotNil(t, err) + assert.Equal(expectedFilesNames, actualContent) +} + +func TestGitlabGetFile(t *testing.T) { + // Prepare + mux, client := setupGitlab(t) + + + expectedFile := gitlab.File{ + Content: "file", + FileName: "file.yaml", + CommitID: "1", + FilePath: ".workflows/file.yaml", + } + + c := GitlabClientImpl{ + client: client, + cfg: &conf.GlobalConfig{ + GitProviderConfig: conf.GitProviderConfig{ + OrgLevelWebhook: true, + OrgName: "group1", + RepoList: "project1", + }, + }, + } + + mux.HandleFunc("/api/v4/projects/project1/repository/files/.workflows", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + + ref := r.URL.Query().Get("ref") + // Check if the ref value matches the expected value + if ref != "branch1" { + http.Error(w, "Invalid ref value", http.StatusBadRequest) + return + } + + mockHTTPResponse(t, w, expectedFile) + }) + + + ctx := context.Background() + + // Execute + actualFile, err := c.GetFile(&ctx, "project1", "branch1", ".workflows") + + // Assert + assert := assertion.New(t) + assert.NotNil(t, err) + + assert.Equal(*actualFile.Path, expectedFile.FilePath) + assert.Equal(*actualFile.Content, expectedFile.Content) +} + +func TestGitlabPingHook(t *testing.T) { + // Prepare + ctx := context.Background() + assert := assertion.New(t) + mux, client := setupGitlab(t) + + hookUrl := "https://url" + + // Test-repo2 existing webhook + mux.HandleFunc("/api/v4/projects/test-repo1/hooks/234", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + mockHTTPResponse(t, w, nil) + }) + + mux.HandleFunc("/api/v4/groups/test/hooks/123", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + mockHTTPResponse(t, w, nil) + + }) + + c := GitlabClientImpl{ + client: client, + cfg: &conf.GlobalConfig{ + GitProviderConfig: conf.GitProviderConfig{}, + }, + } + + // Define test cases + tests := []struct { + name string + repo *string + hook *HookWithStatus + config *conf.GitProviderConfig + }{ + { + name: "Ping repo webhook", + hook: &HookWithStatus{ + HookID: 234, + HealthStatus: true, + RepoName: utils.SPtr("test-repo1"), + }, + config: &conf.GitProviderConfig{ + OrgLevelWebhook: false, + OrgName: "test", + WebhookURL: hookUrl, + }, + }, + { + name: "Ping org webhook", + hook: &HookWithStatus{ + HookID: 123, + HealthStatus: true, + RepoName: nil, + }, + config: &conf.GitProviderConfig{ + OrgLevelWebhook: true, + OrgName: "test", + WebhookURL: hookUrl, + }, + }, + } + // Run test cases + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + c.cfg.GitProviderConfig = *test.config + // Call the function being tested + err := c.PingHook(&ctx, test.hook) + + assert.Nil(err) + + + }) + } +} + +func TestGitlabSetStatus(t *testing.T) { + // Prepare + ctx := context.Background() + assert := assertion.New(t) + mux, client := setupGitlab(t) + + mux.HandleFunc("/api/v4/projects/test-repo1/statuses/test-commit", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "POST") + + w.WriteHeader(http.StatusCreated) + jsonBytes := []byte(`{"status": "ok"}`) + _, _ = fmt.Fprint(w, string(jsonBytes)) + }) + + c := GitlabClientImpl{ + client: client, + cfg: &conf.GlobalConfig{ + GitProviderConfig: conf.GitProviderConfig{ + OrgLevelWebhook: false, + OrgName: "test", + RepoList: "test-repo1", + }, + }, + } + + // Define test cases + tests := []struct { + name string + repo *string + commit *string + linkURL *string + status *string + message *string + wantedError error + }{ + { + name: "Notify success", + repo: utils.SPtr("test-repo1"), + commit: utils.SPtr("test-commit"), + linkURL: utils.SPtr("https://argo"), + status: utils.SPtr("success"), + message: utils.SPtr(""), + wantedError: nil, + }, + { + name: "Notify pending", + repo: utils.SPtr("test-repo1"), + commit: utils.SPtr("test-commit"), + linkURL: utils.SPtr("https://argo"), + status: utils.SPtr("pending"), + message: utils.SPtr(""), + wantedError: nil, + }, + { + name: "Notify error", + repo: utils.SPtr("test-repo1"), + commit: utils.SPtr("test-commit"), + linkURL: utils.SPtr("https://argo"), + status: utils.SPtr("error"), + message: utils.SPtr("some message"), + wantedError: nil, + }, + { + name: "Notify failure", + repo: utils.SPtr("test-repo1"), + commit: utils.SPtr("test-commit"), + linkURL: utils.SPtr("https://argo"), + status: utils.SPtr("failure"), + message: utils.SPtr(""), + wantedError: nil, + }, + { + name: "Non managed repo", + repo: utils.SPtr("non-existing-repo"), + commit: utils.SPtr("test-commit"), + linkURL: utils.SPtr("https://argo"), + status: utils.SPtr("error"), + message: utils.SPtr(""), + wantedError: errors.New("some error"), + }, + { + name: "Non existing commit", + repo: utils.SPtr("test-repo1"), + commit: utils.SPtr("not-exists"), + linkURL: utils.SPtr("https://argo"), + status: utils.SPtr("error"), + message: utils.SPtr(""), + wantedError: errors.New("some error"), + }, + { + name: "Wrong URL", + repo: utils.SPtr("test-repo1"), + commit: utils.SPtr("test-commit"), + linkURL: utils.SPtr("argo"), + status: utils.SPtr("error"), + message: utils.SPtr(""), + wantedError: errors.New("some error"), + }, + } + // Run test cases + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + + // Call the function being tested + err := c.SetStatus(&ctx, test.repo, test.commit, test.linkURL, test.status, test.message) + + // Use assert to check the equality of the error + if test.wantedError != nil { + assert.Error(err) + assert.NotNil(err) + } else { + assert.NoError(err) + assert.Nil(err) + } + }) + } +} + +func TestGitlabSetWebhook(t *testing.T) { + // Prepare + ctx := context.Background() + assert := assertion.New(t) + mux, client := setupGitlab(t) + + hookUrl := "https://url" + + // new group webhook + mux.HandleFunc("/api/v4/groups/groupA/hooks", func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + mockHTTPResponse(t,w,[]*gitlab.GroupHook{}) + } else if r.Method == "POST"{ + w.WriteHeader(http.StatusCreated) + mockHTTPResponse(t,w,gitlab.GroupHook{ID:123,URL: hookUrl}) + } + }) + // existing group Webhook + mux.HandleFunc("/api/v4/groups/groupB/hooks", func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + mockHTTPResponse(t,w,[]*gitlab.GroupHook{{ID:123,URL: hookUrl}}) + } + }) + mux.HandleFunc("/api/v4/groups/groupB/hooks/123", func(w http.ResponseWriter, r *http.Request) { + if r.Method == "PUT"{ + w.WriteHeader(http.StatusOK) + mockHTTPResponse(t,w,gitlab.GroupHook{ID:123,URL: hookUrl}) + } + }) + + // new project Webhook + mux.HandleFunc("/api/v4/projects/test/test-repo1/hooks", func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + mockHTTPResponse(t,w,[]*gitlab.ProjectHook{{}}) + } + }) + mux.HandleFunc("/api/v4/projects/test-repo1/hooks", func(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" { + w.WriteHeader(http.StatusCreated) + mockHTTPResponse(t,w, gitlab.ProjectHook{ID:123,URL: hookUrl}) + } + }) + // existing project webhook + mux.HandleFunc("/api/v4/projects/test/test-repo2/hooks", func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + mockHTTPResponse(t,w,[]*gitlab.ProjectHook{{ID:123,URL: hookUrl}}) + } + }) + mux.HandleFunc("/api/v4/projects/test-repo2/hooks/123", func(w http.ResponseWriter, r *http.Request) { + if r.Method == "PUT" { + w.WriteHeader(http.StatusOK) + mockHTTPResponse(t,w, gitlab.ProjectHook{ID:123,URL: hookUrl}) + } + }) + + + + c := GitlabClientImpl{ + client: client, + cfg: &conf.GlobalConfig{ + GitProviderConfig: conf.GitProviderConfig{}, + }, + } + + // Define test cases + tests := []struct { + name string + repo *string + config *conf.GitProviderConfig + wantedError error + }{ + { + name: "Repo and orgWebHook enabled", + repo: utils.SPtr("repo"), + config: &conf.GitProviderConfig{ + OrgLevelWebhook: true, + OrgName: "test", + RepoList: "test-repo1", + WebhookURL: hookUrl, + }, + wantedError: errors.New("error"), + }, + { + name: "New group webhook", + repo: nil, + config: &conf.GitProviderConfig{ + OrgLevelWebhook: true, + OrgName: "groupA", + RepoList: "test-repo1", + WebhookURL: hookUrl, + }, + wantedError: nil, + }, + { + name: "Existing group webhook", + repo: nil, + config: &conf.GitProviderConfig{ + OrgLevelWebhook: true, + OrgName: "groupB", + RepoList: "test-repo1", + WebhookURL: hookUrl, + }, + wantedError: nil, + }, + { + name: "New project webhook", + repo: utils.SPtr("test-repo1"), + config: &conf.GitProviderConfig{ + OrgLevelWebhook: false, + OrgName: "test", + RepoList: "test-repo1", + WebhookURL: hookUrl, + }, + wantedError: nil, + }, + { + name: "Existing project webhook", + repo: utils.SPtr("test-repo2"), + config: &conf.GitProviderConfig{ + OrgLevelWebhook: false, + OrgName: "test", + RepoList: "test-repo2", + WebhookURL: hookUrl, + }, + wantedError: nil, + }, + + } + // Run test cases + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + c.cfg.GitProviderConfig = *test.config + // Call the function being tested + _, err := c.SetWebhook(&ctx, test.repo) + + // Use assert to check the equality of the error + if test.wantedError != nil { + assert.NotNil(err) + } else { + assert.Nil(err) + //assert.Equal(hookUrl, hook.Config["url"]) + } + }) + } +} diff --git a/pkg/git_provider/gitlab_utils.go b/pkg/git_provider/gitlab_utils.go new file mode 100644 index 0000000..d1e847e --- /dev/null +++ b/pkg/git_provider/gitlab_utils.go @@ -0,0 +1,154 @@ +package git_provider + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "strings" + + "github.com/quickube/piper/pkg/conf" + "github.com/quickube/piper/pkg/utils" + "github.com/xanzy/go-gitlab" + "golang.org/x/net/context" +) + +func ValidateGitlabPermissions(ctx context.Context, client *gitlab.Client, cfg *conf.GlobalConfig) error { + + repoAdminScopes := []string{"api"} + repoGranularScopes := []string{"write_repository", "read_api"} + + scopes, err := getGitlabScopes(ctx, client) + + if err != nil { + return fmt.Errorf("failed to get scopes: %v", err) + } + if len(scopes) == 0 { + return fmt.Errorf("permissions error: no scopes found for the gitlab client") + } + + if utils.ListContains(repoAdminScopes, scopes) { + return nil + } + if utils.ListContains(repoGranularScopes, scopes) { + return nil + } + + return fmt.Errorf("permissions error: %v is not a valid scope for the project level permissions", scopes) +} + +func getGitlabScopes(ctx context.Context, client *gitlab.Client) ([]string, error) { + + user, resp, err := client.Users.CurrentUser(gitlab.WithContext(ctx)) + if err != nil { + return nil, err + } + if resp.StatusCode == 400 { + return nil, err + } + a := gitlab.ListPersonalAccessTokensOptions{ + UserID: &user.ID, + } + accessTokens, resp, err := client.PersonalAccessTokens.ListPersonalAccessTokens(&a) + fmt.Println(accessTokens) + if err != nil { + return nil, err + } + if resp.StatusCode == 400 { + return nil, err + } + + scopes := accessTokens[0].Scopes + fmt.Println("Gitlab Token Scopes are:", scopes) + + return scopes, nil +} + +func IsGroupWebhookEnabled(ctx *context.Context, c *GitlabClientImpl) (*gitlab.GroupHook, bool) { + emptyHook := gitlab.GroupHook{} + hooks, resp, err := c.client.Groups.ListGroupHooks(c.cfg.GitProviderConfig.OrgName, nil, gitlab.WithContext(*ctx)) + if err != nil { + return &emptyHook, false + } + if resp.StatusCode != 200 { + return &emptyHook, false + } + if len(hooks) == 0 { + return &emptyHook, false + } + for _, hook := range hooks { + if hook.URL == c.cfg.GitProviderConfig.WebhookURL { + return hook, true + } + } + return &emptyHook, false +} + +func IsProjectWebhookEnabled(ctx *context.Context, c *GitlabClientImpl, project string) (*gitlab.ProjectHook, bool) { + emptyHook := gitlab.ProjectHook{} + projectFullName := fmt.Sprintf("%s/%s", c.cfg.GitProviderConfig.OrgName, project) + hooks, resp, err := c.client.Projects.ListProjectHooks(projectFullName, nil, gitlab.WithContext(*ctx)) + if err != nil { + return &emptyHook, false + } + if resp.StatusCode != 200 { + return &emptyHook, false + } + if len(hooks) == 0 { + return &emptyHook, false + } + + for _, hook := range hooks { + if hook.URL == c.cfg.GitProviderConfig.WebhookURL { + return hook, true + } + } + + return &emptyHook, false +} + +func ExtractLabelsId(labels []*gitlab.EventLabel) []string { + var returnLabelsList []string + for _, label := range labels { + returnLabelsList = append(returnLabelsList, fmt.Sprint(label.ID)) + } + return returnLabelsList +} + +func ValidatePayload(r *http.Request, secret []byte) ([]byte, error) { + payload, err := io.ReadAll(r.Body) + if err != nil { + return nil, fmt.Errorf("error reading request body: %v", err) + } + + // Get GitLab signature from headers + gitlabSignature := r.Header.Get("X-Gitlab-Token") + if gitlabSignature == "" { + return nil, fmt.Errorf("no GitLab signature found in headers") + } + + h := hmac.New(sha256.New, secret) + _, err = h.Write(payload) + if err != nil { + return nil, fmt.Errorf("error computing HMAC: %v", err) + } + expectedMAC := hex.EncodeToString(h.Sum(nil)) + + isEqual := hmac.Equal([]byte(gitlabSignature), []byte(expectedMAC)) + if !isEqual { + return nil, fmt.Errorf("secret not correct") + } + return payload, nil +} + +func FixRepoNames(c *GitlabClientImpl) error { + var formattedRepos []string + for _, repo := range strings.Split(c.cfg.GitProviderConfig.RepoList, ",") { + userRepo := fmt.Sprintf("%s/%s", c.cfg.GitProviderConfig.OrgName, repo) + formattedRepos = append(formattedRepos, userRepo) + } + c.cfg.GitProviderConfig.RepoList = strings.Join(formattedRepos, ",") + return nil +} diff --git a/pkg/git_provider/gitlab_utils_test.go b/pkg/git_provider/gitlab_utils_test.go new file mode 100644 index 0000000..88882df --- /dev/null +++ b/pkg/git_provider/gitlab_utils_test.go @@ -0,0 +1,151 @@ +package git_provider + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "testing" + + "github.com/quickube/piper/pkg/conf" + assertion "github.com/stretchr/testify/assert" + "github.com/xanzy/go-gitlab" + "golang.org/x/net/context" +) + +func mockHTTPResponse(t *testing.T, w io.Writer, response interface{}) { + json.NewEncoder(w).Encode(response) +} + + +func TestValidateGitlabPermissions(t *testing.T){ + // + // Prepare + // + type testData = struct { + name string + scopes []string + raiseErr bool + } + var CurrentTest testData + mux, client := setupGitlab(t) + c := GitlabClientImpl{ + client: client, + cfg: &conf.GlobalConfig{ + GitProviderConfig: conf.GitProviderConfig{ + OrgLevelWebhook: false, + OrgName: "test", + RepoList: "test-repo1", + }, + }, + } + ctx := context.Background() + mux.HandleFunc("/api/v4/user", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + mockHTTPResponse(t, w, gitlab.User{ID:1234}) + }) + mux.HandleFunc("/api/v4/personal_access_tokens", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + mockHTTPResponse(t, w, []gitlab.PersonalAccessToken{{Scopes: CurrentTest.scopes,}}) + }) + // + // Execute + // + tests := []testData{ + {name:"validScope", scopes: []string{"api"}, raiseErr: false}, + {name:"invalidScope", scopes: []string{"invalid"}, raiseErr: true}, + } + for _, test := range tests { + CurrentTest = test + t.Run(test.name, func(t *testing.T) { + err := ValidateGitlabPermissions(ctx, c.client, c.cfg) + // + // Assert + // + assert := assertion.New(t) + if test.raiseErr{ + assert.NotNil(err) + }else{ + assert.Nil(err) + } + }) + } +} + +func TestIsGroupWebhookEnabled(t *testing.T){ + // + // Prepare + // + mux, client := setupGitlab(t) + c := GitlabClientImpl{ + client: client, + cfg: &conf.GlobalConfig{ + GitProviderConfig: conf.GitProviderConfig{ + OrgLevelWebhook: true, + OrgName: "group1", + WebhookURL: "testing-url", + }, + }, + } + + hook := []gitlab.GroupHook{{ + ID: 1234, + URL: c.cfg.GitProviderConfig.WebhookURL, + },} + + mux.HandleFunc(fmt.Sprintf("/api/v4/groups/%s/hooks", c.cfg.GitProviderConfig.OrgName), func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + mockHTTPResponse(t, w, hook) + }) + // + // Execute + // + groupHook, isEnabled := IsGroupWebhookEnabled(&c) + // + // Assert + // + assert := assertion.New(t) + assert.Equal(isEnabled, true) + assert.Equal(groupHook.URL, c.cfg.GitProviderConfig.WebhookURL) +} + +func TestIsProjectWebhookEnabled(t *testing.T){ + // + // Prepare + // + mux, client := setupGitlab(t) + project := "test-repo1" + c := GitlabClientImpl{ + client: client, + cfg: &conf.GlobalConfig{ + GitProviderConfig: conf.GitProviderConfig{ + OrgLevelWebhook: false, + OrgName: "group1", + WebhookURL: "testing-url", + RepoList: project, + }, + }, + } + + hook := []gitlab.ProjectHook{{ + ID: 1234, + URL: c.cfg.GitProviderConfig.WebhookURL, + },} + + hooksUrl := fmt.Sprintf("/api/v4/projects/%s/%s/hooks",c.cfg.GitProviderConfig.OrgName, project) + mux.HandleFunc(hooksUrl, func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + mockHTTPResponse(t, w, hook) + }) + // + // Execute + // + projectHook, isEnabled := IsProjectWebhookEnabled(&c, "test-repo1") + // + // Assert + // + assert := assertion.New(t) + assert.Equal(isEnabled, true) + assert.Equal(projectHook.URL, c.cfg.GitProviderConfig.WebhookURL) +} + diff --git a/pkg/git_provider/main.go b/pkg/git_provider/main.go index 9ebc5fa..6143b9a 100644 --- a/pkg/git_provider/main.go +++ b/pkg/git_provider/main.go @@ -20,6 +20,12 @@ func NewGitProviderClient(cfg *conf.GlobalConfig) (Client, error) { return nil, err } return gitClient, nil + case "gitlab": + gitClient, err := NewGitlabClient(cfg) + if err != nil { + return nil, err + } + return gitClient, nil } return nil, fmt.Errorf("didn't find matching git provider %s", cfg.GitProviderConfig.Provider) diff --git a/pkg/git_provider/test_utils.go b/pkg/git_provider/test_utils.go index 20c13b5..909032f 100644 --- a/pkg/git_provider/test_utils.go +++ b/pkg/git_provider/test_utils.go @@ -2,14 +2,17 @@ package git_provider import ( "fmt" - "github.com/google/go-cmp/cmp" - "github.com/google/go-github/v52/github" - "github.com/ktrysmt/go-bitbucket" "net/http" "net/http/httptest" "net/url" "os" "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-github/v52/github" + "github.com/ktrysmt/go-bitbucket" + "github.com/xanzy/go-gitlab" ) const ( @@ -88,3 +91,25 @@ func setupBitbucket() (client *bitbucket.Client, mux *http.ServeMux, serverURL s return client, mux, server.URL, server.Close } +func setupGitlab(t *testing.T) (*http.ServeMux, *gitlab.Client) { + // mux is the HTTP request multiplexer used with the test server. + mux := http.NewServeMux() + + // server is a test HTTP server used to provide mock API responses. + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + // client is the Gitlab client being tested. + client, err := gitlab.NewClient("", + gitlab.WithBaseURL(server.URL), + // Disable backoff to speed up tests that expect errors. + gitlab.WithCustomBackoff(func(_, _ time.Duration, _ int, _ *http.Response) time.Duration { + return 0 + }), + ) + if err != nil { + t.Fatalf("Failed to create client: %v", err) + } + + return mux, client +} \ No newline at end of file