全力で怠けたい

怠けるために全力を尽くしたいブログ。

Go のテンプレートの使い方のメモ。

Go のテンプレートを軽く調べたのでメモ。

Go のテンプレート

とりあえず使ってみる

Go のテンプレートはこんな感じで使う。

package main

import (
    "os"
    "text/template"
)

type Fruit struct {
    Name  string
    Count int
}

func main() {
    text := `持っている果物: {{.}}`
    tmpl, err := template.New("test").Parse(text)
    if err != nil {
        panic(err)
    }

    fruit := Fruit{
        Name:  "リンゴ",
        Count: 1,
    }
    err = tmpl.Execute(os.Stdout, fruit)
    if err != nil {
        panic(err)
    }
}

// 出力
// 持っている果物: {リンゴ 1}

テンプレートはこんな感じでデータに適用することで実行する。 テンプレートはデータに適用しなくてもいいけど普通はテンプレートを使うときはなにかデータに適用すると思う。

さっきはテンプレートを Fruit 構造体に適用したけどテンプレートは構造体だけじゃなくて配列、スライス、マップ、チャネルとかにも適用できる。 たとえばスライスにはこんな感じで適用する。

package main

import (
    "os"
    "text/template"
)

func main() {
    text := `持っている果物: {{.}}`
    tmpl, err := template.New("test").Parse(text)
    if err != nil {
        panic(err)
    }

    fruits := []Fruit{
        {Name: "リンゴ", Count: 1},
        {Name: "マンゴー", Count: 2},
    }
    err = tmpl.Execute(os.Stdout, fruits)
    if err != nil {
        panic(err)
    }
}

// 出力
// 持っている果物: [{リンゴ 1} {マンゴー 2}]

さっきのコードの {{.}} はテンプレート内で使えるアクションの1つ。 さっきのコードはスライスをそのまま参照して出力したけど、アクションを使うと適用したデータの要素たとえば構造体のフィールドとかマップのキーとかを参照したりテンプレートの実行を制御できる。

さっきのコードのテンプレート内でアクションをこんな感じに使うと構造体のスライスの要素それぞれを参照して出力とかができる。

テンプレートをデータに適用するとデータが走査されていってデータの現在位置は . (ドット) で参照できる。 テンプレート内のアクションを使って条件分岐したりデータを繰り返し処理して目的の出力を得ていくので、だいたいテンプレートの使い方を学ぶ = アクションの使い方を学ぶ感じ。

text := `{{range $index, $element := .}}
{{- .Name}} は {{.Count}} 個あります。
{{end}}`

// 出力
// リンゴ は 1 個あります。
// マンゴー は 2 個あります。

Actions: アクション

アクションは適用したデータの要素たとえば構造体のフィールドとかマップのキーとかを参照したりテンプレートの実行を制御する。

アクションは {{}} で囲んで記述する。 さきほどのコードは {{range $index, $element := .}}, {{.Count}} とか {{end}} がアクション。

テンプレート内のアクションじゃないところはそのまま出力するけど、アクションの左の区切り文字 {{ の直後にマイナス記号と半角スペース (-) を記述するとアクションの直前のテキストの末尾のホワイトスペースをトリムする。同じようにアクションの右の区切り文字 }} の直前に半角スペースとマイナス記号 (-) を記述するとアクションの直後のテキストの末尾のホワイトスペースをトリムする。

text := `{{23}} < {{45}}
{{23 -}} < {{- 45}}`

// 出力
// 23 < 45
// 23<45

アクションはたくさんあるのでそれぞれ書いていく。

comment (コメント)

{{/* a comment */}} と記述したところは出力しない。 コメントは複数行にすることもできる。

text := `{{/* コメントは出力しない */}}
Hello Golang!
{{- /* コメントは
複数行もできる */}}
Hello Template!
`

// 出力
// Hello Golang!
// Hello Template!

{{ pipeline}} はパイプライン (pipeline) をデフォルトで fmt.Print で出力する。 パイプラインは結構いろいろ書けるので後で少し詳しく書く。

text := `{{.}}
{{"リテラルを出力"}}
`

// 出力
// [{リンゴ 1} {マンゴー 2}]
// リテラルを出力

{{if pipeline}} T1 {{end}} は パイプラインがゼロ値のときは何も出力しない。パイプラインがゼロ値じゃなかったら T1 を出力する。

text := `{{if ""}}出力しない{{end}}
{{if "a"}}出力する{{end}}
`

// 出力
// 出力する

{{if pipeline}} T1 {{else}} T0 {{end}} はパイプラインがゼロ値のときは T0 を出力してパイプラインがゼロ値じゃなかったら T0 を出力する。

text := `{{if ""}}出力しない{{else}}"" はゼロ値{{end}}
{{if "a"}}"a" はゼロ値じゃない{{else}}{{end}}
`

// 出力
// "" はゼロ値
// "a" はゼロ値じゃない

