GitLab CIを用いてHugoのHP編集をGitLabのWeb UI上だけで完結させる(自動でファイル・テストページ作成/削除・デプロイする)

# はじめに

今回は、静的サイトジェネレーターである[Hugo](https://gohugo.io/)を、GitLabのWebインターフェイス上でユーザーさんに編集してもらうのが良いのではないかということになり、ホームページの編集を行うユーザーさんに小難しいことを考えさせずに更新してもらえばなぁという思いがあって、GitLab CIを用いて実装してみました。

本記事ではGitLab CIを用いて**ファイルの作成とコミット・別サーバーへの転送/削除方法**もご紹介しますので、Hugoに限らずに参考になればと思います。

## 背景

Web上で編集出来るものとしてWordpressがあると思いますが、こちらはコンテンツを複数人が同時に編集することが出来ないようなので(私は使ったことはありませんので詳しくは分かりませんが・・・)、静的サイトジェネレーターを使おうという話になりました。
その中でも、私達は個人的にHugoを使っていたのが採用の経緯です。
そして、Hugoのテーマをチームで開発することになった時にGitLabを使ってみたらWeb UI上で編集出来るしコミット出来るしで、尚且GitLab CIも使えばscheduling機能で毎日自動で更新もしてくれる[^1]し、これは結構イケるのでは!?と思って、実装してみました。

[^1]: HugoはExpire dateなどを指定して、ある日にちを経過したら記事を表示させる/させないなどの指定が出来るのですが、この日付は静的サイトへ**変換した瞬間**の日付・時間を見て表示させるかを判断します。そこで、毎日同じ時間にCIを走らせて変換させてWebサーバーに同期させれば、表示させる/させない記事をコンテンツ編集時だけでなく、1日毎に自動更新できることになります。

## 前提

- 今回、intranet上でのみ見えるサーバー`hoge-intra.com`があり、ここにGitLab CEを立てています
    - 公開する前のテストページもこちらのサーバーに置くことにします
- 外から見てもらうためのサーバー`huga.com`があり、本番環境をここに置きます
    - `huga.com`と`hoge-intra.com`はSSH通信が可能なようにファイアウォールを設定しています 
- 2つのWebサーバーのDocument Rootは共に`/foo/bar/html`とします
- 予め、サーバーhugaにて`gitlab-bot`ユーザーを作成し、RSA認証鍵を**新規に作成**しておきます
    - DocumentRootディレクトリのPermission設定も行ってあります
- GitLab CIはDockerで動かします(Imageの名前は`hugo:latest`)
- リポジトリの名前は`hugo-hp`
- GitLab CE, GitLab Runnerのバージョンは12.1, Gitのバージョンは2.22.0.454, Hugoのバージョンは0.56です。

## 仕様

- 公開するコンテンツはmasterブランチで追跡する
- masterブランチから新たなbranch(draft_xxxというフォーマット)を切ると、自動でhoge-intra.com/draft_xxxというテストページを作る
- `Variables`にファイル名・ディレクトリ名が指定されればそのファイルを**GitLab CIを実行させた人で**コミットする
- draft_xxxブランチ内でコミットがあると、これに対応するhoge-intra.com/draft_xxxのページを自動更新
- draft_xxxがmasterブランチにMergeされると自動でhoge-intra.com/draft_xxxのページを削除し、huga.comに反映される
                                                                                                                                                                                                                                                                                         
# 実装

## SSH通信について
まず、初めに作っておいたRSA秘密鍵をGitLab CIに登録します。`Settings->CI/CD->Variables`と移動して、Keyの名前を`SSH_PRIVATE_KEY`, Valueに秘密鍵を書きます。 この値はリポジトリのMaintenerしか触れないもので、pre-commitを用いて`.gitlab-ci.yml`を変更されなければセキュアなものだと考えています[(gitで特定のファイルの更新を禁止する)](https://qiita.com/ryounagaoka/items/3e7a1b44d43ad0547d4f)。例えば`.gitlab-ci.yml`内に`echo $SSH_PRIVATE_KEY`とか書かれたら・・・。ここは本格的にやるなら、サーバー側でセキュリティを設定するなど、もう少し考えなければならないかもしれません(が、今回は性善説に立って[^3]笑)。

[^3]: 性善説に立てる理由を補足します。本番Webサーバ`huga.com`とイントラの`hoge-intra`内のGitLab CIとの間にしかSSH通信出来ないようになってします。また、仮に他のプロジェクトとRunnerをshareしても(一応specificにしておりますが)、RSA認証秘密鍵は各プロジェクトのOwnerのみ触れるものなのでここで(脆弱性がない限り)秘密鍵は守られます。最後に、ホームページを広報さんたちが変更するという性質上、プロジェクトは広報の方と開発側のみメンバーになっているprivateリポジトリとして設定しています。また、限定的人数に秘密鍵が漏れる可能性があってもその人が出来ることはウェブ改竄のみです。このようにして、性善説を一定の領域内でのみ信じることが出来ます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/255755/b3c3c5ac-9199-3b56-c94b-256b7f64177b.png)

CIに`ssh`や`rsync`をやってもらうのですが、Dockerを使っている場合、コンテナ内にて

- `ssh-agent`が立ち上がっていません
    - 毎回`ssh-agent`を立ち上げる必要があります
    
```yaml
before_script:
  - eval `ssh-agent`
  - ssh-add <(echo "$SSH_PRIVATE_KEY")
```
- known_hostsが破棄されます(つまり毎回、初めて繋ぐ時にYesと打つやつが出てきます)
   - 今回通信するサーバー先は自前のDNSサーバーで管理されているものなので、`ssh`コマンドのオプション`-oStrictHostKeyChecking=no`をつけます。
       - `rsync`の場合は`ssh`プロトコルが出来るオプション`-e`を使います
       - gitの場合は、git 2.1.0以降から`git -c core.sshCommand`を用いてSSHコマンドを指定出来るようになった[(参考)](https://tech-1natsu.hatenablog.com/entry/2018/10/27/102621)ので、これを用いてFinger printの確認を回避しています



## masterブランチからdraft_xxxブランチを切ると、自動でテストページを作る

GitLab CIではCI用の変数作業をしているブランチの名前を`$CI_COMMIT_REF_NAME`にて取得することが可能です。これを利用します。
また、GitLab Runner 11以降では、**正規表現にも対応**しています。これで、`draft_(ホニャララ)`というブランチに対してのみ操作を行うといったことが可能です。
また、Hugoでは`hugo -b`にて、configファイルで指定した以外のBaseURLをコマンドライン上で変更してGenerateしてくれるので、BaseURLにブランチの名前を引っ張って、独自にURLを振ってあげます。

```yaml
  script:
    - 'hugo -b https://hoge-intra.com/test/${CI_COMMIT_REF_NAME}/'
    - 'rsync -au --delete -e "ssh -oStrictHostKeyChecking=no" public/ gitlab-bot@hoge:/foo/bar/html/test/${CI_COMMIT_REF_NAME}'
  only:
    - /^draft_.*$/
```

## CIを通じてファイルを新規作成する

Hugoにて新規記事を作成する場合、普通はshellにて

```
$ hugo new (ディレクトリ名)/(ページ名).md
```

と打たなければなりません。また、下図のようにPipeline実行時に独自に変数を作ってRunnerのshellに投げられるので、これを用いてファイルを作成するようにします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/255755/edcd9079-c741-da69-26d3-f5cd34b2ff75.png)

```yaml
  script:
    - if [[ $CI_COMMIT_REF_NAME =~ ^draft_.*$ ]]; then
    - if [ -n "$FILENAME" ]; then
    - if [ -z "$DIRNAME" ]; then
    - DIRNAME="post"
    - fi
    - hugo new $DIRNAME/$FILENAME.md
    - git config --global user.email $GITLAB_USER_EMAIL
    - git config --global user.name $GITLAB_USER_NAME
    - git add content/$DIRNAME/$FILENAME.md
    - 'git commit -m "new content file creation: $DIRNAME/$FILENAME.md"'
    - git -c core.sshCommand="ssh -oStrictHostKeyChecking=no" push gitlab@hoge-intra/hugo-hp.git HEAD:${CI_COMMIT_REF_NAME}
    - fi
    - fi
  only:
    - web
```

このスクリプトはdraft_(ホニャララ)ブランチのみで、かつWeb UIで`CI/CD -> Run Pipeline`を押して実行されるとき(上図の画面経由)のみ動作させたいです。
前者はshell scriptの`if`文で、後者は`only: web`とすればOKです[^2]。
[^2]: 現バージョンだと、`only`にand条件がかけられないみたいです)
また、新しくファイルが作られるので、このファイルをコミットしないとリモートリポジトリ(つまりGitLab上)に反映されません。そこで、新しく作られたファイルをコミットするのですが、ここでGitLab CIでは、このパイプラインを実行したかユーザーの情報を変数として持っていることを利用します。`$GITLAB_USER_EMAIL`,`$GITLAB_USER_NAME`としてメールアドレス・名前を取得し、設定します。Docker containerがbuildされるたびにgitの設定も初期化されるので、一先ずglobalで変えておきます。

