#!/usr/bin/env bash set -Eeuo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "${repo_root}" errors=0 pass() { printf '[pass] %s\n' "$1" } fail() { printf '[fail] %s\n' "$1" >&2 errors=$((errors + 1)) } required_files=( README.md SECURITY.md infrastructure/.iac-engine.example ) required_directories=( bootstrap/foundation bootstrap/gitops infrastructure/components/_template infrastructure/stacks/_template infrastructure/live/_template gitops/clusters/_template gitops/platform/_template gitops/policies/_template gitops/tenants/_template gitops/apps/_template docs/architecture docs/decisions docs/runbooks examples/minimal scripts tests ) stage_errors="${errors}" for path in "${required_files[@]}"; do if [[ ! -f "${path}" || -L "${path}" ]]; then fail "required regular file is missing or has the wrong type: ${path}" fi done for path in "${required_directories[@]}"; do if [[ ! -d "${path}" || -L "${path}" ]]; then fail "required directory is missing or has the wrong type: ${path}" fi done if ((errors == stage_errors)); then pass "required repository structure" fi stage_errors="${errors}" while IFS= read -r directory; do name="${directory##*/}" if [[ "${name}" == "_template" ]]; then continue fi if [[ ! "${name}" =~ ^[a-z0-9][a-z0-9-]*$ ]]; then fail "directory must use lowercase kebab-case: ${directory}" fi done < <( find bootstrap docs examples gitops infrastructure scripts tests \ \( \ -name .build -o \ -name .cache -o \ -name .git -o \ -name .terraform -o \ -name .terragrunt-cache -o \ -name dist -o \ -name rendered -o \ -name tmp \ \) -prune -o \ -type d -print | sort ) if ((errors == stage_errors)); then pass "directory naming" fi collect_source_files() { if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then git ls-files --cached --others --exclude-standard -z else find . \ \( \ -type d \ \( \ -name .build -o \ -name .cache -o \ -name .git -o \ -name .terraform -o \ -name .terragrunt-cache -o \ -name dist -o \ -name rendered -o \ -name tmp \ \) -prune \ \) -o \ -type f -print0 fi } source_files=() while IFS= read -r -d '' file; do file="${file#./}" if [[ -f "${file}" ]]; then source_files+=("${file}") fi done < <(collect_source_files) is_sensitive_filename() { local file="$1" local name="${file##*/}" case "${file}" in */.decrypted/*) return 0 ;; esac case "${name}" in .env | .env.*) [[ "${name}" == ".env.example" ]] && return 1 return 0 ;; *.dec.yaml | *.decrypted.yaml | *.jks | *.key | *.kubeconfig | *.p12 | *.pem | *.pfx | \ *.tfplan | *.tfstate | *.tfstate.* | credentials | credentials.* | id_dsa | id_ecdsa | \ id_ed25519 | id_rsa | kubeconfig | kubeconfig.* | plan.out | service-account.json | \ service_account.json) return 0 ;; esac return 1 } stage_errors="${errors}" for file in "${source_files[@]}"; do if is_sensitive_filename "${file}"; then fail "sensitive/local artifact must not be stored: ${file}" fi if grep -Eq -- '-----BEGIN (DSA |EC |OPENSSH |RSA )?PRIVATE KEY-----' "${file}" 2>/dev/null; then fail "private key material must not be stored: ${file}" fi done if ((errors == stage_errors)); then pass "sensitive/local artifact checks" fi yaml_secret_kind_pattern="^['\"]?kind['\"]?[[:space:]]*:[[:space:]]*['\"]?Secret['\"]?([[:space:]]*(#.*)?)?$" json_secret_kind_pattern="['\"]kind['\"][[:space:]]*:[[:space:]]*['\"]Secret['\"]" yaml_sops_metadata_pattern='^sops:[[:space:]]*(#.*)?$' json_sops_metadata_pattern='^[[:space:]]*"sops"[[:space:]]*:' yaml_sops_mac_pattern='^[[:space:]]*mac:[[:space:]]*ENC\[AES256_GCM,' json_sops_mac_pattern='^[[:space:]]*"mac"[[:space:]]*:[[:space:]]*"ENC\[AES256_GCM,' stage_errors="${errors}" for file in "${source_files[@]}"; do case "${file}" in bootstrap/*.yaml | bootstrap/*.yml | bootstrap/*.json | \ gitops/*.yaml | gitops/*.yml | gitops/*.json | \ examples/*.yaml | examples/*.yml | examples/*.json) secret_manifest=0 case "${file}" in *.json) grep -Eq "${json_secret_kind_pattern}" "${file}" && secret_manifest=1 ;; *) grep -Eq "${yaml_secret_kind_pattern}" "${file}" && secret_manifest=1 ;; esac if ((secret_manifest == 1)); then case "${file}" in *.sops.yaml | *.sops.yml) if ! grep -Eq "${yaml_sops_metadata_pattern}" "${file}" || ! grep -Eq "${yaml_sops_mac_pattern}" "${file}"; then fail "SOPS Secret is missing encrypted metadata/MAC: ${file}" fi ;; *.sops.json) if ! grep -Eq "${json_sops_metadata_pattern}" "${file}" || ! grep -Eq "${json_sops_mac_pattern}" "${file}"; then fail "SOPS Secret is missing encrypted metadata/MAC: ${file}" fi ;; *) fail "plain Kubernetes Secret is not allowed; use an external reference or a *.sops.yaml file: ${file}" ;; esac fi ;; esac done if ((errors == stage_errors)); then pass "plain Kubernetes Secret manifests" fi stage_errors="${errors}" for file in "${source_files[@]}"; do case "${file}" in */_template/*) continue ;; esac if grep -Eq '__REPLACE_ME_[A-Z0-9_]+__' "${file}"; then fail "unresolved replacement token: ${file}" fi done if ((errors == stage_errors)); then pass "replacement tokens outside _template" fi stage_errors="${errors}" while IFS= read -r script; do if ! bash -n "${script}"; then fail "shell syntax: ${script}" fi done < <(find scripts -type f -name '*.sh' | sort) if ((errors == stage_errors)); then pass "shell syntax" fi kustomizations=() for file in "${source_files[@]}"; do case "${file}" in bootstrap/*/kustomization.yaml | bootstrap/*/kustomization.yml | \ gitops/*/kustomization.yaml | gitops/*/kustomization.yml | \ examples/*/kustomization.yaml | examples/*/kustomization.yml) kustomizations+=("${file}") ;; esac done if ((${#kustomizations[@]} > 0)); then stage_errors="${errors}" if ! command -v kubectl >/dev/null 2>&1; then fail "kubectl is required to render Kustomize roots" else for file in "${kustomizations[@]}"; do rendered="" if ! rendered="$(kubectl kustomize "$(dirname "${file}")")"; then fail "Kustomize render: ${file}" continue fi case "${file}" in gitops/clusters/_template/*) ;; gitops/clusters/*) if [[ -z "${rendered}" ]]; then fail "actual cluster root renders no resources: ${file}" fi ;; esac done fi if ((errors == stage_errors)); then pass "Kustomize render (${#kustomizations[@]} roots)" fi fi charts=() for file in "${source_files[@]}"; do if [[ "${file##*/}" == "Chart.yaml" ]]; then charts+=("${file}") fi done if ((${#charts[@]} > 0)); then stage_errors="${errors}" if ! command -v helm >/dev/null 2>&1; then fail "Helm is required because Chart.yaml files exist" else for chart in "${charts[@]}"; do if ! helm lint "$(dirname "${chart}")"; then fail "Helm lint: ${chart}" fi done fi if ((errors == stage_errors)); then pass "Helm lint (${#charts[@]} charts)" fi fi iac_files=() iac_directories=() has_tofu_syntax=0 for file in "${source_files[@]}"; do case "${file}" in bootstrap/*.tofu | bootstrap/*.tofu.json | infrastructure/*.tofu | infrastructure/*.tofu.json) iac_files+=("${file}") has_tofu_syntax=1 ;; bootstrap/*.tf | bootstrap/*.tf.json | infrastructure/*.tf | infrastructure/*.tf.json) iac_files+=("${file}") ;; esac done for file in "${iac_files[@]}"; do directory="${file%/*}" directory_seen=0 for existing_directory in "${iac_directories[@]}"; do if [[ "${directory}" == "${existing_directory}" ]]; then directory_seen=1 break fi done if ((directory_seen == 0)); then iac_directories+=("${directory}") fi done if ((${#iac_files[@]} > 0)); then stage_errors="${errors}" iac_engine="" if [[ ! -f infrastructure/.iac-engine || -L infrastructure/.iac-engine ]]; then fail "select terraform or tofu in the tracked infrastructure/.iac-engine file" else iac_engine_values=() while IFS= read -r line; do case "${line}" in "" | \#*) continue ;; esac iac_engine_values+=("${line}") done < infrastructure/.iac-engine if ((${#iac_engine_values[@]} != 1)); then fail "infrastructure/.iac-engine must contain exactly one uncommented value" else iac_engine="${iac_engine_values[0]}" case "${iac_engine}" in terraform | tofu) ;; *) fail "infrastructure/.iac-engine must contain exactly terraform or tofu" iac_engine="" ;; esac fi fi if [[ "${iac_engine}" == "terraform" && "${has_tofu_syntax}" == "1" ]]; then fail "Terraform cannot format .tofu/.tofu.json files; select tofu or use compatible .tf files" fi if [[ -n "${iac_engine}" ]]; then if ! command -v "${iac_engine}" >/dev/null 2>&1; then fail "${iac_engine} is selected but is not installed" elif ((errors == stage_errors)); then for directory in "${iac_directories[@]}"; do if ! "${iac_engine}" fmt -check "${directory}"; then fail "${iac_engine} format check: ${directory}" fi done fi fi if ((errors == stage_errors)); then pass "${iac_engine} format (${#iac_files[@]} files)" fi fi stage_errors="${errors}" if ! ./scripts/project-validate.sh; then fail "project-specific validation" fi if ((errors == stage_errors)); then pass "project-specific validation" fi if ((errors > 0)); then printf '\nValidation failed with %d error(s).\n' "${errors}" >&2 exit 1 fi printf '\nValidation passed.\n'