{{if pipeline}} T1 {{else if pipeline}} T0 {{end}}{{if}} - {{else} に別の if を入れこんだやつ。

text := `
{{if ""}}出力しない
{{else if 0}}出力しない
{{else}}"" と 0 はゼロ値
{{end}}`

// 出力
// "" と 0 はゼロ値

{{range pipeline}} T1 {{end}} はパイプラインの要素を繰り返し処理する。

パイプラインは配列、スライス、マップ、あるいはチャネルのいずれかである必要がある。 パイプラインの長さゼロがゼロのときは何も出力しない。 パイプライン要素が繰り返し処理するときドット (.) は現在処理している要素を示す。

text := `{{range .}}{{.}}, {{end}}`
_ = tmpl.Execute(os.Stdout, []int{1, 2, 3})

// 出力
// 1, 2, 3, 

{{range pipeline}} T1 {{else}} T0 {{end}}{{range}} と同じだけどパイプラインの要素がないときは T0 を出力する。

text := `{{range .}}{{.}}, {{else}}パイプラインの要素がない{{end}}`
_ = tmpl.Execute(os.Stdout, nil)

// 出力
// パイプラインの要素がない

{{template "name"}} はテンプレートを名前で指定して nil データに適用と実行する。

text := `{{define "foo"}}*** 事前に定義したテンプレート ***{{end}}
{{template "foo"}}
{{template "foo"}}`

// 出力
// *** 事前に定義したテンプレート ***
// *** 事前に定義したテンプレート ***

{{template "name" pipeline}} はテンプレートを名前で指定してドット (.) をパイプラインにセットして適用と実行する。

text := `{{define "foo"}}{{range $index, $element := .}}{{$element}}, {{end}}{{end}}
{{template "foo" .}}
{{template "foo" .}}`
_ = tmpl.Execute(os.Stdout, []int{1, 2, 3})

// 出力
// 1, 2, 3, 
// 1, 2, 3, 

{{block "name" pipeline}} T1 {{end}}{{define "name"}} T1 {{end}}{{template "name" pipeline}} を実行する短縮記法。

text := `{{block "foo" .}}*** 事前に定義したテンプレート ***{{end}}`

// 出力
// *** 事前に定義したテンプレート ***

{{with pipeline}} T1 {{end}}{{if}} とだいたい同じだけどパイプラインがドット (.) にセットして実行する。

text := `{{range $index, $element := . -}}
{{with $element}}{{.}} はゼロ値じゃない{{end}}
{{end}}`
_ = tmpl.Execute(os.Stdout, []int{0, 1, 0, 2})

// 出力
// 1 はゼロ値じゃない
// 2 はゼロ値じゃない

{{with pipeline}} T1 {{else}} T0 {{end}}{{with pipeline}} T1 {{end}} と同じだけどパイプラインがゼロ値のときは T0 を出力する。

text := `{{range $index, $element := . -}}
{{with $element}}{{.}} はゼロ値じゃない{{else}}ゼロ値{{end}}
{{end}}`
_ = tmpl.Execute(os.Stdout, []int{0, 1, 0, 2})

// 出力
// ゼロ値
// 1 はゼロ値じゃない
// ゼロ値
// 2 はゼロ値じゃない

Arguments: 引数

Arguments は単なる値でいろいろ入れられる。

定数は untyped constant になって nil は untyped nil になる。

text := `
{{print true}}
{{print "文字列"}}
{{print 1}}
{{print 1.5}}
{{print .}}
{{print nil}}`

_ = tmpl.Execute(os.Stdout, []int{0, 1, 0, 2})

// 出力
// true
// 文字列
// 1
// 1.5
// [0 1 0 2]
// <nil>

ドット (.) はその時のドットの値。

text := `{{.}}`
_ = tmpl.Execute(os.Stdout, []int{0, 1, 0, 2})

// 出力
// [0 1 0 2]

$ で始まる変数名。 変数は $ という名前もつけられる。

text := `{{$name := "Alice"}}
{{$ := "Bob"}}
Hello {{$name}}!
Hello {{$}}!`

// 出力
// Hello Alice!
// Hello Bob!

構造体のフィールド。

.Field の形式で記述する。 .Field1.Field2 みたいにネストして参照できて $x.Field1.Field2 みたいに変数のフィールドも参照できる。

text := `{{.Name}} は {{.Count}} 個あります。`
fruit := Fruit{Name: "リンゴ", Count: 1}
_ = tmpl.Execute(os.Stdout, fruit)

// 出力
// リンゴ は 1 個あります。

マップのキー。

.Key の形式で記述する。 .Field1.Key1.Field2.Key2 みたいにネストして参照できて $x.key1.key2 みたいに変数のキーの値も参照できる。

text := `リンゴは {{.リンゴ}} 個あります。
マンゴーは {{.マンゴー}} 個あります。`

m := map[string]int{
    "リンゴ":  1,
    "マンゴー": 3,
}
_ = tmpl.Execute(os.Stdout, m)

// 出力
// リンゴは 1 個あります。
// マンゴーは 3 個あります。

引数のないメソッド。

メソッドは1つか2つの戻り値を返す。 .Field1.Key1.Method1.Field2.Key2.Method2 みたいにネストしてメソッドを呼び出せる。 2つの戻り値を返すときは2つ目の戻り値は error で、2つめの戻り値が nil 以外のときはテンプレートの実行が止まってその erorr が Execute() の戻り値になる。

type Fruit struct {
    Name  string
    Count int
}

func (p Fruit) String() (string, error) {
    s := fmt.Sprintf("%s は %d 個あります。", p.Name, p.Count)
    return s, nil
}

text := `{{.String}}`
fruit := Fruit{Name: "リンゴ", Count: 1}
_ = tmpl.Execute(os.Stdout, fruit)

// 出力
// リンゴ は 1 個あります。

引数のない関数。

引数のない関数の戻り値に関しての振る舞いは引数のないメソッドの戻り値と同じ。

text := `{{print "Hello Template!"}}`

// 出力
// Hello Template!

カッコ ((, )) でグループ化ができる。

{{print or "" "a" "b"}} テンプレートはエラーになるけど以下のように or をカッコで囲ってグループ化するとちゃんと実行する。

text := `{{print (or "" "a" "b")}}`

// 出力
// a

Pipeline: パイプライン

パイプラインはコマンドを パイプ (|) で繋げたもの。

コマンドは単なる値、メソッドあるいは関数で、コマンドは1つまたは2つの値を出力する。 コマンドが2つめの値を出力するとき2つめの出力は erorr で、コマンドの2つめ出力が nil 以外のときはテンプレートの実行が止まってその erorr が Execute() の戻り値になる。

text := `{{"a" | printf "%s"}}
{{"a" | printf "%s" | printf "%q"}}`

// 出力
// a
// "a"

Variables: 変数

変数は {{$variable := pipeline}} の記述で宣言と初期化をする。

宣言済みの変数は {{$variable = pipeline}} の記述で値を割り当てられる。 変数のスコープは変数が {{if}}, {{with}} あるいは {{range}} で宣言すると対応する {{end}} までが変数のスコープになる。

text := `{{$val := "a"}}{{$val}}
{{$val = "b"}}{{$val}}`

// 出力
// a
// b

{{range $index, $element := pipeline}} の記述で pipeline がスライスのときはスライスのインデックスが1つめの変数 $index にセットしてスライスの要素が2つめの変数 $element にセットする。 {{range $index, $element := pipeline}} の記述で pipeline がマッのときはマップのキーが1つめの変数 $index にセットしてマップの要素が2つめの変数 $element にセットする。 このへんの変数名は $index とか $element じゃなくてもいい。

{{range $element := pipeline}} みたいに変数が1つのときは pipeline がスライスのときはスライスの要素が1つめの変数 $element にセットして、pipeline がマップのときはマップの要素が1つめの変数 $element にセットする。このへんの振る舞いは通常の for 文の range の振る舞いと逆なので注意。

Functions: 関数

関数は定義済みの関数とユーザー定義の関数がある。 定義済みの関数をそれぞれ軽く書いて、あとユーザー定義の関数の定義と使い方を書いていく。

定義済みの関数

and は引数のうち最初に見つかったゼロ値を返す。

ゼロ値の引数がないときは最期の引数を返す。

text := `and "a" true 0 1 => {{and "a" true 0 1}}
and "a" true 1 => {{and "a" true 1}}`

// 出力
// and "a" true 0 1 => 0
// and "a" true 1 => 1

call は最初の引数に残りの引数を渡して実行した結果を返す。

最初の引数は1つまたは2つの戻り値を返す関数であること。 関数が返す2つめの戻り値は erorr で、関数の2つめの戻り値が nil 以外のときはテンプレートの実行が止まる。 あと関数の実引数が仮引数と一致しないときもテンプレートの実行が止まる。

text := `{{call .add 1 2}}`

data := map[string]interface{}{
    "add": func(a, b int) int { return a + b },
}
_ = tmpl.Execute(os.Stdout, data)

// 出力
// 3

html は引数を HTML としてエスケープした結果を返す。

text := `{{html "<html>"}}`

// 出力
// &lt;html&gt;

index は最初の引数に残りの引数をインデックスとして適用した結果を返す。

最初の引数は配列、スライスまたはマップである必要がある。

text := `{{index . 2}}`
_ = tmpl.Execute(os.Stdout, []string{"a", "b", "c"})

// 出力
// c

slice は最初の引数を残りの引数でスライスした結果を返す。

最初の引数は配列、スライスまたは文字列である必要がある。

text := `{{slice . 1 3}}`
_ = tmpl.Execute(os.Stdout, []string{"a", "b", "c", "d"})

js は引数を JavaScript としてエスケープした結果を返す。

text := `{{js "<script>alert('hello');</script>"}}`

// 出力
// \x3Cscript\x3Ealert(\'hello\');\x3C/script\x3E

len は引数の長さを返す。

text := `{{len .}}`
_ = tmpl.Execute(os.Stdout, []string{"a", "b", "c", "d"})

// 出力
// 4

not は引数のブール値の否定を返す。

text := `{{not true}}`

// 出力
// false

or は引数のうち最初に見つかった非ゼロ値を返す。

非ゼロ値の引数がないときは最期の引数を返す。

text := `or "" false 1 0 => {{or "" false 1 0}}
or "" false 0 => {{or "" false 0}}`

// 出力
// or "" false 1 0 => 1
// or "" false 0 => 0

print, printlnprintf はそれぞれ fmt.Sprint, fmt.Sprintlnfmt.Sprintfエイリアス

text := `{{print "Hello Template"}}!
{{println "Hello Template"}}!
{{printf "Hello %s!" "Template" }}`

// 出力
// Hello Template!
// Hello Template
// !
// Hello Template!

urlquery は引数をクエリストリングとしてエスケープした結果を返す。

text := `{{urlquery "https://golang.org/?lang=ja"}}`

// 出力
// https%3A%2F%2Fgolang.org%2F%3Flang%3Dja
text := `eq true true => {{eq true true}}
ne true true => {{ne true true}}
lt 1 1 => {{lt 1 1}}
lt 0 1 => {{lt 0 1}}
le 1 1 => {{le 1 1}}
le 0 1 => {{le 0 1}}
gt 1 1 => {{gt 1 1}}
gt 2 1 => {{gt 2 1}}
ge 1 1 => {{ge 1 1}}
ge 2 1 => {{ge 2 1}}`

// 出力
// eq true true => true
// ne true true => false
// lt 1 1 => false
// lt 0 1 => true
// le 1 1 => true
// le 0 1 => true
// gt 1 1 => false
// gt 2 1 => true
// ge 1 1 => true
// ge 2 1 => true

ユーザー定義の関数

ユーザー定義の関数は template.FuncMaptemplate.Funcs() に渡すと使えるようになる。 template.Funcs(funcMap FuncMap)template.Parse() よりも前に呼び出す必要がある。

funcs := map[string]interface{}{
    "sum": func(numbers []int) int {
        total := 0
        for _, v := range numbers {
            total += v
        }
        return total
    },
}
text := `{{sum .}}`
tmpl, err := template.New("test").Funcs(funcs).Parse(text)
_ = tmpl.Execute(os.Stdout, []int{1, 2, 3, 4, 5})

// 出力
// 15

ファイルに保存しているテンプレートを読む

ここまではテンプレートはプログラムの中にハードコードしてたけど実際にテンプレートを使うときはテンプレートはファイルに保存しておいてプログラムからテンプレートを読み込んで使うことが多そう。 ファイルに保存しているテンプレートは template.ParseFiles() とか ParseGlob() で読み込んで使っていく。

tmpl, err := template.ParseFiles("header.template", "main.template", "footer.template")
if err != nil {
    panic(err)
}
err = tmpl.ExecuteTemplate(os.Stdout, "main", nil)
if err != nil {
    panic(err)
}

// 出力
// *** ヘッダー ***
// *** メイン ***
// *** フッター ***

テンプレートを保存してるファイルはこんな感じ。

// header.template
{{define "header"}}*** ヘッダー ***{{end}}

// main.template
{{define "main"}}
{{template "header"}}
*** メイン ***
{{template "footer"}}
{{end}}

// footer.template
{{define "footer"}}*** フッター ***{{end}}

text/template と html/template

text/templatehtml/template は同じインタフェースだけど HTML を出力するときはセキュリティ上の問題から html/template を使うように書いてある。

To generate HTML output, see package html/template, which has the same interface as this package but automatically secures HTML output against certain attacks.

参考ページ

CloudFormation のスタックテンプレートから Secrets Manager のシークレットを動的に参照するメモ。

CloudFormation のスタックテンプレートから Secrets Manager のシークレットを動的に参照するメモ。

CloudFormation のスタックテンプレートから Secrets Manager のシークレットを動的に参照する

CloudFormation のスタックテンプレートからスタックを作っていると必ず一度は秘密情報の扱いをどうするか? みたいな事態に直面する。 ここでは秘密情報というのは DB のパスワードとか Web サービスのAPI キーみたいな「他人に漏れると金銭的損害を始めとしていろんな損害しかも大きな損害を被りそうなすごく秘匿性の高い情報」とかの意味で使うけど、普通に考えると DB のパスワードとか Web サービスの API キーとかを CloudFormation のスタックテンプレートの中に埋め込むのは明らかにまずいと思う。

そういう秘密情報を CloudFormation のスタックテンプレートに使うときは AWS Systems Manager の SecureString パラメータを使う方法と AWS Secrets Manager のシークレットを使う方法があるけどほとんどのケースは AWS Secrets Manager のシークレットを使う方法で事足りるので大概は AWS Secrets Manager のほうを使ってる。 => 一応、秘密情報はパラメータで渡すこともできるけどスタックを作るたびに指定するのは面倒くさい

そのへんのことを軽くメモしていく。

Secrets Manager のシークレットの登録

CloudFormation スタックテンプレートから参照するシークレットは事前に Secrets Manager に登録しておく。

$ aws secretsmanager create-secret --name=db-root-account --secret-string='{"username":"this-is-username","password":"this-is-password"}'
{
    "VersionId": "eaaac565-8481-4e22-8ae1-2e1ba125a233",
    "Name": "db-root-account",
    "ARN": "<ARN>"
}

このあと CloudFormation スタックテンプレートからバージョンを指定してシークレットを取得していくのでシークレットを更新しておく => VersionId のとこがシークレットを登録したときから変わる

$ aws secretsmanager update-secret --secret-id=db-root-account --secret-string='{"username":"this-is-new-username","password":"this-is-new-password"}'
{
    "VersionId": "5606eb1c-2892-4721-9936-324527c366b1",
    "Name": "db-root-account",
    "ARN": "<ARN>"
}

シークレットのバージョンを指定しないでシークレットを取得すると VersionStagesAWSCURRENT のシークレットを取得する。 VersionId のとこがシークレットを更新したときの出力が一致している。

$ aws secretsmanager get-secret-value --secret-id=db-root-account --version-id=5606eb1c-2892-4721-9936-324527c366b1
{
    "Name": "db-root-account",
    "VersionId": "5606eb1c-2892-4721-9936-324527c366b1",
    "SecretString": "{\"username\":\"this-is-new-username\",\"password\":\"this-is-new-password\"}",
    "VersionStages": [
        "AWSCURRENT"
    ],
    "CreatedDate": 1593245998.012,
    "ARN": "<ARN>"
}

シークレットを更新したときの VersionId を指定してシークレットを取得するとさきほどと同じシークレットを取得する。

$ aws secretsmanager get-secret-value --secret-id=db-root-account --version-id=5606eb1c-2892-4721-9936-324527c366b1
{
    "Name": "db-root-account",
    "VersionId": "5606eb1c-2892-4721-9936-324527c366b1",
    "SecretString": "{\"username\":\"this-is-new-username\",\"password\":\"this-is-new-password\"}",
    "VersionStages": [
        "AWSCURRENT"
    ],
    "CreatedDate": 1593245998.012,
    "ARN": "<ARN>"
}

シークレットを最初に登録したときの VersionId を指定してシークレットを取得すると最初に登録したシークレットを取得する。

$ aws secretsmanager get-secret-value --secret-id=db-root-account --version-id=eaaac565-8481-4e22-8ae1-2e1ba125a233
{
    "Name": "db-root-account",
    "VersionId": "eaaac565-8481-4e22-8ae1-2e1ba125a233",
    "SecretString": "{\"username\":\"this-is-username\",\"password\":\"this-is-password\"}",
    "VersionStages": [
        "AWSPREVIOUS"
    ],
    "CreatedDate": 1593245628.992,
    "ARN": "<ARN>"
}

CloudFormation のスタックテンプレートからシークレットを参照

CloudFormation スタックテンプレートから Secrets Manager に登録しているシークレットを動的に参照していく。作成するリソースはなんでもいいんだけどすぐに作れてお金がかからないセキュリティグループを作成していく。

まずバージョンは指定しないでシークレットを参照していく。 こんな感じのスタックテンプレートを書いて CloudFormation スタックを作成していく。

AWSTemplateFormatVersion: "2010-09-09"
Resources:
  HogeSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: dynamic refernces
      GroupName: hoge-security-group
      SecurityGroupIngress:
        - CidrIp: 0.0.0.0/0
          IpProtocol: tcp
          FromPort: 80
          ToPort: 80
          Description: '{{resolve:secretsmanager:db-root-account:SecretString:username::}}'
        - CidrIp: 0.0.0.0/0
          IpProtocol: tcp
          FromPort: 443
          ToPort: 443
          Description: '{{resolve:secretsmanager:db-root-account:SecretString:password::}}'
      VpcId: <VPC ID>

CLI でセキュリティグループの定義を見てみると更新したあとのシークレットが取得しているのが分かる。

$ aws ec2 describe-security-groups --group-names=hoge-security-group | jq '.SecurityGroups[].IpPermissions[].IpRanges[].Description'
"this-is-new-username"
"this-is-new-password"

次はシークレットのバージョンを指定してシークレットを参照していく。 シークレットを最初に登録したときの VersionIdeaaac565-8481-4e22-8ae1-2e1ba125a233 を Description のとこに追加して CloudFormation スタックを作成していく。

AWSTemplateFormatVersion: "2010-09-09"
Resources:
  HogeSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: dynamic refernces
      GroupName: hoge-security-group
      SecurityGroupIngress:
        - CidrIp: 0.0.0.0/0
          IpProtocol: tcp
          FromPort: 80
          ToPort: 80
          Description: '{{resolve:secretsmanager:db-root-account:SecretString:username::eaaac565-8481-4e22-8ae1-2e1ba125a233}}'
        - CidrIp: 0.0.0.0/0
          IpProtocol: tcp
          FromPort: 443
          ToPort: 443
          Description: '{{resolve:secretsmanager:db-root-account:SecretString:password::eaaac565-8481-4e22-8ae1-2e1ba125a233}}'
      VpcId: <VPC ID>

CLI でセキュリティグループの定義を見てみると最初に登録したシークレットが取得しているのが分かる。

$ aws ec2 describe-security-groups --group-names=hoge-security-group | jq '.SecurityGroups[].IpPermissions[].IpRanges[].Description'
"this-is-username"
"this-is-password"

CloudFormation スタックテンプレートからはこんな感じで Secrets Manager に登録しているシークレットを取得できる。

気にしておきたいこと

シークレットのリファレンスパターン

CloudFormation スタックテンプレートから Secrets Manager に登録しているシークレットを参照するときは {{resolve:secretsmanager:secret-id:secret-string:json-key:version-stage:version-id}} の形式で参照していく。 resolve:secretsmanager の部分は固定で、それ以外の部分はそれぞれの意味と使い方を書いていく。

secret-id

secret-id のとこは Secrets Manager に登録しているシークレットの ID を指定する。シークレットの完全な ARN を指定して別の AWS アカウントのシークレットを参照することもできる。

さきほどの例は db-root-account を指定した。

secret-string

SecretString 固定。

json-key

値を取得するシークレットのキー名を指定する。 CloudFormation は json-key を省略するとシークレットテキスト全体を取得する。

さきほどの例は usernamepassword を指定してシークレットのユーザー名とパスワードがそれぞれセキュリティグループの ingress ルールの Description に埋め込んだやつ。 もしさきほどの例で json-key を省略すると {"username":"this-is-new-username","password":"this-is-new-password"} がセキュリティグループの ingress ルールの Description に埋め込む感じ。

version-stage

version-stage を指定して取得するシークレットのバージョンを指定できる。 version-stage は AWSCURRENT とか AWSPREVIOUS とかがある。

version-stage と verson-id はどちらか1つしか指定できないので version-stage を指定するときは version-id は指定できず、version-stage も version-id も指定しないときは AWSCURRENT の version-stage を指定したのと同じになる。

さきほどの1つめの CloudFormation スタックテンプレートは version-stage も version-id も指定しないので AWSCURRENT の version-stage を指定したのと同じつまり更新後のシークレット this-is-new-usernamethis-is-new-password を取得している。

version-id

version-id を指定して取得するシークレットのバージョンを指定できる。 version-stage と verson-id はどちらか1つしか指定できないので version-stage を指定するときは version-id は指定できず、version-stage も version-id も指定しないときは AWSCURRENT の version-stage を指定したのと同じになる。

さきほどの2つめの CloudFormation スタックテンプレートは最初にシークレットを登録したときの version-id を指定しているので最初に登録したときのシークレット this-is-usernamethis-is-password を取得している。

必要なアクセス許可

Secrets Manager に格納されているシークレットを取得するには取得するシークレットに対して GetSecretValue を呼び出すためのアクセス権を持っている必要がある。

動的参照の制限

CloudFormation テンプレートスタックから Secrets Manager に登録しているシークレットを取得するとき固有の制限ではなくて動的参照に共通している制限だけどいくつか制限があるので書いておく。

  • スタックテンプレートは動的参照を最大で60個までしか含めない
  • AWS::Include とか AWS::Serverless などのトランスフォームの場合、AWS CloudFormation はトランスフォームを呼び出す前には動的な参照を解決せず、動的参照のリテラル文字列をトランスフォームに渡す
  • カスタムリソースは Secrets Manager に登録しているシークレットは動的参照できない
  • CloudFormation は最終値としてバックスラッシュを含む動的な参照は解決できない

参考ページ

CDK で EC2 のユーザーデータを構築する方法。

EC2 はインスタンスを起動するときにユーザーデータをインスタンスに渡してツールをセットアップしたりスクリプトを実行できるけど、CDK で EC2 のユーザーデータを構築する方法をメモしておく。

CDK で EC2 のユーザーデータを構築する

CDK で EC2 のユーザーデータを構築する方法はいくつかあるのでそれぞれを書いていく。

CDK で EC2 のユーザーデータを構築する方法はいくつかあるけど、先に結論を書いちゃうと今のとこは UserData.addCommands() と fs モジュールを組み合わせる方法がベストプラクティスのような気がしてる。

UserData.addCommands()

ユーザーデータを UserData.addCommands() で追加していく。 UserData.addCommands()で追加したユーザーデータは EC2 インスタンス/var/lib/cloud/instances/<instance-id>/user-data.txt にコピーされて EC2 インスタンスの起動時に実行される。

この方法は簡単だけどユーザーデータでやることが増えていくと UserData.addCommands() のとこをたくさん書くことになるのが少し面倒くさい。

import * as cdk from '@aws-cdk/core'
import * as ec2 from '@aws-cdk/aws-ec2'

const userData = ec2.UserData.forLinux({shebang: '#!/bin/bash'})
userData.addCommands(
  'echo userData doing!',
  'echo userData done!',
)

Asset construct

aws-s3-assets の Asset construct を使うとローカルのファイルをそのままユーザーデータとして構築して EC2 インスタンスの起動時に実行する。

import * as cdk from '@aws-cdk/core'
import * as ec2 from '@aws-cdk/aws-ec2'
import {Asset} from '@aws-cdk/aws-s3-assets'
import * as path from 'path'

// ユーザーデータを S3 にアップロードする
const asset = new Asset(this, 'userdata-asset', {
  path: path.join(__dirname, 'assets', 'userdata.sh')
})

// なんか EC2 インスタンスを作る
const instance = new ec2.Instance(this, 'ec2', {
  // 省略
})

// ユーザーデータを S3 からダウンロードする
asset.grantRead(instance.role)
const localPath = instance.userData.addS3DownloadCommand({
  bucket: asset.bucket,
  bucketKey: asset.s3ObjectKey,
})

// S3 からダウンロードしたユーザーデータを実行する
instance.userData.addExecuteFileCommand({
  filePath: localPath,
})

CDK アプリはこんな感じのディレクトリ構成を想定してる。

lib/userdata-stack.ts がスタック定義で lib/assets/userdata.sh がユーザーデータ。

├── bin
│   └── practice_of_cdk.ts
├── cdk.json
├── cdk.out
├── lib
│   ├── assets
│   │   └── userdata.sh
│   └── userdata-stack.ts
│
(省略)

この方法はユーザーデータのシェルスクリプトを普通にエディターとかでいじれるのが楽。

#!/bin/bash
echo userData doing!
echo userData end!

ただ、この方法は aws-s3-assets が CDK 1.46.0 時点では experimental であるため将来的に破壊的な変更が入る可能性がある。

UserData.addCommands() + fs モジュール

今のところは UserData.addCommands() と fs モジュールを組み合わせる方法がベストプラクティスのような気がしてる。

この方法はユーザーデータのシェルスクリプトを普通にエディターとかでいじれるのが楽なのと experimental な API を使ってないので CDK のバージョンを上げるときに破壊的な変更が入ることを気にしなくていい。

import * as cdk from '@aws-cdk/core'
import * as ec2 from '@aws-cdk/aws-ec2'
import * as path from 'path'

const userData = ec2.UserData.forLinux({shebang: '#!/bin/bash'})

// ローカルのユーザーデータのファイルを読み込む
const script = fs.readFileSync(
  path.join(__dirname, 'assets', 'userdata.sh'),
  {encoding: 'utf8'})

// 読み込んだユーザーデータを改行コードで split して userData.addCommands() に追加する
userData.addCommands(...script.split('\n'))

ユーザーデータの実行ログとか

ユーザーデータの実行ログは /var/log/cloud-init-output.log に出力する。

ユーザーデータがやることが増えていくと EC2 インスタンスが起動してもユーザーデータの実行がなかなか終わらないとか増えてくると思うので tail -f /var/log/cloud-init-output.log みたいなコマンドでユーザーデータのログが流れていくのを見たりしてる。

参考ページ

Travis CI で環境変数を暗号化して使う方法。

Travis CI で環境変数を暗号化して .travis.yml に保存してアプリから使う方法をメモしておく。

Travis CI で環境変数を暗号化して使う方法

環境とか前提条件

Travis CI は travis-ci.comを使ってる。

環境変数の暗号化に使う travis コマンドのバージョン。

$ travis --version
1.9.1

環境変数の暗号化

環境変数の暗号化は travis コマンドを使う。 Mac は Homebrew でインストールするのが楽だと思う。

$ brew install travis

環境変数travis login コマンドで Travis CI にログインしてから暗号化していく。 Travis CI は travis-ci.comを使うので travis login コマンドは --com オプションか --pro オプションを指定していく。

$ travis login --com

カレントディレクトリを Git リポジトリに移動してから travis encrypt コマンドで環境変数を暗号化していく。 Travis CI は travis-ci.comを使うので travis encrypt コマンドは --com オプションか --pro オプションを指定していく。 暗号化した環境変数travis encrypt コマンドの出力の secure: に続いて出力するので .travis.yml にコピペする。

$ cd /path/to/your/git/repo

$ travis encrypt --com MY_SECRET_ENV=super_secret
Please add the following to your .travis.yml file:

  secure: "dIEvchZ14clAFpaxtTwQrKSy3ueNgQSUGilGQRKMmV8tWJq3iVoZwQxr5uggrQdyL74MuFOweELyuMXcw4bGX5W/pBecZ7weacrEw2Phaf/M81sjv4dmhT86XNsqzR3ZjhR2DzeyFMAbmyTWbV3iCJ7PVj6KIodPku1bHKZuTDhfgIO3/zz9GYSSlt6xyE+X+0R9853vN4hw2Xls64obHUv+UL5/uxWK3scvTM1LnSddzbX+oLCbxN6kNJV9YKYN36M22WXMLqqtPPr5L9ogNPrGSr6iMsfWuvgyNzP4gv4tUqEP6aTiLFM5PVf36BVZWjkkAnlKeptFkzNVeA9uSLykbTn3ju8ESq/lXVEBbhv/Ig2etEP2IuA0+7nOA43fn++cDn7iJ4TYKZszXtoHp2wG/OoDwfF/h58RS6JiosfBNczetoEokM82tXBrXf3OKoTVxoKmac+i65FFMgDNU4KtIPb58fPw9CVpOIWNPjzXtfbJ78YrZhjl1gyMHLQKK3hHhB7M4l4v8CVHeqe5cAxCK0oQs5TW5dEP+dDbr/pmbwqImGwhM1ClENCpFFPegFVHILjtpV+s5nvkFepd/v5WzBIUhWigaho43U6xGUky2fa5dwicCaegU5PwMOBAZtYAPzQGCnXAnquqD2dVIucghFGvNudrDaYNHTEZOiY="

Pro Tip: You can add it automatically by running with --add.

暗号化した環境変数はこんな感じで .travis.yml に入れる。

env:
  global:
  - secure: dIEvchZ14clAFpaxtTwQrKSy3ueNgQSUGilGQRKMmV8tWJq3iVoZwQxr5uggrQdyL74MuFOweELyuMXcw4bGX5W/pBecZ7weacrEw2Phaf/M81sjv4dmhT86XNsqzR3ZjhR2DzeyFMAbmyTWbV3iCJ7PVj6KIodPku1bHKZuTDhfgIO3/zz9GYSSlt6xyE+X+0R9853vN4hw2Xls64obHUv+UL5/uxWK3scvTM1LnSddzbX+oLCbxN6kNJV9YKYN36M22WXMLqqtPPr5L9ogNPrGSr6iMsfWuvgyNzP4gv4tUqEP6aTiLFM5PVf36BVZWjkkAnlKeptFkzNVeA9uSLykbTn3ju8ESq/lXVEBbhv/Ig2etEP2IuA0+7nOA43fn++cDn7iJ4TYKZszXtoHp2wG/OoDwfF/h58RS6JiosfBNczetoEokM82tXBrXf3OKoTVxoKmac+i65FFMgDNU4KtIPb58fPw9CVpOIWNPjzXtfbJ78YrZhjl1gyMHLQKK3hHhB7M4l4v8CVHeqe5cAxCK0oQs5TW5dEP+dDbr/pmbwqImGwhM1ClENCpFFPegFVHILjtpV+s5nvkFepd/v5WzBIUhWigaho43U6xGUky2fa5dwicCaegU5PwMOBAZtYAPzQGCnXAnquqD2dVIucghFGvNudrDaYNHTEZOiY=

travis encrypt コマンドの出力を .travis.yml にコピペするのは面倒くさいけど travis encrypt コマンドを --add オプションを指定して実行すると暗号化した環境変数を .travis.yml に追加するかの確認のプロンプトが表示する。 y を入力すると暗号化した環境変数が .travis.yml に反映する。楽。

$ travis encrypt --com MY_SECRET_ENV=super_secret --add

Overwrite the config file /path/to/your/git/repo/.travis.yml with the content below?

This reformats the existing file.

---
env:
  global:
    secure: hMvpI0fq/FtqpevJU1wOu72votNKw+UeVwoWjqfBef8e//J4GrzGaK7qw8TcU+qhg2DWSXOjDhxgQO419tSOnsMj0YrmRX2R07girRnAWzJn19zgklYCUC9l3vX2qYnq+iFq22Gg94w/R9O5V7HfKbcg9weqpTyv8rX4yrLBRs48YKVWZzEWL6Ui1JKL0LGdThbeekSPN5kfs900rTR5jMPF9ILtoZAT9nSmcYplXP0jXfk4w7wrHszP0p+J1lhqYi+IoAEbb4Vh0WlzigLrFNTOjKiBGxer8JBeXut7x0DdWuGfM1ujVwbeG9SIxNr0CRuYBUl/NeDqfSSq5JJcUd3T8I/CuWROBgTkb1lG9ehr2+kuKC8hbYVbmrnv9Vztjmrk/21XVsHWMIdNj+ihkuUWTggErJ+uk3knqjv94noW9Pni2TWQI+NdrKZhwvCL2+lZpnyuTX5v0etT85aaGq1cDfipI6aB6bPeW1gitG6s2jPlA8AG7qGMOwZbh2Kn0TtIBiwl/CJWCAfLAzqZ7awWDaRg28Jb6R069ON3ZVAXw4eKoW37FNhLZJp2nDHtE+vWBoNkJRt1uPliVE1p2xn9OosQ2tWGtu/yaJSSZOCOgWiBWcsH/jhgRwI1ybOMwsAfygHmsniRPel2oDJ2pi7WWvCJu17E0UKy5Bnzs30=


(y/N)

カレントディレクトリを Git リポジトリに移動してから travis encrypt コマンドで環境変数を暗号化したけど、実は travis encrypt --repo <SLUG> みたいに --repo オプションで Githubリポジトリを指定するとカレントディレクトリがどこにいても環境変数を暗号化することができる。 ただ --add オプションが効かなくなるので暗号化した環境変数を .travis.yml にコピペするのが面倒くさいので、自分はカレントディレクトリを Git リポジトリに移動してから travis encrypt --add してる。

暗号化した環境変数Travis CI ログへの出力

暗号化した環境変数[secure] みたいにマスクして Travis CI のログに出力する。

Setting environment variables from .travis.yml
$ export MY_SECRET_ENV=[secure]

参考ページ

AWS Lambda と pixela4go を使って Github リポジトリのクローン数の草を Pixela に生やすメモ。

※この記事は AWS Lambda を使って Github リポジトリのクローン数の草を Pixela に生やすメモ を pixela4go を使うように書き直したものです。

Github は Web UI や API を使ってリポジトリのクローン数を取得することができるのだが、どちらの方法を使っても過去2週間分のクローン数しか見られない。そこで、AWS Lambda を使って定期的に Github のクローン数を取得して Pixela に記録することにした。Pixela は操作が簡単な上に草を生やすことができるのでビジュアル面でも Github のクローン数を記録するという用途にぴったりだ。

実際に pixela-client-go のクローン数を Pixela に記録してみるとこんな感じになる。

AWS Lambda を使って Github リポジトリのクローン数の草を Pixela に生やすまでにしたことをメモしておく。

はじめに

使うもの

前提条件

もし AWS のアカウントや Github のアカウントがないとか API トークンがないなら事前に取得しておく。 このあたりの情報は公式サイトはもちろんネット上にたくさんあるので困ることはないと思う。

やること

  • Pixela アカウントとグラフを作成
  • Lambda 関数の作成
    • トリガーを追加
    • 関数パッケージの作成
    • 関数パッケージのアップロード
    • 環境変数の設定
    • Lambda 関数の動作確認

Pixela アカウントとグラフを作成

Github のクローン数を Pixela に記録するために Pixela アカウントの作成と Pixela グラフを作成する。 公式ブログを見ながら作っていくと迷うことなく作れると思う。

blog.a-know.me

Lambda 関数の作成

関数の作成

Github のクローン数を取得して Pixela に記録する関数を作成していく。

AWS コンソール を表示して Lambda > 関数 > 関数の作成 ボタンを押す。

f:id:ebc_2in2crc:20190813155338p:plain:w600

一から作成 を選んで関数名を入力する。 ランタイムは Go 1.x を選んで実行ロールは 基本的な Lambda アクセス権限で新しいロールを作成 を選ぶ。

f:id:ebc_2in2crc:20190813155717p:plain:w600

トリガーを追加

作成した関数を定期的に動かすためにトリガーを設定していく。

Designer の トリガーを追加 を押してトリガーの設定画面を表示する。

f:id:ebc_2in2crc:20200605221626p:plain

トリガーの設定画面は以下のように設定する。

  • トリガーの選択は CloudWatch Events を選択
  • ルールは 新規ルールの作成 を選択
  • ルール名は 0100-utc-everyday を入力
  • ルールタイプは スケジュール式 を選択
  • スケジュール式は cron(0 1 * * ? *) を入力

f:id:ebc_2in2crc:20190813162204p:plain:w600

トリガーの有効化 にチェックを付けて 追加 ボタンを押す。

f:id:ebc_2in2crc:20190813162826p:plain:w600

トリガーの設定はこれで完了。この設定をしておくと Lambda 関数が毎日1時 (UTC) に自動的に実行するようになる。

関数パッケージの作成

Lambda で動かす関数パッケージを作っていく。

以下のコードを main.go というファイル名で保存する。

package main

import (
    "context"
    "errors"
    "os"
    "strconv"
    "time"

    "github.com/aws/aws-lambda-go/lambda"
    "github.com/ebc-2in2crc/pixela4go"
    "github.com/google/go-github/github"
    "golang.org/x/oauth2"
)

const timeFormat = "20060102"

type MyEvent struct {
    Time time.Time `json:"time"`
}

var (
    githubToken = os.Getenv("GITHUB_TOKEN")
    githubUser  = os.Getenv("GITHUB_USER")
    githubRepo  = os.Getenv("GITHUB_REPO")

    pixelaToken = os.Getenv("PIXELA_USER_TOKEN")
    userName    = os.Getenv("PIXELA_USER_NAME")
    graphID     = os.Getenv("PIXELA_GRAPH_ID")
)

func main() {
    lambda.Start(HandleRequest)
}

func HandleRequest(event MyEvent) (string, error) {
    yesterday := event.Time.Truncate(time.Hour * 24).Add(-time.Hour * 24)
    cloneCount, err := retrieveCloneCount(yesterday)
    if err != nil {
        return "failed to retrieve clone count", err
    }

    if err := registerCloneCount(yesterday, cloneCount); err != nil {
        return "failed to register clone count", err
    }

    return "success", nil
}

func retrieveCloneCount(t time.Time) (int, error) {
    client := newGithubClient(githubToken)
    clones, resp, err := client.Repositories.ListTrafficClones(
        context.Background(),
        githubUser,
        githubRepo,
        &github.TrafficBreakdownOptions{Per: "day"},
    )
    if err != nil {
        return 0, err
    }
    _ = resp.Body.Close()

    d := t.Format(timeFormat)
    for _, c := range clones.Clones {
        if c.GetTimestamp().Format(timeFormat) == d {
            return c.GetCount(), nil
        }
    }
    return 0, nil
}

func newGithubClient(token string) *github.Client {
    oauthCli := oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(&oauth2.Token{
        AccessToken: token,
    }))
    return github.NewClient(oauthCli)
}

