IGGG

群馬大学電子計算機研究会 IGGG

IGGG

IGGG

Information technology researching society

of the Gunmer,

by the Gunmer,

for the Gunmer

Persona Color Theme © - 2019 IGGG

GitHub Actions でブログの更新をツイートする

2019-11-03

iggg.org を移行する その2

2019-10-13

GitHub Actions を使ってみた

2019-10-11

libnss-json 始めました

2019-09-25

esaの利用をはじめました

2018-12-24

iggg.org を移行する

2018-12-22

CircleCI の設定ファイルを 2.0 に更新

2018-12-15

Slack から特定のアカウントでツイートする Bot を作った

2017-06-01

Slack と GAS と GitHub を使って部内の問題・情報管理を円滑にしたい話

2017-05-17

IGGG の GitHub Pages の開発環境の Dockerイメージを作る

2017-05-11
2019-11-03

GitHub Actions でブログの更新をツイートする

面白そうだったので先に作っちゃいました。
GitHub Actions 自体はすでに導入したので、あとは Tweet をできるようにするだけです。

ツイートメッセージを組み立てる

こういう感じにツイートしたい:

【ブログを更新しました】libnss-json 始めました https://t.co/M6g35h7vuj

— IGGG(群馬大学電子計算機研究会) (@IGGGorg) September 25, 2019

このためには

  1. 記事のタイトル
  2. 記事のリンク

が必要ですね。これらを git の差分などから構築するために THE シェル芸します。

ブログは Hexo で作っており、記事は source/_posts/hoge.md に追加します。最終的なリンクは [base_url]/YYYY/MM/DD/hoge となるので、リンクを得るには日付の情報とファイル名が必要です。

マークダウンには front matter でタイトルや日付が書いてあります:

---
title: GitHub Actions を使ってみた
date: 2019-10-11 00:00:00
tags:
- GitHub
categories: Web
cover: "/images/use-github-actions/actions.jpg"
---

IGGG ソフトウェア基盤部のひげです。
...

これをよしなにパースします:

  1. 更新の有無: git diff fdd0928^...fdd0928 --name-only --diff-filter=A -- source/_posts/*.md
  2. (1) のファイルパス path/to/file からタイトル取得: head path/to/file | grep '^title:' | sed 's/^title: *//g'
  3. (1) のファイルパスから日付を取得: head path/to/file | grep '^date:' | sed -e 's/^date: *\([0-9]\{4\}\)-\([0-9]\{2\}\)-\([0-9]\{2\}\) .*$/\1\/\2\/\3/g'
  4. (1) のファイルパスから拡張子抜きのファイル名を取得: echo path/to/file | sed -e 's/source\/_posts\/\(.*\)\.md/\1/'
  5. (3) の日付と (5) のファイル名から URL を取得: echo "https://iggg,github.io/${date}/${file_name}"

これらをするシェルスクリプトがこちら:

BASE_URL="https://iggg.github.io"
TWEET_MESSAGE=""
LATEST=0

DIFF_FILES=`git diff ${TARGET_BRANCH} --name-only --diff-filter=A -- source/_posts/*.md`
for FILE_PATH in $DIFF_FILES ; do
TITLE=`head ${FILE_PATH} | grep '^title:' | sed 's/^title: *//g'`
DATE=`head ${FILE_PATH} | grep '^date:' | sed -e 's/^date: *\([0-9]\{4\}\)-\([0-9]\{2\}\)-\([0-9]\{2\}\) .*$/\1\/\2\/\3/g'`
FILE_NAME=`echo ${FILE_PATH} | sed -e 's/source\/_posts\/\(.*\)\.md/\1/'`
MESSAGE="${TITLE} ${BASE_URL}/${DATE}/${FILE_NAME}"

DATE_TIME=`date -d "${DATE}" '+%s'`
if [ $DATE_TIME -gt $LATEST ] ; then
TWEET_MESSAGE="【ブログを更新しました】${MESSAGE}"
LATEST=${DATE_TIME}
fi
done

echo ${TWEET_MESSAGE}

TARGET_BRANCH だけ外から与えます。ループしているのは、(1) で複数投稿があった場合に最新のものだけを拾うためです。

ツイートする

最初は curl で API でも叩けばいいかなぁって思ってたけど Twitter API は意外とめんどい。そこでひらめく、せっかく GitHub Actions だし、アクションを使えばいいんだと(天才)。

ググってもなさそうだったので作りました:

  • actions/tweet at master · matsubara0507/actions

Python の tweepy を使っています。理由は (1) スクリプト系の言語で (2) 扱いが簡単(クライアントオブジェクト生成してメソッド叩くだけ)で (3) 今でもメンテナンスされているのがちょうどこれだったからです(Ruby の twitter gem は2017から更新止まってた)。

使い方はこんな感じ:

uses: matsubara0507/actions/tweet@master
with:
consumer_key: ${{ secrets.TWITTER_CONSUMER_KEY }}
consumer_secret: ${{ secrets.TWITTER_CONSUMER_SECRET }}
access_token: ${{ secrets.TWITTER_ACCESS_TOKEN }}
access_token_secret: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }}
message: 'This is test tweet by GitHub Actions'

ここで問題が1つ。どうやってさっき生成したツイートメッセージを with.message に渡すか。
ここで、なんかしらのコマンドの実行を与えることはどうやらできないっぽい:

- name: Build tweet message
# この結果を
run: ./.github/scripts/tweet-message.bash
shell: bash
- name: Tweet
uses: matsubara0507/actions/tweet@master
with:
message: # ここに与えたい
...

この位置で変数を使うには steps.[step_id].outputs.hoge を作る必要がある。
しかし、これはアクション側で事前に設定するもの(少なくとも現在は)で、独自で定義することはできない:

# こう言うのができれば良いのに
- name: Build tweet message
id: message
run: ./.github/scripts/tweet-message.bash
shell: bash
output: result # こんな構文は無い
- name: Tweet
uses: matsubara0507/actions/tweet@master
with:
message: ${{ steps.message.outputs.result }}
...

だったら、なんかスクリプト実行してその出力を outputs に退避させるアクションを使えばいいんだと(天才)。
はい、ググってなさそうだったんで、ないなら作る精神:

  • actions/outputs at master · matsubara0507/actions

これを利用するとこんな感じでツイートできました:

jobs:
tweet:
runs-on: ubuntu-18.04
steps:
- uses: actions/checkout@v1
- name: Build tweet message
uses: matsubara0507/actions/outputs@master
id: message
env:
TARGET_BRANCH: HEAD^
with:
script_path: ./.github/scripts/tweet-message.bash
- name: Tweet
uses: matsubara0507/actions/tweet@master
with:
consumer_key: ${{ secrets.TWITTER_CONSUMER_KEY }}
consumer_secret: ${{ secrets.TWITTER_CONSUMER_SECRET }}
access_token: ${{ secrets.TWITTER_ACCESS_TOKEN }}
access_token_secret: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }}
message: ${{ steps.message.outputs.result }}

おまけ: アクションの作り方

作り方は2つあります。JavaScript (TypeScript) を使う方法と Docker を使う方法。

JavaScript Docker
仮想環境 Linux, MacOS, Windows Linux
起動速度 速い 遅い(pull or build)
依存関係 前後に影響(たぶん) アクションで独立

Docker の方が簡単ですが、JavaScript は次のステップにも影響を与えることができます。
ちなみに、outputs アクションは JavaScript で、tweet アクションは Docker で作りました。

両方とも、GitHub リポジトリにあげておけば直接利用できます。

JavaScript の場合

  • ココを見て
  • アクションのプリミティブなやつはだいたい actions/toolkit リポジトリにあります
  • 使い方の例が TypeScript だったりするのが罠

Docker の場合

  • ココを見て
  • GitHub リポジトリの場合は docker build
  • レジストリにあげると docker pull

おしまい

GitHub Actions は、いよいよ 11/13 に GA されるんで楽しみです!

Web
GitHub, JavaScript, Python
2019-10-13

iggg.org を移行する その2

これの続きです。

  • iggg.org を移行する|群馬大学電子計算機研究会 IGGG

半年前…やりきりました。
実際に iggg.org はすでに新しくなっており、IGGG/new.iggg.org というリポジトリで動いてます。

前回からの残タスク

やったのはこれ

  1. NEWSの記事の細かい修正
  2. wiki の移行
  3. コミットから自動生成
  4. ドメインを iggg.org にする

1. NEWSの記事の細かい修正

鬼門その1。
いくつか古い記法が残っていました。

埋め込み系

まずは埋め込み系:

  • スライド の埋め込み (SlideShare)
  • Twitter と YouTube の埋め込み

ええ、この辺りは機械的にやりようがないので。。。

  1. 問題の箇所がどこか grep
  2. 対応する旧ページを見に行く
  3. Embed 記法を書き換える

愚直です(たいした数がないのでいいんですけど)。

画像

そして次は画像。
記法の変換は大体できていたのが、サイズがめちゃくちゃデカイので Hugo のショートコードを設定して直した:

{{ $src := .Get "src" }}
{{ $scale := float (default "1" (.Get "scale" )) }}
{{ $title := .Get "title" }}
{{ $config := imageConfig (printf "/static/%s" $src) }}
<figure style="margin: 1em">
<img src="{{ .Site.BaseURL | absLangURL }}{{$src}}"
style="max-width: {{mul $config.Width $scale}}px"
width="100%"
alt="{{$title}}"
title="{{$title}}">
<figcaption>{{$title}}</figcaption>
</figure>

これを layouts/shortcodes/img.html に保存し、{< img s1c="/path/to/image" scale="0.2" title="タイトル" >}} と書くことでサイズを指定したり、正しいパスに変換してくれたりしてくれる。
完璧だ。

alias

旧ページと新ページでページの URL が変わってしまう。
旧はページのタイトルや設定した URL になってるのだが、対して新は日付の URL。
さて、どうするか。
alias の設定自体は Hugo のフロントマターで指定できます:

