1クール続けるブログ

とりあえず1クール続けるソフトウェアエンジニアの備忘録

conftest学んで試してみた

記事一覧はこちら

要約

conftestというOPAのツールを使って、k8sのdeploymentをテストしてみた話です。 OPAとは?というところから、その中で利用するRego言語についても軽くまとめてみました。

Conftestとは

構造化された設定データに対してテストを書くのに役立つツールとのことです。

例えば、KubernetesマニフェストやTerraformのコードをターゲットにテストを書くことが可能なようです。僕は配信で他社さんが使っていることを発表していて知りました。

Conftestはポリシーの宣言にOpen Policy AgentのRego言語を利用します。

Open Policy Agentとは

OPAはスタック全体でのポリシー施行を統一する、OSSのポリシーエンジンとのこと(参考)。発音は"oh-pa"になる。 OPAはポリシーをコードで書ける宣言型言語とポリシーの決定をソフトウェアからオフロードするシンプルなAPIを提供してくれるもの。

例えば、マイクロサービスかつpolyglotで実装していると、色んな言語でそれぞれポリシーを書かなきゃいけなくなるけど、それを避けられるというという認識を僕はしました。

下記の図は公式サイトより引用させていただきました。

https://d33wubrfki0l68.cloudfront.net/b394f524e15a67457b85fdfeed02ff3f2764eb9e/6ac2b/docs/latest/images/opa-service.svg

OPAの実行方法はとしては下記のようなものがある(参考)。

Conftestの go.mod を見る限り、ConftestはOPAのGoのライブラリをよんでポリシーの評価を行っているようです。

OPA Document Modelについて

外部からOPAにデータをロードされる全てのデータを Base Document と呼びます。また、OPAではルールによって生成された値を Virtual Document と呼びます。 これら2つはRegoで、同じ dot/bracket-style で参照することができます。 data というグローバル変数を使って両方にアクセスすることが出来ます。

Base Document はOPAの外部から来るものなので、 data 配下のどこに置かれるかどうかは読み込むソフトウェアによって制御されます。その一方で、 Virtual Documentdata 配下の位置は package 宣言によって制御されます。

ポリシー決定のためにOPAに問い合わせを行う際には、非同期 / 同期の2つの方法があります。 conftestは後者です。同期的にpushされた Base Document は "input" というグローバル変数の下にぶら下がります。ポリシーではこれらにアクセスすることができます。

Rego Languageとは

公式リファレンスはこちらプレイグラウンドも用意されている。

Datalogという言語にインスパイアされた言語らしいです。Datalogを拡張してJSONのような構造化ドキュメントをサポートします。

Scalar値

主に複数の場所から参照される定数を定義するために利用する構文です Strings, numbers, booleans, null値をサポートするそうな。バッククォートで囲めば、escape sequencesが解釈されない生の文字列として扱うことが可能です

greeting     := "Hello"
raw_greeting := `Hello\t`
max_height   := 42
pi           := 3.14159
allowed      := true
location     := null

複合値

こちらも定数定義で利用されることが主な構文。 ObjectはKey-Value形式で、Keyはどの型も許容する。 SetもRegoはサポートする。なお、JSONとして出力された際には、配列として表現される(JSONにSetの概念が無いため)。

# Object
point_p := {"x": 14, "y": 4, "z": 27}
port := {80: "http", 443: "https"}      # port[80] のようにフィールドにアクセス
# Set
s := {point_p.x, point_p.y, point.z}
s == {4, 27, 14}    # true 順序が違ってもOK!

変数

ルールの先頭に現れる変数(例えば deny[msg]msg )は、ルールの入力と出力を兼ねます。Regoでは多くのプログラミング言語と違い、入力と出力を同時に持ちます。 値がバインドされていない変数の場合は出力となるし、そうでなければ出力になります。

参照

参照はネストされたドキュメントで利用されます。 一般的には"dot-access"で表現しますが、canonicalなものとしてはdotを排除したPythonのdictionary lookupに近い形になるそうな どちらの形式も有効ですが、下記4つのケースではcanonical formを利用する必要があります。

  • [a-z], [A-Z], [0-9], _ (underscore) 以外の文字を含む文字列キー
  • numbers, booleans, null などの文字列以外のキー
  • 変数キー
  • 複合キー
# dot-access
sites[0].servers[1].hostname
# canonical form
sites[0]["servers"][1]["hostname"]

# variable key
# indexがi,jとバインディングされつつ、ループしてすべての要素を列挙する
sites[i].servers[j].hostname
sites[_].servers[_].hostname   # i, jを利用する必要がない場合には _ を利用する

# composite key
s[[1, 2]]

Module

Regoにおいて、policyはModuleの内部で宣言されるものです。 Moduleは以下の3つから構成されるものです。

  • 1つのPackage宣言
  • 0個以上のImport文
  • 0個以上のRule定義

Packageは1個以上のModuleで定義されたRuleを特定の名前空間にグループ化します。 同じPackageのモジュールは同じディレクトリに配置しなくてもよい。 Moduleで定義されたRuleは自動的にエクスポートされる。

