~/.aws/(config|credentials)の設定情報を元にMFAを行い、一時的なセキュリティ認証情報を取得してコマンドを実行するawsdoを作った

久しぶりに使うAWSのprofileがありまして、そのprofileについての記憶が失われていた結果、コマンド実行成功までに時間を溶かしてしまいました。

というのも、私は普段使うprofileではaswrapでAssumeRole(と多要素認証)を透過的に便利に実行していた結果、IAMの認証設定については何も考えなくなっていて「とりあえず aswrap 」を実行していました。

そして、

$ AWS_PROFILE=myaws aswrap aws s3 ls

An error occurred (AccessDenied) when calling the ListBuckets operation: Access Denied

「あれ?認証情報無効にしたっけな 🤔」とか、トンチンカンな推測をしていました。

よくよく確認してみたらそのprofileは「MFAは必須だがAssumeRole用ではない」profileで、aws sts assume-role ではなく、 aws sts get-session-token でMFAを通す(多要素認証をする)べきものでした。

「じゃあ aswrap と同じ使い勝手で aws sts get-session-token を実行するラッパーコマンドを作ればいい」と思い立ち、書いているうちに最終的には~/.aws/(config|credentials)の設定情報を元に aws sts assume-roleaws sts get-session-token を使い分けて一時的なセキュリティ認証情報を取得してコマンドを実行するラッパーコマンドになりました。

github.com

インストール

macOSであればHomebrewでインストール可能です。

$  brew install k1LoW/tap/awsdo

その他のインストール方法はREADMEに記載しています。

使い方

使い方はaswrapとほぼ同じで、一時的なセキュリティ認証情報を使って実行したいコマンドを awsdo の引数に指定するだけです。

AWS_PROFILE=myaws awsdo -- terraform apply
Enter MFA code for arn:aws:iam::111111111111:mfa/k1low: 123456
[...]

引数に何も渡さなければ環境変数を出力します。

$ AWS_PROFILE=myaws awsdo
Enter MFA code for arn:aws:iam::111111111111:mfa/k1low: 123456
export AWS_REGION=ap-northeast-1
export AWS_ACCESS_KEY_ID=XXXXXXXXXXXXXXXX
export AWS_SECRET_ACCESS_KEY=vl/Zv5hGxdy1DPh7IfpYwP/YKU8J6645...
export AWS_SESSION_TOKEN=FwoGZXIYXdGUaFij9VStcW9fcbuKCKGAWjLxF/3hXgGSoemniFV...

awsdoの動き

awsdoは以下のように動きます。

  • まず、~/.aws/credentials~/.aws/config を読み込みます。
  • 環境変数 AWS_PROFILE--profile オプションから対象のprofileのセクションを探します。
  • 一時的なセキュリティ認証情報を取得します。対象セクションの設定によって以下のように挙動を変えます。
    1. もし対象セクションが role_arn を持っていれば、 awsdo は assume role を試みます ( sts:AssumeRole ).
      • 対象セクションに mfa_serial の記載がない場合でも、 awsdo は一度有効なMFAデバイスの有無を問い合わせます。 ( iam:ListMFADevices ).
      • 前段でMFAデバイス ( mfa_serial )を取得出来ている場合、MFAを実施します(トークンコードを要求します)
      • 一時的なセキュリティ認証情報を取得します
    2. もし対象セクションが role_arn を持っていなければ、awsdo は session token の取得を試みます ( sts:getSessionToken ).
      • 対象セクションに mfa_serial の記載がない場合でも、 awsdo は一度有効なMFAデバイスの有無を問い合わせます。 ( iam:ListMFADevices ).
      • 前段でMFAデバイス ( mfa_serial )を取得出来ている場合、MFAを実施します(トークンコードを要求します)
      • 一時的なセキュリティ認証情報を取得します
  • 一時的なセキュリティ認証情報を環境変数にセットして引数に渡されたコマンドを実行するか、環境変数をexportコマンドの形で出力します。
    • AWS_ACCESS_KEY_ID
    • AWS_SECRET_ACCESS_KEY
    • AWS_SESSION_TOKEN
    • AWS_REGION

