Sphinx + Docker + CircleCIで自動ビルド/デプロイ

#導入
Sphinxは拡張性が高いドキュメントツールとしてITエンジニアから愛されている。[要出典]
ただ、「reStructuredTextの書き方がわからない」「ビューワーがなく、いちいちビルドするのがめんどくさい」等否定的な意見が多いのも確かである。
今回はめんどくさがりなエンジニア、あまりコード触れない事務寄りの人達の為にローカルでの自動ビルド環境を整えた。
また、CircleCIを使ってCI/CDする。

#環境

##ディレクトリ構成

ディレクトリ構成は以下。`tree -a`をベースに修正。

```
.
├── .circleci
│   └── config.yml
├── .gitignore
├── README.md
├── build
│   └── .gitkeep
├── containers
│   └── python
│       └── Dockerfile
├── docker-compose.yml
└── src
    ├── .circleci
    │   └── comments.py
    ├── .venv 
    ├── Makefile
    ├── Pipfile
    ├── Pipfile.lock
    ├── setup.cfg
    └── source
        ├── .rstcheck.cfg
        ├── _static
        │   └── css
        │       └── my_theme.css
        ├── _templates
        ├── conf.py
        └── index.rst
```

##Docker

あまり導入するライブラリが少ないので、直インストールでも良いとは思ったが、きっちりするためにpipenvを導入した。
pipenvとdockerコマンドの相性がどうも思わしくない、DockerfileはCMDに一つのコマンドしか設定できない、volumeの設定をしたい、との諸々の事情があり、`docker-compose`を利用することにした。

```dockerfile:containers/python/Dockerfile
FROM python:3.9-buster

ENV PIPENV_INSTALL_TIMEOUT=9000
ENV PIPENV_VENV_IN_PROJECT=1

RUN apt-get update && apt-get install -y python-pip
RUN /usr/local/bin/python -m pip install --upgrade pip
RUN pip install pipenv
```

```yml:docker-compose.yml
version: '3'

services:
  python:
    build:
      context: .
      dockerfile: ./containers/python/Dockerfile
    working_dir: '/var/src/'
    tty: true
    volumes:
      - ./src:/var/src
      - ./build:/var/build
    ports:
      - "8000:8000"
    command: >
      bash -c "pipenv install --dev &&
      pipenv run sphinx-autobuild --host 0.0.0.0 --port 8000 /var/src/source /var/build/html"

```

Dockerfileではpipとpipenvのインストールにとどめ、パッケージのインストールはdocker-compose.ymlに記述。pipenvはパッケージのインストールに時間がかかる場合が多いので、`PIPENV_INSTALL_TIMEOUT=9000`を設定している。
service名を`python`としているが、この時内部でコマンドを叩きたい時に`docker-compose exec python <command>`となり、pythonを使う際は`docker-compose exec python pipenv run python 〜`となり、混乱の元なので、サービス名は区別がつく名前にすることを推奨したい。

## Sphinx

###ライブラリ設定

```config:src/Pipfile
[[source]]
name = "pypi"
url = "https://pypi.org/simple"
verify_ssl = true

[dev-packages]
flake8 = "*"
rstcheck = "*"
requests = "*"

[packages]
sphinx = "*"
sphinx-rtd-theme = "*"
sphinx-autobuild = "*"

[requires]
python_version = "3.9"
```

- dev-packages
    - flake8
        - 主にsrc/source/conf.pyのチェック
    - rstcheck
        - .rst形式のファイルのチェック
    - requests
        - bitbucketのPull RequestへのComment APIを送信するのに使う
- packages
    - sphinx
        - ドキュメントを作成する根幹ライブラリ
    - sphinx-rtd-theme
        - Read the Docsテーマ。好み次第。
    - sphinx-autobuild
        - ファイルをウォッチして自動ビルドを行う

```python:conf.py
"""
デフォルトコメント部分を省略して表示
"""
import sphinx_rtd_theme

project = 'project_name'
copyright = 'year, author_name'
author = 'author_name'

release = 'version'

extensions = []

templates_path = ['_templates']

language = 'ja'

exclude_patterns = []

html_theme = 'sphinx_rtd_theme'
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]

html_static_path = ['_static']
html_style = 'css/my_theme.css'  # defaultの表示に不満があるので継承したmy_themeを指定
```

###カスタムテーマ