---
title: "本会の活動拠点が決定しました!"
date: 2014-06-16
aliases:
- /news/base-was-decided
---

## 場所は…?
...

ではどうやって元の URL と新しい URL を対応させるか。
根性です。
根性しました。

根性の様子

2. wiki の移行

鬼門その2。
静的サイトはキッツイので代替の要件から考えた。

  • ページは公開されていい
  • ユーザー登録は(多少)クローズド
  • マークダウンか何かでインポートできる

以上を踏まえた結果 Scrapbox.io にしました:

  • IGGG - Scrapbox

で、以降手順はこんな感じ

  1. PukiWiki のデータ全部抜く
  2. PukiWiki から MD に変換
  3. MD 内の wiki へのリンクを Scrapbox に差し替え
  4. 画像も Scrapbox にいい感じに
  5. MD を Scrapbox にインポート

PukiWiki のデータ全部抜く

自分でセットアップしてないので、まずは PukiWiki のデータを探した:

$ ls /srv/http/wiki/wiki-data/wiki/ | head
32303137E5B9B4E5BAA620E381A1E381B3E381A3E5AD90E5A4A7E5ADA6.txt
32303138E5B9B4E5BAA620E381A1E381B3E381A3E5AD90E5A4A7E5ADA6.txt
32E3818BE38289E5A78BE381BEE3828BE695B4E695B0E58897.txt
3A526563656E7444656C65746564.txt
3A636F6E6669672F4261644265686176696F72.txt
3A636F6E6669672F617574682F6F70656E69642F6D697869.txt
3A636F6E6669672F6931386E2F746578742F6A615F4A50.txt
3A636F6E6669672F706C7567696E2F6174746163682F6D696D652D74797065.txt
3A636F6E6669672F706C7567696E2F63686172742F64656661756C74.txt
3A636F6E6669672F706C7567696E2F72656665726572.txt

発見。ファイル名はどうやら16進数でエンコードされたURL(タイトル)らしい。
Ruby で適当にデコードしてみた:

$ pwd
/srv/http/wiki/wiki-data/wiki
$ find . -name "*.txt" | xargs -INAME ruby -e 'puts [ARGV[0].delete("./").delete(".txt")].pack("H*")' NAME
MenuBar
群桐祭 2015
Ren'Pyで遊ぶ(その2)
Help/Plugin/D
Ziyuu
:config/plugin/attach/mime-type
C勉強会2015
ジャンク祭り 2017
ETロボコン2015
arthur63
メンバー会議 20160121
atpons
UML勉強会2018
FrontPage
IGGG Meetup 2016 Winter
CTFの大会
コアメンバー会議 20150327
ジャンク祭り 20150618
トキオヤマグチ
...

謎は解けたので後は固めて scp するだけ:

# これは SSH 先
$ sudo tar czvf wiki-data.tar.gz /srv/http/wiki/wiki-data/wiki
...
$ ls -lah wiki-data.tar.gz
-rw-r--r-- 1 root root 249K 9月 30 18:23 wiki-data.tar.gz

# これはローカル
$ scp hoge@fuga:/path/to/wiki-data.tar.gz .
$ tar xzvf wiki-data.tar.gz
...

PukiWiki から MD に変換

魔法の sed 芸した:

  • PukiWiki の文書を Markdown に変換するワンライナー(一部 crowi-plus 仕様) - Qiita

とはいえいくつか漏れがある:

  • -hoge みたいな h1 要素があり、スペースが無い
  • #contents などもともとマジックワードのようなのがある
  • 画像の形式が変
  • [[xxx:yyy]] 形式のリンク

どうしようもないので手動で。。。
後、メタっぽいページはいらないので削除した(e.g. Help)。

後、タイトルを Ruby 芸:

ls | grep '.txt' | xargs -IORIG bash -c 'ruby -e "puts [ARGV[0].delete(%!.txt!)].pack(%!H*!).gsub(?\s, ?_)" ORIG | xargs -INEW echo mv ORIG NEW.md'

これでも漏れがあるので手動で直す。。。(空白とか)

MD 内の wiki へのリンクを Scrapbox に差し替え

参照:

  • ページをリンクする - Scrapbox ヘルプ

タイトルと同じならこの記法に変換。
それ以外は普通のリンクに。

ほぼ手作業で。

画像も Scrapbox にいい感じに

まず画像を持ってくる:

$ cd /srv/http/wiki/wiki-data/attach
$ ls | grep -v '.log' | xargs file | grep PNG
32E3818BE38289E5A78BE381BEE3828BE695B4E695B0E58897_696D61676530312E706E67: PNG image data, 225 x 31, 8-bit grayscale, non-interlaced
32E3818BE38289E5A78BE381BEE3828BE695B4E695B0E58897_696D61676530322E706E67: PNG image data, 225 x 31, 8-bit grayscale, non-interlaced
427575_735F646F742E706E67: PNG image data, 48 x 48, 8-bit/color RGBA, non-interlaced
457863656CE381A7E6A99FE6A2B0E5ADA6E7BF9228E69C80E8BF91E5828DE6B39529E38284E381A3E381A6E381BFE3828B_696D6730312E706E67: PNG image data, 1462 x 1482, 8-bit/color RGBA, non-interlaced
457863656CE381A7E6A99FE6A2B0E5ADA6E7BF9228E69C80E8BF91E5828DE6B39529E38284E381A3E381A6E381BFE3828B_696D6730322E706E67: PNG image data, 1456 x 1484, 8-bit/color RGBA, non-interlaced
...

やり方は *.txt と同じ(割愛)。
それを同じようにデコード(これは名前を出してみてるだけだけど):

$ ls | xargs -I{} ruby -e 'puts ARGV[0].split(?_).map{|x| [x].pack("H*")}.flatten.join(?/)' {}
2から始まる整数列/image01.png
2から始まる整数列/image02.png
Buu/s_dot.png
Excelで機械学習(最近傍法)やってみる/img01.png
Excelで機械学習(最近傍法)やってみる/img02.png
Excelで機械学習(最近傍法)やってみる/img03.png
Excelで機械学習(最近傍法)やってみる/img04.png
...

画像は Scrapbox にインポートできないっぽいので、大した量じゃないし雑なリポジトリを作って雑にあげた:

  • IGGG/resources - GitHub

あとは画像のリンクを直すだけ(半ば手作業で)。

MD を Scrapbox にインポート

参照:

  • ページをインポート・エクスポートする - Scrapbox ヘルプ

MD から Scrapbox にインポートできる形式に変換するには scrapbox-converter という CLI ツールを使う:

  • pastak/scrapbox-converter - GitHub

ガット変換して、試しにフォーマットして見て変な部分があれば 手作業で 直してインポート!
やったね!

new.iggg.org 側のリンクを修正

一括置換してみたが記法にいくつか種類があり、半ば手作業(完)

3. コミットから自動生成

せっかくなので GitHub Actions を使った。
その辺りは前回の記事に書いた:

  • GitHub Actions を使ってみた|群馬大学電子計算機研究会 IGGG

4. ドメインを iggg.org にする

あとはドメインの設定を変えるだけ。
リポジトリの Settings で別のカスタムドメインを設定すると勝手に CNAME をプッシュしてくれる。

おしまい

無事管理するものを減らせたぜ。

Web
GitHub, Hugo, Scrapbox.io
2019-10-11

GitHub Actions を使ってみた

IGGG ソフトウェア基盤部のひげです。
GitHub Pages へのデプロイに GitHub Actions を使ってみたので、そのことについて記事を書きます。

ちなみに IGGG/new.iggg.org と IGGG/IGGG.github.io に GitHub Actions を使ってみました。

GitHub Actions

GitHub が用意した CI/CD。

.github/workflows 配下に YAML ファイルで設定を置くことができます。
まだベータ版な点に注意。

  • Features • GitHub Actions · GitHub

設定する

やりたいことは2つ:

  1. PR では静的サイトを生成できるか試す
  2. メインブランチ(master)なら静的サイトをデプロイする(GitHub Pages)

こんな感じにした:

/
|- .github
| |- workflows
| | |- verify.yml
| | \- deploy.yml
| \- scripts
| \- deploy.bash
...

うまく Condition を使って一つの YAML にまとめてもよかったんだけど、めんどくさくなったので分けた。
ファイル名から察せれる通り、verify.yml が (1) を deploy.yml が (2) のための設定ファイルだ。

verify.yml

verify.yml は次の通り:

name: Verify PR
on: pull_request
jobs:
build:
runs-on: ubuntu-18.04
steps:
- uses: actions/checkout@v1
with:
fetch-depth: 1
submodules: true
- name: Setup Hugo
uses: peaceiris/actions-hugo@v2.2.1
with:
hugo-version: '0.58.3'
- name: Build
run: hugo --gc --cleanDestinationDir --minify --config config-prod.toml
- name: display status
uses: docker://buildpack-deps:18.04-scm
with:
entrypoint: git
args: status

(ちなみにこれは IGGG/new.iggg.org の方で、これは Hugo による静的サイト)

on: pull_request と記述することで PR に対してのみ動作します。
jobs 以下が実際の動作の内容で、各ステップでは状態を共有します。
uses で GitHub Actions で実行するアクション(リポジトリ)を指定できます(actions で始まるものは公式です):

  • actions/checkout - GitHub
    • 対象のリポジトリのブランチへクローンしてチェックアウトする
    • frtch-depth: 1 とすることでシャロークローンしてくれます
    • submodules: true とすることで --recursive オプション付きでクローンしてくれます(Hugo は利用するテーマを submodule として置くことが多い)
  • peaceiris/actions-hugo - GitHub
    • Hugo をセットアップする
    • hugo-version でバージョンを指定できる

docker://xxx という指定をすることで、Docker Hub などの Docker イメージのレジストリから直接指定することもできます。
で、結局このジョブは、単純に Hugo をビルドしてみてるだけですね。