挙動として特徴的なのは

  1. ~/.aws/credentials~/.aws/config の設定によってaws sts assume-roleaws sts get-session-token と挙動を変える
  2. 設定情報に mfa_serial がなくてもMFAデバイスを問い合わせてMFAをしようと試みる
  3. 必ず一時的なセキュリティ認証情報を取得して利用する

というところでしょうか。

1と2を実現したことで、~/.aws/(config|credentials)の設定について考えることができるだけなくなることを期待しています。

まとめ

AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY は手元にあるけどCLIでMFAを通すための設定よくわからん」「STS好き」という方は是非「とりあえず awsdo 」も試してみてください。

また、本文からもお分かりのとおり、 awsdo は aswrap の仕組みに大きな影響を受けています。aswrapの便利さを知っていたからこそawsdoの実装をしたのは間違いありません。

aswrap に限らず数々の便利OSSツール/ライブラリの発明していただいているfujiwara さんには本当に感謝しております!!

以上、久しぶりのAWS関連エントリでした。

生ログを構造化ログに変換するツールlrepに日時パース機能をつけた

k1low.hatenablog.com

の続きです。

ログからSQLiteDDLやクエリを生成できるなら日時は日時としてパースできたほうが使い勝手がいいと思い実装しました*1

github.com

名前付き正規表現でログの日時部分に名前をつけて(以下のサンプルだと time )、そのキーを --ts-key (-k) で、時刻フォーマットを --ts-format (-T) で指定することで日時をパースしてSQLiteのクエリに反映させます。

$ tail -f /var/log/access.log | lrep -t sqlite --no-m0 '^(?P<host>\S*) \S* \S* \[(?P<time>.*)\] "(?P<request>.*)" (?P<status>\S*) (?P<bytes>\S*)' -k time -T '%d/%b/%Y:%H:%M:%S %z'
CREATE TABLE IF NOT EXISTS lines (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  host TEXT,
  time TIMESTAMP,
  request TEXT,
  status TEXT,
  bytes TEXT,
  _raw TEXT,
  created TIMESTAMP NOT NULL
);
INSERT INTO lines(host, time, request, status, bytes, _raw, created) VALUES ('20.156.87.118', '2020-08-09 02:30:16', 'GET /category/electronics HTTP/1.1" 200 104 "/item/games/1927', '"Mozilla/4.0', '(com
patible;', '20.156.87.118 - - [09/Aug/2020:11:30:16 +0900] "GET /category/electronics HTTP/1.1" 200 104 "/item/games/1927" "Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 6.0; WOW64; Trident/4.0; GTB6; SL
CC1; .NET CLR 2.0.50727; Media Center PC 5.0; .NET CLR 3.5.30729; .NET CLR 3.0.30618; .NET4.0C)"', datetime('now'));

日時をパースするためのフォーマットは strptime(3)のフォーマットでもGoのフォーマットでもOKです。

あとはビルトイン正規表現を増やすだけかなあ。。。

*1:SQLiteにはデータ型としての日時型はありませんが

正規表現を使ってログをカジュアルに構造化ログ/データに変換するツールlrepを作った

tl;dr

Fluentdのregexp parser pluginCLI版っぽいツールを作りました(個人的に一番伝わりやすい言い方だと思っている)。

構造化されていないログを分析するとき面倒問題

最近のログは最初からJSONやLTSVなどの構造化ログになっていたり、Fluentdなどで構造化データに変換されたうえでRDBやElasticsearch、BigQueryなどに収集されていたりすることが多くなってきていると思います。

ログの分析をしたいとき、RDBに保存されているのであればSQLが便利です。

また、構造化ログにさえなっていればtrdsqlgo-jsqliteなど、構造化ログに対してSQLクエリを実行する手段はあります。保存先がS3なのであればS3 Selectを利用することもできます。

じゃあ、構造化されていない、いわゆる生ログの分析はどうすればいいかというと、

  1. (簡単な分析であれば)UNIXコマンドなど(awkとかsedとかsortとかwcとか)を駆使するか
  2. 分析したいことに合わせて生ログを必要最低限の構造化データに変換するか

だと思います。

