【CI/CD】UI5プロジェクトのパイプラインを構築するまで　～パイプライン編～

##はじめに
UI5のCI/CDパイプラインを構築してみて、苦労した点、解決方法などを紹介します。
目的は、Test、Build、Deployまで通してやってみることです。
SAPの[Project Piper](https://sap.github.io/jenkins-library/)という洗練されたツールもあるのですが、まだDockerもJenkinsもよくわかっていないので、まずは自分が理解できるレベルから始めようと思いました。

環境構築編は[こちら](https://qiita.com/tami/items/43b43651792afcea11ea)

##ゴール
以下のリポジトリにあるUI5 Toolingのサンプルプロジェクトを使ってパイプラインを構築します。
https://github.com/SAP/openui5-sample-app
Test、Buildを経てABAPサーバへのデプロイまで実行します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/98d25765-46d8-c649-9827-cce8eea6e1af.png)

##ステップ
1. UI5 ToolingのサンプルアプリをFork
2. Jenkinsfileを追加
3. Jenkinsのパイプラインを作成
4. Testステップでのエラーつぶし
5. Deploy

##1. UI5 ToolingのサンプルアプリをFork
今回の目的はCI/CDのパイプラインを作ることなので、アプリの作成は省略しました。
以下のサンプルアプリをForkして自分のリポジトリに持ってきました。
このプロジェクトはテストがちゃんと書いてあるのでTestステップの確認にも使えるのです。
https://github.com/SAP/openui5-sample-app

###パイプラインに関係するファイル
####project.json
project.jsonでは以下のスクリプトが定義されていました。buildとtestのスクリプトはそのまま使えそうです。

```json:package.json
{
	"name": "openui5-sample-app",
	"version": "0.2.0",
	"description": "Sample of an OpenUI5 app",
	"private": true,
	"scripts": {
		"start": "ui5 serve",
		"lint": "eslint webapp",
		"karma": "karma start",
		"karma-ci": "rimraf coverage && karma start karma-ci.conf.js",
		"watch": "npm run karma",
		"test": "npm run lint && npm run karma-ci",
		"build": "ui5 build -a --clean-dest",
		"build-self-contained": "ui5 build self-contained -a --clean-dest",
		"serve-dist": "ws --compress -d dist"
	},
	"dependencies": {},
	"devDependencies": {
		"@ui5/cli": "^2.2.6",
		"eslint": "^6.8.0",
		"karma": "^4.4.1",
		"karma-chrome-launcher": "^3.1.0",
		"karma-coverage": "^2.0.2",
		"karma-ui5": "^2.1.2",
		"local-web-server": "^3.0.7",
		"rimraf": "^3.0.2"
	}
}
```
####karma.conf.js
karma.conf.jsはKarmaによる自動テストで使われる設定ファイルです。devDependencyにある[karma-ui5](https://github.com/SAP/karma-ui5)というモジュールのおかげで、UI5のテストがとてもシンプルな設定で行えます。customLaunchersとして2つの設定（Chrome、ChromeHeadless）がありますが、CI環境ではChromeHeadlessを使います。ChromeHeadlessでは大変苦労することになるのですが、これについては後述します。

```javascript:karma.conf.js
module.exports = function(config) {
	"use strict";

	var chromeFlags = [
		"--window-size=1280,1024"
	];

	config.set({

		frameworks: ["ui5"],

		browsers: ["CustomChrome"],

		browserConsoleLogOptions: {
			level: "error"
		},

		customLaunchers: {
			CustomChrome: {
				base: "Chrome",
				flags: chromeFlags
			},
			CustomChromeHeadless: {
				base: "ChromeHeadless",
				flags: chromeFlags
			}
		},

	});
};
```

####karma-ci.conf.js
karma-ci.conf.jsはCI環境使うためのKarmaの設定ファイルです。karma.conf.jsの設定とマージして使われます。

```javascript:karma-ci.conf.js
module.exports = function(config) {
	"use strict";

	require("./karma.conf")(config);
	config.set({

		preprocessors: {
			"{webapp,webapp/!(test)}/*.js": ["coverage"]
		},

		coverageReporter: {
			includeAllSources: true,
			reporters: [
				{
					type: "html",
					dir: "coverage"
				},
				{
					type: "text"
				}
			],
			check: {
				each: {
					statements: 100,
					branches: 100,
					functions: 100,
					lines: 100
				}
			}
		},

		reporters: ["progress", "coverage"],

		browsers: ["CustomChromeHeadless"],

		singleRun: true

	});
};
```


##2. Jenkinsfileを追加
まずは、以下のようなJenkinsfileを作成しました。実行環境としてnode:14-alpineというDockerイメージを使うことにしました（nodeが使えて新しめという理由で選択）。この状態でプロジェクトをリモートリポジトリにpushします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/0b524abc-0634-a143-967c-a29f19e3223e.png)

##3. Jenkinsのパイプラインを作成
Blue Oceanのメニューからパイプラインを作成します。Blue Oceanのメニューからだとウィザードに従っていくだけで簡単にパイプラインが作れます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/1b3de30c-6efb-da92-18cd-99a2b16d12ee.png)
以下の動画で詳しいステップが説明されています。初回はGitアカウントのアクセストークンの設定が必要になります。
[Jenkins Minute - Creating Your First Pipeline in Blue Ocean](https://www.youtube.com/watch?v=FhDomw6BaHU)
ここで作成されたパイプラインはMultibranch Pipelineになります。すなわち、複数のブランチがある場合に、Jenkinsfileを持つすべてのブランチに対して個別のパイプラインが作成されます。

作成したMultibranch Pipelineに対して"Scan Repository Now"を実行すると、リポジトリが検索され、Jenkinsfileを持つブランチのビルドが開始します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/49ebd48f-dab9-cb2b-4e00-8411b60f1734.png)