func registerCloneCount(date time.Time, cloneCount int) error {
    client := pixela.New(userName, pixelaToken)
    pci := &pixela.PixelCreateInput{
        GraphID:  pixela.String(graphID),
        Date:     pixela.String(date.Format(timeFormat)),
        Quantity: pixela.String(strconv.Itoa(cloneCount)),
    }
    result, err := client.Pixel().Create(pci)
    if err != nil {
        return err
    }
    if result.IsSuccess == false {
        return errors.New(result.Message)
    }

    return nil
}

AWS Lambda にアップロードする zip ファイルを作成する。

$ go build -o hello main.go && zip hello.zip hello
  adding: hello (deflated 53%)

MacWindowsコンパイルするときは GOOS=linux を付けて Linux 向けにクロスコンパイルする。

$ GOOS=linux go build -o hello main.go && zip hello.zip hello
  adding: hello (deflated 53%)

main.go がやっていること

main.go はざっくり書くと「プログラムの実行日付の前日の Github リポジトリのクローン数を取得」して「プログラム実行日付の前日の (PIxela の) Pixel に記録」している。 当日のクローン数を当日の Pixel に記録したいときは yesterday := event.Time.Truncate(time.Hour * 24).Add(-time.Hour * 24) のあたりを適当にいじるとよい。