世の中のログを触る人々は、どちらもやった経験がありそうな気がしてなりません。

そしてどちらもちょっと面倒です*1

なので今回、私は「2. 分析したいことに合わせて生ログを必要最低限の構造化データに変換する」を簡単にするためにlrepというツールを作りました。

github.com

lrep

lrepは、正規表現のキャプチャグループ(( ) で囲まれた部分正規表現にマッチした値を参照できる機能 )でマッチした値を構造化データのフィールドとして、ログを構造化ログ/データに変換するツールです。

例えば Common Log FormatApacheアクセスログの場合、以下のような正規表現で構造化ログに変換できます。

$ tail -F /var/log/access.log | lrep '^(\S*) \S* \S* \[(.*)\] "(.*)" (\S*) (\S*)'
{"_raw":"100.21.169.226 - - [25/Jul/2020:16:25:05 +0900] \"GET /category/electronics HTTP/1.1\" 200 114","m0":"100.21.169.226 - - [25/Jul/2020:16:25:05 +0900] \"GET /category/electronics HTTP/1.1\" 200 1\
14","m1":"100.21.169.226","m2":"25/Jul/2020:16:25:05 +0900","m3":"GET /category/electronics HTTP/1.1","m4":"200","m5":"114"}
{"_raw":"104.141.81.229 - - [25/Jul/2020:16:25:05 +0900] \"GET /item/office/1680 HTTP/1.1\" 200 49","m0":"104.141.81.229 - - [25/Jul/2020:16:25:05 +0900] \"GET /item/office/1680 HTTP/1.1\" 200 49","m1":"\
104.141.81.229","m2":"25/Jul/2020:16:25:05 +0900","m3":"GET /item/office/1680 HTTP/1.1","m4":"200","m5":"49"}
{"_raw":"132.189.225.189 - - [25/Jul/2020:16:25:05 +0900] \"GET /category/office HTTP/1.1\" 200 97","m0":"132.189.225.189 - - [25/Jul/2020:16:25:05 +0900] \"GET /category/office HTTP/1.1\" 200 97","m1":"\
132.189.225.189","m2":"25/Jul/2020:16:25:05 +0900","m3":"GET /category/office HTTP/1.1","m4":"200","m5":"97"}
[...]

部分正規表現を含めそれぞれ一致した箇所が、自動でそれぞれ以下のようなフィールド名になります。

フィールド名 説明
m0 一致全体
m1 部分一致1番目
m2 部分一致2番目
... ...
_raw (デフォルト) 元の生ログ

また、名前付きキャプチャグループも利用してフィールド名を明示的に指定することも可能ですし、利用側として意図的ではない m0 (一致全体)や _raw (元の生ログ)のフィールドを除外することもできます。

$ tail -f /var/log/access.log | lrep --no-m0 --no-raw '^(?P<host>\S*) \S* \S* \[(?P<time>.*)\] "(?P<request>.*)" (?P<status>\S*) (?P<bytes>\S*)'
{"bytes":"118","host":"100.39.167.131","request":"GET /category/toys HTTP/1.1","status":"200","time":"25/Jul/2020:17:46:26 +0900"}
{"bytes":"70","host":"36.30.101.105","request":"GET /item/electronics/1293 HTTP/1.1","status":"200","time":"25/Jul/2020:17:46:26 +0900"}
{"bytes":"123","host":"212.87.25.78","request":"GET /category/software HTTP/1.1","status":"200","time":"25/Jul/2020:17:46:26 +0900"}
{"bytes":"76","host":"84.189.195.199","request":"GET /category/office HTTP/1.1","status":"200","time":"25/Jul/2020:17:46:27 +0900"}
{"bytes":"103","host":"164.78.219.152","request":"GET /item/electronics/1175 HTTP/1.1","status":"200","time":"25/Jul/2020:17:46:28 +0900"}
[...]

サポートしているフォーマット

JSON、LTSVといった一般的な構造化ログフォーマットに加えてSQLite用のクエリがあります(これが一番作りたかった)。

-t sqlite を指定するとDDLも含めたSQLite用クエリに変換できます。

