Skip to content

Commit

Permalink
Scaffolding
Browse files Browse the repository at this point in the history
Add `regal new rule` command to allow quickly getting started working
with custom rules, or builtin rules for Regal itself. See added docs for
more details.

Fixes #206

Signed-off-by: Anders Eknert <anders@styra.com>
  • Loading branch information
anderseknert committed Aug 2, 2023
1 parent 52e8d5d commit 494e504
Show file tree
Hide file tree
Showing 12 changed files with 419 additions and 7 deletions.
4 changes: 2 additions & 2 deletions bundle/regal/result.rego
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ _fail_annotated(metadata, details) := violation if {
"level": config.rule_level(config.for_rule(with_location)),
})

without_custom_and_scope := object.remove(with_category, ["custom", "scope"])
without_custom_and_scope := object.remove(with_category, ["custom", "scope", "schemas"])
related_resources := resource_urls(without_custom_and_scope.related_resources, category)

violation := json.patch(
Expand All @@ -80,7 +80,7 @@ _fail_annotated_custom(metadata, details) := violation if {
"level": config.rule_level(config.for_rule(with_location)),
})

violation := object.remove(with_category, ["custom", "scope"])
violation := object.remove(with_category, ["custom", "scope", "schemas"])
}

fail(metadata, details) := _fail_annotated(metadata, details)
Expand Down
245 changes: 245 additions & 0 deletions cmd/new.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
// nolint:wrapcheck
package cmd

import (
"fmt"
"log"
"os"
"path/filepath"
"regexp"
"strings"
"text/template"

"github.com/spf13/cobra"

"github.com/styrainc/regal/internal/embeds"
)

// The revive check will warn about using underscore in struct names, but it's seemingly not aware of keywords.
//
//nolint:revive
type newRuleCommandParams struct {
type_ string // 'type' is a keyword
category string
name string
output string
}

type TemplateValues struct {
Category string
NameOriginal string
Name string
NameTest string
}

var (
categoryRegex = regexp.MustCompile(`^[a-z]+$`)
nameRegex = regexp.MustCompile(`^[a-z_]+[a-z0-9_\-]*$`)
)

//nolint:lll
func init() {
newCommand := &cobra.Command{
Hidden: true,
Use: "new <template>",
Long: `Create a new resource according to the chosen template (currently only 'rule' available).
The new command is a development utility for scaffolding new resources for use by Regal.
An example of such a resource would be new linter rules, which could be created either for inclusion in Regal core, or custom rules for your organization or team.`,
}

params := newRuleCommandParams{}

newRuleCommand := &cobra.Command{
Use: "rule [-t type] [-c category] [-n name]",
Short: "Create new rule from template",
Long: `Create a new linter rule, for inclusion in Regal or a custom rule for your organization or team.
Example:
regal new rule --type custom --category naming --name camel-case`,

PreRunE: func(cmd *cobra.Command, args []string) error {
if params.type_ != "custom" && params.type_ != "builtin" {
return fmt.Errorf("type must be 'custom' or 'builtin', got %v", params.type_)
}

if params.category == "" {
return fmt.Errorf("category is required for rule")
}

if !categoryRegex.MatchString(params.category) {
return fmt.Errorf("category must be a single word, using lowercase letters only")
}

if params.name == "" {
return fmt.Errorf("name is required for rule")
}

if !nameRegex.MatchString(params.name) {
return fmt.Errorf("name must consist only of lowercase letters, numbers, underscores and dashes")
}

return nil
},

Run: func(_ *cobra.Command, args []string) {
if err := scaffoldRule(params); err != nil {
log.SetOutput(os.Stderr)
log.Println(err)
os.Exit(1)
}
},
}

newRuleCommand.Flags().StringVarP(&params.type_, "type", "t", "custom", "type of rule (custom or builtin)")
newRuleCommand.Flags().StringVarP(&params.category, "category", "c", "", "category for rule")
newRuleCommand.Flags().StringVarP(&params.name, "name", "n", "", "name of rule")
newRuleCommand.Flags().StringVarP(&params.output, "output", "o", "", "output directory")

newCommand.AddCommand(newRuleCommand)
RootCommand.AddCommand(newCommand)
}