関数パッケージのアップロード

作成した zip ファイルを AWS にアップロードしていく。

コードエントリタイプは .zip ファイルをアップロード を選択して アップロード ボタンを押してさきほど作成した zip ファイルを AWS にアップロードする。

f:id:ebc_2in2crc:20200605221804p:plain

環境変数の設定

関数パッケージが使う環境変数アップロード ボタンの下の 環境変数 に設定していく。

API トークンなど重要な情報を含んでいるので実際は暗号化するほうがよい。

環境変数 設定する値
GITHUB_TOKEN GithubAPI トーク
GITHUB_USER Github のユーザー名
GITHUB_REPO クローン数の草を生やしたいリポジトリ
PIXELA_USER_TOKEN Pixela の API トーク
PIXELA_USER_NAME Pixela のユーザー名
PIXELA_GRAPH_ID クローン数の草を生やす先のグラフの名前

これで Lambda 関数の作成は完了。 続いて Lambda 関数がちゃんと動くか確認していく。

Lambda 関数の動作確認

関数パッケージと環境変数が正しく設定されていて Lambda 関数がちゃんと動くかどうかを確認していく。

画面の上のほうにある テストイベントの設定 を押して テストイベントの設定 画面を表示する。

f:id:ebc_2in2crc:20200605221834p:plain