$ tail -f /var/log/access.log | lrep -t sqlite --no-m0 '^(?P<host>\S*) \S* \S* \[(?P<time>.*)\] "(?P<request>.*)" (?P<status>\S*) (?P<bytes>\S*)'
CREATE TABLE IF NOT EXISTS lines (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  host TEXT,
  time TEXT,
  request TEXT,
  status TEXT,
  bytes TEXT,
  _raw TEXT,
  created NUMERIC NOT NULL
);
INSERT INTO lines(host, time, request, status, bytes, _raw, created) VALUES ('224.51.78.136', '25/Jul/2020:17:51:24 +0900', 'GET /category/books HTTP/1.1', '200', '130', '224.51.78.136 - - [25/Jul/2020:1\
7:51:24 +0900] "GET /category/books HTTP/1.1" 200 130', datetime('now'));
INSERT INTO lines(host, time, request, status, bytes, _raw, created) VALUES ('152.114.184.75', '25/Jul/2020:17:51:25 +0900', 'GET /category/finance HTTP/1.1', '200', '56', '152.114.184.75 - - [25/Jul/202\
0:17:51:25 +0900] "GET /category/finance HTTP/1.1" 200 56', datetime('now'));
INSERT INTO lines(host, time, request, status, bytes, _raw, created) VALUES ('168.57.224.190', '25/Jul/2020:17:51:25 +0900', 'GET /category/games?from=10 HTTP/1.1', '200', '60', '168.57.224.190 - - [25/J\
ul/2020:17:51:25 +0900] "GET /category/games?from=10 HTTP/1.1" 200 60', datetime('now'));
[...]

この形式の一番良いところろはパイプでダイレクトに sqlite3 コマンドに渡してSQLiteファイルを作成できることです。

$ cat /var/log/access.log | lrep -t sqlite --common | sqlite3 lines.db
$ sqlite3 lines.db
SQLite version 3.32.3 2020-06-18 14:00:33
Enter ".help" for usage hints.
sqlite> .tables
lines
sqlite> SELECT * FROM lines LIMIT 5;
1|200.159.42.212|27/Jul/2020:06:56:18 +0900|GET /item/music/1294 HTTP/1.1|200|112|200.159.42.212 - - [27/Jul/2020:06:56:18 +0900] "GET /item/music/1294 HTTP/1.1" 200 112|2020-07-26 21:56:18
2|68.54.151.135|27/Jul/2020:06:56:18 +0900|GET /item/electronics/3894 HTTP/1.1|200|96|68.54.151.135 - - [27/Jul/2020:06:56:18 +0900] "GET /item/electronics/3894 HTTP/1.1" 200 96|2020-07-26 21:56:18
3|84.93.121.21|27/Jul/2020:06:56:18 +0900|GET /category/software HTTP/1.1|200|126|84.93.121.21 - - [27/Jul/2020:06:56:18 +0900] "GET /category/software HTTP/1.1" 200 126|2020-07-26 21:56:18
4|176.36.75.163|27/Jul/2020:06:56:18 +0900|GET /category/garden HTTP/1.1|200|113|176.36.75.163 - - [27/Jul/2020:06:56:18 +0900] "GET /category/garden HTTP/1.1" 200 113|2020-07-26 21:56:18
5|40.60.150.212|27/Jul/2020:06:56:18 +0900|GET /category/games HTTP/1.1|200|75|40.60.150.212 - - [27/Jul/2020:06:56:18 +0900] "GET /category/games HTTP/1.1" 200 75|2020-07-26 21:56:18
sqlite>

ログにSQLで分析が捗ります。

ビルトイン正規表現

上記コマンドで使った --common はビルトインの正規表現です。

ビルトインの正規表現lrep builtin で確認できます。

$ lrep builtin
common       Common Log Format
combined     Combined Log Format
postgresql   PostgreSQL log
$ lrep builtin common
NAME
       common -- Common Log Format

REGEXP
       ^(?P<host>\S*) (?P<ident>\S*) (?P<user>\S*) \[(?P<time>.*)\] "(?P<method>\S+)(?: +(?P<resource>\S*) +(?P<proto>\S*?))?" (?P<status>\S*) (?P<bytes>\S*)