deploy.yml

ここからが鬼門。
対象は GitHub Pages なので、デプロイするとはすなわち GitHub にプッシュすることですね。
その時に CI 側に権限を与える必要があるのですが、個人的にパーソナルトークンを使うのがいやで、可能ならリポジトリごとに設定できる SSH 鍵を使いたい。

そのように設定した deploy.yml は次の通り:

name: Deploy GitHub Pages
on:
push:
branches:
- master
paths-ignore:
- "docs/**"
jobs:
build:
runs-on: ubuntu-18.04
steps:
- uses: actions/checkout@v1
with:
fetch-depth: 1
submodules: true
- name: Setup Hugo
uses: peaceiris/actions-hugo@v2.2.1
with:
hugo-version: '0.58.3'
- name: Build
run: hugo --gc --cleanDestinationDir --minify --config config-prod.toml
- name: deploy
uses: docker://buildpack-deps:18.04-scm
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
GIT_NAME: Bot
GIT_EMAIL: example@example.com
TARGET_BRANCH: master
with:
entrypoint: /bin/bash
args: .github/scripts/deploy.bash

on.push.branches でこのワークフローが動作するブランチを指定しています。
on.push.paths-ignore: ["docs/**"] とすることで、もし 差分が docs 配下にしかない場合は動作しない ようにしています。
この paths-ignore と ** は最近追加された機能で、詳しくは後述します。

jobs の前半は verify.yml と同じです。
違うのは name: deploy のステップだけ。
これは .github/scripts/deploy.bash を実行しているだけですね。
中身を見てます:

#!/bin/bash
set -eux

# ssh agent のセットアップ
## DEPLOY_KEY 環境変数に secret から秘密鍵を与える
eval "$(ssh-agent -s)"
mkdir -p /root/.ssh
ssh-keyscan -t rsa github.com > /root/.ssh/known_hosts
echo "${DEPLOY_KEY}" > /root/.ssh/id_rsa
chmod 400 /root/.ssh/id_rsa

# コミットするための準備
## GITHUB_REPOSITORY は GitHub Actions が用意してくれてる環境変数
git config user.name "${GIT_NAME}"
git config user.email "${GIT_EMAIL}"
git remote set-url origin git@github.com:${GITHUB_REPOSITORY}.git

# docs 配下の差分だけ TARGET_BRANCH にプッシュする
git checkout ${TARGET_BRANCH}
git status
git add docs
git diff --staged --quiet || git commit -m "Update docs by GitHub Actions"
git push origin ${TARGET_BRANCH}

DEPLOY_KEY で指定する SSH 鍵はリポジトリごとに設定するものを指定しています(その方が権限管理が楽で個人的には好みです)。
git diff --staged --quiet || git commit -m "..." することで docs 配下に差分があった時にだけコミットを作ります。
もし差分がなく、コミットを作らなかった場合は git push は変更がなかったとメッセージを吐いて終了します。
docs 配下に差分があった時だけプッシュすることで on.push.paths-ignore: ["docs/**"] と組み合わさって、GitHub Actions によるプッシュ(デプロイ)で GitHub Actions が再度動作することは無くなります(残念ながら skip ci のような機能は現状無いので)。
さて、これでリポジトリごとの SSH 鍵でデプロイする設定ができました!

ちなみに、本当は Secret に秘密鍵を直接置くのは嫌なんですけど、、、まぁとりあえず妥協しました。

躓いたこと: on.push.paths

公式ドキュメントには当時、以下のようにすれば「docs 配下にのみ差分があったら動作しないようにできる」と書いてありました:

on:
push:
paths:
- '*'
- '!/docs/*'

これではうまくいきません。
色々調べた結果、* はディレクトリ階層を掘ってはくれないのです。
これを読む限り、これはどうやら Go のモジュールの仕様らしいですね。
もし *.md の差分だけ動作して欲しい場合は:

on:
push:
paths:
- '*.md'
- '*/*.md'
- '*/*/*.md'
- '*/*/*/*.md'

みたいなアホな設定をする必要がありました。
「ベータだなぁ〜」って思ってた矢先、なんと神アップデートがありました:

  • GitHub Actions – event filtering updates

