diff --git a/docs/functions.md b/docs/functions.md index 833bc28..e584057 100644 --- a/docs/functions.md +++ b/docs/functions.md @@ -671,6 +671,54 @@ terraform.checks({"assert": {"condition": "bool"}}, {}) ] ``` +## `terraform.removed_blocks` + +```rego +blocks := terraform.removed_blocks(schema, options) +``` + +Returns Terraform removed blocks. + +- `schema` (schema): schema for attributes referenced in rules. +- `options` (object[string: string]): options to change the retrieve/evaluate behavior. + +Returns: + +- `blocks` (array[object]): Terraform "removed" blocks. + +The `schema` and `options` are equivalent to the arguments of the `terraform.resources` function. + +Examples: + +```hcl +removed { + from = aws_instance.example + + lifecycle { + destroy = false + } +} +``` + +```rego +terraform.removed_blocks({"from": "any"}, {}) +``` + +```json +[ + { + "config": { + "from": { + "unknown": true, + "sensitive": false, + "range": {...} + } + }, + "decl_range": {...} + } +] +``` + ## `terraform.module_range` ```rego diff --git a/integration/integration_test.go b/integration/integration_test.go index e36dac1..43807d9 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -225,6 +225,17 @@ func TestIntegration(t *testing.T) { dir: "checks", test: true, }, + { + name: "removed blocks", + command: exec.Command("tflint", "--format", "json", "--force"), + dir: "removed", + }, + { + name: "removed blocks (test)", + command: exec.Command("tflint", "--format", "json", "--force"), + dir: "removed", + test: true, + }, } dir, _ := os.Getwd() diff --git a/integration/removed/.tflint.hcl b/integration/removed/.tflint.hcl new file mode 100644 index 0000000..3d0b8cc --- /dev/null +++ b/integration/removed/.tflint.hcl @@ -0,0 +1,9 @@ +plugin "terraform" { + enabled = false +} + +plugin "opa" { + enabled = true + + policy_dir = "policies" +} diff --git a/integration/removed/main.tf b/integration/removed/main.tf new file mode 100644 index 0000000..1de5276 --- /dev/null +++ b/integration/removed/main.tf @@ -0,0 +1,7 @@ +removed { + from = aws_instance.example + + lifecycle { + destroy = false + } +} diff --git a/integration/removed/policies/main.rego b/integration/removed/policies/main.rego new file mode 100644 index 0000000..22c6e4b --- /dev/null +++ b/integration/removed/policies/main.rego @@ -0,0 +1,8 @@ +package tflint + +deny_removed_blocks[issue] { + moved := terraform.removed_blocks({}, {}) + count(moved) > 0 + + issue := tflint.issue("removed blocks are not allowed", moved[0].decl_range) +} diff --git a/integration/removed/policies/main_test.rego b/integration/removed/policies/main_test.rego new file mode 100644 index 0000000..4f89cd3 --- /dev/null +++ b/integration/removed/policies/main_test.rego @@ -0,0 +1,25 @@ +package tflint +import future.keywords + +mock_removed_blocks(schema, options) := terraform.mock_removed_blocks(schema, options, {"main.tf": ` +removed { + from = aws_instance.example + + lifecycle { + destroy = false + } +}`}) + +test_deny_removed_blocks_passed if { + issues := deny_removed_blocks with terraform.removed_blocks as mock_removed_blocks + + count(issues) == 1 + issue := issues[_] + issue.msg == "removed blocks are not allowed" +} + +test_deny_removed_blocks_failed if { + issues := deny_removed_blocks with terraform.removed_blocks as mock_removed_blocks + + count(issues) == 0 +} diff --git a/integration/removed/result.json b/integration/removed/result.json new file mode 100644 index 0000000..aa30e72 --- /dev/null +++ b/integration/removed/result.json @@ -0,0 +1,25 @@ +{ + "issues": [ + { + "rule": { + "name": "opa_deny_removed_blocks", + "severity": "error", + "link": "policies/main.rego:3" + }, + "message": "removed blocks are not allowed", + "range": { + "filename": "main.tf", + "start": { + "line": 1, + "column": 1 + }, + "end": { + "line": 1, + "column": 8 + } + }, + "callers": [] + } + ], + "errors": [] +} diff --git a/integration/removed/result_test.json b/integration/removed/result_test.json new file mode 100644 index 0000000..8a48ec6 --- /dev/null +++ b/integration/removed/result_test.json @@ -0,0 +1,25 @@ +{ + "issues": [ + { + "rule": { + "name": "opa_test_deny_removed_blocks_failed", + "severity": "error", + "link": "policies/main_test.rego:21" + }, + "message": "test failed", + "range": { + "filename": "", + "start": { + "line": 0, + "column": 0 + }, + "end": { + "line": 0, + "column": 0 + } + }, + "callers": [] + } + ], + "errors": [] +} diff --git a/opa/functions.go b/opa/functions.go index 8c11fb2..a64d45a 100644 --- a/opa/functions.go +++ b/opa/functions.go @@ -29,6 +29,7 @@ func Functions(runner tflint.Runner) []func(*rego.Rego) { movedBlocksFunc(runner).asOption(), importsFunc(runner).asOption(), checksFunc(runner).asOption(), + removedBlocksFunc(runner).asOption(), moduleRangeFunc(runner).asOption(), issueFunc().asOption(), } @@ -48,6 +49,7 @@ func TesterFunctions(runner tflint.Runner) []*tester.Builtin { movedBlocksFunc(runner).asTester(), importsFunc(runner).asTester(), checksFunc(runner).asTester(), + removedBlocksFunc(runner).asTester(), moduleRangeFunc(runner).asTester(), issueFunc().asTester(), } @@ -69,6 +71,7 @@ func MockFunctions() []func(*rego.Rego) { mockFunction2(movedBlocksFunc).asOption(), mockFunction2(importsFunc).asOption(), mockFunction2(checksFunc).asOption(), + mockFunction2(removedBlocksFunc).asOption(), } } @@ -86,6 +89,7 @@ func TesterMockFunctions() []*tester.Builtin { mockFunction2(movedBlocksFunc).asTester(), mockFunction2(importsFunc).asTester(), mockFunction2(checksFunc).asTester(), + mockFunction2(removedBlocksFunc).asTester(), } } @@ -529,6 +533,35 @@ func checksFunc(runner tflint.Runner) *function2 { } } +// terraform.removed_blocks: blocks := terraform.removed_blocks(schema, options) +// +// Returns Terraform removed blocks. +// +// schema (schema) schema for attributes referenced in rules. +// options (options) options to change the retrieve/evaluate behavior. +// +// Returns: +// +// blocks (array[block]) Terraform "removed" blocks +func removedBlocksFunc(runner tflint.Runner) *function2 { + return &function2{ + function: function{ + Decl: ®o.Function{ + Name: "terraform.removed_blocks", + Decl: types.NewFunction( + types.Args(schemaTy, optionsTy), + types.NewArray(nil, blockTy), + ), + Memoize: true, + Nondeterministic: true, + }, + }, + Func: func(_ rego.BuiltinContext, schema *ast.Term, options *ast.Term) (*ast.Term, error) { + return blockFunc(schema, options, "removed", runner) + }, + } +} + // terraform.module_range: range := terraform.module_range() // // Returns a range for the current Terraform module. diff --git a/opa/functions_test.go b/opa/functions_test.go index b79f1d2..5e57124 100644 --- a/opa/functions_test.go +++ b/opa/functions_test.go @@ -1426,6 +1426,109 @@ check "health_check" { } } +func TestRemovedBlocksFunc(t *testing.T) { + tests := []struct { + name string + config string + schema map[string]any + options map[string]string + want []map[string]any + }{ + { + name: "removed block", + config: ` +removed { + from = var.foo +} + +variable "foo" {}`, + schema: map[string]any{"from": "any"}, + want: []map[string]any{ + { + "config": map[string]any{ + "from": map[string]any{ + "unknown": true, + "sensitive": false, + "range": map[string]any{ + "filename": "main.tf", + "start": map[string]int{ + "line": 3, + "column": 9, + "byte": 19, + }, + "end": map[string]int{ + "line": 3, + "column": 16, + "byte": 26, + }, + }, + }, + }, + "decl_range": map[string]any{ + "filename": "main.tf", + "start": map[string]int{ + "line": 2, + "column": 1, + "byte": 1, + }, + "end": map[string]int{ + "line": 2, + "column": 8, + "byte": 8, + }, + }, + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + schema, err := ast.InterfaceToValue(test.schema) + if err != nil { + t.Fatal(err) + } + options, err := ast.InterfaceToValue(test.options) + if err != nil { + t.Fatal(err) + } + config, err := ast.InterfaceToValue(map[string]string{"main.tf": test.config}) + if err != nil { + t.Fatal(err) + } + want, err := ast.InterfaceToValue(test.want) + if err != nil { + t.Fatal(err) + } + + runner, diags := NewTestRunner(map[string]string{"main.tf": test.config}) + if diags.HasErrors() { + t.Fatal(diags) + } + + ctx := rego.BuiltinContext{} + got, err := removedBlocksFunc(runner).Func(ctx, ast.NewTerm(schema), ast.NewTerm(options)) + if err != nil { + t.Fatal(err) + } + + if diff := cmp.Diff(want.String(), got.Value.String()); diff != "" { + t.Error(diff) + } + + ctx = rego.BuiltinContext{} + got, err = mockFunction2(removedBlocksFunc).Func(ctx, ast.NewTerm(schema), ast.NewTerm(options), ast.NewTerm(config)) + if err != nil { + t.Fatal(err) + } + + if diff := cmp.Diff(want.String(), got.Value.String()); diff != "" { + t.Error(diff) + } + }) + } +} + func TestModuleRangeFunc(t *testing.T) { tests := []struct { name string