OpenShiftで始めるCI/CD：GitLabとSonarQube、Tekton Triggersで実現するパイプライン自動化

# 前提
本記事では、前回の記事に続いて、OpenShift上のGitLabとSonarQube、Tekton Triggersを利用して、GitLabへのpushをトリガーに、Angularアプリのパイプラインを自動実行する仕組みを示す。

https://qiita.com/ssh-22/items/c6859fe5c6967f28dd75

# アーキテクチャ

```mermaid
graph TD
    subgraph OpenShift Cluster
        subgraph GitLab
            A[Push Event]
        end
        
        subgraph Tekton Triggers
            B[Webhook Listener]
            C[Webhook Trigger]
            D[Trigger Binding]
        end
        
        subgraph Tekton Pipeline
            E[Pipeline Execution]
            F[Git Clone Stage]
            G[Lint Stage]
            H[Test Stage]
            I[e2e Stage]
            J[SonarQube Analysis]
        end
        
        subgraph SonarQube
            K[Code Quality Analysis]
        end
    end
    
    A --> B
    B --> C
    C --> D
    D --> E
    E --> F
    F --> G
    G --> H
    H --> I
    I --> J
    J --> K
```

# リソース
作成するリソースは下記の通り。

## Tekton Pipelines
[Kubernetes CI/CDパイプラインの実装 \(impress top gear\) ](https://www.amazon.co.jp/dp/4295012750)と[OpenShift Pipelines のご紹介(Tekton Triggers編)](https://rheb.hatenablog.com/entry/2021/07/30/OpenShift_Pipelines_%E3%81%AE%E3%81%94%E7%B4%B9%E4%BB%8B%28Tekton_Triggers%E7%B7%A8%29)を参考に作成。体系的に内容を理解したい場合は前者の本がおすすめ。

### Pipeline
```yaml
apiVersion: tekton.dev/v1beta1
kind: Pipeline
metadata:
  name: npm
spec:
  params:
    - name: url
      type: string
    - name: revision
      type: string
  workspaces:
    - name: shared-workspace
  tasks:
    - name: git-clone
      taskRef:
        name: git-clone
        kind: ClusterTask
      params:
        - name: url
          value: "$(params.url)"
        - name: revision
          value: "$(params.revision)"
        - name: sslVerify
          value: "false"
      workspaces:
        - name: output
          workspace: shared-workspace
    - name: npm-test-install
      runAfter:
        - git-clone
      taskSpec:
        steps:
          - name: npm-test-install
            image: cypress/included
            workingDir: /workspace/shared-workspace
            script: |
              #!/bin/sh
              npm install
      workspaces:
        - name: shared-workspace
    - name: npm-lint
      runAfter:
        - npm-test-install
      taskSpec:
        steps:
          - name: npm-lint
            image: cypress/included
            workingDir: /workspace/shared-workspace
            script: |
              #!/bin/sh
              npm run lint
      workspaces:
        - name: shared-workspace
    - name: npm-test
      runAfter:
        - npm-lint
      taskSpec:
        steps:
          - name: npm-test
            image: cypress/included
            workingDir: /workspace/shared-workspace
            script: |
              #!/bin/sh
              npm run test:ci
      workspaces:
        - name: shared-workspace
    - name: npm-sonar
      runAfter:
        - npm-test
      taskSpec:
        steps:
          - name: npm-sonar
            image: cypress/included
            workingDir: /workspace/shared-workspace
            script: |
              #!/bin/sh
              echo "SONAR_TOKEN: $SONAR_TOKEN"
              echo "SONAR_HOST_URL: $SONAR_HOST_URL"
              cat /workspace/shared-workspace/sonar-project.properties
              npm run sonar:ci
            env:
              - name: SONAR_TOKEN
                valueFrom:
                  secretKeyRef:
                    name: sonarqube-secret
                    key: SONAR_TOKEN
              - name: SONAR_HOST_URL
                valueFrom:
                  configMapKeyRef:
                    name: sonarqube-config
                    key: SONAR_HOST_URL
            volumeMounts:
              - name: sonarqube-config
                mountPath: /workspace/shared-workspace/sonar-project.properties
                subPath: sonar-project.properties
                readOnly: true
        volumes:
          - name: sonarqube-config
            configMap:
              name: sonarqube-config
      workspaces:
        - name: shared-workspace
    - name: npm-e2e-install
      runAfter:
        - npm-sonar
      taskSpec:
        steps:
          - name: npm-e2e-install
            image: mcr.microsoft.com/playwright:v1.49.1-noble
            workingDir: /workspace/shared-workspace
            script: |
              #!/bin/sh
              npm install
              npx playwright install
      workspaces:
        - name: shared-workspace
    - name: npm-e2e
      runAfter:
        - npm-e2e-install
      taskSpec:
        steps:
          - name: e2e-test
            image: mcr.microsoft.com/playwright:v1.49.1-noble
            workingDir: /workspace/shared-workspace
            script: |
              #!/bin/sh
              npm run e2e
      workspaces:
        - name: shared-workspace

```

### Trigger Binding
GitLab webhookのbodyは下記を参照。

https://docs.gitlab.com/ee/user/project/integrations/webhook_events.html#push-events

```yaml
apiVersion: triggers.tekton.dev/v1beta1
kind: TriggerBinding
metadata:
  name: gitlab-binding
spec:
  params:
    - name: url
      value: "$(body.repository.git_ssh_url)"
    - name: revision
      value: "$(body.ref)"
```
### Trigger Template
```yaml
apiVersion: triggers.tekton.dev/v1beta1
kind: TriggerTemplate
metadata:
  name: gitlab-trigger-template
spec:
  params:
    - name: url
    - name: revision
  resourcetemplates:
    - apiVersion: tekton.dev/v1beta1
      kind: PipelineRun
      metadata:
        generateName: npm-
      spec:
        pipelineRef:
          name: npm
        podTemplate:
          securityContext:
            fsGroup: 65532
            runAsUser: 1000
            runAsGroup: 1000
        params:
          - name: url
            value: "$(tt.params.url)"
          - name: revision
            value: "$(tt.params.revision)"
        workspaces:
          - name: shared-workspace
            persistentVolumeClaim:
              claimName: shared-workspace
        serviceAccountName: git-sa
```

### EventListener
:::note warn
検証目的のため、下記のようにinterceptorsは実装していない。
参考：https://tekton.dev/docs/triggers/interceptors/#gitlab-interceptors

```yaml
      interceptors:
        - gitlab:
            secretRef:
              secretName: gitlab-webhook
              secretKey: webhook-secret
            eventTypes:
              - push
        - cel:
            filter: "body.ref == \"refs/heads/main\""
```
:::



```yaml
apiVersion: triggers.tekton.dev/v1beta1
kind: EventListener
metadata:
  name: gitlab-listener
spec:
  serviceAccountName: git-sa
  triggers:
    - name: gitlab-trigger
      bindings:
        - ref: gitlab-binding
      template:
        ref: gitlab-trigger-template
```

### Role
下記URLを参考に作成。

https://gitlab.com/cloudnative_impress/bookinfo-manifests/-/blob/main/tekton/auth/tekton-admin-triggers-rbac.yaml?ref_type=heads

```yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: tekton-triggers-role
rules:
  # EventListeners need to be able to fetch all namespaced resources
  - apiGroups: ["triggers.tekton.dev"]
    resources: ["eventlisteners", "triggerbindings", "triggertemplates", "triggers"]
    verbs: ["get", "list", "watch"]
  - apiGroups: [""]
  # configmaps is needed for updating logging config
    resources: ["configmaps"]
    verbs: ["get", "list", "watch"]
  # Permissions to create resources in associated TriggerTemplates
  - apiGroups: ["tekton.dev"]
    resources: ["pipelineruns", "pipelineresources", "taskruns"]
    verbs: ["create"]
  - apiGroups: [""]
    resources: ["serviceaccounts"]
    verbs: ["impersonate"]
  - apiGroups: ["policy"]
    resources: ["podsecuritypolicies"]
    resourceNames: ["tekton-triggers"]
    verbs: ["use"]
```

### RoleBinding
```yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: tekton-triggers-rolebinding
subjects:
  - kind: ServiceAccount
    name: git-sa
roleRef:
  kind: Role
  name: tekton-triggers-role
  apiGroup: rbac.authorization.k8s.io
```

### ClusterRole
下記URLを参考に作成。

https://gitlab.com/cloudnative_impress/bookinfo-manifests/-/blob/main/tekton/auth/tekton-admin-triggers-rbac.yaml?ref_type=heads

```yaml
kind: ClusterRole
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: tekton-triggers-clusterrole
rules:
  # EventListeners need to be able to fetch any clustertriggerbindings
- apiGroups: ["triggers.tekton.dev"]
  resources: ["clustertriggerbindings", "clusterinterceptors"]
  verbs: ["get", "list", "watch"]
```

### ClusterRoleBinging
```yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: tekton-triggers-clusterbinding
subjects:
- kind: ServiceAccount
  name: git-sa
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: tekton-triggers-clusterrole
```

## GitLab
前回の記事同様、git cloneはsshで行う前提。

### Deployment
:::note warn
内部IPの名前解決となるため、allow_local_requests_from_web_hooks_and_servicesとenforce_dns_rebinding_protectionなどの設定が必要。
:::

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: gitlab
  labels:
    app: gitlab
spec:
  replicas: 1
  selector:
    matchLabels:
      app: gitlab
  template:
    metadata:
      labels:
        app: gitlab
    spec:
      containers:
        - name: gitlab
          image: docker.io/gitlab/gitlab-ce:17.7.0-ce.0
          resources:
            limits:
              memory: "8Gi"
              cpu: "1"
            requests:
              memory: "4Gi"
              cpu: "500m"
          securityContext:
            runAsUser: 0
            runAsGroup: 0
            allowPrivilegeEscalation: true
            privileged: true
          ports:
            - containerPort: 80
            - containerPort: 443
            - containerPort: 22
          env:
            - name: GITLAB_OMNIBUS_CONFIG
              value: |
                external_url '<RoutesのURL>';
                gitlab_rails['gitlab_shell_ssh_port'] = 30222;
                gitlab_rails['webhook_timeout'] = 60;
                gitlab_rails['trusted_proxies'] = ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'];
                gitlab_rails['allow_local_requests_from_web_hooks_and_services'] = true;
                gitlab_rails['allow_local_requests_from_system_hooks'] = true;
                gitlab_rails['enforce_dns_rebinding_protection'] = false;
          volumeMounts:
            - name: gitlab-config
              mountPath: /etc/gitlab/gitlab.rb
              subPath: gitlab.rb
            - name: gitlab-logs
              mountPath: /var/log/gitlab
            - name: gitlab-data
              mountPath: /var/opt/gitlab
      volumes:
        - name: gitlab-config
          configMap:
            name: gitlab-config
        - name: gitlab-logs
          persistentVolumeClaim:
            claimName: gitlab-logs-pvc
        - name: gitlab-data
          persistentVolumeClaim:
            claimName: gitlab-data-pvc
```

### Service
```yaml
apiVersion: v1
kind: Service
metadata:
  name: gitlab
spec:
  type: NodePort
  selector:
    app: gitlab
  ports:
    - name: http
      protocol: TCP
      port: 80
      targetPort: 80
      nodePort: 30080
    - name: https
      protocol: TCP
      port: 443
      targetPort: 443
      nodePort: 30443
    - name: ssh
      protocol: TCP
      port: 22
      targetPort: 22
      nodePort: 30022
```

### PVC
```yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: gitlab-config-pvc
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 5Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: gitlab-logs-pvc
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 5Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: gitlab-data-pvc
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 10Gi
```

### ConfigMap
:::note info
envと内容が重複するため、どちらかで良いと思われる。
:::
```yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: gitlab-config
data:
  gitlab.rb: |
    external_url '<RoutesのURL>'
    gitlab_rails['gitlab_shell_ssh_port'] = 30022
    gitlab_rails['webhook_timeout'] = 60
    gitlab_rails['trusted_proxies'] = ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16']
    gitlab_rails['allow_local_requests_from_web_hooks_and_services'] = true
    gitlab_rails['allow_local_requests_from_system_hooks'] = true
    gitlab_rails['enforce_dns_rebinding_protection'] = false
```

### Secret
```yaml
apiVersion: v1
kind: Secret
metadata:
  name: git-auth-secret
  annotations:
    tekton.dev/git-0: <gitlabのサービス名>
type: kubernetes.io/ssh-auth
data:
  ssh-privatekey: <base64エンコードしたssh秘密鍵>
```
## SonarQube
### Deployment
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: sonarqube
  labels:
    app: sonarqube
spec:
  selector:
    matchLabels:
      app: sonarqube
  template:
    metadata:
      labels:
        app: sonarqube
    spec:
      containers:
      - name: sonarqube
        image: sonarqube:lts
        resources:
          limits:
            memory: "2Gi"
            cpu: "1000m"
          requests:
            memory: "1Gi"
            cpu: "500m"
        ports:
        - containerPort: 9000
        env:
        - name: SONAR_ES_BOOTSTRAP_CHECKS_DISABLE
          value: "true"
        - name: SONAR_SEARCH_JAVAOPTS
          value: "-Xms512m -Xmx1g"
        volumeMounts:
        - name: sonarqube-data
          mountPath: /opt/sonarqube/data
        - name: sonarqube-logs
          mountPath: /opt/sonarqube/logs
        - name: sonarqube-extensions
          mountPath: /opt/sonarqube/extensions
      volumes:
      - name: sonarqube-data
        persistentVolumeClaim:
          claimName: sonarqube-data
      - name: sonarqube-logs
        persistentVolumeClaim:
          claimName: sonarqube-logs
      - name: sonarqube-extensions
        persistentVolumeClaim:
          claimName: sonarqube-extensions
```

### Service
```yaml
apiVersion: v1
kind: Service
metadata:
  name: sonarqube
spec:
  type: NodePort
  selector:
    app: sonarqube
  ports:
  - port: 9000
    targetPort: 9000
    nodePort: 30090
```

### PVC
```yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: sonarqube-data
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 5Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: sonarqube-logs
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 2Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: sonarqube-extensions
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 1Gi
```

### ConfigMap
```yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: sonarqube-config
data:
  sonar-project.properties: |
    sonar.projectName=angular
    sonar.projectKey=angular
    sonar.sources=src
    sonar.tests=tests
    sonar.javascript.lcov.reportPaths=coverage/lcov.info
    sonar.junit.reportPaths=results/test/results.xml
    sonar.exclusions=**/*.spec.ts,**/node_modules/**,**/tests/**
    sonar.eslint.reportPaths=results/lint/eslint-report.json
  SONAR_HOST_URL: http://sonarqube:9000
```


### Secret
```yaml
apiVersion: v1
kind: Secret
metadata:
  name: sonarqube-secret
type: Opaque
data:
  SONAR_TOKEN: <SonarQubeで発行したトークン>
```

## Angular
### package.json
```json
{
  "name": "my-first-angular-app",
  "version": "0.0.0",
  "scripts": {
    "ng": "ng",
    "start": "ng serve",
    "build": "ng build",
    "watch": "ng build --watch --configuration development",
    "test": "ng test --code-coverage",
    "test:ci": "ng test --no-watch --no-progress --browsers=ChromeHeadless --code-coverage",
    "e2e": "playwright test",
    "lint": "eslint 'src/**/*.{ts,html}' --output-file results/lint/eslint-report.json --format json",
    "sonar": "node sonar-scanner.js",
    "sonar:ci": "sonar-scanner -Dsonar.login=$SONAR_TOKEN -Dsonar.host.url=$SONAR_HOST_URL -X"
  },
  "private": true,
  "dependencies": {
    "@angular/animations": "^19.0.0",
    "@angular/common": "^19.0.0",
    "@angular/compiler": "^19.0.0",
    "@angular/core": "^19.0.0",
    "@angular/forms": "^19.0.0",
    "@angular/platform-browser": "^19.0.0",
    "@angular/platform-browser-dynamic": "^19.0.0",
    "@angular/router": "^19.0.0",
    "rxjs": "~7.8.0",
    "tslib": "^2.3.0",
    "zone.js": "~0.15.0"
  },
  "devDependencies": {
    "@angular-devkit/build-angular": "^19.0.6",
    "@angular/cli": "^19.0.6",
    "@angular/compiler-cli": "^19.0.0",
    "@playwright/test": "^1.49.1",
    "@types/jasmine": "~5.1.0",
    "angular-eslint": "19.0.2",
    "eslint": "^9.16.0",
    "jasmine-core": "~5.4.0",
    "karma": "~6.4.0",
    "karma-chrome-launcher": "~3.2.0",
    "karma-coverage": "~2.2.0",
    "karma-jasmine": "~5.1.0",
    "karma-junit-reporter": "^2.0.1",
    "karma-webpack": "^5.0.1",
    "puppeteer": "^23.11.1",
    "sonarqube-scanner": "^4.2.6",
    "typescript": "~5.6.2",
    "typescript-eslint": "8.18.0"
  }
}
```

### karma.conf.js
```js
module.exports = function (config) {
    config.set({
      // ベースパス（ファイルを解決する際の基準ディレクトリ）
      basePath: '',
  
      // フレームワークを指定（例: Jasmine, Mochaなど）
      frameworks: ['jasmine', '@angular-devkit/build-angular'],
  
      // ファイルやパターンを指定してテストに含める
      files: [],
  
      // ファイルやパターンを指定してテストから除外する
      exclude: [],
  
      // プリプロセッサを指定
      preprocessors: {},
  
      // レポート形式
      reporters: ['progress', 'junit', 'coverage'],
  
      // テスト結果を保存するディレクトリ
      junitReporter: {
        outputDir: require('path').join(__dirname, './results/test'),
        outputFile: 'results.xml',
        useBrowserName: false
      },
  
      // サーバーのポート
      port: 9876,
  
      // カラフルなログ出力を有効化
      colors: true,
  
      // ログレベル（例: config.LOG_INFO, config.LOG_DEBUGなど）
      logLevel: config.LOG_INFO,
  
      // ファイル変更を監視して自動でテストを再実行する
      autoWatch: true,
  
      // 使用するブラウザを指定
      browsers: ['ChromeHeadless'],
  
      // カスタムブラウザのランチャーを設定
      customLaunchers: {
        ChromeHeadlessCustom: {
          base: 'ChromeHeadless',
          flags: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage', '--remote-debugging-port=9222']
        }
      },
  
      // シングルラン実行モード（デフォルト: false）
      singleRun: false,
  
      // 並列実行の数（デフォルト: 無制限）
      concurrency: Infinity,
  
      // タイムアウト設定
      captureTimeout: 120000, // Chromeが接続を確立するまでのタイムアウト
      browserDisconnectTimeout: 10000, // ブラウザが切断されるまでのタイムアウト
      browserNoActivityTimeout: 60000, // テスト中のアクティビティがない場合のタイムアウト
  
      // Angular CLIの構成をロード
      client: {
        clearContext: false, // Karmaの実行後、ブラウザのコンテキストをクリアしない
      },
  
      // プラグインの設定
      plugins: [
        require('karma-jasmine'),
        require('karma-chrome-launcher'),
        require('karma-coverage'),
        require('karma-junit-reporter'),
        require('@angular-devkit/build-angular/plugins/karma'),
      ],
  
      coverageReporter: {
        dir: require('path').join(__dirname, './coverage'), // 出力先ディレクトリ
        reporters: [
          { type: 'html' }, // HTML形式のレポート
          { type: 'lcovonly', subdir: '.', file: 'lcov.info' } // lcov.infoを生成
        ],
        fixWebpackSourcePaths: true
      },

      failOnEmptyTestSuite: false,  // 空のテストスイートでもエラーにしない
    });
  };

```