** でディレクトリ階層を吸収してくれるのです。
つまり、**/*.md と書けば任意の深さのマークダウンの差分を検知してくれます。
また、paths-ignore は ! を省くことができる機能ですね。

おしまい

GitHub Actions を初めて使ってみましたが、結構満足してます(paths の修正のおかげで)。
あとはキャッシュぐらいかな。
それと、同じ GitHub 内だし GitHub Actions 用の SSH 鍵を設定する機能を公式が用意して欲しい。

Web
GitHub
2019-09-25

libnss-json 始めました

こんにちは。IGGG 何もしない部のatponsです。みなさんはサークルのサーバのユーザ管理、どうしていますか?

われわれのサークルは、そこまで規模が大きくないため、サーバの数は少なく、一台のみVPSをレンタルしています。

なので、これまでは手作業でユーザの追加を行ったり、時にはLDAPを用いてユーザを管理していました。しかし、年々メンテナンスをしていくユーザが卒業していき、LDAPなどを全て停止していました。

正しく設定が変更されてLDAPとかが抜けていればいいのですが、実際pam.d以下をちゃんとみてsssd(サークルではSSSDを利用していました)を削除するのがつらく、あるあるなsudo遅い問題(解決しにいくため)などが多発していました。

libnss-jsonを知る

先日行われた技術書典7に参加し、東工大デジタル創作同好会traPのSysAd班が出している「traP SysAd TechBook」を買って色々と読んでいたところ、libnss-jsonというのがあると言うことを知りました。

libnssとは

Linux(*nixにもあるらしい)にはName Service Switch(NSS)と呼ばれる、/etc/passwdなどをファイルからどこから読むのかを管理する機構があります。これにLDAPなどを読みに行くようなものを書けば、getentをした際にそこに読みに行きます、というワケです。これらは、NSSサービスとして書くことができます。

libnss-jsonを使う

これをJSONファイルで定義して、なおかつリモートから読み込んでくれるようなNSSサービスが、libnss-jsonです。導入方法などは上で挙げたtraPの本がとても参考になりました。

実際に導入する際には、導入用のAnsible Playbookを用意したり、Vagrantでしっかりと動作確認できるようにしました。既存のサーバで適用する前に、さまざまなケースを試し、問題ないことを確認した上でデプロイしました。

traPの本では、traPがフォークしたこのリポジトリを使って紹介されていました。

SSHも便利に使う

traPの本では、OpenSSHの設定でAuthorizedKeysCommandを上手く使って、上のJSONで定義したユーザ名を使ってGitHubの公開鍵と組み合わせていました。われわれのサークルもこの方式を採用させていただきました。

おわりに

Name Service Switchのバックエンドをいろんなモノに差し替えるという発想は、最近だとSTNSが有名だと思います。ただ、大学のサークル、しかも小規模となると、あまりメンテナンス性とか(抜けることが少ない)、階層性についてこだわりたくないなと思っていました。実際、プロビジョニングツールを書いたりはしていたのですが…。

しかし、このlibnss-jsonでかなりLightweightに管理できて個人的にはとても満足しています。

このlibnss-jsonを自動で展開するAnsible Playbookも書いたので、これで将来サーバが増えても簡単に管理できると思います!

さいごに、有益な情報を書いてくださったtraPのみなさまには感謝しかないです!
ありがとうございました。

Infra
libnss-json, Linux
2018-12-24

esaの利用をはじめました

本記事はIGGG アドベントカレンダー 2018 24日目の記事です。

群馬大学電子計算機研究会 IGGGでは,2018年の2月頃から情報共有の場としてesaを利用させていただいています.

esaとは

esaは「情報を育てる」という視点で作られた、自律的なチームのための情報共有サービス(esa公式ページより)です.詳しくは公式ページをみていただくとして,とりあえずMarkdownで書けて便利です.

なぜesaか

IGGGでは,以前からPukiWikiが情報共有の場として利用されてきました.これは,外部向けにも閲覧可能であることから,情報発信等には向いていましたが,内部で持っておきたい情報(引き継ぎ・会計等)についてはここには書けない状況になっていました.

先日公開された記事中でも,IGGGがGitHubのissueやWikiをベースとして運営の情報を管理しているということが挙がっていましたが,GitHubを普段利用しないメンバーにとってはなかなか見づらい・使いづらいということが頻繁に起こっていました.

サーバ管理等のコストなどもあり,より良いものに移行していきたい,引き継ぎがうまく出来るようにしたいというところで,esaの存在を知りました.

アカデミックプランの存在

esaにはアカデミックプランが存在しており(2018/12現在),条件を満たしていれば一定期間無償(再申請可能)で利用することが可能です.

そこで,申請を行い,現在無償で利用させて頂いています.

esaの使いどころ

個人的には,以下のように使い分けて行っています.

  • esa
    • 議事録
    • 会計
    • 周知事項など
  • PukiWiki
    • イベント/メンバ向け
  • GitHub Wiki
    • 引き継ぎ資料など(esaに移行したいかも?)

誰が見たか,記事へのコメント,WIP機能は今までの他のツールにはなく,とても使い勝手が良いです.

副産物として,群馬大学では学内でG Suiteが利用されており,群馬大学のアカウントでログインできるのも,使いやすくて便利です.

所感

メンバのみなさんには,もっとesaを使って色々書いて行ってみて欲しいです.部内Wikiですから,誰もちょっかい出さないと思うので(笑),あとバージョニングもあるので戻せますよ!

そろそろ引き継ぎを考える時期になりました.esaをはじめとしたツールを使いこなして,スムーズに引き継ぎが出来るようにがんばります!

最後に,申請を承認していただいたesaのみなさまにはこの場を借りて感謝申し上げます.

記事中の esa アイコンは クリエイティブ・コモンズ 表示 - 非営利 - 改変禁止 4.0 国際 ライセンスの下に提供されています。© esa LLC

Service
esa
2018-12-22

iggg.org を移行する

本記事はIGGG アドベントカレンダー 2018 22日目の記事です。

本記事では前々からやろうとしていた iggg.org の移行作業について書こうと思います。
現状ほとんど出来上がっていて、あとは細かいところの確認とドメインの変更を残すだけです。
GitHub Pages としてホストしており、下記URLよりアクセスできます。

  • https://iggg.github.io/new.iggg.org

なぜ移行するか

現在(2018/12/22) iggg.org はさくらのマシン上で WordPress を使って動いています。
諸々運用・管理がめんどくさくなってきた(アクティブな部員も少なってきたし)ので、(更新しなければ)運用コストゼロの静的サイトにしてしまおうとなったのです。

移行作業

実はうちには IGGG/management という議論用のプライベートリポジトリがあります。
作業はだいたい、そこの Issue に書いてあります。

社会人になってから Issue に途中作業を雑に書き連ねていく癖がついた。

データを抽出

まずは WP にあるデータを抽出する必要があります:

  • 記事のデータ: 可能なら Markdown として
  • メディア系(主に画像)

記事のデータは最初 wordpress-to-jekyll-exporter を使おうとしましたが、なんかうまくいかず断念。
そこで以下の記事を参考にして抽出しました:

  • WordPress の Markdown 移行補助ツールを開発してみた - アカベコマイリ

結構古い記事ですが、ちゃんと動作しました。
記事自体は xml2md という自作ツールの紹介ですが、中盤に WordPress を XML としてエクスポートする方法が書かれています。
ただ、変な Markdown になっていたり、いらないページまで Markdown になっていたりするので、そこは手作業で間引きます。

さて、次にメディア系です。
メディア系の抽出にか以下の記事を参考にしました:

  • Wordpressのメディアファイルを一括ダウンロードするプラグイン「Export Media Library」|Knowledge Base

このプラグインを利用してローカルにダウンロードしました。
あとはこれらを適当に git リポジトリに入れてプッシュすれば抽出完了。

Hugo

静的サイトジェネレーターには Hugo を使いました。
このサイトは Hexo (JS 製)だし、他の IGGG のサイトは Jekyll (Ruby 製)なのですが、せっかくなので使ったことないものを選択してみました。

CSS 職人になって WordPress で利用してたテーマを再現するのは苦行なので、なんとなく構造が似て入ればいいかなぐらいの気持ちで作ります。
そのため、なんとなく構造を再現できそうなテーマをベースとして選びました:

  • Hugo Theme Dopetrope|Hugo Themes

Hugo 利用するテーマをサブモジュールとして設定するみたいです(多分)。
なので、テーマをカスタマイズするためにベースにしたいテーマをフォークしました:

  • IGGG/hugo-theme-dopetrope - GitHub

基本的にうちのサイトのホームには:

  • 上部にナビゲーター
  • 画像スライダー
  • 最近の記事の更新
  • IGGGについての説明
  • イベントボード
  • Twitter タイムライン

があります。
ナビゲーターは dopetrope のものを利用しています。
ただし,config からナビゲーターの要素を次のように指定できるように変更しました:

[params.pages.home]
title = "Home"
link = "/"
internal = true
order = 0

[params.pages.about]
title = "About"
link = "/about"
internal = true
order = 1

...

[params.pages.wiki]
title = "Wiki"
link = "https://www.iggg.org/wiki/?FrontPage"
internal = false
order = 5

順番をうまくコントロールすることができなかったので order というパラメタを持たせています。
呼び出し側は次のようになります:

<nav id="nav">
{{ $title := .Page.Title }}
{{ $relLink := .Page.RelPermalink }}
{{ $baseUrl := .Site.BaseURL }}
<ul>
{{ range $page := sort .Site.Params.Pages "order" }}
{{ if $page.internal }}
<li {{ if or (eq $title $page.title) (eq $relLink $page.link) }} class="current" {{ end }}>
<a href="{{ $baseUrl }}{{ $page.link }}">{{ $page.title }}</a>
</li>
{{ else }}
<li><a href="{{ $page.link }}">{{ $page.title }}</a></li>
{{ end }}
{{ end }}
</ul>
</nav>

sort 関数に配列とパラメタ名を渡すと、そのパラメタでソートしてイテレーターに渡してくれます。
Hugo のテンプレートには結構リッチな組み込み関数が多いので面白いですね.
結構詰まったのがスコープです。
$baseUrl := .Site.BaseURL のように range の外で変数に定義しないと、range の中で .Site.BaseURL を呼び出しても想定通り取得できません(もしかしたら別の方法があるかも)。

画像のスライダーには balaramadurai/hugo-travelify-theme のモノを拝借しました。
ただ、次のように config からスライダーの画像を指定できるようにしています:

[params.slider]
enable = true
slides = [
"2014/05/DSC03469.jpg",
"2014/05/DSC08554_.jpg",
"2014/05/DSC07319.jpg",
"2014/05/2014-03-19-11.24.19.jpg",
"2014/05/DSC08222.jpg",
"2014/06/DSC09428.jpg",
"2014/06/DSC09437.jpg",
"2014/06/2014-06-23-17.05.10.jpg",
"2014/06/2014-06-25-21.20.40.jpg",
"2014/06/2014-06-28-17.54.04.jpg",
]

「最近の記事の更新」や「IGGGについての説明」は dopetrope のモノを使い、CSSで調整しています。
ちなみに、CSSを変更しても、うまく読み込まれず苦労しました(リロードしたり変更したり、結局正しいやり方は分からず)。
イベントボードは自作して、今まで同様に外から設定できるようにしています:

sections:
- title: "What's New"

- title: 'TWITTER'

- title: 'EVENTS'
events:
- title: 'IGGG ADVENT CALENDAR 2017'
imagelink: 'https://adventar.org/calendars/2300'
imageurl: '/images/2017/11/igggAC2017.jpg'

Twitter のタイムラインはここで生成したモノをただ単純に埋め込んでいます。

GitHub Pages

さて、ここまでくれば後は Public にするだけだ(ここまでは Private リポジトリにしてた)。
Settings で Public に変更し、GitHub Pages の設定を docs にしてオンする。
意気揚々と iggg.github.io/new.iggg,org にアクセスすると。。。。

見れない!
あれ、なぜだ??
答えはこれ:

  • How to fix page 404 on Github Page? - Stack Overflow

コミットしないと GitHub Pages の生成がされないらしい。
なので空コミットしたら無事表示された!

残タスク

  • 古いページは多分崩れてるので直さないと
  • DNS を iggg.org にする
  • コミットから自動生成する仕組み
  • プレビュー機能

おしまい

Hugo 結構使いやすい。

Web
GitHub, WordPress, Hugo
2018-12-15

CircleCI の設定ファイルを 2.0 に更新

IGGG ソフトウェア基盤部のひげです。
1年ぶりの更新です。

本記事はIGGG アドベントカレンダー 2018 15日目の記事です。
まぁ今回はほとんど埋まっていませんが(笑)

本当は別の話を書こうと思っていたんだけど、記事を書くためにこのサイトを整備、というかほったらかしにいていた CircleCI 2.0 へのアップデートをしたら思いの外大変だったのでその過程を書きたいと思います。
まぁみんな既に 2.0 への更新は済んでそうですけどね。

ここの構成

このサイトは CircleCI を使って自動デプロイなどを導入している:

  • GitHub Pages + Hexo + CircleCI + Heroku で自動デプロイ管理|群馬大学電子計算機研究会 IGGG

実はこれ、 CircleCI 1.0 のままだった。
もう2年半近く前だからしょうがないね。
元々の設定はこんな感じ:

machine:
timezone: Asia/Tokyo
node:
version: 4.4.5
deployment:
production:
branch: source
commands:
- git config --global user.name "IGGGorg"
- git config --global user.email "contact@iggg.org"
- git submodule init
- git submodule update
- ./node_modules/.bin/hexo clean
- ./node_modules/.bin/hexo generate
- ./node_modules/.bin/hexo deploy
staging:
branch: staging
commands:
- git config --global user.name "IGGGorg"
- git config --global user.email "contcat@iggg.org"
- ./node_modules/.bin/hexo clean
- ./node_modules/.bin/hexo generate
- ./node_modules/.bin/hexo deploy --branch $CIRCLE_BRANCH --config _staging_config.yml
general:
branches:
ignore:
- master

更新作業

ちなみに最終的にこんな感じ:

defaults: &defaults
docker:
- image: circleci/node:11.4.0
environment:
TZ: Asia/Tokyo
working_directory: ~/work

version: 2
jobs:
build:
<<: *defaults
steps:
- checkout
- restore_cache:
keys:
- v1-dependencies-{{ checksum "package.json" }}
# fallback to using the latest cache if no exact match is found
- v1-dependencies-
- run: npm install
- run: node_modules/.bin/hexo clean
- run: node_modules/.bin/hexo generate
- save_cache:
paths:
- node_modules
key: v1-dependencies-{{ checksum "package.json" }}
- persist_to_workspace:
root: .
paths: [ '*' ]
deploy-production:
<<: *defaults
steps:
- attach_workspace:
at: .
- run:
name: init
command: |
git config --global user.name "IGGGorg"
git config --global user.email "contact@iggg.org"
mkdir ~/.ssh/ && echo -e "Host github.com\n\tStrictHostKeyChecking no\n" > ~/.ssh/config
git submodule init
git submodule update
- run:
name: deploy to production
command: ./node_modules/.bin/hexo deploy
deploy-staging:
<<: *defaults
steps:
- attach_workspace:
at: .
- run:
name: init
command: |
git config --global user.name "IGGGorg"
git config --global user.email "contact@iggg.org"
- run:
name: deploy to staging
command: |
mkdir .deploy
cd .deploy
git init
echo "{ \"root\": \"public/\" }" > static.json
git add -A
git commit -m "First commit"
cp -r ../public .
git add -A
git commit -m "Site updated"
git push -u https://heroku:$HEROKU_API_KEY@git.heroku.com/iggg-github-io-staging.git master -f

workflows:
version: 2
build-and-deploy:
jobs:
- build:
filters:
branches:
ignore:
- master
- deploy-production:
requires:
- build
filters:
branches:
only:
- source
- deploy-staging:
requires:
- build
filters:
branches:
only:
- staging

やっつけでやったからだいぶ余計な部分がありそう。

CircleCI 2.0

旧バージョンである CircleCI 1.0 は2018年8月31日に終了し、以降は 2.0 でしか CI を回せなくなった(このサイトは2017年3月以降回していない笑)。

変更の仕方はそこまで難しくない。
下記の公式ドキュメントにしたがって変更して行けば良い:

  • Migrating a Linux Project from 1.0 to 2.0 - CircleCI

手順をざっくりと抜粋すると:

  1. /circle.yml を /.circleci/config.yml に置換
  2. version: 2 を冒頭に追加
  3. deployment は jobs に変更:
    • commands は steps にする
    • commands のリストの要素は run: にする
  4. machine 以下の設定を docker にして jobs に書き加える
  5. 適当に workflows を定義
    • branch の設定はこっちでする

あとは checkout やキャッシュ回りの設定、job 間でワークスペースを共有するために persist_to_workspace と attach_workspace を追記した。

Hexo の更新

Node が古すぎて CircleCI の Docker イメージがなかった:

Build-agent version 0.1.1250-22bf9f5d (2018-12-12T11:32:15+0000)
Starting container circleci/node:4.4.5
image cache not found on this host, downloading circleci/node:4.4.5

Error response from daemon: manifest for circleci/node:4.4.5 not found

ので、随分古い Node と Hexo を使っていたので更新した。

  • Node: 4.4.5 -> 11.4.0
  • Hexo: 3.3.5 -> 3.8.0

ここは特に問題なく動作した(たぶん)。

Heroku と CircleCI

ここからがしんどい。。。

上の手順で適当に書き換えても動かなかった。
何がかというと、最終的なデプロイの部分だ。
まずは Staging である Heroku の部分。
CircleCI でデプロイしてみたら:

create mode 100644 public/tags/Twitter/index.html
create mode 100644 public/tags/guntohfes/index.html
The authenticity of host 'heroku.com (50.19.85.156)' can't be established.
RSA key fingerprint is SHA256:XXX/o.
Are you sure you want to continue connecting (yes/no)? Step was canceled

という状態で固まってしまった。
いろいろ調べてみたら、そもそも過去に設定したやり方はもう古いみたいだ(本当に?)。
なので 2.0 の資料を参考にし HEROKU_API_KEY を使った方法にしようと思う。

Hexo の Heroku へのデプロイには hexo-deployer-heroku というライブラリを使っている。
このライブラリで HEROKU_API_KEY 環境変数を埋め込むのは難しそうなので、hexo-deployer-heroku のコードを読んで同様の手順を CI で直接実行するようにした:

mkdir .deploy
cd .deploy
git init
cp -r ../public .
git add -A
git commit -m "Site updated"
git push -u https://heroku:$HEROKU_API_KEY@git.heroku.com/iggg-github-io-staging.git master -f

しかし、次のようなエラーが出た:

...
Writing objects: 100% (171/171), 4.58 MiB | 8.98 MiB/s, done.
Total 171 (delta 33), reused 0 (delta 0)
remote: Compressing source files... done.
remote: Building source:
remote:
remote: -----> App not compatible with buildpack: https://buildpack-registry.s3.amazonaws.com/buildpacks/heroku/php.tgz
remote:
remote: ! ERROR: Application not supported by this buildpack!
remote: !
remote: ! The 'heroku/php' buildpack is set on this application, but was
remote: ! unable to detect a PHP codebase.
remote: !
remote: ! A PHP app on Heroku requires a 'composer.json' at the root of
remote: ! the directory structure, or an 'index.php' for legacy behavior.
remote: !
remote: ! If you are trying to deploy a PHP application, ensure that one
remote: ! of these files is present at the top level directory.
remote: !
remote: ! If you are trying to deploy an application written in another
remote: ! language, you need to change the list of buildpacks set on your
remote: ! Heroku app using the 'heroku buildpacks' command.
remote: !
remote: ! For more information, refer to the following documentation:
remote: ! https://devcenter.heroku.com/articles/buildpacks
remote: ! https://devcenter.heroku.com/articles/php-support#activation
...

git push のところで起きている。
buildpack に heroku/php を指定しているが、この場合はワークスペースに index.php などがないとダメらしい。
hexo-deployer-heroku ではこの assets の中身をコピーして heroku/php に合わせていた。
同じようにしてもいいが、別に PHP ではないので静的サイト用の buildpack に変更するようにした:

  • heroku/heroku-buildpack-static - GitHub

heroku-buildpack-static の設定ファイルとして static.json をワークスペースに置く必要がある。
なので、下記のコマンドを git init と cp -r ../public . の間で実行する:

echo "{ \"root\": \"public/\" }" > static.json
git add -A
git commit -m "First commit"

これで無事 Heroku にデプロイできた!

GitHub と CircleCI

こっちも案の定ダメだった。
Heroku の時と同様に yes/no と聞かれて止まってしまう。
GitHub へのデプロイには hexojs/hexo-deployer-git を使っている。
Heroku の時みたいに同様の動作を直接書き込んでもいいが、できれば API トークンを使いたくないので、別の方法を調べた。
良さそうな CircleCI の質問ページがあった:

  • Git clone fails in Circle 2.0 - Build Environment - CircleCI Community Discussion

次のようなのをデプロイする前に書けばいいらしい:

mkdir ~/.ssh/ && echo -e "Host github.com\n\tStrictHostKeyChecking no\n" > ~/.ssh/config

エラーが変わった:

...
create mode 100644 tags/guntohfes/index.html
Warning: Permanently added 'github.com,192.30.253.112' (RSA) to the list of known hosts.

ERROR: The key you are authenticating with has been marked as read only.
fatal: Could not read from remote repository.

Please make sure you have the correct access rights
and the repository exists.
FATAL Something's wrong. Maybe you can find the solution here: http://hexo.io/docs/troubleshooting.html
Error: Warning: Permanently added 'github.com,192.30.253.112' (RSA) to the list of known hosts.

ERROR: The key you are authenticating with has been marked as read only.
fatal: Could not read from remote repository.

Please make sure you have the correct access rights
and the repository exists.

at ChildProcess.<anonymous> (/home/circleci/work/node_modules/hexo-util/lib/spawn.js:37:17)
at ChildProcess.emit (events.js:189:13)
at maybeClose (internal/child_process.js:978:16)
at Socket.stream.socket.on (internal/child_process.js:395:11)
at Socket.emit (events.js:189:13)
at Pipe._handle.close (net.js:613:12)
Exited with code 2

これは設定している GitHub の SSH 鍵に書き込み権限が無いせいだ。
読み込みだけのやつはコンソールからボタン一つでできたが、書き込み権限付きの鍵は自分で作る必要がある。
以下の記事がわかりやすかった(似たような記事はたくさんあると思うけど):

  • ircle CI で Github に write access 可能な Deploy key を設定する - Qiita

これで無事本番にもデプロイできた!

残り作業

README とか Docker のところとかもほんとは整備しなきゃ。。。

おしまい

更新は計画的に。

Web
Heroku, GitHub, CircleCI
2017-06-01

Slack から特定のアカウントでツイートする Bot を作った

IGGG ソフトウェア基盤部のひげです。

Slack をインターフェースにして特定の Twitter アカウントでツイートする Slack Bot を GAS で作った話。
なんか Bot ばっかり作ってる気がする。

いきさつ

IGGG は部としての活動はあまりなく、個人での活動が多い(ちなみに、それを支援する部になればなぁと思ってます。)。
いっけん何もしてないように見えるが、各々で何かしてる場合があるので、それをもっと広報してみようという話が、この前の Meetup のときにあった。

そこで、個人の活動を PR するための Twitter アカウントを作って、活動、例えば、各々のWebサイトの更新や GitHub リポジトリの更新などをツイートしようとなった。

ただ、いちいちログインしてツイートするのはめんどくさい。
ということで、Slack の特定のチャンネルで発言すると、それを本文としてツイートできるようにすることにした。

作る

ステップバイステップに作ったので、せっかくだから、その過程を書いておく。
最終的な GAS コードはココにある。
いくつかのプロパティの他に、以下の外部ライブラリを使用した。

  • SlackApp : M3W5Ut3Q39AaIwLquryEPMwV62A3znfOO
  • OAuth1 : 1CXDCY5sqT9ph64fFwSzVtXnbjpSfWdRymafDrtIZ7Z_hwysTY7IIhi7s
  • Underscore : M3i7wmUA_5n0NSEaa6NnNqOBao7QLBR4j

1. とりあえずそのままツイート

まずは何も考えずにそのままツイートしてくれる Slack Bot を作る。
GAS なので、Outgoing Webhook を使う。
Bot の名前を announcer にするということにして(Customize Name をいじるわけではない、いじってもいいけど),@announcer を Trigger Word(s) に設定する。

イメージはこんな感じ

と打つと、Twitterで「こんにちは、テスト !!」をつぶやいてくれる。

  • Google Apps ScriptからTwitterに投稿する。|ポンコツプログラマの開発日記

を参考にして作った。

1-1. Twitter API Token の取得

Twitter API を叩くために必要。
電話番号が登録されているアカウントでないとダメなので、自分のツイッターアカウントで発行した(ちなみに、こういう開発での用途か ROM 専でしか使ってない、ぼくは)。
発行の手順は簡単

  1. Twitter にログイン
  2. https://dev.twitter.com/docs にアクセスして上の方にある My apps をクリック
  3. Create New App をクリックして必要事項を埋める
    • Name, Description, Website を埋める必要があるが、正直なんでもいい
    • アプリで重要なのは Callback URL だが、まだ埋めなくても平気
  4. Keys and Access Tokens というタブをクリックすると、そこに必要なトークンがある

1-2. Bot を書く!

サンプルコードを参考にして、ソースコードはこんな感じ。

ちなみに、プロパティは以下のようになっている

  • VERIFY_TOKEN: Outgoing Webhook Slack App の Token
  • SLACK_API_TOKEN: ココから発行できる Slack の API トークン
  • ICON_ID: Google Drive にある Slack Bot のアイコンに使う画像の ID (別に無くてもいいし、URL を使うように書き換えたって良い)
  • TWITTER_CONSUMER_KEY: 1-1 で用意した Consumer Key (API Key)
  • TWITTER_CONSUMER_SECRET: 1-1 で用意した Consumer Secret (API Secret)
function doPost(e) {
var prop = PropertiesService.getScriptProperties().getProperties();

if (prop.VERIFY_TOKEN != e.parameter.token) {
throw new Error('invalid token.');
}

/* for Slack */
var slackApp = SlackApp.create(prop.SLACK_API_TOKEN);

