GitLab CI/CDを使ってOASからKong Gatewayの設定を更新する

API Gatewayの1つであるKong Gatewayは設定内容をYAMLで定義でき、`deck`コマンドというCLIで反映することが出来る。
また`deck`コマンドはOpen API Specification(以下OAS)形式で書かれたAPI仕様をKong Gatewayの設定値に変更することが出来る。
これを利用すると、OAS形式でAPIの仕様を作成することでKong Gatewayの設計を個別にしなくてもそのままGatewayに反映する事ができる。
またAPIクライアントであるInsomnia上でOASを作成すると、そのCLIである`inso`コマンドでテストすることが出来る
これにより、API開発者が実装したAPIの仕様をテストからデプロイまでコマンドラインのみで行う事ができる。
これをGitOpsの仕組みと組み合わせると、以下のような感じでPush契機で反映させることも可能。
![20240506164139.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547981/f4e1e87a-48be-d35e-af35-8eaed6db0220.png)


このフロー（APIOps）を実現する際、GitHub Actionsを使った例は見かけるのだがGitLabを使った例はなかったので試してみた。
なお、検証に使ったファイルは[こちら](https://github.com/imurata/gitlab-apiops-test)に置いている。

## 検証内容
ここでは以下を実現するパイプラインを作成し、動作確認を行う。

#### Merge Request(PR)時
1. Linterの実行(`inso lint spec`)
2. OASをdeck形式のYAMLに変換(`deck file openapi2kong`)
3. YAMLの検証(`deck gateway validate`)
4. 現環境とのdiffの取得(`deck gateway diff`)

#### Merge時
1. OASをdeck形式のYAMLに変換(`deck file openapi2kong`)
2. YAMLの検証(`deck gateway validate`)
3. 現環境とのdiffの取得(`deck gateway diff`)
4. Kong Gatewayに設定を反映(`deck gateway sync`)


本来はInsomniaによるOASのテストなども含めることが出来るが、パイプラインが肥大化するためテストはシンプルにLintのみに留めた。

## 前提
検証環境については以下が既に用意されているものとする。
- Kubernetes
- GitLab
- GitLab Runner
- Kong Gateway

GitLab、GitLab Runnerの準備については[こちら](https://qiita.com/ipppppei/items/d915cb245701c08abb38)の方法で用意した。
ただし、Runnerについては`Run untagged jobs`を指定してタグなしでも動くようにしている。
Kong Gatewayについては[こちら](https://qiita.com/ipppppei/items/9b8d0cca52597a124bb1)の方法で用意した。

## 検証時のファイル構成
以下のファイルを作成して検証する。
```sh
.
├── .gitlab-ci.yml
├── k8s
│   ├── 00_httpbin-ns.yaml
│   ├── 01_httpbin-deploy.yaml
│   └── 02_httpbin-svc.yaml
└── openapi
    └── httpbin.yaml
```
ファイルはそれぞれ以下の通り。
- `.gitlab-ci.yml`：GitLabで使うパイプラインの定義
- `k8s/`：APIの発行先となるサンプルアプリケーション([httpbin](https://httpbin.org/))のManifest（アプリのコードは含まない）
- `openapi/httpbin.yaml`：OAS形式のAPI仕様書

`k8s/`に関しては、今回はManifest修正契機でのGitOpsの動作確認は対象外なのでこのリポジトリ内に含める必要はない。（将来的にこちらもGitOpsで使いたいので入れただけ）


## ファイルの作成
リポジトリ内にManifestとOASのYAML、パイプラインのYAMLを作成する。
リポジトリとしては今回以下のリポジトリを作成した。
- リポジトリ名：`gitlab-apiops-test`
- 公開範囲：`Private`

リポジトリのメンバーにRunnerのトークンと紐づくメンバー（Administrator含む）を追加しておくこと。
追加していない場合、Runnerが以下のメッセージを出力して失敗する。
```sh
remote: You are not allowed to download code from this project.
fatal: unable to access 'https://gitlab.eks.xxxx.com/imurata/gitlab-apiops-test.git/': The requested URL returned error: 403
```
作成後、リポジトリをCloneしてディレクトリ内に移動する。
```sh
MYHOST=gitlab.eks.xxxx.com
git clone https://${MYHOST}/imurata/gitlab-apiops-test.git
cd gitlab-apiops-test
```

### サンプルAPIアプリのManifestの作成
APIを使ったアプリケーションを用意する。
ここでは[httpbin](https://httpbin.org/)と同等の動きをするコンテナを利用し、このPodをAPIアプリケーションとして利用する。
`k8s`というディレクトリを作成し、`Namespace`,`Deployment`,`Service`を作成する。
```sh
mkdir k8s
kubectl create ns httpbin --dry-run=client -o yaml | kubectl neat -f - > k8s/00_httpbin-ns.yaml
kubectl create deploy -n httpbin --image kennethreitz/httpbin httpbin -r 1 --dry-run=client -o yaml | kubectl neat -f - > k8s/01_httpbin-deploy.yaml
kubectl create svc clusterip -n httpbin httpbin --tcp=80:80 --dry-run=client -o yaml | kubectl neat -f - > k8s/02_httpbin-svc.yaml
```
上記のManifestにより、`httpbin`という`Deployment`が`httpbin`という`Namespace`上に作成され、Service `httpbin`によって公開される

起動して動作確認する。
最初にクラスタ上にリソースを展開する。
```sh
kubectl apply -f k8s/
```
適当なPodを立ち上げて`httpbin.httpbin.svc`にアクセスしてみる。
```sh
$ kubectl run curl --image centos --rm -it --restart=Never -- curl httpbin.httpbin.svc/ip
{
  "origin": "192.168.55.194"
}
```
httpbinは`/ip`にアクセスするとアクセス元のIPを返すので問題なさそうだ。
### OASの作成
シンプルに`GET`と`POST`のみ確認するためのエンドポイントを定義する。
```yaml
mkdir ./openapi
cat <<EOF > ./openapi/httpbin.yaml
openapi: 3.0.0
info:
  title: httpbin
  version: "0.1"
paths:
  /get:
    get:
      operationId: /get
      responses: 
        "200":
          description: "get: response 200"
  /post:
    post:
      operationId: /post
      responses: 
        "200":
          description: "post: response 200"
servers:
  - url: http://httpbin.httpbin.svc
EOF
```
`servers.url`はKong GatewayのDataPlaneがKubernetes内にいることを想定して`Service`経由でアクセスしているが、Kubernetesクラスタ外に存在する場合は`type: LoadBalancer`や`Ingress`等を利用してData Plane(Proxy)を外部に公開し、そのURLを指定する必要がある。
`responses`については記載がなくても`deck`コマンドでconvert出来るが、`inso lint`でエラーになってパイプラインが止まるため足している。
作成したら`inso lint`、`deck file openapi2kong`が通ることを確認する。
```
inso lint spec openapi/httpbin.yaml
deck file openapi2kong -s openapi/httpbin.yaml
```
`inso lint`に関してはWarningが表示されるが、コマンドの戻り値はゼロなのでパイプライン的には問題ない。

### パイプラインの定義(`.gitlab-ci.yml`)の作成

以下のパイプラインを作成する。

Merge Request時：
![20240509160328.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547981/fb2c81c8-f1b6-979f-a97b-9f1c410de446.png)

MainへのMerge時：
![20240509154831.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547981/e4298446-09eb-eb2f-01b7-5c0a4218e7fa.png)



以下、`.gitlab-ci.yml`の内容となる。

```yaml
cat <<EOF > ./.gitlab-ci.yml
stages:
- test
- convert
- valid
- deploy

variables:
  DECK_KONG_ADDR: https://kong.aws.unohude.info/api
  DECK_TLS_SKIP_VERIFY: true
  DECK_HEADERS: "kong-admin-token:$CICD_KONG_ADMIN_TOKEN"
  WORKSPACE: "httpbin"

lint:
  rules:
  - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    changes:
    - openapi/**/*
  image: kong/inso
  stage: test
  script:
  - |
    set -x 
    echo "Lint OAS spec."
    inso lint spec openapi/httpbin.yaml

convert-deck:
  rules:
  - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    changes:
    - openapi/**/*
  - if: '$CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"'
    changes:
    - openapi/**/*
  image: kong/deck
  stage: convert
  script:
  - |
    set -x
    echo "Convert OAS to deck YAML."
    deck file openapi2kong -s openapi/httpbin.yaml -o deck.yaml
  artifacts:
    paths:
      - deck.yaml

valid-deck:
  rules:
  - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    changes:
    - openapi/**/*
  - if: '$CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"'
    changes:
    - openapi/**/*
  image: kong/deck
  stage: valid
  script:
  - |
    set -x
    echo "Validate deck.yaml"
    deck gateway validate deck.yaml

diff-deck:
  rules:
  - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    changes:
    - openapi/**/*
  - if: '$CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"'
    changes:
    - openapi/**/*
  image: kong/deck
  stage: valid
  script:
  - |
    set -x
    echo "Diff deck.yaml and Gateway"
    deck gateway diff deck.yaml --workspace $WORKSPACE

deploy:
  rules:
  - if: '$CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"'
    changes:
    - openapi/**/*
  image: kong/deck
  stage: deploy
  script:
  - |
    set -x
    echo "Deploy"
    deck gateway sync deck.yaml --workspace $WORKSPACE
```
以下、`.gitlab-ci.yml`内のポイントを解説する。
```yaml
variables:
  DECK_KONG_ADDR: https://kong.aws.unohude.info/api
  DECK_TLS_SKIP_VERIFY: true
  DECK_HEADERS: "kong-admin-token:$CICD_KONG_ADMIN_TOKEN"
  WORKSPACE: "httpbin"
```
`variables`では`deck`コマンド利用時に必要な環境変数を定義する。`DECK_KONG_ADDR`はAdminAPIのURLで、コマンドの引数やファイルでも渡すことが可能だが、環境変数でも渡せるためここでは環境変数で渡している。
`DECK_TLS_SKIP_VERIFY`についてはKong Ingress Controller(KIC)で自己証明書を使って構築しているため、証明書のチェックをスキップするために指定する。
`DECK_HEADERS`はKong GatewayをRBACを有効化して起動しており、認証を突破するために`kong-admin-token:`でAdminのTokenを渡している。
Tokenは`CICD_KONG_ADMIN_TOKEN`というリポジトリのCI/CD設定（`Settings`->`CI/CD`->`Variables`）で指定して、リポジトリのファイル内に残らないようにしている。
RBACを使っていない人は`DECK_HEADERS`は指定しなくてもよい。
`WORKSPACE`はKong GatewayのWorkspace名を設定している。
事前に作っておく必要はなく、なければ`deck gateway sync`時に自動で生成される。

```yaml
lint:
  rules:
  - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    changes:
    - openapi/**/*
```
Lint処理はMerge Request発生時のみ動かしたいので`"merge_request_event"`の条件で発動条件を絞っている。
また、`changes`で更新対象のディレクトリを絞り、OASの更新以外では動かないようにしている。
```yaml
convert-deck:
   :(省略)
  - if: '$CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"'
    changes:
    - openapi/**/*
```
Lint以外のJobは`main`ブランチへのMergeのタイミングでも動いて欲しいため、条件を足している。

```yaml
  artifacts:
    paths:
      - deck.yaml
```
`deck file openapi2kong`で変換したdeck用のYAMLは後続のJobでも利用するため、Artifactsで引き継ぐようにしている。
パイプラインからデプロイした環境に問題があった場合はこのArtifactsをDownloadしてトラブルシュートに利用することになる。


全てのファイルを作成し終わったらファイルをPushする。
```sh
git add -A
git commit -m "initial commit"
git push
```

## 動作確認
ブラウザでササッと確認する。
GitLabのUIからリポジトリのhttpbin.yamlを開き、`Edit`->`Edit single file`から編集モードに移行し、
`version: "0.1"`を`version: "0.2"`に変更する。
Commitの際に`Target Branch`を`main`から適当なブランチ名に変更し、`Start a new merge request with these changes`にチェックを入れて`Commit changes`をクリックする。
![20240509164022.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547981/082205dd-e878-de0e-51a9-3ff42590755c.png)

次にMerge Requestの作成画面に移行するので`Create merge request`をクリックする。
クリックするとパイプラインが動き出す。
![20240509164327.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547981/186023ae-493e-e791-0f9f-362b6f5ad0d3.png)
しばらく待つと問題なければ`passed`という表示とともに全てがグリーンとなる。
![20240509164513.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547981/3a5cef19-7f8a-6fd9-1192-6ed3cd881839.png)

それぞれのJobをクリックすれば、実行内容も確認できる。
![20240509164736.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547981/c5fdf3b9-5dc2-0ce2-70e4-9898eeb6317e.png)
また、OASからdeck形式に変換されたYAMLはJob`convert-deck`からのartifactsから確認できる。
![20240509164952.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547981/a132b6f6-48d6-e212-fdac-72d85b387ad5.png)


Merge Requestのパイプライン実行後、Merge Requestの画面から`Merge`ボタンをクリックしてMergeを実行する。
すると今度はMerge用のパイプラインが起動する。
![20240509165159.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547981/8ec09826-932c-4fa5-bb15-5977c3c24a55.png)
 
こちらもしばらく待てば全てグリーンになり、Job`deploy`の結果を見ることで`deck gateway sync`によりKong Gateway側に反映されたことが分かる。
![20240509165515.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547981/26a4af7c-167d-cc41-bb61-c2f646fd2089.png)

Kong GatewayのUIからもRouteが設定されていることが確認できる。
![20240509165644.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547981/2f1ef989-9d52-eb07-3bf4-fe80d403741d.png)

実際にアクセスしてみる。
```sh
PROXY_URL=xxx.us-east-1.elb.amazonaws.com
curl -X GET $PROXY_URL/get
curl -X POST $PROXY_URL/post
```
それぞれ正常なレスポンスが取得できると思う。
また、今回は`/get`と`/post`しか実装しなかったため、`/ip`などでは以下の様にRouteが見つからずエラーとなる。
```sh
$ curl -X GET $PROXY_URL/ip
{
  "message":"no Route matched with those values",
  "request_id":"b6b4a717abd07c19b6d6a873d6a27849"
}
```
こちらも期待した動作となった。

ということで、GitLabのCI/CDの仕組みを使ってKong GatewayのAPIOpsが実現出来ることが確認できた。