├── .github ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── docs.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .goreleaser.yml ├── LICENSE ├── Makefile ├── README.md ├── _examples ├── .policy │ ├── config.hcl │ ├── functions.hcl │ ├── rules.hcl │ └── variables.hcl ├── manifests │ ├── .policy │ │ ├── functions.hcl │ │ └── rules.hcl │ └── microservices │ │ ├── x-echo-jp │ │ └── development │ │ │ ├── Deployment │ │ │ ├── redis-master.yaml │ │ │ ├── test.yaml │ │ │ └── test.yml │ │ │ ├── PodDisruptionBudget │ │ │ └── pdb.yaml │ │ │ └── Service │ │ │ └── service.yaml │ │ └── x-gateway-jp │ │ └── development │ │ └── Deployment │ │ └── test.yaml └── spinnaker │ ├── .policy │ └── functions.hcl │ └── x-echo-jp │ └── development │ └── deploy-to-dev-v2.yaml ├── apply.go ├── docs ├── Dockerfile ├── commands │ ├── _index.md │ ├── apply.md │ └── fmt.md ├── concepts │ ├── policy-as-code.md │ └── policy.md ├── configuration │ ├── _index.md │ ├── load.md │ ├── policy │ │ ├── config.md │ │ ├── functions.md │ │ ├── rules.md │ │ └── variables.md │ └── syntax │ │ ├── _index.md │ │ ├── custom-functions.md │ │ ├── functions │ │ ├── color.md │ │ ├── exist.md │ │ ├── ext.md │ │ ├── glob.md │ │ ├── grep.md │ │ ├── jsonpath.md │ │ ├── lookuplist.md │ │ ├── match.md │ │ ├── pathshorten.md │ │ └── wc.md │ │ └── interpolation.md ├── index.md ├── intro │ ├── install.md │ ├── rules.md │ └── run.md └── requirements.txt ├── fmt.go ├── go.mod ├── go.sum ├── lint ├── args.go ├── args_test.go ├── hclconvert │ ├── blocktree.go │ └── convert.go ├── internal │ ├── policy │ │ ├── config.go │ │ ├── context.go │ │ ├── funcs │ │ │ ├── assertion.go │ │ │ ├── basic.go │ │ │ ├── collection.go │ │ │ ├── filepath.go │ │ │ ├── jsonpath.go │ │ │ ├── jsonpath_test.go │ │ │ ├── unix.go │ │ │ └── unix_test.go │ │ ├── loader │ │ │ └── loader.go │ │ ├── policy.go │ │ ├── rule.go │ │ ├── terraform │ │ │ └── functions.go │ │ └── variable.go │ └── topological │ │ ├── sort.go │ │ └── sort_test.go ├── lint.go ├── policy.go └── testdata │ ├── 01.tf │ ├── 02.tf │ ├── 03.tf │ └── 04.tf ├── main.go ├── mkdocs.yml ├── pkg └── logging │ └── logging.go └── scripts └── release.sh /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## WHAT 2 | 3 | (Write what you need) 4 | 5 | ## WHY 6 | 7 | (Write the background of this issue) 8 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## WHAT 2 | 3 | (Write the change being made with this pull request) 4 | 5 | ## WHY 6 | 7 | (Write the motivation why you submit this pull request) 8 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - 'mkdocs.yml' 9 | - 'docs/**' 10 | - '.github/workflows/docs.yml' 11 | 12 | jobs: 13 | deploy: 14 | runs-on: ubuntu-18.04 15 | steps: 16 | - uses: actions/checkout@v2 17 | 18 | - name: Setup Python 19 | uses: actions/setup-python@v1 20 | with: 21 | python-version: '3.6' 22 | architecture: 'x64' 23 | 24 | - name: Cache dependencies 25 | uses: actions/cache@v1 26 | with: 27 | path: ~/.cache/pip 28 | key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} 29 | restore-keys: | 30 | ${{ runner.os }}-pip- 31 | 32 | - name: Install dependencies 33 | run: | 34 | python3 -m pip install --upgrade pip 35 | python3 -m pip install -r ./docs/requirements.txt 36 | 37 | - run: mkdocs build 38 | 39 | - name: Deploy 40 | uses: peaceiris/actions-gh-pages@v3 41 | with: 42 | deploy_key: ${{ secrets.ACTIONS_DEPLOY_KEY }} 43 | publish_dir: ./site 44 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | tags: 5 | - "v[0-9]+.[0-9]+.[0-9]+" 6 | jobs: 7 | goreleaser: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v1 12 | with: 13 | fetch-depth: 1 14 | - name: Setup Go 15 | uses: actions/setup-go@v1 16 | with: 17 | go-version: 1.13 18 | - name: Run GoReleaser 19 | uses: goreleaser/goreleaser-action@v1 20 | with: 21 | version: latest 22 | args: release --rm-dist 23 | env: 24 | # To upload homebrew formula to other repos, 25 | # need to set the dedicated token having enough permissions 26 | # https://github.com/goreleaser/goreleaser/issues/982 27 | GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN }} 28 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | paths: 6 | - '**.go' 7 | - '.github/workflows/test.yml' 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Setup Go 14 | uses: actions/setup-go@v1 15 | with: 16 | go-version: 1.13 17 | - name: Checkout 18 | uses: actions/checkout@v2 19 | - name: Test 20 | run: go test -v -race ./... 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | stein 2 | dist/ 3 | site/ 4 | vendor/ 5 | *.swp 6 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | project_name: stein 2 | env: 3 | - GO111MODULE=on 4 | before: 5 | hooks: 6 | - go mod tidy 7 | builds: 8 | - main: . 9 | binary: stein 10 | ldflags: 11 | - -s -w 12 | - -X main.Version={{.Version}} 13 | - -X main.Revision={{.ShortCommit}} 14 | env: 15 | - CGO_ENABLED=0 16 | archives: 17 | - name_template: '{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}' 18 | replacements: 19 | darwin: darwin 20 | linux: linux 21 | windows: windows 22 | 386: i386 23 | amd64: x86_64 24 | format_overrides: 25 | - goos: windows 26 | format: zip 27 | release: 28 | prerelease: auto 29 | 30 | # https://goreleaser.com/customization/#Homebrew 31 | brews: 32 | - github: 33 | owner: b4b4r07 34 | name: homebrew-tap 35 | folder: Formula 36 | homepage: https://github.com/b4b4r07/stein 37 | description: A linter for config files with a customizable rule set 38 | skip_upload: auto 39 | test: | 40 | system "#{bin}/stein", "--version" 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Masaki ISHIYAMA 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := help 2 | 3 | .PHONY: all 4 | all: 5 | 6 | .PHONY: build 7 | build: ## Build for local environment 8 | @go build 9 | 10 | .PHONY: run 11 | run: build ## Run example script 12 | # stein loads the HCL files located on .policy directory by default 13 | # in addition, .policy directory can be overridden by each directory of given arguments 14 | # 15 | # in this case, 16 | # stein applies rules located in these default directory to _examples/manifests/microservices/*/*/*/* 17 | # * _examples/.policy/ 18 | # * _examples/manifests/.policy/ 19 | # stein doesn't apply this rules to them 20 | # * _examples/spinnaker/.policy/ 21 | # 22 | # Regardless of the default directory placed under the given path, 23 | # the following environment variables can be specified for the policy applied to all paths. 24 | # this variables can take multiple values separated by a comma, also can take directories and files 25 | # 26 | # export STEIN_POLICY=root-policy/,another-policy/special.hcl 27 | ./stein apply \ 28 | _examples/manifests/microservices/*/*/*/* \ 29 | _examples/spinnaker/*/*/* 30 | 31 | .PHONY: help 32 | help: ## Show help message for Makefile target 33 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ 34 | | sort \ 35 | | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 36 | 37 | .PHONY: docs-build 38 | docs-build: ## Build documentations with mkdocs 39 | @docker build -t mkdocs docs 40 | 41 | .PHONY: docs-live 42 | docs-live: build-docs ## Live viewing with mkdocs 43 | @docker run --rm -it -p 3000:3000 -v ${PWD}:/docs mkdocs 44 | 45 | .PHONY: docs-deploy 46 | docs-deploy: docs-build ## Deploy generated documentations to gh-pages 47 | @docker run --rm -it -v ${PWD}:/docs -v ~/.ssh:/root/.ssh mkdocs mkdocs gh-deploy 48 | 49 | .PHONY: test 50 | test: ## Run test 51 | @go test -v -race ./... 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | stein 2 | ===== 3 | 4 | ***2021-02-24 Currently [opa](https://github.com/open-policy-agent/conftest) testing framework is better than this approach. Please do not use this project except for HCL lovers.*** 5 | 6 | [![test](https://github.com/b4b4r07/stein/workflows/test/badge.svg)][test] 7 | [![release](https://github.com/b4b4r07/stein/workflows/release/badge.svg)][release] 8 | [![docs](https://github.com/b4b4r07/stein/workflows/docs/badge.svg)][docs] 9 | 10 | [test]: https://github.com/b4b4r07/stein/actions?query=workflow%3Atest 11 | [release]: https://github.com/b4b4r07/stein/actions?query=workflow%3Arelease 12 | [docs]: https://github.com/b4b4r07/stein/actions?query=workflow%3Adocs 13 | 14 | [![][release-badge]][release-link] [![][license-badge]][license-link] [![][report-badge]][report-link] [![][go-version-badge]][go-version-link] [![][website-badge]][website-link] 15 | 16 | [release-badge]: https://img.shields.io/github/release/b4b4r07/stein.svg?style=popout 17 | [release-link]: https://github.com/b4b4r07/stein/releases 18 | 19 | [license-badge]: https://img.shields.io/github/license/b4b4r07/stein.svg?style=popout 20 | [license-link]: https://b4b4r07.mit-license.org 21 | 22 | [report-badge]: https://goreportcard.com/badge/github.com/b4b4r07/stein 23 | [report-link]: https://goreportcard.com/report/github.com/b4b4r07/stein 24 | 25 | [go-version-badge]: https://img.shields.io/github/go-mod/go-version/b4b4r07/stein 26 | [go-version-link]: https://github.com/b4b4r07/stein/blob/master/go.mod 27 | 28 | [website-badge]: https://img.shields.io/website?down_color=lightgrey&down_message=down&up_color=green&up_message=up&url=https%3A%2F%2Fbabarot.me%2Fstein 29 | [website-link]: https://babarot.me/stein 30 | 31 | Stein is a linter for config files with a customizable rule set. 32 | Supported config file types are JSON, YAML and HCL for now. 33 | 34 | The basic design of this tool are heavily inspired by [HashiCorp Sentinel](https://www.hashicorp.com/sentinel) and its lots of implementations come from [Terraform](https://www.terraform.io/). 35 | 36 | ![](https://user-images.githubusercontent.com/4442708/66107167-8a83f800-e5fa-11e9-9719-f7f03624ee46.png) 37 | 38 | ## Motivation 39 | 40 | As the motivation of this tool, the factor which accounts for the most of them is the [Policy as Code](https://b4b4r07.github.io/stein/concepts/policy-as-code/). 41 | 42 | Thanks to [Infrastructure as Code](https://en.wikipedia.org/wiki/Infrastructure_as_code), the number of cases that the configurations of its infrastructure are described as code is increasing day by day. 43 | Then, it became necessary to set the lint or policy for the config files. 44 | As an example: the namespace of Kubernetes to be deployed, the number of replicas of Pods, the naming convention of a namespace, etc. 45 | 46 | This tool makes it possible to describe those requests as code (called as the [rules](https://b4b4r07.github.io/stein/configuration/policy/rules/)). 47 | 48 | ## Documentations 49 | 50 | [Stein Documentations][website-link] 51 | -------------------------------------------------------------------------------- /_examples/.policy/config.hcl: -------------------------------------------------------------------------------- 1 | config { 2 | report { 3 | format = "${format("[{{.Level}}] {{.Rule}} %s", color("{{.Message}}", "white"))}" 4 | style = "console" 5 | color = true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /_examples/.policy/functions.hcl: -------------------------------------------------------------------------------- 1 | function "remove_ext" { 2 | params = [file] 3 | result = replace(basename(file), ext(file), "") 4 | } 5 | 6 | function "white" { 7 | params = [file] 8 | result = color(file, "white") 9 | } 10 | 11 | function "is_irregular_namespace_pattern" { 12 | params = [] 13 | result = contains(var.special_cases, get_service_id_with_env(filename)) 14 | } 15 | 16 | function "kind" { 17 | params = [name] 18 | result = jsonpath("kind") == title(name) 19 | } 20 | -------------------------------------------------------------------------------- /_examples/.policy/rules.hcl: -------------------------------------------------------------------------------- 1 | rule "namespace_specification" { 2 | description = "Check namespace name is not empty" 3 | 4 | conditions = [ 5 | "${jsonpath("metadata.namespace") != ""}", 6 | ] 7 | 8 | report { 9 | level = "ERROR" 10 | message = "Namespace is not specified" 11 | } 12 | } 13 | 14 | rule "namespace_name" { 15 | description = "Check namespace name is valid" 16 | 17 | depends_on = ["rule.namespace_specification"] 18 | 19 | precondition { 20 | cases = [ 21 | "${!is_irregular_namespace_pattern()}", 22 | ] 23 | } 24 | 25 | conditions = [ 26 | "${jsonpath("metadata.namespace") == get_service_id_with_env(filename)}", 27 | ] 28 | 29 | report { 30 | level = "ERROR" 31 | message = "${format("Namespace name %q is invalid", jsonpath("metadata.namespace"))}" 32 | } 33 | } 34 | 35 | rule "namespace_name_irregular" { 36 | description = "Check namespace name is valid" 37 | 38 | depends_on = ["rule.namespace_specification"] 39 | 40 | precondition { 41 | cases = [ 42 | "${is_irregular_namespace_pattern()}", 43 | ] 44 | } 45 | 46 | conditions = [ 47 | "${contains(lookuplist(var.namespace_name_map, jsonpath("metadata.namespace")), get_service_id_with_env(filename))}", 48 | ] 49 | 50 | report { 51 | level = "ERROR" 52 | message = "${format("This case is irregular pattern, so %q is invalid", jsonpath("metadata.namespace"))}" 53 | } 54 | } 55 | 56 | rule "extension" { 57 | description = "Acceptable yaml file extensions are limited" 58 | 59 | conditions = [ 60 | "${ext(filename) == ".yaml" || ext(filename) == ".yaml.enc"}", 61 | ] 62 | 63 | report { 64 | level = "ERROR" 65 | message = "File extension should be yaml or yaml.enc" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /_examples/.policy/variables.hcl: -------------------------------------------------------------------------------- 1 | variable "shortened_environment" { 2 | description = "Shortened environment, such as prod, dev" 3 | type = "map" 4 | 5 | default = { 6 | production = "prod" 7 | development = "dev" 8 | laboratory = "lab" 9 | } 10 | } 11 | 12 | variable "special_cases" { 13 | description = "" 14 | type = "list" 15 | 16 | default = [ 17 | "x-gateway-jp-dev", 18 | "x-gateway-jp-prod", 19 | ] 20 | } 21 | 22 | variable "namespace_name_map" { 23 | type = "map" 24 | 25 | default = { 26 | "gateway" = [ 27 | "x-gateway-jp-dev", 28 | "x-gateway-jp-prod", 29 | ] 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /_examples/manifests/.policy/functions.hcl: -------------------------------------------------------------------------------- 1 | function "get_service_name" { 2 | params = [file] 3 | result = basename(dirname(dirname(dirname(file)))) 4 | } 5 | 6 | function "get_env" { 7 | params = [file] 8 | result = basename(dirname(dirname(file))) 9 | } 10 | 11 | function "get_service_id_with_env" { 12 | params = [file] 13 | result = format("%s-%s", get_service_name(file), lookup(var.shortened_environment, get_env(file))) 14 | } 15 | 16 | -------------------------------------------------------------------------------- /_examples/manifests/.policy/rules.hcl: -------------------------------------------------------------------------------- 1 | rule "filename" { 2 | description = "Check filename is the same as metadata.name" 3 | 4 | conditions = [ 5 | "${jsonpath("metadata.name") == remove_ext(filename)}", 6 | ] 7 | 8 | report { 9 | level = "ERROR" 10 | message = "${format("Filename should be %s.yaml (metadata.name + .yaml)", jsonpath("metadata.name"))}" 11 | } 12 | } 13 | 14 | rule "resource_per_file" { 15 | description = "" 16 | 17 | conditions = [ 18 | "${wc(grep("^kind: ", file(filename))) == 0}", 19 | ] 20 | 21 | report { 22 | level = "ERROR" 23 | message = "Only 1 resource should be defined in a YAML file" 24 | } 25 | } 26 | 27 | rule "yaml_separator" { 28 | description = "Do not use YAML separator" 29 | 30 | conditions = [ 31 | "${length(grep("^---", file(filename))) == 0}", 32 | ] 33 | 34 | report { 35 | level = "WARN" 36 | message = "YAML separator \"---\" should be removed" 37 | } 38 | } 39 | 40 | rule "pdb_defined" { 41 | description = "Check the PDB resouces are defined" 42 | 43 | precondition { 44 | cases = [ 45 | "${kind("Deployment")}", 46 | ] 47 | } 48 | 49 | conditions = [ 50 | "${length(glob(format("%s/PodDisruptionBudget/*", dirname(dirname(filename))))) > 0}", 51 | ] 52 | 53 | report { 54 | level = "ERROR" 55 | message = "PDB for this Deployment not found" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /_examples/manifests/microservices/x-echo-jp/development/Deployment/redis-master.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 # for k8s versions before 1.9.0 use apps/v1beta2 and before 1.8.0 use extensions/v1beta1 2 | kind: Deployment 3 | metadata: 4 | name: redis-master 5 | namespace: x-echo-jp-dev 6 | spec: 7 | selector: 8 | matchLabels: 9 | app: redis 10 | role: master 11 | tier: backend 12 | replicas: 1 13 | template: 14 | metadata: 15 | labels: 16 | app: redis 17 | role: master 18 | tier: backend 19 | spec: 20 | containers: 21 | - name: master 22 | image: k8s.gcr.io/redis:e2e # or just image: redis 23 | resources: 24 | requests: 25 | cpu: 100m 26 | memory: 100Mi 27 | ports: 28 | - containerPort: 6379 29 | - name: master-second 30 | image: k8s.gcr.io/redis:e2e # or just image: redis 31 | ports: 32 | - containerPort: 6379 33 | -------------------------------------------------------------------------------- /_examples/manifests/microservices/x-echo-jp/development/Deployment/test.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: test 5 | namespace: x-echo-jp-dev 6 | spec: 7 | selector: 8 | matchLabels: 9 | app: redis 10 | role: master 11 | tier: backend 12 | replicas: 1 13 | template: 14 | metadata: 15 | labels: 16 | app: redis 17 | role: master 18 | tier: backend 19 | spec: 20 | containers: 21 | - name: master 22 | image: k8s.gcr.io/redis:e2e # or just image: redis 23 | resources: 24 | requests: 25 | cpu: 100m 26 | memory: 100Mi 27 | ports: 28 | - containerPort: 6379 29 | - name: master-second 30 | image: k8s.gcr.io/redis:e2e # or just image: redis 31 | ports: 32 | - containerPort: 6379 33 | -------------------------------------------------------------------------------- /_examples/manifests/microservices/x-echo-jp/development/Deployment/test.yml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: test-1 5 | namespace: x-echo-jp-dev 6 | spec: 7 | selector: 8 | matchLabels: 9 | app: redis 10 | role: master 11 | tier: backend 12 | replicas: 1 13 | template: 14 | metadata: 15 | labels: 16 | app: redis 17 | role: master 18 | tier: backend 19 | spec: 20 | containers: 21 | - name: master 22 | image: k8s.gcr.io/redis:e2e # or just image: redis 23 | resources: 24 | requests: 25 | cpu: 100m 26 | memory: 100Mi 27 | ports: 28 | - containerPort: 6379 29 | - name: master-second 30 | image: k8s.gcr.io/redis:e2e # or just image: redis 31 | ports: 32 | - containerPort: 6379 33 | --- 34 | apiVersion: apps/v1 35 | kind: Deployment 36 | metadata: 37 | name: test-2 38 | namespace: x-echo-jp-dev 39 | spec: 40 | selector: 41 | matchLabels: 42 | app: redis 43 | role: master 44 | tier: backend 45 | replicas: 1 46 | template: 47 | metadata: 48 | labels: 49 | app: redis 50 | role: master 51 | tier: backend 52 | spec: 53 | containers: 54 | - name: master 55 | image: k8s.gcr.io/redis:e2e # or just image: redis 56 | resources: 57 | requests: 58 | cpu: 100m 59 | memory: 100Mi 60 | ports: 61 | - containerPort: 6379 62 | - name: master-second 63 | image: k8s.gcr.io/redis:e2e # or just image: redis 64 | ports: 65 | - containerPort: 6379 66 | -------------------------------------------------------------------------------- /_examples/manifests/microservices/x-echo-jp/development/PodDisruptionBudget/pdb.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: policy/v1beta1 2 | kind: PodDisruptionBudget 3 | metadata: 4 | name: test 5 | namespace: x-echo-jp-dev 6 | spec: 7 | minAvailable: 50% 8 | selector: 9 | matchLabels: 10 | app: echo 11 | -------------------------------------------------------------------------------- /_examples/manifests/microservices/x-echo-jp/development/Service/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: service 5 | # namespace: hoge # not specified (hoge is invalid value even if specified) 6 | labels: 7 | app: redis 8 | tier: backend 9 | role: master 10 | spec: 11 | ports: 12 | - port: 6379 13 | targetPort: 6379 14 | selector: 15 | app: redis 16 | tier: backend 17 | role: master 18 | -------------------------------------------------------------------------------- /_examples/manifests/microservices/x-gateway-jp/development/Deployment/test.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apps/v1 3 | kind: Deployment 4 | metadata: 5 | name: test 6 | # namespace: gateway 7 | namespace: x-gateway-jp-dev 8 | spec: 9 | selector: 10 | matchLabels: 11 | app: redis 12 | role: master 13 | tier: backend 14 | replicas: 1 15 | template: 16 | metadata: 17 | labels: 18 | app: redis 19 | role: master 20 | tier: backend 21 | spec: 22 | containers: 23 | - name: master 24 | image: k8s.gcr.io/redis:e2e # or just image: redis 25 | resources: 26 | requests: 27 | cpu: 100m 28 | memory: 100Mi 29 | ports: 30 | - containerPort: 6379 31 | - name: master-second 32 | image: k8s.gcr.io/redis:e2e # or just image: redis 33 | ports: 34 | - containerPort: 6379 35 | -------------------------------------------------------------------------------- /_examples/spinnaker/.policy/functions.hcl: -------------------------------------------------------------------------------- 1 | function "get_service_name" { 2 | params = [file] 3 | result = basename(dirname(dirname(file))) 4 | } 5 | 6 | function "get_env" { 7 | params = [file] 8 | result = basename(dirname(file)) 9 | } 10 | 11 | function "get_service_id_with_env" { 12 | params = [file] 13 | result = format("%s-%s", get_service_name(file), lookup(var.shortened_environment, get_env(file))) 14 | } 15 | 16 | -------------------------------------------------------------------------------- /_examples/spinnaker/x-echo-jp/development/deploy-to-dev-v2.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 # for k8s versions before 1.9.0 use apps/v1beta2 and before 1.8.0 use extensions/v1beta1 2 | kind: Deployment 3 | metadata: 4 | name: test-1 5 | namespace: x-echo-jp-dev 6 | spec: 7 | selector: 8 | matchLabels: 9 | app: redis 10 | role: master 11 | tier: backend 12 | replicas: 1 13 | template: 14 | metadata: 15 | labels: 16 | app: redis 17 | role: master 18 | tier: backend 19 | spec: 20 | containers: 21 | - name: master 22 | image: k8s.gcr.io/redis:e2e # or just image: redis 23 | resources: 24 | requests: 25 | cpu: 100m 26 | memory: 100Mi 27 | ports: 28 | - containerPort: 6379 29 | - name: master-second 30 | image: k8s.gcr.io/redis:e2e # or just image: redis 31 | ports: 32 | - containerPort: 6379 33 | --- 34 | apiVersion: apps/v1 # for k8s versions before 1.9.0 use apps/v1beta2 and before 1.8.0 use extensions/v1beta1 35 | kind: Deployment 36 | metadata: 37 | name: test-2 38 | namespace: x-echo-jp-prod 39 | spec: 40 | selector: 41 | matchLabels: 42 | app: redis 43 | role: master 44 | tier: backend 45 | replicas: 1 46 | template: 47 | metadata: 48 | labels: 49 | app: redis 50 | role: master 51 | tier: backend 52 | spec: 53 | containers: 54 | - name: master 55 | image: k8s.gcr.io/redis:e2e # or just image: redis 56 | resources: 57 | requests: 58 | cpu: 100m 59 | memory: 100Mi 60 | ports: 61 | - containerPort: 6379 62 | - name: master-second 63 | image: k8s.gcr.io/redis:e2e # or just image: redis 64 | ports: 65 | - containerPort: 6379 66 | -------------------------------------------------------------------------------- /apply.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "os" 9 | "strings" 10 | 11 | "github.com/b4b4r07/stein/lint" 12 | "github.com/fatih/color" 13 | "github.com/hashicorp/hcl2/hcl" 14 | ) 15 | 16 | // ApplyCommand is one of the subcommands 17 | type ApplyCommand struct { 18 | CLI 19 | Option ApplyOption 20 | 21 | runningFile lint.File 22 | } 23 | 24 | // ApplyOption is the options for ApplyCommand 25 | type ApplyOption struct { 26 | PolicyPath string 27 | } 28 | 29 | func (c *ApplyCommand) flagSet() *flag.FlagSet { 30 | flags := flag.NewFlagSet("apply", flag.ExitOnError) 31 | flags.StringVar(&c.Option.PolicyPath, "policy", "", "path to the policy files or the directory where policy files are located") 32 | flags.VisitAll(func(f *flag.Flag) { 33 | if s := os.Getenv(strings.ToUpper(envEnvPrefix + f.Name)); s != "" { 34 | f.Value.Set(s) 35 | } 36 | }) 37 | return flags 38 | } 39 | 40 | // Run run apply command 41 | func (c *ApplyCommand) Run(args []string) int { 42 | flags := c.flagSet() 43 | if err := flags.Parse(args); err != nil { 44 | return c.exit(err) 45 | } 46 | args = flags.Args() 47 | 48 | if len(args) == 0 { 49 | return c.exit(errors.New("No config files given as arguments")) 50 | } 51 | 52 | linter, err := lint.NewLinter(args, strings.Split(c.Option.PolicyPath, ",")...) 53 | if err != nil { 54 | return c.exit(err) 55 | } 56 | 57 | var results []lint.Result 58 | for _, file := range linter.Files() { 59 | c.runningFile = file 60 | result, err := linter.Run(file) 61 | if err != nil { 62 | return c.exit(err) 63 | } 64 | results = append(results, result) 65 | } 66 | 67 | for _, result := range results { 68 | linter.Print(result) 69 | } 70 | linter.PrintSummary(results...) 71 | 72 | return c.exit(linter.Status(results...)) 73 | } 74 | 75 | // Synopsis returns synopsis 76 | func (c *ApplyCommand) Synopsis() string { 77 | return "Applies a policy to arbitrary config files." 78 | } 79 | 80 | // Help returns help message 81 | func (c *ApplyCommand) Help() string { 82 | var b bytes.Buffer 83 | flags := c.flagSet() 84 | flags.SetOutput(&b) 85 | flags.PrintDefaults() 86 | return fmt.Sprintf( 87 | "Usage of %s:\n %s\n\nOptions:\n%s", flags.Name(), c.Synopsis(), b.String(), 88 | ) 89 | } 90 | 91 | // exit overwides CLI.exit 92 | func (c *ApplyCommand) exit(msg interface{}) int { 93 | wr := hcl.NewDiagnosticTextWriter( 94 | c.Stderr, // writer to send messages to 95 | c.runningFile.Policy.Files, // the parser's file cache, for source snippets 96 | 100, // wrapping width 97 | true, // generate colored/highlighted output 98 | ) 99 | switch m := msg.(type) { 100 | case error: 101 | // TODO 102 | color.New(color.Underline).Fprintln(c.Stderr, c.runningFile.Path) 103 | switch diags := m.(type) { 104 | case hcl.Diagnostics: 105 | if len(diags) == 0 { 106 | return 1 107 | } 108 | wr.WriteDiagnostic(diags[0]) 109 | return 1 110 | } 111 | case lint.Status: 112 | return int(m) 113 | } 114 | return c.CLI.exit(msg) 115 | } 116 | -------------------------------------------------------------------------------- /docs/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.10.2 2 | 3 | RUN apk update && apk add --no-cache \ 4 | bash \ 5 | git \ 6 | git-fast-import \ 7 | openssh \ 8 | python3 \ 9 | python3-dev \ 10 | curl \ 11 | && python3 -m ensurepip \ 12 | && rm -r /usr/lib/python*/ensurepip \ 13 | && pip3 install --upgrade pip setuptools \ 14 | && rm -r /root/.cache \ 15 | && rm -rf /var/cache/apk/* 16 | 17 | COPY requirements.txt / 18 | RUN pip install -U -r /requirements.txt 19 | 20 | WORKDIR /docs 21 | 22 | EXPOSE 3000 23 | 24 | CMD ["mkdocs", "serve", "--dev-addr=0.0.0.0:3000", "--livereload"] 25 | -------------------------------------------------------------------------------- /docs/commands/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Stein CLI Commands" 3 | date: 2019-01-17T15:26:15Z 4 | draft: false 5 | weight: 60 6 | item: "Commands (CLI)" 7 | 8 | --- 9 | 10 | Stein is controlled via a very easy to use command-line interface (CLI). Stein is only a single command-line application: `stein`. This application then takes a subcommand such as "apply" or "plan". The complete list of subcommands is in the navigation to the left. 11 | 12 | The Stein CLI is a well-behaved command line application. In erroneous cases, a non-zero exit status will be returned. It also responds to `-h` and `--help` as you'd expect. To view a list of the available commands at any time, just run `stein` with no arguments. 13 | 14 | To view a list of the available commands at any time, just run stein with no arguments: 15 | 16 | ```console 17 | $ stein 18 | Usage: stein [--version] [--help] [] 19 | 20 | Available commands are: 21 | apply Applies a policy to arbitrary config files. 22 | fmt Formats a policy source to a canonical format. 23 | 24 | ``` 25 | 26 | To get help for any specific command, pass the `-h` flag to the relevant subcommand. For example, to see help about the apply subcommand: 27 | 28 | ```console 29 | $ stein apply -h 30 | Usage of apply: 31 | Applies a policy to arbitrary config files. 32 | 33 | Options: 34 | -policy string 35 | path to the policy files or the directory where policy files are located 36 | 37 | ``` 38 | 39 | ## Shell Tab-completion 40 | 41 | TBD 42 | 43 | ## Debug stein 44 | 45 | Stein has detailed logs which can be enabled by setting the `STEIN_LOG` environment variable to any value. This will cause detailed logs to appear on stderr. 46 | 47 | You can set `STEIN_LOG` to one of the log levels `TRACE`, `DEBUG`, `INFO`, `WARN` or `ERROR` to change the verbosity of the logs. `TRACE` is the most verbose and it is the default if `STEIN_LOG` is set to something other than a log level name. 48 | 49 | To persist logged output you can set `STEIN_LOG_PATH` in order to force the log to always be appended to a specific file when logging is enabled. Note that even when `STEIN_LOG_PATH` is set, `STEIN_LOG` must be set in order for any logging to be enabled. 50 | 51 | If you find a bug with Stein, please include the detailed log by using a service such as gist. 52 | -------------------------------------------------------------------------------- /docs/commands/apply.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Command: apply" 3 | item: "apply" 4 | date: 2019-01-17T15:26:15Z 5 | draft: false 6 | weight: 61 7 | 8 | --- 9 | 10 | The `stein apply` command is used to execute a policy locally for development purposes. 11 | 12 | ``` 13 | Usage: stein apply [options] POLICY 14 | ``` 15 | 16 | This command executes the policy file at the path specified by POLICY. 17 | 18 | The output will indicate whether the policy passed or failed. The exit code also reflects the status of the policy: 0 is pass, 1 is fail, 2 is undefined (fail, but because the result was undefined), and 2 is a runtime error. 19 | 20 | A configuration file can be specified with `-policy` to define available import plugins, mock data, and global values. This is used to simulate a policy embedded within an application. The documentation for this configuration file is below. 21 | 22 | The command-line flags are all optional. The list of available flags are: 23 | 24 | - [`-policy=file[,file,dir,...]`](#policy-file) - Path to HCL file path or a directory path located in HCL files. You can specify multiple paths (directory or just HCL file) with a comma. The `STEIN_POLICY` variable is the environment variable version of this flag. 25 | 26 | See also [How policies are loaded by Stein](../configuration/load.md) 27 | -------------------------------------------------------------------------------- /docs/commands/fmt.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Command: fmt" 3 | item: "fmt" 4 | date: 2019-01-17T15:26:15Z 5 | draft: false 6 | weight: 62 7 | 8 | --- 9 | 10 | The `stein fmt` command formats a policy source to a canonical format. 11 | 12 | ``` 13 | Usage: stein fmt [options] FILE ... 14 | ``` 15 | 16 | This command formats all the specified policy files to a canonical format. 17 | 18 | By default, policy files are overwritten in place. This behavior can be changed with the `-write` flag. If a specified FILE is - then stdin is read and the output is always written to stdout. 19 | 20 | The command-line flags are all optional. The list of available flags are: 21 | 22 | - [`-write=true`](#write-true) - Write formatted policy to the named source file. If false, output will go to stdout. If multiple files are specified, the output will be concatenated directly. 23 | - [`-check=false`](#check-false) - Don't format, only check if formatting is necessary. Files that require formatting are printed, and a non-zero exit code is returned if changes are required. 24 | 25 | ```console 26 | $ stein fmt -check test.hcl 27 | config { 28 | - 29 | report { 30 | format = "{{.Level}}: {{.Rule}}: {{.Message}}" 31 | - style = "console" 32 | - color = true 33 | + style = "console" 34 | + color = true 35 | } 36 | - 37 | } 38 | ``` 39 | -------------------------------------------------------------------------------- /docs/concepts/policy-as-code.md: -------------------------------------------------------------------------------- 1 | # Policy as Code 2 | 3 | Policy as code is the idea of writing code in a high-level language to manage and automate policies. By representing policies as code in text files, proven software development best practices can be adopted such as version control, automated testing, and automated deployment. 4 | 5 | Many existing policy or ACL systems do not practice policy as code. Many policies are set by clicking in a GUI, which isn't easily repeatable nor versionable. They usually don't provide any system for testing policies other than testing an action that would violate the policy. This makes it difficult for automated testing. And the policy language itself varies by product. 6 | 7 | Stein is built around the idea and provides all the benefits of policy as code. 8 | 9 | !!! Note 10 | The idea of "Policy as Code" is proposed by HashiCorp and HashiCorp Sentinel. [Policy as Code - Sentinel by HashiCorp](https://docs.hashicorp.com/sentinel/concepts/policy-as-code) 11 | -------------------------------------------------------------------------------- /docs/concepts/policy.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Policy" 3 | date: 2019-01-17T15:26:15Z 4 | draft: false 5 | weight: 11 6 | 7 | --- 8 | 9 | ## What's a policy? 10 | 11 | The policy describes the rule "The configuration file should be written like this". How the configuration file should be written depends on the company and team. Stein respects and embraces the approaches adopted by Terraform and [HashiCorp Sentinel](https://docs.hashicorp.com/sentinel/intro/) so that it can be defined flexibly. 12 | 13 | Stein can test against the configuration file based on those policies. Therefore, by combining with CI etc., you can enforce rules on the configuration file. 14 | 15 | ## Why needs policies? 16 | 17 | Nowadays, thanks to the infiltration of the concept of "Infrastructure as Code", many infrastructure settings are coded in a configuration file language such as YAML. 18 | 19 | For YAML files that we have to maintain like Kubernetes manifest files, as we continue to maintain them in the meantime, we will want to unify the writing style with policies like a style guide. 20 | 21 | Let's say reviewing Kubernetes YAML. In many cases, you will find points to point out repeatedly in repeated reviews. For example, explicitly specifying namespace, label, and so on. The things that humans point out every time should be checked mechanically beforehand. It's important for reviewers as well as for reviewees. 22 | 23 | Besides, there are points that can not be pointed out in the review, and a lot of difficult points to comment. For example, it's whether the specified namespace name is correct or not. In addition, let's say Terraform use-case. 24 | As an example: before infrastructure as code and autoscaling, if an order came through for 5,000 new machines, a human would likely respond to the ticket verifying that the user really intended to order 5,000 new machines. Today, automation can almost always freely order 5,000 new compute instances without any hesitation, which can result in unintended expense or system instability ([HashiCorp Sentinel](https://docs.hashicorp.com/sentinel/intro/why) has basically the same intention and Stein's one also comes from that). 25 | 26 | In order to avoid these accidents in advance, it is very important to define policies as codes and warn them based on them. 27 | -------------------------------------------------------------------------------- /docs/configuration/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Configuration" 3 | item: "" 4 | date: 2019-01-17T15:26:15Z 5 | draft: false 6 | weight: 20 7 | 8 | --- 9 | 10 | Stein uses text files to describe infrastructure and to set variables. These text files are called Stein Policy Configuration and end in just `.hcl`. This section talks about the format of these files as well as how they're loaded. 11 | 12 | The format of the policy files are able to be in extended HCL (called as Stein format). The Stein format is more human-readable, supports comments, and is the generally recommended format for most Stein files. The JSON format is meant for machines to create, modify, and update, but can also be done by Stein operators if you prefer. 13 | 14 | Click a sub-section in the navigation to the left to learn more about Stein configuration. 15 | -------------------------------------------------------------------------------- /docs/configuration/load.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Load Order" 3 | item: "" 4 | date: 2019-01-17T15:26:15Z 5 | draft: false 6 | weight: 44 7 | 8 | --- 9 | 10 | ## How policies are loaded by Stein 11 | 12 | To understand how stein loads policy files and recognizes them is very important for writing and applying policies to the files effectively. 13 | `stein apply` requires always one or more arguments only. 14 | It assumes the config file paths such as YAML, JSON and so on. 15 | 16 | The path may have a hierarchical structure. 17 | In Stein, when a path with a hierarchical structure is given as arguments, stein recognizes the HCL file in `.policy` directory placed in the path included in that path as a policy to be applied. 18 | 19 | Let's see a concrete example. 20 | 21 | ``` 22 | _examples 23 | |-- .policy/ 24 | | |-- config.hcl 25 | | |-- functions.hcl 26 | | |-- rules.hcl 27 | | `-- variables.hcl 28 | |-- manifests/ 29 | | |-- .policy/ 30 | | | |-- functions.hcl 31 | | | `-- rules.hcl 32 | | `-- microservices/ 33 | | |-- x-echo-jp/ 34 | | | `-- development/ 35 | | | |-- Deployment/ 36 | | | | |-- redis-master.yaml 37 | | | | |-- test.yaml 38 | | | | `-- test.yml 39 | | | |-- PodDisruptionBudget/ 40 | | | | `-- pdb.yaml 41 | | | `-- Service/ 42 | | | `-- service.yaml 43 | | `-- x-gateway-jp/ 44 | | `-- development/ 45 | | `-- Deployment/ 46 | | `-- test.yaml 47 | `-- spinnaker/ 48 | |-- .policy/ 49 | | `-- functions.hcl 50 | `-- x-echo-jp/ 51 | `-- development/ 52 | `-- deploy-to-dev-v2.yaml 53 | ``` 54 | 55 | There are some Kubernetes YAML with hierarchical structure and some policies here. 56 | 57 | In this case, `stein` recognizes these HCL files as the policy to be applied to the arguments if `_examples/manifests/microservices/x-echo-jp/development/Deployment/test.yaml` is given as arguments of `stein`: 58 | 59 | - `_examples/.policy/*.hcl` 60 | - `_examples/manifests/.policy/*.hcl` 61 | 62 | This is because given argument file contains `_examples/` and `_examples/manifests`. 63 | 64 | That is, all YAML files located in `_examples/manifests/` is applied with `_examples/.policy/*.hcl` and `_examples/manifests/.policy/*.hcl`. 65 | 66 | On the other hand, all YAML files located in `_examples/spinnaker/` is applied with `_examples/.policy/*.hcl` and `_examples/spinnaker/.policy/*.hcl`. 67 | 68 | So, you can control the policy to apply by appropriately creating the directory and placing the YAML files and `.policy` directory there. 69 | 70 | In addition, if you want to apply policies placed in places that have no relation to given arguments, you can control by environment variable or `apply` flag. 71 | 72 | ```bash 73 | export STEIN_POLICY=/path/to/policy 74 | stein apply deployment.yaml 75 | 76 | # or 77 | 78 | stein apply -policy /path/to/policy deployment.yaml 79 | ``` 80 | 81 | Also `STEIN_POLICY` (`-policy`) can take multiple values separated by a comma, also can take directories and files: 82 | 83 | ```bash 84 | STEIN_POLICY=root-policy/,another-policy/special.hcl 85 | # -> these files are applied, besides ".policy/*.hcl" included in given arguments 86 | # root-policy/*.hcl 87 | # another-policy/special.hcl 88 | ``` 89 | -------------------------------------------------------------------------------- /docs/configuration/policy/config.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Config Configuration" 3 | item: "Config" 4 | date: 2019-01-17T15:26:15Z 5 | draft: false 6 | weight: 43 7 | 8 | --- 9 | 10 | The `config` is a block that can describe settings related to stein lint. Basically stein configuration is based on "Smart default" concept. It means that it has been set up sufficiently from the beginning. Moreover, this means that you can use it without having to define this block and no need to change the setting. However, depending on the item, you may want to customize it. Therefore, you can change the setting according to the `config` block accordingly. 11 | 12 | This page assumes you're familiar with the [configuration syntax](../syntax/_index.md) already. 13 | 14 | ## Example 15 | 16 | A `config` configuration looks like the following: 17 | 18 | ```terraform 19 | config { 20 | report { 21 | format = "${format("{{.Level}}: {{.Rule}} %s", color("{{.Message}}", "white"))}" 22 | style = "console" 23 | color = true 24 | } 25 | } 26 | ``` 27 | 28 | ## Description 29 | 30 | Only one `config` block can be defined. 31 | 32 | Within the block (the `{ }`) is configuration for the config block. 33 | 34 | ### Meta-parameters 35 | 36 | There are **meta-parameters** available to config block: 37 | 38 | - `report` (configuration block) - 39 | - `format` (string) - Report message format. It's shown in lint message. In format, it can be described with [Go template](https://golang.org/pkg/text/template/). `{{.Level}}` is converted to the lint level, `{{.Rule}}` is converted to the rule name, and `{{.Message}}` is converted to the lint message. 40 | - `style` (string) - Report style. It can take "console", "inline" now. 41 | - `color` (bool) - Whether to color output. 42 | 43 | If config block isn't defined, the following configuration is used by default. 44 | 45 | ```terraform 46 | config { 47 | report { 48 | format = "${format("[{{.Level}}] {{.Rule}} {{.Message}}")}" 49 | style = "console" 50 | color = true 51 | } 52 | } 53 | ``` 54 | 55 | ## Syntax 56 | 57 | The full syntax is: 58 | 59 | ```hcl 60 | config { 61 | [REPORT] 62 | } 63 | ``` 64 | 65 | where REPORT is: 66 | 67 | ```hcl 68 | report { 69 | format = FORMAT 70 | style = [console|inline] 71 | color = bool 72 | } 73 | ``` 74 | -------------------------------------------------------------------------------- /docs/configuration/policy/functions.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Function Configuration" 3 | item: "Function" 4 | date: 2019-01-17T15:26:15Z 5 | draft: false 6 | weight: 42 7 | 8 | --- 9 | 10 | It is recommended that you read the [Custom Functions](../syntax/custom-functions.md) page prior to reading this section of the documentation. The page will explain what the custom functions are and how to use them. On the other hands, this documentation will guide you the basics of writing custom functions and introducing it into your policies efficiently. 11 | 12 | This page assumes you're familiar with the [configuration syntax](../syntax/_index.md) already. 13 | 14 | ## Example 15 | 16 | A `function` configuration looks like the following: 17 | 18 | ```hcl 19 | function "get_service_name" { 20 | params = [file] 21 | result = basename(dirname(dirname(dirname(file)))) 22 | } 23 | 24 | function "get_env" { 25 | params = [file] 26 | result = basename(dirname(dirname(file))) 27 | } 28 | 29 | function "get_service_id_with_env" { 30 | params = [file] 31 | result = format("%s-%s", get_service_name(file), lookup(var.shortened_environment, get_env(file))) 32 | } 33 | ``` 34 | 35 | ## Description 36 | 37 | The `function` block creates an user-defined function of the given *NAME* (first parameter). The name must be unique. 38 | 39 | Within the block (the `{ }`) is configuration for the function. 40 | 41 | ### Meta-parameters 42 | 43 | There are **meta-parameters** available to all rules: 44 | 45 | - `params` (list of strings) - Parameters for the function. Like arguments. It can be referenced within the function. The variable name for `params` can specify arbitrary string. 46 | - `variadic_param` (list of strings) - Variable arguments for the function. 47 | - `result` (any) - Return value of the function. It can take just string of course, but also take variables, built-in functions and custom functions even. 48 | 49 | ## Syntax 50 | 51 | The full syntax is: 52 | 53 | ```hcl 54 | rule NAME { 55 | params = [ARG, ...] 56 | 57 | [variadic_param = [ARG, ...]] 58 | 59 | result = RETURN-VALUE 60 | } 61 | ``` 62 | -------------------------------------------------------------------------------- /docs/configuration/policy/rules.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Rule Configuration" 3 | item: "Rule" 4 | date: 2019-01-17T15:26:15Z 5 | draft: false 6 | weight: 41 7 | 8 | --- 9 | 10 | The most important thing you'll configure with Stein are rules. Rules are a component of your policies. It might be some rule set such as a region to be deployed, naming convention, or some linting. Or it can be a higher level component such as an email provider, DNS record, or database provider. 11 | 12 | This page assumes you're familiar with the [configuration syntax](../syntax/_index.md) already. 13 | 14 | ## Example 15 | 16 | A `rule` configuration looks like the following: 17 | 18 | ```hcl 19 | rule "replicas" { 20 | description = "Check the number of replicas is sufficient" 21 | 22 | conditions = [ 23 | "${jsonpath(".spec.replicas") > 3}", 24 | ] 25 | 26 | report { 27 | level = "ERROR" 28 | message = "Too few replicas" 29 | } 30 | } 31 | ``` 32 | 33 | ## Description 34 | 35 | The `rule` block creates a rule set of the given *NAME* (first parameter). The name must be unique. 36 | 37 | Within the block (the `{ }`) is configuration for the rule. 38 | 39 | ### Meta-parameters 40 | 41 | There are **meta-parameters** available to all rules: 42 | 43 | - `description` (string) - A human-friendly description for the rule. This is primarily for documentation for users using your Stein configuration. When a module is published in Terraform Registry, the given description is shown as part of the documentation. 44 | - `depends_on` (list of strings) - Other rules which this rule depends on. This rule will be skipped if the dependency rules has failed. The rule name which will be described in "depends_on" list should follow as "rule.xxx". 45 | - `precondition` (configuration block; optional) - 46 | - `cases` (list of bools) - Conditions to determine whether the rule should be executed. This rule will only be executed if all preconditions return true. 47 | - `conditions` (list of bools) - Conditions for deciding whether this rule passes or fails. In order to pass, all conditions must return True. 48 | - `report` (configuration block) - 49 | - `level` (string) - Error level. It can take "ERROR" or "WARN" as the level. In case of "ERROR", this rule fails. But in case of "WARN", this rule doesn't fail. 50 | - `message` (string) - Error message. Let's write the conditions for passing the role here. 51 | 52 | ## Syntax 53 | 54 | The full syntax is: 55 | 56 | ```hcl 57 | rule NAME { 58 | description = DESCRIPTION 59 | 60 | [depends_on = [NAME, ...]] 61 | 62 | [PRECONDITION] 63 | 64 | conditions = [CONDITION, ...] 65 | 66 | REPORT 67 | } 68 | ``` 69 | 70 | where PRECONDITION is: 71 | 72 | ```hcl 73 | precondition { 74 | cases = [CONDITION, ...] 75 | } 76 | ``` 77 | 78 | where REPORT is: 79 | 80 | ```hcl 81 | report { 82 | level = [ERROR|WARN] 83 | message = MESSAGE 84 | } 85 | ``` 86 | -------------------------------------------------------------------------------- /docs/configuration/policy/variables.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Variable Configuration" 3 | item: "Variable" 4 | date: 2019-01-17T15:26:15Z 5 | draft: false 6 | weight: 41 7 | 8 | --- 9 | 10 | Input variables serve as parameters for a Terraform module. 11 | 12 | When used in the root module of a configuration, variables can be set from CLI arguments and environment variables. For [child modules](), they allow values to pass from parent to child. 13 | 14 | Input variable usage is introduced in the Getting Started guide section [Input Variables](). 15 | 16 | This page assumes you're familiar with the [configuration syntax](../syntax/_index.md) already. 17 | 18 | ## Example 19 | 20 | Input variables can be defined as follows: 21 | 22 | ```terraform 23 | variable "key" { 24 | type = "string" 25 | } 26 | 27 | variable "images" { 28 | type = "map" 29 | 30 | default = { 31 | us-east-1 = "image-1234" 32 | us-west-2 = "image-4567" 33 | } 34 | } 35 | 36 | variable "zones" { 37 | default = ["us-east-1a", "us-east-1b"] 38 | } 39 | ``` 40 | 41 | ## Description 42 | 43 | The `variable` block configures a single input variable for a Terraform module. Each block declares a single variable. 44 | 45 | The name given in the block header is used to assign a value to the variable via the CLI and to reference the variable elsewhere in the configuration. 46 | 47 | Within the block body (between `{ }`) is configuration for the variable, which accepts the following arguments: 48 | 49 | *WIP* 50 | -------------------------------------------------------------------------------- /docs/configuration/syntax/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Syntax" 3 | date: 2019-01-17T15:26:15Z 4 | draft: false 5 | weight: 50 6 | 7 | --- 8 | 9 | The syntax of Stein configurations is called [HashiCorp Configuration Language (HCL)](https://github.com/hashicorp/hcl). It is meant to strike a balance between human readable and editable as well as being machine-friendly. For machine-friendliness, Stein can also read JSON configurations. For general Stein configurations, however, we recommend using the HCL Stein syntax. 10 | -------------------------------------------------------------------------------- /docs/configuration/syntax/custom-functions.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Custom Functions" 3 | date: 2019-01-17T15:26:15Z 4 | draft: false 5 | weight: 52 6 | 7 | --- 8 | 9 | !!! Info 10 | 11 | This idea basically comes from [hcl2/ext/userfunc at master · hashicorp/hcl2](https://github.com/hashicorp/hcl2/tree/master/ext/userfunc) 12 | 13 | ## What's custom functions? 14 | 15 | The custom function feature is like an user-defined functions. You can freely define functions that Stein doesn't provide as [a built-in function](../syntax/interpolation.md#built-in-functions). 16 | 17 | Of course you can define it freely, so you can customize it by wrapping a built-in function, or you can use it like an alias. 18 | 19 | ## Why need custom functions? 20 | 21 | Stein functions as a versatile testing framework for configuration files such as YAML. Stein therefore doesn't provide a function only to achieve a specific company's use case or purpose. However, there will be many cases in which you want to do it. This custom function feature covers that. 22 | 23 | ## Usage 24 | 25 | This HCL extension allows a calling application to support user-defined functions. 26 | 27 | Functions are defined via a specific block type, like this: 28 | 29 | ```hcl 30 | function "add" { 31 | params = [a, b] 32 | result = a + b 33 | } 34 | 35 | function "list" { 36 | params = [] 37 | variadic_param = items 38 | result = items 39 | } 40 | ``` 41 | 42 | Predefined keywords to be used in `function` block is: 43 | 44 | - `params`: Arguments for the function. 45 | - `variadic_param`: Variable-length argument list. It can be omitted. 46 | - `result`: Return value. It can take not only just string but also other functions, variables and etc. 47 | 48 | ## Examples 49 | 50 | ```hcl 51 | function "remove_ext" { 52 | params = [file] 53 | result = replace(basename(file), ext(file), "") 54 | } 55 | 56 | # "${remove_ext("/path/to/secret.txt")}" => secret 57 | ``` 58 | 59 | ```hcl 60 | variable "shortened_environment" { 61 | description = "Shortened environment, such as prod, dev" 62 | type = "map" 63 | 64 | default = { 65 | production = "prod" 66 | development = "dev" 67 | laboratory = "lab" 68 | } 69 | } 70 | 71 | function "shorten_env" { 72 | params = [env] 73 | result = lookup(var.shortened_environment, env) 74 | } 75 | 76 | # ${shorten_env("development")} => dev 77 | ``` 78 | -------------------------------------------------------------------------------- /docs/configuration/syntax/functions/color.md: -------------------------------------------------------------------------------- 1 | # color(text, color) 2 | 3 | Returns a string colorized by the color name. 4 | 5 | ## Type 6 | 7 | Arguments | Return values 8 | ---|--- 9 | string, string | string 10 | 11 | ## Usage 12 | 13 | ```hcl 14 | "${color("hello!", "white")}" 15 | # => "\x1b[37mhello!\x1b[0m" 16 | 17 | "${color("hello!", "red", "BgBlack")}" 18 | # => "\x1b[31m\x1b[40mhello!\x1b[0m" 19 | ``` 20 | -------------------------------------------------------------------------------- /docs/configuration/syntax/functions/exist.md: -------------------------------------------------------------------------------- 1 | # exist(path) 2 | 3 | Returns true if given file or directory exists 4 | 5 | ## Type 6 | 7 | Arguments | Return values 8 | ---|--- 9 | string | boolean 10 | 11 | ## Usage 12 | 13 | ```hcl 14 | "${exist("/path/to/whatever")}" 15 | # => true (if exists) 16 | ``` 17 | -------------------------------------------------------------------------------- /docs/configuration/syntax/functions/ext.md: -------------------------------------------------------------------------------- 1 | # ext(file) 2 | 3 | Returns the file extensions. 4 | 5 | ## Type 6 | 7 | Arguments | Return values 8 | ---|--- 9 | string | string 10 | 11 | ## Usage 12 | 13 | ```hcl 14 | "${ext("a.txt")}" 15 | # => ".txt" 16 | ``` 17 | -------------------------------------------------------------------------------- /docs/configuration/syntax/functions/glob.md: -------------------------------------------------------------------------------- 1 | # glob(pattern) 2 | 3 | Return an array of filenames or directories that matches the specified pattern. 4 | 5 | ## Type 6 | 7 | Arguments | Return type 8 | ---|--- 9 | string | list(string) 10 | 11 | ## Usage 12 | 13 | ```hcl 14 | "${glob("*.txt")}" 15 | ``` 16 | 17 | The output of the code above could be: 18 | 19 | ```hcl 20 | ["a.txt", "b.txt", "c.txt"] 21 | ``` 22 | -------------------------------------------------------------------------------- /docs/configuration/syntax/functions/grep.md: -------------------------------------------------------------------------------- 1 | # grep(pattern, text) 2 | 3 | Returns the text block matched with the given pattern. 4 | 5 | ## Type 6 | 7 | Arguments | Return values 8 | ---|--- 9 | string | string 10 | 11 | ## Usage 12 | 13 | ``` 14 | My life didn't please me, 15 | so I created my life. 16 | - Coco Chanel 17 | ``` 18 | 19 | ```hcl 20 | "${grep(file("text.txt"), "life")}" 21 | # => "My life didn't please me,\nso I created my life." 22 | ``` 23 | -------------------------------------------------------------------------------- /docs/configuration/syntax/functions/jsonpath.md: -------------------------------------------------------------------------------- 1 | # jsonpath(query) 2 | 3 | This function returns the value corresponding to given query. The query should be followed JSONPATH. However, as of now, this jsonpath function uses [tidwall/gjson](https://github.com/tidwall/gjson) package as JSONPATH internally. So basically for now, please refer to [its godoc page](https://godoc.org/github.com/tidwall/gjson). 4 | 5 | ## Type 6 | 7 | Arguments | Return values 8 | ---|--- 9 | string | string / number / list / map 10 | 11 | ## Usage 12 | 13 | Let's say you run some queries with `jsonpath` function in your rule file against the following [Kubernetes Deployment](https://kubernetes.io/docs/concepts/workloads/controllers/deployment/) manifest file. 14 | 15 | !!! Info "A example manifest file for Kubernetes Deployment" 16 | 17 | These config files listed with three format are the same. Technically Kubernetes allows to accept only JSON and YAML as manifest file. A HCL code listed here is just a example for explaining HCL is the compatible for JSON and YAML. 18 | 19 | ``` json tab="JSON" 20 | { 21 | "apiVersion": "extensions/v1beta1", 22 | "kind": "Deployment", 23 | "metadata": { 24 | "name": "nginx" 25 | }, 26 | "spec": { 27 | "replicas": 2, 28 | "template": { 29 | "metadata": { 30 | "labels": { 31 | "run": "nginx" 32 | } 33 | }, 34 | "spec": { 35 | "containers": [ 36 | { 37 | "name": "nginx", 38 | "image": "nginx:1.11", 39 | "ports": [ 40 | { 41 | "containerPort": 80 42 | } 43 | ] 44 | } 45 | ] 46 | } 47 | } 48 | } 49 | } 50 | ``` 51 | 52 | ```yaml tab="YAML" 53 | apiVersion: extensions/v1beta1 54 | kind: Deployment 55 | metadata: 56 | name: nginx 57 | spec: 58 | replicas: 2 59 | template: 60 | metadata: 61 | labels: 62 | run: nginx 63 | spec: 64 | containers: 65 | - name: nginx 66 | image: nginx:1.11 67 | ports: 68 | - containerPort: 80 69 | ``` 70 | 71 | ```terraform tab="HCL" 72 | "apiVersion" = "extensions/v1beta1" 73 | 74 | "kind" = "Deployment" 75 | 76 | "metadata" = { 77 | "name" = "nginx" 78 | } 79 | 80 | "spec" = { 81 | "replicas" = 2 82 | 83 | "template" "metadata" "labels" { 84 | "run" = "nginx" 85 | } 86 | 87 | "template" "spec" { 88 | "containers" = { 89 | "image" = "nginx:1.11" 90 | 91 | "name" = "nginx" 92 | 93 | "ports" = { 94 | "containerPort" = 80 95 | } 96 | } 97 | } 98 | } 99 | ``` 100 | 101 | First, let's say to use this query against above manifest file. 102 | 103 | ```hcl 104 | jsonpath("spec.replicas") 105 | ``` 106 | 107 | 108 | It will return `2` (type is number). 109 | -------------------------------------------------------------------------------- /docs/configuration/syntax/functions/lookuplist.md: -------------------------------------------------------------------------------- 1 | # lookuplist(map, key) 2 | 3 | Returns a list matched by the key in the given map. 4 | 5 | Like the Terraform's [`lookup`](https://www.terraform.io/docs/configuration/interpolation.html#lookup-map-key-default-) but this is only for returning a list. 6 | 7 | ## Type 8 | 9 | Arguments | Return values 10 | ---|--- 11 | map, string | list(string) 12 | 13 | ## Usage 14 | 15 | ```hcl 16 | variable "colors" { 17 | type = "map" 18 | 19 | default = { 20 | "red" = [ 21 | "burgundy", 22 | "terracotta", 23 | "scarlet", 24 | ] 25 | "blue" = [ 26 | "heliotrope", 27 | "cerulean blue", 28 | "turquoise blue", 29 | ] 30 | } 31 | } 32 | ``` 33 | 34 | ```hcl 35 | "${lookuplist(var.colors, "red")}" 36 | # => ["burgundy", "terracotta", "scarlet"] 37 | 38 | "${contains(lookuplist(var.colors, "red"), "scarlet")}" 39 | # => true 40 | ``` 41 | -------------------------------------------------------------------------------- /docs/configuration/syntax/functions/match.md: -------------------------------------------------------------------------------- 1 | # match(text, text) 2 | 3 | Returns a true if the text is matched with the pattern. 4 | 5 | ## Type 6 | 7 | Arguments | Return values 8 | ---|--- 9 | string, string | boolean 10 | 11 | ## Usage 12 | 13 | ```hcl 14 | "${match("^a", "abcdef")}" 15 | # => true 16 | ``` 17 | -------------------------------------------------------------------------------- /docs/configuration/syntax/functions/pathshorten.md: -------------------------------------------------------------------------------- 1 | # pathshorten(path) 2 | 3 | Returns the file path shortened like [:fa-external-link: Vim's one](http://vimdoc.sourceforge.net/htmldoc/eval.html#pathshorten()). 4 | 5 | ## Type 6 | 7 | Arguments | Return values 8 | ---|--- 9 | string | string 10 | 11 | ## Usage 12 | 13 | ```hcl 14 | "${pathshorten("manifests/microservices/x-gateway-jp/development/Service/a.yaml")}" 15 | # => "m/m/x/d/S/a.yaml" 16 | ``` 17 | -------------------------------------------------------------------------------- /docs/configuration/syntax/functions/wc.md: -------------------------------------------------------------------------------- 1 | # wc(text, [l, c, w]) 2 | 3 | Returns the counted number of text as options (**l** ines, **w** ords, **c** hars). 4 | Default option is **l** ines. Same as UNIX's one. 5 | 6 | ## Type 7 | 8 | Arguments | Return values 9 | ---|--- 10 | string, (string...) | number 11 | 12 | ## Usage 13 | 14 | ```hcl 15 | "${wc("foo\nbar baz")}" 16 | # => 1 17 | 18 | "${wc("foo\nbar baz", "c")}" 19 | # => 11 20 | 21 | "${wc("foo\nbar baz", "w")}" 22 | # => 3 23 | ``` 24 | 25 | -------------------------------------------------------------------------------- /docs/configuration/syntax/interpolation.md: -------------------------------------------------------------------------------- 1 | # Interpolation Syntax 2 | 3 | Embedded within strings in Terraform, whether you're using the Terraform syntax or JSON syntax, you can interpolate other values. These interpolations are wrapped in `${}`, such as `${var.foo}`. 4 | 5 | The interpolation syntax is powerful and allows you to reference variables, attributes of resources, call functions, etc. 6 | 7 | You can perform simple math in interpolations, allowing you to write conditions such as `${count.index + 1}`. And you can also use conditionals to determine a value based on some logic. 8 | 9 | You can escape interpolation with double dollar signs: `$${foo}` will be rendered as a literal `${foo}`. 10 | 11 | ## Available Variables 12 | 13 | There are a variety of available variable references you can use. 14 | 15 | ### User string variables 16 | 17 | Use the `var.prefix` followed by the variable name. For example, `${var.foo}` will interpolate the `foo` variable value. 18 | 19 | ### User map variables 20 | 21 | The syntax is `var.MAP["KEY"]`. For example, `${var.amis["us-east-1"]}` would get the value of the `us-east-1` key within the amis map variable. 22 | 23 | ### User list variables 24 | 25 | The syntax is `"${var.LIST}"`. For example, `"${var.subnets}"` would get the value of the subnets list, as a list. You can also return list elements by index: `${var.subnets[idx]}`. 26 | 27 | ### Path information 28 | 29 | *WIP* 30 | 31 | The syntax is `path.TYPE`. `TYPE` can be `file`, `dir`, or `policies`. cwd will interpolate the current working directory. module will interpolate the path to the current module. root will interpolate the path of the root module. In general, you probably want the path.module variable. 32 | 33 | ```hcl 34 | "${path.file}" 35 | # => manifests/microservices/x-gateway-jp/development/Service/a.yaml 36 | # [Notes] 37 | # this variable is an alias of `filename` variable 38 | 39 | "${path.dir}" 40 | # => manifests/microservices/x-gateway-jp/development/Service 41 | ``` 42 | 43 | ### Predefined variables 44 | 45 | - `filename`: Filename to be applied policy (alias of `path.policy`) 46 | 47 | ### Environment variables information 48 | 49 | The syntax is `env.ENV`. `ENV` can be `USER`, `HOME`, etc. These values comes from `env` command output. 50 | 51 | ```hcl 52 | "${env.HOME}" 53 | # => /home/username 54 | 55 | "${env.EDITOR}" 56 | # => vim 57 | ``` 58 | 59 | ## Conditionals 60 | 61 | Interpolations may contain conditionals to branch on the final value. 62 | 63 | ```hcl 64 | "${var.user == "john" ? var.member : env.anonymous}" 65 | 66 | # => var.member (if var.user is john) 67 | # => var.anonymous (if var.user is not john) 68 | ``` 69 | 70 | The conditional syntax is the well-known ternary operation: 71 | 72 | ``` 73 | CONDITION ? TRUEVAL : FALSEVAL 74 | ``` 75 | 76 | The condition can be any valid interpolation syntax, such as variable access, a function call, or even another conditional. The true and false value can also be any valid interpolation syntax. The returned types by the true and false side must be the same. 77 | 78 | The supported operators are: 79 | 80 | - Equality: `==` and `!=` 81 | - Numerical comparison: `>`, `<`, `>=`, `<=` 82 | - Boolean logic: `&&`, `||`, unary `!` 83 | 84 | # Built-in Functions 85 | 86 | Stein ships with built-in functions. Functions are called with the syntax `name(arg, arg2, ...)`. For example, to read a file: `${file("path.txt")}`. 87 | 88 | Stein supports all Terraform's built-in functions listed in [this page](https://www.terraform.io/docs/configuration/interpolation.html#built-in-functions). 89 | 90 | In addition to these functions, it also comes with the original built-in functions to make it even easier to write rules. 91 | 92 | For more details, please see also ==Built-in Functions== in Navigation bar on left. 93 | 94 | # Custom Functions 95 | 96 | While supporting some useful built-in functions, Stein allows to create user-defined functions. 97 | 98 | ```hcl 99 | function "add" { 100 | params = [a, b] 101 | result = a + b 102 | } 103 | ``` 104 | 105 | ```hcl 106 | "${add(1, 3)}" 107 | # => 4 108 | ``` 109 | 110 | For more details, please see also [Custom Functions](custom-functions.md) 111 | 112 | ## Math 113 | 114 | Almost the same as [Terraform Math](https://www.terraform.io/docs/configuration/interpolation.html#math) mechanism. 115 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | Stein 2 | ===== 3 | 4 | [![][release-badge]][release-link] [![][license-badge]][license-link] 5 | 6 | [release-badge]: https://img.shields.io/github/release/b4b4r07/stein.svg?style=popout 7 | [release-link]: https://github.com/b4b4r07/stein/releases 8 | [license-badge]: https://img.shields.io/github/license/b4b4r07/stein.svg?style=popout 9 | [license-link]: https://b4b4r07.mit-license.org 10 | 11 | ![](https://user-images.githubusercontent.com/4442708/66107167-8a83f800-e5fa-11e9-9719-f7f03624ee46.png) 12 | 13 | Stein is a linter for config files with a customizable rule set. 14 | Supported config file types are JSON, YAML and HCL for now. 15 | 16 | The basic design of this tool are heavily inspired by [HashiCorp Sentinel](https://www.hashicorp.com/sentinel) and its lots of implementations come from [Terraform](https://www.terraform.io/). 17 | 18 | ## Motivation 19 | 20 | As the motivation of this tool, the factor which accounts for the most of them is the [Policy as Code](./concepts/policy-as-code.md). 21 | 22 | Thanks to [Infrastructure as Code](https://en.wikipedia.org/wiki/Infrastructure_as_code), the number of cases that the configurations of its infrastructure are described as code is increasing day by day. 23 | Then, it became necessary to set the lint or policy for the config files. 24 | As an example: the namespace of Kubernetes to be deployed, the number of replicas of Pods, the naming convention of a namespace, etc. 25 | 26 | This tool makes it possible to describe those requests as code (called as the [rules](./configuration/policy/rules.md)). 27 | 28 | Enjoy! 29 | -------------------------------------------------------------------------------- /docs/intro/install.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Installing Stein" 3 | date: 2019-01-17T15:26:15Z 4 | draft: false 5 | weight: 51 6 | 7 | --- 8 | 9 | Installing Stein is simple. There are two approaches to installing Stein: 10 | 11 | 1. Using a precompiled binary 12 | 2. Installing from source 13 | 14 | Downloading a precompiled binary is easiest, and we provide downloads over TLS along with SHA256 sums to verify the binary. We also distribute a PGP signature with the SHA256 sums that can be verified. 15 | 16 | ## Precompiled Binaries 17 | 18 | To install the precompiled binary, download the appropriate package for your system. Stein is currently packaged as a zip file. We do not have any near term plans to provide system packages. 19 | 20 | [Releases · b4b4r07/stein](https://github.com/b4b4r07/stein/releases) 21 | 22 | Once the zip is downloaded, unzip it into any directory. The stein binary inside is all that is necessary to run Stein (or `stein.exe` for Windows). Any additional files, if any, aren't required to run Stein. 23 | 24 | Copy the binary to anywhere on your system. If you intend to access it from the command-line, make sure to place it somewhere on your `PATH`. 25 | 26 | If you're macOS user and using [Homebrew](https://brew.sh/), you can install via brew command: 27 | 28 | ```console 29 | $ brew install b4b4r07/tap/stein 30 | ``` 31 | 32 | ## Compiling from Source 33 | 34 | To compile from source, you will need [Go](https://golang.org/) installed and configured properly (including a `GOPATH` environment variable set), as well as a copy of [git](https://www.git-scm.com/) in your `PATH`. 35 | 36 | First, clone the Stein repository from GitHub into your `GOPATH`: 37 | 38 | ```console 39 | $ mkdir -p $GOPATH/src/github.com/b4b4r07 && cd $_ 40 | $ git clone https://github.com/b4b4r07/stein.git 41 | $ cd stein 42 | ``` 43 | 44 | Then, build Stein for your current system and put the binary in `./bin/` (relative to the git checkout). The make dev target is just a shortcut that builds stein for only your local build environment (no cross-compiled targets). 45 | 46 | ```console 47 | $ make build 48 | ``` 49 | 50 | ## Verifying the Installation 51 | 52 | To verify Stein is properly installed, run `stein -h` on your system. You should see help output. If you are executing it from the command line, make sure it is on your `PATH` or you may get an error about Stein not being found. 53 | 54 | ```console 55 | $ stein -h 56 | ``` 57 | -------------------------------------------------------------------------------- /docs/intro/rules.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Writing Stein rules" 3 | date: 2019-01-17T15:26:15Z 4 | draft: false 5 | weight: 52 6 | 7 | --- 8 | 9 | Let's say you want to create a policy for the next YAML file. 10 | 11 | ```yaml 12 | apiVersion: v1 13 | metadata: 14 | name: my-service 15 | # namespace: echo <-- OMITTED 16 | spec: 17 | selector: 18 | app: MyApp 19 | ports: 20 | - protocol: TCP 21 | port: 80 22 | targetPort: 9376 23 | ``` 24 | 25 | This is Kubernetes YAML of [Service](https://kubernetes.io/docs/concepts/services-networking/service/) manifest. 26 | The field `metadata.namespace` in Service can be omitted. 27 | However, let's say you want to define it explicitly and force the owner to specify this. 28 | In such a case, [rule](../configuration/policy/rules.md) block is useful. 29 | A rule is simple block which can be represented by simple DSL schema by using HCL. 30 | 31 | The rule suitable for this case is as follows. 32 | 33 | ```hcl 34 | rule "namespace_specification" { 35 | description = "Check namespace name is not empty" 36 | 37 | conditions = [ 38 | "${jsonpath("metadata.namespace") != ""}", 39 | ] 40 | 41 | report { 42 | level = "ERROR" 43 | message = "Namespace is not specified" 44 | } 45 | } 46 | ``` 47 | 48 | The most important attributes in rule block is `conditions` list. 49 | 50 | This list is a collections of boolean values. 51 | If this list contains one or more ***false*** values, this rule will fail. 52 | The failed rule will output an error message according to the report block. 53 | 54 | By the way, `jsonpath` is provided as a built-in function. 55 | The available functions are here: [Interpolation Syntax](../configuration/syntax/interpolation.md). 56 | -------------------------------------------------------------------------------- /docs/intro/run.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Running Stein" 3 | date: 2019-01-17T15:26:15Z 4 | draft: false 5 | weight: 53 6 | 7 | --- 8 | 9 | ## Apply stein rules 10 | 11 | After writing up your rules, let's run stein command. 12 | 13 | The Stein CLI is a well-behaved command line application. 14 | In erroneous cases, a non-zero exit status will be returned. 15 | It also responds to `-h` and `--help` as you'd expect. 16 | To view a list of the available commands at any time, just run stein with no arguments. 17 | 18 | To apply the rule to that YAML file and run the test you can do with the [`apply`](../commands/apply.md) subcommand. 19 | 20 | ```console 21 | $ stein apply -policy rules.hcl service.yaml 22 | service.yaml 23 | [ERROR] rule.namespace_specification Namespace is not specified 24 | 25 | ===================== 26 | 1 error(s), 0 warn(s) 27 | ``` 28 | 29 | You can show the error message with exit code `1`. 30 | 31 | The location (a file path directly or a directory path which is located policies) of policy files can be specified with `-policy` flag. 32 | Otherwise, you can tell stein the location of policies with `STEIN_POLICY` environment variable. 33 | 34 | Moreover, stein automatically checks `.policy` directory whether policies written in HCL are located or not when running. 35 | So you can put it on `.policy` directory like the following: 36 | 37 | ```console 38 | $ tree . 39 | service.yaml 40 | .policy/ 41 | `-- rules.hcl 42 | ``` 43 | 44 | For more details about this behavior, see also [How policies are loaded by Stein](../configuration/load.md). 45 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | mkdocs-material~=4.4.0 2 | mkdocs~=1.0.4 3 | pymdown-extensions~=6.0 4 | pygments>=2.7.4 5 | fontawesome_markdown 6 | -------------------------------------------------------------------------------- /fmt.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "io" 9 | "io/ioutil" 10 | "os" 11 | "path/filepath" 12 | "strings" 13 | 14 | "github.com/hashicorp/hcl/hcl/printer" 15 | "github.com/mitchellh/colorstring" 16 | "github.com/sergi/go-diff/diffmatchpatch" 17 | ) 18 | 19 | // FmtCommand is one of the subcommands 20 | type FmtCommand struct { 21 | CLI 22 | Option FmtOption 23 | } 24 | 25 | // FmtOption is the options for FmtCommand 26 | type FmtOption struct { 27 | Check bool 28 | Write bool 29 | } 30 | 31 | func (c *FmtCommand) flagSet() *flag.FlagSet { 32 | flags := flag.NewFlagSet("fmt", flag.ExitOnError) 33 | flags.BoolVar(&c.Option.Check, "check", false, "perform a syntax check on the given files and produce diagnostics") 34 | flags.BoolVar(&c.Option.Write, "write", true, "overwrite source files instead of writing to stdout") 35 | flags.VisitAll(func(f *flag.Flag) { 36 | if s := os.Getenv(strings.ToUpper(envEnvPrefix + f.Name)); s != "" { 37 | f.Value.Set(s) 38 | } 39 | }) 40 | return flags 41 | } 42 | 43 | // Run run fmt command 44 | func (c *FmtCommand) Run(args []string) int { 45 | flags := c.flagSet() 46 | if err := flags.Parse(args); err != nil { 47 | return c.exit(err) 48 | } 49 | 50 | files := flags.Args() 51 | return c.exit(c.fmt(files)) 52 | } 53 | 54 | // Synopsis returns synopsis 55 | func (c *FmtCommand) Synopsis() string { 56 | return "Formats a policy source to a canonical format." 57 | } 58 | 59 | // Help returns help message 60 | func (c *FmtCommand) Help() string { 61 | var b bytes.Buffer 62 | flags := c.flagSet() 63 | flags.SetOutput(&b) 64 | flags.PrintDefaults() 65 | return fmt.Sprintf( 66 | "Usage of %s:\n %s\n\nOptions:\n%s", flags.Name(), c.Synopsis(), b.String(), 67 | ) 68 | } 69 | 70 | func (c *FmtCommand) fmt(files []string) error { 71 | if len(files) == 0 { 72 | if c.Option.Write { 73 | return errors.New("cannot use -w without source files") 74 | } 75 | 76 | return c.processFile("", os.Stdin, os.Stdout) 77 | } 78 | 79 | for _, file := range files { 80 | switch dir, err := os.Stat(file); { 81 | case err != nil: 82 | return err 83 | case dir.IsDir(): 84 | // This tool can't walk a whole directory because it doesn't 85 | // know what file naming schemes will be used by different 86 | // HCL-embedding applications, so it'll leave that sort of 87 | // functionality for apps themselves to implement. 88 | // return fmt.Errorf("can't format directory %s", file) 89 | c.walkDir(file) 90 | default: 91 | if err := c.processFile(file, nil, os.Stdout); err != nil { 92 | return err 93 | } 94 | } 95 | } 96 | return nil 97 | } 98 | 99 | func (c *FmtCommand) processFile(filename string, in io.Reader, out io.Writer) error { 100 | if in == nil { 101 | f, err := os.Open(filename) 102 | if err != nil { 103 | return err 104 | } 105 | defer f.Close() 106 | in = f 107 | } 108 | 109 | src, err := ioutil.ReadAll(in) 110 | if err != nil { 111 | return err 112 | } 113 | 114 | formatted, err := printer.Format(src) 115 | if err != nil { 116 | return err 117 | } 118 | 119 | if c.Option.Check { 120 | printDiff(lineDiff(string(src), string(formatted))) 121 | return nil 122 | } 123 | 124 | if c.Option.Write { 125 | err = ioutil.WriteFile(filename, formatted, 0644) 126 | } else { 127 | _, err = out.Write(formatted) 128 | } 129 | 130 | return err 131 | } 132 | 133 | func isHCL(f os.FileInfo) bool { 134 | // ignore non-hcl files 135 | name := f.Name() 136 | return !f.IsDir() && !strings.HasPrefix(name, ".") && strings.HasSuffix(name, ".hcl") 137 | } 138 | 139 | func (c *FmtCommand) walkDir(path string) { 140 | filepath.Walk(path, c.visitFile) 141 | } 142 | 143 | func (c *FmtCommand) visitFile(path string, f os.FileInfo, err error) error { 144 | if err == nil && isHCL(f) { 145 | err = c.processFile(path, nil, os.Stdout) 146 | } 147 | 148 | return err 149 | } 150 | 151 | func lineDiff(src1, src2 string) []diffmatchpatch.Diff { 152 | dmp := diffmatchpatch.New() 153 | 154 | a, b, c := dmp.DiffLinesToChars(src1, src2) 155 | diffs := dmp.DiffMain(a, b, false) 156 | result := dmp.DiffCharsToLines(diffs, c) 157 | 158 | return result 159 | } 160 | 161 | func printDiff(diffs []diffmatchpatch.Diff) { 162 | for _, d := range diffs { 163 | lines := strings.Split(strings.TrimRight(d.Text, "\n"), "\n") 164 | var prefix string 165 | switch d.Type { 166 | case diffmatchpatch.DiffDelete: 167 | prefix = "[red]- " 168 | case diffmatchpatch.DiffInsert: 169 | prefix = "[green]+ " 170 | case diffmatchpatch.DiffEqual: 171 | prefix = " " 172 | } 173 | for _, l := range lines { 174 | s := fmt.Sprintf("%s %s", prefix, l) 175 | fmt.Println(colorstring.Color(s)) 176 | } 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/b4b4r07/stein 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/apparentlymart/go-cidr v1.0.0 // indirect 7 | github.com/armon/go-radix v1.0.0 // indirect 8 | github.com/b4b4r07/go-pathshorten v0.0.0-20170605152316-b07af83744ea 9 | github.com/davecgh/go-spew v1.1.1 10 | github.com/fatih/color v1.7.0 11 | github.com/hashicorp/hcl v1.0.0 12 | github.com/hashicorp/hcl2 v0.0.0-20191002203319-fb75b3253c80 13 | github.com/hashicorp/logutils v0.0.0-20150609070431-0dc08b1671f3 14 | github.com/hashicorp/terraform v0.12.0-alpha4 15 | github.com/mattn/go-isatty v0.0.4 // indirect 16 | github.com/mitchellh/cli v1.0.0 17 | github.com/mitchellh/colorstring v0.0.0-20150917214807-8631ce90f286 18 | github.com/mitchellh/go-homedir v1.0.0 // indirect 19 | github.com/posener/complete v1.2.1 // indirect 20 | github.com/sergi/go-diff v1.0.0 21 | github.com/tidwall/gjson v1.6.5 22 | github.com/zclconf/go-cty v1.1.1 23 | k8s.io/apimachinery v0.19.0-beta.2 24 | ) 25 | -------------------------------------------------------------------------------- /lint/args.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "io/ioutil" 9 | "log" 10 | "os" 11 | "path/filepath" 12 | 13 | "github.com/hashicorp/hcl2/hcl" 14 | "github.com/hashicorp/hcl2/hcl/hclsyntax" 15 | 16 | "k8s.io/apimachinery/pkg/util/yaml" 17 | 18 | "github.com/b4b4r07/stein/lint/hclconvert" 19 | "github.com/b4b4r07/stein/lint/internal/policy" 20 | "github.com/b4b4r07/stein/lint/internal/policy/loader" 21 | ) 22 | 23 | // File represents the files to be linted 24 | // It's converted from the arguments 25 | type File struct { 26 | Path string 27 | Data []byte 28 | 29 | // Meta field means the annotation 30 | Meta string 31 | 32 | // File struct has Policy data 33 | // because policy applied to the file should be determined by each file 34 | Policy loader.Policy 35 | 36 | Diagnostics hcl.Diagnostics 37 | } 38 | 39 | // filesFromArgs converts from given arguments to the collection of File object 40 | func filesFromArgs(args []string, additionals ...string) (files []File, err error) { 41 | log.Printf("[TRACE] converting from args to lint.Files\n") 42 | 43 | for _, arg := range args { 44 | var diags hcl.Diagnostics 45 | log.Printf("[INFO] converting lint.File: %s\n", arg) 46 | policies := loader.SearchPolicyDir(arg) 47 | policies = append(policies, additionals...) 48 | log.Printf("[INFO] policies: %#v\n", policies) 49 | 50 | loadedPolicy, err := loader.Load(policies...) 51 | if err != nil { 52 | switch e := err.(type) { 53 | case hcl.Diagnostics: 54 | log.Printf("[DEBUG] diags reported from loader.Load in fileFromArg\n") 55 | diags = append(diags, e...) 56 | case error: 57 | return files, err 58 | } 59 | } 60 | 61 | data, decodeDiags := policy.Decode(loadedPolicy.Body) 62 | diags = append(diags, decodeDiags...) 63 | 64 | loadedPolicy.Data = data 65 | 66 | ext := filepath.Ext(arg) 67 | switch ext { 68 | case ".yaml", ".yml": 69 | yamlFiles, err := handleYAML(arg) 70 | if err != nil { 71 | return files, err 72 | } 73 | log.Printf("[TRACE] %d block(s) found in YAML: %s\n", len(yamlFiles), arg) 74 | for _, file := range yamlFiles { 75 | file.Policy = loadedPolicy 76 | file.Diagnostics = diags 77 | files = append(files, file) 78 | } 79 | case ".json": 80 | data, err := ioutil.ReadFile(arg) 81 | if err != nil { 82 | return files, err 83 | } 84 | files = append(files, File{ 85 | Path: arg, 86 | Data: data, 87 | Policy: loadedPolicy, 88 | Diagnostics: diags, 89 | }) 90 | case ".hcl", ".tf": 91 | contents, err := ioutil.ReadFile(arg) 92 | if err != nil { 93 | return files, err 94 | } 95 | f, d := hclsyntax.ParseConfig(contents, arg, hcl.Pos{Line: 1, Column: 1}) 96 | if d.HasErrors() { 97 | return files, fmt.Errorf("unable to parse HCL: %s", d.Error()) 98 | } 99 | 100 | v, err := hclconvert.ConvertFile(f) 101 | if err != nil { 102 | return files, fmt.Errorf("unable to convert HCL: %s", err) 103 | } 104 | 105 | data, err := json.MarshalIndent(v, "", " ") 106 | if err != nil { 107 | return files, fmt.Errorf("unable to marshal json: %s", err) 108 | } 109 | 110 | files = append(files, File{ 111 | Path: arg, 112 | Data: data, 113 | Policy: loadedPolicy, 114 | Diagnostics: diags, 115 | }) 116 | default: 117 | return files, fmt.Errorf("%q (%s): unsupported file type", arg, ext) 118 | } 119 | } 120 | 121 | return files, nil 122 | } 123 | 124 | func handleYAML(path string) (files []File, err error) { 125 | file, err := os.Open(path) 126 | if err != nil { 127 | return files, err 128 | } 129 | defer file.Close() 130 | 131 | fi, err := file.Stat() 132 | if err != nil { 133 | return files, err 134 | } 135 | 136 | dd := yaml.NewDocumentDecoder(file) 137 | defer dd.Close() 138 | var documents [][]byte 139 | for { 140 | res := make([]byte, fi.Size()) 141 | _, err := dd.Read(res) 142 | if err == io.EOF { 143 | break 144 | } 145 | if err != nil { 146 | return files, err 147 | } 148 | documents = append(documents, bytes.Trim(res, "\x00")) 149 | } 150 | 151 | for idx, document := range documents { 152 | data, err := yaml.ToJSON(document) 153 | if err != nil { 154 | return files, err 155 | } 156 | meta := "" 157 | if len(documents) > 1 { 158 | // If one or more blocks are defined in one YAML file, 159 | // records the numbering of the block in Meta field 160 | meta = fmt.Sprintf("Block %d", idx+1) 161 | } 162 | files = append(files, File{ 163 | Path: path, 164 | Data: data, 165 | Meta: meta, 166 | }) 167 | } 168 | 169 | return files, err 170 | } 171 | -------------------------------------------------------------------------------- /lint/args_test.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import ( 4 | "bufio" 5 | "io/ioutil" 6 | "os" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | func Test_filesFromArgs(t *testing.T) { 12 | tests := []struct { 13 | Filename string 14 | Want string 15 | }{ 16 | { 17 | "testdata/01.tf", 18 | `{ 19 | "provider": [ 20 | { 21 | "google": [ 22 | { 23 | "project": "my-project-id", 24 | "region": "us-central1" 25 | } 26 | ] 27 | } 28 | ] 29 | }`, 30 | }, 31 | { 32 | "testdata/02.tf", 33 | `{ 34 | "provider": [ 35 | { 36 | "google": [ 37 | { 38 | "project": "my-project-id", 39 | "region": "us-central1" 40 | } 41 | ] 42 | }, 43 | { 44 | "aws": [ 45 | { 46 | "region": "us-east-1", 47 | "version": "~\u003e 2.0" 48 | } 49 | ] 50 | }, 51 | { 52 | "google": [ 53 | { 54 | "alias": "west", 55 | "project": "my-project-id", 56 | "region": "us-west1" 57 | } 58 | ] 59 | } 60 | ] 61 | }`, 62 | }, 63 | { 64 | "testdata/03.tf", 65 | `{ 66 | "module": [ 67 | { 68 | "foo": [ 69 | { 70 | "array": [ 71 | "val1", 72 | "val2" 73 | ], 74 | "bar": "baz" 75 | } 76 | ] 77 | } 78 | ] 79 | }`, 80 | }, 81 | { 82 | "testdata/04.tf", 83 | `{ 84 | "resource": [ 85 | { 86 | "google_project": [ 87 | { 88 | "my_project": [ 89 | { 90 | "name": "My Project", 91 | "org_id": "1234567", 92 | "project_id": "your-project-id" 93 | } 94 | ] 95 | } 96 | ] 97 | } 98 | ] 99 | }`, 100 | }, 101 | } 102 | 103 | for _, test := range tests { 104 | t.Run(test.Filename, func(t *testing.T) { 105 | files, err := filesFromArgs([]string{test.Filename}) 106 | if err != nil { 107 | t.Fatalf("unexpected error: %s", err) 108 | } 109 | if len(files) != 1 { 110 | t.Errorf("unexpected files length: got %d, want 1", len(files)) 111 | } 112 | got := string(files[0].Data) 113 | if got != test.Want { 114 | t.Errorf("wrong result: got: %#v, want: %#v", got, test.Want) 115 | } 116 | }) 117 | } 118 | } 119 | 120 | // This test is based on https://github.com/kubernetes/apimachinery/blob/d8530e6c952f75365336be8ea29cfd758ce49ee8/pkg/util/yaml/decoder_test.go#L57-L82 121 | func Test_handleYAML_LineTooLong(t *testing.T) { 122 | tmpfile, err := ioutil.TempFile("", "Test_handleYAML_LineTooLong") 123 | if err != nil { 124 | t.Fatalf("failed to create temporary file: %s", err) 125 | } 126 | defer os.Remove(tmpfile.Name()) 127 | 128 | d := ` 129 | stuff: 1 130 | ` 131 | // maxLen 5 M 132 | dd := strings.Repeat(d, 512*1024) 133 | if _, err := tmpfile.WriteString(dd); err != nil { 134 | t.Fatalf("failed to write to temporary file: %s", err) 135 | } 136 | if err := tmpfile.Close(); err != nil { 137 | t.Fatalf("failed to close temporary file: %s", err) 138 | } 139 | 140 | _, err = handleYAML(tmpfile.Name()) 141 | if err != bufio.ErrTooLong { 142 | t.Fatalf("want %q, got %q", bufio.ErrTooLong, err) 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /lint/hclconvert/blocktree.go: -------------------------------------------------------------------------------- 1 | package hclconvert 2 | 3 | type blockTree struct { 4 | name string 5 | subTrees []*blockTree 6 | values []interface{} 7 | } 8 | 9 | func NewBlockTree(name string) *blockTree { 10 | bt := &blockTree{} 11 | bt.name = name 12 | bt.subTrees = []*blockTree{} 13 | bt.values = make([]interface{}, 0) 14 | return bt 15 | } 16 | 17 | func (bt *blockTree) appendSubTree(s *blockTree) { 18 | bt.subTrees = append(bt.subTrees, s) 19 | } 20 | 21 | func (bt *blockTree) appendValue(v interface{}) { 22 | bt.values = append(bt.values, v) 23 | } 24 | 25 | func (bt *blockTree) out() map[string][]interface{} { 26 | out := make(map[string][]interface{}) 27 | if len(bt.subTrees) > 0 { 28 | out[bt.name] = make([]interface{}, len(bt.subTrees)) 29 | for i, s := range bt.subTrees { 30 | out[bt.name][i] = s.out() 31 | } 32 | return out 33 | 34 | } else if len(bt.values) > 0 { 35 | out[bt.name] = make([]interface{}, len(bt.values)) 36 | copy(out[bt.name], bt.values) 37 | return out 38 | } 39 | return nil 40 | } 41 | 42 | func mergeSliceMap(m1 map[string][]interface{}, m2 map[string][]interface{}) map[string][]interface{} { 43 | result := map[string][]interface{}{} 44 | for k, v := range m1 { 45 | result[k] = v 46 | } 47 | for k, v := range m2 { 48 | if s, ok := result[k]; ok { 49 | result[k] = append(s, v...) 50 | } else { 51 | result[k] = v 52 | } 53 | } 54 | return result 55 | } 56 | -------------------------------------------------------------------------------- /lint/hclconvert/convert.go: -------------------------------------------------------------------------------- 1 | package hclconvert 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/hashicorp/hcl2/hcl" 7 | "github.com/hashicorp/hcl2/hcl/hclsyntax" 8 | "github.com/zclconf/go-cty/cty" 9 | ctyconvert "github.com/zclconf/go-cty/cty/convert" 10 | ctyjson "github.com/zclconf/go-cty/cty/json" 11 | ) 12 | 13 | // This file is attributed to https://github.com/tmccombs/hcl2json. 14 | // convertBlock() is customized to create the same data structure as by previous stein version 15 | 16 | type jsonObj map[string]interface{} 17 | 18 | // Convert an hcl File to a json serializable object 19 | // This assumes that the body is a hclsyntax.Body 20 | func ConvertFile(file *hcl.File) (jsonObj, error) { 21 | c := converter{bytes: file.Bytes} 22 | body := file.Body.(*hclsyntax.Body) 23 | return c.convertBody(body) 24 | } 25 | 26 | type converter struct { 27 | bytes []byte 28 | } 29 | 30 | func (c *converter) rangeSource(r hcl.Range) string { 31 | return string(c.bytes[r.Start.Byte:r.End.Byte]) 32 | } 33 | 34 | func (c *converter) convertBody(body *hclsyntax.Body) (jsonObj, error) { 35 | var err error 36 | out := make(jsonObj) 37 | for key, value := range body.Attributes { 38 | out[key], err = c.convertExpression(value.Expr) 39 | if err != nil { 40 | return nil, err 41 | } 42 | } 43 | 44 | for _, block := range body.Blocks { 45 | err = c.convertBlock(block, out) 46 | if err != nil { 47 | return nil, err 48 | } 49 | } 50 | 51 | return out, nil 52 | } 53 | 54 | func (c *converter) convertBlock(block *hclsyntax.Block, out jsonObj) error { 55 | var key string = block.Type 56 | 57 | value, err := c.convertBody(block.Body) 58 | if err != nil { 59 | return err 60 | } 61 | 62 | // This implementation is different from hcl2json's 63 | root := NewBlockTree(key) 64 | current := root 65 | for _, label := range block.Labels { 66 | inner := NewBlockTree(label) 67 | current.appendSubTree(inner) 68 | current = inner 69 | } 70 | current.appendValue(value) 71 | result := root.out() 72 | 73 | if _, ok := out[key]; ok { 74 | tmp := map[string][]interface{}{} 75 | tmp[key] = out[key].([]interface{}) 76 | result = mergeSliceMap(tmp, result) 77 | } 78 | out[key] = result[key] 79 | 80 | return nil 81 | } 82 | 83 | func (c *converter) convertExpression(expr hclsyntax.Expression) (interface{}, error) { 84 | // assume it is hcl syntax (because, um, it is) 85 | switch value := expr.(type) { 86 | case *hclsyntax.LiteralValueExpr: 87 | return ctyjson.SimpleJSONValue{Value: value.Val}, nil 88 | case *hclsyntax.TemplateExpr: 89 | return c.convertTemplate(value) 90 | case *hclsyntax.TemplateWrapExpr: 91 | return c.convertExpression(value.Wrapped) 92 | case *hclsyntax.TupleConsExpr: 93 | var list []interface{} 94 | for _, ex := range value.Exprs { 95 | elem, err := c.convertExpression(ex) 96 | if err != nil { 97 | return nil, err 98 | } 99 | list = append(list, elem) 100 | } 101 | return list, nil 102 | case *hclsyntax.ObjectConsExpr: 103 | m := make(jsonObj) 104 | for _, item := range value.Items { 105 | key, err := c.convertKey(item.KeyExpr) 106 | if err != nil { 107 | return nil, err 108 | } 109 | m[key], err = c.convertExpression(item.ValueExpr) 110 | if err != nil { 111 | return nil, err 112 | } 113 | } 114 | return m, nil 115 | default: 116 | return c.wrapExpr(expr), nil 117 | } 118 | } 119 | 120 | func (c *converter) convertTemplate(t *hclsyntax.TemplateExpr) (string, error) { 121 | if t.IsStringLiteral() { 122 | // safe because the value is just the string 123 | v, err := t.Value(nil) 124 | if err != nil { 125 | return "", err 126 | } 127 | return v.AsString(), nil 128 | } 129 | var builder strings.Builder 130 | for _, part := range t.Parts { 131 | s, err := c.convertStringPart(part) 132 | if err != nil { 133 | return "", err 134 | } 135 | builder.WriteString(s) 136 | } 137 | return builder.String(), nil 138 | } 139 | 140 | func (c *converter) convertStringPart(expr hclsyntax.Expression) (string, error) { 141 | switch v := expr.(type) { 142 | case *hclsyntax.LiteralValueExpr: 143 | s, err := ctyconvert.Convert(v.Val, cty.String) 144 | if err != nil { 145 | return "", err 146 | } 147 | return s.AsString(), nil 148 | case *hclsyntax.TemplateExpr: 149 | return c.convertTemplate(v) 150 | case *hclsyntax.TemplateWrapExpr: 151 | return c.convertStringPart(v.Wrapped) 152 | case *hclsyntax.ConditionalExpr: 153 | return c.convertTemplateConditional(v) 154 | case *hclsyntax.TemplateJoinExpr: 155 | return c.convertTemplateFor(v.Tuple.(*hclsyntax.ForExpr)) 156 | default: 157 | // treating as an embedded expression 158 | return c.wrapExpr(expr), nil 159 | } 160 | } 161 | 162 | func (c *converter) convertKey(keyExpr hclsyntax.Expression) (string, error) { 163 | // a key should never have dynamic input 164 | if k, isKeyExpr := keyExpr.(*hclsyntax.ObjectConsKeyExpr); isKeyExpr { 165 | keyExpr = k.Wrapped 166 | if _, isTraversal := keyExpr.(*hclsyntax.ScopeTraversalExpr); isTraversal { 167 | return c.rangeSource(keyExpr.Range()), nil 168 | } 169 | } 170 | return c.convertStringPart(keyExpr) 171 | } 172 | 173 | func (c *converter) convertTemplateConditional(expr *hclsyntax.ConditionalExpr) (string, error) { 174 | var builder strings.Builder 175 | builder.WriteString("%{if ") 176 | builder.WriteString(c.rangeSource(expr.Condition.Range())) 177 | builder.WriteString("}") 178 | trueResult, err := c.convertStringPart(expr.TrueResult) 179 | if err != nil { 180 | return "", err 181 | } 182 | builder.WriteString(trueResult) 183 | falseResult, err := c.convertStringPart(expr.FalseResult) 184 | if err != nil { 185 | return "", err 186 | } 187 | if len(falseResult) > 0 { 188 | builder.WriteString("%{else}") 189 | builder.WriteString(falseResult) 190 | } 191 | builder.WriteString("%{endif}") 192 | 193 | return builder.String(), nil 194 | } 195 | 196 | func (c *converter) convertTemplateFor(expr *hclsyntax.ForExpr) (string, error) { 197 | var builder strings.Builder 198 | builder.WriteString("%{for ") 199 | if len(expr.KeyVar) > 0 { 200 | builder.WriteString(expr.KeyVar) 201 | builder.WriteString(", ") 202 | } 203 | builder.WriteString(expr.ValVar) 204 | builder.WriteString(" in ") 205 | builder.WriteString(c.rangeSource(expr.CollExpr.Range())) 206 | builder.WriteString("}") 207 | templ, err := c.convertStringPart(expr.ValExpr) 208 | if err != nil { 209 | return "", err 210 | } 211 | builder.WriteString(templ) 212 | builder.WriteString("%{endfor}") 213 | 214 | return builder.String(), nil 215 | } 216 | 217 | func (c *converter) wrapExpr(expr hclsyntax.Expression) string { 218 | return "${" + c.rangeSource(expr.Range()) + "}" 219 | } 220 | -------------------------------------------------------------------------------- /lint/internal/policy/config.go: -------------------------------------------------------------------------------- 1 | package policy 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/hashicorp/hcl2/hcl" 7 | ) 8 | 9 | // Config is 10 | type Config struct { 11 | Name string 12 | 13 | Config hcl.Body 14 | 15 | TypeRange hcl.Range 16 | DeclRange hcl.Range 17 | 18 | // can ignore (TODO) 19 | Report *ReportConfig 20 | } 21 | 22 | // ReportConfig is 23 | type ReportConfig struct { 24 | Name string 25 | 26 | Config hcl.Body 27 | 28 | TypeRange hcl.Range 29 | DeclRange hcl.Range 30 | } 31 | 32 | var configBlockSchema = &hcl.BodySchema{ 33 | Blocks: []hcl.BlockHeaderSchema{ 34 | { 35 | Type: "report", 36 | // LabelNames: []string{"name"}, 37 | }, 38 | }, 39 | } 40 | 41 | func decodeConfigBlock(block *hcl.Block) (*Config, hcl.Diagnostics) { 42 | // attrs, diags := block.Body.JustAttributes() 43 | // if len(attrs) == 0 { 44 | // return nil, diags 45 | // } 46 | // 47 | // // locals := make([]*Local, 0, len(attrs)) 48 | // var config *Config 49 | // for name, attr := range attrs { 50 | // if !hclsyntax.ValidIdentifier(name) { 51 | // diags = append(diags, &hcl.Diagnostic{ 52 | // Severity: hcl.DiagError, 53 | // Summary: "Invalid config value name", 54 | // Detail: badIdentifierDetail, 55 | // Subject: &attr.NameRange, 56 | // }) 57 | // } 58 | // 59 | // config = &Config{ 60 | // Name: name, 61 | // // Expr: attr.Expr, 62 | // DeclRange: attr.Range, 63 | // } 64 | // } 65 | // 66 | // return config, diags 67 | 68 | cfg := &Config{ 69 | DeclRange: block.DefRange, 70 | // TypeRange: block.LabelRanges[0], 71 | } 72 | content, remain, diags := block.Body.PartialContent(configBlockSchema) 73 | cfg.Config = remain 74 | 75 | for _, block := range content.Blocks { 76 | switch block.Type { 77 | case "report": 78 | report, reportDiags := decodeReportConfigBlock(block) 79 | diags = append(diags, reportDiags...) 80 | if report != nil { 81 | cfg.Report = report 82 | } 83 | default: 84 | continue 85 | } 86 | } 87 | 88 | return cfg, diags 89 | } 90 | 91 | func decodeReportConfigBlock(block *hcl.Block) (*ReportConfig, hcl.Diagnostics) { 92 | content, config, diags := block.Body.PartialContent(&hcl.BodySchema{ 93 | Attributes: []hcl.AttributeSchema{ 94 | { 95 | Name: "format", 96 | // Required: true, 97 | }, 98 | { 99 | Name: "style", 100 | // Required: true, 101 | }, 102 | { 103 | Name: "color", 104 | // Required: true, 105 | }, 106 | }, 107 | }) 108 | rc := &ReportConfig{ 109 | Config: config, 110 | DeclRange: block.DefRange, 111 | } 112 | 113 | if attr, exists := content.Attributes["style"]; exists { 114 | val, valDiags := attr.Expr.Value(nil) 115 | diags = append(diags, valDiags...) 116 | style := val.AsString() 117 | switch style { 118 | case "console": 119 | default: 120 | diags = append(diags, &hcl.Diagnostic{ 121 | Severity: hcl.DiagError, 122 | Summary: "Invalid style value", 123 | Detail: fmt.Sprintf("got %q but want %q", style, []string{"console"}), 124 | Subject: &attr.NameRange, 125 | }) 126 | } 127 | } 128 | 129 | return rc, diags 130 | } 131 | -------------------------------------------------------------------------------- /lint/internal/policy/context.go: -------------------------------------------------------------------------------- 1 | package policy 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/b4b4r07/stein/lint/internal/policy/funcs" 10 | "github.com/b4b4r07/stein/lint/internal/policy/terraform" 11 | "github.com/hashicorp/hcl2/ext/userfunc" 12 | "github.com/hashicorp/hcl2/hcl" 13 | "github.com/zclconf/go-cty/cty" 14 | "github.com/zclconf/go-cty/cty/convert" 15 | "github.com/zclconf/go-cty/cty/function" 16 | ) 17 | 18 | // BuildContext is 19 | func (p Policy) BuildContext(body hcl.Body, filename string, filedata []byte) (*hcl.EvalContext, hcl.Diagnostics) { 20 | ctx := &hcl.EvalContext{ 21 | Variables: map[string]cty.Value{ 22 | "filename": cty.StringVal(filename), // alias of path.filename 23 | "path": cty.ObjectVal(map[string]cty.Value{ 24 | "file": cty.StringVal(filename), 25 | "dir": cty.StringVal(filepath.Base(filename)), 26 | }), 27 | }, 28 | Functions: map[string]function.Function{ 29 | // jsonpath 30 | "jsonpath": funcs.GJSONFunc(filename, filedata), 31 | // filepath 32 | "glob": funcs.GlobFunc, 33 | "pathshorten": funcs.PathShortenFunc, 34 | "ext": funcs.ExtFunc, 35 | "exist": funcs.ExistFunc, 36 | // basic 37 | "match": funcs.MatchFunc, 38 | "color": funcs.ColorFunc, 39 | "hoge": funcs.HogeFunc, 40 | // assertion 41 | // "equal": funcs.EqualFunc, // Disable 42 | // "type": funcs.TypeFunc, // Disable 43 | // unix 44 | "grep": funcs.GrepFunc, 45 | "wc": funcs.WcFunc, 46 | // collection 47 | "lookuplist": funcs.LookupListFunc, 48 | }, 49 | } 50 | 51 | functions, body, diags := userfunc.DecodeUserFunctions(body, "function", func() *hcl.EvalContext { 52 | return ctx 53 | }) 54 | 55 | wantType := cty.DynamicPseudoType 56 | 57 | variableMap := map[string]cty.Value{} 58 | for _, variable := range p.Variables { 59 | val, err := convert.Convert(variable.Default, wantType) 60 | if err != nil { 61 | // We should never get here because this problem should've been caught 62 | // during earlier validation, but we'll do something reasonable anyway. 63 | diags = diags.Append(&hcl.Diagnostic{ 64 | Severity: hcl.DiagError, 65 | Summary: `Incorrect variable type`, 66 | Detail: fmt.Sprintf(`The resolved value of variable %q is not appropriate: %s.`, "", err), 67 | Subject: &variable.DeclRange, 68 | }) 69 | // Stub out our return value so that the semantic checker doesn't 70 | // produce redundant downstream errors. 71 | val = cty.UnknownVal(wantType) 72 | } 73 | variableMap[variable.Name] = val 74 | } 75 | ctx.Variables["var"] = cty.ObjectVal(variableMap) 76 | 77 | envs := make(map[string]cty.Value) 78 | for _, env := range os.Environ() { 79 | key := strings.Split(env, "=")[0] 80 | val, _ := os.LookupEnv(key) 81 | envs[key] = cty.StringVal(val) 82 | } 83 | ctx.Variables["env"] = cty.ObjectVal(envs) 84 | 85 | for name, f := range functions { 86 | ctx.Functions[name] = f 87 | } 88 | 89 | // TODO 90 | for name, f := range terraform.Functions(os.Getenv("PWD")) { 91 | ctx.Functions[name] = f 92 | } 93 | 94 | // expandFuncs := map[string]function.Function{ 95 | // "maphoge": funcs.MapHogeFunc, 96 | // } 97 | // for name, f := range expandFuncs{ 98 | // ctx.Functions[name] = f 99 | // } 100 | ctx.Functions["maphoge"] = funcs.MapHogeFunc(ctx) 101 | return ctx, diags 102 | } 103 | -------------------------------------------------------------------------------- /lint/internal/policy/funcs/assertion.go: -------------------------------------------------------------------------------- 1 | package funcs 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/zclconf/go-cty/cty" 7 | "github.com/zclconf/go-cty/cty/function" 8 | ) 9 | 10 | // EqualFunc is 11 | var EqualFunc = function.New(&function.Spec{ 12 | Params: []function.Parameter{ 13 | { 14 | Name: "a", 15 | Type: cty.DynamicPseudoType, 16 | AllowUnknown: true, 17 | AllowDynamicType: true, 18 | AllowNull: true, 19 | }, 20 | { 21 | Name: "b", 22 | Type: cty.DynamicPseudoType, 23 | AllowUnknown: true, 24 | AllowDynamicType: true, 25 | AllowNull: true, 26 | }, 27 | }, 28 | Type: function.StaticReturnType(cty.Bool), 29 | Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { 30 | a := args[0] 31 | b := args[1] 32 | if a.Type() != b.Type() { 33 | return cty.NilVal, fmt.Errorf("type not same: left is %q but right is %q", a.Type().FriendlyName(), b.Type().FriendlyName()) 34 | } 35 | return a.Equals(b), nil 36 | }, 37 | }) 38 | 39 | // TypeFunc is 40 | var TypeFunc = function.New(&function.Spec{ 41 | Params: []function.Parameter{ 42 | { 43 | Name: "arg", 44 | Type: cty.DynamicPseudoType, 45 | AllowUnknown: true, 46 | AllowDynamicType: true, 47 | AllowNull: true, 48 | }, 49 | }, 50 | Type: function.StaticReturnType(cty.String), 51 | Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { 52 | return cty.StringVal(args[0].Type().FriendlyName()), nil 53 | }, 54 | }) 55 | -------------------------------------------------------------------------------- /lint/internal/policy/funcs/basic.go: -------------------------------------------------------------------------------- 1 | package funcs 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | 8 | "github.com/fatih/color" 9 | "github.com/hashicorp/hcl2/hcl" 10 | "github.com/zclconf/go-cty/cty" 11 | "github.com/zclconf/go-cty/cty/function" 12 | ) 13 | 14 | // https://godoc.org/github.com/apparentlymart/go-cty/cty/function/stdlib 15 | 16 | // HogeFunc is 17 | var HogeFunc = function.New(&function.Spec{ 18 | Params: []function.Parameter{ 19 | { 20 | Name: "str", 21 | Type: cty.String, 22 | }, 23 | }, 24 | Type: function.StaticReturnType(cty.String), 25 | Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { 26 | return cty.StringVal(args[0].AsString() + " hoge"), nil 27 | }, 28 | }) 29 | 30 | // MapHogeFunc is 31 | func MapHogeFunc(ctx *hcl.EvalContext) function.Function { 32 | return function.New(&function.Spec{ 33 | Params: []function.Parameter{ 34 | { 35 | Name: "fn", 36 | Type: cty.String, 37 | }, 38 | // { 39 | // Name: "params", 40 | // Type: cty.List(cty.String), 41 | // }, 42 | }, 43 | VarParam: &function.Parameter{ 44 | Name: "params", 45 | Type: cty.String, 46 | }, 47 | Type: function.StaticReturnType(cty.String), 48 | Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { 49 | fn := args[0].AsString() 50 | // TODO 51 | return ctx.Functions[fn].Call(args[1:]) 52 | }, 53 | }) 54 | } 55 | 56 | // MatchFunc is 57 | var MatchFunc = function.New(&function.Spec{ 58 | Params: []function.Parameter{ 59 | { 60 | Name: "pattern", 61 | Type: cty.String, 62 | }, 63 | { 64 | Name: "text", 65 | Type: cty.String, 66 | }, 67 | }, 68 | Type: function.StaticReturnType(cty.Bool), 69 | Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { 70 | pattern := args[0].AsString() 71 | text := args[1].AsString() 72 | 73 | matched, err := regexp.MatchString(pattern, text) 74 | if err != nil { 75 | return cty.NilVal, err 76 | } 77 | 78 | return cty.BoolVal(matched), nil 79 | }, 80 | }) 81 | 82 | // ColorFunc is 83 | var ColorFunc = function.New(&function.Spec{ 84 | Params: []function.Parameter{ 85 | { 86 | Name: "text", 87 | Type: cty.String, 88 | }, 89 | }, 90 | VarParam: &function.Parameter{ 91 | Name: "attrs", 92 | Type: cty.String, 93 | }, 94 | Type: function.StaticReturnType(cty.String), 95 | Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { 96 | text := args[0].AsString() 97 | var attrs []color.Attribute 98 | for _, arg := range args[1:] { 99 | attr := strings.ToLower(arg.AsString()) 100 | switch attr { 101 | // Fg 102 | case "black", "FgBlack", "fgblack": 103 | attrs = append(attrs, color.FgBlack) 104 | case "red", "FgRed", "fgred": 105 | attrs = append(attrs, color.FgRed) 106 | case "green", "FgGreen", "fggreen": 107 | attrs = append(attrs, color.FgGreen) 108 | case "yellow", "FgYellow", "fgyellow": 109 | attrs = append(attrs, color.FgYellow) 110 | case "blue", "FgBlue", "fgblue": 111 | attrs = append(attrs, color.FgBlue) 112 | case "magenta", "FgMagenta", "fgmagenta": 113 | attrs = append(attrs, color.FgMagenta) 114 | case "cyan", "FgCyan", "fgcyan": 115 | attrs = append(attrs, color.FgCyan) 116 | case "white", "FgWhite", "fgwhite": 117 | attrs = append(attrs, color.FgWhite) 118 | // Bg 119 | case "BgBlack", "bgblack": 120 | attrs = append(attrs, color.BgBlack) 121 | case "BgRed", "bgred": 122 | attrs = append(attrs, color.BgRed) 123 | case "BgGreen", "bggreen": 124 | attrs = append(attrs, color.BgGreen) 125 | case "BgYellow", "bgyellow": 126 | attrs = append(attrs, color.BgYellow) 127 | case "BgBlue", "bgblue": 128 | attrs = append(attrs, color.BgBlue) 129 | case "BgMagenta", "bgmagenta": 130 | attrs = append(attrs, color.BgMagenta) 131 | case "BgCyan", "bgcyan": 132 | attrs = append(attrs, color.BgCyan) 133 | case "BgWhite", "bgwhite": 134 | attrs = append(attrs, color.BgWhite) 135 | // FgHi 136 | case "FgHiBlack", "fghiblack": 137 | attrs = append(attrs, color.FgHiBlack) 138 | case "FgHiRed", "fghired": 139 | attrs = append(attrs, color.FgHiRed) 140 | case "FgHiGreen", "fghigreen": 141 | attrs = append(attrs, color.FgHiGreen) 142 | case "FgHiYellow", "fghiyellow": 143 | attrs = append(attrs, color.FgHiYellow) 144 | case "FgHiBlue", "fghiblue": 145 | attrs = append(attrs, color.FgHiBlue) 146 | case "FgHiMagenta", "fghimagenta": 147 | attrs = append(attrs, color.FgHiMagenta) 148 | case "FgHiCyan", "fghicyan": 149 | attrs = append(attrs, color.FgHiCyan) 150 | case "FgHiWhite", "fghiwhite": 151 | attrs = append(attrs, color.FgHiWhite) 152 | // Attr 153 | case "Reset", "reset": 154 | attrs = append(attrs, color.Reset) 155 | case "Bold", "bold": 156 | attrs = append(attrs, color.Bold) 157 | case "Faint", "faint": 158 | attrs = append(attrs, color.Faint) 159 | case "Italic", "italic": 160 | attrs = append(attrs, color.Italic) 161 | case "Underline", "underline": 162 | attrs = append(attrs, color.Underline) 163 | case "BlinkSlow", "blinkslow": 164 | attrs = append(attrs, color.BlinkSlow) 165 | case "BlinkRapid", "blinkrapid": 166 | attrs = append(attrs, color.BlinkRapid) 167 | case "ReverseVideo", "reversevideo": 168 | attrs = append(attrs, color.ReverseVideo) 169 | case "Concealed", "concealed": 170 | attrs = append(attrs, color.Concealed) 171 | case "CrossedOut", "crossedout": 172 | attrs = append(attrs, color.CrossedOut) 173 | default: 174 | return cty.NilVal, fmt.Errorf("%q: invalid attr name", attr) 175 | } 176 | } 177 | c := color.New(attrs...) 178 | return cty.StringVal(c.Sprintf(text)), nil 179 | }, 180 | }) 181 | -------------------------------------------------------------------------------- /lint/internal/policy/funcs/collection.go: -------------------------------------------------------------------------------- 1 | package funcs 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/zclconf/go-cty/cty" 7 | "github.com/zclconf/go-cty/cty/convert" 8 | "github.com/zclconf/go-cty/cty/function" 9 | ) 10 | 11 | // LookupListFunc contructs a function that performs dynamic lookups of map types. 12 | var LookupListFunc = function.New(&function.Spec{ 13 | Params: []function.Parameter{ 14 | { 15 | Name: "inputMap", 16 | Type: cty.DynamicPseudoType, 17 | }, 18 | { 19 | Name: "key", 20 | Type: cty.String, 21 | }, 22 | }, 23 | Type: func(args []cty.Value) (ret cty.Type, err error) { 24 | mapVar := args[0] 25 | lookupKey := args[1].AsString() 26 | if !mapVar.IsWhollyKnown() { 27 | return cty.NilType, nil 28 | } 29 | m := mapVar.AsValueMap() 30 | val, ok := m[lookupKey] 31 | if !ok { 32 | return cty.List(cty.String), nil 33 | } 34 | i := 0 35 | types := make([]cty.Type, val.LengthInt()) 36 | for it := val.ElementIterator(); it.Next(); { 37 | _, av := it.Element() 38 | types[i] = av.Type() 39 | i++ 40 | } 41 | retType, _ := convert.UnifyUnsafe(types) 42 | if retType == cty.NilType { 43 | return cty.NilType, fmt.Errorf("all arguments must have the same type") 44 | } 45 | 46 | return cty.List(retType), nil 47 | }, 48 | Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { 49 | mapVar := args[0] 50 | lookupKey := args[1].AsString() 51 | if !mapVar.IsWhollyKnown() { 52 | return cty.UnknownVal(retType), nil 53 | } 54 | // mapVar.Type().IsMapType() 55 | m := mapVar.AsValueMap() 56 | val, ok := m[lookupKey] 57 | if !ok { 58 | return cty.ListValEmpty(cty.String), nil 59 | } 60 | list := make([]cty.Value, 0, val.LengthInt()) 61 | for it := val.ElementIterator(); it.Next(); { 62 | _, av := it.Element() 63 | av, _ = convert.Convert(av, retType.ElementType()) 64 | list = append(list, av) 65 | } 66 | return cty.ListVal(list), nil 67 | }, 68 | }) 69 | -------------------------------------------------------------------------------- /lint/internal/policy/funcs/filepath.go: -------------------------------------------------------------------------------- 1 | package funcs 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | 7 | pathshorten "github.com/b4b4r07/go-pathshorten" 8 | "github.com/zclconf/go-cty/cty" 9 | "github.com/zclconf/go-cty/cty/function" 10 | ) 11 | 12 | // GlobFunc returns a list of files matching a given pattern 13 | var GlobFunc = function.New(&function.Spec{ 14 | Params: []function.Parameter{ 15 | { 16 | Name: "pattern", 17 | Type: cty.String, 18 | }, 19 | }, 20 | Type: function.StaticReturnType(cty.List(cty.String)), 21 | Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { 22 | pattern := args[0].AsString() 23 | 24 | files, err := filepath.Glob(pattern) 25 | if err != nil { 26 | return cty.NilVal, err 27 | } 28 | 29 | vals := make([]cty.Value, len(files)) 30 | for i, file := range files { 31 | vals[i] = cty.StringVal(file) 32 | } 33 | 34 | if len(vals) == 0 { 35 | return cty.ListValEmpty(cty.String), nil 36 | } 37 | return cty.ListVal(vals), nil 38 | }, 39 | }) 40 | 41 | // PathShortenFunc returns the shorten path of given path 42 | var PathShortenFunc = function.New(&function.Spec{ 43 | Params: []function.Parameter{ 44 | { 45 | Name: "path", 46 | Type: cty.String, 47 | }, 48 | }, 49 | Type: function.StaticReturnType(cty.String), 50 | Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { 51 | path := args[0].AsString() 52 | return cty.StringVal(pathshorten.Run(path)), nil 53 | }, 54 | }) 55 | 56 | // ExtFunc returns an extension of given file 57 | var ExtFunc = function.New(&function.Spec{ 58 | Params: []function.Parameter{ 59 | { 60 | Name: "file", 61 | Type: cty.String, 62 | }, 63 | }, 64 | Type: function.StaticReturnType(cty.String), 65 | Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { 66 | return cty.StringVal(filepath.Ext(args[0].AsString())), nil 67 | }, 68 | }) 69 | 70 | // ExistFunc returns true if given path exists 71 | var ExistFunc = function.New(&function.Spec{ 72 | Params: []function.Parameter{ 73 | { 74 | Name: "path", 75 | Type: cty.String, 76 | }, 77 | }, 78 | Type: function.StaticReturnType(cty.Bool), 79 | Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { 80 | path := args[0].AsString() 81 | _, err = os.Stat(path) 82 | exist := !os.IsNotExist(err) 83 | return cty.BoolVal(exist), nil 84 | }, 85 | }) 86 | -------------------------------------------------------------------------------- /lint/internal/policy/funcs/jsonpath.go: -------------------------------------------------------------------------------- 1 | package funcs 2 | 3 | import ( 4 | "fmt" 5 | "math/big" 6 | "strconv" 7 | 8 | "github.com/tidwall/gjson" 9 | 10 | "github.com/zclconf/go-cty/cty" 11 | "github.com/zclconf/go-cty/cty/function" 12 | ctyjson "github.com/zclconf/go-cty/cty/json" 13 | ) 14 | 15 | func getJSON(query string, file string, data []byte) ([]byte, error) { 16 | result := gjson.GetBytes(data, query) 17 | if !result.Exists() { 18 | // Even if the given query refers to a field that does not exist, 19 | // it does not return an error 20 | // This is because there are cases which a rule defined by a user 21 | // refers to a field that does not exist and tests it 22 | // 23 | // return []byte{}, fmt.Errorf("%q: not found in %q", query, file) 24 | // return []byte(""), nil 25 | // 26 | // TODO: Fix this handling 27 | // By introducing the default value, 28 | // no problem for now even if the result is not found. 29 | // This is because returns default values if not found case 30 | return []byte{}, fmt.Errorf("%q: not found in %q", query, file) 31 | } 32 | return []byte(result.String()), nil 33 | } 34 | 35 | // GJSON determines whether a file exists at the given path. 36 | // 37 | // The underlying function implementation works relative to a input file 38 | // and its contents, so this wrapper takes a input file string, etc 39 | // and uses it to onstruct the underlying function before calling it. 40 | func GJSON(file string, data []byte, query cty.Value) (cty.Value, error) { 41 | fn := GJSONFunc(file, data) 42 | return fn.Call([]cty.Value{query}) 43 | } 44 | 45 | // GJSONFunc is 46 | func GJSONFunc(file string, data []byte) function.Function { 47 | return function.New(&function.Spec{ 48 | Params: []function.Parameter{ 49 | { 50 | Name: "str", 51 | Type: cty.String, 52 | }, 53 | }, 54 | VarParam: &function.Parameter{ 55 | Name: "default", 56 | Type: cty.DynamicPseudoType, 57 | }, 58 | Type: func(args []cty.Value) (cty.Type, error) { 59 | query := args[0].AsString() 60 | defaultVal := cty.StringVal("") 61 | if len(args) > 1 { 62 | defaultVal = args[1] 63 | } 64 | b, err := getJSON(query, file, data) 65 | if err != nil { 66 | return defaultVal.Type(), nil 67 | } 68 | ty, err := ctyjson.ImpliedType(b) 69 | if err != nil { 70 | // When the result from getJSON can not be converted to JSON (that is, array or map), 71 | // treat the return value as a string 72 | return defaultVal.Type(), nil 73 | } 74 | return ty, nil 75 | }, 76 | Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { 77 | query := args[0].AsString() 78 | defaultVal := cty.StringVal("") 79 | if len(args) > 1 { 80 | defaultVal = args[1] 81 | } 82 | b, err := getJSON(query, file, data) 83 | if err != nil || len(b) == 0 { 84 | return defaultVal, nil 85 | } 86 | switch b[0] { 87 | case '{', '[': 88 | val, err := ctyjson.Unmarshal(b, retType) 89 | if err != nil { 90 | return cty.StringVal(string(b)), nil 91 | } 92 | return val, nil 93 | } 94 | isValidNumber := func(b byte) bool { 95 | return '0' <= b && b <= '9' 96 | } 97 | var shouldReturnString bool 98 | for _, char := range b { 99 | if !isValidNumber(char) { 100 | shouldReturnString = true 101 | } 102 | } 103 | if shouldReturnString { 104 | str := string(b) 105 | switch str { 106 | case "true": 107 | return cty.BoolVal(true), nil 108 | case "false": 109 | return cty.BoolVal(false), nil 110 | default: 111 | return cty.StringVal(str), nil 112 | } 113 | } 114 | f64, _ := strconv.ParseFloat(string(b), 64) 115 | val := big.NewFloat(f64) 116 | return cty.NumberVal(val), nil 117 | }, 118 | }) 119 | } 120 | -------------------------------------------------------------------------------- /lint/internal/policy/funcs/jsonpath_test.go: -------------------------------------------------------------------------------- 1 | package funcs 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/zclconf/go-cty/cty" 7 | "k8s.io/apimachinery/pkg/util/yaml" 8 | ) 9 | 10 | func toJSON(json []byte) []byte { 11 | yaml, err := yaml.ToJSON(json) 12 | if err != nil { 13 | return json 14 | } 15 | return yaml 16 | } 17 | 18 | func TestGJSONFunc(t *testing.T) { 19 | tests := []struct { 20 | Filename string 21 | Contents []byte 22 | Query cty.Value 23 | Want cty.Value 24 | }{ 25 | { 26 | "simple.json", 27 | []byte(`{"key": "value"}`), 28 | cty.StringVal("key"), 29 | cty.StringVal("value"), 30 | }, 31 | { 32 | "simple_kubernetes.yaml", 33 | toJSON([]byte(`--- 34 | apiVersion: v1 35 | kind: Test 36 | metadata: 37 | name: test 38 | namespace: testns 39 | `)), 40 | cty.StringVal("metadata.name"), 41 | cty.StringVal("test"), 42 | }, 43 | { 44 | "string_value.yaml", 45 | toJSON([]byte(`key: value`)), 46 | cty.StringVal("key"), 47 | cty.StringVal("value"), 48 | }, 49 | { 50 | "number_value.yaml", 51 | toJSON([]byte(`port: 8080`)), 52 | cty.StringVal("port"), 53 | cty.NumberIntVal(8080), 54 | }, 55 | { 56 | "object_value.yaml", 57 | toJSON([]byte(`--- 58 | apiVersion: v1 59 | kind: Test 60 | metadata: 61 | name: test 62 | namespace: testns 63 | `)), 64 | cty.StringVal("metadata"), 65 | cty.ObjectVal(map[string]cty.Value{"name": cty.StringVal("test"), "namespace": cty.StringVal("testns")}), 66 | }, 67 | { 68 | "tuple_value.yaml", 69 | toJSON([]byte(`--- 70 | test: 71 | - a 72 | - b 73 | `)), 74 | cty.StringVal("test"), 75 | cty.TupleVal([]cty.Value{cty.StringVal("a"), cty.StringVal("b")}), 76 | }, 77 | { 78 | "strict_string.json", 79 | []byte(`{"maxSurge": "100%"}`), 80 | cty.StringVal("maxSurge"), 81 | cty.StringVal("100%"), 82 | }, 83 | { 84 | "strict_boolean.json", 85 | []byte(`{"ok": true}`), 86 | cty.StringVal("ok"), 87 | cty.BoolVal(true), 88 | }, 89 | { 90 | "strict_boolean.json", 91 | []byte(`{"ok": "true"}`), 92 | cty.StringVal("ok"), 93 | cty.BoolVal(true), 94 | }, 95 | { 96 | "strict_boolean.json", 97 | []byte(`{"ok": "false"}`), 98 | cty.StringVal("ok"), 99 | cty.BoolVal(false), 100 | }, 101 | { 102 | "empty_string_value.json", 103 | []byte(`{"ok": ""}`), 104 | cty.StringVal("ok"), 105 | cty.StringVal(""), 106 | }, 107 | } 108 | 109 | for _, test := range tests { 110 | t.Run(test.Filename, func(t *testing.T) { 111 | got, err := GJSON(test.Filename, test.Contents, test.Query) 112 | if err != nil { 113 | t.Fatalf("unexpected error: %s", err) 114 | } 115 | if !got.RawEquals(test.Want) { 116 | t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Want) 117 | } 118 | }) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /lint/internal/policy/funcs/unix.go: -------------------------------------------------------------------------------- 1 | package funcs 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "regexp" 8 | "strings" 9 | 10 | "github.com/zclconf/go-cty/cty" 11 | "github.com/zclconf/go-cty/cty/function" 12 | ) 13 | 14 | // GrepFunc is 15 | var GrepFunc = function.New(&function.Spec{ 16 | Params: []function.Parameter{ 17 | { 18 | Name: "pattern", 19 | Type: cty.String, 20 | }, 21 | { 22 | Name: "text", 23 | Type: cty.String, 24 | }, 25 | }, 26 | Type: function.StaticReturnType(cty.String), 27 | Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { 28 | pattern := args[0].AsString() 29 | text := args[1].AsString() 30 | var matches []string 31 | 32 | r := bufio.NewReader(strings.NewReader(text)) 33 | for { 34 | l, err := r.ReadString('\n') 35 | if err == io.EOF { 36 | break 37 | } else if err != nil { 38 | return cty.NilVal, err 39 | } 40 | l = strings.TrimRight(l, "\n") 41 | matched, err := regexp.MatchString(pattern, l) 42 | if err != nil { 43 | return cty.NilVal, err 44 | } 45 | if matched { 46 | matches = append(matches, l) 47 | } 48 | } 49 | return cty.StringVal(strings.Join(matches, "\n")), nil 50 | }, 51 | }) 52 | 53 | // WcFunc is 54 | var WcFunc = function.New(&function.Spec{ 55 | Params: []function.Parameter{ 56 | { 57 | Name: "text", 58 | Type: cty.String, 59 | }, 60 | }, 61 | VarParam: &function.Parameter{ 62 | Name: "opts", 63 | Type: cty.String, 64 | }, 65 | Type: function.StaticReturnType(cty.Number), 66 | Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { 67 | text := args[0].AsString() 68 | opts := args[1:] 69 | var ( 70 | lines = int64(strings.Count(text, "\n")) 71 | chars = int64(len(text)) 72 | words = int64(len(strings.Fields(text))) 73 | ) 74 | for _, opt := range opts { 75 | switch opt.AsString() { 76 | case "l": 77 | return cty.NumberIntVal(lines), nil 78 | case "c": 79 | return cty.NumberIntVal(chars), nil 80 | case "w": 81 | return cty.NumberIntVal(words), nil 82 | default: 83 | return cty.NilVal, fmt.Errorf("%v: not supported option", opt.AsString()) 84 | } 85 | } 86 | // default option is l 87 | return cty.NumberIntVal(lines), nil 88 | }, 89 | }) 90 | 91 | // Grep returns the matched line from given text by using regex 92 | func Grep(pattern cty.Value, text cty.Value) (cty.Value, error) { 93 | return GrepFunc.Call([]cty.Value{pattern, text}) 94 | } 95 | 96 | // Wc counts the characters, words, or lines from given text 97 | func Wc(text cty.Value, opts ...cty.Value) (cty.Value, error) { 98 | args := make([]cty.Value, len(opts)+1) 99 | args[0] = text 100 | copy(args[1:], opts) 101 | return WcFunc.Call(args) 102 | } 103 | -------------------------------------------------------------------------------- /lint/internal/policy/funcs/unix_test.go: -------------------------------------------------------------------------------- 1 | package funcs 2 | 3 | import ( 4 | "bufio" 5 | "math/rand" 6 | "testing" 7 | 8 | "github.com/zclconf/go-cty/cty" 9 | ) 10 | 11 | func RandomString(n int) string { 12 | var letter = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") 13 | b := make([]rune, n) 14 | for i := range b { 15 | b[i] = letter[rand.Intn(len(letter))] 16 | } 17 | return string(b) 18 | } 19 | 20 | func TestGrep(t *testing.T) { 21 | tests := map[string]struct { 22 | Pattern cty.Value 23 | Text cty.Value 24 | Want cty.Value 25 | }{ 26 | "Match": { 27 | cty.StringVal("hoge"), 28 | cty.StringVal("test\nhogehoge\nfoo\nbar\n"), 29 | cty.StringVal("hogehoge"), 30 | }, 31 | "NotMatch": { 32 | cty.StringVal("buz"), 33 | cty.StringVal("test\nhogehoge\nfoo\nbar\n"), 34 | cty.StringVal(""), 35 | }, 36 | "MatchLongString": { 37 | cty.StringVal("hoge"), 38 | cty.StringVal(RandomString(bufio.MaxScanTokenSize) + "\nhogehoge\nfoo\nbar\nhogebaz\n" + RandomString(bufio.MaxScanTokenSize+1)), 39 | cty.StringVal("hogehoge\nhogebaz"), 40 | }, 41 | } 42 | 43 | for name, test := range tests { 44 | t.Run(name, func(t *testing.T) { 45 | got, err := Grep(test.Pattern, test.Text) 46 | 47 | if err != nil { 48 | t.Fatalf("unexpected error: %s", err) 49 | } 50 | 51 | if !got.RawEquals(test.Want) { 52 | t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Want) 53 | } 54 | }) 55 | } 56 | } 57 | 58 | func TestWc(t *testing.T) { 59 | tests := map[string]struct { 60 | Args []cty.Value 61 | Want cty.Value 62 | }{ 63 | "OneLine": { 64 | []cty.Value{cty.StringVal("foo\nbar baz")}, 65 | cty.NumberIntVal(1), 66 | }, 67 | "OneLineWithCharOption": { 68 | []cty.Value{cty.StringVal("foo\nbar baz"), cty.StringVal("c")}, 69 | cty.NumberIntVal(11), 70 | }, 71 | "OneLineWithWordOption": { 72 | []cty.Value{cty.StringVal("foo\nbar baz"), cty.StringVal("w")}, 73 | cty.NumberIntVal(3), 74 | }, 75 | } 76 | 77 | for name, test := range tests { 78 | t.Run(name, func(t *testing.T) { 79 | got, err := Wc(test.Args[0], test.Args[1:]...) 80 | 81 | if err != nil { 82 | t.Fatalf("unexpected error: %s", err) 83 | } 84 | 85 | if !got.RawEquals(test.Want) { 86 | t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Want) 87 | } 88 | }) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /lint/internal/policy/loader/loader.go: -------------------------------------------------------------------------------- 1 | package loader 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "log" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | 11 | "github.com/b4b4r07/stein/lint/internal/policy" 12 | "github.com/b4b4r07/stein/pkg/logging" 13 | "github.com/hashicorp/hcl2/hcl" 14 | "github.com/hashicorp/hcl2/hclparse" 15 | ) 16 | 17 | // Parser represents mainly HCL parser 18 | type Parser struct { 19 | p *hclparse.Parser 20 | } 21 | 22 | // NewParser creates Parser instance 23 | func NewParser() *Parser { 24 | return &Parser{hclparse.NewParser()} 25 | } 26 | 27 | func (p *Parser) loadHCLFile(path string) (hcl.Body, hcl.Diagnostics) { 28 | log.Printf("[TRACE] parsing as HCL body: %s\n", path) 29 | src, err := ioutil.ReadFile(path) 30 | if err != nil { 31 | return nil, hcl.Diagnostics{ 32 | { 33 | Severity: hcl.DiagError, 34 | Summary: "Failed to read file", 35 | Detail: fmt.Sprintf("The file %q could not be read.", path), 36 | }, 37 | } 38 | } 39 | 40 | var file *hcl.File 41 | var diags hcl.Diagnostics 42 | switch { 43 | case strings.HasSuffix(path, ".json"): 44 | file, diags = p.p.ParseJSON(src, path) 45 | default: 46 | file, diags = p.p.ParseHCL(src, path) 47 | } 48 | 49 | // If the returned file or body is nil, then we'll return a non-nil empty 50 | // body so we'll meet our contract that nil means an error reading the file. 51 | if file == nil || file.Body == nil { 52 | return hcl.EmptyBody(), diags 53 | } 54 | 55 | return file.Body, diags 56 | } 57 | 58 | func visitHCL(files *[]string) filepath.WalkFunc { 59 | return func(path string, info os.FileInfo, err error) error { 60 | if err != nil { 61 | return err 62 | } 63 | switch filepath.Ext(path) { 64 | case ".hcl": 65 | *files = append(*files, path) 66 | } 67 | return nil 68 | } 69 | } 70 | 71 | // getPolicyFiles walks the given path and returns the files ending with HCL 72 | // Also, it returns the path if the path is just a file and a HCL file 73 | func getPolicyFiles(path string) ([]string, error) { 74 | var ( 75 | files []string 76 | err error 77 | ) 78 | fi, err := os.Stat(path) 79 | if err != nil { 80 | return files, err 81 | } 82 | if fi.IsDir() { 83 | return files, filepath.Walk(path, visitHCL(&files)) 84 | } 85 | switch filepath.Ext(path) { 86 | case ".hcl": 87 | files = append(files, path) 88 | } 89 | return files, err 90 | } 91 | 92 | func splitStepsBySeparator(path, sep string) []string { 93 | var paths []string 94 | fi, err := os.Stat(path) 95 | if err != nil { 96 | return paths 97 | } 98 | if !fi.IsDir() { 99 | path = filepath.Dir(path) 100 | } 101 | for { 102 | paths = append(paths, path) 103 | if path == "." { 104 | break 105 | } 106 | if !strings.Contains(path, sep) { 107 | path = "." 108 | } 109 | path = filepath.Dir(path) 110 | } 111 | return paths 112 | } 113 | 114 | // SearchPolicyDir searchs the hierarchy of the given path step by step and find the default directory 115 | func SearchPolicyDir(path string) []string { 116 | log.Printf("[TRACE] search .policy dir in each path steps of %q\n", path) 117 | paths := splitStepsBySeparator(path, string(os.PathSeparator)) 118 | var dirs []string 119 | for _, path := range paths { 120 | dir := filepath.Join(path, ".policy") 121 | _, err := os.Stat(dir) 122 | if err != nil { 123 | // skip if policy dir doesn't exist 124 | continue 125 | } 126 | dirs = append(dirs, dir) 127 | } 128 | log.Printf("[TRACE] recognized %#v as policy dirs for %s\n", dirs, path) 129 | return dirs 130 | } 131 | 132 | // Policy represents the raw data of HCL files decoded by hcl2 package 133 | type Policy struct { 134 | Body hcl.Body 135 | Files map[string]*hcl.File 136 | // Data represents the raw data decoded based on stein schema 137 | Data *policy.Policy 138 | } 139 | 140 | // Load reads the files and converts them to Policy object 141 | func Load(paths ...string) (Policy, error) { 142 | log.Printf("[TRACE] finding HCL files from %#v\n", paths) 143 | parser := NewParser() 144 | 145 | var diags hcl.Diagnostics 146 | var bodies []hcl.Body 147 | var policies []string 148 | var err error 149 | 150 | // paths can take a file path and a dir path 151 | for _, path := range paths { 152 | // if c.Option.Policy is empty, in other words, additionals is nothing, 153 | // paths are likely to contain empty string. 154 | // if so, skip to run getPolicyFiles 155 | if path == "" { 156 | continue 157 | } 158 | files, err := getPolicyFiles(path) 159 | if err != nil { 160 | return Policy{}, err 161 | } 162 | // gather full paths of HCL file to one array 163 | policies = append(policies, files...) 164 | } 165 | 166 | log.Printf("[INFO] found HCL files which should be loaded as policies: %v\n", 167 | logging.Dump(policies)) 168 | 169 | // delete duplicate file paths 170 | // in consideration of the case the same files are read 171 | // 172 | // TODO: think if unique is needed (if not needed, just returns error) 173 | log.Printf("[TRACE] remove duplicated policies in unique()\n") 174 | for _, policy := range unique(policies) { 175 | body, fDiags := parser.loadHCLFile(policy) 176 | bodies = append(bodies, body) 177 | diags = append(diags, fDiags...) 178 | } 179 | 180 | if diags.HasErrors() { 181 | err = diags 182 | } 183 | 184 | return Policy{ 185 | Body: hcl.MergeBodies(bodies), 186 | Files: parser.p.Files(), 187 | }, err 188 | } 189 | 190 | func unique(args []string) []string { 191 | results := make([]string, 0, len(args)) 192 | encountered := map[string]bool{} 193 | for i := 0; i < len(args); i++ { 194 | if !encountered[args[i]] { 195 | encountered[args[i]] = true 196 | results = append(results, args[i]) 197 | } 198 | } 199 | return results 200 | } 201 | -------------------------------------------------------------------------------- /lint/internal/policy/policy.go: -------------------------------------------------------------------------------- 1 | package policy 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/hashicorp/hcl2/hcl" 7 | ) 8 | 9 | // Policy is 10 | type Policy struct { 11 | Config *Config 12 | Rules []*Rule 13 | Variables []*Variable 14 | Outputs []*Output 15 | } 16 | 17 | // policySchema is the schema for the top-level of a config file. We use 18 | // the low-level HCL API for this level so we can easily deal with each 19 | // block type separately with its own decoding logic. 20 | var policySchema = &hcl.BodySchema{ 21 | Blocks: []hcl.BlockHeaderSchema{ 22 | { 23 | Type: "locals", 24 | }, 25 | // lint 26 | { 27 | Type: "rule", 28 | LabelNames: []string{"name"}, 29 | }, 30 | { 31 | Type: "config", 32 | }, 33 | { 34 | Type: "function", 35 | LabelNames: []string{"name"}, 36 | }, 37 | { 38 | Type: "variable", 39 | LabelNames: []string{"name"}, 40 | }, 41 | { 42 | Type: "output", 43 | LabelNames: []string{"name"}, 44 | }, 45 | { 46 | Type: "debug", 47 | LabelNames: []string{"name"}, 48 | }, 49 | }, 50 | } 51 | 52 | // Decode is 53 | func Decode(body hcl.Body) (*Policy, hcl.Diagnostics) { 54 | policy := &Policy{} 55 | content, diags := body.Content(policySchema) 56 | 57 | for _, block := range content.Blocks { 58 | switch block.Type { 59 | 60 | case "variable": 61 | cfg, cfgDiags := decodeVariableBlock(block, false) 62 | diags = append(diags, cfgDiags...) 63 | if cfg != nil { 64 | policy.Variables = append(policy.Variables, cfg) 65 | } 66 | 67 | case "rule": 68 | rule, ruleDiags := decodeRuleBlock(block) 69 | diags = append(diags, ruleDiags...) 70 | if rule != nil { 71 | policy.Rules = append(policy.Rules, rule) 72 | } 73 | 74 | case "output": 75 | output, outputDiags := decodeOutputBlock(block, false) 76 | diags = append(diags, outputDiags...) 77 | if output != nil { 78 | policy.Outputs = append(policy.Outputs, output) 79 | } 80 | 81 | case "config": 82 | config, configDiags := decodeConfigBlock(block) 83 | diags = append(diags, configDiags...) 84 | if config != nil { 85 | policy.Config = config 86 | } 87 | 88 | case "function": 89 | 90 | } 91 | } 92 | 93 | diags = append(diags, checkRulesUnique(policy.Rules)...) 94 | diags = append(diags, checkRulesDependencies(policy.Rules)...) 95 | 96 | return policy, diags 97 | } 98 | 99 | func checkRulesUnique(rules []*Rule) hcl.Diagnostics { 100 | encountered := map[string]*Rule{} 101 | var diags hcl.Diagnostics 102 | for _, rule := range rules { 103 | if existing, exist := encountered[rule.Name]; exist { 104 | diags = append(diags, &hcl.Diagnostic{ 105 | Severity: hcl.DiagError, 106 | Summary: "Duplicate rule definition", 107 | Detail: fmt.Sprintf("A rule named %q was already defined at %s. Rule names must be unique within a policy.", existing.Name, existing.DeclRange), 108 | Subject: &rule.DeclRange, 109 | }) 110 | } 111 | encountered[rule.Name] = rule 112 | } 113 | return diags 114 | } 115 | 116 | func checkRulesDependencies(rules []*Rule) hcl.Diagnostics { 117 | var diags hcl.Diagnostics 118 | for _, rule := range rules { 119 | for _, dep := range rule.Dependencies { 120 | exists := func(rules []*Rule) bool { 121 | for _, rule := range rules { 122 | if rule.Name == dep { 123 | return true 124 | } 125 | } 126 | return false 127 | }(rules) 128 | if !exists { 129 | // TODO: Replace more suitable range with rule.DeclRange 130 | // "rule.DeclRange" is the declaration range of this rule 131 | // however, what we want here is the declaration range of "depends_on" 132 | diags = append(diags, &hcl.Diagnostic{ 133 | Severity: hcl.DiagError, 134 | Summary: "Invalid dependency rule", 135 | Detail: fmt.Sprintf("A dependency rule %q specified in %q is not defined", dep, rule.Name), 136 | Subject: &rule.DeclRange, 137 | }) 138 | } 139 | } 140 | } 141 | return diags 142 | } 143 | -------------------------------------------------------------------------------- /lint/internal/policy/rule.go: -------------------------------------------------------------------------------- 1 | package policy 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/hashicorp/hcl2/hcl" 8 | "github.com/hashicorp/hcl2/hcl/hclsyntax" 9 | "github.com/zclconf/go-cty/cty" 10 | ) 11 | 12 | // RulePrefix is the prefix of the rule name 13 | const RulePrefix = "rule." 14 | 15 | func decodeRuleBlock(block *hcl.Block) (*Rule, hcl.Diagnostics) { 16 | rule := &Rule{ 17 | Name: block.Labels[0], 18 | // Name: block.Labels[1], 19 | DeclRange: block.DefRange, 20 | TypeRange: block.LabelRanges[0], 21 | } 22 | content, remain, diags := block.Body.PartialContent(ruleBlockSchema) 23 | rule.Config = remain 24 | 25 | // TODO 26 | if !hclsyntax.ValidIdentifier(rule.Name) { 27 | diags = append(diags, &hcl.Diagnostic{ 28 | Severity: hcl.DiagError, 29 | Summary: "Invalid output name", 30 | Detail: badIdentifierDetail, 31 | Subject: &block.LabelRanges[0], 32 | }) 33 | } 34 | 35 | // TODO: Depends on jsonpath's default value 36 | // By introducing the default value concept into jsonpath func, 37 | // it became not to need to take care of this 38 | // if jsonpath doesn't have default value, this case will be failed sometimes 39 | // e.g. "${jsonpath("spec.replicas") > 0}" 40 | // if kind is Service, jsonpath("spec.replicas") returns "" 41 | // so this expression will be "${"" > 0}" as a result 42 | // This is the reason why needs default value for jsonpath 43 | // Otherwise, operate the LHS which is dependent on RHS in hcl2 library 44 | // 45 | // See also 46 | // https://github.com/hashicorp/hcl2/blob/cce5ae6cc5c890122f922573d6bf973eef0509f7/hcl/hclsyntax/expression_ops.go#L123-L196 47 | // 48 | // if attr, exists := content.Attributes["conditions"]; exists { 49 | // } 50 | 51 | if attr, exists := content.Attributes["depends_on"]; exists { 52 | val, valDiags := attr.Expr.Value(nil) 53 | diags = append(diags, valDiags...) 54 | for it := val.ElementIterator(); it.Next(); { 55 | _, dep := it.Element() 56 | dependency := strings.TrimPrefix(dep.AsString(), RulePrefix) 57 | rule.Dependencies = append(rule.Dependencies, dependency) 58 | } 59 | } 60 | 61 | for _, block := range content.Blocks { 62 | switch block.Type { 63 | case "locals": 64 | // locals, localsDiags := decodeLocalsBlock(block) 65 | // diags = append(diags, localsDiags...) 66 | // if locals != nil { 67 | // rule.Locals = locals 68 | // } 69 | // rule.Locals = append(rule.Locals, locals...) 70 | case "report": 71 | report, reportDiags := decodeReportBlock(block) 72 | diags = append(diags, reportDiags...) 73 | if report != nil { 74 | rule.Report = report 75 | } 76 | case "precondition": 77 | precondition, preconditionDiags := decodePreconditionBlock(block) 78 | diags = append(diags, preconditionDiags...) 79 | if precondition != nil { 80 | rule.Precondition = precondition 81 | } 82 | default: 83 | continue 84 | } 85 | } 86 | 87 | return rule, diags 88 | } 89 | 90 | // Rule isj 91 | type Rule struct { 92 | Name string 93 | 94 | Config hcl.Body 95 | 96 | TypeRange hcl.Range 97 | DeclRange hcl.Range 98 | 99 | Description string 100 | Dependencies []string 101 | Precondition *Precondition 102 | Conditions []bool 103 | Report *Report 104 | 105 | Debug []string 106 | } 107 | 108 | // // Locals is 109 | // type Locals struct { 110 | // Config hcl.Body 111 | // 112 | // TypeRange hcl.Range 113 | // DeclRange hcl.Range 114 | // } 115 | 116 | // Report is 117 | type Report struct { 118 | Config hcl.Body 119 | 120 | TypeRange hcl.Range 121 | DeclRange hcl.Range 122 | 123 | Level string 124 | Message string 125 | } 126 | 127 | // Precondition is 128 | type Precondition struct { 129 | Config hcl.Body 130 | 131 | TypeRange hcl.Range 132 | DeclRange hcl.Range 133 | 134 | Cases []bool 135 | } 136 | 137 | var ruleBlockSchema = &hcl.BodySchema{ 138 | Blocks: []hcl.BlockHeaderSchema{ 139 | { 140 | Type: "locals", 141 | }, 142 | { 143 | Type: "report", 144 | }, 145 | { 146 | Type: "precondition", 147 | }, 148 | }, 149 | Attributes: []hcl.AttributeSchema{ 150 | { 151 | Name: "description", 152 | Required: true, 153 | }, 154 | { 155 | Name: "depends_on", 156 | }, 157 | { 158 | Name: "conditions", 159 | Required: true, 160 | }, 161 | }, 162 | } 163 | 164 | func decodeReportBlock(block *hcl.Block) (*Report, hcl.Diagnostics) { 165 | content, config, diags := block.Body.PartialContent(&hcl.BodySchema{ 166 | Attributes: []hcl.AttributeSchema{ 167 | { 168 | Name: "level", 169 | Required: true, 170 | }, 171 | { 172 | Name: "message", 173 | Required: true, 174 | }, 175 | }, 176 | }) 177 | 178 | report := &Report{ 179 | Config: config, 180 | DeclRange: block.DefRange, 181 | } 182 | 183 | if attr, exists := content.Attributes["level"]; exists { 184 | val, valDiags := attr.Expr.Value(nil) 185 | diags = append(diags, valDiags...) 186 | if diags.HasErrors() { 187 | return report, diags 188 | } 189 | switch val.Type() { 190 | case cty.String: 191 | // ok 192 | default: 193 | diags = append(diags, &hcl.Diagnostic{ 194 | Severity: hcl.DiagError, 195 | Summary: "Type string is required here", 196 | Detail: fmt.Sprintf("It can take %q as the level", []string{"WARN", "ERROR"}), 197 | Subject: &attr.NameRange, 198 | }) 199 | return report, diags 200 | } 201 | level := val.AsString() 202 | switch level { 203 | case "ERROR": 204 | case "WARN": 205 | default: 206 | diags = append(diags, &hcl.Diagnostic{ 207 | Severity: hcl.DiagError, 208 | Summary: "Invalid level type", 209 | Detail: fmt.Sprintf("got %q but want %q", level, []string{"WARN", "ERROR"}), 210 | Subject: &attr.NameRange, 211 | }) 212 | } 213 | } else { 214 | diags = append(diags, &hcl.Diagnostic{ 215 | Severity: hcl.DiagError, 216 | Summary: "Missing required argument", 217 | Detail: "The argument \"level\" is required, but no definition was found.", 218 | Subject: &block.DefRange, 219 | }) 220 | } 221 | 222 | if _, exists := content.Attributes["message"]; !exists { 223 | diags = append(diags, &hcl.Diagnostic{ 224 | Severity: hcl.DiagError, 225 | Summary: "Missing required argument", 226 | Detail: "The argument \"message\" is required, but no definition was found.", 227 | Subject: &block.DefRange, 228 | }) 229 | } 230 | 231 | return report, diags 232 | } 233 | 234 | func decodePreconditionBlock(block *hcl.Block) (*Precondition, hcl.Diagnostics) { 235 | _, config, diags := block.Body.PartialContent(&hcl.BodySchema{ 236 | Attributes: []hcl.AttributeSchema{ 237 | { 238 | Name: "cases", 239 | Required: true, 240 | }, 241 | }, 242 | }) 243 | 244 | precondition := &Precondition{ 245 | Config: config, 246 | DeclRange: block.DefRange, 247 | } 248 | 249 | return precondition, diags 250 | } 251 | -------------------------------------------------------------------------------- /lint/internal/policy/terraform/functions.go: -------------------------------------------------------------------------------- 1 | package terraform 2 | 3 | import ( 4 | "github.com/hashicorp/terraform/lang/funcs" 5 | "github.com/zclconf/go-cty/cty/function" 6 | "github.com/zclconf/go-cty/cty/function/stdlib" 7 | ) 8 | 9 | // Functions is 10 | func Functions(baseDir string) map[string]function.Function { 11 | return map[string]function.Function{ 12 | "abs": stdlib.AbsoluteFunc, 13 | "basename": funcs.BasenameFunc, 14 | "base64decode": funcs.Base64DecodeFunc, 15 | "base64encode": funcs.Base64EncodeFunc, 16 | "base64gzip": funcs.Base64GzipFunc, 17 | "base64sha256": funcs.Base64Sha256Func, 18 | "base64sha512": funcs.Base64Sha512Func, 19 | "bcrypt": funcs.BcryptFunc, 20 | "ceil": funcs.CeilFunc, 21 | "chomp": funcs.ChompFunc, 22 | "cidrhost": funcs.CidrHostFunc, 23 | "cidrnetmask": funcs.CidrNetmaskFunc, 24 | "cidrsubnet": funcs.CidrSubnetFunc, 25 | "coalesce": stdlib.CoalesceFunc, 26 | "coalescelist": funcs.CoalesceListFunc, 27 | "compact": funcs.CompactFunc, 28 | "concat": stdlib.ConcatFunc, 29 | "contains": funcs.ContainsFunc, 30 | "csvdecode": stdlib.CSVDecodeFunc, 31 | "dirname": funcs.DirnameFunc, 32 | "distinct": funcs.DistinctFunc, 33 | "element": funcs.ElementFunc, 34 | "chunklist": funcs.ChunklistFunc, 35 | "file": funcs.MakeFileFunc(baseDir, false), 36 | "fileexists": funcs.MakeFileExistsFunc(baseDir), 37 | "filebase64": funcs.MakeFileFunc(baseDir, true), 38 | "flatten": funcs.FlattenFunc, 39 | "floor": funcs.FloorFunc, 40 | "format": stdlib.FormatFunc, 41 | "formatlist": stdlib.FormatListFunc, 42 | "indent": funcs.IndentFunc, 43 | "index": funcs.IndexFunc, 44 | "join": funcs.JoinFunc, 45 | "jsondecode": stdlib.JSONDecodeFunc, 46 | "jsonencode": stdlib.JSONEncodeFunc, 47 | "keys": funcs.KeysFunc, 48 | "length": funcs.LengthFunc, 49 | "list": funcs.ListFunc, 50 | "log": funcs.LogFunc, 51 | "lookup": funcs.LookupFunc, 52 | "lower": stdlib.LowerFunc, 53 | "map": funcs.MapFunc, 54 | "matchkeys": funcs.MatchkeysFunc, 55 | "max": stdlib.MaxFunc, 56 | "md5": funcs.Md5Func, 57 | "merge": funcs.MergeFunc, 58 | "min": stdlib.MinFunc, 59 | "pathexpand": funcs.PathExpandFunc, 60 | "pow": funcs.PowFunc, 61 | "replace": funcs.ReplaceFunc, 62 | "rsadecrypt": funcs.RsaDecryptFunc, 63 | "sha1": funcs.Sha1Func, 64 | "sha256": funcs.Sha256Func, 65 | "sha512": funcs.Sha512Func, 66 | "signum": funcs.SignumFunc, 67 | "slice": funcs.SliceFunc, 68 | "sort": funcs.SortFunc, 69 | "split": funcs.SplitFunc, 70 | "substr": stdlib.SubstrFunc, 71 | "timestamp": funcs.TimestampFunc, 72 | "timeadd": funcs.TimeAddFunc, 73 | "title": funcs.TitleFunc, 74 | "transpose": funcs.TransposeFunc, 75 | "trimspace": funcs.TrimSpaceFunc, 76 | "upper": stdlib.UpperFunc, 77 | "urlencode": funcs.URLEncodeFunc, 78 | "uuid": funcs.UUIDFunc, 79 | "values": funcs.ValuesFunc, 80 | "zipmap": funcs.ZipmapFunc, 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /lint/internal/policy/variable.go: -------------------------------------------------------------------------------- 1 | package policy 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/hashicorp/hcl2/ext/typeexpr" 7 | "github.com/hashicorp/hcl2/gohcl" 8 | "github.com/hashicorp/hcl2/hcl" 9 | "github.com/hashicorp/hcl2/hcl/hclsyntax" 10 | "github.com/zclconf/go-cty/cty" 11 | "github.com/zclconf/go-cty/cty/convert" 12 | ) 13 | 14 | // Variable represents a "variable" block in a module or file. 15 | type Variable struct { 16 | Name string 17 | Description string 18 | Default cty.Value 19 | Type cty.Type 20 | ParsingMode VariableParsingMode 21 | 22 | DescriptionSet bool 23 | 24 | DeclRange hcl.Range 25 | } 26 | 27 | func decodeVariableBlock(block *hcl.Block, override bool) (*Variable, hcl.Diagnostics) { 28 | v := &Variable{ 29 | Name: block.Labels[0], 30 | DeclRange: block.DefRange, 31 | } 32 | 33 | // Unless we're building an override, we'll set some defaults 34 | // which we might override with attributes below. We leave these 35 | // as zero-value in the override case so we can recognize whether 36 | // or not they are set when we merge. 37 | if !override { 38 | v.Type = cty.DynamicPseudoType 39 | v.ParsingMode = VariableParseLiteral 40 | } 41 | 42 | content, diags := block.Body.Content(variableBlockSchema) 43 | 44 | if !hclsyntax.ValidIdentifier(v.Name) { 45 | diags = append(diags, &hcl.Diagnostic{ 46 | Severity: hcl.DiagError, 47 | Summary: "Invalid variable name", 48 | Detail: badIdentifierDetail, 49 | Subject: &block.LabelRanges[0], 50 | }) 51 | } 52 | 53 | // Don't allow declaration of variables that would conflict with the 54 | // reserved attribute and block type names in a "module" block, since 55 | // these won't be usable for child modules. 56 | // for _, attr := range moduleBlockSchema.Attributes { 57 | // if attr.Name == v.Name { 58 | // diags = append(diags, &hcl.Diagnostic{ 59 | // Severity: hcl.DiagError, 60 | // Summary: "Invalid variable name", 61 | // Detail: fmt.Sprintf("The variable name %q is reserved due to its special meaning inside module blocks.", attr.Name), 62 | // Subject: &block.LabelRanges[0], 63 | // }) 64 | // } 65 | // } 66 | 67 | if attr, exists := content.Attributes["description"]; exists { 68 | valDiags := gohcl.DecodeExpression(attr.Expr, nil, &v.Description) 69 | diags = append(diags, valDiags...) 70 | v.DescriptionSet = true 71 | } 72 | 73 | if attr, exists := content.Attributes["type"]; exists { 74 | ty, parseMode, tyDiags := decodeVariableType(attr.Expr) 75 | diags = append(diags, tyDiags...) 76 | v.Type = ty 77 | v.ParsingMode = parseMode 78 | } 79 | 80 | if attr, exists := content.Attributes["default"]; exists { 81 | val, valDiags := attr.Expr.Value(nil) 82 | diags = append(diags, valDiags...) 83 | 84 | // Convert the default to the expected type so we can catch invalid 85 | // defaults early and allow later code to assume validity. 86 | // Note that this depends on us having already processed any "type" 87 | // attribute above. 88 | // However, we can't do this if we're in an override file where 89 | // the type might not be set; we'll catch that during merge. 90 | if v.Type != cty.NilType { 91 | var err error 92 | val, err = convert.Convert(val, v.Type) 93 | if err != nil { 94 | diags = append(diags, &hcl.Diagnostic{ 95 | Severity: hcl.DiagError, 96 | Summary: "Invalid default value for variable", 97 | Detail: fmt.Sprintf("This default value is not compatible with the variable's type constraint: %s.", err), 98 | Subject: attr.Expr.Range().Ptr(), 99 | }) 100 | val = cty.DynamicVal 101 | } 102 | } 103 | 104 | v.Default = val 105 | } 106 | 107 | return v, diags 108 | } 109 | 110 | func decodeVariableType(expr hcl.Expression) (cty.Type, VariableParsingMode, hcl.Diagnostics) { 111 | if exprIsNativeQuotedString(expr) { 112 | // Here we're accepting the pre-0.12 form of variable type argument where 113 | // the string values "string", "list" and "map" are accepted has a hint 114 | // about the type used primarily for deciding how to parse values 115 | // given on the command line and in environment variables. 116 | // Only the native syntax ends up in this codepath; we handle the 117 | // JSON syntax (which is, of course, quoted even in the new format) 118 | // in the normal codepath below. 119 | val, diags := expr.Value(nil) 120 | if diags.HasErrors() { 121 | return cty.DynamicPseudoType, VariableParseHCL, diags 122 | } 123 | str := val.AsString() 124 | switch str { 125 | case "string": 126 | return cty.String, VariableParseLiteral, diags 127 | case "list": 128 | return cty.List(cty.DynamicPseudoType), VariableParseHCL, diags 129 | case "map": 130 | return cty.Map(cty.DynamicPseudoType), VariableParseHCL, diags 131 | default: 132 | return cty.DynamicPseudoType, VariableParseHCL, hcl.Diagnostics{{ 133 | Severity: hcl.DiagError, 134 | Summary: "Invalid legacy variable type hint", 135 | Detail: `The legacy variable type hint form, using a quoted string, allows only the values "string", "list", and "map". To provide a full type expression, remove the surrounding quotes and give the type expression directly.`, 136 | Subject: expr.Range().Ptr(), 137 | }} 138 | } 139 | } 140 | 141 | // First we'll deal with some shorthand forms that the HCL-level type 142 | // expression parser doesn't include. These both emulate pre-0.12 behavior 143 | // of allowing a list or map of any element type as long as all of the 144 | // elements are consistent. This is the same as list(any) or map(any). 145 | switch hcl.ExprAsKeyword(expr) { 146 | case "list": 147 | return cty.List(cty.DynamicPseudoType), VariableParseHCL, nil 148 | case "map": 149 | return cty.Map(cty.DynamicPseudoType), VariableParseHCL, nil 150 | } 151 | 152 | ty, diags := typeexpr.TypeConstraint(expr) 153 | if diags.HasErrors() { 154 | return cty.DynamicPseudoType, VariableParseHCL, diags 155 | } 156 | 157 | switch { 158 | case ty.IsPrimitiveType(): 159 | // Primitive types use literal parsing. 160 | return ty, VariableParseLiteral, diags 161 | default: 162 | // Everything else uses HCL parsing 163 | return ty, VariableParseHCL, diags 164 | } 165 | } 166 | 167 | // VariableParsingMode defines how values of a particular variable given by 168 | // text-only mechanisms (command line arguments and environment variables) 169 | // should be parsed to produce the final value. 170 | type VariableParsingMode rune 171 | 172 | // VariableParseLiteral is a variable parsing mode that just takes the given 173 | // string directly as a cty.String value. 174 | const VariableParseLiteral VariableParsingMode = 'L' 175 | 176 | // VariableParseHCL is a variable parsing mode that attempts to parse the given 177 | // string as an HCL expression and returns the result. 178 | const VariableParseHCL VariableParsingMode = 'H' 179 | 180 | // Parse uses the receiving parsing mode to process the given variable value 181 | // string, returning the result along with any diagnostics. 182 | // 183 | // A VariableParsingMode does not know the expected type of the corresponding 184 | // variable, so it's the caller's responsibility to attempt to convert the 185 | // result to the appropriate type and return to the user any diagnostics that 186 | // conversion may produce. 187 | // 188 | // The given name is used to create a synthetic filename in case any diagnostics 189 | // must be generated about the given string value. This should be the name 190 | // of the root module variable whose value will be populated from the given 191 | // string. 192 | // 193 | // If the returned diagnostics has errors, the returned value may not be 194 | // valid. 195 | func (m VariableParsingMode) Parse(name, value string) (cty.Value, hcl.Diagnostics) { 196 | switch m { 197 | case VariableParseLiteral: 198 | return cty.StringVal(value), nil 199 | case VariableParseHCL: 200 | fakeFilename := fmt.Sprintf("", name) 201 | expr, diags := hclsyntax.ParseExpression([]byte(value), fakeFilename, hcl.Pos{Line: 1, Column: 1}) 202 | if diags.HasErrors() { 203 | return cty.DynamicVal, diags 204 | } 205 | val, valDiags := expr.Value(nil) 206 | diags = append(diags, valDiags...) 207 | return val, diags 208 | default: 209 | // Should never happen 210 | panic(fmt.Errorf("Parse called on invalid VariableParsingMode %#v", m)) 211 | } 212 | } 213 | 214 | // Output represents an "output" block in a module or file. 215 | type Output struct { 216 | Name string 217 | Description string 218 | Expr hcl.Expression 219 | DependsOn []hcl.Traversal 220 | Sensitive bool 221 | 222 | DescriptionSet bool 223 | SensitiveSet bool 224 | 225 | DeclRange hcl.Range 226 | } 227 | 228 | func decodeOutputBlock(block *hcl.Block, override bool) (*Output, hcl.Diagnostics) { 229 | o := &Output{ 230 | Name: block.Labels[0], 231 | DeclRange: block.DefRange, 232 | } 233 | 234 | schema := outputBlockSchema 235 | if override { 236 | // schema = schemaForOverrides(schema) 237 | } 238 | 239 | content, diags := block.Body.Content(schema) 240 | 241 | if !hclsyntax.ValidIdentifier(o.Name) { 242 | diags = append(diags, &hcl.Diagnostic{ 243 | Severity: hcl.DiagError, 244 | Summary: "Invalid output name", 245 | Detail: badIdentifierDetail, 246 | Subject: &block.LabelRanges[0], 247 | }) 248 | } 249 | 250 | if attr, exists := content.Attributes["description"]; exists { 251 | valDiags := gohcl.DecodeExpression(attr.Expr, nil, &o.Description) 252 | diags = append(diags, valDiags...) 253 | o.DescriptionSet = true 254 | } 255 | 256 | if attr, exists := content.Attributes["value"]; exists { 257 | o.Expr = attr.Expr 258 | } 259 | 260 | if attr, exists := content.Attributes["sensitive"]; exists { 261 | valDiags := gohcl.DecodeExpression(attr.Expr, nil, &o.Sensitive) 262 | diags = append(diags, valDiags...) 263 | o.SensitiveSet = true 264 | } 265 | 266 | // if attr, exists := content.Attributes["depends_on"]; exists { 267 | // // deps, depsDiags := decodeDependsOn(attr) 268 | // // diags = append(diags, depsDiags...) 269 | // // o.DependsOn = append(o.DependsOn, deps...) 270 | // } 271 | 272 | return o, diags 273 | } 274 | 275 | // Local represents a single entry from a "locals" block in a module or file. 276 | // The "locals" block itself is not represented, because it serves only to 277 | // provide context for us to interpret its contents. 278 | type Local struct { 279 | Name string 280 | Expr hcl.Expression 281 | 282 | DeclRange hcl.Range 283 | } 284 | 285 | func decodeLocalsBlock(block *hcl.Block) ([]*Local, hcl.Diagnostics) { 286 | attrs, diags := block.Body.JustAttributes() 287 | if len(attrs) == 0 { 288 | return nil, diags 289 | } 290 | 291 | locals := make([]*Local, 0, len(attrs)) 292 | for name, attr := range attrs { 293 | if !hclsyntax.ValidIdentifier(name) { 294 | diags = append(diags, &hcl.Diagnostic{ 295 | Severity: hcl.DiagError, 296 | Summary: "Invalid local value name", 297 | Detail: badIdentifierDetail, 298 | Subject: &attr.NameRange, 299 | }) 300 | } 301 | 302 | locals = append(locals, &Local{ 303 | Name: name, 304 | Expr: attr.Expr, 305 | DeclRange: attr.Range, 306 | }) 307 | } 308 | return locals, diags 309 | } 310 | 311 | var variableBlockSchema = &hcl.BodySchema{ 312 | Attributes: []hcl.AttributeSchema{ 313 | { 314 | Name: "description", 315 | }, 316 | { 317 | Name: "default", 318 | }, 319 | { 320 | Name: "type", 321 | }, 322 | }, 323 | } 324 | 325 | var outputBlockSchema = &hcl.BodySchema{ 326 | Attributes: []hcl.AttributeSchema{ 327 | { 328 | Name: "description", 329 | }, 330 | { 331 | Name: "value", 332 | Required: true, 333 | }, 334 | { 335 | Name: "depends_on", 336 | }, 337 | { 338 | Name: "sensitive", 339 | }, 340 | }, 341 | } 342 | 343 | // A consistent detail message for all "not a valid identifier" diagnostics. 344 | const badIdentifierDetail = "A name must start with a letter and may contain only letters, digits, underscores, and dashes." 345 | 346 | // exprIsNativeQuotedString determines whether the given expression looks like 347 | // it's a quoted string in the HCL native syntax. 348 | // 349 | // This should be used sparingly only for situations where our legacy HCL 350 | // decoding would've expected a keyword or reference in quotes but our new 351 | // decoding expects the keyword or reference to be provided directly as 352 | // an identifier-based expression. 353 | func exprIsNativeQuotedString(expr hcl.Expression) bool { 354 | _, ok := expr.(*hclsyntax.TemplateExpr) 355 | return ok 356 | } 357 | 358 | // schemaForOverrides takes a *hcl.BodySchema and produces a new one that is 359 | // equivalent except that any required attributes are forced to not be required. 360 | // 361 | // This is useful for dealing with "override" config files, which are allowed 362 | // to omit things that they don't wish to override from the main configuration. 363 | // 364 | // The returned schema may have some pointers in common with the given schema, 365 | // so neither the given schema nor the returned schema should be modified after 366 | // using this function in order to avoid confusion. 367 | // 368 | // Overrides are rarely used, so it's recommended to just create the override 369 | // schema on the fly only when it's needed, rather than storing it in a global 370 | // variable as we tend to do for a primary schema. 371 | func schemaForOverrides(schema *hcl.BodySchema) *hcl.BodySchema { 372 | ret := &hcl.BodySchema{ 373 | Attributes: make([]hcl.AttributeSchema, len(schema.Attributes)), 374 | Blocks: schema.Blocks, 375 | } 376 | 377 | for i, attrS := range schema.Attributes { 378 | ret.Attributes[i] = attrS 379 | ret.Attributes[i].Required = false 380 | } 381 | 382 | return ret 383 | } 384 | -------------------------------------------------------------------------------- /lint/internal/topological/sort.go: -------------------------------------------------------------------------------- 1 | package topological 2 | 3 | // Graph represents a dependency graph 4 | type Graph struct { 5 | nodes []string 6 | outputs map[string]map[string]int 7 | inputs map[string]int 8 | } 9 | 10 | // NewGraph creates new Graph object 11 | func NewGraph(cap int) *Graph { 12 | return &Graph{ 13 | nodes: make([]string, 0, cap), 14 | inputs: make(map[string]int), 15 | outputs: make(map[string]map[string]int), 16 | } 17 | } 18 | 19 | // AddNode adds new node to Graph 20 | func (g *Graph) AddNode(node string) bool { 21 | g.nodes = append(g.nodes, node) 22 | 23 | if _, ok := g.outputs[node]; ok { 24 | return false 25 | } 26 | g.outputs[node] = make(map[string]int) 27 | g.inputs[node] = 0 28 | return true 29 | } 30 | 31 | // AddNodes adds one or more new node to Graph 32 | func (g *Graph) AddNodes(nodes ...string) bool { 33 | for _, node := range nodes { 34 | if ok := g.AddNode(node); !ok { 35 | return false 36 | } 37 | } 38 | return true 39 | } 40 | 41 | // AddEdge adds new source to the destination 42 | func (g *Graph) AddEdge(from, to string) bool { 43 | m, ok := g.outputs[from] 44 | if !ok { 45 | return false 46 | } 47 | 48 | m[to] = len(m) + 1 49 | g.inputs[to]++ 50 | 51 | return true 52 | } 53 | 54 | func (g *Graph) unsafeRemoveEdge(from, to string) { 55 | delete(g.outputs[from], to) 56 | g.inputs[to]-- 57 | } 58 | 59 | // RemoveEdge removes the description from the source 60 | func (g *Graph) RemoveEdge(from, to string) bool { 61 | if _, ok := g.outputs[from]; !ok { 62 | return false 63 | } 64 | g.unsafeRemoveEdge(from, to) 65 | return true 66 | } 67 | 68 | // Sort sorts nodes based on topological sort algorithm 69 | func (g *Graph) Sort() ([]string, bool) { 70 | L := make([]string, 0, len(g.nodes)) 71 | S := make([]string, 0, len(g.nodes)) 72 | 73 | for _, node := range g.nodes { 74 | if g.inputs[node] == 0 { 75 | S = append(S, node) 76 | } 77 | } 78 | 79 | for len(S) > 0 { 80 | var n string 81 | n, S = S[0], S[1:] 82 | L = append(L, n) 83 | 84 | ms := make([]string, len(g.outputs[n])) 85 | for m, i := range g.outputs[n] { 86 | ms[i-1] = m 87 | } 88 | 89 | for _, m := range ms { 90 | g.unsafeRemoveEdge(n, m) 91 | 92 | if g.inputs[m] == 0 { 93 | S = append(S, m) 94 | } 95 | } 96 | } 97 | 98 | N := 0 99 | for _, v := range g.inputs { 100 | N += v 101 | } 102 | 103 | if N > 0 { 104 | return L, false 105 | } 106 | 107 | return L, true 108 | } 109 | -------------------------------------------------------------------------------- /lint/internal/topological/sort_test.go: -------------------------------------------------------------------------------- 1 | package topological 2 | 3 | import "testing" 4 | 5 | func index(s []string, v string) int { 6 | for i, s := range s { 7 | if s == v { 8 | return i 9 | } 10 | } 11 | return -1 12 | } 13 | 14 | type Edge struct { 15 | From string 16 | To string 17 | } 18 | 19 | func TestDuplicatedNode(t *testing.T) { 20 | graph := NewGraph(2) 21 | graph.AddNode("a") 22 | if graph.AddNode("a") { 23 | t.Errorf("not raising duplicated node error") 24 | } 25 | 26 | } 27 | 28 | func TestRemoveNotExistEdge(t *testing.T) { 29 | graph := NewGraph(0) 30 | if graph.RemoveEdge("a", "b") { 31 | t.Errorf("not raising not exist edge error") 32 | } 33 | } 34 | 35 | func TestWikipedia(t *testing.T) { 36 | graph := NewGraph(8) 37 | graph.AddNodes("2", "3", "5", "7", "8", "9", "10", "11") 38 | 39 | edges := []Edge{ 40 | {"7", "8"}, 41 | {"7", "11"}, 42 | 43 | {"5", "11"}, 44 | 45 | {"3", "8"}, 46 | {"3", "10"}, 47 | 48 | {"11", "2"}, 49 | {"11", "9"}, 50 | {"11", "10"}, 51 | 52 | {"8", "9"}, 53 | } 54 | 55 | for _, e := range edges { 56 | graph.AddEdge(e.From, e.To) 57 | } 58 | 59 | result, ok := graph.Sort() 60 | if !ok { 61 | t.Errorf("closed path detected in no closed pathed graph") 62 | } 63 | 64 | for _, e := range edges { 65 | if i, j := index(result, e.From), index(result, e.To); i > j { 66 | t.Errorf("dependency failed: not satisfy %v(%v) > %v(%v)", e.From, i, e.To, j) 67 | } 68 | } 69 | } 70 | 71 | func TestCycle(t *testing.T) { 72 | graph := NewGraph(3) 73 | graph.AddNodes("1", "2", "3") 74 | 75 | graph.AddEdge("1", "2") 76 | graph.AddEdge("2", "3") 77 | graph.AddEdge("3", "1") 78 | 79 | _, ok := graph.Sort() 80 | if ok { 81 | t.Errorf("closed path not detected in closed pathed graph") 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /lint/lint.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | tt "html/template" 8 | "io" 9 | "log" 10 | "os" 11 | "regexp" 12 | "strings" 13 | 14 | "github.com/b4b4r07/stein/lint/internal/policy" 15 | "github.com/b4b4r07/stein/lint/internal/policy/loader" 16 | "github.com/b4b4r07/stein/lint/internal/topological" 17 | "github.com/b4b4r07/stein/pkg/logging" 18 | "github.com/fatih/color" 19 | "github.com/hashicorp/hcl2/gohcl" 20 | "github.com/hashicorp/hcl2/hcl" 21 | ) 22 | 23 | const ( 24 | // RulePrefix is a prefix of rule name 25 | RulePrefix = "rule." 26 | 27 | // DefaultFormat is a default format string 28 | DefaultFormat = "[{{.Level}}] {{.Rule}} {{.Message}}" 29 | 30 | // LevelError represents the error level reported by lint 31 | LevelError = "ERROR" 32 | // LevelWarning represents the warning level reported by lint 33 | LevelWarning = "WARN" 34 | ) 35 | 36 | // Status represents the status code of Lint 37 | type Status int 38 | 39 | const ( 40 | // Success is the success code of Lint 41 | Success Status = iota 42 | // Failure is the failure code of Lint 43 | Failure 44 | ) 45 | 46 | // Linter is a linter structure 47 | type Linter struct { 48 | stdout io.Writer 49 | stderr io.Writer 50 | 51 | config *Config 52 | cache cache 53 | 54 | // policy is a policy schema 55 | policy *policy.Policy 56 | // body is a decoded body that all policies is merged 57 | body hcl.Body 58 | // files are converted from given arguments (file paths are assumed) 59 | files []File 60 | } 61 | 62 | type cache struct { 63 | // policies represents all policy files loaded by Run method 64 | policies map[string]Policy 65 | 66 | // policy represent the structure of Policy corresponding to currently loaded YAML 67 | policy Policy 68 | 69 | // filepath represents the file path of current YAML loaded in Run method 70 | filepath string 71 | } 72 | 73 | // NewLinter creates Linter object based on Lint Policy 74 | func NewLinter(args []string, additionals ...string) (*Linter, error) { 75 | log.Printf("[TRACE] lint args: %#v\n", args) 76 | log.Printf("[TRACE] lint additional policies: %#v\n", additionals) 77 | 78 | files, err := filesFromArgs(args, additionals...) 79 | if err != nil { 80 | log.Printf("[ERROR] filesFromArgs failed in NewLinter: %v", err) 81 | return &Linter{}, err 82 | } 83 | 84 | return &Linter{ 85 | stdout: os.Stdout, 86 | stderr: os.Stderr, 87 | cache: cache{ 88 | policies: map[string]Policy{}, 89 | policy: Policy{}, 90 | filepath: "", 91 | }, 92 | policy: nil, 93 | body: nil, 94 | files: files, 95 | }, nil 96 | } 97 | 98 | func (l *Linter) setPolicy(policy loader.Policy) { 99 | l.body = policy.Body 100 | l.policy = policy.Data 101 | } 102 | 103 | func (l *Linter) decodePolicy(file File) (Policy, error) { 104 | var policy Policy 105 | 106 | if l.policy == nil { 107 | log.Printf("[ERROR] l.policy is nil, needs to be set by l.setPolicy()") 108 | return policy, errors.New("decoded policy is not found") 109 | } 110 | 111 | ctx, diags := l.policy.BuildContext(l.body, file.Path, file.Data) 112 | if diags.HasErrors() { 113 | return policy, diags 114 | } 115 | 116 | decodeDiags := gohcl.DecodeBody(l.body, ctx, &policy) 117 | diags = append(diags, decodeDiags...) 118 | if diags.HasErrors() { 119 | return policy, diags 120 | } 121 | 122 | // policy.Config can be nil 123 | // In that case it should be set to default value 124 | if policy.Config == nil { 125 | policy.Config = &Config{ 126 | Report: ReportConfig{ 127 | Format: DefaultFormat, 128 | Style: "console", 129 | Color: true, 130 | }, 131 | } 132 | log.Printf("[INFO] use default policy config %v\n", logging.Dump(policy.Config)) 133 | } 134 | 135 | return policy, nil 136 | } 137 | 138 | // Result represents the execution result of Lint 139 | // It's represented against one argument 140 | // The result of each rules for the argument is stored in Items 141 | type Result struct { 142 | Path string 143 | Items Items 144 | OK bool 145 | 146 | // RulesNotFound is a flag for rules not found 147 | RulesNotFound bool 148 | // Metadata is something notes related to Result 149 | Metadata string 150 | } 151 | 152 | // Item represents the result of a rule 153 | type Item struct { 154 | Name string 155 | Message string 156 | Status Status 157 | Level string 158 | } 159 | 160 | // Items is the collenction of Item object 161 | type Items []Item 162 | 163 | // Files returns []File object converted from given arguments 164 | func (l *Linter) Files() []File { 165 | log.Printf("[INFO] lint files: %v\n", logging.Dump(l.files)) 166 | return l.files 167 | } 168 | 169 | // Run runs the linter against a file of an argument 170 | func (l *Linter) Run(file File) (Result, error) { 171 | log.Printf("[INFO] run lint.Run with arg %q\n", file.Path) 172 | log.Printf("[TRACE] start to run lint.Run with file %v\n", logging.Dump(file)) 173 | 174 | if file.Diagnostics.HasErrors() { 175 | log.Printf("[ERROR] file.Diagnostics found %v\n", logging.Dump(file.Diagnostics)) 176 | return Result{}, file.Diagnostics 177 | } 178 | 179 | // Set a policy to read for each files 180 | l.setPolicy(file.Policy) 181 | 182 | log.Printf("[TRACE] decoding policy file by policy schema\n") 183 | policy, err := l.decodePolicy(file) 184 | if err != nil { 185 | return Result{}, err 186 | } 187 | log.Printf("[TRACE] policy decoded: %v\n", logging.Dump(policy)) 188 | 189 | if err := policy.Validate(); err != nil { 190 | return Result{}, err 191 | } 192 | 193 | l.cache.policies[file.Path] = policy 194 | l.cache.policy = policy 195 | l.cache.filepath = file.Path 196 | l.config = policy.Config 197 | 198 | if err := l.Validate(); err != nil { 199 | return Result{}, err 200 | } 201 | 202 | result := Result{ 203 | Path: file.Path, 204 | Items: []Item{}, 205 | OK: true, 206 | RulesNotFound: len(policy.Rules) == 0, 207 | Metadata: file.Meta, 208 | } 209 | 210 | // sort rules by depends_on 211 | policy.Rules.Sort() 212 | 213 | length := l.calcReportLength() 214 | for _, rule := range policy.Rules { 215 | log.Printf("[INFO] evalute %q\n", RulePrefix+rule.Name) 216 | if err := rule.Validate(); err != nil { 217 | return result, err 218 | } 219 | 220 | if rule.hasDependencies() { 221 | // Skip execution of the rule if the dependent rule fails 222 | ok := rule.checkDependenciesAreOK(result) 223 | if !ok { 224 | log.Printf( 225 | "[TRACE] skip to evalute %q because dependencies %v are failed", 226 | RulePrefix+rule.Name, rule.Dependencies) 227 | continue 228 | } 229 | } 230 | 231 | message, err := rule.BuildMessage(policy.Config.Report, length) 232 | if err != nil { 233 | return result, err 234 | } 235 | 236 | result.Items = append(result.Items, Item{ 237 | Name: rule.Name, 238 | Message: message, 239 | Status: rule.getStatus(), 240 | Level: rule.Report.Level, 241 | }) 242 | 243 | // this linter will fail if it has even one failed rule. 244 | if rule.getStatus() != Success { 245 | result.OK = false 246 | } 247 | 248 | for _, debug := range rule.Debugs { 249 | log.Printf("[TRACE] %#v\n", debug) 250 | } 251 | 252 | log.Printf("[TRACE] result: %v\n", logging.Dump(result.Items[len(result.Items)-1])) 253 | } 254 | 255 | return result, nil 256 | } 257 | 258 | // Sort sorts the rules based on its own dependencies 259 | // 260 | // For example, in the case that these rules are defined like below, 261 | // the order which the rules are executed should be as follows: 262 | // 263 | // rule.a --- rule.b --- rule.c 264 | // `- rule.d 265 | // 266 | // rule "a" { 267 | // ... 268 | // } 269 | // rule "b" { 270 | // depends_on = ["rule.a"] 271 | // } 272 | // rule "c" { 273 | // depends_on = ["rule.b"] 274 | // } 275 | // rule "d" { 276 | // depends_on = ["rule.a"] 277 | // } 278 | // 279 | // This implementation is based on the algorithm of topological sort 280 | // 281 | func (r *Rules) Sort() { 282 | graph := topological.NewGraph(len(*r)) 283 | for _, rule := range *r { 284 | graph.AddNode(rule.Name) 285 | } 286 | 287 | for _, rule := range *r { 288 | if !rule.hasDependencies() { 289 | continue 290 | } 291 | for _, dependency := range rule.Dependencies { 292 | dependency = strings.TrimPrefix(dependency, RulePrefix) 293 | graph.AddEdge(dependency, rule.Name) 294 | } 295 | } 296 | 297 | orderedRuleNames, ok := graph.Sort() 298 | if !ok { 299 | // TODO: Handle this pattern 300 | // For now it can be ignored. 301 | // 302 | // return 303 | } 304 | 305 | var sortedRules Rules 306 | for _, orderedRuleName := range orderedRuleNames { 307 | sortedRules = append(sortedRules, r.getOneByName(orderedRuleName)) 308 | } 309 | *r = sortedRules 310 | } 311 | 312 | func (r Rules) getOneByName(name string) Rule { 313 | for _, rule := range r { 314 | if rule.Name == name { 315 | return rule 316 | } 317 | } 318 | return Rule{} 319 | } 320 | 321 | func (r *Rule) hasDependencies() bool { 322 | return len(r.Dependencies) > 0 323 | } 324 | 325 | func (r *Rule) checkDependenciesAreOK(result Result) bool { 326 | for _, dependency := range r.Dependencies { 327 | depRule := strings.TrimPrefix(dependency, RulePrefix) 328 | item := result.Items.getOneByName(depRule) 329 | switch item.Status { 330 | case Success: 331 | // even if this item succeeds, 332 | // checks all other items 333 | continue 334 | case Failure: 335 | return false 336 | } 337 | } 338 | return true 339 | } 340 | 341 | func (i Items) getOneByName(name string) Item { 342 | for _, item := range i { 343 | if item.Name == name { 344 | return item 345 | } 346 | } 347 | return Item{} 348 | } 349 | 350 | func (r *Rule) getStatus() Status { 351 | if r.SkipCase() { 352 | return Success 353 | } 354 | if r.TrueCase() { 355 | return Success 356 | } 357 | return Failure 358 | } 359 | 360 | // Print prints the result of Lint based on Result reported by Run 361 | func (l *Linter) Print(result Result) { 362 | const consolePadding = " " 363 | 364 | var ( 365 | out = l.stderr 366 | cfg = l.config.Report 367 | ) 368 | 369 | // Setup Print method 370 | switch cfg.Style { 371 | case "console": 372 | color.New(color.Underline).Fprintf(out, result.Path) 373 | if len(result.Metadata) > 0 { 374 | metadata := fmt.Sprintf(" (%s)", result.Metadata) 375 | if cfg.Color { 376 | metadata = color.CyanString(metadata) 377 | } 378 | fmt.Fprintf(out, metadata) 379 | } 380 | fmt.Fprintln(out) 381 | } 382 | 383 | // Main logic of Print 384 | for _, rule := range result.Items { 385 | // Do not print successful items 386 | if rule.Status == Success { 387 | continue 388 | } 389 | switch cfg.Style { 390 | case "console": 391 | fmt.Fprintf(out, consolePadding) 392 | } 393 | fmt.Fprintln(out, rule.Message) 394 | } 395 | 396 | // Teardown Print method 397 | switch cfg.Style { 398 | case "console": 399 | switch { 400 | case result.RulesNotFound: 401 | fmt.Fprintln(out, consolePadding+"No rules found") 402 | case result.OK: 403 | fmt.Fprintln(out, consolePadding+"No violated rules") 404 | } 405 | fmt.Fprintln(out) 406 | } 407 | } 408 | 409 | // Status indicates execution result of Lint by the status code 410 | func (l *Linter) Status(results ...Result) Status { 411 | for _, result := range results { 412 | if !result.OK { 413 | return Failure 414 | } 415 | } 416 | return Success 417 | } 418 | 419 | // PrintSummary prints the summary of all results of the entire Lint 420 | func (l *Linter) PrintSummary(results ...Result) { 421 | s := struct { 422 | warns int 423 | errors int 424 | }{} 425 | for _, result := range results { 426 | for _, item := range result.Items { 427 | if item.Status == Success { 428 | continue 429 | } 430 | switch item.Level { 431 | case LevelError: 432 | s.errors++ 433 | case LevelWarning: 434 | s.warns++ 435 | } 436 | } 437 | } 438 | result := fmt.Sprintf("%d error(s), %d warn(s)", s.errors, s.warns) 439 | fmt.Fprintln(l.stderr, strings.Repeat("=", len(result))) 440 | fmt.Fprintln(l.stderr, result) 441 | } 442 | 443 | // SkipCase returns true if cases of a precondition block includes one or more failed cases 444 | func (r *Rule) SkipCase() bool { 445 | // don't skip if a precondition block is not specified 446 | if r.Precondition == nil { 447 | return false 448 | } 449 | for _, ok := range r.Precondition.Cases { 450 | if !ok { 451 | return true 452 | } 453 | } 454 | return false 455 | } 456 | 457 | // TrueCase returns true if all conditions in a rule are true. 458 | func (r *Rule) TrueCase() bool { 459 | for _, expr := range r.Conditions { 460 | if !expr { 461 | return false 462 | } 463 | } 464 | return true 465 | } 466 | 467 | // Validate validates linter configuration 468 | func (l *Linter) Validate() error { 469 | validations := []struct { 470 | rule bool 471 | message string 472 | }{} 473 | 474 | for _, validation := range validations { 475 | if !validation.rule { 476 | return fmt.Errorf(validation.message) 477 | } 478 | } 479 | 480 | return nil 481 | } 482 | 483 | // Validate validates rule syntax 484 | func (r *Rule) Validate() error { 485 | validations := []struct { 486 | rule bool 487 | message string 488 | }{ 489 | { 490 | r.Report.Level == LevelError || r.Report.Level == LevelWarning, 491 | fmt.Sprintf("report level accepts only %s or %s", LevelError, LevelWarning), 492 | }, 493 | { 494 | len(r.Report.Message) > 0, 495 | fmt.Sprintf("%s: report message should be written", r.Name), 496 | }, 497 | } 498 | 499 | for _, validation := range validations { 500 | if !validation.rule { 501 | return fmt.Errorf(validation.message) 502 | } 503 | } 504 | 505 | return nil 506 | } 507 | 508 | // ReportLength is the information of the max length of each format strings 509 | type ReportLength struct { 510 | // Max length of RulePrefix + {{.Rule}} 511 | MaxRuleName int 512 | 513 | // Max length of {{.Level}} 514 | MaxLevel int 515 | 516 | // Max length of {{.Message}} 517 | MaxMessage int 518 | } 519 | 520 | // calcReportLength is a method that measures how long length each placeholder 521 | // used in a template actually occurred the maximum length. 522 | // 523 | // Example: 524 | // [ERROR] rule.one_resource_per_one_file Only 1 resource should be defined in a YAML file 525 | // [WARN ] rule.yaml_separator YAML separator "---" should be removed 526 | // 527 | // In this case, calcReportLength below will be returned 528 | // max level length: 5, max rule name length: 30, max message length: 48 529 | // 530 | func (l *Linter) calcReportLength() ReportLength { 531 | var length ReportLength 532 | 533 | for _, rule := range l.cache.policy.Rules { 534 | if len(rule.Name) > length.MaxRuleName { 535 | length.MaxRuleName = len(rule.Name) 536 | } 537 | if len(rule.Report.Level) > length.MaxLevel { 538 | length.MaxLevel = len(rule.Report.Level) 539 | } 540 | if len(rule.Report.Message) > length.MaxMessage { 541 | length.MaxMessage = len(rule.Report.Message) 542 | } 543 | } 544 | return length 545 | } 546 | 547 | // BuildMessage formats the results reported by linter. 548 | func (r *Rule) BuildMessage(cfg ReportConfig, length ReportLength) (string, error) { 549 | format := DefaultFormat 550 | if len(cfg.Format) > 0 { 551 | format = cfg.Format 552 | } 553 | 554 | renderedFormat := new(bytes.Buffer) 555 | tpl, err := tt.New("").Parse(format) 556 | if err != nil { 557 | return "", err 558 | } 559 | 560 | var ( 561 | ruleName = r.Name 562 | level = r.Report.Level 563 | message = r.Report.Message 564 | ) 565 | 566 | var ( 567 | rulePadding = strings.Repeat(" ", length.MaxRuleName-len(ruleName)) 568 | levelPadding = strings.Repeat(" ", length.MaxLevel-len(level)) 569 | messagePadding = strings.Repeat(" ", length.MaxMessage-len(message)) 570 | ) 571 | 572 | if cfg.Color { 573 | switch level { 574 | case LevelError: 575 | level = color.RedString(level) 576 | case LevelWarning: 577 | level = color.YellowString(level) 578 | } 579 | // Colorize by default in case of only no advance color specification 580 | if !containsANSI(message) && !containsANSI(format) { 581 | message = color.WhiteString(message) 582 | } 583 | } 584 | 585 | err = tpl.Execute(renderedFormat, map[string]interface{}{ 586 | "Rule": RulePrefix + ruleName + rulePadding, 587 | "Level": level + levelPadding, 588 | "Message": tt.HTML(message + messagePadding), 589 | }) 590 | if err != nil { 591 | return "", err 592 | } 593 | 594 | switch renderedFormat.Len() { 595 | case 0: 596 | log.Printf("[ERROR] unexpected error. renderedFormat.Len() is zero length") 597 | return "", errors.New("invalid format string") 598 | default: 599 | format = renderedFormat.String() 600 | } 601 | 602 | return format, nil 603 | } 604 | 605 | func stripANSI(str string) string { 606 | const ansi = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))" 607 | var re = regexp.MustCompile(ansi) 608 | return re.ReplaceAllString(str, "") 609 | } 610 | 611 | func containsANSI(str string) bool { 612 | return str != stripANSI(str) 613 | } 614 | -------------------------------------------------------------------------------- /lint/policy.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/hashicorp/hcl2/hcl" 7 | ) 8 | 9 | // Policy represents the rule set against config files of arguments 10 | type Policy struct { 11 | Config *Config `hcl:"config,block"` 12 | Rules Rules `hcl:"rule,block"` 13 | Outputs []Output `hcl:"output,block"` 14 | 15 | Debugs []Debug `hcl:"debug,block"` 16 | 17 | Remain hcl.Body `hcl:",remain"` 18 | } 19 | 20 | // Config represents the configuration of the linter itself 21 | type Config struct { 22 | Report ReportConfig `hcl:"report,block"` 23 | } 24 | 25 | // ReportConfig represents the configuration of the report itself 26 | type ReportConfig struct { 27 | Format string `hcl:"format,optional"` 28 | Style string `hcl:"style,optional"` 29 | Color bool `hcl:"color,optional"` 30 | } 31 | 32 | // Rule represents the linting rule 33 | type Rule struct { 34 | Name string `hcl:"name,label"` 35 | 36 | Description string `hcl:"description"` 37 | Dependencies []string `hcl:"depends_on,optional"` 38 | Precondition *Precondition `hcl:"precondition,block"` 39 | Conditions []bool `hcl:"conditions"` 40 | Report Report `hcl:"report,block"` 41 | 42 | Debugs []string `hcl:"debug,optional"` 43 | } 44 | 45 | // Rules is the collenction of Rule object 46 | type Rules []Rule 47 | 48 | // Report represents the rule of reporting style 49 | type Report struct { 50 | // Level takes ERROR or WARN 51 | // In case of ERROR, the report message of the failed rule is shown and the linter returns false 52 | // In case of WARN, the report message of the failed rule is shown and the linter returns true 53 | Level string `hcl:"level"` 54 | 55 | // Message is shown when the rule is failed 56 | Message string `hcl:"message"` 57 | } 58 | 59 | // Precondition represents a condition that determines whether the rule should be executed 60 | type Precondition struct { 61 | Cases []bool `hcl:"cases"` 62 | } 63 | 64 | // Output is WIP 65 | type Output struct { 66 | Name string `hcl:"name,label"` 67 | Value *hcl.Attribute `hcl:"value"` 68 | } 69 | 70 | // Debug is WIP 71 | type Debug struct { 72 | Name string `hcl:"name,label"` 73 | Value *hcl.Attribute `hcl:"value"` 74 | } 75 | 76 | // Validate validates policy syntax 77 | func (p *Policy) Validate() error { 78 | validations := []struct { 79 | rule bool 80 | message string 81 | }{ 82 | { 83 | // inline is the secret format for now 84 | p.Config.Report.Style == "console" || p.Config.Report.Style == "inline", 85 | fmt.Sprintf("%s: console is only acceptable for report style", p.Config.Report.Style), 86 | }, 87 | } 88 | 89 | for _, validation := range validations { 90 | if !validation.rule { 91 | return fmt.Errorf(validation.message) 92 | } 93 | } 94 | 95 | return nil 96 | } 97 | -------------------------------------------------------------------------------- /lint/testdata/01.tf: -------------------------------------------------------------------------------- 1 | provider "google" { 2 | project = "my-project-id" 3 | region = "us-central1" 4 | } 5 | -------------------------------------------------------------------------------- /lint/testdata/02.tf: -------------------------------------------------------------------------------- 1 | provider "google" { 2 | project = "my-project-id" 3 | region = "us-central1" 4 | } 5 | provider "aws" { 6 | version = "~> 2.0" 7 | region = "us-east-1" 8 | } 9 | provider "google" { 10 | project = "my-project-id" 11 | region = "us-west1" 12 | alias = "west" 13 | } 14 | -------------------------------------------------------------------------------- /lint/testdata/03.tf: -------------------------------------------------------------------------------- 1 | module "foo" { 2 | bar = "baz" 3 | 4 | array = [ 5 | "val1", 6 | "val2", 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /lint/testdata/04.tf: -------------------------------------------------------------------------------- 1 | resource "google_project" "my_project" { 2 | name = "My Project" 3 | project_id = "your-project-id" 4 | org_id = "1234567" 5 | } 6 | 7 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "os" 8 | "runtime" 9 | 10 | "github.com/b4b4r07/stein/pkg/logging" 11 | "github.com/mitchellh/cli" 12 | ) 13 | 14 | const ( 15 | // AppName is the application name 16 | AppName = "stein" 17 | 18 | Version = "unset" 19 | Revision = "unset" 20 | 21 | envEnvPrefix = "STEIN_" 22 | ) 23 | 24 | // CLI represents the command-line interface 25 | type CLI struct { 26 | Stdout io.Writer 27 | Stderr io.Writer 28 | } 29 | 30 | func main() { 31 | logWriter, err := logging.LogOutput() 32 | if err != nil { 33 | panic(err) 34 | } 35 | log.SetOutput(logWriter) 36 | 37 | log.Printf("[INFO] Stein version: %s (%s)", Version, Revision) 38 | log.Printf("[INFO] Go runtime version: %s", runtime.Version()) 39 | log.Printf("[INFO] CLI args: %#v", os.Args) 40 | 41 | stein := CLI{ 42 | Stdout: os.Stdout, 43 | Stderr: os.Stderr, 44 | } 45 | 46 | app := cli.NewCLI(AppName, Version) 47 | app.Args = os.Args[1:] 48 | app.Commands = map[string]cli.CommandFactory{ 49 | "apply": func() (cli.Command, error) { 50 | return &ApplyCommand{CLI: stein}, nil 51 | }, 52 | "fmt": func() (cli.Command, error) { 53 | return &FmtCommand{CLI: stein}, nil 54 | }, 55 | } 56 | exitStatus, err := app.Run() 57 | if err != nil { 58 | fmt.Fprintf(os.Stderr, "[ERROR] %s: %v\n", AppName, err) 59 | } 60 | os.Exit(exitStatus) 61 | } 62 | 63 | func (c CLI) exit(msg interface{}) int { 64 | switch m := msg.(type) { 65 | case int: 66 | return m 67 | case nil: 68 | return 0 69 | case string: 70 | fmt.Fprintf(c.Stdout, "%s\n", m) 71 | return 0 72 | case error: 73 | fmt.Fprintf(c.Stderr, "[ERROR] %s: %s\n", AppName, m.Error()) 74 | return 1 75 | default: 76 | panic(msg) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | # Project information 2 | site_name: 'Stein Documentation' 3 | site_description: 'A linter for config files with a customizable rule set' 4 | site_author: 'Masaki ISHIYAMA' 5 | site_url: 'https://b4b4r07.github.io/stein/' 6 | 7 | theme: 8 | name: 'material' 9 | palette: 10 | primary: 'blue grey' 11 | accent: 'blue grey' 12 | logo: 13 | icon: 'school' 14 | favicon: 'assets/images/favicon.png' 15 | # feature: 16 | # tabs: true 17 | 18 | google_analytics: 19 | - 'UA-44183504-3' 20 | - 'auto' 21 | 22 | markdown_extensions: 23 | - fontawesome_markdown 24 | - meta 25 | - codehilite 26 | - admonition 27 | - toc: 28 | permalink: "#" 29 | - pymdownx.arithmatex 30 | - pymdownx.betterem: 31 | smart_enable: all 32 | - pymdownx.caret 33 | - pymdownx.critic 34 | - pymdownx.details 35 | - pymdownx.emoji: 36 | emoji_generator: !!python/name:pymdownx.emoji.to_svg 37 | - pymdownx.inlinehilite 38 | - pymdownx.magiclink: 39 | repo_url_shortener: true 40 | repo_url_shorthand: true 41 | social_url_shorthand: true 42 | user: b4b4r07 43 | repo: stein 44 | - pymdownx.mark 45 | - pymdownx.smartsymbols 46 | - pymdownx.superfences 47 | - pymdownx.tasklist: 48 | custom_checkbox: true 49 | - pymdownx.tilde 50 | 51 | # Customization 52 | extra: 53 | social: 54 | - type: globe 55 | link: https://tellme.tokyo 56 | - type: github 57 | link: https://github.com/b4b4r07 58 | - type: twitter 59 | link: https://twitter.com/b4b4r07 60 | 61 | extra_css: 62 | - "https://maxcdn.bootstrapcdn.com/font-awesome/4.6.1/css/font-awesome.min.css" 63 | 64 | # Repository 65 | repo_name: b4b4r07/stein 66 | repo_url: https://github.com/b4b4r07/stein 67 | edit_uri: edit/master/docs 68 | 69 | # Copyright 70 | copyright: 'Copyright © 2019 Masaki ISHIYAMA' 71 | 72 | # Pages and navigation pane 73 | nav: 74 | - Concepts: 75 | - Policy as Code: concepts/policy-as-code.md 76 | - Policy: concepts/policy.md 77 | - Configuration: 78 | - Introduction: configuration/_index.md 79 | - configuration/load.md 80 | - Policy: 81 | - Rules: configuration/policy/rules.md 82 | - Config: configuration/policy/config.md 83 | - Variables: configuration/policy/variables.md 84 | - Functions: configuration/policy/functions.md 85 | - Syntax: 86 | - Introduction: configuration/syntax/_index.md 87 | - Interpolation: configuration/syntax/interpolation.md 88 | - Built-in Funtions: 89 | - color: configuration/syntax/functions/color.md 90 | - exist: configuration/syntax/functions/exist.md 91 | - ext: configuration/syntax/functions/ext.md 92 | - glob: configuration/syntax/functions/glob.md 93 | - grep: configuration/syntax/functions/grep.md 94 | - jsonpath: configuration/syntax/functions/jsonpath.md 95 | - lookuplist: configuration/syntax/functions/lookuplist.md 96 | - match: configuration/syntax/functions/match.md 97 | - pathshorten: configuration/syntax/functions/pathshorten.md 98 | - wc: configuration/syntax/functions/wc.md 99 | - configuration/syntax/custom-functions.md 100 | - Getting Started: 101 | - intro/install.md 102 | - intro/rules.md 103 | - intro/run.md 104 | - Commands (CLI): 105 | - commands/_index.md 106 | - commands/apply.md 107 | - commands/fmt.md 108 | -------------------------------------------------------------------------------- /pkg/logging/logging.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "io/ioutil" 7 | "log" 8 | "os" 9 | "runtime" 10 | "strings" 11 | "syscall" 12 | 13 | "github.com/davecgh/go-spew/spew" 14 | "github.com/hashicorp/logutils" 15 | ) 16 | 17 | // These are the environmental variables that determine if we log, and if 18 | // we log whether or not the log should go to a file. 19 | const ( 20 | EnvLog = "STEIN_LOG" 21 | EnvLogFile = "STEIN_LOG_PATH" 22 | ) 23 | 24 | // ValidLevels is a list of valid log levels 25 | var ValidLevels = []logutils.LogLevel{"TRACE", "DEBUG", "INFO", "WARN", "ERROR"} 26 | 27 | // LogOutput determines where we should send logs (if anywhere) and the log level. 28 | func LogOutput() (logOutput io.Writer, err error) { 29 | logOutput = ioutil.Discard 30 | 31 | logLevel := LogLevel() 32 | if logLevel == "" { 33 | return 34 | } 35 | 36 | logOutput = os.Stderr 37 | if logPath := os.Getenv(EnvLogFile); logPath != "" { 38 | var err error 39 | logOutput, err = os.OpenFile(logPath, syscall.O_CREAT|syscall.O_RDWR|syscall.O_APPEND, 0666) 40 | if err != nil { 41 | return nil, err 42 | } 43 | } 44 | 45 | // This was the default since the beginning 46 | logOutput = &logutils.LevelFilter{ 47 | Levels: ValidLevels, 48 | MinLevel: logutils.LogLevel(logLevel), 49 | Writer: logOutput, 50 | } 51 | 52 | return 53 | } 54 | 55 | // SetOutput checks for a log destination with LogOutput, and calls 56 | // log.SetOutput with the result. If LogOutput returns nil, SetOutput uses 57 | // ioutil.Discard. Any error from LogOutout is fatal. 58 | func SetOutput() { 59 | out, err := LogOutput() 60 | if err != nil { 61 | log.Fatal(err) 62 | } 63 | 64 | if out == nil { 65 | out = ioutil.Discard 66 | } 67 | 68 | log.SetOutput(out) 69 | } 70 | 71 | // LogLevel returns the current log level string based the environment vars 72 | func LogLevel() string { 73 | envLevel := os.Getenv(EnvLog) 74 | if envLevel == "" { 75 | return "" 76 | } 77 | 78 | logLevel := "TRACE" 79 | if isValidLogLevel(envLevel) { 80 | // allow following for better ux: info, Info or INFO 81 | logLevel = strings.ToUpper(envLevel) 82 | } else { 83 | log.Printf("[WARN] Invalid log level: %q. Defaulting to level: TRACE. Valid levels are: %+v", 84 | envLevel, ValidLevels) 85 | } 86 | 87 | return logLevel 88 | } 89 | 90 | // IsDebugOrHigher returns whether or not the current log level is debug or trace 91 | func IsDebugOrHigher() bool { 92 | level := string(LogLevel()) 93 | return level == "DEBUG" || level == "TRACE" 94 | } 95 | 96 | func isValidLogLevel(level string) bool { 97 | for _, l := range ValidLevels { 98 | if strings.ToUpper(level) == string(l) { 99 | return true 100 | } 101 | } 102 | 103 | return false 104 | } 105 | 106 | // Call outputs logs with function name 107 | func Call(format string, a ...interface{}) { 108 | count, _, _, _ := runtime.Caller(1) 109 | fn := runtime.FuncForPC(count) 110 | // fileName, fileLine := fn.FileLine(count) 111 | // log.Printf("[DEBUG] %s (%s:%d) | %v", fn.Name(), fileName, fileLine, fmt.Sprintf(format, a...)) 112 | log.Printf("%s | %v", fn.Name(), fmt.Sprintf(format, a...)) 113 | } 114 | 115 | // Dump returns detailed format string 116 | func Dump(a ...interface{}) string { 117 | cfg := &spew.ConfigState{ 118 | ContinueOnMethod: true, 119 | DisableCapacities: true, 120 | DisableMethods: true, 121 | DisablePointerAddresses: true, 122 | DisablePointerMethods: true, 123 | Indent: " ", 124 | MaxDepth: 3, 125 | SortKeys: false, 126 | } 127 | return cfg.Sdump(a) 128 | } 129 | -------------------------------------------------------------------------------- /scripts/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | bash <(wget -o /dev/null -qO - https://git.io/release-go) 4 | --------------------------------------------------------------------------------