宣言的スキーマ管理のためのデータマイグレーション

宣言的スキーマ管理をする場合に「データマイグレーションをどうするか」という課題があって、Ridgepoleについては「データマイグレーションはほかのツールでやってほしい」というポリシーで開発してきた。

C社にいた頃はLiquibaseを使って「GitHubSQLファイルがマージされたら冪等に実行する」という社内ツールを作ったが、現職でも宣言的スキーマ管理をするケースが増えて「データマイグレーションをどげんかせんといかん」という機運になってきたので、テータマイグレーション用のツールを作った。

github.com

qrevはSQLファイルを実行し、履歴を管理するツール。

設計方針は以下の通り。

  • up・downという概念はない。ロールバックもない
  • 成否にかかわらず一回実行されたら再実行はしない
  • ただし失敗したSQLファイルを編集したら再実行できる

極めてシンプルなツールだが、データマイグレーションには必要十分ではないかと考えている。

開発フローに投入して様子見中。

テストの時だけメソッドをはやす

main.go

package main

import "fmt"

type Foo struct {
    I int
    J int
}

func (foo *Foo) Sum() int {
    return foo.I + foo.J
}

func main() {
    foo := &Foo{1, 2}
    fmt.Println(foo.Sum())
}

helper_test.go

package main

import (
    "fmt"
)

func (foo *Foo) Dump() string {
    return fmt.Sprintf("%+v", foo)
}

main_test.go

package main_test

// ↓ではメソッドをはやせない
// type Foo = main.Foo
// type Foo main.Foo

import (
    main "hello"
    "testing"

    "github.com/stretchr/testify/assert"
)

func TestHello(t *testing.T) {
    assert := assert.New(t)
    foo := &main.Foo{1, 2}
    assert.Equal(3, foo.Sum())
    t.Log(foo.Dump())
}

terraform-provider-googlesheetsを作っていた

terraformからGoogleスプレッドシートの値を取得したいことがたま〜にあるので、少し前にスプレッドシート取得用のproviderを作った。

github.com

サービスアカウントにスプレッドシートを共有して

シート名とレンジを指定するとその範囲のデータをJSON文字列としてとってこれる

provider "googlesheets" {
  credentials_json = file("credentials.json")
  # credentials_env = "CREDS_ENV_NAME"
}

data "googlesheets_sheet" "my_sheet" {
  sheet_id = "..."
  range    = "シート1!A1:B2"
}

output "values" {
  value = jsondecode(data.googlesheets_sheet.my_sheet.json)
}

googlesheets_sensitive_sheetリソースを使うとsensitiveな値として扱える。

data "googlesheets_sensitive_sheet" "my_sheet" {
  sheet_id = "..."
  range    = "シート1!A1:B2"
}

output "sensitive_values" {
  value     = jsondecode(data.googlesheets_sensitive_sheet.my_sheet.json)
  sensitive = true
}

エフェメラルリソースもある。

ephemeral "googlesheets_sheet" "my_sheet" {
  sheet_id = "..."
  range    = "シート1!A1:B1"
}

provider "any_other" {
  api_key = jsondecode(ephemeral.googlesheets_sheet.my_sheet.json)[0][0]
}

pgx/v5でstring配列をやりとりする

github.com/jackc/pgx/v5 v5.7.6 での検証

pgx.Conn

pgx.Connを使っている場合は、[]stringをそのまま使えるのであまり考える必要はない。

package main

import (
    "context"
    "fmt"
    "log"

    "github.com/jackc/pgx/v5"
)

func main() {
    ctx := context.Background()
    conn, err := pgx.Connect(ctx, "postgres://postgres@localhost:5432/postgres")
    if err != nil {
        log.Fatal(err)
    }
    defer conn.Close(ctx)

    // CREATE TABLE users (
    //  id    SERIAL PRIMARY KEY,
    //  name  VARCHAR(100),
    //  email TEXT,
    //  tags  VARCHAR(50)[]
    // );

    var name string
    var tags []string

    emails := []string{"sugawara@example.com", "hoge@example.com"}
    err = conn.QueryRow(ctx, "SELECT name, tags FROM users WHERE email = ANY($1)", emails).Scan(&name, &tags)

    if err != nil {
        log.Fatal(err)
    }

    fmt.Println(name, tags) //=> sugawara [foo bar zoo]
}

stdlib

プレースホルダーにstring配列を渡す

プレースホルダーには[]stringをそのまま渡せる。

package main

import (
    "database/sql"
    "fmt"
    "log"

    _ "github.com/jackc/pgx/v5/stdlib"
)

