AWS Secrets Managerから秘匿値を取得して環境変数を設定しコマンドを実行するツールを作った

envchainのバックエンドにAWS Secrets Managerを使ったようなツールを作った。

github.com

使い方

Secrets Managerに秘匿値を設定した上で

$ aws secretsmanager get-secret-value --secret-id foo/bar
{
  ...
  "SecretString": "BAZ",
  ...

$ aws secretsmanager get-secret-value --secret-id foo/zoo # JSON secret
{
  ...
  "SecretString": "{\"TOKEN\":\"AAA\",\"SECRET\":\"BBB\"}",
  ...

sevの設定ファル(~/.sev.toml)を作成し

[hello]
BAR = "secretsmanager://foo/bar"
ZOO = "secretsmanager://foo/zoo:TOKEN"
BAZ = "BAZBAZBAZ"

[world]
HOGE = "secretsmanager://foo/zoo:SECRET"
FOGA = "secretsmanager://foo/bar"
PIYO = "PIYOPIYOPIYO"

sevでラップしてプロファイル名を指定してコマンドを実行すると、秘匿値が環境変数経由でコマンドに渡される。

$ sev hello -- env
FOO=BAZ
ZOO=AAA
BAZ=BAZBAZBAZ

$ sev world -- env
HOGE=BBB
FUGA=BAZ
PIYO=PIYOPIYOPIYO

設定ファイルにAWS_PROFILEを指定しておくと、デフォルトではそのAWS_PROFILEを使ってSecrets Manager APIを呼び出す。

[hello]
AWS_PROFILE = "my-profile"
BAR = "secretsmanager://foo/bar"
# foo/barの値の取得にmy-profileが使われる

aws_iam_policy_documentを使うとplanの差分が大きくなるパターン

aws_iam_policy_documentを使っていると、terraform planを実行したときに差分が大きくて変更がわかりにくくなることがあったので検証してみた。

具体的には以下のようなパターンで差分が大きくなった。


まず以下のようなtfがあったとして

resource "aws_cloudwatch_log_group" "my-group" {
  name = "my-group"
}

resource "aws_iam_role" "log-writer" {
  name = "log-writer"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "lambda.amazonaws.com"
        }
      },
    ]
  })
}

data "aws_iam_policy_document" "put-log" {
  statement {
    actions = [
      "logs:CreateLogStream",
      "logs:DescribeLogStreams",
      "logs:PutLogEvents",
      "logs:GetLogEvents",
    ]

    resources = [
      # 普通、aws_cloudwatch_log_group.my-group.arn を使うが、説明のために便宜上
      "arn:aws:logs:ap-northeast-1:XXX:log-group:${aws_cloudwatch_log_group.my-group.name}:*",
    ]
  }
}

resource "aws_iam_role_policy" "put-log" {
  role = aws_iam_role.log-writer.name
  name = "put-log"
  policy = data.aws_iam_policy_document.put-log.json
}

aws_cloudwatch_log_group.my-groupのname属性を変更する。

diff --git a/terraform.tf b/terraform.tf
index cea67e4..d806f57 100644
--- a/terraform.tf
+++ b/terraform.tf
@@ -14,7 +14,7 @@ provider "aws" {
 }

 resource "aws_cloudwatch_log_group" "my-group" {
-  name = "my-group"
+  name = "my-group2"
 }

 resource "aws_iam_role" "log-writer" {

terraform planの差分は以下のようになる。

  # aws_iam_role_policy.put-log will be updated in-place
  ~ resource "aws_iam_role_policy" "put-log" {
        id          = "log-writer:put-log"
        name        = "put-log"
      ~ policy      = jsonencode(
            {
              - Statement = [
                  - {
                      - Action   = [
                          - "logs:CreateLogStream",
                          - "logs:DescribeLogStreams",
                          - "logs:PutLogEvents",
                          - "logs:GetLogEvents",
                        ]
                      - Effect   = "Allow"
                      - Resource = "arn:aws:logs:ap-northeast-1:XXX:log-group:my-group:*"
                    },
                ]
              - Version   = "2012-10-17"
            }
        ) -> (known after apply)
        # (2 unchanged attributes hidden)
    }

これをjsonencodeで直接ポリシーを指定するとポリシーの差分だけ表示される。

resource "aws_iam_role_policy" "put-log" {
  role = aws_iam_role.log-writer.name
  name = "put-log"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = [
          "logs:CreateLogStream",
          "logs:DescribeLogStreams",
          "logs:PutLogEvents",
          "logs:GetLogEvents",
        ]
        Effect   = "Allow"
        Resource = "arn:aws:logs:ap-northeast-1:XXX:log-group:${aws_cloudwatch_log_group.my-group.name}:*"
      },
    ]
  })
}

  # aws_iam_role_policy.put-log will be updated in-place
  ~ resource "aws_iam_role_policy" "put-log" {
        id          = "log-writer:put-log"
        name        = "put-log"
      ~ policy      = jsonencode(
          ~ {
              ~ Statement = [
                  ~ {
                      ~ Resource = "arn:aws:logs:ap-northeast-1:XXX:log-group:my-group:*" -> "arn:aws:logs:ap-northeast-1:XXX:log-group:my-group2:*"
                        # (2 unchanged attributes hidden)
                    },
                ]
                # (1 unchanged attribute hidden)
            }
        )
        # (2 unchanged attributes hidden)
    }

name属性のようにリソースの変更前に値がわかっているようなものだと、同様の現象が発生しそう。

追記