##4. Testステップでのエラーつぶし
一番苦労したのはTestステップでした。Testステップは以下のスクリプトで起動し、ESLintによるコードチェックとUnitテスト、Integrationテスト(OPA5)が実行されます。

```
"test": "npm run lint && npm run karma-ci"
```

###エラー1: 環境変数CHROME_BINがない
ビルドすると、さっそく以下のようなエラーが出ました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/e1e7a6a7-b5be-b6b3-a280-6130e1298d0b.png)

####対応：puppeteerを使う（ただし暫定）
puppeteerというモジュールは、環境に応じたChromeの実行ファイルをダウンロードしてくれます。以下のように設定してみました。

- puppeteerをdevDependencyに追加
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/01ea3567-1c55-660f-acd5-edd3c139e345.png)
- karma.conf.jsの先頭でCHROME_BINの設定を追加
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/8e773dc2-010d-e712-8de9-1cb300216350.png)

この結果、CHROME_BINがないエラーは解消しました。

###エラー2: ChromeHeadlessが見つからない
次に、ChromeHeadlessが見つからないというエラーが出ました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/d2d290bb-c9e9-38d2-91ec-5b1e492f33fa.png)

調べていくと、どうもDockerコンテナの中でChromeHeadlessを動かすのは一筋縄ではいかないらしいことがわかりました。
>4. also Chrome requires some system libraries and doesn’t work on default nodejs Docker image. So, I’ve found `geekykaran/headless-chrome-node-docker`.

https://medium.com/front-end-weekly/karma-js-headless-chrome-and-docker-35c134df28f3 より

####対応：rastasheep/alpine-node-chromiumを使う
nodeとchromeが使えるDockerイメージを探していたところ、以下を見つけました。
https://github.com/rastasheep/alpine-node-chromium/blob/master/12-alpine/Dockerfile
シンプルで、何をやっているか自分でも理解できそうだったので、これを使うことにしました。このファイルの中身をまるごとコピーして、プロジェクト直下にDockerfileとして配置しました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/2d23bb5d-1153-857a-f20e-d9b13d684185.png)