SAMPLE
       152.120.218.99 - - [25/Jul/2020:12:25:54 +0900] "GET /category/books HTTP/1.1" 200 67

現在はたったの3つだけですが、MySQLのログフォーマットなどを追加するなど継続して整備したいです。

今後など

今後実装していきたいこととして

  • ビルトイン正規表現をいざという時に備えて増やしておきたい
  • 時刻表現だけ特別扱いしたい(時刻としてパースしたい)

などを考えています。

是非使ってみてください。また、(特にビルトイン正規表現の)Pull Requestお待ちしています。

ところで、lrep、どこかでみたことのあるツールな気がしているんですよね。。

*1:「じゃあ最初から構造化データにしとけ」となるのですが対象のミドルウェアにはその機能がなかったり、Fluentdで捌くにもそれはそれでコンピューティングリソースを消費するし、毎回分析するようなログじゃない場合いろいろ億劫になりますよね...。そしてまた分析が必要な場面が訪れるというループ...

プロセスやファイルのケーパビリティを確認できるツールを再発明しつつLinux capabilitiesの理解を進めている

最近社内でLinux capabilitiesの話題がでていて「そういえばちゃんと理解していないな」と思ったので、夜ちょこちょこと技術エントリとかmanとかを読んでいました。

結構な頻度で同僚のエントリがヒットするのが良いです。エントリを見つけたら「わからなければ社内でカジュアルに聞ける」という福利厚生付き。

manをPro契約しているDeepLで翻訳して一気に読んでやろうと思ったら Capability Permitted Effective などが日本語に訳されてしまってむしろ混乱してしまうという出来事もありしました。辞書設定すればいけるのかな。

プロセスやファイルのCapabilitiesを確認する方法

プロセス(スレッド)のケーパビリティを確認する方法は getpcaps コマンドを、 ファイルのケーパビリティを確認する方法は getcap コマンドを利用するのが良いでしょう。

Ubuntuならlibcap2-bin パッケージに入っているので、

$ sudo apt install libcap2-bin

で手に入れることができます。

使い方も

$ getpcaps [PID]
$ getcap [PATH]

とわかりやすいです。

capv

getpcapsgetpcap を使うのが一番良いのですが、ケーパビリティセットの表記が +ep となっていたり、ファイルに(あまり意味はないが)Effectiveのケーパビリティだけをつけると

root@cd406362eb55:/go# touch /tmp/setcap_test
root@cd406362eb55:/go# setcap all+e /tmp/setcap_test
root@cd406362eb55:/go# getcap /tmp/setcap_test
/tmp/setcap_test =
root@cd406362eb55:/go#

となっていたり*1と、cap_from_text(3)形式になっていて、私はまだ慣れません。

まだわからなかったことがあるので、理解もかねてプロセスやファイルのケーパビリティを確認できるツールを作っています。

github.com

使い方は

$ capv -p [PID]
$ capv -f [PATH]

といった感じであまり変わらないのですが、表示だけ自分が理解をしやすいように変更しています。

root@cd406362eb55:/go# capv -p 1
P(permitted)
  [CAP_CHOWN CAP_DAC_OVERRIDE CAP_FOWNER CAP_FSETID CAP_KILL CAP_SETGID CAP_SETUID CAP_SETPCAP CAP_NET_BIND_SERVICE CAP_NET_RAW CAP_SYS_CHROOT CAP_MKNOD CAP_AUDIT_WRITE CAP_SETFCAP]
P(inheritable)
  [CAP_CHOWN CAP_DAC_OVERRIDE CAP_FOWNER CAP_FSETID CAP_KILL CAP_SETGID CAP_SETUID CAP_SETPCAP CAP_NET_BIND_SERVICE CAP_NET_RAW CAP_SYS_CHROOT CAP_MKNOD CAP_AUDIT_WRITE CAP_SETFCAP]
P(effective)
  [CAP_CHOWN CAP_DAC_OVERRIDE CAP_FOWNER CAP_FSETID CAP_KILL CAP_SETGID CAP_SETUID CAP_SETPCAP CAP_NET_BIND_SERVICE CAP_NET_RAW CAP_SYS_CHROOT CAP_MKNOD CAP_AUDIT_WRITE CAP_SETFCAP]