const BOT_NAME = 'announcer';
const BOT_ICON = 'http://drive.google.com/uc?export=view&id=' + prop.ICON_ID;
var option = { username : BOT_NAME, icon_url : BOT_ICON, link_names : 1 };

var message = e.parameter.text.split('\n');
var channelName = e.parameter.channel_name;

if (message[0] != ('@' + BOT_NAME)) {
throw new Error('invalid bot name.');
}

var result = postTweet(message.slice(1, message.length).join('\n'));

var text = '';
if (result != 'error') {
text = 'Success!\n' + 'https://twitter.com/IGGGorg_PR/status/' + result['id_str'];
} else {
text = 'Denied...'
}
Logger.log(slackApp.postMessage(channelName, text, option));
}

/**
* Authorizes and makes a request to the Twitter API.
*/
function postTweet(text) {
var service = getService();
if (service.hasAccess()) {
var url = 'https://api.twitter.com/1.1/statuses/update.json';
var payload = {
status: text
};
var response = service.fetch(url, {
method: 'post',
payload: payload
});
var result = JSON.parse(response.getContentText());
Logger.log(JSON.stringify(result, null, 2));
return result;
} else {
var authorizationUrl = service.authorize();
Logger.log('Open the following URL and re-run the script: %s',
authorizationUrl);
return 'error';
}
}