func scaffoldRule(params newRuleCommandParams) error {
if params.output == "" {
params.output = mustGetWd()
}

if params.type_ == "custom" {
return scaffoldCustomRule(params)
}

if params.type_ == "builtin" {
return scaffoldBuiltinRule(params)
}

return fmt.Errorf("unsupported type %v", params.type_)
}

func scaffoldCustomRule(params newRuleCommandParams) error {
rulesDir := filepath.Join(params.output, ".regal", "rules", params.category)

if err := os.MkdirAll(rulesDir, 0o770); err != nil {
return err
}

ruleTmpl, err := template.ParseFS(embeds.EmbedTemplatesFS, "templates/custom/custom.rego.tpl")
if err != nil {
return err
}

ruleFileName := strings.ToLower(strings.ReplaceAll(params.name, "-", "_")) + ".rego"

ruleFile, err := os.Create(filepath.Join(rulesDir, ruleFileName))
if err != nil {
return err
}

err = ruleTmpl.Execute(ruleFile, templateValues(params))
if err != nil {
return err
}

testTmpl, err := template.ParseFS(embeds.EmbedTemplatesFS, "templates/custom/custom_test.rego.tpl")
if err != nil {
return err
}

testFileName := strings.ToLower(strings.ReplaceAll(params.name, "-", "_")) + "_test.rego"

testFile, err := os.Create(filepath.Join(rulesDir, testFileName))
if err != nil {
return err
}

err = testTmpl.Execute(testFile, templateValues(params))
if err != nil {
return err
}

log.Printf("Created custom rule %q in %s\n", params.name, rulesDir)

return nil
}

func scaffoldBuiltinRule(params newRuleCommandParams) error {
rulesDir := filepath.Join(params.output, "bundle", "regal", "rules", params.category)

if err := os.MkdirAll(rulesDir, 0o770); err != nil {
return err
}

ruleTmpl, err := template.ParseFS(embeds.EmbedTemplatesFS, "templates/builtin/builtin.rego.tpl")
if err != nil {
return err
}

ruleFileName := strings.ToLower(strings.ReplaceAll(params.name, "-", "_")) + ".rego"

ruleFile, err := os.Create(filepath.Join(rulesDir, ruleFileName))
if err != nil {
return err
}

err = ruleTmpl.Execute(ruleFile, templateValues(params))
if err != nil {
return err
}

testTmpl, err := template.ParseFS(embeds.EmbedTemplatesFS, "templates/builtin/builtin_test.rego.tpl")
if err != nil {
return err
}

testFileName := strings.ToLower(strings.ReplaceAll(params.name, "-", "_")) + "_test.rego"

testFile, err := os.Create(filepath.Join(rulesDir, testFileName))
if err != nil {
return err
}

err = testTmpl.Execute(testFile, templateValues(params))
if err != nil {
return err
}

log.Printf("Created builtin rule %q in %s\n", params.name, rulesDir)

return nil
}

func templateValues(params newRuleCommandParams) TemplateValues {
var tmplNameValue string

if strings.Contains(params.name, "-") {
tmplNameValue = `["` + params.name + `"]`
} else {
tmplNameValue = "." + params.name
}

var tmplNameTestValue string

if strings.Contains(params.name, "-") {
tmplNameTestValue = `["` + params.name + `_test"]`
} else {
tmplNameTestValue = "." + params.name + "_test"
}

return TemplateValues{
Category: params.category,
NameOriginal: params.name,
Name: tmplNameValue,
NameTest: tmplNameTestValue,
}
}

func mustGetWd() string {
wd, err := os.Getwd()
if err != nil {
log.Fatal(err)
}

return wd
}
20 changes: 20 additions & 0 deletions docs/custom-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,26 @@ linter rules might then look something like this:
If you so prefer, custom rules may also be provided using the `--rules` option for `regal lint`, which may point either
to a Rego file, or a directory containing Rego files and potentially data (JSON or YAML).

