zaki work log

作業ログやら生活ログやらなんやら

[Terraform / AWS] externalデータソースを使った外部コマンド実行でセキュリティグループに自分のIPアドレスをセットする

Terraformで環境を作成する際のアクセス元IPを設定したい場合に、AWSのwebポータルだと「マイIP」でアクセス元グローバルIPを簡単にセットできるけどTerraformなどは機能として提供されてなさそうだっため、事前にアドレスを取得してそれをセットするという手順を実現できないか調べてみた。
IPアドレスを知るにはcurlでそういう値を返すサーバーに問い合わせるのが手軽なので、そのコマンド結果をTerraformコード内で使う方法についてお試し。

external_external | Data Sources | hashicorp/external | Terraform | Terraform Registry

externalを使ったIPアドレスの取得とエラー

コマンドを実行してIPアドレスを得るにはいくつかあると思うが、慣れているcurl -sS https://ifconfig.ioを実行。

# ダメな書き方
data "external" "src_ip" {
  program = program = ["curl", "-sS", "https://ifconfig.io/"]
}

ただし↑のように書いてもエラーになる。

│ Error: Unexpected External Program Results
│ 
│   with module.dev_online.data.external.src_ip,
│   on modules/online/main.tf line 55, in data "external" "src_ip":
│   55:   program = ["curl", "-sS", "https://ifconfig.io/"]
│ 
│ The data source received unexpected results after executing the program.
│ 
│ Program output must be a JSON encoded map of string keys and string values.
│ 
│ If the error is unclear, the output can be viewed by enabling Terraform's logging at TRACE level. Terraform documentation
│ on logging: https://www.terraform.io/internals/debugging
│ 
│ Program: /usr/bin/curl
│ Result Error: invalid character '.' after top-level value

エラーやドキュメントをよく見るとコマンドの出力はJSON形式である必要がある。(後述するけどstringであるとも書いてある)

Program output must be a JSON encoded map of string keys and string values.

externalのドキュメントの「External Program Protocol」の項は以下。

The program must read all of the data passed to it on stdin, and parse it as a JSON object.

https://registry.terraform.io/providers/hashicorp/external/latest/docs/data-sources/external#external-program-protocol

ということで、JSON形式のレスポンスを返すようにオプションのパスを追加してみると

│ Error: Unexpected External Program Results
│ 
│   with module.dev_online.data.external.src_ip,
│   on modules/online/main.tf line 56, in data "external" "src_ip":
│   56:   program = ["curl", "-sS", "https://ifconfig.io/all.json"]
│ 
│ The data source received unexpected results after executing the program.
│ 
│ Program output must be a JSON encoded map of string keys and string values.
│ 
│ If the error is unclear, the output can be viewed by enabling Terraform's logging at TRACE level. Terraform documentation
│ on logging: https://www.terraform.io/internals/debugging
│ 
│ Program: /usr/bin/bash
│ Result Error: json: cannot unmarshal number into Go value of type string

またエラー。
エラーを見るとnumberをstringにできないとある。
いろいろ試行錯誤の末、以下の出力の通りportがnumberになっているのがエラーの原因で、externalでは「すべて文字列であること」が条件。

$ curl -sS https://ifconfig.io/all.json | python3 -m json.tool 
{
    "country_code": "JP",
    "encoding": "gzip, br",
    "forwarded": "*.*.*.*",
    "host": "*.*.*.*",
    "ifconfig_cmd_hostname": "ifconfig.io",
    "ifconfig_hostname": "ifconfig.io",
    "ip": "*.*.*.*",
    "lang": "",
    "method": "GET",
    "mime": "*/*",
    "port": 23814,
    "referer": "",
    "ua": "curl/8.5.0"
}

まぁこの文字列であるという条件のことも、エラーとドキュメントにも書いてあるんだけどね。

The JSON object contains the contents of the query argument and its values will always be strings.

https://registry.terraform.io/providers/hashicorp/external/latest/docs/data-sources/external#external-program-protocol

数値型を除外してエラー回避

試しに以下の内容でportの出力を除外して実行すると期待通りに動作する。

data "external" "src_ip" {
  program = ["bash", "-c", "curl https://ifconfig.io/all.json | python3 -m json.tool | grep -v port"]
}

さすがにこれだと不格好すぎるなので、もう少しスマートに書き直す。
ここからはもはやTerraform関係なく単にLinuxコマンドの文字列制御だけど。

エスケープがどうしても多くなるけど基本コマンドで書くなら、/ipでなく/ip.jsonにするとクォートを付けてくれるのでそれを使って

program = ["bash", "-c", "echo {\\\"ip\\\":$(curl ifconfig.io/ip.json)}"]

もしくは、jqが使えるならこんな感じで

program = ["bash", "-c", "curl -sS https://ifconfig.io/all.json | jq '{ip}'"]

あるいはjqでエラーになるnumber型であるportを除外

program = ["bash", "-c", "curl -sS https://ifconfig.io/all.json | jq 'del(.port)'"]

あまりスマートじゃないけど、-nで入力なしの実行からIPアドレスJSON形式の出力を作成するのでも動く。

program = ["bash", "-c", "jq -n \".ip = $(curl -s https://ifconfig.io/ip.json)\""]

セキュリティグループ設定

データソースで取得した結果は、.resultで参照できる。
前述のようにキーipであれば、.result.ipになる。

セキュリティグループのIPアドレスに使用するならこんな感じ。

resource "aws_security_group" "sg" {
  name   = ...
  vpc_id = ...

  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["${data.external.src_ip.result.ip}/32"]
  }

  ...
}

補足

使用にあたってはexternalプロバイダーが必要なので、初回であればterraform initでプロバイダー更新を行う。

╷
│ Error: Inconsistent dependency lock file
│ 
│ The following dependency selections recorded in the lock file are inconsistent with the current configuration:
│   - provider registry.terraform.io/hashicorp/external: required by this configuration but no version is selected
│ 
│ To update the locked dependency selections to match a changed configuration, run:
│   terraform init -upgrade
╵

ifconfig.ioのレスポンスを得るだけなら

あとから検索したら実はhttpデータソースがあるようなので、これなら「HTTPアクセスして結果を得る」ところまでをTerraformにさせることができるみたい。

まとめ

  • コマンドの実行結果をデータソースにするにはexternalを使うよ
  • コマンドの実行結果はJSON形式になってる必要があるよ
  • JSON内のデータの型はすべてstringである必要があるよ
  • curlを使ったHTTPアクセスの結果をどうにかしたい場合はhttpというデータソースもあるよ
  • エラーとドキュメントよく読もうね