/**
* Reset the authorization state, so that it can be re-tested.
*/
function reset() {
var service = getService();
service.reset();
}

/**
* Configures the service.
*/
function getService() {
var prop = PropertiesService.getScriptProperties().getProperties();
return OAuth1.createService('Twitter')
// Set the endpoint URLs.
.setAccessTokenUrl('https://api.twitter.com/oauth/access_token')
.setRequestTokenUrl('https://api.twitter.com/oauth/request_token')
.setAuthorizationUrl('https://api.twitter.com/oauth/authorize')

// Set the consumer key and secret.
.setConsumerKey(prop.TWITTER_CONSUMER_KEY)
.setConsumerSecret(prop.TWITTER_CONSUMER_SECRET)

// Set the name of the callback function in the script referenced
// above that should be invoked to complete the OAuth flow.
.setCallbackFunction('authCallback')

// Set the property store where authorized tokens should be persisted.
.setPropertyStore(PropertiesService.getUserProperties());
}

/**
* Handles the OAuth callback.
*/
function authCallback(request) {
var service = getService();
var authorized = service.handleCallback(request);
if (authorized) {
return HtmlService.createHtmlOutput('Success!');
} else {
return HtmlService.createHtmlOutput('Denied');
}
}

reset, getService, authCallback 関数はサンプルコードをそのまんま、 postTweet 関数はサンプルコードの run 関数を返り値があるように書き換えたモノだ。

次に、Twitter側に https://script.google.com/macros/d/{SCRIPT_ID}/usercallback を Setting からの Callback URL に書き込む。
ここでの SCRIPT_ID は ファイル の プロジェクトのプロパティ にある スクリプト ID に書いてある文字列である(URLからも実はわかる)。

できたら、いちど postTweet 関数を GAS 側で実行すると、現在ログインしている Twitter アカウント でのアプリケーション連携の認証ページへ飛ばされるので許可すればよい。

そして最後に、GAS側の 公開 の ウェブアプリケーションとして導入 に書いてある URL を Outgoing Webhook の URL(s) にコピペすれば Slack 側とも繫がることができる。

1-3. ためしに実行

こんな感じ

2. 適当にフィルタリング

なんでもかんでもツイートされては困るので、http ってキーワードとかでフィルターを掛けてみてはどうか、という話があったので、簡単にかけてみた。

function doPost(e) {
var prop = PropertiesService.getScriptProperties().getProperties();

/* 同じなので割愛 */

if (message[0] != ('@' + BOT_NAME)) {
throw new Error('invalid bot name.');
}

var text = '';
var messageBody = message.slice(1, message.length).join('\n');
if (messageBody.indexOf('http') == -1) {
text = 'Denied: do not include "http".';
} else {
var result = tweet(messageBody);
if (result != 'error') {
text = 'Success!\n' + 'https://twitter.com/IGGGorg_PR/status/' + result['id_str'];
} else {
text = 'Denied...';
}
}
Logger.log(slackApp.postMessage(channelName, text, option));
}

GAS の JavaScript は古いため、文字列が任意の文字列を含むかどうかを indexOf メソッドで調べるしかないらしい。
雑な実装ですね…

3. Tweet Request (TR) によるレビュー

どう考えてもガバガバフィルターだなぁと思ってるところに神からのお告げが来た。

Real Time Messaging API であれば何でも取得できるので実装できそうだったが、GAS では RTM は使えない…orz

だがしかし、Add Reaction をフックして投稿することはできないけど、

  1. PR を作るように Tweet Request を作成するメッセージを打つ
  2. TR に LGTM な Add Reaction をする
  3. PR をマージするみたいに TR を許可(ツイート)する用のメッセージを打つ
    • 但し、Add Reaction が少ないとツイートできない

って感じに、リクエストの作成とツイートをポストするのを PR みたいに分ければできそうだ!
Add Reaction の取得自体は RTM じゃなくても、Slack の REST API の reactions.get を使えばできる。

TR の管理にはスプレッドシートを使う(GitHub の Issue でもいい気はするけど)。
なので、スプレッドシート用に以下のプロパティを追加した。

  • SPREAD_SHEET_ID: TRを管理するためのスプレッドシートのID
  • SHEET_NAME: TRを管理するためのスプレッドシートのシート名

コード書き直す

ソースコードはこんな感じになった(無駄に長い気もする)

function doPost(e) {
var prop = PropertiesService.getScriptProperties().getProperties();

if (prop.VERIFY_TOKEN != e.parameter.token) {
throw new Error('invalid token.');
}

/* Load Spread Sheet */
var sheet = SpreadsheetApp.openById(prop.SPREAD_SHEET_ID).getSheetByName(prop.SHEET_NAME);

/* for Slack */
var slackApp = SlackApp.create(prop.SLACK_API_TOKEN);

const BOT_NAME = 'announcer';
const BOT_ICON = 'http://drive.google.com/uc?export=view&id=' + prop.ICON_ID;
var option = { username : BOT_NAME, icon_url : BOT_ICON, link_names : 1 };

var body = e.parameter.text.slice(e.parameter.trigger_word.length).trim();
var timestamp = e.parameter.timestamp;
var channelId = e.parameter.channel_id;
var text = '';
var _ = Underscore.load();
switch (e.parameter.trigger_word) {
case '$tweet?':
const rowNum = sheet.getLastRow() + 1;
setTweetRequest(sheet, _.extend(e.parameter, {body: body, num: rowNum}));
text = 'set tweet request: ' + rowNum;
break;
case '$tweet!':
var tr = getTweetRequest(sheet, body);
if (tr.ok) {
var result = tweetWithCheck(tr, prop.SLACK_API_TOKEN);
if (result.ok)
sheet.getRange(tr.num, 4).setValue(1);
text = result.text;
} else {
text = tr.error;
}
break;
default:
text = 'undefined trigger word: ' + e.parameter.trigger_word;
}
Logger.log(slackApp.postMessage(channelId, text, option));
// Logger.log(text);
}

function getTweetRequest(sheet, rowNum) {
if (isNaN(rowNum)) {
return { ok: false, error: 'please input number: ' + rowNum };
}
var body = sheet.getRange(rowNum, 1).getValue();
if (body == '') {
return { ok: false, error: 'not found tweet request: ' + rowNum };
}
return {
ok: true,
body: body,
channel_id: sheet.getRange(rowNum, 2).getValue(),
timestamp: sheet.getRange(rowNum, 3).getValue().slice(1),
num: rowNum,
done: sheet.getRange(rowNum, 4).getValue() == 1
};
}

function setTweetRequest(sheet, tr) {
sheet.getRange(tr.num, 1).setValue(tr.body);
sheet.getRange(tr.num, 2).setValue(tr.channel_id);
sheet.getRange(tr.num, 3).setValue('t' + tr.timestamp);
sheet.getRange(tr.num, 4).setValue(tr.done ? 1 : 0);
}

function tweetWithCheck(tr, token) {
if (tr.done) {
return { ok: false, text: 'TR ' + tr.num + ' has already been tweeted.' };
}
var url = 'https://slack.com/api/reactions.get';
var options = {
method: 'post',
payload: {
token: token,
channel: tr.channel_id,
timestamp: tr.timestamp
}
};
var result = JSON.parse(UrlFetchApp.fetch(url, options));
if (!result.ok) {
return { ok: false, text: 'error: ' + result.error };
}
const borderline = 2;
var lgtm = 0;
for (var i in result.message.reactions) {
var reaction = result.message.reactions[i];
if (reaction.name == '+1') {
lgtm = reaction.count;
}
}
var emassage = 'Few :+1: for tweet req: need ' + borderline + ', now ' + lgtm;
return lgtm >= borderline ? tweet(tr) : { ok: false, text: emassage }
}

