Skip to content

Commit 90e9db2

Browse files
committed
new post: revenge-of-hourly-antenna
1 parent 9225aed commit 90e9db2

File tree

1 file changed

+222
-0
lines changed

1 file changed

+222
-0
lines changed
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
---
2+
title: リベンジ・オブ・毎時更新 Haskell Antenna
3+
headingBackgroundImage: ../../img/background.png
4+
headingDivClass: post-heading
5+
author: Nobutada MATSUBARA
6+
postedBy: <a href="https://matsubara0507.github.io/whoami">Nobutada MATSUBARA(@matsubara0507)</a>
7+
date: January 18, 2020
8+
tags: Antenna
9+
...
10+
---
11+
12+
Haskell-jpのコンテンツの一つとして[Haskell Antenna](https://haskell.jp/antenna/)というWebページの開発・運用をしております。
13+
14+
<img src="../../img/2019/hourly-antenna/antenna-page.jpg" style="width: 100%;">
15+
16+
[2019年の今頃、これを自動毎時更新しようと Drone Cloudによる毎時更新を設定しました](https://haskell.jp/blog/posts/2019/hourly-antenna.html)
17+
18+
しかし。。。なんと3月ぐらいからこれが止まっています(どうやら、Drone Cloudのこの機能を利用してマイニングをした人がいたらしく止めてしまったようです)。
19+
現在は**僕がだいたい毎朝1回、手動でCIを回しています**。。。
20+
21+
ずっとなんとかしなきゃなぁと思い続けてはや9ヶ月。
22+
やっと重い腰をあげてなんとかしました!
23+
というよりは、なんとかする方法を思い付いたので実装してみました。
24+
25+
# どうするか?
26+
27+
[GCPにはalways freeプランというのがあり](https://cloud.google.com/free/docs/gcp-free-tier?hl=ja#always-free)、GCEインスタンスはf1-microであれば一台だけ無料です(2020/1現在)。
28+
これに、毎時実行して更新をプッシュする antenna プログラムを仕込んでおけば良いではないかということに気づきました。
29+
30+
Haskell Antenna自体はGitHub Pagesであり、HTMLなどは [haskell-jp/antenna](https://github.com/haskell-jp/antenna) という Haskell製CLIアプリケーションで生成しています。
31+
これをcronか何かで毎時実行しても良いですが
32+
33+
1. cronとDockerの組み合わせが割とめんどくさい(antenna は Docker Image として提供している)
34+
2. cronにした場合更新を GitHub にどうやってプッシュしようかなどを考えるのがめんどくさい
35+
36+
という問題があります。
37+
38+
そこで、(2) のプッシュの部分も含めて毎時実行の処理をantennaアプリケーションに閉じ込めてしまえば、`docker run` しておくだけで良いのではないか?というのを思い付きました!
39+
ということで、そういう風にantennaを改良します。
40+
41+
# 実装する
42+
43+
antennaプログラムに「gitコマンドを読んでGitHubリポジトリに更新をプッシュする機能」と「全てを毎時実行する機能」の2つを組み込む必要があります。
44+
ここで後方互換性を維持するために、これらはオプションでオンする機能にしましょう。
45+
なのでまずは、antenna CLIアプリケーションのオプションを整理するところから始めます。
46+
47+
## オプションの整理
48+
49+
改修前の antenna は特別オプションを持っていません。
50+
`getArgs` で引数(設定ファイルのパス)を受け取るだけです
51+
52+
```haskell
53+
import System.Environment (getArgs)
54+
55+
-- generate 関数が設定から HTML ファイル群を生成する IO アクション
56+
main :: IO ()
57+
main = (listToMaybe <$> getArgs) >>= \case
58+
Nothing -> error "please input config file path."
59+
Just path -> generate path =<< readConfig path
60+
```
61+
62+
これを [extensible の `GetOpt`](https://hackage.haskell.org/package/extensible-0.7/docs/Data-Extensible-GetOpt.html) を使ってオプションを貰えるように拡張します
63+
64+
```haskell
65+
-- withGetOpt' は usage を独自で扱えるように拡張した Data.Extensible.withGetOpt です
66+
-- runCmd 関数が内部で runCmd を呼び出します
67+
main :: IO ()
68+
main = withGetOpt' "[options] [input-file]" opts $ \r args usage ->
69+
if | r ^. #help -> hPutBuilder stdout (fromString usage)
70+
| r ^. #version -> hPutBuilder stdout (Version.build version)
71+
| otherwise -> runCmd r $ listToMaybe args
72+
where
73+
opts = #help @= helpOpt
74+
<: #version @= versionOpt
75+
<: #verbose @= verboseOpt
76+
<: nil
77+
78+
type Options = Record
79+
'[ "help" >: Bool
80+
, "version" >: Bool
81+
, "verbose" >: Bool
82+
]
83+
84+
helpOpt :: OptDescr' Bool
85+
helpOpt = optFlag ['h'] ["help"] "Show this help text"
86+
87+
versionOpt :: OptDescr' Bool
88+
versionOpt = optFlag [] ["version"] "Show version"
89+
90+
verboseOpt :: OptDescr' Bool
91+
verboseOpt = optFlag ['v'] ["verbose"] "Enable verbose mode: verbosity level \"debug\""
92+
```
93+
94+
差分全体はこの[PR](https://github.com/haskell-jp/antenna/pull/20)で確認することができます。
95+
興味のある人はみてみてください。
96+
ついでに `runCmd` 関数は[mix.hs](https://github.com/matsubara0507/mix.hs)を使って `RIO env ()` のボイラーテンプレートを減らしています(実はおいおい役に立ちます)。
97+
98+
## git コマンドを呼ぶ
99+
100+
Haskellアプリケーションからgitコマンドを実行するにはShellyを使うことにします。
101+
そこで、mix.hsのshellプラグインを使うことで簡単に実装することができます。
102+
まずはコミットを作る部分を実装しましょう
103+
104+
```haskell
105+
import qualified Git -- 自作Shelly製gitコマンド関数群
106+
import Mix
107+
import qualified Mix.Plugin.Shell as MixShell
108+
109+
runCmd :: Options -> Maybe FilePath -> IO ()
110+
runCmd opts (Just path) = do
111+
config <- readConfig path
112+
let plugin = hsequence
113+
$ #logger <@=> MixLogger.buildPlugin logOpts
114+
<: #config <@=> pure config
115+
<: #work <@=> pure "."
116+
<: nil
117+
Mix.run plugin $ do
118+
when (opts ^. #withCommit) $ MixShell.exec (Git.pull [])
119+
generate path
120+
when (opts ^. #withCommit) $ commitGeneratedFiles
121+
where
122+
logOpts = ...
123+
124+
commitGeneratedFiles :: RIO Env ()
125+
commitGeneratedFiles = do
126+
files <- view #files <$> asks (gitConfig . view #config)
127+
MixShell.exec $ do
128+
Git.add files
129+
changes <- Git.diffFileNames ["--staged"]
130+
when (not $ null changes) $ Git.commit ["-m", message]
131+
where
132+
message = ...
133+
```
134+
135+
全ての差分はこの[PR](https://github.com/haskell-jp/antenna/pull/21)から確認できます。
136+
PRを見ればわかりますが、`runCmd` 関数に追記したのは `when (opts ^. #withCommit)` から始まる2行です(`Options``#withCommit` を追加しています)。
137+
mix.hsのshellプラグインを使うことでShellyのログをだいたいそれっぽくrioのロガーに流してくれます。
138+
139+
次に、`git push`も実装します
140+
141+
```haskell
142+
runCmd :: Options -> Maybe FilePath -> IO ()
143+
runCmd opts (Just path) = do
144+
...
145+
Mix.run plugin $ do
146+
when (opts ^. #withCommit) $ MixShell.exec (Git.pull [])
147+
generate path
148+
when (opts ^. #withCommit) $ commitGeneratedFiles
149+
when (opts ^. #withPush) $ pushCommit
150+
151+
pushCommit :: RIO Env ()
152+
pushCommit = do
153+
branch <- view #branch <$> asks (gitConfig . view #config)
154+
MixShell.exec (Git.push ["origin", branch])
155+
```
156+
157+
前から使っている `gitConfig` は設定ファイルからgitコマンドに関する設定を取ってきています(例えば、どのファイルをコミットするかやどのブランチにプッシュするかなど)。
158+
159+
差分があった場合は`git commit`を実行し、最後に`git push`するようなオプション、`--with-commit``--with-push`を実装できました(他にも実装していますが割愛)。
160+
161+
## 毎時実行
162+
163+
メインディッシュである毎時実行です。
164+
Haskell-jp Slackで、スケジューリング実行をHaskellアプリケーション内で行うのにちょうど良いパッケージはありますか?と尋ねたところcronというパッケージを紹介してもらいました(名前がややこしい笑)。
165+
調べてみたところ、ちょうど良さそうなのでこれを使うことにします
166+
167+
```haskell
168+
import System.Cron (addJob, execSchedule)
169+
170+
main :: IO ()
171+
main = withGetOpt' "[options] [input-file]" opts $ \r args usage ->
172+
if | r ^. #help -> hPutBuilder stdout (fromString usage)
173+
| r ^. #version -> hPutBuilder stdout (Version.build version)
174+
| r ^. #hourly -> runCmd r (listToMaybe args) `withCron` "0 * * * *"
175+
| otherwise -> runCmd r (listToMaybe args)
176+
where
177+
opts = ...
178+
179+
withCron :: IO () -> Text -> IO ()
180+
withCron act t = do
181+
_ <- execSchedule $ addJob act t
182+
forever $ threadDelay maxBound -- 無限ループ
183+
```
184+
185+
全ての差分はこの[PR](https://github.com/haskell-jp/antenna/pull/22)から確認できます。
186+
すっごい簡単ですね。
187+
188+
ついでに、毎日実行と毎分実行するオプションも追加しています。
189+
190+
# インスタンスを起動する
191+
192+
まずはGCP Consoleからインスタンス作成します。
193+
構成は次の通りです
194+
195+
- f1-micro
196+
- オレゴンリージョン
197+
- 30GBの標準ストレージ
198+
- OSはUbuntu 18.04
199+
200+
GCP ConsoleからSSHして、docker コマンドをインストールします(やり方は[公式サイト](https://docs.docker.com/install/linux/docker-ce/ubuntu/)のをそのまま)。
201+
ここまでできたら試しに `sudo docker pull haskelljp/antenna` して最新のイメージを取得してみましょう。
202+
203+
次に、GitHubにプッシュするためにSSH Keyを生成してデプロイキーを haskell-jp/Antenna に設定します。
204+
できたら適当に `git clone git@github.com:haskell-jp/antenna.git` してブランチを `gh-pages` に切り替えます。
205+
206+
あとは次のコマンドでantennaプログラムを実行するだけです
207+
208+
```
209+
$ sudo docker run -d \
210+
-v `pwd`:/work
211+
-v `echo $HOME`/.ssh:/root/.ssh \
212+
haskelljp/antenna antenna --verbose --with-commit --with-push --with-copy --hourly sites.yaml
213+
```
214+
215+
`docker logs` を使って様子をみてましたが、うまくいってるようです!
216+
217+
# 今後やりたいこと
218+
219+
igrep氏が[Issue](https://github.com/haskell-jp/antenna/issues/16)にしてくれてるように、Haskell Antennaの正しい差分をHaskell-jp Slackに通知する仕組みを整備しようと考えてます。
220+
221+
実はコミットをHaskellアプリケーション内で組み立てるようになった結果、Haskellアプリケーション側でいい感じに差分を調べ上げ、その結果をコミットメッセージに組み込むことができるようになりました。
222+
さすがにHTMLやフィードの `git diff` を解析するのは大変なので、いい感じに各サイトの最終更新ログを残すようにしてみようかなって考えてます。

0 commit comments

Comments
 (0)