P(bounding)
  [CAP_CHOWN CAP_DAC_OVERRIDE CAP_FOWNER CAP_FSETID CAP_KILL CAP_SETGID CAP_SETUID CAP_SETPCAP CAP_NET_BIND_SERVICE CAP_NET_RAW CAP_SYS_CHROOT CAP_MKNOD CAP_AUDIT_WRITE CAP_SETFCAP]
P(ambient)
  []

先ほどのEffectiveのケーパビリティだけたてたファイルも

root@cd406362eb55:/go# capv -f /tmp/setcap_test
F(permitted)
  []
F(inheritable)
  []
F(effective)
  1

とEffectiveがついていることがわかるようになっています(ファイルケーパビリティのEffectiveだけ0か1)。

あと、これも私が理解のために実装している機能なのですが、 -p-f の両方を指定すると、そのプロセスでファイルを実行(execve(2))したあとのスレッドのケーパビリティセットを表示できるようにしています。

root@cd406362eb55:/go# capv -p 1 -f /tmp/setcap_test
P'(permitted) = (P(inheritable) & F(inheritable)) | (F(permitted) & P(bounding)) | P'(ambient)
  []
P'(inheritable) = P(inheritable)
  [CAP_CHOWN CAP_DAC_OVERRIDE CAP_FOWNER CAP_FSETID CAP_KILL CAP_SETGID CAP_SETUID CAP_SETPCAP CAP_NET_BIND_SERVICE CAP_NET_RAW CAP_SYS_CHROOT CAP_MKNOD CAP_AUDIT_WRITE CAP_SETFCAP]
P'(effective) = F(effective) ? P'(permitted) : P'(ambient)
  []
P'(bounding) = P(bounding)
  [CAP_CHOWN CAP_DAC_OVERRIDE CAP_FOWNER CAP_FSETID CAP_KILL CAP_SETGID CAP_SETUID CAP_SETPCAP CAP_NET_BIND_SERVICE CAP_NET_RAW CAP_SYS_CHROOT CAP_MKNOD CAP_AUDIT_WRITE CAP_SETFCAP]
P'(ambient) = (file is privileged) ? 0 : P(ambient)
  []

ただ、私が理解のために実装しているだけなので正しいかどうかは全く保証できません*2

残り、securebitsとバウンディングセットについて、まだわかっていないので引き続き理解を進めようと思います。

*1:正確にはコンテナ内でのファイルケーパビリティセットなのでNamespaced file capabilitiesで説明しています。挙動が異なるのかもしれませんがまだ調べていません

*2:スレッドケーパビリティの確認にシステムコールではなくprocfsをパースしていたりなど

Elispのエラーを久しぶりにデバッグした

いつからか、goplsの起動時にいつも以下のようなエラーがでて、定義ジャンプができなくなってしまっていました。プライベートのMacBookでは発生していませんでした。

(file-error "Opening directory" "Permission denied" "//.PKInstallSandboxManager-SystemSoftware").

直近だと業務でGoをガッツリ書くことがなかったので放置していたのですが、ふと業務でGoのOSSをいじることになりイラッときたので原因を特定することに(遅い)。

まずおもむろに以下を .emacs.d の適当なところに記載して、backtraceを得られるようにします。

(setq debug-on-error t)

で、Goなファイルを開くと上述したエラーがでてbacktraceつらつらとでてきました。

/.PKInstallSandboxManager-SystemSoftware がルートにあるディレクトリということから lsp--directory-files-recursively がアヤシイとにらんで軽くコードを確認してfmfmとなったあとに検索。

で、ビンゴ

github.com

以下のコマンドで / を削除しました。

M-x lsp-workspace-folders-remove

おそらく何かの拍子に /ワークスペースだと認識されてしまったんだろうなーと推測。

終わり。

MySQL8.0.11からNO_TABLE_OPTIONSが削除されていた

上記のツイートで知りました。

NO_TABLE_OPTIONSが削除されたVerのリリースノートは以下です。

docs.oracle.com