## Creating a New Rule

The simplest way to create a new rule is to use the `regal new rule` command. This command provides scaffolding for
quickly creating a new rule, including a file for testing. The command has two required arguments: `--category` and
`--name`, which should be self-explanatory. To create a new custom rule:

```shell
regal new rule --category naming --name foo-bar-baz
```

This will create a `.regal/rules` directory in the current working directory, if one does not already exist, and place
a directory named after `--category` in it, where it will place a policy for the rule, and another one to test it. If
you'd rather create this directory structure in some other place than the current working directory, you may use the
`--output` flag to specify a different location. The generated rule includes a simple example, which can be verified by
running `regal test .regal/rules/${category}`. Modify the rule and the test to suit your needs!

If you'd like to create a new built-in rule for submitting a PR in Regal, you may add the `--type builtin` flag to the
command (the default is `custom`). This will create a similar scaffolding under `bundle/regal/rules` in the Regal
repository.

## Developing Rules

Regal rules works primarily on the [abstract syntax tree](https://en.wikipedia.org/wiki/Abstract_syntax_tree) (AST) as
Expand Down
50 changes: 50 additions & 0 deletions e2e/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,56 @@ func TestTestRegalTestWithExtendedASTTypeChecking(t *testing.T) {
}
}

func TestCreateNewCustomRuleFromTemplate(t *testing.T) {
t.Parallel()

stdout := bytes.Buffer{}
stderr := bytes.Buffer{}

tmpDir := t.TempDir()

err := regal(&stdout, &stderr)("new", "rule", "--category", "naming", "--name", "foo-bar-baz", "--output", tmpDir)

if exp, act := 0, ExitStatus(err); exp != act {
t.Errorf("expected exit status %d, got %d", exp, act)
}

err = regal(&stdout, &stderr)("test", tmpDir)

if exp, act := 0, ExitStatus(err); exp != act {
t.Errorf("expected exit status %d, got %d", exp, act)
}

if strings.HasPrefix(stdout.String(), "PASS 1/1") {
t.Errorf("expected stdout to contain PASS 1/1, got %q", stdout.String())
}
}

func TestCreateNewBuiltinRuleFromTemplate(t *testing.T) {
t.Parallel()

stdout := bytes.Buffer{}
stderr := bytes.Buffer{}

tmpDir := t.TempDir()

err := regal(&stdout, &stderr)("new", "rule", "--category", "naming", "--name", "foo-bar-baz", "--output", tmpDir)

if exp, act := 0, ExitStatus(err); exp != act {
t.Errorf("expected exit status %d, got %d", exp, act)
}

err = regal(&stdout, &stderr)("test", tmpDir)

if exp, act := 0, ExitStatus(err); exp != act {
t.Errorf("expected exit status %d, got %d", exp, act)
}

if strings.HasPrefix(stdout.String(), "PASS 1/1") {
t.Errorf("expected stdout to contain PASS 1/1, got %q", stdout.String())
}
}

func binary() string {
if b := os.Getenv("REGAL_BIN"); b != "" {
return b
Expand Down
4 changes: 4 additions & 0 deletions internal/embeds/embeds.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,8 @@ import "embed"

var EmbedBundleFS embed.FS

//go:embed templates
var EmbedTemplatesFS embed.FS

//go:embed schemas/regal-ast.json
var ASTSchema []byte
File renamed without changes.
20 changes: 20 additions & 0 deletions internal/embeds/templates/builtin/builtin.rego.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# METADATA
# description: Add description of rule here!
package regal.rules.{{.Category}}{{.Name}}

import future.keywords.contains
import future.keywords.if
import future.keywords.in

import data.regal.result

report contains violation if {
# Or change to imports, packages, comments, etc.
some rule in input.rules
# Deny any rule named foo, bar, or baz. This is just an example!
# Add your own rule logic here.
rule.head.name in {"foo", "bar", "baz"}

violation := result.fail(rego.metadata.chain(), result.location(rule))
}
Loading

0 comments on commit 494e504

Please sign in to comment.