refactor: 폴더 구조 변경

This commit is contained in:
donghyeon-ka
2026-08-02 00:22:19 +09:00
parent f9c463f87a
commit be2f8e4863
1869 changed files with 4565 additions and 295591 deletions
+43
View File
@@ -0,0 +1,43 @@
# scripts AGENTS
Role:
- own helper automation for render, diff, apply, validate, backup, restore, and CI checks
- support Kustomize and operations workflows without replacing declarative ownership
Allowed:
- bash entrypoints
- shell libraries
- CI validation scripts
- backup / restore helper scripts
- wrapper commands around `kubectl kustomize`, `kubectl diff -k`, `kubectl apply -k`
Forbidden:
- scripts becoming the primary source of YAML truth
- large heredoc-generated manifests as the default path
- embedding production secret values
- hiding environment differences in shell conditionals instead of overlays
- giant all-in-one deploy scripts that mix unrelated concerns
Read first:
- `/docs/standards/infra/scripts.md`
- `/docs/standards/infra/kustomize.md`
- `/docs/standards/infra/operations-runbook-upgrade-rollback.md`
- `/docs/standards/infra/backup-restore.md`
- `/docs/standards/infra/k3s-specific.md`
Examples:
- `/docs/examples/infra/scripts.md`
- `/docs/examples/infra/kustomize.md`
- `/docs/examples/infra/backup-restore.md`
- `/docs/examples/infra/operations-runbook-upgrade-rollback.md`
- `/docs/examples/infra/k3s-specific.md`
Rules:
- scripts are helpers, not source of truth
- prefer bash
- non-trivial scripts use functions + `main "$@"`
- use `local` for function-local variables
- ShellCheck must pass
- prefer render -> diff -> apply flow
- destructive actions require explicit opt-in
- environment/context/namespace must be explicit, not implicit
+40
View File
@@ -0,0 +1,40 @@
# Scripts
Kustomize source를 대체하지 않는 얇고 명시적인 운영 자동화입니다.
```text
scripts/
├── bin/
│ ├── bootstrap.sh
│ └── teardown.sh
├── tasks/
├── lib/
├── ci/
│ ├── validate.sh
│ └── validate-docs.sh
├── doctor.sh
└── validate.sh
```
- `doctor.sh`: 로컬 도구와 IaC 엔진 선택 상태 확인
- `validate.sh`: 템플릿 구조, 민감 파일, shell, Kustomize와 IaC 형식 검증
- `ci/validate.sh`: 프로젝트 entrypoint, schema, policy, shell, secret와 문서 검증
- `bin/bootstrap.sh`: stage별 render/dry-run/diff/confirm/apply와 readiness 대기
- `bin/teardown.sh`: 명시적 opt-in을 요구하는 역순 제거
```bash
make doctor
make check
export KUBE_CONTEXT_LAB='<expected-context>'
bash scripts/bin/bootstrap.sh lab
bash scripts/bin/teardown.sh lab
```
Context 검증은 `CONFIRM=yes`로 우회할 수 없습니다. teardown은 기본적으로
app/operation만 제거하며 data/Vault/namespace는 `DELETE_DATA=yes`, 공유
operator는 `TEARDOWN_PLATFORM=yes`가 추가로 필요합니다.
검증한 Kubernetes context는 이후 모든 `kubectl`/`helm` 호출과 하위 task에
고정됩니다. `prod`의 Vault init material은 repository 밖 경로만 허용하며,
Secret 강제 재생성에는 `ALLOW_PROD_SECRET_RESET=yes`가 추가로 필요합니다.
+305
View File
@@ -0,0 +1,305 @@
#!/usr/bin/env bash
# Deploy one environment through explicit readiness boundaries.
#
# Kustomize stages are the deployment contract:
# namespaces -> 00-platform -> 10-vault -> 20-secrets
# -> 30-data -> MinIO credential provisioning -> 35-registry
# -> 40-operations -> 50-apps
#
# `gitops/clusters/<env>/main/all` is deliberately not applied. It exists only for local
# render, schema, and policy validation because Kustomize cannot express CRD
# establishment, controller readiness, Vault initialization, or one-shot Job
# completion.
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=../lib/common.sh
. "$SCRIPT_DIR/../lib/common.sh"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
GITOPS_ROOT="$REPO_ROOT/gitops"
TASKS_DIR="$SCRIPT_DIR/../tasks"
FIELD_MANAGER="project-infra-bootstrap"
NAMESPACE="mnt"
export REPO_ROOT
VSO_MANAGED_SECRETS=(
identity-postgres-superuser
keycloak-db
keycloak-db-operator
auth-server-db
keycloak-bootstrap-admin
keycloak-bootstrap-admin-operator
keycloak-client-auth-server-ingress
oauth2-proxy-secrets
minio-tenant-env
docker-registry-minio
docker-registry-basic-auth
docker-registry-pull-credentials
)
PRE_DATA_SECRETS=(
identity-postgres-superuser
keycloak-db
keycloak-db-operator
auth-server-db
keycloak-bootstrap-admin
keycloak-bootstrap-admin-operator
keycloak-client-auth-server-ingress
oauth2-proxy-secrets
minio-tenant-env
docker-registry-basic-auth
docker-registry-pull-credentials
)
usage() {
cat >&2 <<'EOF'
Usage: scripts/bin/bootstrap.sh <lab|staging|prod>
The selected overlay must exist. The repository currently ships `lab`.
Required:
KUBE_CONTEXT_<ENV> or KUBE_CONTEXT Expected kubectl context
Options:
VAULT_KEYS_FILE=<path> Vault init result. prod requires a path outside the repo
RESET_STALE_SECRETS=yes Delete VSO-managed Kubernetes Secrets before resync
ALLOW_PROD_SECRET_RESET=yes Required with RESET_STALE_SECRETS=yes in prod
SKIP_DIFF=yes Skip kubectl diff (not recommended)
CONFIRM=yes Approve each stage in non-interactive automation
EOF
exit 1
}
render_entrypoint() {
local entrypoint="$1"
local output="$2"
kubectl kustomize "$entrypoint" >"$output"
}
apply_entrypoint() {
local label="$1"
local entrypoint="$2"
[[ -f "$entrypoint/kustomization.yaml" ]] \
|| die "Kustomize entrypoint 없음: $entrypoint"
local tmpdir rendered rc=0
tmpdir="$(mktemp -d -t project-infra-bootstrap.XXXXXX)"
trap_cleanup_path "$tmpdir"
rendered="$tmpdir/rendered.yaml"
render_entrypoint "$entrypoint" "$rendered"
log "[$label] render 완료: $(grep -c '^kind:' "$rendered") resources"
if ! kubectl apply -f "$rendered" \
--dry-run=server \
--server-side \
--field-manager="$FIELD_MANAGER" \
>"$tmpdir/dry-run.out" 2>&1; then
err "[$label] server-side dry-run 실패"
sed 's/^/ /' "$tmpdir/dry-run.out" >&2
die "$label 검증 실패"
fi
if [[ "${SKIP_DIFF:-}" == "yes" ]]; then
warn "[$label] SKIP_DIFF=yes: diff 생략"
else
kubectl diff -f "$rendered" \
--server-side \
--field-manager="$FIELD_MANAGER" \
>"$tmpdir/diff.out" 2>&1 || rc=$?
case "$rc" in
0)
log "[$label] 변경 없음"
;;
1)
log "[$label] diff"
sed 's/^/ /' "$tmpdir/diff.out" >&2
;;
*)
err "[$label] kubectl diff 실패 (exit=$rc)"
sed 's/^/ /' "$tmpdir/diff.out" >&2
die "$label diff 실패"
;;
esac
fi
confirm "[$label] env=$ENV_NAME context='$KUBE_CONTEXT_LOCKED' 에 적용합니까?" \
|| die "사용자 취소"
kubectl apply -f "$rendered" \
--server-side \
--field-manager="$FIELD_MANAGER"
}
precheck_namespaces() {
local phase
ns_exists "$NAMESPACE" || return 0
phase="$(ns_phase "$NAMESPACE")"
[[ "$phase" != "Terminating" ]] \
|| die "namespace '$NAMESPACE' 가 Terminating 상태입니다. teardown 복구를 먼저 완료하세요."
}
wait_platform_ready() {
log "cert-manager CRD / controller 준비 대기"
retry 30 2 kubectl wait --for=condition=Established --timeout=10s \
crd/certificates.cert-manager.io \
crd/clusterissuers.cert-manager.io
kubectl -n cert-manager rollout status deployment/cert-manager --timeout=300s
kubectl -n cert-manager rollout status deployment/cert-manager-webhook --timeout=300s
kubectl -n cert-manager rollout status deployment/cert-manager-cainjector --timeout=300s
log "Keycloak Operator 준비 대기"
retry 30 2 kubectl wait --for=condition=Established --timeout=10s \
crd/keycloaks.k8s.keycloak.org \
crd/keycloakrealmimports.k8s.keycloak.org
kubectl -n "$NAMESPACE" rollout status deployment/keycloak-operator --timeout=300s
}
install_helm_operators() {
log "MinIO Operator 설치"
ENV_NAME="$ENV_NAME" bash "$TASKS_DIR/minio-operator-install.sh"
log "Vault Secrets Operator 설치"
ENV_NAME="$ENV_NAME" bash "$TASKS_DIR/vso-install.sh"
}
wait_vault_running() {
log "Vault 초기화 전 pod/vault-0 Running 대기"
retry 3 10 kubectl -n "$NAMESPACE" wait \
--for=jsonpath='{.status.phase}'=Running \
pod/vault-0 \
--timeout=120s
}
initialize_and_seed_vault() {
log "Vault 초기화 / unseal / Kubernetes auth 구성"
ENV_NAME="$ENV_NAME" bash "$TASKS_DIR/vault-init.sh"
log "애플리케이션 Secret seed"
ENV_NAME="$ENV_NAME" bash "$TASKS_DIR/vault-seed-apps.sh"
}
reset_stale_secrets_if_requested() {
[[ "${RESET_STALE_SECRETS:-}" == "yes" ]] || return 0
if [[ "$ENV_NAME" == "prod" && "${ALLOW_PROD_SECRET_RESET:-}" != "yes" ]]; then
die "prod Secret 재생성은 ALLOW_PROD_SECRET_RESET=yes 가 추가로 필요합니다."
fi
warn "RESET_STALE_SECRETS=yes: VSO destination Secret 재생성"
local secret_name
for secret_name in "${VSO_MANAGED_SECRETS[@]}"; do
kubectl -n "$NAMESPACE" delete secret "$secret_name" --ignore-not-found
done
}
pre_data_secrets_ready() {
local secret_name
for secret_name in "${PRE_DATA_SECRETS[@]}"; do
kubectl -n "$NAMESPACE" get secret "$secret_name" >/dev/null 2>&1 || return 1
done
}
wait_secrets_ready() {
log "30-data 선행 VSO destination Secret 생성 대기"
retry 90 2 pre_data_secrets_ready
}
wait_data_ready() {
log "PostgreSQL 준비 대기"
kubectl -n "$NAMESPACE" rollout status statefulset/identity-postgres --timeout=600s
log "MinIO Tenant 준비 대기"
retry 24 5 kubectl -n "$NAMESPACE" wait --for=condition=Ready \
pod -l v1.min.io/tenant=minio --timeout=20s
log "Keycloak 준비 대기"
retry 24 5 kubectl -n "$NAMESPACE" wait --for=condition=Ready \
pod -l app.kubernetes.io/instance=keycloak --timeout=20s
}
provision_registry() {
log "docker-registry용 MinIO bucket / access key 구성"
ENV_NAME="$ENV_NAME" bash "$TASKS_DIR/minio-provision-registry.sh"
apply_entrypoint "35-registry" "$CLUSTER_ROOT/stages/35-registry"
log "docker-registry MinIO credential Secret 생성 대기"
retry 90 2 kubectl -n "$NAMESPACE" get secret docker-registry-minio
log "docker-registry 준비 대기"
kubectl -n "$NAMESPACE" rollout status deployment/docker-registry --timeout=300s
}
wait_operations_complete() {
log "Flyway migration 완료 대기"
kubectl -n "$NAMESPACE" wait --for=condition=Complete \
job/auth-server-migrate-0-1-0 --timeout=1800s
log "Keycloak realm import 완료 대기"
kubectl -n "$NAMESPACE" wait --for=condition=Done \
keycloakrealmimport/platform-realm-v1 --timeout=600s
}
wait_apps_ready() {
kubectl -n "$NAMESPACE" rollout status deployment/auth-server --timeout=600s
kubectl -n "$NAMESPACE" rollout status deployment/oauth2-proxy --timeout=300s
}
summary() {
log "============================================"
log " $ENV_NAME staged bootstrap 완료"
log "============================================"
log "Vault init material: ${VAULT_KEYS_FILE:-$REPO_ROOT/vault-init-keys.json}"
log "이 파일은 Git에서 제외되지만 평문입니다. 오프라인 보관소로 이동하세요."
log "검증: kubectl -n $NAMESPACE get pods,jobs,vaultstaticsecrets"
}
main() {
ENV_NAME="${1:-}"
case "$ENV_NAME" in
lab | staging | prod) ;;
*) usage ;;
esac
CLUSTER_ROOT="$GITOPS_ROOT/clusters/$ENV_NAME/main"
NAMESPACE_ENTRYPOINT="$CLUSTER_ROOT/namespaces"
[[ -f "$CLUSTER_ROOT/all/kustomization.yaml" ]] \
|| die "클러스터 entrypoint가 없습니다: $CLUSTER_ROOT"
require_cmd kubectl helm jq openssl mc curl
require_kube_context "$ENV_NAME"
precheck_namespaces
log "Project-Infra staged bootstrap: env=$ENV_NAME context=$KUBE_CONTEXT_LOCKED"
# Namespace first: a server-side dry-run cannot create a Namespace and
# validate namespaced resources against it in the same request.
apply_entrypoint "namespaces" "$NAMESPACE_ENTRYPOINT"
apply_entrypoint "00-platform" "$CLUSTER_ROOT/stages/00-platform"
wait_platform_ready
install_helm_operators
apply_entrypoint "10-vault" "$CLUSTER_ROOT/stages/10-vault"
wait_vault_running
initialize_and_seed_vault
reset_stale_secrets_if_requested
apply_entrypoint "20-secrets" "$CLUSTER_ROOT/stages/20-secrets"
wait_secrets_ready
apply_entrypoint "30-data" "$CLUSTER_ROOT/stages/30-data"
wait_data_ready
provision_registry
apply_entrypoint "40-operations" "$CLUSTER_ROOT/stages/40-operations"
wait_operations_complete
apply_entrypoint "50-apps" "$CLUSTER_ROOT/stages/50-apps"
wait_apps_ready
summary
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@"
fi
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env bash
# Remove an environment in reverse dependency order.
#
# Default scope removes only application and one-shot operation resources.
# Stateful data, Vault, namespaces, CRDs, and shared operators require explicit
# opt-in flags.
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=../lib/common.sh
. "$SCRIPT_DIR/../lib/common.sh"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
GITOPS_ROOT="$REPO_ROOT/gitops"
NAMESPACE="mnt"
VSO_NAMESPACE="${VSO_NAMESPACE:-vault-secrets-operator-system}"
VSO_RELEASE="${VSO_RELEASE:-vault-secrets-operator}"
MINIO_OPERATOR_NAMESPACE="${MINIO_OPERATOR_NAMESPACE:-minio-operator}"
MINIO_OPERATOR_RELEASE="${MINIO_OPERATOR_RELEASE:-minio-operator}"
NAMESPACE_DELETE_INCOMPLETE=0
usage() {
cat >&2 <<'EOF'
Usage: scripts/bin/teardown.sh <lab|staging|prod>
Default:
Remove stages 50-apps and 40-operations only.
Options:
DELETE_DATA=yes Also remove data, VSO CRs, Vault, and app namespace
TEARDOWN_PLATFORM=yes Also remove shared Helm operators and stage 00
(requires DELETE_DATA=yes)
FORCE_FINALIZERS=yes Last-resort namespace finalizer removal
(requires DELETE_DATA=yes)
ALLOW_PROD_DESTRUCTIVE=yes Required for prod
CONFIRM=yes Non-interactive confirmation
EOF
exit 1
}
delete_entrypoint() {
local label="$1"
local entrypoint="$2"
[[ -f "$entrypoint/kustomization.yaml" ]] || {
log "[$label] entrypoint 없음: skip"
return 0
}
log "[$label] delete"
kubectl delete -k "$entrypoint" \
--ignore-not-found \
--wait=true \
--timeout=300s
}
delete_application_scope() {
delete_entrypoint "50-apps" "$CLUSTER_ROOT/stages/50-apps"
delete_entrypoint "40-operations" "$CLUSTER_ROOT/stages/40-operations"
}
delete_data_scope() {
[[ "${DELETE_DATA:-}" == "yes" ]] || {
log "DELETE_DATA 미지정: data / secrets / Vault / namespace 보존"
return 0
}
delete_entrypoint "35-registry" "$CLUSTER_ROOT/stages/35-registry"
delete_entrypoint "30-data" "$CLUSTER_ROOT/stages/30-data"
delete_entrypoint "20-secrets" "$CLUSTER_ROOT/stages/20-secrets"
delete_entrypoint "10-vault" "$CLUSTER_ROOT/stages/10-vault"
log "namespace/$NAMESPACE 삭제"
if ! kubectl delete namespace "$NAMESPACE" \
--ignore-not-found \
--wait=true \
--timeout=300s; then
warn "namespace 삭제가 제한 시간 안에 완료되지 않았습니다."
NAMESPACE_DELETE_INCOMPLETE=1
fi
}
force_finalize_if_requested() {
[[ "${FORCE_FINALIZERS:-}" == "yes" ]] || return 0
[[ "${DELETE_DATA:-}" == "yes" ]] \
|| die "FORCE_FINALIZERS=yes 는 DELETE_DATA=yes 와 함께만 사용할 수 있습니다."
ns_exists "$NAMESPACE" || return 0
warn "FORCE_FINALIZERS=yes: orphaned volume/controller state가 생길 수 있습니다."
confirm "namespace/$NAMESPACE finalizer를 강제로 제거합니까?" || die "사용자 취소"
strip_finalizers_all_ns_resources "$NAMESPACE"
force_finalize_namespace "$NAMESPACE"
wait_namespace_gone "$NAMESPACE" 120 \
|| die "namespace/$NAMESPACE 강제 finalize 후에도 남아 있습니다."
NAMESPACE_DELETE_INCOMPLETE=0
}
delete_platform_scope() {
[[ "${TEARDOWN_PLATFORM:-}" == "yes" ]] || {
log "TEARDOWN_PLATFORM 미지정: 공유 operator / CRD / stage 00 보존"
return 0
}
[[ "${DELETE_DATA:-}" == "yes" ]] \
|| die "TEARDOWN_PLATFORM=yes 는 DELETE_DATA=yes 와 함께만 사용할 수 있습니다."
warn "공유 controller와 cluster-scoped 리소스를 제거합니다."
confirm "이 클러스터가 다른 환경과 공유되지 않음을 확인했습니까?" || die "사용자 취소"
helm -n "$VSO_NAMESPACE" uninstall "$VSO_RELEASE" --wait --timeout=5m \
2>/dev/null || warn "VSO Helm release가 없거나 uninstall에 실패했습니다."
helm -n "$MINIO_OPERATOR_NAMESPACE" uninstall "$MINIO_OPERATOR_RELEASE" --wait --timeout=5m \
2>/dev/null || warn "MinIO Operator Helm release가 없거나 uninstall에 실패했습니다."
delete_entrypoint "00-platform" "$CLUSTER_ROOT/stages/00-platform"
kubectl delete namespace "$VSO_NAMESPACE" "$MINIO_OPERATOR_NAMESPACE" \
--ignore-not-found \
--wait=true \
--timeout=300s \
|| warn "operator namespace 삭제가 완료되지 않았습니다."
}
main() {
ENV_NAME="${1:-}"
case "$ENV_NAME" in
lab | staging | prod) ;;
*) usage ;;
esac
CLUSTER_ROOT="$GITOPS_ROOT/clusters/$ENV_NAME/main"
[[ -f "$CLUSTER_ROOT/all/kustomization.yaml" ]] \
|| die "클러스터 entrypoint가 없습니다: $CLUSTER_ROOT"
require_cmd kubectl helm jq
require_kube_context "$ENV_NAME"
require_production_gate "$ENV_NAME" "$NAMESPACE"
local scope="apps + one-shot operations"
[[ "${DELETE_DATA:-}" == "yes" ]] && scope="$scope + data + Vault + namespace"
[[ "${TEARDOWN_PLATFORM:-}" == "yes" ]] && scope="$scope + shared platform"
log "Project-Infra teardown: env=$ENV_NAME context=$KUBE_CONTEXT_LOCKED"
confirm "삭제 범위: $scope. 계속합니까?" || die "사용자 취소"
delete_application_scope
delete_data_scope
force_finalize_if_requested
delete_platform_scope
if ((NAMESPACE_DELETE_INCOMPLETE == 1)) && ns_exists "$NAMESPACE"; then
die "$ENV_NAME teardown 미완료: namespace/$NAMESPACE 가 남아 있습니다."
fi
log "$ENV_NAME teardown 완료: $scope"
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@"
fi
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Lightweight documentation gate. Manifest examples remain reviewable prose, so
# this gate checks deterministic repository contracts instead of applying them.
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=../lib/common.sh
. "$SCRIPT_DIR/../lib/common.sh"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
require_cmd rg
failed=0
OPERATIONAL_DOCS=(
"$REPO_ROOT/README.md"
"$REPO_ROOT/guide.md"
"$REPO_ROOT/gitops/PROJECT.md"
"$REPO_ROOT/gitops/clusters/lab/main/README.md"
"$REPO_ROOT/docs/architecture.md"
"$REPO_ROOT/docs/operations.md"
"$REPO_ROOT/docs/networking.md"
"$REPO_ROOT/docs/ingress-traefik.md"
"$REPO_ROOT/docs/security-hardening.md"
"$REPO_ROOT/docs/troubleshooting.md"
"$REPO_ROOT/docs/vault-vso.md"
)
if rg -n $'\t' "$REPO_ROOT/docs" --glob '*.md'; then
err "docs: tab 문자를 사용한 Markdown이 있습니다."
failed=$((failed + 1))
fi
if rg -n 'k8s/(base|components|overlays|scripts)|terraform/(modules|environments)|base/(app|managing|plugins)/' \
"$REPO_ROOT/README.md" \
"$REPO_ROOT/AGENTS.md" \
"$REPO_ROOT/guide.md" \
"$REPO_ROOT/bootstrap" \
"$REPO_ROOT/infrastructure" \
"$REPO_ROOT/gitops" \
"$REPO_ROOT/scripts/README.md" \
"$REPO_ROOT/scripts/AGENTS.md" \
"$REPO_ROOT/docs" \
--glob '*.md'; then
err "docs: 폐기된 Kustomize 경로가 남아 있습니다."
failed=$((failed + 1))
fi
if rg -n '(password|token|secret)[[:space:]]*=[[:space:]]*\"[^<${][^\"]+\"' \
"${OPERATIONAL_DOCS[@]}"; then
err "docs: 실제 값처럼 보이는 credential literal이 있습니다."
failed=$((failed + 1))
fi
((failed == 0)) || exit 1
log "documentation contract 검사 통과"
+165
View File
@@ -0,0 +1,165 @@
#!/usr/bin/env bash
# Render every declared entrypoint, then run available schema/policy/shell gates.
# CI sets VALIDATION_PROFILE=full so a missing validator is a hard failure.
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=../lib/common.sh
. "$SCRIPT_DIR/../lib/common.sh"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
ENTRYPOINTS_FILE="$REPO_ROOT/tests/kustomize-entrypoints.txt"
KUBE_LINTER_CFG="$REPO_ROOT/.kube-linter.yaml"
VALIDATION_PROFILE="${VALIDATION_PROFILE:-local}"
CRD_CATALOG='https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json'
WORK_DIR="$(mktemp -d -t project-infra-validate.XXXXXX)"
trap_cleanup_path "$WORK_DIR"
tool_available() {
command -v "$1" >/dev/null 2>&1
}
require_full_tool() {
local tool="$1"
if tool_available "$tool"; then
return 0
fi
[[ "$VALIDATION_PROFILE" != "full" ]] \
|| die "VALIDATION_PROFILE=full: 필수 도구가 없습니다: $tool"
warn "$tool 없음: 해당 검증을 skip합니다. 'mise install' 또는 CI를 사용하세요."
return 1
}
render_kustomization() {
local entrypoint="$1"
local output="$2"
if tool_available kustomize; then
kustomize build "$entrypoint" >"$output"
elif tool_available kubectl; then
kubectl kustomize "$entrypoint" >"$output"
else
die "kustomize 또는 kubectl 중 하나가 필요합니다."
fi
}
load_entrypoints() {
[[ -f "$ENTRYPOINTS_FILE" ]] || die "entrypoint inventory 없음: $ENTRYPOINTS_FILE"
mapfile -t ENTRYPOINTS < <(
sed -e 's/#.*$//' -e '/^[[:space:]]*$/d' "$ENTRYPOINTS_FILE"
)
((${#ENTRYPOINTS[@]} > 0)) || die "검증할 entrypoint가 없습니다."
}
validate_manifests() {
local rel entrypoint rendered failed=0
local have_kubeconform=false have_kube_linter=false
require_full_tool kubeconform && have_kubeconform=true
require_full_tool kube-linter && have_kube_linter=true
for rel in "${ENTRYPOINTS[@]}"; do
entrypoint="$REPO_ROOT/$rel"
[[ -f "$entrypoint/kustomization.yaml" ]] || {
err "$rel: kustomization.yaml 없음"
failed=$((failed + 1))
continue
}
rendered="$WORK_DIR/$(printf '%s' "$rel" | tr '/' '_').yaml"
if ! render_kustomization "$entrypoint" "$rendered" 2>"$rendered.err"; then
err "$rel: render 실패"
sed 's/^/ /' "$rendered.err" >&2
failed=$((failed + 1))
continue
fi
log "$rel: render=ok ($(grep -c '^kind:' "$rendered") resources)"
if grep -Eq 'replace-in-overlay|__REPLACE_ME_[A-Z0-9_]+' "$rendered"; then
err "$rel: unresolved placeholder가 render 결과에 남아 있습니다."
grep -En 'replace-in-overlay|__REPLACE_ME_[A-Z0-9_]+' "$rendered" \
| sed 's/^/ /' >&2
failed=$((failed + 1))
fi
if [[ "$have_kubeconform" == "true" ]]; then
if ! kubeconform \
-strict \
-ignore-missing-schemas \
-schema-location default \
-schema-location "$CRD_CATALOG" \
-summary \
"$rendered" >"$rendered.kubeconform" 2>&1; then
err "$rel: kubeconform 실패"
sed 's/^/ /' "$rendered.kubeconform" >&2
failed=$((failed + 1))
fi
fi
if [[ "$have_kube_linter" == "true" ]]; then
if ! kube-linter lint \
--config "$KUBE_LINTER_CFG" \
"$rendered" >"$rendered.kube-linter" 2>&1; then
err "$rel: kube-linter 실패"
sed 's/^/ /' "$rendered.kube-linter" >&2
failed=$((failed + 1))
fi
fi
done
((failed == 0)) || return 1
}
validate_shell() {
local failed=0
mapfile -t shell_files < <(
find "$REPO_ROOT/scripts" -type f -name '*.sh' -print | sort
)
if require_full_tool shellcheck; then
shellcheck -S style -x -P SCRIPTDIR "${shell_files[@]}" \
|| failed=$((failed + 1))
fi
if require_full_tool shfmt; then
shfmt -i 2 -bn -ci -d "${shell_files[@]}" || failed=$((failed + 1))
fi
((failed == 0)) || return 1
}
validate_secrets() {
if require_full_tool gitleaks; then
local scan_dir="$WORK_DIR/gitleaks-source"
mkdir -p "$scan_dir"
(
cd "$REPO_ROOT"
git ls-files -z --cached --others --exclude-standard \
| while IFS= read -r -d '' source_file; do
if [[ -f "$source_file" ]]; then
printf '%s\0' "$source_file"
fi
done \
| tar --null --files-from=- --create --file=-
) | tar --extract --file=- --directory="$scan_dir"
gitleaks dir "$scan_dir" \
--no-banner \
--redact \
--config "$REPO_ROOT/.gitleaks.toml"
fi
}
main() {
load_entrypoints
local failed=0
validate_manifests || failed=$((failed + 1))
validate_shell || failed=$((failed + 1))
validate_secrets || failed=$((failed + 1))
bash "$SCRIPT_DIR/validate-docs.sh" || failed=$((failed + 1))
((failed == 0)) || die "$failed validation group(s) 실패"
log "모든 validation gate 통과 (profile=$VALIDATION_PROFILE)"
}
main "$@"
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env bash
set -Eeuo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${repo_root}"
required_tools=(
bash
curl
find
git
grep
helm
jq
kubectl
make
mc
openssl
rg
sed
)
optional_tools=(
age
argocd
conftest
flux
gitleaks
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 deployment.\n' >&2
exit 1
fi
printf '\nDoctor check passed.\n'
+363
View File
@@ -0,0 +1,363 @@
#!/usr/bin/env bash
# Common shell library for Project-Infra scripts.
#
# Source this with: . "$(dirname "$0")/../lib/common.sh"
# or from tasks/: . "$SCRIPT_DIR/../lib/common.sh"
#
# Provides:
# - strict mode + safe IFS
# - log()/warn()/err()/die() to stderr with ISO 8601 + level prefix
# - trap_cleanup_path() / trap_cleanup_fn() — EXIT 시 경로 rm -rf 또는 함수 호출
# - require_cmd() / require_env() — preconditions
# - confirm() — interactive + CONFIRM=yes env-var gate
# - mask_secret() — masks sensitive values in logs
# - retry() — retry a command with linear backoff
# - require_kube_context() / require_production_gate() — env 타깃 검증 가드
# - kubectl() / helm() — 검증된 context를 모든 하위 호출에 고정
# - ns_exists() / ns_phase() / strip_finalizers_in_ns() /
# strip_finalizers_all_ns_resources() / force_finalize_namespace() /
# wait_namespace_gone() — namespace teardown 복구 헬퍼
#
# All functions emit diagnostic output to stderr; stdout stays clean
# so callers can pipe subcommand output normally.
# shellcheck shell=bash
# -----------------------------------------------------------------------------
# strict mode
# -----------------------------------------------------------------------------
set -Eeuo pipefail
IFS=$'\n\t'
# -----------------------------------------------------------------------------
# logging
# -----------------------------------------------------------------------------
_ts() { date -u +"%Y-%m-%dT%H:%M:%SZ"; }
log() { printf '%s [INFO] %s\n' "$(_ts)" "$*" >&2; }
warn() { printf '%s [WARN] %s\n' "$(_ts)" "$*" >&2; }
err() { printf '%s [ERROR] %s\n' "$(_ts)" "$*" >&2; }
die() {
err "$*"
exit 1
}
# -----------------------------------------------------------------------------
# cleanup registration
#
# eval 기반 문자열 cleanup 은 공통 라이브러리에 두기에 부적절하다 (셸 인젝션
# 경로가 열리기 쉽고, Google shell 가이드 기준 code smell). 대신 두 가지 구체
# 타입만 제공한다:
# - trap_cleanup_path <path> : EXIT 시 rm -rf 로 삭제할 경로
# - trap_cleanup_fn <fname> : EXIT 시 인자 없이 호출할 함수 이름
# 두 종류 다 LIFO 로 실행되고, 실패해도 전체 종료 코드는 보존된다.
# -----------------------------------------------------------------------------
_CLEANUP_PATHS=()
_CLEANUP_FNS=()
trap_cleanup_path() {
local p="$1"
[[ -n "$p" ]] || return 0
_CLEANUP_PATHS+=("$p")
}
trap_cleanup_fn() {
local fn="$1"
declare -F "$fn" >/dev/null 2>&1 \
|| {
err "trap_cleanup_fn: 함수를 찾을 수 없음: $fn"
return 1
}
_CLEANUP_FNS+=("$fn")
}
_run_cleanups() {
local rc="${1:-$?}"
local i
# Signal handler가 exit할 때 EXIT trap으로 재진입하지 않도록 먼저 해제한다.
trap - EXIT INT TERM
for ((i = ${#_CLEANUP_FNS[@]} - 1; i >= 0; i--)); do
"${_CLEANUP_FNS[$i]}" || true
done
for ((i = ${#_CLEANUP_PATHS[@]} - 1; i >= 0; i--)); do
rm -rf -- "${_CLEANUP_PATHS[$i]}" || true
done
return "$rc"
}
_handle_signal() {
local signal_number="$1"
local rc=$((128 + signal_number))
_run_cleanups "$rc" || true
exit "$rc"
}
trap '_run_cleanups $?' EXIT
trap '_handle_signal 2' INT
trap '_handle_signal 15' TERM
# -----------------------------------------------------------------------------
# context-locked Kubernetes clients
# -----------------------------------------------------------------------------
# bootstrap/teardown의 context 확인이 끝나면 KUBE_CONTEXT_LOCKED를 export한다.
# 이후 current-context가 다른 프로세스에서 바뀌어도 모든 kubectl/helm 호출은
# 검증한 context를 명시적으로 사용한다. 하위 task도 common.sh를 source하므로
# 같은 lock을 상속한다.
kubectl() {
local locked_context="${KUBE_CONTEXT_LOCKED:-${KUBE_CONTEXT:-}}"
if [[ -n "$locked_context" ]]; then
command kubectl --context "$locked_context" "$@"
else
command kubectl "$@"
fi
}
helm() {
local locked_context="${KUBE_CONTEXT_LOCKED:-${KUBE_CONTEXT:-}}"
if [[ -n "$locked_context" ]]; then
command helm --kube-context "$locked_context" "$@"
else
command helm "$@"
fi
}
_lock_kube_context() {
KUBE_CONTEXT_LOCKED="$1"
HELM_KUBECONTEXT="$1"
export KUBE_CONTEXT_LOCKED HELM_KUBECONTEXT
}
# -----------------------------------------------------------------------------
# preconditions
# -----------------------------------------------------------------------------
require_cmd() {
local cmd
for cmd in "$@"; do
if declare -F "$cmd" >/dev/null 2>&1; then
type -P "$cmd" >/dev/null 2>&1 \
|| die "필수 명령어가 PATH 에 없습니다: $cmd"
else
command -v "$cmd" >/dev/null 2>&1 \
|| die "필수 명령어가 PATH 에 없습니다: $cmd"
fi
done
}
require_env() {
local var
for var in "$@"; do
if [[ -z "${!var:-}" ]]; then
die "필수 환경 변수가 비어있습니다: $var"
fi
done
}
# -----------------------------------------------------------------------------
# destructive gate
# -----------------------------------------------------------------------------
# Usage: confirm "namespace 'mnt' 의 모든 리소스를 삭제합니다. 계속?"
# Returns 0 if the user said yes (interactively or via CONFIRM=yes env).
confirm() {
local prompt="$1"
if [[ "${CONFIRM:-}" == "yes" ]]; then
log "CONFIRM=yes → 자동 진행: $prompt"
return 0
fi
if [[ ! -t 0 ]]; then
die "비대화 환경에서는 CONFIRM=yes 환경 변수를 지정하세요: $prompt"
fi
local answer
read -r -p "$prompt [y/N]: " answer
[[ "$answer" == "y" || "$answer" == "Y" ]]
}
# -----------------------------------------------------------------------------
# secret masking (for logs)
# -----------------------------------------------------------------------------
# Usage: log "root token = $(mask_secret "$ROOT_TOKEN")"
mask_secret() {
local s="$1"
local n=${#s}
if ((n <= 8)); then
printf '***'
else
printf '%s***%s' "${s:0:4}" "${s: -4}"
fi
}
# -----------------------------------------------------------------------------
# retry helper
# -----------------------------------------------------------------------------
# Usage: retry 5 2 kubectl wait --for=condition=Ready pod/vault-0 -n mnt --timeout=10s
# - $1: max attempts
# - $2: sleep seconds between attempts
# - $3..: command and arguments
retry() {
local attempts="$1"
shift
local delay="$1"
shift
local i=0
until "$@"; do
i=$((i + 1))
if ((i >= attempts)); then
err "최대 시도 횟수 ${attempts} 회 초과: $*"
return 1
fi
warn "실패 ($i/$attempts), ${delay}s 후 재시도: $*"
sleep "$delay"
done
}
# -----------------------------------------------------------------------------
# kube-context / env 타깃 검증
# -----------------------------------------------------------------------------
# require_kube_context <env_name>
#
# env → 기대 context 매핑을 다음 우선순위로 해결한다:
# 1) $KUBE_CONTEXT (명시적으로 주입된 값 — CI 에서 사용)
# 2) $KUBE_CONTEXT_<ENV_UPPER> (env 별 매핑 — 쉘 rc 에 선언하면 편함)
#
# 매칭 실패 시 현재 context 를 출력하고 사용자에게 context 이름을 직접
# 재입력받아 확인한다. 비대화 환경은 die.
#
# 이 함수를 통과하면 다음이 보장된다:
# - kubectl 이 가리키는 cluster 가 env 의 의도된 cluster
# - 사용자/CI 가 그 사실을 명시적으로 인지함 (실수 클러스터 apply 방지)
require_kube_context() {
local env_name="$1"
require_cmd kubectl
local current
current="$(kubectl config current-context 2>/dev/null || true)"
[[ -n "$current" ]] || die "kubectl current-context 가 비어있습니다. kubeconfig 를 먼저 설정하세요."
local env_upper
env_upper="$(printf '%s' "$env_name" | tr '[:lower:]' '[:upper:]')"
local mapped_var="KUBE_CONTEXT_${env_upper}"
local expected="${KUBE_CONTEXT:-${!mapped_var:-}}"
if [[ -n "$expected" ]]; then
if [[ "$current" != "$expected" ]]; then
die "kube-context 불일치: env=$env_name 기대='$expected' 현재='$current' (KUBE_CONTEXT 또는 ${mapped_var} 와 kubectl 현재 context 가 다름)"
fi
_lock_kube_context "$current"
log "kube-context OK: env=$env_name context='$current'"
return 0
fi
# 매핑이 없을 때:
# - 비대화(CI) → 무조건 die. CONFIRM=yes 로도 우회 불가 (context 는
# destructive 작업의 타깃이라 명시성이 절대 원칙).
# - 대화형 TTY → 현재 context 이름 재입력으로 확인.
if [[ ! -t 0 ]]; then
die "비대화 환경에서는 KUBE_CONTEXT 또는 ${mapped_var} 가 필수입니다 (CONFIRM=yes 로 우회 불가)."
fi
warn "env=$env_name 의 기대 context 가 지정되지 않았습니다."
warn " (권장) export KUBE_CONTEXT_${env_upper}='<context-name>' 를 쉘 rc 에 선언"
warn "현재 context: $current"
local typed
read -r -p "확인을 위해 현재 context 이름을 그대로 입력하세요 ('$current'): " typed
[[ "$typed" == "$current" ]] || die "context 이름 불일치 — 중단"
_lock_kube_context "$current"
}
# require_production_gate <env_name>
#
# env=prod 에서 파괴적 작업을 실행하려면 ALLOW_PROD_DESTRUCTIVE=yes 를 요구.
# 추가로 namespace 이름 재입력을 강제해서 오타 한 번으로 prod 가 날아가는 것을 막는다.
# dev/staging 은 통과.
require_production_gate() {
local env_name="$1"
local ns="$2"
[[ "$env_name" == "prod" ]] || return 0
if [[ "${ALLOW_PROD_DESTRUCTIVE:-}" != "yes" ]]; then
die "env=prod 파괴적 작업은 ALLOW_PROD_DESTRUCTIVE=yes 환경 변수가 필요합니다."
fi
if [[ ! -t 0 ]]; then
die "env=prod 는 대화형 TTY 에서만 실행 가능합니다 (namespace 재입력 확인 필요)."
fi
local typed
warn "env=prod 파괴적 작업 — namespace '$ns' 를 그대로 재입력하세요."
read -r -p "namespace: " typed
[[ "$typed" == "$ns" ]] || die "namespace 재입력 불일치 — 중단"
}
# -----------------------------------------------------------------------------
# namespace / finalizer 정리 헬퍼
# -----------------------------------------------------------------------------
# ns_phase <namespace> — namespace 의 .status.phase 를 출력. 없으면 빈 문자열.
ns_phase() {
local ns="$1"
kubectl get namespace "$ns" -o jsonpath='{.status.phase}' 2>/dev/null || true
}
# ns_exists <namespace> — 존재하면 0, 없으면 1
ns_exists() {
kubectl get namespace "$1" >/dev/null 2>&1
}
# strip_finalizers_in_ns <namespace> <kind...>
# 지정한 kind 들의 모든 인스턴스에서 metadata.finalizers 를 제거한다.
# kind 가 CRD 여도 동작 (kubectl 이 해당 API 서버에 등록되어 있기만 하면).
strip_finalizers_in_ns() {
local ns="$1"
shift
local kind obj
for kind in "$@"; do
while IFS= read -r obj; do
[[ -z "$obj" ]] && continue
kubectl -n "$ns" patch "$obj" --type=merge \
-p '{"metadata":{"finalizers":null}}' >/dev/null 2>&1 || true
log " finalizer 제거: -n $ns $obj"
done < <(kubectl -n "$ns" get "$kind" -o name 2>/dev/null || true)
done
}
# strip_finalizers_all_ns_resources <namespace>
# namespace 에 남아있는 모든 namespaced 리소스의 finalizer 를 일괄 제거.
# 최후 수단 — Terminating 에 걸린 리소스들을 떼어낼 때만 사용.
strip_finalizers_all_ns_resources() {
local ns="$1"
local kinds
# namespaced=true 리소스 종류만
kinds=$(kubectl api-resources --namespaced=true --verbs=delete -o name 2>/dev/null)
local kind
for kind in $kinds; do
while IFS= read -r obj; do
[[ -z "$obj" ]] && continue
kubectl -n "$ns" patch "$obj" --type=merge \
-p '{"metadata":{"finalizers":null}}' >/dev/null 2>&1 || true
done < <(kubectl -n "$ns" get "$kind" -o name 2>/dev/null || true)
done
}
# force_finalize_namespace <namespace>
# namespace 자체의 spec.finalizers 를 비워서 API 서버가 강제 삭제하도록 한다.
# kubectl replace --raw 로 /finalize 엔드포인트 호출.
# 전제: kubectl + jq 존재. 주의 — orphaned PV 등이 남을 수 있음.
force_finalize_namespace() {
local ns="$1"
require_cmd jq
log " namespace $ns 강제 finalize (API /finalize)"
kubectl get namespace "$ns" -o json \
| jq '.spec.finalizers = [] | .metadata.finalizers = []' \
| kubectl replace --raw "/api/v1/namespaces/${ns}/finalize" -f - >/dev/null
}
# wait_namespace_gone <namespace> <timeout_seconds>
# namespace 가 완전히 사라질 때까지 대기. timeout 초과 시 1 반환.
wait_namespace_gone() {
local ns="$1" timeout="${2:-60}" i=0
while ns_exists "$ns"; do
i=$((i + 1))
if ((i >= timeout)); then
return 1
fi
sleep 1
done
return 0
}
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# Vault helper functions. Depends on lib/common.sh being sourced first.
#
# Environment contract:
# VAULT_NAMESPACE — k8s namespace where vault-0 runs (default: mnt)
# VAULT_POD — Vault pod name (default: vault-0)
# VAULT_KEYS_FILE — path to JSON file produced by vault operator init
# (default: $REPO_ROOT/vault-init-keys.json)
# shellcheck shell=bash
# shellcheck source=./common.sh
# (common.sh must be sourced by the caller.)
: "${VAULT_NAMESPACE:=mnt}"
: "${VAULT_POD:=vault-0}"
# vault_exec <args...> — exec vault CLI inside the Vault pod.
# -i 를 붙여서 호출자의 stdin 을 컨테이너로 파이프 (vault login -no-print - 등).
vault_exec() {
kubectl exec -i -n "$VAULT_NAMESPACE" "$VAULT_POD" -- vault "$@"
}
# vault_exec_sh <script> — exec /bin/sh -c "$script" inside the Vault pod.
# Use when the vault CLI call needs shell features (heredocs, redirects, stdin).
vault_exec_sh() {
kubectl exec -i -n "$VAULT_NAMESPACE" "$VAULT_POD" -- sh -c "$1"
}
# vault_status_json — echoes the JSON output of `vault status`, or empty on error.
vault_status_json() {
kubectl exec -n "$VAULT_NAMESPACE" "$VAULT_POD" -- \
vault status -format=json 2>/dev/null || true
}
vault_is_initialized() {
local json
json="$(vault_status_json)"
[[ -n "$json" ]] && echo "$json" | jq -e '.initialized == true' >/dev/null
}
vault_is_sealed() {
local json
json="$(vault_status_json)"
[[ -n "$json" ]] && echo "$json" | jq -e '.sealed == true' >/dev/null
}
# vault_unseal_from_keyfile <keys_file>
#
# 주의: `vault operator unseal <KEY>` 형태로 argv 에 키를 넘기면 `ps` 로
# 노출된다 (container 내부 프로세스라도 보안 경계는 유지). vault CLI 는
# argv 가 없으면 stdin 에서 읽으므로 stdin 으로만 전달한다.
vault_unseal_from_keyfile() {
local keys_file="$1"
[[ -f "$keys_file" ]] || die "unseal 키 파일이 없습니다: $keys_file"
local threshold
threshold="$(jq -r '.unseal_threshold' "$keys_file")"
local i key
for ((i = 0; i < threshold; i++)); do
key="$(jq -r ".unseal_keys_b64[$i]" "$keys_file")"
printf '%s' "$key" | vault_exec operator unseal >/dev/null
done
unset key
log "Vault unseal 완료 (threshold=${threshold})"
}
# vault_login_root_from_keyfile <keys_file>
# Logs the vault CLI inside the pod using the root token. stdout is suppressed
# so the token never reaches terminals or logs.
vault_login_root_from_keyfile() {
local keys_file="$1"
local token
token="$(jq -r '.root_token' "$keys_file")"
printf '%s' "$token" | vault_exec_sh 'vault login -no-print -' >/dev/null
}
# vault_kv_exists <path> — returns 0 if the KV v2 secret exists.
vault_kv_exists() {
local path="$1"
[[ "$path" == secret/* ]] || path="secret/${path}"
vault_exec kv get -format=json "$path" >/dev/null 2>&1
}
# vault_auth_method_enabled <mount> — returns 0 if the auth method is enabled.
vault_auth_method_enabled() {
local mount="$1"
vault_exec auth list -format=json 2>/dev/null \
| jq -e --arg m "${mount}/" '.[$m] // empty' >/dev/null
}
# vault_secrets_engine_enabled <mount> — returns 0 if the secrets engine is enabled.
vault_secrets_engine_enabled() {
local mount="$1"
vault_exec secrets list -format=json 2>/dev/null \
| jq -e --arg m "${mount}/" '.[$m] // empty' >/dev/null
}
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# Install (or upgrade) the MinIO Operator Helm release.
#
# MinIO Tenant CR (minio.min.io/v2) 는 이 Operator 가 CRD 를 먼저 등록해야
# apply 가능하다. bootstrap.sh 의 Phase 1 이전에 실행된다.
#
# Operator 는 자체 namespace (기본 minio-operator) 에 배포된다. 애플리케이션
# namespace (mnt) 와 분리되므로 Tenant CR 만 mnt 에 있어도 동작한다.
#
# Optional env:
# MINIO_OPERATOR_NAMESPACE — 기본 minio-operator
# MINIO_OPERATOR_RELEASE — 기본 minio-operator
# MINIO_OPERATOR_VERSION — 기본 7.0.0
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=../lib/common.sh
. "$SCRIPT_DIR/../lib/common.sh"
: "${MINIO_OPERATOR_NAMESPACE:=minio-operator}"
: "${MINIO_OPERATOR_RELEASE:=minio-operator}"
: "${MINIO_OPERATOR_VERSION:=7.0.0}"
helm_install() {
log "helm repo: minio-operator 등록 (idempotent)"
helm repo add minio-operator https://operator.min.io >/dev/null 2>&1 || true
helm repo update minio-operator >/dev/null
log "helm upgrade --install ${MINIO_OPERATOR_RELEASE} (v=${MINIO_OPERATOR_VERSION} ns=${MINIO_OPERATOR_NAMESPACE})"
helm upgrade --install "$MINIO_OPERATOR_RELEASE" minio-operator/operator \
--namespace "$MINIO_OPERATOR_NAMESPACE" \
--create-namespace \
--version "$MINIO_OPERATOR_VERSION" \
--wait \
--atomic \
--timeout 5m
}
wait_ready() {
log "MinIO Operator Pod Ready 확인"
kubectl -n "$MINIO_OPERATOR_NAMESPACE" wait --for=condition=Ready \
pod -l "app.kubernetes.io/name=operator" \
--timeout=120s
log "Tenant CRD 등록 확인"
kubectl get crd tenants.minio.min.io >/dev/null \
|| die "Tenant CRD 가 여전히 없음. Operator 설치 로그 확인."
}
main() {
require_cmd helm kubectl
helm_install
wait_ready
log "minio-operator-install 태스크 완료"
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@"
fi
+331
View File
@@ -0,0 +1,331 @@
#!/usr/bin/env bash
# Provision the docker-registry MinIO dependency after the Tenant is ready.
#
# The registry credential is generated by MinIO as an access key with an inline,
# bucket-scoped policy. The generated secret is captured once and written to
# Vault through stdin. Root and application credentials are never passed as
# command-line arguments.
#
# Required env:
# REPO_ROOT
# ENV_NAME
#
# Optional env:
# DOCKER_REGISTRY_BUCKET default: docker-registry
# PF_LOCAL_PORT default: 9900
# MINIO_NAMESPACE default: mnt
# MINIO_SERVICE default: minio
# MINIO_SCHEME lab default: http; other envs: https
# MINIO_SERVICE_PORT lab default: 80; other envs: 443
# MINIO_TLS_INSECURE default: no
# ALLOW_INSECURE_MINIO_TLS required when non-lab explicitly uses insecure TLS
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=../lib/common.sh
. "$SCRIPT_DIR/../lib/common.sh"
# shellcheck source=../lib/vault.sh
. "$SCRIPT_DIR/../lib/vault.sh"
: "${DOCKER_REGISTRY_BUCKET:=docker-registry}"
: "${PF_LOCAL_PORT:=9900}"
: "${MINIO_NAMESPACE:=mnt}"
: "${MINIO_SERVICE:=minio}"
ENV_NAME="${ENV_NAME:-}"
MC_ALIAS="minio-provision"
REGISTRY_CHECK_ALIAS="registry-check"
MC_CONFIG_DIR=""
PF_PID=""
CREATED_ACCESS_KEY=""
ACCESS_KEY_PERSISTED="no"
ROOT_USER=""
ROOT_PASSWORD=""
POLICY_FILE=""
MC_GLOBAL_ARGS=()
resolve_transport_defaults() {
case "$ENV_NAME" in
lab)
: "${MINIO_SCHEME:=http}"
: "${MINIO_SERVICE_PORT:=80}"
: "${MINIO_TLS_INSECURE:=no}"
;;
*)
: "${MINIO_SCHEME:=https}"
: "${MINIO_SERVICE_PORT:=443}"
: "${MINIO_TLS_INSECURE:=no}"
;;
esac
case "$MINIO_SCHEME" in
http | https) ;;
*) die "MINIO_SCHEME은 http 또는 https만 허용됩니다: $MINIO_SCHEME" ;;
esac
case "$MINIO_TLS_INSECURE" in
yes | no) ;;
*) die "MINIO_TLS_INSECURE는 yes 또는 no만 허용됩니다." ;;
esac
if [[ "$ENV_NAME" != "lab" && "$MINIO_TLS_INSECURE" == "yes" &&
"${ALLOW_INSECURE_MINIO_TLS:-}" != "yes" ]]; then
die "lab 외 환경에서 TLS 검증 비활성화는 ALLOW_INSECURE_MINIO_TLS=yes 가 추가로 필요합니다."
fi
}
cleanup_port_forward() {
if [[ -n "${PF_PID:-}" ]] && kill -0 "$PF_PID" 2>/dev/null; then
kill "$PF_PID" 2>/dev/null || true
wait "$PF_PID" 2>/dev/null || true
fi
}
mc_cmd() {
mc "${MC_GLOBAL_ARGS[@]}" "$@"
}
cleanup_created_access_key() {
if [[ -z "${CREATED_ACCESS_KEY:-}" || "$ACCESS_KEY_PERSISTED" == "yes" ]]; then
return 0
fi
warn "Vault 기록 전 실패: 이번 실행에서 만든 MinIO access key 정리"
mc_cmd admin accesskey rm "$MC_ALIAS" "$CREATED_ACCESS_KEY" >/dev/null 2>&1 \
|| warn "생성한 access key 자동 정리 실패 — MinIO IAM을 점검하세요."
}
wait_minio_ready() {
log "MinIO Tenant Pod Ready 대기"
retry 30 5 kubectl -n "$MINIO_NAMESPACE" wait --for=condition=Ready --timeout=10s \
pod -l "v1.min.io/tenant=$MINIO_SERVICE"
}
start_port_forward() {
log "port-forward 생성 (127.0.0.1:${PF_LOCAL_PORT} -> svc/${MINIO_SERVICE}:${MINIO_SERVICE_PORT})"
kubectl -n "$MINIO_NAMESPACE" port-forward \
"svc/${MINIO_SERVICE}" "${PF_LOCAL_PORT}:${MINIO_SERVICE_PORT}" \
>/dev/null 2>&1 &
PF_PID=$!
trap_cleanup_fn cleanup_port_forward
local curl_args=(-fsS --max-time 2)
if [[ "$MINIO_SCHEME" == "https" && "$MINIO_TLS_INSECURE" == "yes" ]]; then
curl_args+=(-k)
fi
retry 15 1 curl "${curl_args[@]}" \
"${MINIO_SCHEME}://127.0.0.1:${PF_LOCAL_PORT}/minio/health/live" \
-o /dev/null
log "MinIO health endpoint 응답 확인"
}
read_root_credentials() {
local env_blob
env_blob="$(
vault_exec kv get -format=json secret/minio/tenant-env \
| jq -r '.data.data["config.env"]'
)"
ROOT_USER="$(
printf '%s\n' "$env_blob" \
| sed -n 's/^export MINIO_ROOT_USER="\(.*\)"$/\1/p'
)"
ROOT_PASSWORD="$(
printf '%s\n' "$env_blob" \
| sed -n 's/^export MINIO_ROOT_PASSWORD="\(.*\)"$/\1/p'
)"
unset env_blob
[[ -n "$ROOT_USER" && -n "$ROOT_PASSWORD" ]] \
|| die "MinIO root 자격증명 파싱 실패 — secret/minio/tenant-env 확인"
}
init_mc_config() {
MC_CONFIG_DIR="$(mktemp -d -t project-infra-mc.XXXXXX)"
chmod 700 "$MC_CONFIG_DIR"
trap_cleanup_path "$MC_CONFIG_DIR"
MC_GLOBAL_ARGS=(--config-dir "$MC_CONFIG_DIR")
if [[ "$MINIO_SCHEME" == "https" && "$MINIO_TLS_INSECURE" == "yes" ]]; then
MC_GLOBAL_ARGS+=(--insecure)
fi
mc_cmd admin accesskey create --help >/dev/null \
|| die "현재 mc는 'admin accesskey create'를 지원하지 않습니다."
}
import_mc_alias() {
local alias_name="$1"
local endpoint="$2"
local access_key="$3"
local secret_key="$4"
# NUL-delimited stdin keeps both credentials out of jq and mc argv.
printf '%s\0%s\0%s\0' "$endpoint" "$access_key" "$secret_key" \
| jq -Rs '
split("\u0000") |
{
url: .[0],
accessKey: .[1],
secretKey: .[2],
api: "s3v4",
path: "auto"
}
' \
| mc_cmd alias import "$alias_name" >/dev/null
}
configure_root_alias() {
local endpoint="${MINIO_SCHEME}://127.0.0.1:${PF_LOCAL_PORT}"
import_mc_alias "$MC_ALIAS" "$endpoint" "$ROOT_USER" "$ROOT_PASSWORD"
unset ROOT_USER ROOT_PASSWORD
}
ensure_bucket() {
log "MinIO bucket 보장 (${DOCKER_REGISTRY_BUCKET})"
mc_cmd mb --ignore-existing "${MC_ALIAS}/${DOCKER_REGISTRY_BUCKET}" >/dev/null
}
write_inline_policy() {
POLICY_FILE="$MC_CONFIG_DIR/docker-registry-policy.json"
jq -nc --arg bucket "$DOCKER_REGISTRY_BUCKET" '{
Version: "2012-10-17",
Statement: [
{
Effect: "Allow",
Action: [
"s3:ListBucket",
"s3:ListBucketMultipartUploads",
"s3:GetBucketLocation"
],
Resource: ["arn:aws:s3:::\($bucket)"]
},
{
Effect: "Allow",
Action: [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject",
"s3:ListMultipartUploadParts",
"s3:AbortMultipartUpload"
],
Resource: ["arn:aws:s3:::\($bucket)/*"]
}
]
}' >"$POLICY_FILE"
chmod 600 "$POLICY_FILE"
}
read_vault_registry_field() {
local field="$1"
vault_exec kv get -format=json secret/docker-registry/minio \
| jq -er --arg field "$field" '.data.data[$field]'
}
verify_registry_credentials() {
local access_key="$1"
local secret_key="$2"
local endpoint="${MINIO_SCHEME}://127.0.0.1:${PF_LOCAL_PORT}"
import_mc_alias "$REGISTRY_CHECK_ALIAS" "$endpoint" "$access_key" "$secret_key"
mc_cmd stat "${REGISTRY_CHECK_ALIAS}/${DOCKER_REGISTRY_BUCKET}" >/dev/null \
|| die "Vault의 docker-registry MinIO credential 검증 실패"
}
use_existing_access_key() {
vault_kv_exists "docker-registry/minio" || return 1
local access_key secret_key
access_key="$(read_vault_registry_field access_key)" \
|| die "secret/docker-registry/minio.access_key 읽기 실패"
secret_key="$(read_vault_registry_field secret_key)" \
|| die "secret/docker-registry/minio.secret_key 읽기 실패"
if mc_cmd admin accesskey info "$MC_ALIAS" "$access_key" >/dev/null 2>&1; then
verify_registry_credentials "$access_key" "$secret_key"
unset access_key secret_key
log "기존 docker-registry MinIO access key 검증 완료"
return 0
fi
if mc_cmd admin user info "$MC_ALIAS" "$access_key" >/dev/null 2>&1; then
unset access_key secret_key
die "기존 Vault 값은 legacy MinIO user를 가리킵니다. 승인된 credential rotation 절차로 먼저 전환하세요."
fi
unset access_key secret_key
die "Vault에는 registry credential이 있지만 대응하는 MinIO access key가 없습니다. 자동 재생성하지 않습니다."
}
persist_generated_access_key() {
local access_key="$1"
local secret_key="$2"
if ! printf '%s\0%s\0' "$access_key" "$secret_key" \
| jq -Rs '
split("\u0000") |
{
access_key: .[0],
secret_key: .[1]
}
' \
| vault_exec kv put secret/docker-registry/minio - >/dev/null; then
die "생성된 docker-registry credential의 Vault 기록 실패"
fi
ACCESS_KEY_PERSISTED="yes"
}
create_access_key() {
log "bucket-scoped docker-registry MinIO access key 생성"
local creation_json access_key secret_key
creation_json="$(
mc_cmd --json admin accesskey create "$MC_ALIAS" \
--name docker-registry \
--description "docker-registry bucket access" \
--policy "$POLICY_FILE"
)" || die "MinIO access key 생성 실패"
access_key="$(
jq -ser 'map(select(.accessKey? or .access_key?)) | last | .accessKey // .access_key' \
<<<"$creation_json"
)" || die "생성된 MinIO access key 응답 파싱 실패"
secret_key="$(
jq -ser 'map(select(.secretKey? or .secret_key?)) | last | .secretKey // .secret_key' \
<<<"$creation_json"
)" || die "생성된 MinIO secret key 응답 파싱 실패"
unset creation_json
[[ -n "$access_key" && -n "$secret_key" ]] \
|| die "MinIO가 생성한 access key 응답이 비어 있습니다."
CREATED_ACCESS_KEY="$access_key"
verify_registry_credentials "$access_key" "$secret_key"
persist_generated_access_key "$access_key" "$secret_key"
unset access_key secret_key CREATED_ACCESS_KEY
log "생성된 docker-registry credential을 Vault에 기록"
}
main() {
require_cmd kubectl jq mc curl sed
require_env REPO_ROOT ENV_NAME
resolve_transport_defaults
local keys_file="${VAULT_KEYS_FILE:-$REPO_ROOT/vault-init-keys.json}"
vault_login_root_from_keyfile "$keys_file"
wait_minio_ready
start_port_forward
read_root_credentials
init_mc_config
configure_root_alias
trap_cleanup_fn cleanup_created_access_key
ensure_bucket
write_inline_policy
use_existing_access_key || create_access_key
log "minio-provision-registry 태스크 완료"
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@"
fi
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env bash
# Initialize Vault (Shamir 5-of-3) and bootstrap the Kubernetes auth method,
# KV v2 secrets engine, and the vault-secrets-operator policy/role.
#
# Idempotent: 이미 초기화된 Vault 에서는 init 을 skip 하지만 unseal 과
# post-init 구성은 매번 재적용해서 bootstrap.sh 에서 여러 번 호출되어도 안전.
#
# Required env:
# REPO_ROOT — repository root
#
# Optional env:
# VAULT_NAMESPACE / VAULT_POD / VAULT_KEYS_FILE — see lib/vault.sh
# ENV_NAME — dev|staging|prod (bootstrap.sh 가 주입). prod 일 때 기본 경로
# (repo working tree) 사용을 차단한다 — VAULT_KEYS_FILE 를 명시
# 해서 repo 바깥의 안전한 경로로 써야 한다.
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=../lib/common.sh
. "$SCRIPT_DIR/../lib/common.sh"
# shellcheck source=../lib/vault.sh
. "$SCRIPT_DIR/../lib/vault.sh"
KEY_SHARES=5
KEY_THRESHOLD=3
resolve_keys_file() {
DEFAULT_KEYS_FILE="$REPO_ROOT/vault-init-keys.json"
KEYS_FILE="${VAULT_KEYS_FILE:-$DEFAULT_KEYS_FILE}"
local keys_dir keys_name resolved_keys_dir resolved_repo_root
keys_dir="$(dirname "$KEYS_FILE")"
keys_name="$(basename "$KEYS_FILE")"
resolved_keys_dir="$(cd "$keys_dir" 2>/dev/null && pwd -P)" \
|| die "VAULT_KEYS_FILE 상위 디렉터리가 없습니다: $keys_dir"
resolved_repo_root="$(cd "$REPO_ROOT" && pwd -P)"
KEYS_FILE="$resolved_keys_dir/$keys_name"
[[ ! -L "$KEYS_FILE" ]] \
|| die "VAULT_KEYS_FILE 은 symbolic link일 수 없습니다: $KEYS_FILE"
# prod 에서 repo 내부 평문 저장 차단 — root token / unseal key 가 repo
# working tree 에 남으면 accidental commit / backup / IDE 인덱싱 경로로 유출.
if [[ "${ENV_NAME:-}" == "prod" &&
("$KEYS_FILE" == "$resolved_repo_root" || "$KEYS_FILE" == "$resolved_repo_root/"*) ]]; then
die "env=prod 에서는 VAULT_KEYS_FILE 를 repo 바깥 경로로 반드시 지정해야 합니다. (예: VAULT_KEYS_FILE=/run/secrets/vault-keys.json 또는 sops age 로 암호화)"
fi
}
initialize_if_needed() {
if vault_is_initialized; then
log "Vault 는 이미 초기화되어 있습니다. 초기화 단계를 건너뜁니다."
return 0
fi
log "Vault 초기화 중 (shares=${KEY_SHARES}, threshold=${KEY_THRESHOLD})..."
local keys_dir
keys_dir="$(dirname "$KEYS_FILE")"
[[ -d "$keys_dir" ]] || die "VAULT_KEYS_FILE 상위 디렉터리가 없습니다: $keys_dir"
local tmp_out
tmp_out="$(mktemp "${keys_dir}/.vault-init-keys.XXXXXX")"
trap_cleanup_path "$tmp_out"
vault_exec operator init \
-key-shares="$KEY_SHARES" \
-key-threshold="$KEY_THRESHOLD" \
-format=json >"$tmp_out"
chmod 600 "$tmp_out"
mv -- "$tmp_out" "$KEYS_FILE"
chmod 600 "$KEYS_FILE"
log "unseal keys + root token 저장: $KEYS_FILE (권한 0600)"
}
unseal_if_needed() {
if vault_is_sealed; then
log "Vault sealed 상태, unseal 진행"
vault_unseal_from_keyfile "$KEYS_FILE"
else
log "Vault unsealed 상태"
fi
}
enable_kv_v2() {
if vault_secrets_engine_enabled "secret"; then
log "secrets engine 'secret/' 이미 활성화됨"
return 0
fi
log "KV v2 secrets engine 을 secret/ 에 활성화"
vault_exec secrets enable -path=secret kv-v2
}
enable_k8s_auth() {
if vault_auth_method_enabled "kubernetes"; then
log "auth method 'kubernetes/' 이미 활성화됨"
else
log "Kubernetes auth method 활성화"
vault_exec auth enable kubernetes
fi
log "Kubernetes auth method 설정 (kubernetes_host + ca + token_reviewer_jwt)"
vault_exec_sh '
vault write auth/kubernetes/config \
kubernetes_host="https://kubernetes.default.svc.cluster.local:443" \
kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt \
token_reviewer_jwt=@/var/run/secrets/kubernetes.io/serviceaccount/token
' >/dev/null
}
write_policies_and_roles() {
log "policy 'vso-auth-platform' 작성 (identity-postgres/*, auth-server/*, keycloak/*, oauth2-proxy/*)"
vault_exec_sh 'cat <<"EOF" | vault policy write vso-auth-platform -
path "secret/data/identity-postgres/*" {
capabilities = ["read"]
}
path "secret/data/auth-server/*" {
capabilities = ["read"]
}
path "secret/data/keycloak/*" {
capabilities = ["read"]
}
path "secret/data/oauth2-proxy/*" {
capabilities = ["read"]
}
EOF
' >/dev/null
log "policy 'vso-storage' 작성 (minio/*, docker-registry/*)"
vault_exec_sh 'cat <<"EOF" | vault policy write vso-storage -
path "secret/data/minio/*" {
capabilities = ["read"]
}
path "secret/data/docker-registry/*" {
capabilities = ["read"]
}
EOF
' >/dev/null
log "k8s auth role 'vso-auth-platform' 작성"
vault_exec write auth/kubernetes/role/vso-auth-platform \
bound_service_account_names=vault-secrets-operator \
bound_service_account_namespaces="$VAULT_NAMESPACE" \
audience=vault \
policies=vso-auth-platform \
ttl=1h >/dev/null
log "k8s auth role 'vso-storage' 작성"
vault_exec write auth/kubernetes/role/vso-storage \
bound_service_account_names=vault-secrets-operator \
bound_service_account_namespaces="$VAULT_NAMESPACE" \
audience=vault \
policies=vso-storage \
ttl=1h >/dev/null
}
cleanup_legacy() {
log "구 policy/role 정리 (존재하면 삭제)"
# 이전 단일 policy (세분화 전) 잔재
vault_exec policy delete vault-secrets-operator >/dev/null 2>&1 || true
vault_exec delete auth/kubernetes/role/vault-secrets-operator >/dev/null 2>&1 || true
# Registry auth 제거로 더 이상 사용 안 하는 policy/role
vault_exec policy delete vso-registry >/dev/null 2>&1 || true
vault_exec delete auth/kubernetes/role/vso-registry >/dev/null 2>&1 || true
}
main() {
require_cmd kubectl jq
require_env REPO_ROOT
resolve_keys_file
initialize_if_needed
unseal_if_needed
vault_login_root_from_keyfile "$KEYS_FILE"
enable_kv_v2
enable_k8s_auth
write_policies_and_roles
cleanup_legacy
log "vault-init 태스크 완료"
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@"
fi
+280
View File
@@ -0,0 +1,280 @@
#!/usr/bin/env bash
# Seed application secrets into Vault KV v2.
#
# 이미 Vault KV 에 있는 경로는 skip — 운영자가 회전한 값을 절대 덮어쓰지 않는다.
# 아직 없는 경로에 대해 다음 순으로 값을 결정:
# 1) 환경 변수에 값이 있으면 사용
# 2) TTY 면 read -r -s 로 프롬프트 (확인 재입력 + echo 없음, bash history 안전)
# 3) 비대화 + env 비어있고 AUTO_GENERATE=yes 면 openssl rand 로 자동 생성
# → 생성된 값은 stdout 에 절대 출력하지 않음. 필요 시 나중에:
# vault kv get -field=password secret/<path>
#
# Vault CLI 의 `vault kv put <path> -` 모드로 **JSON payload 를 stdin** 으로 전달해
# 비밀번호가 argv / 프로세스 테이블 / 쉘 히스토리 어디에도 노출되지 않는다.
#
# Required env:
# REPO_ROOT
#
# Optional env (비면 대화형 입력 or AUTO_GENERATE):
# POSTGRES_SUPERUSER_USERNAME 기본 postgres
# POSTGRES_SUPERUSER_PASSWORD
#
# KEYCLOAK_DB_PASSWORD
#
# AUTH_SERVER_DB_USERNAME 기본 auth_server
# AUTH_SERVER_DB_PASSWORD
#
# KEYCLOAK_ADMIN_USERNAME 기본 admin
# KEYCLOAK_ADMIN_PASSWORD
#
# MINIO_ROOT_USER 기본 minioadmin
# MINIO_ROOT_PASSWORD
#
# AUTH_SERVER_INGRESS_CLIENT_SECRET Keycloak realm client 'auth-server-ingress' 의 secret.
# KeycloakRealmImport 가 ${AUTH_SERVER_INGRESS_CLIENT_SECRET}
# env 치환으로 가져간다. oauth2-proxy 도 동일 값을 받음.
#
# OAUTH2_PROXY_COOKIE_SECRET oauth2-proxy session cookie signing/encryption key.
#
# DOCKER_REGISTRY_PUSH_USERNAME 외부 Ingress push 용 BasicAuth username.
# 기본값 'registry-push'.
# DOCKER_REGISTRY_PUSH_PASSWORD 외부 Ingress push 용 BasicAuth password.
#
# Options:
# AUTO_GENERATE=yes 대화형 TTY 가 아니고 env 도 비었을 때 랜덤 값 생성
#
# NOTE: 비밀번호에 큰따옴표 " 는 사용 금지 (MinIO env-file 포맷이 깨짐).
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=../lib/common.sh
. "$SCRIPT_DIR/../lib/common.sh"
# shellcheck source=../lib/vault.sh
. "$SCRIPT_DIR/../lib/vault.sh"
: "${POSTGRES_SUPERUSER_USERNAME:=postgres}"
: "${AUTH_SERVER_DB_USERNAME:=auth_server}"
: "${KEYCLOAK_ADMIN_USERNAME:=admin}"
: "${MINIO_ROOT_USER:=minioadmin}"
: "${DOCKER_REGISTRY_PUSH_USERNAME:=registry-push}"
generate_random_password() {
openssl rand -base64 48 | tr -d '/+=' | head -c 24
}
resolve_password() {
local var_name="$1"
local prompt_label="$2"
local value="${!var_name:-}"
if [[ -n "$value" ]]; then
printf '%s' "$value"
return 0
fi
if [[ -t 0 ]]; then
local input confirm
while true; do
read -r -s -p "${prompt_label}: " input
echo >&2
if [[ -z "$input" ]]; then
err " 비밀번호가 비어있습니다. 다시 입력하세요."
continue
fi
if [[ "$input" == *'"'* ]]; then
err " 큰따옴표(\") 는 사용할 수 없습니다 (MinIO env 포맷)."
continue
fi
read -r -s -p "${prompt_label} 한 번 더: " confirm
echo >&2
if [[ "$input" == "$confirm" ]]; then
printf '%s' "$input"
return 0
fi
err " 일치하지 않습니다. 다시."
done
fi
if [[ "${AUTO_GENERATE:-}" == "yes" ]]; then
local generated
generated="$(generate_random_password)"
warn " ${var_name}: 자동 생성 (vault kv get 으로 조회 가능)"
printf '%s' "$generated"
return 0
fi
die "${var_name} 가 비어있고 비대화 환경이며 AUTO_GENERATE=yes 도 아닙니다."
}
seed_if_missing() {
local path="$1"
if vault_kv_exists "$path"; then
log " secret/${path}: 이미 존재 → skip (회전된 값 보호)"
return 1
fi
return 0
}
kv_put_nul_json() {
local path="$1"
local jq_expr="$2"
jq -Rs "$jq_expr" \
| kubectl exec -i -n "$VAULT_NAMESPACE" "$VAULT_POD" -- \
vault kv put "secret/${path}" - >/dev/null
}
vault_kv_get_field() {
local path="$1"
local field="$2"
vault_exec kv get -format=json "secret/${path}" \
| jq -r --arg field "$field" '.data.data[$field] // empty'
}
seed_identity_postgres_superuser() {
seed_if_missing "identity-postgres/superuser" || return 0
local pw
pw="$(resolve_password POSTGRES_SUPERUSER_PASSWORD "Postgres superuser 비밀번호")"
printf '%s\0%s\0' "$POSTGRES_SUPERUSER_USERNAME" "$pw" \
| kv_put_nul_json "identity-postgres/superuser" \
'split("\u0000") | {username: .[0], password: .[1]}'
unset pw
log " secret/identity-postgres/superuser 작성"
}
seed_keycloak_db() {
seed_if_missing "keycloak/db" || return 0
local pw
pw="$(resolve_password KEYCLOAK_DB_PASSWORD "Keycloak DB 비밀번호")"
printf '%s\0' "$pw" \
| kv_put_nul_json "keycloak/db" \
'split("\u0000") | {password: .[0]}'
unset pw
log " secret/keycloak/db 작성"
}
seed_auth_server_db() {
seed_if_missing "auth-server/db" || return 0
local pw
pw="$(resolve_password AUTH_SERVER_DB_PASSWORD "auth-server DB 비밀번호")"
printf '%s\0%s\0' "$AUTH_SERVER_DB_USERNAME" "$pw" \
| kv_put_nul_json "auth-server/db" \
'split("\u0000") | {
SPRING_DATASOURCE_USERNAME: .[0],
SPRING_DATASOURCE_PASSWORD: .[1]
}'
unset pw
log " secret/auth-server/db 작성"
}
seed_keycloak_bootstrap_admin() {
seed_if_missing "keycloak/bootstrap-admin" || return 0
local pw
pw="$(resolve_password KEYCLOAK_ADMIN_PASSWORD "Keycloak 관리자 비밀번호")"
printf '%s\0%s\0' "$KEYCLOAK_ADMIN_USERNAME" "$pw" \
| kv_put_nul_json "keycloak/bootstrap-admin" \
'split("\u0000") | {
KEYCLOAK_ADMIN: .[0],
KEYCLOAK_ADMIN_PASSWORD: .[1]
}'
unset pw
log " secret/keycloak/bootstrap-admin 작성"
}
seed_minio_tenant_env() {
seed_if_missing "minio/tenant-env" || return 0
local pw env_content
pw="$(resolve_password MINIO_ROOT_PASSWORD "MinIO 루트 비밀번호")"
env_content=$(
printf 'export MINIO_ROOT_USER="%s"\nexport MINIO_ROOT_PASSWORD="%s"\n' \
"$MINIO_ROOT_USER" "$pw"
)
printf '%s\0' "$env_content" \
| kv_put_nul_json "minio/tenant-env" \
'split("\u0000") | {"config.env": .[0]}'
unset pw env_content
log " secret/minio/tenant-env 작성"
}
# 외부에서 registry.project.com 으로 push 할 때 Traefik BasicAuth 가 검증할
# htpasswd 라인을 저장한다. Registry 자체 auth 는 켜지지 않고, 외부 Ingress
# 경계에서만 인증한다.
seed_docker_registry_basic_auth() {
seed_if_missing "docker-registry/basic-auth" || return 0
local pw hash users
pw="$(resolve_password DOCKER_REGISTRY_PUSH_PASSWORD "docker-registry 외부 push 비밀번호")"
hash="$(printf '%s' "$pw" | openssl passwd -apr1 -stdin)"
users="${DOCKER_REGISTRY_PUSH_USERNAME}:${hash}"
printf '%s\0%s\0%s\0' "$DOCKER_REGISTRY_PUSH_USERNAME" "$pw" "$users" \
| kv_put_nul_json "docker-registry/basic-auth" \
'split("\u0000") | {
username: .[0],
password: .[1],
users: .[2]
}'
unset pw hash users
log " secret/docker-registry/basic-auth 작성 (username=${DOCKER_REGISTRY_PUSH_USERNAME})"
}
# Keycloak realm import 가 client secret 을 ${AUTH_SERVER_INGRESS_CLIENT_SECRET}
# env 치환으로 요구한다. oauth2-proxy 는 동일 값을 /etc/oauth2-proxy-secrets/
# client-secret 파일로 읽는다. 두 쪽이 같은 값을 써야 OIDC client 인증이 맞는다.
seed_keycloak_client_auth_server_ingress() {
seed_if_missing "keycloak/clients/auth-server-ingress" || return 0
local pw
pw="$(resolve_password AUTH_SERVER_INGRESS_CLIENT_SECRET \
"Keycloak client 'auth-server-ingress' secret")"
AUTH_SERVER_INGRESS_CLIENT_SECRET="$pw"
printf '%s\0' "$pw" \
| kv_put_nul_json "keycloak/clients/auth-server-ingress" \
'split("\u0000") | {client_secret: .[0]}'
unset pw
log " secret/keycloak/clients/auth-server-ingress 작성"
}
seed_oauth2_proxy_forward_auth() {
seed_if_missing "oauth2-proxy/forward-auth" || return 0
local client_secret cookie_secret
if [[ -n "${AUTH_SERVER_INGRESS_CLIENT_SECRET:-}" ]]; then
client_secret="$AUTH_SERVER_INGRESS_CLIENT_SECRET"
elif vault_kv_exists "keycloak/clients/auth-server-ingress"; then
client_secret="$(vault_kv_get_field "keycloak/clients/auth-server-ingress" "client_secret")"
else
client_secret="$(resolve_password AUTH_SERVER_INGRESS_CLIENT_SECRET \
"Keycloak client 'auth-server-ingress' secret")"
fi
[[ -n "$client_secret" ]] || die "auth-server-ingress client secret 을 확인할 수 없습니다."
cookie_secret="$(resolve_password OAUTH2_PROXY_COOKIE_SECRET \
"oauth2-proxy cookie secret")"
printf '%s\0%s\0' "$client_secret" "$cookie_secret" \
| kv_put_nul_json "oauth2-proxy/forward-auth" \
'split("\u0000") | {
client_secret: .[0],
cookie_secret: .[1]
}'
unset client_secret cookie_secret
log " secret/oauth2-proxy/forward-auth 작성"
}
main() {
require_cmd kubectl jq openssl
require_env REPO_ROOT
local keys_file="${VAULT_KEYS_FILE:-$REPO_ROOT/vault-init-keys.json}"
vault_login_root_from_keyfile "$keys_file"
log "애플리케이션 Secret seed 시작 (이미 존재하는 경로는 skip)"
seed_identity_postgres_superuser
seed_keycloak_db
seed_auth_server_db
seed_keycloak_bootstrap_admin
seed_minio_tenant_env
seed_keycloak_client_auth_server_ingress
seed_oauth2_proxy_forward_auth
seed_docker_registry_basic_auth
log "vault-seed-apps 태스크 완료"
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@"
fi
+187
View File
@@ -0,0 +1,187 @@
#!/usr/bin/env bash
# Configure operator authentication for Vault.
#
# Adds a `userpass` auth method + `vault-admin` policy + one or more admin users.
# Operators use these credentials instead of the root token for day-to-day work.
# The root token stays offline and is only used for break-glass / rekey.
#
# Required env:
# REPO_ROOT — repo root (for vault-init-keys.json path)
#
# Interactive inputs (대화형 TTY 에서 자동 프롬프트):
# VAULT_ADMIN_USERNAME — login name (비면 프롬프트)
# VAULT_ADMIN_PASSWORD — 초기 비밀번호 (비면 무음 입력 프롬프트)
#
# Non-interactive 시 (CI 등): 위 두 개를 env var 로 전달해야 함.
#
# Optional env:
# VAULT_ADMIN_TTL — token TTL per login (default: 8h)
# VAULT_ADMIN_MAX_TTL — maximum TTL (default: 24h)
#
# 입력 검증:
# - username: 영문/숫자/._- 만 허용, 1..64 자
# - TTL/MAX_TTL: 숫자+단위(s|m|h|d), 1..16 자
# 이 값들은 Vault API 경로/파라미터에 쓰이는데, 과거 구현은 `sh -c` 문자열에
# 직접 보간했다. allowlist 를 통과시키면 쉘 메타문자 주입 경로가 닫히고,
# 아래 `vault write` 호출은 `sh -c` 없이 kubectl exec 로 직접 실행한다.
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=../lib/common.sh
. "$SCRIPT_DIR/../lib/common.sh"
# shellcheck source=../lib/vault.sh
. "$SCRIPT_DIR/../lib/vault.sh"
USERNAME_RE='^[A-Za-z0-9._-]{1,64}$'
TTL_RE='^[0-9]+[smhd]?$'
validate_inputs() {
[[ "$VAULT_ADMIN_USERNAME" =~ $USERNAME_RE ]] \
|| die "VAULT_ADMIN_USERNAME 형식 불일치 (영문/숫자/._- 만 허용, 1..64자): '$VAULT_ADMIN_USERNAME'"
[[ "$ADMIN_TTL" =~ $TTL_RE ]] \
|| die "VAULT_ADMIN_TTL 형식 불일치 (예: 8h, 3600, 30m): '$ADMIN_TTL'"
[[ "$ADMIN_MAX_TTL" =~ $TTL_RE ]] \
|| die "VAULT_ADMIN_MAX_TTL 형식 불일치 (예: 24h, 86400): '$ADMIN_MAX_TTL'"
((${#ADMIN_TTL} <= 16 && ${#ADMIN_MAX_TTL} <= 16)) \
|| die "TTL 값이 너무 깁니다."
}
prompt_username_if_missing() {
[[ -n "${VAULT_ADMIN_USERNAME:-}" ]] && return 0
if [[ ! -t 0 ]]; then
die "VAULT_ADMIN_USERNAME 환경 변수가 비어있습니다 (비대화 환경)."
fi
read -r -p "운영자 username (e.g. alice): " VAULT_ADMIN_USERNAME
[[ -n "$VAULT_ADMIN_USERNAME" ]] || die "username 이 비어있습니다."
}
prompt_password_if_missing() {
[[ -n "${VAULT_ADMIN_PASSWORD:-}" ]] && return 0
if [[ ! -t 0 ]]; then
die "VAULT_ADMIN_PASSWORD 환경 변수가 비어있습니다 (비대화 환경)."
fi
read -r -s -p "${VAULT_ADMIN_USERNAME} 초기 비밀번호: " VAULT_ADMIN_PASSWORD
echo >&2
[[ -n "$VAULT_ADMIN_PASSWORD" ]] || die "비밀번호가 비어있습니다."
local confirm
read -r -s -p "비밀번호 한 번 더 입력: " confirm
echo >&2
[[ "$VAULT_ADMIN_PASSWORD" == "$confirm" ]] \
|| die "비밀번호가 일치하지 않습니다."
unset confirm
}
enable_userpass() {
if vault_auth_method_enabled "userpass"; then
log "auth method 'userpass/' 이미 활성화됨"
else
log "userpass auth method 활성화"
vault_exec auth enable userpass
fi
}
write_admin_policy() {
# sudo capability 는 sys/* 일부 엔드포인트 (seal/audit/generate-root) 때문에 필요.
# 권한이 강하므로 인프라 담당자에게만 부여.
log "policy 'vault-admin' 작성"
vault_exec_sh 'cat <<"EOF" | vault policy write vault-admin -
# KV v2 — 전 경로 관리
path "secret/*" {
capabilities = ["create", "read", "update", "delete", "list"]
}
path "secret/data/*" {
capabilities = ["create", "read", "update", "delete", "list"]
}
path "secret/metadata/*" {
capabilities = ["create", "read", "update", "delete", "list"]
}
# Auth method / identity 관리
path "auth/*" {
capabilities = ["create", "read", "update", "delete", "list", "sudo"]
}
path "identity/*" {
capabilities = ["create", "read", "update", "delete", "list"]
}
# policy 관리 (자기 자신 포함)
path "sys/policies/*" {
capabilities = ["create", "read", "update", "delete", "list"]
}
path "sys/policy/*" {
capabilities = ["create", "read", "update", "delete", "list"]
}
# 상태 / 감사 장치 / mount 관리
path "sys/health" { capabilities = ["read"] }
path "sys/seal-status" { capabilities = ["read"] }
path "sys/mounts" { capabilities = ["read", "list"] }
path "sys/mounts/*" {
capabilities = ["create", "read", "update", "delete", "list", "sudo"]
}
path "sys/audit" { capabilities = ["read", "list"] }
path "sys/audit/*" {
capabilities = ["create", "read", "update", "delete", "sudo"]
}
EOF
' >/dev/null
}
# 사용자 생성/갱신 — `sh -c` 를 쓰지 않는다.
#
# 비밀번호는 `vault write -` 의 JSON stdin 으로 전달해 argv / 쉘 히스토리 노출이
# 전혀 없다. 나머지 파라미터 (username / TTL) 는 allowlist 를 통과한 값만
# kubectl exec argv 로 직접 전달된다 (쉘 해석 없음).
create_or_update_user() {
log "userpass user '${VAULT_ADMIN_USERNAME}' 생성/갱신 (ttl=${ADMIN_TTL} max_ttl=${ADMIN_MAX_TTL})"
printf '%s\0%s\0%s\0' "$VAULT_ADMIN_PASSWORD" "$ADMIN_TTL" "$ADMIN_MAX_TTL" \
| jq -Rs '
split("\u0000") |
{
password: .[0],
token_policies: "vault-admin",
token_ttl: .[1],
token_max_ttl: .[2]
}
' \
| kubectl exec -i -n "$VAULT_NAMESPACE" "$VAULT_POD" -- \
vault write "auth/userpass/users/${VAULT_ADMIN_USERNAME}" - >/dev/null
unset VAULT_ADMIN_PASSWORD
}
print_next_steps() {
log "vault-setup-admin 태스크 완료"
log ""
log "다음 단계:"
log " 1) 운영자는 다음 명령으로 로그인 (root token 사용 중단)"
log " vault login -method=userpass username=${VAULT_ADMIN_USERNAME}"
log " 2) 각 운영자는 첫 로그인 후 비밀번호를 변경"
log " vault write auth/userpass/users/${VAULT_ADMIN_USERNAME}/password password='<new>'"
log " 3) root token 은 오프라인 금고로 이동 후 vault-init-keys.json 에서 삭제 검토"
log " (재발급은 \"vault operator generate-root -init\" 으로 가능)"
}
main() {
require_cmd kubectl jq
require_env REPO_ROOT
prompt_username_if_missing
prompt_password_if_missing
ADMIN_TTL="${VAULT_ADMIN_TTL:-8h}"
ADMIN_MAX_TTL="${VAULT_ADMIN_MAX_TTL:-24h}"
validate_inputs
local keys_file="${VAULT_KEYS_FILE:-$REPO_ROOT/vault-init-keys.json}"
vault_login_root_from_keyfile "$keys_file"
enable_userpass
write_admin_policy
create_or_update_user
print_next_steps
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@"
fi
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# Install (or upgrade) the vault-secrets-operator Helm release.
#
# Idempotent. 이전 실행에서 failed / pending / uninstalling 상태로 남은 릴리즈
# 메타데이터가 있으면 먼저 uninstall 한 뒤 깨끗하게 재설치한다 (Helm 의
# "no deployed releases" 문제 회피).
#
# `--atomic` 은 설치 실패 시 자동 rollback 을 수행하는데, rollback 결과
# "no deployed releases" 상태가 돼서 다음 helm upgrade --install 이 실패하는
# 연쇄 문제를 낳는다. 이 스크립트는 대신 실패 상태를 명시적으로 감지해
# uninstall 로 cleanup 하므로 --atomic 없이 실행한다.
#
# VSO 는 전용 namespace 에 설치된다 (mnt 에 직접 두면 PSS Restricted 라벨과
# 차트 Pod spec 의 불일치로 Pod 생성이 막힘). Controller 는 cluster-scoped RBAC
# 를 갖고 있어 mnt 의 VaultAuth/VaultStaticSecret 도 정상 watch 한다.
#
# Optional env:
# VSO_NAMESPACE — target namespace (default: vault-secrets-operator-system)
# VSO_RELEASE — helm release name (default: vault-secrets-operator)
# VSO_VERSION — chart version pin (default: 0.9.0)
# VSO_VALUES_FILE — values file path
# (default: $REPO_ROOT/gitops/platform/secret-delivery/base/helm/values.yaml)
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=../lib/common.sh
. "$SCRIPT_DIR/../lib/common.sh"
: "${VSO_NAMESPACE:=vault-secrets-operator-system}"
: "${VSO_RELEASE:=vault-secrets-operator}"
: "${VSO_VERSION:=0.9.0}"
resolve_values_file() {
: "${VSO_VALUES_FILE:=$REPO_ROOT/gitops/platform/secret-delivery/base/helm/values.yaml}"
[[ -f "$VSO_VALUES_FILE" ]] || die "values.yaml 없음: $VSO_VALUES_FILE"
}
register_repo() {
log "helm repo: hashicorp 등록 (idempotent)"
helm repo add hashicorp https://helm.releases.hashicorp.com >/dev/null 2>&1 || true
helm repo update hashicorp >/dev/null
}
# 기존 릴리즈가 dirty 상태 (failed/pending-*/uninstalling/uninstalled) 면 선제
# uninstall 해야 다음 upgrade --install 이 성공한다.
cleanup_dirty_release() {
local status=""
if helm -n "$VSO_NAMESPACE" status "$VSO_RELEASE" -o json >/dev/null 2>&1; then
status="$(helm -n "$VSO_NAMESPACE" status "$VSO_RELEASE" -o json \
| jq -r '.info.status // "unknown"')"
fi
case "$status" in
"")
log "기존 Helm 릴리즈 없음 → 신규 install"
;;
"deployed")
log "기존 릴리즈 상태=deployed → upgrade 진행"
;;
"failed" | "pending-install" | "pending-upgrade" | "pending-rollback" | "uninstalling" | "uninstalled")
warn "기존 릴리즈 상태=$status (dirty) → helm uninstall 먼저"
helm -n "$VSO_NAMESPACE" uninstall "$VSO_RELEASE" --wait --timeout 5m 2>/dev/null \
|| warn " uninstall 중 오류 무시 (이미 부분 정리됐을 수 있음)"
;;
*)
warn "기존 릴리즈 상태=$status (예상 외) — 일단 upgrade 시도"
;;
esac
}
install_or_upgrade() {
log "helm upgrade --install $VSO_RELEASE (v=$VSO_VERSION ns=$VSO_NAMESPACE)"
helm upgrade --install "$VSO_RELEASE" hashicorp/vault-secrets-operator \
--namespace "$VSO_NAMESPACE" \
--create-namespace \
--version "$VSO_VERSION" \
--values "$VSO_VALUES_FILE" \
--wait \
--timeout 5m
}
wait_ready() {
log "VSO Pod Ready 확인"
kubectl -n "$VSO_NAMESPACE" wait --for=condition=Ready \
pod -l "app.kubernetes.io/name=vault-secrets-operator" \
--timeout=120s
}
main() {
require_cmd helm kubectl jq
require_env REPO_ROOT
resolve_values_file
register_repo
cleanup_dirty_release
install_or_upgrade
wait_ready
log "vso-install 태스크 완료"
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@"
fi
+389
View File
@@ -0,0 +1,389 @@
#!/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
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 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)
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)
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
if ((errors > 0)); then
printf '\nValidation failed with %d error(s).\n' "${errors}" >&2
exit 1
fi
printf '\nValidation passed.\n'