テストイベントの設定 画面は以下のように設定する。

  • 新しいテストイベントの作成 を選択
  • イベントテンプレートは Hello World を選択
  • イベント名は test を入力

イベントの JSON は以下を設定する。 2020-06-04 のところはテストをしたい日付、たとえば Github リポジトリがクローンされたことが分かっている日、あるいは Github リポジトリがクローンされなかったことが分かっている日などを入力する。

{
  "time": "2020-06-04T01:00:00Z"
}

入力し終わったら 保存 ボタンを押してテストイベントを保存する。

次は作成したテストイベントを使って Lambda 関数の実際の動きを確認していく。

画面の上のほうにある テスト ボタンを押してテストイベント test を使って Lambda 関数を動かす。 実行結果: 成功 が表示されれば Lambda 関数は問題なく動いている。

f:id:ebc_2in2crc:20200605221926p:plain

以上。

参考ページ

blog.a-know.me

developer.github.com

Pixela の Go クライアント pixela4go を作りました。

Pixela の Go クライアント pixela4go を作りました。

github.com

Pixela とは?

任意の数値を登録してアレのあれっぽくグラフを作れるクールな API サービスです。

詳しくは↓

pixe.la blog.a-know.me

使い方

package main

import (
    "log"
 
    "github.com/ebc-2in2crc/pixela4go"
)