## Merge request時にテストページを削除する

draftブランチが作成される度にコンテンツが作られまくると流石にストレージを圧迫するので、ここでは編集が終わってmasterブランチにMergeしてもらう時に自動でファイルを消してもらうということをします。

```yaml
delete_testpage:
  stage: del_test
  script:
    - if [[ $CI_COMMIT_REF_NAME =~ ^draft_.*$ ]]; then
    - ssh -oStrictHostKeyChecking=no gitlab-bot@hoge rm -r /foo/bar/test/${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME}
    - fi
  only: 
    refs:
      - merge_requests
```

ここでのポイントは

```yaml
only:
  refs:
    - merge_requests
```
です。Merge requestを送った時にもPipelineが走るのですが、これも分岐の条件として用いる事ができます。もちろん`except`などでも使えます。


## 最終形態

最終的に、こんな感じで実装出来ました。

```.gitlab-ci.yml
image: hugo:latest

variables:
  GIT_SUBMODULE_STRATEGY: recursive

stages:
  - creation
  - test 
  - del_test
  - deploy
  
before_script:
  - eval `ssh-agent`
  - ssh-add <(echo "$SSH_PRIVATE_KEY")

file_creation:
  stage: creation
  script:
    - if [[ $CI_COMMIT_REF_NAME =~ ^draft_.*$ ]]; then
    - if [ -n "$FILENAME" ]; then
    - if [ -z "$DIRNAME" ]; then
    - DIRNAME="post"
    - fi
    - hugo new $DIRNAME/$FILENAME.md
    - git config --global user.email $GITLAB_USER_EMAIL
    - git config --global user.name $GITLAB_USER_NAME
    - git add content/$DIRNAME/$FILENAME.md
    - 'git commit -m "new content file creation: $DIRNAME/$FILENAME.md"'
    - git -c core.sshCommand="ssh -oStrictHostKeyChecking=no" push gitlab@hoge-intra/hugo-hp.git HEAD:${CI_COMMIT_REF_NAME}
    - fi
    - fi
  only:
    - web

draft:
  stage: test
  script:
    - 'hugo -b https://hoge-intra.com/test/${CI_COMMIT_REF_NAME}/'
    - rsync -au --delete -e "ssh -oStrictHostKeyChecking=no" public/ gitlab-bot@hoge:foo/bar/html/test/${CI_COMMIT_REF_NAME}
  only:
    - /^draft_.*$/

conversion_test:
  stage: test
  script:
    - hugo
  except:
    - /^draft_.*$/
    - master

delete_testpage:
  stage: del_test
  script:
    - if [[ $CI_COMMIT_REF_NAME =~ ^draft_.*$ ]]; then
    - ssh -oStrictHostKeyChecking=no gitlab-bot@hoge rm -r /foo/bar/test/${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME}
    - fi
  only: 
    refs:
      - merge_requests
  
production:
  stage: deploy
  script:
    - hugo -b https://hoge.com/
    - rsync -au --delete -e "ssh -oStrictHostKeyChecking=no" public/ gitlab-bot@hoge:/foo/bar/html/test 
  artifacts:
    paths:
      - public
  only:
  - master
```