ローカルのDockerfileを使うので、Jenkinsfileのagentは以下のように修正します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/f6d05635-7627-a4c0-b60f-b588053416dd.png)

この結果、ChromeHeadlessは見つかるようになり、OPA5テストが実行できるようになりました。なお、puppeteerは不要になったので、エラー1で設定した内容は元に戻しました。

###エラー3：OPA5テストが途中で失敗する
出だしのテストはうまくいくのですが、途中でエラーになってしまいます。画面のボタンを押したり、フィルタを適用したりするケースでエラーになっていました。特定の画面操作がうまくいっていないようです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/e03720c5-30b7-1788-78ae-b9cc1bf927d4.png)

実は、ChromeHeadlessが見つからない事象について調べていた時に、`--no-sandbox`を付けたら解消したという情報が複数あったため、karma.conf.jsの設定を以下のようにしていました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/4716720d-84d9-b5a7-e87d-8b8d5878d753.png)

`--no-sandbox`をつけると、CI環境だけでなくローカル環境でもOPA5テストが失敗することに気づきました。`--no-sandbox`を外してみたところ、CI環境では権限エラーらしきものが出ました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/f62e4061-d637-c25d-bdda-0eab29a2af63.png)

####背景
Dockerコンテナはrootユーザー（全権限を持ったユーザー）で実行していますが、rootユーザーだとsandboxモードがうまく動かないようです。そこで、以下の対応が必要になります。

- rootでないユーザで実行する
- そのユーザに適切な権限を付与する

[こちら]((https://ndportmann.com/chrome-in-docker/))の記事で以下の対応方法が紹介されていました。（1. おすすめでない->4. おすすめ）
1. sandboxをやめる（つまり`--no-sandbox`をつける。今回は使用できない）
2. Dockerコンテナを起動するときに`--privileged`をつける
3. Dockerコンテナを起動するときに`--cap-add=SYS_ADMIN`をつける
4. Dockerコンテナを起動するとき`--security-opt seccomp=<設定ファイル>`で許可するシステムコールの種類を指定する
セキュリティの観点から、4.が推奨されるということです。

4.で使う設定ファイルは以下からダウンロードすることができます。
https://raw.githubusercontent.com/jfrazelle/dotfiles/master/etc/docker/seccomp/chrome.json
参考：https://hub.docker.com/r/zenika/alpine-chrome

####対応
#####rootでないユーザで実行する
まずはrootでないユーザで実行する必要があるため、Dockerfileに以下のコードを追加しました。chromiumというシステムグループ、およびシステムユーザを作成しています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/24a9ae63-fe8e-2034-7ffd-8927f145aefa.png)
ここにもちょっとした罠があって、alpineのイメージを使用しているためgroupaddやuseraddコマンドは使えず、addgroup、adduserなら使えるということでした。
https://stackoverflow.com/questions/49955097/how-do-i-add-a-user-when-im-using-alpine-as-a-base-image

この設定をしたうえで、Dockerfileのagentの指定を以下のように書き換えたところ、とりあえずテストはうまくいくようになりました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/f745a226-19ac-5605-273f-2a2b62fc7df9.png)

#####Dockerコンテナを起動するときにシステムコールの種類を指定する
やはり推奨されている設定で実行したいと思い、この方法にトライしてみました。ただ、設定ファイルをどこに置けばいいのかがまずわかりませんでした。

試行錯誤しているうちに、jenkins_home_volumeというボリュームの存在に気づきました（Cx Serverを使っているとボリュームのマウントも自動的にやってくれる）。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/4e0308f3-f42a-7ef8-9158-93a2fff9ee5f.png)

このボリュームの中に設定ファイルを入れればJenkinsからアクセスできそうだったので、以下のようにしました。