func main() {
    client := pixela.New("YOUR_NAME", "YOUR_TOKEN")

    // 新しいユーザーを作る
    uci := &pixela.UserCreateInput{
        AgreeTermsOfService: pixela.Bool(true),
        NotMinor:            pixela.Bool(true),
        ThanksCode:          pixela.String("thanks-code"),
    }
    result, err := client.User().Create(uci)
    if err != nil {
        log.Fatal(err)
    }
    if result.IsSuccess == false {
        log.Fatal(result.Message)
    }

    // 新しい slack チャンネル を作る
    cci := &pixela.ChannelCreateInput{
        ID:          pixela.String("channel-id"),
        Name:        pixela.String("channel-name"),
        Type:        pixela.String(pixela.ChannelTypeSlack),
        SlackDetail: &pixela4go.SlackDetail{
            URL:         pixela4go.String("https://hooks.slack.com/services/xxxx"),
            UserName:    pixela4go.String("slack-user-name"),
            ChannelName: pixela4go.String("slack-channel-name"),
        },
    }
    result, err = client.Channel().Create(cci)
    if err != nil {
        log.Fatal(err)
    }
    if result.IsSuccess == false {
        log.Fatal(result.Message)
    }

    // 新しいグラフを作る
    gci := &pixela.GraphCreateInput{
        ID:                  pixela.String("graph-id"),
        Name:                pixela.String("graph-name"),
        Unit:                pixela.String("commit"),
        Type:                pixela.String(pixela.GraphTypeInt),
        Color:               pixela.String(pixela.GraphColorShibafu),
        TimeZone:            pixela.String("Asia/Tokyo"),
        SelfSufficient:      pixela.String(pixela.GraphSelfSufficientIncrement),
        IsSecret:            pixela.Bool(true),
        PublishOptionalData: pixela.Bool(true),
    }
    result, err = client.Graph().Create(gci)
    if err != nil {
        log.Fatal(err)
    }
    if result.IsSuccess == false {
        log.Fatal(result.Message)
    }

    // 値をピクセルに記録する
    pci := &pixela.PixelCreateInput{
        Date:         pixela.String("20180915"),
        Quantity:     pixela.String("5"),
        GraphID:      pixela.String("graph-id"),
    }
    result, err = client.Pixel().Create(pci)
    if err != nil {
        log.Fatal(err)
    }
    if result.IsSuccess == false {
        log.Fatal(result.Message)
    }

    // 新しい通知ルールを作る
    nci := &pixela.NotificationCreateInput{
        GraphID:   pixela.String("graph-id"),
        ID:        pixela.String("notification-id"),
        Name:      pixela.String("notification-name"),
        Target:    pixela.String(pixela.NotificationTargetQuantity),
        Condition: pixela.String(pixela.NotificationConditionGreaterThan),
        Threshold: pixela.String("3"),
        RemindBy:  pixela.String("23"),
        ChannelID: pixela.String("channel-id"),
    }
    result, err = client.Notification().Create(nci)
    if err != nil {
        log.Fatal(err)
    }
    if result.IsSuccess == false {
        log.Fatal(result.Message)
    }

    // 新しい webhook を作る
    wci := &pixela.WebhookCreateInput{
        GraphID: pixela.String("graph-id"),
        Type:    pixela.String(pixela.WebhookTypeIncrement),
    }
    webhook, err := client.Webhook().Create(wci)
    if err != nil {
        log.Fatal(err)
    }
    if webhook.IsSuccess == false {
        log.Fatal(webhook.Message)
    }

    // webhook を呼び出す
    wii := &pixela.WebhookInvokeInput{WebhookHash: pixela.String("webhook-hash")}
    result, err = client.Webhook().Invoke(wii)
    if err != nil {
        log.Fatal(err)
    }
    if result.IsSuccess == false {
        log.Fatal(result.Message)
    }
}

