refactor: 구조 변경
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
# Scripts
|
||||
|
||||
로컬과 CI가 동일하게 사용하는 얇고 명시적인 자동화만 둡니다.
|
||||
|
||||
- `doctor.sh`: 필수/선택 도구 가용성 확인
|
||||
- `validate.sh`: 템플릿 공통 구조/보안/렌더 검증 후 프로젝트 검증 실행
|
||||
- `project-validate.sh`: Argo CD, Vault, Terraform의 프로젝트별 계약 검증
|
||||
- `bootstrap-argocd.sh`: 확인된 context에 Argo CD bootstrap 수행
|
||||
- `vault-init.sh`: 개발 Vault의 init/unseal/revoke-root ceremony
|
||||
|
||||
검증 중 도구를 몰래 다운로드하거나 환경을 변경하지 않습니다. 실제 IaC,
|
||||
policy, secret 도구를 선택하면 `doctor.sh`의 필수 목록과 `validate.sh`의
|
||||
검증을 함께 확장합니다.
|
||||
|
||||
IaC source가 있으면 tracked `infrastructure/.iac-engine` 선택을 요구합니다.
|
||||
기본 검사는 provider-neutral하게 유지하기 위해 format까지만 수행하므로,
|
||||
실제 프로젝트는 root별 `init -backend=false`와 semantic validate를 추가해야
|
||||
합니다. 내용 기반 secret scanner와 schema/policy validator도 프로젝트 도구로
|
||||
고정해 CI에 추가합니다.
|
||||
|
||||
범용 `apply`/`destroy` 스크립트를 추가하지 않습니다. 배포 스크립트가 필요하면
|
||||
대상 environment/root를 필수 입력으로 받고 production 승인과 locking 정책을
|
||||
반영합니다.
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 --context <kube-context>" >&2
|
||||
}
|
||||
|
||||
if [[ "${1:-}" != "--context" || -z "${2:-}" || -n "${3:-}" ]]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
expected_context="$2"
|
||||
|
||||
for cmd in curl kubectl sha256sum; do
|
||||
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||||
echo "$cmd is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
current_context="$(kubectl config current-context)"
|
||||
if [[ "$current_context" != "$expected_context" ]]; then
|
||||
echo "Refusing bootstrap: current context is ${current_context}, expected ${expected_context}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
source "${REPO_ROOT}/bootstrap/gitops/argocd/version.env"
|
||||
|
||||
manifest="$(mktemp)"
|
||||
cleanup() {
|
||||
rm -f "$manifest"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
curl -fsSL \
|
||||
"https://raw.githubusercontent.com/argoproj/argo-cd/${ARGOCD_VERSION}/manifests/install.yaml" \
|
||||
-o "$manifest"
|
||||
printf '%s %s\n' "$ARGOCD_INSTALL_SHA256" "$manifest" | sha256sum -c -
|
||||
|
||||
kubectl --context "$expected_context" create namespace argocd --dry-run=client -o yaml |
|
||||
kubectl --context "$expected_context" apply -f -
|
||||
kubectl --context "$expected_context" apply \
|
||||
--server-side \
|
||||
--force-conflicts \
|
||||
-n argocd \
|
||||
-f "$manifest"
|
||||
kubectl --context "$expected_context" -n argocd \
|
||||
rollout status deployment/argocd-server --timeout=300s
|
||||
kubectl --context "$expected_context" -n argocd \
|
||||
rollout status deployment/argocd-repo-server --timeout=300s
|
||||
kubectl --context "$expected_context" apply \
|
||||
-f "${REPO_ROOT}/bootstrap/gitops/argocd/control-plane-project.yaml"
|
||||
kubectl --context "$expected_context" apply \
|
||||
-f "${REPO_ROOT}/bootstrap/gitops/argocd/root-application.yaml"
|
||||
|
||||
echo "Argo CD ${ARGOCD_VERSION}, its control-plane project, and the root Application are installed."
|
||||
Executable
+174
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${repo_root}"
|
||||
|
||||
required_tools=(
|
||||
bash
|
||||
find
|
||||
git
|
||||
grep
|
||||
kubectl
|
||||
make
|
||||
sed
|
||||
)
|
||||
|
||||
optional_tools=(
|
||||
age
|
||||
argocd
|
||||
conftest
|
||||
flux
|
||||
gitleaks
|
||||
helm
|
||||
kubeconform
|
||||
shellcheck
|
||||
sops
|
||||
terraform
|
||||
tofu
|
||||
trivy
|
||||
yamllint
|
||||
)
|
||||
|
||||
activated_tools=()
|
||||
missing=0
|
||||
|
||||
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)
|
||||
|
||||
has_chart=0
|
||||
iac_file=""
|
||||
for file in "${source_files[@]}"; do
|
||||
if [[ "${file##*/}" == "Chart.yaml" ]]; then
|
||||
has_chart=1
|
||||
fi
|
||||
|
||||
case "${file}" in
|
||||
bootstrap/*.tf | bootstrap/*.tf.json | bootstrap/*.tofu | bootstrap/*.tofu.json | \
|
||||
infrastructure/*.tf | infrastructure/*.tf.json | infrastructure/*.tofu | \
|
||||
infrastructure/*.tofu.json)
|
||||
iac_file="${file}"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if ((has_chart == 1)); then
|
||||
activated_tools+=("helm")
|
||||
fi
|
||||
|
||||
if [[ -n "${iac_file}" ]]; then
|
||||
iac_engine=""
|
||||
if [[ -f infrastructure/.iac-engine && ! -L infrastructure/.iac-engine ]]; then
|
||||
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
|
||||
iac_engine="${iac_engine_values[0]}"
|
||||
fi
|
||||
fi
|
||||
|
||||
case "${iac_engine}" in
|
||||
terraform | tofu)
|
||||
activated_tools+=("${iac_engine}")
|
||||
;;
|
||||
*)
|
||||
printf '[missing] infrastructure/.iac-engine must select terraform or tofu because IaC files exist.\n' >&2
|
||||
missing=1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
is_activated() {
|
||||
local candidate="$1"
|
||||
local tool
|
||||
|
||||
for tool in "${activated_tools[@]}"; do
|
||||
if [[ "${candidate}" == "${tool}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
printf 'Required tools\n'
|
||||
for tool in "${required_tools[@]}"; do
|
||||
if command -v "${tool}" >/dev/null 2>&1; then
|
||||
printf ' [ok] %-14s %s\n' "${tool}" "$(command -v "${tool}")"
|
||||
else
|
||||
printf ' [missing] %s\n' "${tool}"
|
||||
missing=1
|
||||
fi
|
||||
done
|
||||
|
||||
if ((${#activated_tools[@]} > 0)); then
|
||||
printf '\nTools required by activated source files\n'
|
||||
for tool in "${activated_tools[@]}"; do
|
||||
if command -v "${tool}" >/dev/null 2>&1; then
|
||||
printf ' [ok] %-14s %s\n' "${tool}" "$(command -v "${tool}")"
|
||||
else
|
||||
printf ' [missing] %s\n' "${tool}"
|
||||
missing=1
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
printf '\nOptional tools (required only after the related feature is enabled)\n'
|
||||
for tool in "${optional_tools[@]}"; do
|
||||
if is_activated "${tool}"; then
|
||||
continue
|
||||
fi
|
||||
|
||||
if command -v "${tool}" >/dev/null 2>&1; then
|
||||
printf ' [found] %-14s %s\n' "${tool}" "$(command -v "${tool}")"
|
||||
else
|
||||
printf ' [not set] %s\n' "${tool}"
|
||||
fi
|
||||
done
|
||||
|
||||
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
printf '\nRepository: Git work tree detected.\n'
|
||||
else
|
||||
printf '\nRepository: Git is not initialized yet; run git init when this skeleton becomes a repository.\n'
|
||||
fi
|
||||
|
||||
if ((missing != 0)); then
|
||||
printf '\nInstall/configure the missing required items before validation.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '\nDoctor check passed.\n'
|
||||
Executable
+281
@@ -0,0 +1,281 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
for cmd in bash git helm jq kubectl rg terraform; do
|
||||
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||||
echo "$cmd is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
forbidden_files="$(
|
||||
git ls-files --cached --others --exclude-standard |
|
||||
rg '(^|/)(\\.terraform|\\.terraform-state|\\.local)(/|$)|(^|/)terraform\\.tfstate($|\\.)|\\.tfplan$' ||
|
||||
true
|
||||
)"
|
||||
if [[ -n "$forbidden_files" ]]; then
|
||||
echo "Generated or state files are tracked:" >&2
|
||||
echo "$forbidden_files" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
while IFS= read -r script; do
|
||||
bash -n "$script"
|
||||
done < <(rg --files -g '*.sh')
|
||||
|
||||
while IFS= read -r script; do
|
||||
if [[ ! -x "$script" ]]; then
|
||||
echo "Shell entry point is not executable: ${script}" >&2
|
||||
exit 1
|
||||
fi
|
||||
done < <(rg --files scripts -g '*.sh')
|
||||
|
||||
jq empty renovate.json
|
||||
jq empty gitops/apps/systems/auth-system/base/files/keycloak/project-auth-realm.json
|
||||
|
||||
overlays=(
|
||||
bootstrap/gitops/argocd
|
||||
gitops/clusters/dev-k3s
|
||||
gitops/platform/control-plane/argocd
|
||||
gitops/clusters/dev-k3s/overlays/platform/vault
|
||||
gitops/clusters/dev-k3s/overlays/systems/auth-system
|
||||
gitops/clusters/dev-k3s/overlays/workloads/auth-server
|
||||
gitops/clusters/dev-k3s/overlays/workloads/api-server
|
||||
)
|
||||
for overlay in "${overlays[@]}"; do
|
||||
kubectl kustomize "$overlay" >/dev/null
|
||||
done
|
||||
|
||||
control_plane_render="$(kubectl kustomize gitops/platform/control-plane/argocd)"
|
||||
if [[ "$(rg -c '^kind: AppProject$' <<<"$control_plane_render")" -ne 4 ||
|
||||
"$(rg -c '^kind: ApplicationSet$' <<<"$control_plane_render")" -ne 4 ]]; then
|
||||
echo "The control plane must render exactly four AppProjects and four ApplicationSets." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
application_manifests="$(
|
||||
rg -l '^kind:[[:space:]]+Application$' \
|
||||
--glob '*.yaml' \
|
||||
--glob '*.yml' \
|
||||
. |
|
||||
sort
|
||||
)"
|
||||
if [[ "$application_manifests" != "./bootstrap/gitops/argocd/root-application.yaml" ]]; then
|
||||
echo "Only the bootstrap root may be an explicit Argo CD Application:" >&2
|
||||
echo "$application_manifests" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! rg -q '^[[:space:]]+project:[[:space:]]+gitops-control-plane$' \
|
||||
bootstrap/gitops/argocd/root-application.yaml ||
|
||||
! rg -q '^[[:space:]]+path:[[:space:]]+gitops/clusters/dev-k3s$' \
|
||||
bootstrap/gitops/argocd/root-application.yaml ||
|
||||
rg -n '^[[:space:]]+project:[[:space:]]+default$' \
|
||||
bootstrap gitops/platform/control-plane; then
|
||||
echo "The root and generated Applications must use explicit least-privilege AppProjects." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
applicationsets=(
|
||||
gitops/platform/control-plane/argocd/application-sets/platform-addons.yaml
|
||||
gitops/platform/control-plane/argocd/application-sets/platform-services.yaml
|
||||
gitops/platform/control-plane/argocd/application-sets/systems.yaml
|
||||
gitops/platform/control-plane/argocd/application-sets/workloads.yaml
|
||||
)
|
||||
for applicationset in "${applicationsets[@]}"; do
|
||||
for safety_setting in \
|
||||
'missingkey=error' \
|
||||
'applicationsSync: create-update' \
|
||||
'preserveResourcesOnDeletion: true' \
|
||||
'Prune=confirm,Delete=confirm'; do
|
||||
if ! rg -q "$safety_setting" "$applicationset"; then
|
||||
echo "${applicationset} is missing ApplicationSet safety setting: ${safety_setting}" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
if ! rg -q '^ - Prune=confirm$' \
|
||||
gitops/platform/control-plane/argocd/application-sets/platform-addons.yaml; then
|
||||
echo "Platform addons must require approval before pruning chart resources." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if rg -n 'project:[[:space:]]+"?\{\{' gitops/platform/control-plane/argocd/application-sets; then
|
||||
echo "ApplicationSet projects are privilege boundaries and must never be templated." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git_applicationsets=(
|
||||
gitops/platform/control-plane/argocd/application-sets/platform-services.yaml
|
||||
gitops/platform/control-plane/argocd/application-sets/systems.yaml
|
||||
gitops/platform/control-plane/argocd/application-sets/workloads.yaml
|
||||
)
|
||||
for applicationset in "${git_applicationsets[@]}"; do
|
||||
if ! rg -q '^[[:space:]]+targetRevision:[[:space:]]+main$' "$applicationset" ||
|
||||
rg -q '^[[:space:]]+(repoURL|targetRevision):[[:space:]]+"?\{\{' "$applicationset"; then
|
||||
echo "${applicationset} must pin the canonical repository main branch in its template." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
enabled_gates="$(
|
||||
rg -o 'autoSync: "true"' gitops/platform/control-plane/argocd/application-sets |
|
||||
wc -l |
|
||||
tr -d ' '
|
||||
)"
|
||||
disabled_gates="$(
|
||||
rg -o 'autoSync: "false"' gitops/platform/control-plane/argocd/application-sets |
|
||||
wc -l |
|
||||
tr -d ' '
|
||||
)"
|
||||
if [[ "$enabled_gates" -ne 2 || "$disabled_gates" -ne 4 ]]; then
|
||||
echo "Initial sync gates must enable only Sealed Secrets and Vault." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sealed_secrets_render="$(
|
||||
helm template sealed-secrets sealed-secrets \
|
||||
--repo https://bitnami.github.io/sealed-secrets \
|
||||
--version 2.17.9 \
|
||||
--namespace kube-system \
|
||||
--set fullnameOverride=sealed-secrets-controller \
|
||||
--set image.repository=bitnami/sealed-secrets-controller \
|
||||
--set-string image.tag=0.33.1@sha256:e7fad65c2d2f47e48d9ca17408ed56961bfa6a6dd74ccd4a1a214664156534bc
|
||||
)"
|
||||
if [[ "$sealed_secrets_render" != *'image: docker.io/bitnami/sealed-secrets-controller:0.33.1@sha256:e7fad65c2d2f47e48d9ca17408ed56961bfa6a6dd74ccd4a1a214664156534bc'* ]]; then
|
||||
echo "Sealed Secrets image digest was not rendered by the Helm chart." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
vault_injector_render="$(
|
||||
helm template vault-agent-injector vault \
|
||||
--repo https://helm.releases.hashicorp.com \
|
||||
--version 0.32.0 \
|
||||
--namespace vault \
|
||||
--set server.enabled=false \
|
||||
--set injector.enabled=true \
|
||||
--set global.externalVaultAddr=http://vault.vault.svc.cluster.local:8200 \
|
||||
--set global.tlsDisable=true \
|
||||
--set injector.image.repository=hashicorp/vault-k8s \
|
||||
--set-string injector.image.tag=1.7.2@sha256:ae3d307658b72a1cf35dab9bdf92c995d45cdc7183af0516857714b5bd0ba84d \
|
||||
--set injector.agentImage.repository=hashicorp/vault \
|
||||
--set-string injector.agentImage.tag=1.18.5@sha256:750bb37c1638fa194ab37053a81618c61bb0491ddec6fccac87c07a8e6cd8166
|
||||
)"
|
||||
if [[ "$vault_injector_render" != *'image: "hashicorp/vault-k8s:1.7.2@sha256:ae3d307658b72a1cf35dab9bdf92c995d45cdc7183af0516857714b5bd0ba84d"'* ||
|
||||
"$vault_injector_render" != *'value: "hashicorp/vault:1.18.5@sha256:750bb37c1638fa194ab37053a81618c61bb0491ddec6fccac87c07a8e6cd8166"'* ]]; then
|
||||
echo "Vault injector or Agent image digest was not rendered by the Helm chart." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rendered_images="$(
|
||||
{
|
||||
kubectl kustomize gitops/clusters/dev-k3s/overlays/systems/auth-system
|
||||
kubectl kustomize gitops/clusters/dev-k3s/overlays/platform/vault
|
||||
} | rg '^[[:space:]]+image: (hashicorp/vault|postgres|quay\\.io/keycloak)'
|
||||
)"
|
||||
if printf '%s\n' "$rendered_images" | rg -v '@sha256:[a-f0-9]{64}$'; then
|
||||
echo "A third-party runtime image is not pinned by digest." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
workload_kustomizations=(
|
||||
gitops/clusters/dev-k3s/overlays/workloads/auth-server/kustomization.yaml
|
||||
gitops/clusters/dev-k3s/overlays/workloads/api-server/kustomization.yaml
|
||||
)
|
||||
for workload_kustomization in "${workload_kustomizations[@]}"; do
|
||||
if rg -q '^[[:space:]]+digest:[[:space:]]+sha256:[a-f0-9]{64}$' \
|
||||
"$workload_kustomization"; then
|
||||
continue
|
||||
fi
|
||||
if ! rg -q '^[[:space:]]+newTag:[[:space:]]+[a-f0-9]{7,40}$' \
|
||||
"$workload_kustomization"; then
|
||||
echo "${workload_kustomization} must use a verified digest or a temporary commit-shaped tag." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
terraform fmt -check -recursive infrastructure
|
||||
terraform_roots=(
|
||||
infrastructure/live/dev-k3s/vault-foundation
|
||||
infrastructure/live/dev-k3s/vault-workloads
|
||||
infrastructure/live/dev-k3s/vault-database
|
||||
)
|
||||
for root in "${terraform_roots[@]}"; do
|
||||
data_dir="$(mktemp -d)"
|
||||
TF_DATA_DIR="$data_dir" terraform -chdir="$root" init \
|
||||
-backend=false \
|
||||
-input=false \
|
||||
-lockfile=readonly >/dev/null
|
||||
TF_DATA_DIR="$data_dir" terraform -chdir="$root" validate
|
||||
rm -rf "$data_dir"
|
||||
done
|
||||
|
||||
if rg -n \
|
||||
'github\\.com/DongHyeonka/Project-Auth-GitOps|bitnami-labs\\.github\\.io/sealed-secrets|/home/donghyeon/dev/Project-Auth-GitOps|terraform/vault(-transit)?/(dev|reconcile)|gitops/clusters/dev-k3s/manifests|platform/auth-system|platform/security/vault|platform-config|postgres\\.platform\\.svc|keycloak(-public)?\\.platform\\.svc|kv/data/dev/platform' \
|
||||
--glob '!docs/archive/**' \
|
||||
--glob '!docs/runbooks/terraform-state-migration.md' \
|
||||
--glob '!scripts/project-validate.sh' \
|
||||
.; then
|
||||
echo "Current files contain a legacy URL, path, namespace, or Vault secret path." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if rg -n '^path[[:space:]]+"[^"]*[+*]' infrastructure/live/dev-k3s/vault-workloads/policies; then
|
||||
echo "Workload Vault policies must use exact paths; wildcard paths require a security review." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
automation_policies=(
|
||||
infrastructure/live/dev-k3s/vault-foundation/policies/vault-workloads-automation-dev.hcl
|
||||
infrastructure/live/dev-k3s/vault-foundation/policies/vault-database-automation-dev.hcl
|
||||
)
|
||||
for policy in "${automation_policies[@]}"; do
|
||||
for self_path in \
|
||||
'sys/capabilities-self' \
|
||||
'auth/token/lookup-self' \
|
||||
'auth/token/revoke-self'; do
|
||||
if ! rg -q "^path \"${self_path}\"" "$policy"; then
|
||||
echo "${policy} is missing required no-default-policy self service path: ${self_path}" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
if rg -n \
|
||||
'^path[[:space:]]+"[^"]*[+*]|capabilities[[:space:]]*=.*"(sudo|list)"|^path[[:space:]]+"auth/token/(create|roles)' \
|
||||
infrastructure/live/dev-k3s/vault-foundation/policies; then
|
||||
echo "Delegated automation policies must not use wildcards, sudo/list, or token issuance paths." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if rg -n '^path[[:space:]]+"(sys/|auth/|database/config|database/roles)' \
|
||||
infrastructure/live/dev-k3s/vault-workloads/policies; then
|
||||
echo "Runtime workload policies may not configure Vault control-plane objects." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if rg -n 'uses:[[:space:]]+[^#[:space:]]+@v[0-9]' .gitea/workflows; then
|
||||
echo "Gitea Actions must be pinned to an immutable commit SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if rg -n 'ApplyOutOfSyncOnly=true' \
|
||||
bootstrap gitops/clusters gitops/platform/control-plane; then
|
||||
echo "ApplyOutOfSyncOnly is incompatible with hook-based migrations and must not be enabled." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if rg -n 'git[[:space:]]+push[^#]*(HEAD:)?main([[:space:]]|$)' .gitea scripts; then
|
||||
echo "Automation must promote changes through a branch and review, not push directly to main." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git diff --check
|
||||
echo "Repository validation passed."
|
||||
Executable
+401
@@ -0,0 +1,401 @@
|
||||
#!/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'
|
||||
Executable
+156
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
VAULT_ADDR="${VAULT_ADDR:-http://127.0.0.1:8200}"
|
||||
VAULT_INIT_OUTPUT="${VAULT_INIT_OUTPUT:-${REPO_ROOT}/.local/vault/dev-k3s-init.json}"
|
||||
|
||||
for cmd in jq rg vault; do
|
||||
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||||
echo "$cmd is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
export VAULT_ADDR
|
||||
|
||||
status_json() {
|
||||
local output=""
|
||||
local status_code=0
|
||||
|
||||
set +e
|
||||
output="$(vault status -format=json 2>/dev/null)"
|
||||
status_code=$?
|
||||
set -e
|
||||
|
||||
if [[ "$status_code" -ne 0 && "$status_code" -ne 2 ]]; then
|
||||
echo "Vault is not reachable at ${VAULT_ADDR}." >&2
|
||||
return "$status_code"
|
||||
fi
|
||||
printf '%s\n' "$output"
|
||||
}
|
||||
|
||||
unseal() {
|
||||
local status=""
|
||||
local unseal_key=""
|
||||
|
||||
status="$(status_json)"
|
||||
if [[ "$(jq -r '.sealed' <<<"$status")" == "false" ]]; then
|
||||
echo "Vault is already unsealed."
|
||||
return
|
||||
fi
|
||||
if [[ ! -f "$VAULT_INIT_OUTPUT" ]]; then
|
||||
echo "Init material is unavailable: ${VAULT_INIT_OUTPUT}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
unseal_key="$(jq -er '.unseal_keys_b64[0]' "$VAULT_INIT_OUTPUT")"
|
||||
vault operator unseal "$unseal_key" >/dev/null
|
||||
echo "Vault is unsealed."
|
||||
}
|
||||
|
||||
init() {
|
||||
local status=""
|
||||
|
||||
status="$(status_json)"
|
||||
if [[ "$(jq -r '.initialized' <<<"$status")" == "true" ]]; then
|
||||
echo "Refusing initialization: Vault is already initialized." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -e "$VAULT_INIT_OUTPUT" ]]; then
|
||||
echo "Refusing to overwrite existing init material: ${VAULT_INIT_OUTPUT}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
umask 077
|
||||
mkdir -p "$(dirname "$VAULT_INIT_OUTPUT")"
|
||||
vault operator init \
|
||||
-key-shares=1 \
|
||||
-key-threshold=1 \
|
||||
-format=json >"$VAULT_INIT_OUTPUT"
|
||||
chmod 0600 "$VAULT_INIT_OUTPUT"
|
||||
unseal
|
||||
|
||||
echo "Dev Vault was initialized with a dev-only 1-of-1 Shamir key."
|
||||
echo "Move ${VAULT_INIT_OUTPUT} to encrypted custody before continuing."
|
||||
}
|
||||
|
||||
revoke_root() {
|
||||
local database_lookup=""
|
||||
local database_token="${VAULT_DATABASE_TOKEN:-}"
|
||||
local root_token=""
|
||||
local temporary=""
|
||||
local workloads_lookup=""
|
||||
local workloads_token="${VAULT_WORKLOADS_TOKEN:-}"
|
||||
|
||||
if [[ ! -f "$VAULT_INIT_OUTPUT" ]]; then
|
||||
echo "Init material is unavailable: ${VAULT_INIT_OUTPUT}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$workloads_token" || -z "$database_token" ]]; then
|
||||
echo "Refusing root revocation: VAULT_WORKLOADS_TOKEN and VAULT_DATABASE_TOKEN are required." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! VAULT_TOKEN="$workloads_token" \
|
||||
vault token capabilities sys/policies/acl/auth-server-dev |
|
||||
rg -q '(^|,|[[:space:]])update($|,|[[:space:]])'; then
|
||||
echo "Refusing root revocation: the workloads replacement token failed its capability check." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! VAULT_TOKEN="$database_token" \
|
||||
vault token capabilities database/config/auth-system-postgres-dev |
|
||||
rg -q '(^|,|[[:space:]])update($|,|[[:space:]])'; then
|
||||
echo "Refusing root revocation: the database replacement token failed its capability check." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! workloads_lookup="$(
|
||||
VAULT_TOKEN="$workloads_token" vault token lookup -format=json
|
||||
)"; then
|
||||
echo "Refusing root revocation: the workloads replacement token cannot look itself up." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! database_lookup="$(
|
||||
VAULT_TOKEN="$database_token" vault token lookup -format=json
|
||||
)"; then
|
||||
echo "Refusing root revocation: the database replacement token cannot look itself up." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! jq -e '.data.policies | type == "array"' <<<"$workloads_lookup" >/dev/null ||
|
||||
! jq -e '.data.policies | type == "array"' <<<"$database_lookup" >/dev/null; then
|
||||
echo "Refusing root revocation: a replacement token returned an invalid lookup response." >&2
|
||||
exit 1
|
||||
fi
|
||||
if jq -e '.data.policies | index("root") != null' <<<"$workloads_lookup" >/dev/null ||
|
||||
jq -e '.data.policies | index("root") != null' <<<"$database_lookup" >/dev/null; then
|
||||
echo "Refusing root revocation: a replacement token unexpectedly carries the root policy." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
root_token="$(jq -er '.root_token' "$VAULT_INIT_OUTPUT")"
|
||||
VAULT_TOKEN="$root_token" vault token revoke -self
|
||||
|
||||
temporary="$(mktemp "${VAULT_INIT_OUTPUT}.XXXXXX")"
|
||||
jq 'del(.root_token)' "$VAULT_INIT_OUTPUT" >"$temporary"
|
||||
chmod 0600 "$temporary"
|
||||
mv "$temporary" "$VAULT_INIT_OUTPUT"
|
||||
echo "The initial root token was revoked and removed from the local init file."
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
init)
|
||||
init
|
||||
;;
|
||||
unseal)
|
||||
unseal
|
||||
;;
|
||||
revoke-root)
|
||||
revoke_root
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 <init|unseal|revoke-root>" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Reference in New Issue
Block a user