[Sphinx の sphinx_rtd_theme をカスタマイズする](http://kuttsun.blogspot.com/2016/11/sphinx-sphinxrtdtheme.html)を利用

```css:src/source/_static/css/my_theme.css
@import url("theme.css");

.wy-nav-content {
    max-width: none;
}

h1,h2,h3,h4,h5,h6 {
    border-bottom: 1px solid #ccc;
}

.wy-table-responsive table td, .wy-table-responsive table th {
    white-space: normal;
}

colgroup {
    display: none;
}
```

##ビルド

```bash
docker-compose up -d --build
```

sphinx-autobuildがドキュメントをビルドしてbuild以下にhtmlを格納する。

docker-compose.ymlにてホスト側・コンテナ側のポートを共に8000番と指定している。  
ホスト側のポートを変更する場合はdocker-compose.ymlのportsを`<host-port>:8000`の形式で変更する。

##文法チェック

手元で事前にチェックすることでcircleciで通らないという事がないようにする。
pre-commitにフックするのは一々実行されるのが煩わしいので、手動で各自が実行することにした。
Pipfileの[script]に書いておいてコマンド化した方が良いかも。

###flake8

Pythonファイルのチェックに用いる

```config:src/setup.cfg
[flake8]
max-line-length = 120

```

```bash
docker-compose exec python pipenv run flake8 source/conf.py
```

###rstcheck

reSTファイルのチェックに用いる

```config:src/source/.rstcheck.cfg
[rstcheck]
report=warning

```

```bash
docker-compose exec python pipenv run rstcheck -r source
```

##CircleCI

```yaml:.circleci/config.yml
version: 2.1

orbs:
  aws-cli: circleci/aws-cli@1.3.0

jobs:
  build:
    working_directory: ~/python-ci
    docker:
      - image: circleci/python:3.9.0-buster
        environment:
          PIPENV_VENV_IN_PROJECT: true
    steps:
      #　コミットのチェックアウト
      - checkout

      # パッケージのインストール
      - run:
          name: Install python test dependencies
          command: |
            pip install pipenv
            cd src && pipenv sync --dev

      # flake8
      - run:
          name: run flake8
          command: |
            cd src && pipenv run flake8 source/conf.py

      # rstcheck
      - run:
          name: run reST checking
          command: |
            cd src && pipenv run rstcheck -r source

      # ドキュメントをビルドする
      - run:
          name: build doc
          command: |
            cd src && pipenv run make clean html

      # artifactsにビルドしたドキュメントを保存
      - store_artifacts:
          path: /home/circleci/python-ci/build/html

      # gitのコミットメッセージを環境変数に設定
      - run:
          name: get commit message
          command: |
            echo 'export GIT_COMMIT_DESC=`git log --pretty=format:"%s - %an" -n 1 ${CIRCLE_SHA1}`'>> $BASH_ENV
            source $BASH_ENV

      # artifactsに保存したドキュメントのurlをプルリクエストにコメントする
      - run:
          name: send comment to pull requests
          command: |
             cd src && pipenv run python .circleci/comments.py

      - persist_to_workspace:
          root: .
          paths:
            - build

  deploy:
    working_directory: ~/python-ci
    executor: aws-cli/default
    steps:
      # build jobのworkspaceを引き継ぐ
      - attach_workspace:
          at: .

      # orbを利用してaws-cliをセットアップする。
      - aws-cli/setup

      # S3のbucketにビルドしたhtmlをアップロードする
      - run:
          name: update html
          command: aws s3 sync --exact-timestamps --delete build/html/ s3://${AWS_S3_DOCS_BUCKET_NAME}/

      # CloudFrontのcacheを強制的にクリアする
      - run:
          name: clear cache
          command: aws cloudfront create-invalidation --distribution-id ${DISTRIBUTION_ID} --paths "/*"

workflows:
  version: 2
  build_and_deploy:
    jobs:
      # <context_name>にはCircleCIのクレデンシャル情報を入れたcontextの名前を入れる
      - build:
          context: <context_name>
      - deploy:
          context: <context_name>
          requires:
            - build
          filters:
            branches:
              # masterブランチにコミットされた時のみdeployする
              only:
                - master

```

CircleCIの`Organization Settings>Contexts`にて保存できるcontextには

- aws-cliで用いるAWSクレデンシャル・リージョン情報
    - AWS_ACCESS_KEY_ID
    - AWS_SECRET_ACCESS_KEY
    - AWS_DEFAULT_REGION
- deployするS3のバケット名
    - AWS_S3_DOCS_BUCKET_NAME
- Cloudfrontのdistribution id
    - DISTRIBUTION_ID
- Bitbucketのユーザ/アプリパスワード
    - BITBUCKET_USER
    - BITBUCKET_PASS

などを登録する。
事前にS3のバケット、CloudFrontの設定をしておき、aws-cli用クレデンシャルのIAMではS3とCloudFrontの変更権限を与える。

htmlをhttpでホストするだけならbucket名をドメインで作成、静的ウェブサイトホスティングを有効にしてRoute53で関連づけるだけでよく、IP制限もbucketの設定変更で対応できる。但し、「今どきhttpなんですか？」と社内の人間に煽られた場合は、CloudFrontとS3をつなぎ、バージニア北部リージョンのAWS Certificate Manager取得した証明書を関連付けなければいけない。証明書の取得リージョンについてはCloudFrontの設定ページに記載があるが、最初に証明書を取得してからCloudFrontに進もうと考えていると、引っかかるので注意。

##BitbucketのPull Requestにドキュメントのリンクをコメント

[PRごとにCIでStorybookをビルドしてデザイナーとインタラクションまで作っていく話](https://qiita.com/resessh/items/38ef6492d0cf21facec8)で紹介されているように、Pull Requestにコメントするのも実装した。
紹介されているように差分を取って該当箇所を、という感じではなく、ここでは単にindexを投げているが、CircleCIのページに行ってページのリストから探すよりはまだマシかな、と思った次第。

```python:src/.circleci/comments.py
import os
import json
import requests

"""
プロジェクト依存変数

リポジトリのUUIDは
https://api.bitbucket.org/2.0/repositories/<organization_name>/<project_name>
あるいは
https://bitbucket.org/<organization_name>/<project_name>/src/master/
でデベロッパーツールを開き、__initial_state__を検索
で取得可能
"""
VCS_TYPE = 'bb'
REPOSITORY_UUID = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
PATH = '/home/circleci/python-ci/build/html/index.html'

MESSAGE = '''
[CircleCI]{}  
{}  
  
ビルドされたドキュメント  
{}{}  
'''


def main():
    pull_requests_str = os.environ.get('CIRCLE_PULL_REQUESTS', '')
    if pull_requests_str:
        user_name = os.environ.get('BITBUCKET_USER')
        user_pass = os.environ.get('BITBUCKET_PASS')
        project_name = os.environ.get('CIRCLE_PROJECT_REPONAME')
        build_number = os.environ.get('CIRCLE_BUILD_NUM')
        node_index = os.environ.get('CIRCLE_NODE_INDEX')
        comment = os.environ.get('GIT_COMMIT_DESC')
        sha1 = os.environ.get('CIRCLE_SHA1')

        url_base = f'https://{build_number}-{REPOSITORY_UUID}-{VCS_TYPE}.circle-artifacts.com/{node_index}'
        pull_requests = pull_requests_str.split(',')
        for pull_request in pull_requests:
            pull_request_list = pull_request.split('/')
            work_space = pull_request_list[-4]
            pull_request_number = pull_request_list[-1]
            url = f'https://api.bitbucket.org/2.0/repositories/' \
                  + f'{work_space}/{project_name}/pullrequests/{pull_request_number}/comments'
            print(url)
            headers = {
                'content-type': 'application/json',
            }
            message = MESSAGE.format(
                comment,
                sha1,
                url_base,
                PATH
            )
            payload = {
                'content': {
                    'raw': message
                }
            }
            r = requests.post(
                url,
                auth=(user_name, user_pass),
                headers=headers,
                data=json.dumps(payload)
            )
            print(f'status: {r.status_code}')
            print(r.text)

    else:
        print('there is no pull request')


if __name__ == '__main__':
    main()
```

pipenvの関係でsrc以下に入れている。
REPOSITORY_UUIDはBitbucketのサイト/APIから入手して書き換える。
artifactsのurlについては、[CircleCI artifactsのurlをjobのスクリプト内で取得する](https://qiita.com/Nagatatz/items/a74b0dea4953a7166d2a)を参考にされたし。

BitbucketのPRにコメントをつけるAPIには困った。markdownやhtmlでコメントを送る機能があるようなのだがドキュメント通りに送ってもうまく行かなかった。rawでmarkdownを送り付けたらその通りになったので、↑のMessageではmarkdownで書いてrawに入れている。
f文字列でも良さそうだが、インデントによる挙動がわからなかったので、上部に書いてformatで代入した。


#参考文献

[Sphinx](https://www.sphinx-doc.org/ja/master/)
[Sphinx の sphinx_rtd_theme をカスタマイズする](http://kuttsun.blogspot.com/2016/11/sphinx-sphinxrtdtheme.html)
[flake8](https://flake8.pycqa.org/en/latest/)
[rstcheck](https://github.com/myint/rstcheck)
[PRごとにCIでStorybookをビルドしてデザイナーとインタラクションまで作っていく話](https://qiita.com/resessh/items/38ef6492d0cf21facec8)
[Creates a new pull request comment. - Bitbucket API](https://developer.atlassian.com/bitbucket/api/2/reference/resource/repositories/%7Bworkspace%7D/%7Brepo_slug%7D/pullrequests/%7Bpull_request_id%7D/comments#post)
[How to post html comments on pull request via 2.0 api? - ATLASSIAN Comunity](https://community.atlassian.com/t5/Bitbucket-questions/How-to-post-html-comments-on-pull-request-via-2-0-api/qaq-p/1066809)