function tweet(body) {
var result = postTweet(body);
// var result = { id_str: 'tweet!!' };
if (result == 'error') {
return { ok: false, text: 'Denied...' };
} else if (result.errors != undefined) {
return { ok: false, text: 'Denied: ' + result.errors[0].code + ' ' + result.errors[0].message };
} else {
return { ok: true, text: 'Success!\n' + 'https://twitter.com/IGGGorg_PR/status/' + result['id_str'] };
}
}

あんまりスプレッドシートのオブジェクトを伝搬させたくなくて、奇妙な返り値になっている。
まぁとりあえずはこれで良しとします…

CTO の助言のもと、TRの作成とTRのツイートのコマンドを $tweet? と $tweet! にした。

スプレッドシートのカラムは、ツイートしたい本文, チャンネルID, タイムスタンプ, ツイート済みかのフラグ となっている。
reactions.get を使って特定のメッセージの Add Reaction を取得するには、メッセージを特定するために、チャンネルIDとタイムスタンプが必要だ(チャンネル名ではダメ)。

ちなみに、チャンネル名からチャンネルIDを調べるには、ココ を使うのが良いみたい。
また、タイムスタンプはメッセージの時刻を右クリックして取得できる URL、例えば https://iggg.slack.com/archives/C06FXCF4K/p1496313613432037 の 1496313613432037 を 1496313613.432037 のように前から10-11番目の数字の間に . を入れるだけで良い。
まぁ実際は Slack から飛んできたメッセージの情報に載ってるので、わざわざ手作業で集める必要はないんだけど、テストしたいときとかに使った。

Slack API を便利に使う GAS ライブラリでは reactions.get を実行でき無さそうだったので、UrlFetchApp.fetch を以下のように直接使った。

var url = 'https://slack.com/api/reactions.get';
var options = {
method: 'post',
payload: {
token: token,
channel: tr.channel_id,
timestamp: tr.timestamp
}
};
var result = JSON.parse(UrlFetchApp.fetch(url, options));

実行

いい感じ b

おしまい

みんなツイートしてくれるといいなぁ。

Web
Slack, Bot, Google Apps Script, Twitter
2017-05-17

Slack と GAS と GitHub を使って部内の問題・情報管理を円滑にしたい話

IGGG ソフトウェア基盤部のひげです。

特定の GitHub リポジトリの任意の Issue を任意の Slack のチャンネルに通知を飛ばすための Bot (これは公式のインテグレーションではできないはず) を GAS で作った話です。

いきさつ

IGGG では予てより Slack というチャットサービスを利用して日々 雑談 ディスカッションをしております。
しかし、フリープランの Slack では ログが一万件しか残らない!
割と重要な事案が しょーもない雑談 活発な技術的な議論によって、気づいたら流れてしまう…。

皆を説得して有償プランにしようとか、ログの残るサービスに移ろうとか、何度かいろんな案で話し合ったのですが…いろいろな理由で難しい。
そこで、IGGG の 外圧担当 CTO こと 擬音 より GitHub の Issue で管理しよう との提案が、今年の4月中旬ぐらいにされた。

いい感じ

しっかりと問題提起・議論が残り、とてもいい感じ。

問題

ただ、いくつか問題があった。

  1. IGGG の GitHub 組織アカウントに所属しないと議論が見れない
    • 誰しもが GitHub アカウントを持ってるわけでは…
      • もっておこうよ
    • Slack の GitHub Integration で特定のチャンネル(#management)に飛ばす
      • 飛ばしてるけど、決定事項(Issueの結論)だけ #general に飛ばしたい
      • 全部 #general に飛ばしたらうるさい
  2. 特定のチャンネルで見たい特定の Issue がある
    • インフラチャンネルで IPアドレスが欲しい という Issue が見たいとか
    • いちいち #management に移るのめんどい

要するに 1) #general に Issue の Open と Close だけをコメント付きで通知したいのと、 2) 任意の Issue のコメントを任意のチャンネルに通知したい。

ということで、これらの問題を解決するために Slack の Bot を作った。

1. #general に Issue の Open と Close だけをコメント付きで通知する

GitHub インテグレーションでも、Issue の Open, Close だけを飛ばすという設定はある。

GitHub インテグレーションにて

だがしかし、これでは

てすと

という感じの Issue に対し(てててすと というコメントは Close 時に書いたコメント)

てーすと

と感じに来る(そりゃそう)。
ここで、最後のコメントも通知してほしいのだ(Issueの結論を書いて)。

Manager 1号

ということで Bot を作った。
ソースコードはこちら。

以下の3つのGASライブラリを用いている。

  • SlackApp : M3W5Ut3Q39AaIwLquryEPMwV62A3znfOO
  • GitHubAPI : 1F4yn329GjHKdcXu9nm0uBZHFo40NGRUF8dfZCTHM1KjXpOXYr2BzIIcJ
  • Underscore : M3i7wmUA_5n0NSEaa6NnNqOBao7QLBR4j

また、SlackBot の APIトークン と GitHub の APIトークン を利用している。

GitHub の Personal Token を利用しているので、念のため IGGG の GAS では無く、個人の GAS 上で作った。

実装

動作は Issue の Open と Close にフックして動作させ、POSTデータを解析し、適切なメッセージ作成して、Slack に投げている。

function doPost(e) {
var jsonString = e.postData.getDataAsString();
var jsonData = JSON.parse(jsonString);
postMessage(jsonData, 'general');
}

function postMessage(data, channelName) {
var prop = PropertiesService.getScriptProperties().getProperties();
const repo = prop.GITHUB_OWNER + '/' + prop.GITHUB_REPO;

/* 念のためのフィルタリング */
if (data['repository']['full_name'] != repo && data['comment'] == undefined && data['issue'] != undefined) {
throw new Error("invalid repository.");
}
/* 余計なアクション(editedとか)は破棄 */
if (data['action'] != 'opened'
&& data['action'] != 'closed'
&& data['action'] != 'reopened') {
throw new Error("undefined action: " + data['action']);
}

 /* メッセージを作成 */
var number = data['issue']['number'];
var message = makeMessage(data['action'], data['issue'], repo, prop);

/* Slackに投げる */
var slackApp = SlackApp.create(prop.SLACK_API_TOKEN);
const BOT_NAME = 'manager';
const BOT_ICON = 'http://drive.google.com/uc?export=view&id=' + prop.ICON_ID;
var option = { username : BOT_NAME, icon_url : BOT_ICON, link_names : 1 };
var _ = Underscore.load();
slackApp.postMessage(channelName, '', _.extend(option, message));
}

メッセージは Open, Close, ReOpen の場合に分けて作成している。
Close の場合だけ直近のコメントを GitHub API を使って取ってきている(getIssueResentCommentBody)。

function makeMessage(action, issue, repo, prop) {
var user = '<' + issue['user']['html_url'] + '|' + issue['user']['login'] + '>';
/* アクションごとに分岐 */
var actionText = '';
var text = '';
switch(action) {
case 'opened':
actionText = 'created';
text = issue['body'];
break;
case 'closed':
actionText = 'closed';
text = getIssueResentCommentBody(issue['number'], prop);
break;
case 'reopened':
actionText = 're-opened';
text = issue['body'];
break;
}
var pretext = '[' + repo + '] Issue ' + actionText + ' by ' + user;

/* いい感じなメッセージにするために */
return {
'attachments': JSON.stringify([{
'pretext': pretext,
'title': '#' + issue['number'] + ': ' + issue['title'],
'title_link': issue['html_url'],
'color': prop.COLOR,
'text': text,
'footer': '詳細は #management かリポジトリで'
}])
};
}

function getIssueResentCommentBody(number, prop) {
var github = GitHubAPI.create(prop.GITHUB_OWNER, prop.GITHUB_REPO, prop.GITHUB_API_TOKEN);
var comment = github.get("/issues/" + number + "/comments");
return comment[comment.length-1]['body'];
}

attachmentsを使って、頑張っていい感じのメッセージにしている。
これを模索するために、テストとして無駄にメッセージを投げてしまった(ごめんね)。

ちなみに、webhook せずにテストするときは

function test() {
var prop = PropertiesService.getScriptProperties().getProperties();
var data = {
'action': 'closed',
'repository': {
'full_name': prop.GITHUB_OWNER + '/' + prop.GITHUB_REPO
},
'issue': {
'number': 15,
'title': 'このリポジトリは必要か',
'user': {
'login': 'matsubara0507',
'html_url': 'https://github.com/matsubara0507'
},
'html_url': 'https://github.com/' + prop.GITHUB_OWNER + '/' + prop.GITHUB_REPO + '/issues/15',
'body': 'なんか知らぬ間に決まってる感じもある\n自分で見に行けってのもあるけどサ'
}
};
/* テスト!!*/
postMessage(data, 'bot-test');
}

こんな感じの関数を作って実行する。

あとは、このスクリプトを、公開 -> ウェブアプリケーションとして導入 を押して webhook 用の URL を発行し、これを リポジトリの Webhook に設定するだけ。

Issueだけチェックする

実行

いい感じ

2. 任意の Issue のコメントを任意のチャンネルに通知する

GitHub インテグレーションは特定のリポジトリと特定のチェンネルをつなぐ。
よって、特定のリポジトリの特定の Issue と特定の特定のチャンネル繋ぐことはできない。

Manager 2号

どのチャンネルにどの Issue のを通知するかの設定も Slack からしたいよね。
そのため、設定する側と、コメントにフックして通知する側の2つに分けて書くことにする。
それらのソースコードは、これ(設定) と これ(通知)。

ライブラリは 1号のと同じ。

チャンネルと Issue の対応表は(めちゃ簡単な)スプレッドシートで残しておくことにした。