# 動作確認

## ブランチを切ってテストページが出来るか確かめる

まず、masterブランチからdraft_testというブランチを切るとCIが走ります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/255755/af066838-e496-a276-f339-8f7e8ba9eafe.png)
このようにPipelineが成功します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/255755/2b660593-b1ff-8fe4-bcec-f0acf363feba.png)

Web siteが作られているか確認します。
```
$ curl https://www.hoge-intra.com/draft_test/
<!DOCTYPE html><html><head><title>https://www.hoge-intra.com/draft_test/ja</title><link rel="canonical" href="https://www.hoge-intra.com/draft_test/ja"/><meta name="robots" content="noindex"><meta charset="utf-8" /><meta http-equiv="refresh" content="0; url=https://www.hoge-intra.com/draft_test/ja" /></head></html>
```
ただのリダイレクトページですが、適切に作成されています。

## ファイルを作成し、コミットする

次に、ファイル作成をやってみます。
左側のCI/CDから右上のRun Pipelineとクリックすると以下のような画面になりますので、`FILENAME`に`draft_test`を設定します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/255755/d3e365c9-cea4-8cf6-ce81-be1357000bd1.png)

すると、以下のように`file_creation`ステージ・`draft`ステージが走ります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/255755/5a6276a6-91a0-1d4d-a635-39ffa211a99f.png)
肝心のコミットも、私のアカウントにて適切に行われています[^4]。
[^4]: ここのCIは失敗してしまいます。何故なら、ファイルを作成した時に起こるコミットによって走らされるCIはAdministratorで実行されるからです。しかし、Administratorはこのリポジトリにアクセス権限を持たせていないので、エラーが起こるという原理です。が、空ファイルを作っただけで変換テストを行う必要はないだろうという考えです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/255755/e05f15e0-a171-690f-6ecb-648dfc4055aa.png)
そして、ユーザーはWeb UI上でMarkdownを編集します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/255755/3e6052cb-0413-2536-fa23-e14245559e21.png)