func main() {
    db, err := sql.Open("pgx", "postgres://postgres@localhost:5432/postgres")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    // CREATE TABLE users (
    //  id    SERIAL PRIMARY KEY,
    //  name  VARCHAR(100),
    //  email TEXT,
    //  tags  VARCHAR(50)[]
    // );

    var name string

    emails := []string{"sugawara@example.com", "hoge@example.com"}
    err = db.QueryRow("SELECT name FROM users WHERE email = ANY($1)", emails).Scan(&name)

    if err != nil {
        log.Fatal(err)
    }

    fmt.Println(name) //=> sugawara
}

結果のstring配列を受け取る

string配列を受け取る場合はpgtype.NewMapを使う必要がある。

package main

import (
    "database/sql"
    "fmt"
    "log"

    "github.com/jackc/pgx/v5/pgtype"
    _ "github.com/jackc/pgx/v5/stdlib"
  // "github.com/lib/pq"
)

func main() {
    db, err := sql.Open("pgx", "postgres://postgres@localhost:5432/postgres")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    // CREATE TABLE users (
    //  id    SERIAL PRIMARY KEY,
    //  name  VARCHAR(100),
    //  email TEXT,
    //  tags  VARCHAR(50)[]
    // );

    var tags []string

    err = db.QueryRow("SELECT tags FROM users WHERE name = $1", "sugawara").Scan(pgtype.NewMap().SQLScanner(&tags))
    // あるいは pq.Arrayを使う https://pkg.go.dev/github.com/lib/pq#Array
    //err = db.QueryRow("SELECT tags FROM users WHERE name = $1", "sugawara").Scan(pq.Array(&tags))

    if err != nil {
        log.Fatal(err)
    }

    fmt.Println(tags) //=> [foo bar zoo]
}

以下のIssueではpgtype.FlatArrayを使っているが不要なようだった。

github.com

https://github.com/jackc/pgx/blob/61d3c965ad442cc14d6b0e39e0ab3821f3684c03/stdlib/sql.go#L58-L65

// # PostgreSQL Specific Data Types
//
// The pgtype package provides support for PostgreSQL specific types. *pgtype.Map.SQLScanner is an adapter that makes
// these types usable as a sql.Scanner.
//
// m := pgtype.NewMap()
// var a []int64
// err := db.QueryRow("select '{1,2,3}'::bigint[]").Scan(m.SQLScanner(&a))

Google Apps Scriptからのアクセスを雑に認証する

Google Apps ScriptからプライベートなサービスのAPIを呼び出す場合、何らかの認証が必要になるので、GASから取得したOAuth2アクセストークンを検証するプロキシサーバを作ってみた。

github.com

仕組み

ScriptApp.getOAuthToken()を使うと有効なユーザーの OAuth 2.0 アクセス トークンを取得できる。※appscript.jsonの修正が必要

// appscript.json
{
  "timeZone": "Asia/Tokyo",
  "dependencies": {},
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8",
  "oauthScopes": [
    "openid",
    "email",
    "https://www.googleapis.com/auth/script.external_request"
  ]
}

トークンを使ってさらにユーザー情報を取得できる。

$ curl -H "Authorization: Bearer ${TOKEN}" https://openidconnect.googleapis.com/v1/userinfo
{
  "sub": "...",
  "picture": "...,
  "email": "sugawara@winebarrel.jp",
  "email_verified": true,
  "hd": "winebarrel.jp"
}

作成したプロキシサーバではユーザー情報のemailが許可されているかを検証する。

使い方

$ docker run --rm ghcr.io/winebarrel/gap --help
Usage: gap --backend=BACKEND --port=UINT --header-name=STRING --allow-list=ALLOW-LIST,... [flags]

Flags:
  -h, --help                  Show help.
  -b, --backend=BACKEND       Backend URL ($GAP_BACKEND).
  -p, --port=UINT             Listening port ($GAP_PORT).
  -n, --header-name=STRING    Header name to pass the access token
                              ($GAP_HEADER).
  -e, --allow-list=ALLOW-LIST,...
                              Allowed email list that may contain wildcards
                              ($GAP_ALLOW_LIST).
      --version

バックエンドのURL、リッスンポート、アクセストークンを渡すヘッダ名、許可するメールアドレスを指定してプロキシサーバを起動する。

$ docker run --rm -p 8080:8080 ghcr.io/winebarrel/gap -b https://example.com -e '*@winebarrel.jp' -p 8080 -n x-my-gap-token

正しいアクセストークンを渡すとプロキシサーバからバックエンドにアクセスできる。

$ curl -s -H "x-my-gap-token: ${TOKEN}" localhost:8080
<!doctype html>
<html>
<head>
    <title>Example Domain</title>

間違ったアクセストークンを渡すとはじかれる。

$ curl -s -H "x-my-gap-token: ${TOKEN}x" localhost:8080
forbidden

GASからはUrlFetchApp.fetch()でアクセスできる。

function myFunction() {
  const token = ScriptApp.getOAuthToken();

  const response = UrlFetchApp.fetch('https://api.my-private.example.com/foo/bar/zoo', {
    headers: {
      "x-my-gap-token": token
    }
  });

  console.log(response.getContentText());
}