NO_TABLE_OPTIONSとは

SHOW CREATE TABLE の出力で MySQL 固有のテーブルオプション (ENGINE など) を出力しません。このモードはポータビリティモードで mysqldump によって使用されます。 ( MySQL 5.6 リファレンスより )

SHOW CREATE TABLE はテーブル定義をDDLで出力するコマンドです。例えば SHOW CREATE TABLE により以下のようなDDLが出力されたとします。

CREATE TABLE `users` (
  `id` int NOT NULL AUTO_INCREMENT,
  `username` varchar(50) NOT NULL,
  `password` varchar(50) NOT NULL,
  `email` varchar(355) NOT NULL COMMENT 'ex. user@example.com',
  `created` timestamp NOT NULL,
  `updated` timestamp NULL DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `username` (`username`),
  UNIQUE KEY `email` (`email`)
) ENGINE=InnoDB AUTO_INCREMENT=100 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Users table'

NO_TABLE_OPTIONSの説明にある MySQL 固有のテーブルオプション というのは上記DDLENGINE=InnoDB AUTO_INCREMENT=100 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Users table' の部分だと認識しています(間違っていたらごめんなさい)。

この中で AUTO_INCREMENT=100 というオプションはAUTO INCREMENTのスタートを指定するものなのですが、この 100 という値はテーブルにレコードが追加されていく度に動的に変化する(インクリメントされていく)値になります。

上記の挙動の何が困るのかというと「動的に変化する」というところで、実テーブルのスキーマとドキュメントの差分によってCIを回したりするtblsでは「運用しているテーブルだと毎回差分が発生してしまう」という現象が発生してしまいます。

そして、その回避策として NO_TABLE_OPTIONS の利用を推奨していました。

そしてその回避策もとれない状況になったわけです。

NO_TABLE_OPTIONSがなくなった今、tblsの対応

結局tbls v1.39.0からデフォルトでTable Definitionに出力されるDDLのAUTO_INCREMENTの値だけを消すことにしました。

具体的にはv1.39.0から以下のように出力されます。

CREATE TABLE `users` (
  `id` int NOT NULL AUTO_INCREMENT,
  `username` varchar(50) NOT NULL,
  `password` varchar(50) NOT NULL,
  `email` varchar(355) NOT NULL COMMENT 'ex. user@example.com',
  `created` timestamp NOT NULL,
  `updated` timestamp NULL DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `username` (`username`),
  UNIQUE KEY `email` (`email`)
) ENGINE=InnoDB AUTO_INCREMENT=[Redacted by tbls] DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Users table'

もしAUTO_INCREMENTの値もそのまま表示したいときは、DSNのクエリストリングに ?show_auto_increment=1 を追加してください。

「デフォルトで消す」設定にした理由は「tblsがCIで利用するためのツールである」という点に重きを置いたからです。

というわけで

今後ともよろしくお願いします。

spf13/cobraでサブコマンドとして外部コマンドを実行する

tblsには、tbls-cacoo のような外部コマンドをtbls cacoo のようなサブコマンドとして実行できるような機能を実装しています。私はこれを取り敢えず”外部サブコマンド機能”と呼んでいます。

外部サブコマンド機能は、

  1. コマンド名が tbls-XXX という命名規則であること
  2. 1をtbls XXX という形であくまで tbls を実行すること

という2つの条件をみたすことで、環境変数経由でtblsで取得した情報を外部コマンドに渡しています。

これにより、外部コマンド側では少ないコードベースで機能を実現できるようになっています。

実際に、例えばtbls-cacooのコードにはデータベースに接続するような実装は含まれていませんが、以下のようなコマンドでCacooで対象のデータベースのER図を生成するためのCSVを出力できます。

$ TBLS_DSN=postgres://dbuser:dbpass@localhost:5432/dbname tbls cacoo csv --out cacoo.csv
$ ls
tbls.yml
$ tbls cacoo csv --out cacoo.csv

tblsの外部サブコマンドの仕組み

tblsの外部サブコマンドの仕組みですが、具体的には以下のエントリの 追記 に書かれている手法をほぼそのまま採用しています。

pocketberserker.hatenablog.com