現時点の Pixela の最新バージョンの v1.18.0 のすべての API をサポートしています。

インストール

$ go get -u github.com/ebc-2in2crc/pixela-client-go

動機

Pixela はシンプルでいろんなことに応用できるとても便利な API サービスです。

実は pixela-client-go という Pixela の Go クライアントを作って公開しているのですが、実装がそんなによくなくて Pixela のアップデートに追従していくのにつらみを感じていたので車輪を再発明してしまいました。

pixela-client-go は API のパラメーターがプリミティブ型が多くて渡すものを間違えたり引数を省略できなかったりな使いにくさがあるのですが、pixela4go は API のパラメーターが構造体になっているので引数に渡すものを間違えたりしにくくなっているのと、あと引数を省略できるのでかなり使いやすくなっています。

pixela4go という名前

Twitter4J みたいな <サービス名>4<言語名> ネーミングをしてみたかったので。

まとめ

バグとかあると思いますし「こーしたら使いやすくなる」とかあったら issue とか PR 作ったり声かけてもらえると嬉しいです。

ということで Pixela の Go クライアント pixela4go の紹介でした。

AWS で動く Slack の echo bot を CDK で作った。

f:id:ebc_2in2crc:20200510200243p:plain:w400

AWS の CDK を勉強したいと思ってるときに Slack の bot を作りたいと思ったので CDK の勉強を兼ねて Slack のメンションを bot に飛ばすとメンションを返す echo bot を作ってみた。

