profile-image

masatora.net

OPA・Regoでコーディング規約が守られている事を検査する

OPA
Rego
Go
CICD
GitHub Actions

概要

チームやプロジェクトで独自に定めたコーディング規約が遵守されていることを確認するために、OPA・Regoを使ってコードを検査する。Golangでの実装を想定している。

仕組み

まず、検査対象のコードをASTに変換する。ASTは、抽象構文木のことで、コードの構造を表現したデータ構造のこと。ASTを使うことで、コードの構造をプログラムで扱えるようになる。 Regoで記述したコーディング規約をポリシーとして、ASTをインプットとしてOPAに渡すことで、コーディング規約に違反している箇所を検出することができる。

goast

上記を簡単に実現するために、goastというツールを使用する。goastはユーザが定義したポリシーをもとに上記をまとめて行ってくれる。出力した結果にposという名前でPos値を渡すことで、出力に違反しているコードを表示することもできる。

res := {
    "msg": "fmt.Println is not allowed",
    "pos": input.Node.X.Fun.X.NamePos,
    "sev": "ERROR"
}

実装

goastを使って以下のようなルールを記述してみる。

1. メソッドのレシーバは1文字とする

  • 検査対象のGoコード(main.go)
package main

func main() {}

type Test struct{}

func (t Test) Valid() {}

func (tt Test) Invalid() {}
  • ポリシー(policy.rego)
# メソッドのレシーバ名は1文字とする
fail[res] {
    input.Kind == "FuncDecl"
    input.Node.Recv != null
    count(input.Node.Recv.List[x].Names[y].Name) != 1

    res := {
        "msg": "method receiver name should be 1 character",
        "pos": input.Node.Recv.List[x].Names[y].NamePos,
        "sev": "ERROR"
    }
}
  • 実行結果
$ goast eval -p ./policy.rego main.go
[main.go:9] - method receiver name should be 1 character

func (tt Test) Invalid() {}
      ~~~~~~~~~~~~~~~~~~~~~


        Detected 1 violations
  • テスト(policy_test.rego)をテーブル駆動テスト風に記述してみる
package goast

test_policy {
    not _test_policy
}

_test_policy {
    testCases := [
        {
            "name": "レシーバがない",
            "wantErr": false,
            "data": {
                  "Path": "main.go",
                  "FileName": "main.go",
                  "DirName": ".",
                  "Node": {
                    "Doc": null,
                    "Recv": null,
                    "Name": {
                      "NamePos": 20,
                      "Name": "main",
                      "Obj": null
                    },
                    "Type": {
                      "Func": 15,
                      "TypeParams": null,
                      "Params": {
                        "Opening": 24,
                        "List": [],
                        "Closing": 25
                      },
                      "Results": null
                    },
                    "Body": {
                      "Lbrace": 27,
                      "List": [],
                      "Rbrace": 28
                    }
                  },
                  "Kind": "FuncDecl"
            }
        },
        {
            "name": "レシーバが1文字",
            "wantErr": false,
            "data": {
                  "Path": "main.go",
                  "FileName": "main.go",
                  "DirName": ".",
                  "Node": {
                    "Doc": null,
                    "Recv": {
                      "Opening": 56,
                      "List": [
                        {
                          "Doc": null,
                          "Names": [
                            {
                              "NamePos": 57,
                              "Name": "t",
                              "Obj": null
                            }
                          ],
                          "Type": {
                            "NamePos": 59,
                            "Name": "Test",
                            "Obj": null
                          },
                          "Tag": null,
                          "Comment": null
                        }
                      ],
                      "Closing": 63
                    },
                    "Name": {
                      "NamePos": 65,
                      "Name": "Valid",
                      "Obj": null
                    },
                    "Type": {
                      "Func": 51,
                      "TypeParams": null,
                      "Params": {
                        "Opening": 70,
                        "List": [],
                        "Closing": 71
                      },
                      "Results": null
                    },
                    "Body": {
                      "Lbrace": 73,
                      "List": [],
                      "Rbrace": 74
                    }
                  },
                  "Kind": "FuncDecl"
            }
        },
        {
            "name": "レシーバが2文字",
            "wantErr": true,
            "data": {
                  "Path": "main.go",
                  "FileName": "main.go",
                  "DirName": ".",
                  "Node": {
                    "Doc": null,
                    "Recv": {
                      "Opening": 106,
                      "List": [
                        {
                          "Doc": null,
                          "Names": [
                            {
                              "NamePos": 107,
                              "Name": "tt",
                              "Obj": null
                            }
                          ],
                          "Type": {
                            "NamePos": 110,
                            "Name": "Test",
                            "Obj": null
                          },
                          "Tag": null,
                          "Comment": null
                        }
                      ],
                      "Closing": 114
                    },
                    "Name": {
                      "NamePos": 116,
                      "Name": "Invalid",
                      "Obj": null
                    },
                    "Type": {
                      "Func": 101,
                      "TypeParams": null,
                      "Params": {
                        "Opening": 123,
                        "List": [],
                        "Closing": 124
                      },
                      "Results": null
                    },
                    "Body": {
                      "Lbrace": 126,
                      "List": [],
                      "Rbrace": 127
                    }
                  },
                  "Kind": "FuncDecl"
            }
        }
    ]

    tc := testCases[_]
    out := fail with input as tc.data
    tc.wantErr != (count(out) > 0)
}

実行

$ opa test -v .
policy_test.rego:
data.goast.test_policy: PASS (687.958µs)
--------------------------------------------------------------------------------
PASS: 1/1

2. Publicな関数にはコメントをつける

  • Goコード
package main

func main() {}

// Valid is valid
func Valid()   {}
func valid()   {}
func Invalid() {}
  • ポリシー
# Publicな関数にはコメントをつける
fail[res] {
    input.Kind == "FuncDecl"
    is_uppercase(substring(input.Node.Name.Name, 0, 1))
    input.Node.Doc == null

    res := {
        "msg": "public function should have comment",
        "pos": input.Node.Name.NamePos,
        "sev": "ERROR"
    }
}

is_uppercase(str) {
    str == upper(str)
}
  • 実行
goast eval -p ./policy.rego main.go
[main.go:8] - public function should have comment

func Invalid() {}
     ~~~~~~~~~~~~


        Detected 1 violations

3. handler pkg以外がhttp pkgに依存することを禁止する

  • ディレクトリ構成
internal
├── handler
│   └── handler.go
└── service
    └── service.go

3 directories, 2 files
  • handler.go
package handler

import "net/http"

func handler() {
	_, _ = http.Get("http://example.com/")
}
  • service.go
package service

import "net/http"

func service() {
	_, _ = http.Get("http://example.com/")
}
  • ポリシー
# handler pkg以外がhttp pkgに依存することを禁止する
fail[res] {
    input.Kind == "File"
    input.DirName != "internal/handler"
    input.Node.Imports[x].Path.Value == "\"net/http\""

    res := {
        "msg": "handler pkg should not depend on http pkg",
        "pos": input.Node.Imports[x].Path.ValuePos,
        "sev": "ERROR"
    }
}
  • 実行結果
$ goast eval -p ./policy.rego internal
[internal/service/service.go:3] - handler pkg should not depend on http pkg

import "net/http"
       ~~~~~~~~~~


        Detected 1 violations

参考