追記 以外の部分もspf13/cobraの挙動の説明など参考になります)

コードはここらへんで、関係がある部分を抜き出すと以下のようになります。

// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
    Use:                "tbls",
    SilenceErrors:      true,
    SilenceUsage:       true,
    Args:               cobra.ArbitraryArgs,
    DisableFlagParsing: true,
    Run: func(cmd *cobra.Command, args []string) {
        if len(args) == 0 {
            cmd.Println(cmd.UsageString()) // argsがないのであれば通常通りUsageを表示する
            return
        }
        envs := os.Environ()
        subCommand := args[0]
        path, err := exec.LookPath(cmd.Use + "-" + subCommand) // `tbls-*` なコマンドが存在するかを確認
        if err != nil {
            if strings.HasPrefix(subCommand, "-") {
                cmd.PrintErrf("Error: unknown flag: '%s'\n", subCommand)
                cmd.HelpFunc()(cmd, args)
                return
            }
            cmd.PrintErrln(`Error: unknown command "` + subCommand + `" for "tbls"`)
            cmd.PrintErrln("Run 'tbls --help' for usage.")
            return
        }

        configPath, args := parseConfigPath(args[1:]) // argsから `-c` `--config` オプションだけ外部コマンドに渡さずにパースして抜き出す

        // configPathの処理。ここでデータソースに接続したりして必要な環境変数(envs)を組み立てている

        c := exec.Command(path, args...) // 外部コマンドの実行
        c.Env = envs
        c.Stdout = os.Stdout
        c.Stdin = os.Stdin
        c.Stderr = os.Stderr
        if err := c.Run(); err != nil {
            printError(err)
            os.Exit(1)
        }
    },
}

func Execute() {
    if err := rootCmd.Execute(); err != nil {
        printError(err)
        os.Exit(1)
    }
}

元のエントリと比べて違う点は、argsから -c --config オプションだけ限定してパースしていることくらいです。

外部サブコマンドのbash/zsh補完

spf13/cobraは、簡単なbash補完用の関数やzsh補完用の関数は自動生成してくれる機能を提供してくれています。

tblsではこれに手を加えてtbls [TAB] でサブコマンドに加えて外部サブコマンドも候補にでるようにしています。

$ tbls
build       -- tbls-build
cacoo       -- tbls-cacoo
completion  -- output shell completion code
coverage    -- measure document coverage
diff        -- diff database and document
doc         -- document a database
help        -- Help about any command
lint        -- check database document
meta        -- tbls-meta
out         -- analyzes a database and output
version     -- print tbls version

実装はzshだと、

subcommands=( ${(@f)"$(print -l ${^path}/tbls-*(-*N:t) | sort -u | sed -e 's/^tbls-\(.*\)$/\1:tbls-\1/')"} )

のようにPATHにある実行可能な tbls-XXXX といった外部コマンドを取得して(ここ)、補完候補に加えています。

そして、補完候補が入力されたときは

  case "$words[1]" in
  completion)
    _tbls_completion
    ;;
  coverage)
    _tbls_coverage
    ;;
  diff)
    _tbls_diff
    ;;
  doc)
    _tbls_doc
    ;;
  help)
    _tbls_help
    ;;
  lint)
    _tbls_lint
    ;;
  out)
    _tbls_out
    ;;
  version)
    _tbls_version
    ;;
  *)
    if type _tbls-"$words[1]" 1>/dev/null 2>/dev/null
    then
      _tbls-"$words[1]"
    fi
    ;;
  esac

のように、tblsが持っていないサブコマンド ( "$words[1]" )が選択されたときは _tbls-"$words[1]" (外部コマンド側の補完関数)の存在を確認、存在していればそちらの補完処理を渡すような実装になっています(ここ)。

実際の補完関数は tbls completion bashtbls completion zsh を実行してみてください。

上記の補完関数の実現には、syohexさんやbuty4649さんにbashzshの補完機能についての情報を提供してもらったり、syohexさんに実際に修正PRをもらったりしました。この場を借りてお礼を申し上げます。ありがとうございました!

というわけで

"外部サブコマンド" という、ニッチな機能実現のお話でした。