```sh
#一時的なコンテナを作成してjenkins_home_volumeをマウント
sudo docker run --rm -it -v jenkins_home_volume:/tmp/jenkins_home_volume busybox
cd /tmp/jenkins_home_volume

#設定ファイル格納用のディレクトリを作成
mkdir myseccomp
cd myseccomp

#設定ファイルをインストール
wget https://raw.githubusercontent.com/jfrazelle/dotfiles/master/etc/docker/seccomp/chrome.json
```

Jenkinsfileで、Dockerコンテナを起動するときのオプションを以下のように設定
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/a585911f-c4c9-9059-d5e9-e404447fecd8.png)

結果、すべてのテストが成功しました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/8a24266e-55e8-a191-478c-51bb4aac0a3a.png)

Buildも問題なく終了しました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/a90bf53d-9eb9-ab97-6b9c-c90a72ec9346.png)

##5. Deploy
[Project Piper](https://sap.github.io/jenkins-library/scenarios/upload-to-transportrequest/Readme/)では、デプロイはABAPサーバのRFCまたはODataサービスを使用して行いますが、今回はより簡単に、[nwabap-ui5uploader](https://www.npmjs.com/package/nwabap-ui5uploader)というモジュールを使用してデプロイします。詳しいことはわからないのですが、このモジュールはWebIDEからABAPサーバへデプロイするのと同じ仕組みを使っているそうです。

まずは、これをdevDependencyに追加します。

```
npm install nwabap-ui5uploader --save-dev
```

package.jsonにデプロイ用のスクリプトを追加します。

```json:package.json
	"scripts": {
		...
		"upload": "npx nwabap upload",
		...
	}
```

.nwabaprcというファイルをプロジェクト直下に作成し、デプロイに必要な設定を入れます。

```
{
    "base": "./dist", 
    "conn_server": "http://<host>:<port>", 
    "conn_user": "<user>",                 
    "conn_password": "<password>",             
    "abap_package": "<package>",                
    "abap_bsp": "<BSPアプリのID>",            
    "abap_bsp_text": "<BSPアプリのテキスト>",
    "abap_transport": "<移送依頼>"
}
```

JenkinsfileにDeployステップを追加します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/4ac7ed1d-bb7d-f1e9-9be9-d1643ebcc1b2.png)

####デプロイが終わらない！
まずはローカルでデプロイしてみたところ、デプロイ対象のファイルが5000を超える事態になってしまいました。（以下は通常の場合の画面。40件が対象になっている）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/d3ba0c63-6bac-c571-2f5e-983d07dd29d3.png)

実は、今回使用したプロジェクトがスタンドアロンで実行することを想定した作りになっており、ビルドしたときにUI5のライブラリをすべてdist/resourcesフォルダにインストールする動きになっていました。ABAPサーバにデプロイする場合には必要ないため、以下の修正を加えました。

#####sap-ui-core.jsをCDNから取得するように変更
index.htmlのbootstrapを変更します。
before
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/90e61d3e-fc9c-8f35-3bd0-86d1c41e2570.png)

after
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/df0ed318-2ccc-ff53-bd9e-ff3857a248fd.png)

#####ui5.yamlファイルを変更
before
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/1ec5aef8-bb2f-174c-da74-b8d93b4af3bd.png)
after
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/dcc84fed-bf5b-21c2-2239-2a8df037251a.png)

#####karma.conf.jsファイルを変更
configにui5のダウンロード元URLを指定します。

```javascript:karma.conf.js
	config.set({

		frameworks: ["ui5"],

		ui5: {
			url: "https://openui5.hana.ondemand.com"
		},
```

以上の設定で、resourcesフォルダにUI5ライブラリがインストールされることはなくなり、デプロイ対象の数を抑えることができました。パイプラインのステップも全て正常終了しました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276021/2a29c2a1-0207-0abc-6d3e-b50d6b1060ba.png)