HugoもMarkdownで記事を記述するので、実質的にedit画面上でこのようにPreviewも出来るのが嬉しいですね。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/255755/259664cc-b898-4749-24c1-b6afc037e573.png)

変更をコミットするとCIが走り、テストページにも反映されます。

```
$ curl https://www.hoge-intra.com/ja/draft_test/post/draft_test/
(略)
     <div class="single-title">Draft_test</div>
      <div>
        <article class="content">


<h1 id="編集について">編集について</h1>

<p>ここで編集しますよー</p>

<h2 id="プレビュー">プレビュー</h2>

<p><strong>Markdown</strong>のPreviewも<code>GitLab</code>のWeb UIでできちゃう!</p>

<blockquote>
<p>便利でしょ?</p>
</blockquote>

<p><del>僕はキメ顔でそう言った。</del></p>

        </article>
      </div>
(略)
```
HugoにてMarkdownを変換できていることが確認出来ました。

## Merge requestを送ってテストページを削除

最後にテストページが適切に削除出来るかを確認します。

まず、Merge requestを作ります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/255755/e0a93fe1-e568-6c9a-3066-d0aa2d6c22af.png)
すると、Pipelineが走ります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/255755/82b121bd-f4a7-c810-357d-1d5089b73e23.png)
Pipelineの中身を見ると
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/255755/eb32f1db-62a3-34f4-73fb-5404a10e5c89.png)
`delete_testpage`ステージが呼ばれています。

```
$ curl https://www.hoge-intra.com/draft_test/
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>404 Not Found</title>
</head><body>
<h1>Not Found</h1>
<p>The requested URL /new_test/draft_test/ was not found on this server.</p>
</body></html>
```
無事にテストページは削除されました。

# さいごに

今回は、GitLabのWeb UIを用いてあまりGitに詳しくない方でもWeb更新が出来るようなシステムをGitLab CIで実装してみました。
これによって、誰かが編集中のときにでも並行して更新作業を行うことが出来ます。ただ、一人の方が一つの記事を作成・編集することを前提としています(コンフリクトが起きてもGitLabで簡単に解消出来ますが)。
また、編集ユーザーさんがローカルリポジトリを持たなくて良いというのは一つの利点だと思います。
何故かというと、我々は現在Hugoのテーマを開発していまして、それをGit submoduleで管理しようと思っています。
そうした時に

1. テーマの編集が終わったらリポジトリ管理者がmasterブランチに新しいコミットに移しておける(submodule_update)
1. ページ編集する人がmasterブランチから派生ブランチを作れば最新バージョンのテーマでページを作ってくれる

という流れが出来ます。つまり、ユーザーはローカルリポジトリを持たないことから`submodule update`をしてもらわなくても良いということです。

また、テストページをHugoで見るには本来shellにて

```
$ hugo server
``` 
というコマンドを打って、localhost上にWebサーバーを立てて確認するということをしなければなりませんが、今回の実装にてこの部分もシームレス化出来たのではないでしょうか。

# 参考
GitLab CIについて
https://qiita.com/ynott/items/1ff698868ef85e50f5a1
GitLab CIでの変数
https://qiita.com/ynott/items/4c5085b4cd6221bb71c5