diff --git a/.gitignore b/.gitignore index a8da11a32a..e22f49dd5a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,8 @@ site node_modules/ package.json package-lock.json + +# ignore generated doc in tests +/tests/document/*.md +# ignore prospective golden files +/document/testdata/doc/*.golden diff --git a/docs/documentation.md b/docs/documentation.md new file mode 100644 index 0000000000..b9d93ef2cc --- /dev/null +++ b/docs/documentation.md @@ -0,0 +1,74 @@ +# Generate Policy Documentations + +## Document your policies + +OPA has introduced a standard way to document policies called [Metadata](https://www.openpolicyagent.org/docs/latest/policy-language/#metadata). +This format allows for structured in code documentation of policies. + +```opa +# METADATA +# title: My rule +# description: A rule that determines if x is allowed. +# authors: +# - John Doe +# entrypoint: true +allow if { + ... +} +``` + +For the generated documentation to make sense your `packages` should be documented with at least the `title` field +and `rules` should have both `title` and `description`. This will ensure that no section is empty in your +documentations. + +## Generate the documentation + +In code documentation is great but what we often want it to later generated an actual static reference documentation. +The `doc` command will retrieve all annotation of a targeted module and generate a markdown documentation for it. + +```bash +conftest doc path/to/policy +``` + +## Use your own template + +You can override the [default template](../document/resources/document.md) with your own template + +```aiignore +conftest -t template.md path/tp/policies +``` + +All annotation are returned as a sorted list of all annotations, grouped by the path and location of their targeted +package or rule. For instance using this template + +```bash +{{ range . -}} +{{ .Path }} has annotations {{ .Annotations }} +{{ end -}} +``` + +for the following module + +```yaml +# METADATA +# scope: subpackages +# organizations: +# - Acme Corp. +package foo +--- +# METADATA +# description: A couple of useful rules +package foo.bar + +# METADATA +# title: My Rule P +p := 7 +``` + +You will obtain the following rendered documentation: + +```bash +data.foo has annotations {"organizations":["Acme Corp."],"scope":"subpackages"} +data.foo.bar has annotations {"description":"A couple of useful rules","scope":"package"} +data.foo.bar.p has annotations {"scope":"rule","title":"My Rule P"} +``` diff --git a/document/document.go b/document/document.go new file mode 100644 index 0000000000..12f0a1b189 --- /dev/null +++ b/document/document.go @@ -0,0 +1,34 @@ +package document + +import ( + "fmt" + "io" +) + +// GenerateDocument generate a documentation file for a given module +// A single page is generated for the module located in the indicated directory this includes all package, subpackages +// and rules of the provided path. +func GenerateDocument(dir string, tpl string, out io.Writer) error { + + as, err := ParseRegoWithAnnotations(dir) + if err != nil { + return fmt.Errorf("parse rego annotations: %w", err) + } + + sec, err := ConvertAnnotationsToSections(as) + if err != nil { + return fmt.Errorf("validating annotations: %w", err) + } + + var opt []RenderDocumentOption + if tpl != "" { + opt = append(opt, ExternalTemplate(tpl)) + } + + err = RenderDocument(out, sec, opt...) + if err != nil { + return fmt.Errorf("rendering document: %w", err) + } + + return nil +} diff --git a/document/metadata.go b/document/metadata.go new file mode 100644 index 0000000000..cd5b95e999 --- /dev/null +++ b/document/metadata.go @@ -0,0 +1,121 @@ +package document + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/open-policy-agent/opa/ast" + "github.com/open-policy-agent/opa/loader" +) + +var ( + ErrNoAnnotations = errors.New("no annotations found") +) + +// ParseRegoWithAnnotations parse the rego in the indicated directory +func ParseRegoWithAnnotations(directory string) (ast.FlatAnnotationsRefSet, error) { + // Recursively find all rego files (ignoring test files), starting at the given directory. + result, err := loader.NewFileLoader(). + WithProcessAnnotation(true). + Filtered([]string{directory}, func(_ string, info os.FileInfo, _ int) bool { + if strings.HasSuffix(info.Name(), "_test.rego") { + return true + } + + if !info.IsDir() && filepath.Ext(info.Name()) != ".rego" { + return true + } + + return false + }) + + if err != nil { + return nil, fmt.Errorf("load rego files: %w", err) + } + + compiler, err := result.Compiler() + if err != nil { + return nil, fmt.Errorf("compile: %w", err) + } + as := compiler.GetAnnotationSet().Flatten() + + if len(as) == 0 { + return nil, ErrNoAnnotations + } + + return as, nil +} + +// Document represent a page of the documentation +type Document []Section + +// Section is a sequential piece of documentation comprised of ast.Annotations and some pre-processed fields +// This struct exist because some fields of ast.Annotations are not easy to manipulate in go-template +type Section struct { + // RegoPackageName is the string representation of ast.Annotations.Path + RegoPackageName string + // Depth represent title depth for this section (h1, h2, h3, etc.). This values is derived from len(ast.Annotations.RegoPackageName) + // and smoothed such that subsequent section only defer by +/- 1 + Depth int + // MarkdownHeading represent the markdown title symbol #, ##, ###, etc. (produced by strings.Repeat("#", depth)) + MarkdownHeading string + // Annotations is the raw metada provided by OPA compiler + Annotations *ast.Annotations +} + +// Equal is only relevant for tests and assert that two sections are partially Equal +func (s Section) Equal(s2 Section) bool { + if s.MarkdownHeading == s2.MarkdownHeading && + s.RegoPackageName == s2.RegoPackageName && + s.Annotations.Title == s2.Annotations.Title { + return true + } + + return false +} + +// ConvertAnnotationsToSections generates a more convenient struct that can be used to generate the doc +// First concern is to build a coherent title structure; the ideal case is that each package and each rule has a doc, +// but this is not guaranteed. I couldn't find a way to call `strings.Repeat` inside go-template; thus, the title symbol is +// directly provided as markdown (#, ##, ###, etc.) +// Second, the attribute RegoPackageName of ast.Annotations are not easy to use on go-template; thus, we extract it as a string +func ConvertAnnotationsToSections(as ast.FlatAnnotationsRefSet) (Document, error) { + + var d Document + var currentDepth = 0 + var offset = 1 + + for i, entry := range as { + // offset at least by one because all path starts with `data.` + depth := len(entry.Path) - offset + + // If the user is targeting a submodule we need to adjust the depth an offset base on the first annotation found + if i == 0 && depth > 1 { + offset = depth + } + + // We need to compensate for unexpected jump in depth + // otherwise we would start at h3 if no package documentation is present + // or jump form h2 to h4 unexpectedly in subpackages + if (depth - currentDepth) > 1 { + depth = currentDepth + 1 + } + + currentDepth = depth + + h := strings.Repeat("#", depth) + path := strings.TrimPrefix(entry.Path.String(), "data.") + + d = append(d, Section{ + Depth: depth, + MarkdownHeading: h, + RegoPackageName: path, + Annotations: entry.Annotations, + }) + } + + return d, nil +} diff --git a/document/metadata_test.go b/document/metadata_test.go new file mode 100644 index 0000000000..a200553fcc --- /dev/null +++ b/document/metadata_test.go @@ -0,0 +1,269 @@ +package document + +import ( + "errors" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/open-policy-agent/opa/ast" +) + +func getTestModules(t *testing.T, modules [][]string) ast.FlatAnnotationsRefSet { + t.Helper() + + parsed := make([]*ast.Module, 0, len(modules)) + for _, entry := range modules { + pm, err := ast.ParseModuleWithOpts(entry[0], entry[1], ast.ParserOptions{ProcessAnnotation: true}) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + parsed = append(parsed, pm) + } + + as, err := ast.BuildAnnotationSet(parsed) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + return as.Flatten() +} + +// PartialEqual asserts that two objects are equal, depending on what equal means +// For instance, you may pass options to ignore certain fields +// Also, if a struct exports an Equal func this will be used for the assertion +func PartialEqual(t *testing.T, expected, actual any, diffOpts cmp.Option) { + t.Helper() + + if cmp.Equal(expected, actual, diffOpts) { + return + } + + diff := cmp.Diff(expected, actual, diffOpts) + + t.Errorf("Not equal: \n"+ + "expected: %s\n"+ + "actual : %s%s", expected, actual, diff) +} + +func TestParseRegoWithAnnotations(t *testing.T) { + tests := []struct { + name string + directory string + // list of scope/title of the annotation you expect to see + want []string + wantErr error + }{ + { + name: "parse package and sub package", + directory: "testdata/foo", + want: []string{ + "data.foo", + "data.foo.a", + "data.foo.bar", + "data.foo.bar.p", + }, + }, { + name: "target subpackage", + directory: "testdata/foo/bar", + want: []string{ + "data.foo.bar", + "data.foo.bar.p", + }, + }, { + name: "target example awssam that as no annotations", + directory: "../examples/awssam", + want: []string{}, + wantErr: ErrNoAnnotations, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, gotErr := ParseRegoWithAnnotations(tt.directory) + if !errors.Is(gotErr, tt.wantErr) { + t.Errorf("GetAnnotations() error = %v, wantErr %v", gotErr, tt.wantErr) + return + } + + for i, want := range tt.want { + if got[i].Path.String() != want { + t.Errorf("got[%d]Path.String() = %v, want %v", i, tt.want[i], got[i].Path.String()) + } + } + }) + } +} + +func TestConvertAnnotationsToSection(t *testing.T) { + tests := []struct { + name string + modules [][]string + want Document + wantErr bool + }{ + { + name: "Single file", + modules: [][]string{ + {"foo.rego", ` +# METADATA +# title: My Package foo +package foo + +# METADATA +# title: My Rule P +p := 7 +`}, + }, + want: Document{ + { + MarkdownHeading: "#", + RegoPackageName: "foo", + Annotations: &ast.Annotations{ + Title: "My Package foo", + }, + }, + { + MarkdownHeading: "##", + RegoPackageName: "foo.p", + Annotations: &ast.Annotations{ + Title: "My Rule P", + }, + }, + }, + }, + { + name: "Single file of a subpackage", + modules: [][]string{ + {"foo/bar.rego", ` +# METADATA +# title: My Package bar +package foo.bar + +# METADATA +# title: My Rule P +p := 7 +`}, + }, + want: Document{ + { + MarkdownHeading: "#", + RegoPackageName: "foo.bar", + Annotations: &ast.Annotations{ + Title: "My Package bar", + }, + }, + { + MarkdownHeading: "##", + RegoPackageName: "foo.bar.p", + Annotations: &ast.Annotations{ + Title: "My Rule P", + }, + }, + }, + }, + { + name: "Single file, multiple rule and package metadata", + modules: [][]string{ + {"foo.rego", ` +# METADATA +# title: My Package foo +package foo + +# METADATA +# title: My Rule P +p := 7 + +# METADATA +# title: My Rule Q +q := 8 +`}, + }, + want: Document{ + { + MarkdownHeading: "#", + RegoPackageName: "foo", + Annotations: &ast.Annotations{ + Title: "My Package foo", + }, + }, + { + MarkdownHeading: "##", + RegoPackageName: "foo.p", + Annotations: &ast.Annotations{ + Title: "My Rule P", + }, + }, + { + MarkdownHeading: "##", + RegoPackageName: "foo.q", + Annotations: &ast.Annotations{ + Title: "My Rule Q", + }, + }, + }, + }, { + name: "Multiple file and subpackage", + modules: [][]string{ + {"foo.rego", ` +# METADATA +# title: My Package foo +package foo + +# METADATA +# title: My Rule P +p := 7 + +`}, + {"bar/bar.rego", ` +# METADATA +# title: My Package bar +package foo.bar + +# METADATA +# title: My Rule R +r := 9 + +`}, + }, + want: Document{ + { + MarkdownHeading: "#", + RegoPackageName: "foo", + Annotations: &ast.Annotations{ + Title: "My Package foo", + }, + }, + { + MarkdownHeading: "##", + RegoPackageName: "foo.bar", + Annotations: &ast.Annotations{ + Title: "My Package bar", + }, + }, { + MarkdownHeading: "###", + RegoPackageName: "foo.bar.r", + Annotations: &ast.Annotations{ + Title: "My Rule R", + }, + }, { + MarkdownHeading: "##", + RegoPackageName: "foo.p", + Annotations: &ast.Annotations{ + Title: "My Rule P", + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := getTestModules(t, tt.modules) + got, err := ConvertAnnotationsToSections(m) + if (err != nil) != tt.wantErr { + t.Errorf("ConvertAnnotationsToSections() error = %v, wantErr %v", err, tt.wantErr) + return + } + + PartialEqual(t, tt.want, got, nil) + }) + } +} diff --git a/document/resources/document.md b/document/resources/document.md new file mode 100644 index 0000000000..cf0a4f506b --- /dev/null +++ b/document/resources/document.md @@ -0,0 +1,15 @@ +{{ range . -}} +{{ .MarkdownHeading }} {{ .RegoPackageName }} - {{ .Annotations.Title }} + +{{ .Annotations.Description }} +{{ if .Annotations.RelatedResources }} +Related Resources: +{{ range .Annotations.RelatedResources }} +{{ if .Description -}} +* [{{.Description}}]({{ .Ref }}) +{{- else -}} +* {{ .Ref }} +{{- end -}} +{{ end }} +{{ end }} +{{ end -}} diff --git a/document/template.go b/document/template.go new file mode 100644 index 0000000000..baad7d1687 --- /dev/null +++ b/document/template.go @@ -0,0 +1,93 @@ +package document + +import ( + "embed" + "fmt" + "io" + "text/template" +) + +//go:embed resources/* +var resources embed.FS + +// TemplateKind helps us to select where to find the template. +// It can either be embedded or on the host filesystem +type TemplateKind int + +const ( + TemplateKindInternal TemplateKind = iota + TemplateKindExternal +) + +// TemplateConfig represent the location of the template file(s) +type TemplateConfig struct { + kind TemplateKind + path string +} + +func NewTemplateConfig() *TemplateConfig { + return &TemplateConfig{ + kind: TemplateKindInternal, + path: "resources/document.md", + } +} + +type RenderDocumentOption func(*TemplateConfig) + +// ExternalTemplate is a functional option to override the documentation template +// When overriding the template, we assume it is located on the host file system +func ExternalTemplate(tpl string) RenderDocumentOption { + return func(c *TemplateConfig) { + c.kind = TemplateKindExternal + c.path = tpl + } +} + +// RenderDocument takes a slice of Section and generate the markdown documentation either using the default +// embedded template or the user provided template +func RenderDocument(out io.Writer, d Document, opts ...RenderDocumentOption) error { + tpl := NewTemplateConfig() + + // Apply all the functional options to the template configurations + for _, opt := range opts { + opt(tpl) + } + + err := renderTemplate(tpl, d, out) + if err != nil { + return err + } + + return nil +} + +// renderTemplate is an utility function to use go-template it handles fetching the template file(s) +// whether they are embedded or on the host file system. +func renderTemplate(tpl *TemplateConfig, sections []Section, out io.Writer) error { + var t *template.Template + var err error + + switch tpl.kind { + case TemplateKindInternal: + // read the embedded template + t, err = template.ParseFS(resources, tpl.path) + if err != nil { + return err + } + case TemplateKindExternal: + t, err = template.ParseFiles(tpl.path) + if err != nil { + return err + } + default: + return fmt.Errorf("unknown template kind: %v", tpl.kind) + } + + // we render the template + err = t.Execute(out, sections) + if err != nil { + return err + } + + return nil +} diff --git a/document/template_test.go b/document/template_test.go new file mode 100644 index 0000000000..ff0a498cbc --- /dev/null +++ b/document/template_test.go @@ -0,0 +1,71 @@ +package document + +import ( + "bytes" + "os" + "testing" +) + +func TestRenderDocument(t *testing.T) { + tests := []struct { + name string + testdata string + Options []RenderDocumentOption + wantOut string + wantErr bool + }{ + { + name: "Nested packages", + testdata: "./testdata/foo", + wantOut: "./testdata/doc/foo.md", + wantErr: false, + }, { + name: "Nested packages", + testdata: "./testdata/foo", + wantOut: "./testdata/doc/foo.md", + Options: []RenderDocumentOption{ + ExternalTemplate("testdata/template.md"), + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + as, err := ParseRegoWithAnnotations(tt.testdata) + if (err != nil) != tt.wantErr { + t.Errorf("ParseRegoWithAnnotations() error = %v, wantErr %v", err, tt.wantErr) + } + + d, err := ConvertAnnotationsToSections(as) + if (err != nil) != tt.wantErr { + t.Errorf("ConvertAnnotationsToSections() error = %v, wantErr %v", err, tt.wantErr) + } + + gotOut := &bytes.Buffer{} + err = RenderDocument(gotOut, d, tt.Options...) + if (err != nil) != tt.wantErr { + t.Errorf("RenderDocument() error = %v, wantErr %v", err, tt.wantErr) + return + } + + wantOut, err := os.ReadFile(tt.wantOut) + if err != nil { + t.Errorf("unexpected test error: %v", err) + return + } + + if gotOut.String() != string(wantOut) { + t.Errorf("ReadFile() = %v, want %v", gotOut.String(), wantOut) + } + + // prospective golden file, much simpler to see what's the result in case the test fails + // this does not override the existing test, but create a new file called xxx.golden + err = os.WriteFile(tt.wantOut+".golden", gotOut.Bytes(), 0600) + if err != nil { + t.Errorf("unexpected test error: %v", err) + return + } + }, + ) + } +} diff --git a/document/testdata/doc/foo.md b/document/testdata/doc/foo.md new file mode 100644 index 0000000000..3fed3fa97e --- /dev/null +++ b/document/testdata/doc/foo.md @@ -0,0 +1,21 @@ +# foo - My package foo + +the package with rule A and subpackage bar + +## foo.a - My Rule A + +the rule A = 3 + +Related Resources: + +* https://example.com +* [Yet another link](https://example.com/more) + +## foo.bar - My package bar + +The package with rule P + +### foo.bar.p - My Rule P + +the Rule P = 7 + diff --git a/document/testdata/foo/bar/bizz.rego b/document/testdata/foo/bar/bizz.rego new file mode 100644 index 0000000000..dd3d82565b --- /dev/null +++ b/document/testdata/foo/bar/bizz.rego @@ -0,0 +1,9 @@ +# METADATA +# title: My package bar +# description: The package with rule P +package foo.bar + +# METADATA +# title: My Rule P +# description: the Rule P = 7 +p := 7 diff --git a/document/testdata/foo/base.rego b/document/testdata/foo/base.rego new file mode 100644 index 0000000000..ef6233daca --- /dev/null +++ b/document/testdata/foo/base.rego @@ -0,0 +1,16 @@ +# METADATA +# title: My package foo +# description: the package with rule A and subpackage bar +# scope: subpackages +# organizations: +# - Acme Corp. +package foo + +# METADATA +# title: My Rule A +# description: the rule A = 3 +# related_resources: +# - ref: https://example.com +# - ref: https://example.com/more +# description: Yet another link +a := 3 diff --git a/document/testdata/foo/base_test.rego b/document/testdata/foo/base_test.rego new file mode 100644 index 0000000000..12b12c4f22 --- /dev/null +++ b/document/testdata/foo/base_test.rego @@ -0,0 +1,5 @@ +package foo + +test_a_is_3 { + a == 3 +} diff --git a/document/testdata/template.md b/document/testdata/template.md new file mode 100644 index 0000000000..cf0a4f506b --- /dev/null +++ b/document/testdata/template.md @@ -0,0 +1,15 @@ +{{ range . -}} +{{ .MarkdownHeading }} {{ .RegoPackageName }} - {{ .Annotations.Title }} + +{{ .Annotations.Description }} +{{ if .Annotations.RelatedResources }} +Related Resources: +{{ range .Annotations.RelatedResources }} +{{ if .Description -}} +* [{{.Description}}]({{ .Ref }}) +{{- else -}} +* {{ .Ref }} +{{- end -}} +{{ end }} +{{ end }} +{{ end -}} diff --git a/internal/commands/default.go b/internal/commands/default.go index ae077f9231..11a8c5f027 100644 --- a/internal/commands/default.go +++ b/internal/commands/default.go @@ -60,6 +60,7 @@ func NewDefaultCommand() *cobra.Command { cmd.AddCommand(NewVerifyCommand(ctx)) cmd.AddCommand(NewPluginCommand(ctx)) cmd.AddCommand(NewFormatCommand()) + cmd.AddCommand(NewDocumentCommand()) plugins, err := plugin.FindAll() if err != nil { diff --git a/internal/commands/document.go b/internal/commands/document.go new file mode 100644 index 0000000000..92b48fd2a6 --- /dev/null +++ b/internal/commands/document.go @@ -0,0 +1,72 @@ +package commands + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/open-policy-agent/conftest/document" + "github.com/spf13/cobra" +) + +func NewDocumentCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "doc [path [...]]", + Short: "Generate documentation", + RunE: func(cmd *cobra.Command, dir []string) error { + if len(dir) < 1 { + err := cmd.Usage() + if err != nil { + return fmt.Errorf("usage: %s", err) + } + return fmt.Errorf("missing required arguments") + } + + for _, path := range dir { + fileInfo, err := os.Stat(path) + if err != nil { + return err + } + + if !fileInfo.IsDir() { + return fmt.Errorf("%s is not a directory", path) + } + + // Handle the output destination + outDir, err := cmd.Flags().GetString("outDir") + if err != nil { + return fmt.Errorf("invalid outDir: %s", err) + } + + name := filepath.Base(path) + if name == "." || name == ".." { + name = "policy" + } + + outPath := filepath.Join(outDir, name+".md") + f, err := os.OpenFile(outPath, os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return fmt.Errorf("opening %s for writing output: %w", outPath, err) + } + defer f.Close() //nolint // CLI is exiting anyway and there's not much we can do. + + template, err := cmd.Flags().GetString("template") + if err != nil { + return fmt.Errorf("invalid template: %s", err) + } + + err = document.GenerateDocument(path, template, f) + if err != nil { + return fmt.Errorf("generating document: %w", err) + } + } + + return nil + }, + } + + cmd.Flags().StringP("outDir", "o", ".", "Path to the output documentation file") + cmd.Flags().StringP("template", "t", "", "Go template for the document generation") + + return cmd +} diff --git a/tests/document/policy/base.rego b/tests/document/policy/base.rego new file mode 100644 index 0000000000..7dd97aaa11 --- /dev/null +++ b/tests/document/policy/base.rego @@ -0,0 +1,28 @@ +# METADATA +# title: Example using annotations +# description: This package validates that ... +package main + +import data.services + +name := input.metadata.name +kind := input.kind +type := input.spec.type + +# METADATA +# title: Example using annotations +# description: This rule validates that ... +# custom: +# template: 'Cannot expose port %v on LoadBalancer. Denied ports: %v' +deny[msg] { + kind == "Service" + type == "LoadBalancer" + + some p + input.spec.ports[p].port + + input.spec.ports[p].port == services.ports[_] + + metadata := rego.metadata.rule() + msg := sprintf(metadata.custom.template, [input.spec.ports[p].port, services.ports]) +} diff --git a/tests/document/policy/base_test.rego b/tests/document/policy/base_test.rego new file mode 100644 index 0000000000..1bafb44b62 --- /dev/null +++ b/tests/document/policy/base_test.rego @@ -0,0 +1,14 @@ +package main + +test_service_denied { + input := { + "kind": "Service", + "metadata": {"name": "sample"}, + "spec": { + "type": "LoadBalancer", + "ports": [{"port": 22}], + }, + } + + deny["Cannot expose port 22 on LoadBalancer. Denied ports: [22, 21]"] with input as input +} diff --git a/tests/document/policy/sub/bar.rego b/tests/document/policy/sub/bar.rego new file mode 100644 index 0000000000..7a5ef8c193 --- /dev/null +++ b/tests/document/policy/sub/bar.rego @@ -0,0 +1,49 @@ +# METADATA +# title: Example using annotations +# description: This package validates that ... +# custom: +# template: 'Cannot expose port %v on LoadBalancer. Denied ports: %v' +package main.sub + +import data.services + +name := input.metadata.name +kind := input.kind +type := input.spec.type + +# METADATA +# title: Example using annotations +# description: This rule validates that ... +# custom: +# template: 'Cannot expose port %v on LoadBalancer. Denied ports: %v' +deny[msg] { + kind == "Service" + type == "LoadBalancer" + + some p + input.spec.ports[p].port + + input.spec.ports[p].port == services.ports[_] + + metadata := rego.metadata.rule() + msg := sprintf(metadata.custom.template, [input.spec.ports[p].port, services.ports]) +} + +# METADATA +# title: Second Example using annotations +# description: This rule validates that ... +# custom: +# template: 'Cannot expose port %v on LoadBalancer. Denied ports: %v' +deny[msg] { + kind == "Service" + type == "LoadBalancer" + + some p + input.spec.ports[p].port + + input.spec.ports[p].port == services.ports[_] + + metadata := rego.metadata.rule() + msg := sprintf(metadata.custom.template, [input.spec.ports[p].port, services.ports]) +} + diff --git a/tests/document/template.md.tpl b/tests/document/template.md.tpl new file mode 100644 index 0000000000..e469e33e4f --- /dev/null +++ b/tests/document/template.md.tpl @@ -0,0 +1,3 @@ +{{ range . -}} +{{ .RegoPackageName }} has annotations {{ .Annotations }} +{{ end -}} diff --git a/tests/document/test.bats b/tests/document/test.bats new file mode 100644 index 0000000000..b8eda85a63 --- /dev/null +++ b/tests/document/test.bats @@ -0,0 +1,30 @@ +#!/usr/bin/env bats + +@test "Can document the policies" { + rm -f "policy.md" + run $CONFTEST doc ./policy + + [ "$status" -eq 0 ] + echo $output + [ -f "policy.md" ] +} + +@test "Can document the sub package" { + rm -f "sub.md" + run $CONFTEST doc ./policy/sub + + [ "$status" -eq 0 ] + echo $output + [ -f "sub.md" ] +} + +@test "Can document using custom template and output" { + rm -f "custom/policy.md" + mkdir -p "custom" + run $CONFTEST doc -t ./template.md.tpl -o ./custom ./policy + + [ "$status" -eq 0 ] + echo $output + [ -f "custom/policy.md" ] +} +