GASをスプレッドシートのカスタム関数として使っている場合、関数から直接アクセストークンを取得することはできないが、メニューのアイテムとしてScriptApp.getOAuthToken()を呼び出してキャッシュに保存することで、カスタム関数内でアクセストークンを使うことができる。

function onOpen() {
  const ui = SpreadsheetApp.getUi();
  ui.createMenu("認証").addItem("認証", "auth").addToUi();
}

function auth() {
  const cache = CacheService.getUserCache();
  const token = ScriptApp.getOAuthToken();
  cache.put("token", token);
}

function myFunction() {
  const cache = CacheService.getUserCache();
  const token = cache.get("token");
  // ...

cron(5 10 31W * ? 2027) はいつ実行されるか?

EventBrigeのcron式のパーサ cronplan に以下のようなバグ報告があった。

github.com

5 10 31W * ? 2027の出力が cronplan とEventBridgeのコンソールで異なるとのこと。

Wはmonth-of-dayの末尾につけると、指定した日付が平日ではない場合、近い平日に寄せてくれる。

$ curl cronplan.in -d '5 10 31W * ? 2027'
Fri, 29 Jan 2027 10:05:00
Wed, 31 Mar 2027 10:05:00
Fri, 30 Apr 2027 10:05:00
Mon, 31 May 2027 10:05:00
Wed, 30 Jun 2027 10:05:00
Fri, 30 Jul 2027 10:05:00
Tue, 31 Aug 2027 10:05:00
Thu, 30 Sep 2027 10:05:00
Fri, 29 Oct 2027 10:05:00
Tue, 30 Nov 2027 10:05:00

ぱっと見た感じ31日のない月はスキップされているように見えるが、2027/10が表示されないのはおかしい。

% cal 10 2027
      10月 2027
日 月 火 水 木 金 土
                1  2
 3  4  5  6  7  8  9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31

WはEventBridgeのcron式にいくつか存在するよくわからない仕様のやつで(ほかにはFRI#5の5週目の金曜日が存在しなかったらどうなるか、などがある)、コンソールの出力と、たぶん元になっていると思われるQuartzを参考にして実装した。

基本的には土曜日だったら金曜日に、日曜日だったら月曜日に寄せる動作になっているが、1日と月末は特殊で月が変わらないように日付を変更するようになる。 31Wの場合も同様に月が変わらないように変更していたが、その月に31日が存在しない場合はスキップされるという仕様を見落としていたようだった。

とりあえず、見落とした仕様に合わせて実装を直したが、コンソールで2027/10が無視されているのはよくわからない。 正直、コンソールのバグだと思っているが、2027年の10月になってみないと、実際に実行されるかわからない。

% curl cronplan.in -d '5 10 31W * ? 2027'
Fri, 29 Jan 2027 10:05:00
Wed, 31 Mar 2027 10:05:00
Mon, 31 May 2027 10:05:00
Fri, 30 Jul 2027 10:05:00
Tue, 31 Aug 2027 10:05:00
Fri, 29 Oct 2027 10:05:00
Fri, 31 Dec 2027 10:05:00

Quartzもバージョンによって31Wの動作が異なり、最新版の2.5.0ではcronplanと同じ動作だが、2.4.0ではなぜか Fri Apr 30 10:05:00 JST 2027が含まれる。

# quartz 2.4.0
# "0 5 10 31W * ? 2027"
Fri Jan 29 10:05:00 JST 2027
Wed Mar 31 10:05:00 JST 2027
Fri Apr 30 10:05:00 JST 2027 # <- ???
Mon May 31 10:05:00 JST 2027
Fri Jul 30 10:05:00 JST 2027
Tue Aug 31 10:05:00 JST 2027
Fri Oct 29 10:05:00 JST 2027
Fri Dec 31 10:05:00 JST 2027

ちなみに単に月の平日の最終日をスケジュールしたいならLWを使うのがよい。

% curl cronplan.in -d '5 10 LW * ? 2027'
Fri, 29 Jan 2027 10:05:00
Fri, 26 Feb 2027 10:05:00
Wed, 31 Mar 2027 10:05:00
Fri, 30 Apr 2027 10:05:00
Mon, 31 May 2027 10:05:00
Wed, 30 Jun 2027 10:05:00
Fri, 30 Jul 2027 10:05:00
Tue, 31 Aug 2027 10:05:00
Thu, 30 Sep 2027 10:05:00
Fri, 29 Oct 2027 10:05:00

terraform-provider-lambdazipにJavaScript・Go・Python・Ruby・Rustのデプロイ例を追加した

TerraformでLambdaをデプロイするためのプロバイダ terraform-provider-lambdazip にJavaScript・Go・PythonRuby・Rustの例を追加した。

github.com