Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: Documentation command #1009

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
74 changes: 74 additions & 0 deletions docs/documentation.md
Original file line number Diff line number Diff line change
@@ -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 <[email protected]>
# 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"}
```
34 changes: 34 additions & 0 deletions document/document.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package document

import (
"fmt"
"io"
)

// GenerateDocument generated a documentation file for a given module by parting
// A single page is generated for the module located in the indicated directory this includes the package subpackages
// and rules of the provided path, if you want to split the documentation.
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, WithTemplate(tpl))
}

err = RenderDocument(out, sec, opt...)
if err != nil {
return fmt.Errorf("rendering document: %w", err)
}

return nil
}
101 changes: 101 additions & 0 deletions document/metadata.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package document

import (
"fmt"
"os"
"path/filepath"
"strings"

"github.com/open-policy-agent/opa/ast"
"github.com/open-policy-agent/opa/loader"
)

// 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("filter rego files: %w", err)
}

if _, err := result.Compiler(); err != nil {
return nil, fmt.Errorf("compile: %w", err)
}

compiler := ast.NewCompiler()
compiler.Compile(result.ParsedModules())
as := compiler.GetAnnotationSet().Flatten()

return as, nil
}

type Section struct {
H string
Path string
Annotations *ast.Annotations
}

func (s Section) Equal(s2 Section) bool {
if s.H == s2.H &&
s.Path == s2.Path &&
s.Annotations.Title == s2.Annotations.Title {
return true
}

return false
}

// ConvertAnnotationsToSections generate 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 as a doc,
// but this is not guarantied. I couldn't find a way to call strings.Repeat inside go-template, this the title key is
// directly provided as markdown (#, ##, ###, etc.)
// Second the attribute Path of ast.Annotations are not easy to used on go-template, thus we extract it as a string
func ConvertAnnotationsToSections(as ast.FlatAnnotationsRefSet) ([]Section, error) {

var s []Section
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.")

s = append(s, Section{
H: h,
Path: path,
Annotations: entry.Annotations,
})
}

return s, nil
}
Loading
Loading