雑な表

設定側の実装

Slack の Outgoing Webhook Integration にフックしてスプレッドシートに対応関係を書き込むことにする。

@manager <cmd>: <issue-num> というフォーマットでメッセージ送られてくると想定している。
コマンド (<cmd>)には、チャンネルと Issue の対応関係をセットする set-issue と、対応関係をアンセットする unset-issue がある。
また set-issue では、指定された Issue の番号 (<issue-num>) が本当に存在するかや、既にセット済みかを確認している(existRow)。

function doPost(e) {
var prop = PropertiesService.getScriptProperties().getProperties();

/* Spread Sheet の読み取り*/
/* 割愛 */

/* Slack の準備*/
/* 割愛 */

/* メッセージによって分岐 */
var message = e.parameter.text.split(' ');
var channelName = e.parameter.channel_name;

if (message[0] != ('@' + BOT_NAME)) {
throw new Error('invalid bot name.');
}

var _ = Underscore.load();
var subcmd = message[1];
var text = '';
switch(subcmd) {
case 'set-issue:':
var number = message[2];
var issue = getIssue(number, prop);
if (issue == 'error') {
text = 'issuer #' + number + ' is not exist.';
break;
}
if (existRow(table, channelName, number)) {
text = 'issue <' + issue['html_url'] + '| #' + number + '> has already been set for #' + channelName;
} else {
text = 'OK! set issue.';
var repo = prop.GITHUB_OWNER + '/' + prop.GITHUB_REPO;
option = _.extend(option, makeMessage(issue, repo, prop));
sheet.getRange(rowNum + 1, 1).setValue(channelName);
sheet.getRange(rowNum + 1, 2).setValue(number);
}
break;
case 'unset-issue:':
var number = message[2];
text = 'not set yet: ' + channelName + ' - ' + number;
for(var i = 0; i < table.length; i++) {
if (table[i][0] == channelName && table[i][1] == number) {
sheet.getRange(i + 1, 1).setValue('');
sheet.getRange(i + 1, 2).setValue('');
text = 'OK! unset issue.';
}
}
break;
default:
text = 'undefined cmd: ' + subcmd;
break;
}
slackApp.postMessage(channelName, text, option);
}

function existRow(table, channelName, number) {
for (var i = 0; i < table.length; i++) {
if (table[i][1] == number && table[i][0] == channelName)
return true;
}
return false;
}

指定された Issue の番号が本当に存在するかを確認するために、GitHubAPI をたたいて、リポジトリ内の Issue を全て取得し、愚直に線形探索している。
見つかれば、その Issue をそのまま返し、無い場合は "error" という文字列を返している。

function getIssue(number, prop) {
var github = GitHubAPI.create(prop.GITHUB_OWNER, prop.GITHUB_REPO, prop.GITHUB_API_TOKEN);
var issues = github.get('/issues?state=all');
for (var i = 0; i < issues.length; i++) {
if (issues[i]['number'] == number)
return issues[i];
}
return 'error';
}

こいつも attachments でいい感じのメッセージにしている。

function makeMessage(issue, repo, prop) {
var user = '<' + issue['user']['html_url'] + '|' + issue['user']['login'] + '>';
var pretext = '[' + repo + '] Issue created by ' + user;
var title = '#' + issue['number'] + ' ' + issue['title'];
var title_link = issue['html_url'];
return {
'attachments': JSON.stringify([{
'pretext': pretext,
'title': title,
'title_link': title_link,
'color': prop.COLOR,
'text': issue['body']
}])
};
}

設定側の実行

いい感じ

通知側の実装

GitHub リポジトリに Issue のコメントだけに Webhook されるようにする。
そしたら POST データを解析し、スプレッドシートの中から2列目が等しい行の1列目だけ取ってきて(高階関数最高)、POST データからいい感じのメッセージ生成して、Slack Bot に通知している。

function doPost(e) {
var jsonString = e.postData.getDataAsString();
var jsonData = JSON.parse(jsonString);
postMessage(jsonData);
}

function postMessage(data) {
var prop = PropertiesService.getScriptProperties().getProperties();
const repo = prop.GITHUB_OWNER + '/' + prop.GITHUB_REPO;

/* いい感じにフィルタリング */
if (data['repository']['full_name'] != repo && data['comment'] != undefined && data['issue'] != undefined) {
throw new Error('invalid repository.');
}

/* Spread Sheet の読み取り*/
/* 割愛 */

 /* 表からフックされた Issue の番号を線形探索 */
var number = data['issue']['number'];
var channels = table.filter(function(row){
return row[1] == number;
}).map(function(row){ return row[0] });

/* いい感じのメッセージを作成 */
var message = makeMessage(data['action'], data['comment'], data['issue'], repo, prop);

/* Slack の準備*/
/* 割愛 */

var _ = Underscore.load();
channels.forEach(function(channelName){
option = _.extend(option, message);
slackApp.postMessage(channelName, '', option);
})
}

メッセージはコメントの作成・編集・削除ごとに異なる。
ただ、編集時には編集前のコメントしか手に入らないので、GitHub API をたたいて編集後のコメントを取りに行ってる(getIssueCommentBody)。

function makeMessage(action, comment, issue, repo, prop) {
var user = '<' + comment['user']['html_url'] + '|' + comment['user']['login'] + '>';
var issueTitle = '<' + comment['html_url'] + '|' + '#' + issue['number'] + ': ' + issue['title'] + '>';

var actionText = '';
var text = '';
switch(action) {
case 'created':
actionText = 'New';
text = comment['body'];
break;
case 'edited':
actionText = 'Edit';
text = getIssueCommentBody(comment['id'], prop);
break;
case 'deleted':
actionText = 'Delet';
text = comment['body'];
break;
}

var pretext = '[' + repo + '] ' + actionText + ' comment by ' + user + ' on isuue ' + issueTitle;
return {
'attachments': JSON.stringify([{
'pretext': pretext,
'color': prop.COLOR,
'text': text
}])
};
}

function getIssueCommentBody(id, prop) {
var github = GitHubAPI.create(prop.GITHUB_OWNER, prop.GITHUB_REPO, prop.GITHUB_API_TOKEN);
var comment = github.get('/issues/comments/' + id);
return comment['body'];
}

通知側の実行

いい感じ

おしまい

これで部内の問題・情報管理がさらに円滑になるはず!(願望)

Web
GitHub, Slack, Bot, Google Apps Script
2017-05-11

IGGG の GitHub Pages の開発環境の Dockerイメージを作る

IGGG 名古屋支部のひげです。

久々に更新。

Docker がマイブームだったので、この iggg.github.io の開発環境も Docker 化しようという話です。

基本的に話は簡単。
Windows上でやるせいで悪戦苦闘したという感じです。

開発環境

ワタシのパソコンはこんな感じ

  • Windows10 Home
  • Docker ToolBox
  • Docker version 17.03.1-ce
  • VirtualBox 5.1.18

Windows10 Home なので Docker for Windows は使えず、仕方がないので VirtualBox を利用する Docker ToolBox を使ってる。

Dockerfile

いろいろ参考にしつつ、試した結果、これだけで良い。

FROM node:6
RUN npm install -g hexo-cli

このサイトは Hexo を使っているので、Node.js が必要だ。
なので、ベースは公式の node Docker イメージを使った。

これに,hexo-cli をインストールした。
-g はグローバル環境にインストールするというオプション。

あとは、

$ cd C:\Users\hoge\git\iggg.github.io
$ docker build -t iggg-ghio .
$ docker run -it -v /c/Users/hoge/git/iggg.github.io:/app -p 4000:4000 iggg-ghio /bin/bash

などして bash を実行する。
-v オプションで、現在のディレクトリを /app にマウントしている。

そして、Dockerコンテナ内で

$ npm install --no-bin-links
$ hexo clean
$ hexo server

を実行する。
ワークディレクトリにマウントするため、ビルド時に npm install をするわけにはいかない。
そのため、ビルド後のコンテナ内で npm install をしている。
また、--no-bin-links を指定しないと、Windows ではうまくいかない。

これで、VirtualBox で指定したIPアドレス(ToolBoxを使ってなければ localhost)の4000ポートにアクセスすれば見れるはずだ。

docker-compose

docker build してからの操作が多いので docker-compose にまとめてしまおう。
本来の使い方とは異なってるが、こういう用途でも十分使える。

blog:
build: .
volumes:
- .:/app
ports:
- "4000:4000"
command: ./run.sh
working_dir: /app

run.sh は

#! /bin/sh

npm install --no-bin-links
hexo clean
hexo server

Windowsだと、ここで docker-compose up しても次のようなエラーが返ってくる。

ERROR: for browser  Cannot create container for service browser: invalid bind mount spec "C:\\Users\\hoge\\git\\iggg.github.io:/app:rw": invalid volume specification: 'C:\Users\hoge\git\iggg.github.io:/app:rw'
ERROR: Encountered errors while bringing up the project.

原因はパスの指定の仕方で、相対パスで .:/app としてるとおかしくなる。
ググった結果

COMPOSE_CONVERT_WINDOWS_PATHS=1

と書いてある .env ファイルをカレントディレクトリに置くことうまく動作した。

あと、よく怒られたのが、run.sh の改行文字で、LF でないといけないのに、Windows では時折 CRLF に書き換わる(gitで落としてきたときとか)。

実行

docker-compose up して特定のIPアドレスの4000ポートを見ればうまくいく。

おしまい

思いのほか時間かかった。
やっぱ Windows での開発はなかなかきついね。

Web
Docker, Node.js
  • Android

  • Bot

  • CircleCI

  • Docker

  • GitHub

  • Google Apps Script

  • HTML

  • Heroku

  • Hugo

  • Java

  • JavaScript

  • Linux

  • Node.js

  • Python

  • Ruby

  • Scrapbox.io

  • Slack

  • Twitter

  • WordPress

  • esa

  • guntohfes

  • libnss-json

Next