refactor: 폴더 구조 변경
This commit is contained in:
@@ -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
|
||||
@@ -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`가 추가로 필요합니다.
|
||||
Executable
+305
@@ -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
|
||||
Executable
+157
@@ -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
|
||||
Executable
+54
@@ -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 검사 통과"
|
||||
Executable
+165
@@ -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 "$@"
|
||||
Executable
+179
@@ -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'
|
||||
Executable
+363
@@ -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
|
||||
}
|
||||
Executable
+97
@@ -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
|
||||
}
|
||||
Executable
+59
@@ -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
|
||||
Executable
+331
@@ -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
|
||||
Executable
+182
@@ -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
|
||||
Executable
+280
@@ -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
|
||||
Executable
+187
@@ -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
|
||||
Executable
+103
@@ -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
|
||||
Executable
+389
@@ -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'
|
||||
Reference in New Issue
Block a user