##最終的な状態
パイプラインに関連するファイルは最終的に以下のようになりました。

```json:package.json
{
	"name": "openui5-sample-app",
	"version": "0.2.0",
	"description": "Sample of an OpenUI5 app",
	"private": true,
	"scripts": {
		"start": "ui5 serve",
		"lint": "eslint webapp",
		"karma": "karma start",
		"karma-ci": "rimraf coverage && karma start karma-ci.conf.js",
		"watch": "npm run karma",
		"test": "npm run lint && npm run karma-ci",
		"build": "ui5 build -a --clean-dest",
		"upload": "npx nwabap upload",
		"build-self-contained": "ui5 build self-contained -a --clean-dest",
		"serve-dist": "ws --compress -d dist"
	},
	"dependencies": {},
	"devDependencies": {
		"@sap/grunt-sapui5-bestpractice-build": "^1.4.2",
		"@ui5/cli": "^2.2.4",
		"eslint": "^6.8.0",
		"grunt": "^1.1.0",
		"karma": "^4.4.1",
		"karma-chrome-launcher": "^3.1.0",
		"karma-coverage": "^2.0.2",
		"karma-qunit": "^4.1.1",
		"karma-ui5": "^2.1.0",
		"local-web-server": "^3.0.7",
		"nwabap-ui5uploader": "^0.3.4",
		"rimraf": "^3.0.2"
	}
}
```

```dockerfile:Dockerfile
FROM node:12-alpine

MAINTAINER Aleksandar Diklic "https://github.com/rastasheep"

RUN \
  echo "http://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories \
  && echo "http://dl-cdn.alpinelinux.org/alpine/edge/main" >> /etc/apk/repositories \
  && echo "http://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/apk/repositories \
  && apk --no-cache  update \
  && apk --no-cache  upgrade \
  && apk add --no-cache --virtual .build-deps \
    gifsicle pngquant optipng libjpeg-turbo-utils \
    udev ttf-opensans chromium \
  && rm -rf /var/cache/apk/* /tmp/*

ENV CHROME_BIN /usr/bin/chromium-browser
ENV LIGHTHOUSE_CHROMIUM_PATH /usr/bin/chromium-browser

# Add a chrome user
RUN addgroup -S chromium &&\
    adduser -S -g chromium chromium

USER chrome
```

```:jenkinsfile
pipeline {
    agent { 
        dockerfile {
            args '--security-opt seccomp=/var/jenkins_home/myseccomp/chrome.json'
        }    
    }
    stages {
        stage('prepare') {
            steps {
                sh 'npm config set @sap:registry https://npm.sap.com'
                sh 'npm install'
            }
        }
        stage('test') {
            steps {
                sh 'npm run-script test'
            }
        }        
        stage('build') {
            steps {

                sh 'npm run-script build'
            }
        }
        stage('deploy') {
            steps {

                sh 'npm run-script upload'
            }
        }        
    }
}
```
##改善したいところ
とりあえず最後までいけたものの、まだ粗削りなところもあります。例えば、

1. テストの後にビルドしているが、本来はビルドした結果を使ってテストすべきでは
2. デプロイステップで使う設定をプロジェクトの中に置いているが、認証情報も書いているのは良くない
3. デプロイ先のアドレスをホスト名にしたところDockerコンテナの中で名前解決できなかったため、IPアドレス指定にした。本来どうすべきか

##参考
- [Headless One Page Acceptance Tests(OPA) testing in Jenkins pipeline with Karma, UI5, Puppeteer, Docker, and the Project Piper](https://blogs.sap.com/2019/08/01/headless-one-page-acceptance-testsopa-testing-in-jenkins-pipeline-with-karma-ui5-puppeteer-docker-and-the-project-piper/)
- [UI5con@SAP 2019: UI5 Testing with Karma](https://www.youtube.com/watch?v=9dNIiHoy_Jc)