終了コードが 0 だったので、パイプラインは何週間も緑のままだった
目標
本物のOpenTofuの計画をJSONとして取り出して構造を読み、そのJSONにポリシーをかけて、クラスターやクラウドに届く前に、破壊・置き換え・抜けているタグをつかまえます。そして、違反を見つけても0で終了するツールの上で、信頼できるゲートを作ります。
なぜ重要なのか
アドミッションコントロールは、すでに作られたリクエストを見ます。ところが、データベースの置き換えやバケットの公開のような危険な変更は、Kubernetes APIを通らず、クラウドにまっすぐ行きます。それらの変更には、共通点が1つあります。適用前に計画の段階があり、その計画は、JSONとして取り出せる構造化されたドキュメントだということです。ここにポリシーをかければ、元に戻すものがまだないときに、ブロックできます。ただし、計画には、適用してみないと決まらない値が混ざっているため、その場所を知らずにルールを書くと、誤検知が大量に出て、ゲートは数日でオフになります。そして、判定をツールの終了コードに任せると、違反を見つけても0で終了するツール1つのせいで、パイプラインが何週間も、黙ってグリーンになることがあります。そのため、このラボは、ルールの書き方と同じくらい、「何を根拠に判定するのか」を扱います。
ステップ
/root/tfpolicy/main.tfに、local・random・nullのプロバイダーを要求し、リソースを4つ宣言してください。null_resource.apiのtriggersはowner = "platform"・env = "dev"、null_resource.workerのtriggersはowner = "data"・env = "dev"、local_file.configは、${path.module}/out/config.txtにv1の1行(末尾に改行)を書き、terraform_data.releaseは、inputがv1です。tofu initとtofu apply -auto-approveで適用した後、変更が1つも残っていない計画を/root/tfpolicy/base.tfplanとして保存し、tofu show -jsonで/root/tfpolicy/base.jsonを作成してください。main.tfを次のように修正してください。null_resource.workerブロックを削除し、random_pet.suffix(length = 2)を追加し、null_resource.cacheを追加します(triggersはowner = ""・env = "dev"・name = random_pet.suffix.id)。local_file.configの内容はv2の1行に、terraform_data.releaseのinputはv2に変更します。適用しないでください。計画を/root/tfpolicy/change.tfplanとして保存し、/root/tfpolicy/change.jsonを作成した後、/root/tfpolicy/changes.txtに、no-opではないリソースごとに、<주소> <create|update|replace|delete>(プレースホルダーはアドレスと、create、update、replace、deleteのいずれかです)を1行ずつ書いてください。削除と作成が1つのリソースに一緒に入っている場合は、replaceの1行として書きます。/root/tfpolicy/change.jsonのresource_changes[].change.after_unknownを読んで、/root/tfpolicy/unknown.txtを作成してください。不明なリーフ(値がtrueの場所)が1つでもあるリソースごとに、<주소> <모르는 잎 개수>(プレースホルダーはアドレスと、不明なリーフの数です)を1行ずつ書きます。triggers.nameのように入れ子になった場所も、リーフ1つとして数えます。不明なリーフがないリソースは、書きません。行の順序は見ません。/root/tfpolicy/policies/block.yamlに、apiVersion: json.kyverno.io/v1alpha1・kind: ValidatingPolicyのポリシーを書いてください。ルールを1つ置き、change.actionsにdeleteが含まれているリソースが1つもない状態で通過するようにします(単純な削除と置き換えが、どちらも引っかかります)。そのあと、KYVERNO_EXPERIMENTAL=true kyverno json scan --payload /root/tfpolicy/change.json --policy /root/tfpolicy/policies/block.yamlを実行して、その出力の全体と終了コードを/root/tfpolicy/scan-exit.txtに保存してください。終了コードは、exit=<코드>(プレースホルダーはコードです)の形で、最後に1行追記します。/root/tfpolicy/base.jsonにも同じスキャンを実行して、通過するのを目で確認してください。/root/tfpolicy/gate.sh <계획JSON>(プレースホルダーは計画のJSONです)を作成してください。policies/block.yamlでスキャンを実行して、レポートをパースします。違反の行(FAILEDまたはERROR:が入っている行)ごとに、先頭にBLOCKを付けて1行ずつ出力し、最後の行にRESULT block=<위반 줄 수>(プレースホルダーは違反の行数です)を出力してください(後ろに語がさらに付いてもかまいません)。違反がなければ0、1つでもあれば0ではない値で終了します。レポートに判定の行(PASSED・FAILED・ERROR:)が1つもなければ、ツールの出力が変わったということなので2で終了し、引数として受け取ったファイルがないときも、2で終了します。採点ツールは、自分が作ったきれいな計画と、違反の計画で、このスクリプトを実行します。/root/tfpolicy/policies/block.yamlに、2つ目のルールを追加してください。change.after.triggersがあるリソースは、change.after.triggers.ownerが、存在し、空文字列でない場合にのみ通過します。ただし、その値が計画の段階でまだ判明していないリソース(change.after_unknown.triggers.ownerがtrue)は、違反として数えません。ルールを追加した後、./gate.sh /root/tfpolicy/change.jsonをもう一度実行して、違反が2行になるか確認してください。採点ツールは、ownerが空の計画、ownerがまだ不明な値の計画、きれいな計画の3つで、このルールをテストします。/root/tfpolicy/policies/warn.yamlに、2つ目のポリシーファイルを作成してください。ルールを1つ置き、計画JSONの最上位のresource_driftが空であれば通過するようにします(キー自体がない計画も通過する必要があります)。そして、/root/tfpolicy/gate.shを修正して、2つのポリシーを別々に実行するようにしてください。block.yamlの違反は、BLOCKを付けて出力し、終了コードを0以外にし、warn.yamlの違反は、WARNを付けて出力しますが、終了コードは変えません。最後の行は、RESULT block=<차단 위반 수> warn=<경고 위반 수>(プレースホルダーは順に、ブロックする違反の数と、警告の違反の数です)に変更します。警告ポリシーのレポートにも、判定の行が1つもなければ、2で終了します。/root/tfpolicy/out/config.txtを、Terraformの外から直接修正してください(例:printf 'hand-edited\n' > /root/tfpolicy/out/config.txt)。そのあと、計画を/root/tfpolicy/drift.tfplanとして保存し、/root/tfpolicy/drift.jsonを作成してください。最上位にresource_driftができます。最後に、base.json・change.json・drift.jsonの3つの計画に./gate.shを実行して、出力をそれぞれ/root/tfpolicy/reports/base.txt・/root/tfpolicy/reports/change.txt・/root/tfpolicy/reports/drift.txtに保存し、各ファイルの最後の行に、EXIT <게이트 종료 코드>(プレースホルダーはゲートの終了コードです)を追記してください。採点ツールは、3つの計画にゲートをもう一度実行して、レポートと照合します。
参考
- 現場では、ConftestやOPA(Rego)で計画JSONを検査する場所が多いですが、このPodにはconftest・opaがありません。その代わり、kyverno CLI 1.13.2の
kyverno json scanで、同じことをします。任意のJSONをペイロードとして受け取って、ポリシーで判定する構造は同じで、学ぶこと(計画JSONの形、不明な値、判定の根拠)も同じです。 - スキャン:
KYVERNO_EXPERIMENTAL=true kyverno json scan --payload <계획JSON> --policy <정책YAML>(プレースホルダーは計画のJSONとポリシーのYAMLです)。環境変数を抜かすと、実験的機能なので、コマンド自体が拒否されます。 - ポリシーの形式:
apiVersion: json.kyverno.io/v1alpha1・kind: ValidatingPolicy・spec.rules[].assert.all[].check。checkのキーは、括弧で囲んだJMESPath式で、値が期待値です。 - 式だけを別にテストするには、
kyverno jp query -i <파일> '<식>'(プレースホルダーはファイルと式です)を使ってください。JMESPathのJSONリテラルは、バッククォートです。 - Podには、OpenTofu 1.9.0が
tofuとして入っていて、ミラーにはlocal・random・null・tlsのプロバイダーしかありません。クラウドのプロバイダーはないので、タグに当たる場所は、null_resourceのtriggersマップで代用します。 - よくあるミス:
if kyverno json scan ...; thenで判定してしまうことです。このツールは、違反があっても0で終了します。ステップ4で、自分で出力して確認します。 - よくあるミス: 違反が0件であることと、レポートを1行も読めなかったことを区別しないことです。ツールをバージョンアップした日に、ゲートが黙ってオフになります。
- 採点ツールが読まない中間成果物:
/root/tfpolicy/base.tfplan・change.tfplan・drift.tfplan(保存された計画ファイル)と/root/tfpolicy/out/config.txt。 - tofu show · tofu plan · 計画JSONの形式 · kyverno-json · kyverno-json assert · kyverno CLIのjsonコマンド · Conftest · JMESPathの仕様
計画をJSONとして取り出しておく
/root/tfpolicy/main.tfに、local・random・nullのプロバイダーを要求し、リソースを4つ宣言してください。null_resource.apiのtriggersはowner = "platform"・env = "dev"、null_resource.workerのtriggersはowner = "data"・env = "dev"、local_file.configは、${path.module}/out/config.txtにv1の1行(末尾に改行)を書き、terraform_data.releaseは、inputがv1です。tofu initとtofu apply -auto-approveで適用した後、変更が1つも残っていない計画を/root/tfpolicy/base.tfplanとして保存し、tofu show -jsonで/root/tfpolicy/base.jsonを作成してください。
local・random・nullは、Pod内のミラーから取得できるので、インターネットがなくてもinitができます。terraform_dataは、プロバイダーなしで使える内蔵のリソースです。適用を終えた直後に作った計画は、すべてのリソースのactionsがno-opです。ポリシーをテストするときに使う、きれいなペイロードが、まさにこれです。計画ファイルは-outで保存し、JSONは、そのファイルをtofu show -jsonに入れて得ます。
1つの計画に、4つの動作が一度に入ってくる
main.tfを次のように修正してください。null_resource.workerブロックを削除し、random_pet.suffix(length = 2)を追加し、null_resource.cacheを追加します(triggersはowner = ""・env = "dev"・name = random_pet.suffix.id)。local_file.configの内容はv2の1行に、terraform_data.releaseのinputはv2に変更します。適用しないでください。計画を/root/tfpolicy/change.tfplanとして保存し、/root/tfpolicy/change.jsonを作成した後、/root/tfpolicy/changes.txtに、no-opではないリソースごとに、<주소> <create|update|replace|delete>(プレースホルダーはアドレスと、create、update、replace、deleteのいずれかです)を1行ずつ書いてください。削除と作成が1つのリソースに一緒に入っている場合は、replaceの1行として書きます。
tofu show -jsonの結果のresource_changes[]には、addressとchange.actionsがあります。actionsが2要素の配列なら、置き換えです。追加1つと削除1つとして、2回数えると間違いです。どの属性が置き換えを引き起こすかは、プロバイダーが決めるので、計画の出力の# forces replacementの表示でも確認できます。行の順序は、採点では見ません。
計画がまだ知らない値を数えておく
/root/tfpolicy/change.jsonのresource_changes[].change.after_unknownを読んで、/root/tfpolicy/unknown.txtを作成してください。不明なリーフ(値がtrueの場所)が1つでもあるリソースごとに、<주소> <모르는 잎 개수>(プレースホルダーはアドレスと、不明なリーフの数です)を1行ずつ書きます。triggers.nameのように入れ子になった場所も、リーフ1つとして数えます。不明なリーフがないリソースは、書きません。行の順序は見ません。
after_unknownは、afterと同じ構造を持ち、不明なリーフだけをtrueとして残し、わかっているリーフはそもそも抜けているオブジェクトです。jqのpaths(조건)(プレースホルダーは条件です)は、条件を満たす値のパスをすべて出力します。[paths(. == true)] | lengthで、リーフを数えられます。ポリシーを書くときに、この場所が重要な理由は、afterに値がないからといって、すぐに違反と断定すると、誤検知が出るからです。
ルールをデータとして書いて、終了コードを出力してみる
/root/tfpolicy/policies/block.yamlに、apiVersion: json.kyverno.io/v1alpha1・kind: ValidatingPolicyのポリシーを書いてください。ルールを1つ置き、change.actionsにdeleteが含まれているリソースが1つもない状態で通過するようにします(単純な削除と置き換えが、どちらも引っかかります)。そのあと、KYVERNO_EXPERIMENTAL=true kyverno json scan --payload /root/tfpolicy/change.json --policy /root/tfpolicy/policies/block.yamlを実行して、その出力の全体と終了コードを/root/tfpolicy/scan-exit.txtに保存してください。終了コードは、exit=<코드>(プレースホルダーはコードです)の形で、最後に1行追記します。/root/tfpolicy/base.jsonにも同じスキャンを実行して、通過するのを目で確認してください。
kyverno-jsonのassert.checkは、キーがJMESPath式で、値が期待値です。括弧で囲んだキー(식)(プレースホルダーは式です)に、期待値0を置くと、「その式の結果が0でなければならない」になります。配列の中に特定の文字列があるかどうかは、contains(배열, '값')(プレースホルダーは配列と値です)で尋ねます。式を別にテストしてみたいときは、kyverno jp query -i <파일> '<식>'(プレースホルダーはファイルと式です)を使ってください。出力と終了コードを一緒に入れるときは、명령 > 파일 2>&1; echo "exit=$?" >> 파일(プレースホルダーはコマンドとファイルです)の形が楽です。
終了コードを捨てて、レポートを数える
/root/tfpolicy/gate.sh <계획JSON>(プレースホルダーは計画のJSONです)を作成してください。policies/block.yamlでスキャンを実行して、レポートをパースします。違反の行(FAILEDまたはERROR:が入っている行)ごとに、先頭にBLOCK を付けて1行ずつ出力し、最後の行にRESULT block=<위반 줄 수>(プレースホルダーは違反の行数です)を出力してください(後ろに語がさらに付いてもかまいません)。違反がなければ0、1つでもあれば0ではない値で終了します。レポートに判定の行(PASSED・FAILED・ERROR:)が1つもなければ、ツールの出力が変わったということなので2で終了し、引数として受け取ったファイルがないときも、2で終了します。採点ツールは、自分が作ったきれいな計画と、違反の計画で、このスクリプトを実行します。
このステップの核心は、if kyverno json scan ...; thenを使わないことです。そのツールは、違反を見つけても0で終了します。出力を変数に入れて、grep -Eで数え、数えた値が0かどうかで終了します。違反が0件という判定と、「何も読めなかった」という判定を、必ず分ける必要があります。ツールをバージョンアップすると、前者に見える後者が生まれます。set -eは、grepが何も見つけられなかったときに、スクリプトが先に死ぬので、使わないでください。
タグを要求したら、まだ不明な値まで引っかかった
/root/tfpolicy/policies/block.yamlに、2つ目のルールを追加してください。change.after.triggersがあるリソースは、change.after.triggers.ownerが、存在し、空文字列でない場合にのみ通過します。ただし、その値が計画の段階でまだ判明していないリソース(change.after_unknown.triggers.ownerがtrue)は、違反として数えません。ルールを追加した後、./gate.sh /root/tfpolicy/change.jsonをもう一度実行して、違反が2行になるか確認してください。採点ツールは、ownerが空の計画、ownerがまだ不明な値の計画、きれいな計画の3つで、このルールをテストします。
JMESPathのフィルターでは、JSONリテラルをバッククォートで書きます。`true`・`null`です。ないキーを読むとnullが出るので、「キーがない」と「空文字列である」の両方を尋ねる必要があります。そして、after_unknownを見ないと、値をデータソースから計算するモジュールがまるごと違反として出て、数日でゲートがオフになります。ルール1つをまるごとテストするには、kyverno jp query -i <계획JSON> '<식>'(プレースホルダーは計画のJSONと式です)が最も速いです。
あるルールはブロックし、あるルールは知らせるだけにする
/root/tfpolicy/policies/warn.yamlに、2つ目のポリシーファイルを作成してください。ルールを1つ置き、計画JSONの最上位のresource_driftが空であれば通過するようにします(キー自体がない計画も通過する必要があります)。そして、/root/tfpolicy/gate.shを修正して、2つのポリシーを別々に実行するようにしてください。block.yamlの違反は、BLOCK を付けて出力し、終了コードを0以外にし、warn.yamlの違反は、WARN を付けて出力しますが、終了コードは変えません。最後の行は、RESULT block=<차단 위반 수> warn=<경고 위반 수>(プレースホルダーは順に、ブロックする違反の数と、警告の違反の数です)に変更します。警告ポリシーのレポートにも、判定の行が1つもなければ、2で終了します。
resource_driftは、コードの外で誰かが手で変更したものを、計画が知らせてくれる場所です。キーがまったくない計画でlength()を呼ぶとエラーになるので、resource_drift || []``のように、デフォルト値を与えるほうが安全です。ゲート側は、スキャンを2回実行して、数えた値を別々に持ち、終了コードは、ブロック側の数字だけを見て決めます。現場で新しいルールをオンにする順序も、これです。警告で数日数えてみて、引っかかるものが整理された後に、ブロックに移します。
手で直したファイル1つが、ドリフトとして見つかる
/root/tfpolicy/out/config.txtを、Terraformの外から直接修正してください(例: printf 'hand-edited\n' > /root/tfpolicy/out/config.txt)。そのあと、計画を/root/tfpolicy/drift.tfplanとして保存し、/root/tfpolicy/drift.jsonを作成してください。最上位にresource_driftができます。最後に、base.json・change.json・drift.jsonの3つの計画に./gate.shを実行して、出力をそれぞれ/root/tfpolicy/reports/base.txt・/root/tfpolicy/reports/change.txt・/root/tfpolicy/reports/drift.txtに保存し、各ファイルの最後の行に、EXIT <게이트 종료 코드>(プレースホルダーはゲートの終了コードです)を追記してください。採点ツールは、3つの計画にゲートをもう一度実行して、レポートと照合します。
ドリフトは、ステートファイルが記憶している値と実際の値がずれたもので、計画を作るときのリフレッシュで表に出ます。レポートを作るときに、ゲートの終了コードは、リダイレクトの直後に$?で、すぐに受け取る必要があります。間に別のコマンドが1つでも入ると、そのコマンドのコードが取られます。3つのレポートのRESULT行が、互いに違う結果になるのが、このラボの結論です。きれい・ブロック・警告が、1つのゲートで分かれます。