id:MIZZY さんにコメントをいただいたので、aws_cloudwatch_log_group.my-group.name をlocal変数に格納してみた。

resource "aws_cloudwatch_log_group" "my-group" {
  name = "my-group2"
}

locals {
  my_group_name = aws_cloudwatch_log_group.my-group.name
}

data "aws_iam_policy_document" "put-log" {
  statement {
    actions = [
      "logs:CreateLogStream",
      "logs:DescribeLogStreams",
      "logs:PutLogEvents",
      "logs:GetLogEvents",
    ]

    resources = [
      "arn:aws:logs:ap-northeast-1:822997939312:log-group:${local.my_group_name}:*",
    ]
  }
}

いったんlocal変数に格納すると、差分が最小限になる。

プルリクエストが承認+テストをパスしたら通知するmacOSアプリを作った

プルリクエストがマージ可能になったらいち早く知りたいので、承認+テストをパスしたら通知を送るmacOSのアプリを作った。

github.com

approve不要なPRについてはテストが完了した時点で通知がくる(はず)。 テストがこけても通知が来る。rejectされても通知が来る。要はステータスが確定したら通知が来る。

Notificationsをみてもいいんだけど、approveやテスト以外の通知もくるので専用に作ってみた。 Notifications全般の通知を受け取りたいならGitifyを使った方がいいと思う。

通知はこんな感じ。

メニューバーのアイコンをクリックするとステータスが確定したPRの一覧が出る。

あとステータスが確定したPRがないときは、メニューバーのアイコンが黒に変わる。

設定画面で検索条件を変えられるので、特定のorgだけにするとか、一部orgを除外するとかできる。

実装

以下のようなGraphQLを実行して、PR一覧とテストのステータスをまとめて取得している。

https://github.com/winebarrel/Succ/blob/main/Succ/Github/SearchPullRequests.graphql

query SearchPullRequests($query: String!) {
  search(type: ISSUE, last: 100, query: $query) {
    nodes {
      ... on PullRequest {
        repository {
          name
          owner {
            login
          }
        }
        title
        url
        reviewDecision
        commits(last: 1) {
          nodes {
            commit {
              url
              statusCheckRollup {
                state
              }
            }
          }
        }
      }
    }
  }
}

取得したPRのステータスを見て通知するかどうかを判断。

https://github.com/winebarrel/Succ/blob/main/Succ/PullRequest.swift

    private func updateNodes(_ value: GraphQLResult<Github.SearchPullRequestsQuery.Data>) {
        var fetchedNodes: Nodes = []

        value.data?.search.nodes?.forEach { body in
            if let pull = body?.asPullRequest {
                let reviewDecision = pull.reviewDecision

                if reviewDecision != nil && reviewDecision != .approved && reviewDecision != .changesRequested {
                    return
                }

                guard let commit = pull.commits.nodes?.first??.commit else {
                    return
                }

                guard let state = commit.statusCheckRollup?.state else {
                    return
                }

                if state != .success && state != .failure && state != .error {
                    return
                }

                let node = Node(
                    owner: pull.repository.owner.login,
                    repo: pull.repository.name,
                    title: pull.title,
                    url: pull.url,
                    reviewDecision: reviewDecision?.rawValue ?? "",
                    state: state.rawValue,
                    commitUrl: commit.url,
                    success: (reviewDecision == nil || reviewDecision == .approved) && state == .success
                )

                fetchedNodes.append(node)
            }
        }

Golangの構造体の情報をダンプするライブラリを作った

Golangの構造体の情報をダンプするライブラリを作った。

github.com

使い方

こういう感じの設定用structがあったとして

type config struct {
    Home string     `env:"HOME,required"`
    Port int        `env:"PORT" envDefault:"3000"`
    Bar  *subconfig `envPrefix:"SUB_"`
}

type subconfig struct {
    Password     string `env:"PASSWORD,unset,required"`
    IsProduction bool   `env:"PRODUCTION"`
}

ダンプしてJSONで出力できる。

func main() {
    var c config
    ss := tipper.Dump(c)
    //ss := tipper.DumpT[config]()
    fmt.Println(ss[0].Fields[0]) //=> "{Password string [{env PASSWORD [unset required]}]}"
    fmt.Println(ss)
}
[
  {
    "name": "main.subconfig",
    "fields": [
      {
        "name": "Password",
        "type": "string",
        "tags": [
          {
            "key": "env",
            "name": "PASSWORD",
            "options": [
              "unset",
              "required"
            ]
          }
        ]
      },
      // ...

ユースケース

Golangのプログラムでconfig構造体にフィールドを追加したが、ECSタスク定義に環境変数を追加していなかったためデプロイしたらCIがこけた」ということがたまにあるので、プログラム自身に必要な環境変数を出力させて、テストでECSタスク定義の環境変数と比較する…というようなユースケースを考えている。

上記の例だと、必須な環境変数の一覧をjqでとれる。

$ go run main.go | jq -r '.[].fields[] | select(.tags).tags[] | select(.key == "env" and (.options // [] | contains(["required"]))).name'
PASSWORD
HOME

おまけ: Struct Tagのパース

このライブラリではStruct Tagのパースに https://github.com/fatih/structtag を使っている。 キーから値を取得するだけなら、refrect.StructTag.Get()でできるが、すべてのキーを取得するようなことはできないので。

github.com

同様のライブラリはいくつかあるが、アクティブに更新されているものは見つけられなかった。

実装自体がシンプルなためあまり問題になることはないと思うが、できれば標準ライブラリでサポートしてほしい…