=> Slack echo bot

全体像

f:id:ebc_2in2crc:20200510192956j:plain

利用する AWS のサービス

一応、無料枠の範囲で。

Slack echo bot の動かし方

Slack アプリの作成

https://api.slack.com/apps から Slack echo bot のアプリを作る。

Slack echo bot は以下の OAuth scopes を付与しておく。

  • app_mentions:read
  • chat:write

この時点ではまだ Slack echo botAWS にデプロイしていないのでイベントのサブスクリプションはしないでおく。

Slack echo bot のデプロイ

cdk deploy コマンドを実行して Slack echo botAWS にデプロイする。

SLACK_BOT_USER_ACCESS_TOKENhttps://api.slack.com/apps/your-app-id/generalBot User OAuth Access Token を入力して SLACK_SIGNING_SECREThttps://api.slack.com/apps/your-app-id/oauthSigning Secret を入力。

cdk deploy コマンドの出力の OutputsSlackEchoBotStack.slackbotendpointEndpointxxxxxxxx の URL は Slack echo bot アプリのイベントサブスクリプションの設定で使うのでメモしておく。

$ cdk deploy --context SLACK_BOT_USER_ACCESS_TOKEN=your-app-access-token --context SLACK_SIGNING_SECRET=your-app-signing-secret

Outputs:
SlackEchoBotStack.slackbotendpointEndpointxxxxxxxx  = https://xxxxxxxxxx.execute-api.any-region.amazonaws.com/prod/

Slack アプリのイベントサブスクリプション

https://api.slack.com/apps/your-app-id/event-subscriptions を表示して Enable EventsOn にしてさきほどメモしておいた Outputs の URL を Request URL に入力する。 Verified が表示すれば OK.

あと Subscribe to bot events のとこで app_mention をサブスクライブしておく。

動作確認

Slack で bot にメンションを飛ばして bot がメンションを返してきたら成功。

bot がメンションを返してこないときは https://api.slack.com/apps/your-app-id/event-subscriptions を表示して bot がイベントをサブスクリプションできているかとか、API Gateway とか Lambda のログを CloudWatch Logs で確認して問題ありそうなとこを見つけていく。

f:id:ebc_2in2crc:20200510200243p:plain:w400

作ってるときのこと

Slack アプリがサブスクライブしてるイベントが発生すると Slack から Slack アプリにリクエストが飛ぶ。 このときアプリは 3秒以内にレスポンスを返す 必要があるのだけど bot を Lambda 上に構築するとレスポンスが3秒以内に収まらないことがちょいちょいあるようだったので、Lambda 関数は API Gateway から起動するやつ (長いので Lambda-1 としておく) と API Gateway から起動する Lambda から起動するやつ (長いので Lambda-2 としておく) の2つを使うようにした。 Lambda-1 は Lambda-2 を非同期で起動と API Gateway200 を返してて、Lambda-2 は処理本体 (echo するだけなんだけど) をやってる。

あと Slack echo bot はリクエストが本当に Slack からのものかを確認するためにリクエスト署名を使ってる。 Slack の Slash Command をサーバーレスで実装 がすごく参考にさせていただいた。

あと最後になってしまうけど Slack x API Gateway x Lambda(Python) x RDSでChatOpsをしてみた は思いっきり参考にさせていただいた。こちらのページを見てなかったらそもそも Slack の bot を作ろうって気にならなかったかも。

注意しておきたいこととか

Slack echo bot は CDK の勉強で実験的に作ってみたやつなので、bot を実際に運用するときは bot ユーザーの OAuth Access Token や署名に使う Signing Secret は AWS Secrets Manager を利用するか KMS で暗号化することを強く推奨。

参考サイト