Import文はModuleがPackage外で定義されたドキュメントへの依存関係を宣言するものです。これでエクスポートされた識別子を参照することができるようになる。 全てのModuleは data input ドキュメントの暗黙のImport文を含みます。

OPA Document Modelで述べましたが、 data に他のModuleはぶら下がることになりますので、 package servers と宣言したModuleにアクセスする時のimport文は下記のようになります。

package opa.examples

import data.servers

http_servers[server] {
    server := servers[_]
    server.protocols[_] == "http"
}

Operators

結構大事なのに、ドキュメントの下の方にあって拾えてなくて苦労した項目 こちらを参照ください ベストプラクティスとしては、なるべくassignmentとcomparisonを利用したほうが良いとのことです

# assignment(:=) はローカル変数の宣言
x := 100 

# comparison(==) は比較
x == 100

# Unification(:=) はassignmentとcomparisonの組み合わせで、比較を真にする値を代入します。
input.kind = "Service"

Rule

ルールを評価するときにOPAは全ての式が真になるかどうかを見ます 下記のように理解することができます

expression-1 AND expression-2 AND ... AND expression-N

Ruleは同名で定義することが可能で、その場合の評価は下記のように理解できます

<rule-1> OR <rule-2> OR ... OR <rule-N>

インストール方法

https://www.conftest.dev/install/#brew

# Macの場合
$ brew tap instrumenta/instrumenta                                                                                                               
$ brew install conftest
$ conftest -v                                                                                                                                    
Version: 0.23.0
Commit: 6190ded
Date: 2021-01-09T10:26:15Z

# DockerHubにもある(Officialマークは無いけど)
# https://hub.docker.com/r/openpolicyagent/conftest
docker pull penpolicyagent/conftest

実行方法

デフォルトではconftestコマンドの実行されたディレクトリ直下ののpolicyディレクトリを見に行きます(該当コード)。さらに.regoファイルの中で、デフォルトではmainパッケージのruleを見に来ます(該当コード)。これは—-policyフラグと--namespaceフラグで上書きすることができます。

$ pwd
.../conftest/examples/kubernetes
$ ll                                                                                             
total 24
-rw-r--r--  1 44smkn  staff   546B  2 12 11:42 deployment+service.yaml
-rw-r--r--  1 44smkn  staff   576B  2 12 11:42 deployment.yaml
drwxr-xr-x  8 44smkn  staff   256B  2 12 11:42 policy
-rw-r--r--  1 44smkn  staff   172B  2 12 11:42 service.yaml
$ conftest test deployment.yaml                                                                  
FAIL - deployment.yaml - main - Containers must not run as root in Deployment hello-kubernetes
FAIL - deployment.yaml - main - Deployment hello-kubernetes must provide app/release labels for pod selectors
FAIL - deployment.yaml - main - hello-kubernetes must include Kubernetes recommended labels: https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/#labels
FAIL - deployment.yaml - main - Found deployment hello-kubernetes but deployments are not allowed

5 tests, 1 passed, 0 warnings, 4 failures, 0 exceptions

Conftestが認識するルール

Conftestが探しに行くルールは、deny, violation, warn です(該当コード)。それぞれのルールには、suffixにunderscoreとidentifierをくっつけることができます。例えば、deny_myrule のように記述できる。

violationdenyルールと同じように評価されるが、文字列だけでなく構造体のエラーを返すことができる。

ConftestでKubernetesマニフェストをテストする

試しにk8sマニフェストに自分なりのポリシーを用意してテストしてみようと思います。k8sマニフェストはkustomizeを利用して定義します。

kustomize build | conftest test - のようにパイプで渡してもconftestを流すことができます(該当コード

Deploymentにポリシーを設定してみる

github.com

policyは下記のような感じ

package main

name := input.metadata.name

# deployment かつ Root以外のユーザで起動していなければ 拒否する
# not -> https://www.openpolicyagent.org/docs/latest/policy-language/#negation
deny[msg] {
    input.kind == "Deployment"
    not input.spec.template.spec.securityContext.runAsNonRoot

    msg := sprintf("Containers must not run as root in Deployment %s", [name])
}

# deployment かつ タグ付けされていないコンテナイメージを利用している場合は拒否する
deny[msg] {
    input.kind == "Deployment"
    image := input.spec.template.spec.containers[_].image
    not contains(image, ":")

    msg := sprintf("You must use tagged container images in Deployment %s", [name])
}

コマンドで実行するとこう

$ kustomize build overlays/production | conftest test -                                             
FAIL - - main - Containers must not run as root in Deployment production-prod-sbdemo
FAIL - - main - You must use tagged container images in Deployment production-prod-sbdemo

6 tests, 4 passed, 0 warnings, 2 failures, 0 exceptions

想定通りになりました!Regoは結構深ぼっていくと面白そうですね(ちょっと理解するのに時間がかかりました)。
以上!