diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 0000000..3b89bdc --- /dev/null +++ b/AGENT.md @@ -0,0 +1,97 @@ +# Agent Guide + +이 파일은 이 저장소에서 작업을 시작하는 사람과 에이전트를 위한 첫 진입 문서입니다. + +## Read First + +작업을 시작하면 아래 순서로 문서를 읽습니다. + +1. 이 파일 `AGENT.md` +2. 루트 [README.md](/home/donghyeon/dev/Project-Auth-GitOps/README.md) +3. 현재 작업과 직접 관련된 runbook + - Vault transit: [runbooks/vault-transit/dev/README.md](/home/donghyeon/dev/Project-Auth-GitOps/runbooks/vault-transit/dev/README.md) + - Workload Vault: [runbooks/vault/dev/README.md](/home/donghyeon/dev/Project-Auth-GitOps/runbooks/vault/dev/README.md) + - Argo CD 구조: [argocd/README.md](/home/donghyeon/dev/Project-Auth-GitOps/argocd/README.md) + +## Working Rules + +- README 최상단 `현재 최신 Dev 아키텍처` 는 최신 상태로 유지합니다. +- 기존 cycle은 지우지 말고 README 맨 아래에 새 cycle을 추가합니다. +- ops 변경이나 장애 대응을 했으면 명령, 관찰, 판단 근거, 수정, 검증을 함께 남깁니다. +- 워크플로 구조를 크게 바꿨으면 README에 새 cycle을 추가합니다. +- 민감한 값은 절대 문서에 기록하지 않습니다. + - token, password, kubeconfig 본문, secret payload 금지 + - 대신 존재 여부, 길이, secret name, 리소스 상태만 기록 +- repo 밖 임시 파일(`/tmp/...`)로 작업한 secret manifest는 Git에 넣지 않습니다. +- Vault 관련 자동화는 **bootstrap** 과 **reconcile** 을 분리합니다. + - bootstrap: privileged token 필요, 수동 runbook + - reconcile: workflow AppRole 기반 routine CI +- GitHub Actions YAML에는 긴 Bash를 직접 넣지 않고 `scripts/ci/*.sh` wrapper로 분리합니다. +- GitHub context(`inputs`, `client_payload`) 는 inline Bash에서 직접 쓰지 않고 step `env:` 로 매핑한 뒤 스크립트에서 읽습니다. +- `scripts/ci/reconcile-vault-dev.sh` 는 기본적으로 port-forward 모드지만, 장기적으로는 `RECONCILE_USE_PORT_FORWARD=false` + in-cluster service URL로 돌리는 방향이 목표입니다. +- `vault-dev-reconcile.yaml` 은 아래 GitHub Variables 로 runner/네트워크 전환을 제어합니다. + - `VAULT_DEV_RECONCILE_RUNS_ON` + - `RECONCILE_USE_PORT_FORWARD` + - `TRANSIT_VAULT_ADDR` + - `WORKLOAD_VAULT_ADDR` + +## Troubleshooting Format + +README cycle의 `### 6. 트러블슈팅 메모` 에 아래 형식으로 남깁니다. + +- 재현/확인 명령: 실제로 사용한 명령 +- 핵심 관찰값: 에러 문구, 상태 변화, 이벤트, 로그 핵심 +- 판단 근거: 왜 그 관찰값을 보고 해당 원인이라고 판단했는지 +- 수정 또는 조치: 어떤 파일/리소스를 바꿨는지 +- 검증 명령: 해결 여부를 다시 확인한 명령 + +## Current Dev Pitfalls + +- Argo CD `Application` 이 `Repository not found` 를 내면 로컬 git 인증이 아니라 `argocd` namespace의 repo credential secret부터 확인합니다. +- self-hosted runner 문제는 workflow YAML보다 먼저 runner 호스트의 실제 CLI 설치 여부를 확인합니다. + - `command -v kubectl vault jq base64 curl terraform` +- Vault raft 사용 시 `api_addr`, `cluster_addr`, listener `cluster_address`, service/deployment의 `8201` 포트 일관성을 먼저 확인합니다. +- `hashicorp/vault` 이미지를 ConfigMap mount와 함께 쓸 때는 read-only mount 충돌과 단일 PVC rollout 충돌을 같이 봅니다. + - 필요하면 `/tmp` 로 config 복사 후 실행, `strategy: Recreate` 적용 +- Vault Agent template에 여러 `export` 줄을 렌더링할 때는 aggressive trim(`{{- ... -}}`) 때문에 줄바꿈이 붙지 않는지 실제 `/vault/secrets/*` 파일을 확인합니다. +- Postgres PVC를 유지하는 상태에서 Vault KV만 바꾸면 DB 내부 사용자 비밀번호와 주입값이 어긋날 수 있습니다. KV 값과 persisted DB state를 함께 확인합니다. +- Headless Service를 DB connection host로 쓸 때는 pod 안에서 실제로 어떤 이름이 해석되는지 먼저 확인합니다. + - `postgres.platform.svc.cluster.local` 이 안 되면 `postgres-0.postgres.platform.svc.cluster.local` 같이 pod FQDN 확인 +- CI에서 Terraform local backend를 쓰면 self-hosted runner가 기존 `.terraform-state/*.tfstate` 를 실제로 보고 있는지 먼저 확인합니다. state가 완전히 없을 때뿐 아니라 일부 리소스만 남은 partial state도 위험합니다. + - `terraform state list` + - `terraform state show ` + - workflow에서 빠진 리소스를 개별 import 하도록 유지 + - 단, `vault_approle_auth_backend_role_secret_id` 처럼 provider가 import를 지원하지 않는 리소스는 예외로 두고 재생성으로 수렴시킵니다. +- Vault KV-v2 mount는 provider import 결과가 `type=kv` + `options.version=2` 로 보이므로, 선언도 그 형태로 맞추고 `prevent_destroy = true` 를 유지합니다. +- seal token을 state로 다시 맞출 때는 `auth/token/revoke-accessor` 권한이 필요합니다. +- `vault_mount`, `vault_auth_backend`, `vault_token.seal` 같이 bootstrap 성격이 강한 리소스는 routine CI에서 replacement가 나지 않게 drift를 최소화합니다. + - 필요하면 `ignore_changes` + - 더 좋게는 bootstrap 단계와 reconcile 단계를 분리 +- `kubectl rollout status` 실패 시 `describe`, `logs`, `events` 를 세트로 봅니다. + +## Default Command Order + +Argo CD app 문제: + +```bash +kubectl -n argocd get applications +kubectl -n argocd describe application +kubectl -n argocd get secrets +``` + +Runner 문제: + +```bash +command -v kubectl +command -v vault +command -v terraform +``` + +Vault pod 문제: + +```bash +kubectl -n get deploy,pods +kubectl -n describe deployment +kubectl -n logs deploy/ --tail=200 +kubectl -n get events --sort-by=.lastTimestamp | tail -n 30 +``` diff --git a/INTERN_GUIDE.md b/INTERN_GUIDE.md new file mode 100644 index 0000000..a6b1abd --- /dev/null +++ b/INTERN_GUIDE.md @@ -0,0 +1,3044 @@ +# 인턴 가이드: 개념, 아키텍처, 코드로 이해하는 Project Auth GitOps + +## 1. 이 문서의 목표 + +이 문서의 목적은 **개념**, **현재 프로젝트 아키텍처**, **실제 코드**를 하나의 흐름으로 이해하게 만드는 것입니다. + +즉, 이 문서는 단순한 코드 해설서가 아니라 아래 3층 구조를 목표로 합니다. + +1. 개념 레이어 + 이 프로젝트가 왜 Kubernetes, GitOps, Vault, Terraform, Keycloak 구조를 쓰는지 이해합니다. +2. 아키텍처 레이어 + 현재 dev 환경에서 각 구성요소가 어떤 책임을 가지며 어떻게 연결되는지 이해합니다. +3. 코드 레이어 + 실제로 `apps/`, `infra/`, `scripts/`, `terraform/`, `runbooks/` 안의 파일을 읽고 수정할 수 있게 합니다. + +이 문서를 다 읽고 나면 최소한 아래 질문에 스스로 답할 수 있어야 합니다. + +- 이 저장소는 왜 존재하는가? +- `apps/`만 보면 왜 절반밖에 이해하지 못하는가? +- `auth-server`는 왜 `Deployment`와 `Job`을 둘 다 가지는가? +- 비밀값은 어디에서 생성되고, 어디를 지나서, 어떤 방식으로 Pod 안으로 들어가는가? +- 왜 `vault-transit`과 `vault`를 둘 다 두었는가? +- 어떤 값은 `ConfigMap`에 두고, 어떤 값은 Vault에 두는가? +- 무엇을 `base`에 두고 무엇을 `overlay`에 두는가? +- 어떤 변경이 다른 파일들까지 연쇄적으로 수정하게 만드는가? + +이 문서는 **현재 dev 환경 기준**으로 설명합니다. + +## 2. 가장 먼저 알아야 하는 사실 + +이 저장소는 애플리케이션 소스 저장소가 아니라 **GitOps 저장소**입니다. + +- `Project-Auth-Server`, `Project-Api-Server` 같은 앱 저장소는 코드와 CI를 담당합니다. +- 이 저장소는 Kubernetes에 반영할 선언과 CD를 담당합니다. +- 그래서 `apps/` 안의 YAML만 읽으면 "앱이 어떻게 배포되는지"는 보이지만, "누가 이걸 적용하는지", "비밀값은 누가 준비하는지", "왜 이런 구조인지"는 보이지 않습니다. + +즉, 이 저장소를 제대로 이해하려면 아래를 **한 묶음**으로 봐야 합니다. + +- `apps/`: 앱 매니페스트 +- `infra/`: 공용 인프라 매니페스트 +- `argocd/`: Argo CD가 어떤 폴더를 감시할지 정의 +- `scripts/`: 실제 운영 절차를 자동화하는 Bash +- `terraform/`: Vault 내부 상태를 선언적으로 맞추는 코드 +- `runbooks/`: 사람이 최초 bootstrap할 때 따르는 문서와 Vault 정책 + +## 3. 파일 확장자부터 정확히 알고 가기 + +| 확장자 | 뜻 | 이 저장소에서 하는 일 | +| ------- | -------------------------------- | -------------------------------------------------------------------- | +| `.yaml` | 선언형 데이터 포맷 | Kubernetes 리소스, Argo CD Application, GitHub Actions 워크플로 정의 | +| `.sh` | Bash 스크립트 | 운영 절차를 순서대로 자동 실행 | +| `.hcl` | HashiCorp Configuration Language | Vault 정책, Vault 서버 설정 | +| `.tf` | Terraform 설정 파일 | Vault 내부 리소스를 선언적으로 생성/동기화 | + +중요한 점은 `*.tf`도 내부적으로 HCL 문법을 사용하지만, **의미가 다르다**는 것입니다. + +- `runbooks/vault/dev/policies/*.hcl`: "누가 어떤 경로를 읽거나 쓸 수 있는가"를 적는 **Vault 정책** +- `infra/vault/base/files/vault/vault.hcl`: Vault 서버 자체가 어떻게 동작할지 적는 **Vault 서버 설정** +- `terraform/**/*.tf`: Terraform이 어떤 Vault 리소스를 만들어야 하는지 적는 **IaC 코드** + +## 4. 용어 사전 + +### 4-1. Kubernetes 용어 + +| 용어 | 뜻 | 이 저장소에서 왜 중요한가 | +| -------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------- | +| Manifest | Kubernetes에 적용할 선언 파일 | `apps/`, `infra/`, `argocd/` 대부분이 manifest다 | +| Namespace | 리소스를 논리적으로 나누는 공간 | `auth-dev`, `api-dev`, `platform`, `vault`, `vault-transit`가 서로 다른 책임을 가진다 | +| Pod | 실제 컨테이너가 뜨는 가장 작은 실행 단위 | Deployment나 Job이 결국 Pod를 만든다 | +| Deployment | stateless 앱을 원하는 개수만큼 유지하는 리소스 | `auth-server`, `api-server`, `keycloak`, `vault`가 여기에 해당한다 | +| StatefulSet | 이름, 저장소, 순서가 중요한 워크로드용 리소스 | `postgres`는 데이터가 있으므로 `Deployment`가 아니라 `StatefulSet`을 쓴다 | +| Job | 한 번 실행하고 끝나는 작업 | DB migration, Keycloak client sync에 사용된다 | +| Service | Pod 앞에 놓는 고정된 네트워크 진입점 | Pod IP가 바뀌어도 `auth-server`, `api-server`, `postgres`에 접속할 수 있다 | +| Ingress | 클러스터 바깥 또는 north-south HTTP 진입 규칙 | Traefik을 통해 public host를 연결한다 | +| NetworkPolicy | Pod 간 통신 허용/차단 규칙 | 기본 차단 후 필요한 통신만 허용하는 구조를 만든다 | +| ConfigMap | 민감하지 않은 설정값 저장소 | 포트, 호스트, issuer URI 같은 값을 둔다 | +| Secret | 민감한 값 저장소 | 이 저장소는 runtime secret을 가급적 Vault로 옮기고 image pull secret만 예외로 남긴다 | +| SealedSecret | Git에 올려도 되는 암호화된 Secret 형태 | `ghcr-regcred`처럼 예외적으로 Git에 남겨야 하는 secret에 쓴다 | +| ServiceAccount | Pod가 Kubernetes API 세계에서 갖는 신분 | Vault Kubernetes auth가 이 신분을 이용해 Pod를 검증한다 | +| ExternalName Service | 다른 DNS 이름으로 트래픽을 넘기는 Service | `auth-public`, `api-public`처럼 Traefik 이름을 우회해 내부에서도 같은 public host를 쓰게 한다 | +| Probe | 컨테이너 준비 상태/생존 상태 확인 | 준비 전 트래픽 차단, 비정상 재시작 판단에 쓰인다 | + +### 4-2. GitOps / Kustomize / Argo CD 용어 + +| 용어 | 뜻 | 이 저장소에서 왜 중요한가 | +| ------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------- | +| GitOps | Git에 있는 선언을 실제 클러스터 상태의 기준으로 삼는 방식 | 사람이 직접 `kubectl edit` 하지 않고 Git을 수정한다 | +| Source of truth | 최종 기준이 되는 원본 | 앱 배포 선언의 원본은 이 저장소이고, 일부 seed secret의 원본은 provider Vault다 | +| Kustomize | YAML을 base + overlay 구조로 합성하는 도구 | 공통 뼈대와 환경별 차이를 분리한다 | +| Base | 환경과 무관한 공통 정의 | 공통 Deployment, Service, ServiceAccount 등이 들어간다 | +| Overlay | 특정 환경에만 적용되는 차이 | dev용 namespace, host, image tag, Vault patch가 들어간다 | +| Patch | 기존 리소스 일부만 덮어쓰는 변경 조각 | `deployment.vault-patch.yaml`이 대표적이다 | +| Argo CD Application | "이 경로를 이 namespace로 동기화하라"는 선언 | `argocd/applications/dev/**`가 담당한다 | +| Sync wave | Argo CD 적용 순서를 정하는 숫자 | Vault, platform, apps 순서를 안정적으로 맞춘다 | +| Hook / PreSync | 일반 리소스 적용 전/후에 특별하게 실행되는 리소스 | DB migration Job은 앱 배포보다 먼저 돈다 | + +### 4-3. Vault / Terraform 용어 + +| 용어 | 뜻 | 이 저장소에서 왜 중요한가 | +| --------------- | --------------------------------------------------------- | --------------------------------------------------------------------------- | +| Vault | 비밀값과 암호 기능을 제공하는 시스템 | 이 프로젝트의 runtime secret 관리 중심이다 | +| Provider Vault | 다른 Vault를 돕는 상위 Vault | 여기서는 `vault-transit`이 workload Vault를 돕는다 | +| Workload Vault | 앱이 직접 사용하는 Vault | `vault`가 여기에 해당한다 | +| Transit engine | 데이터를 직접 저장하지 않고 암호 연산만 해주는 Vault 엔진 | JWT 서명, auto-unseal에 사용된다 | +| Auto-unseal | Vault가 재기동 시 자동으로 봉인을 풀 수 있게 하는 방식 | workload Vault는 provider Vault의 transit key로 자동 unseal된다 | +| Mount | Vault 안의 기능이 걸린 경로 | `kv`, `database`, `transit`, `auth/kubernetes`, `auth/approle`이 대표적이다 | +| Policy | Vault 경로별 권한 규칙 | `auth-server-dev.hcl` 같은 파일이 여기에 해당한다 | +| Kubernetes auth | Pod의 ServiceAccount JWT로 Vault에 로그인하는 방식 | 앱/플랫폼 Pod가 이 방식으로 secret을 받는다 | +| AppRole | 기계 대 기계 로그인 방식 | CI와 bootstrap 자동화가 사용한다 | +| TTL | 토큰이나 동적 계정의 수명 | migration용 DB 계정 같은 것을 오래 살지 않게 만든다 | +| Terraform state | Terraform이 "내가 무엇을 만들었는지" 기억하는 파일 | bootstrap 루트와 reconcile 루트가 분리되어 있다 | +| Idempotent | 여러 번 실행해도 결과가 같게 유지되는 성질 | reconcile 스크립트와 Terraform 설계의 핵심이다 | + +## 5. 이 저장소를 이해하기 위한 핵심 개념 + +이 섹션은 "왜 이런 구조가 필요한가"를 설명합니다. +이 섹션을 먼저 이해해야 뒤에서 나오는 YAML, Bash, HCL, TF가 단순 문법이 아니라 **설계의 결과물**로 보입니다. + +### 5-1. 인증, 인가, OAuth2, OIDC, JWT, Keycloak + +이 프로젝트를 이해하려면 먼저 아래 개념을 구분해야 합니다. + +| 용어 | 뜻 | 이 프로젝트에서의 의미 | +| -------------------- | ---------------------------------------- | -------------------------------------------------------- | +| 인증(Authentication) | "너 누구냐?"를 확인하는 것 | 로그인 성공 여부, 토큰 발급 대상 식별 | +| 인가(Authorization) | "너 여기 접근해도 되냐?"를 확인하는 것 | API 접근 권한, 역할(Role) 확인 | +| OAuth2 | 권한 위임 프레임워크 | 소셜 로그인, 외부 로그인 연동의 큰 틀 | +| OIDC(OpenID Connect) | OAuth2 위에 사용자 신원 확인을 얹은 표준 | 로그인 결과를 표준화된 토큰 형태로 다룸 | +| JWT | 서명된 토큰 포맷 | auth-server가 발급하고 api-server가 검증 | +| Issuer | 토큰을 발급한 주체 | `auth-server`, `Keycloak`, `Vault transit` 설정과 연결됨 | +| Client | 인증 서버를 이용하는 애플리케이션 | Keycloak 안의 `project-auth-server` client | +| Redirect URI | 로그인 후 다시 돌아올 주소 | Keycloak client sync Job이 코드로 맞춘다 | + +이 프로젝트에서는 인증 관련 역할이 셋으로 나뉩니다. + +- `Keycloak` + 외부 로그인 제공자와 연결되고, 로그인 브로커 역할을 한다 +- `auth-server` + 우리 서비스 관점의 인증 서버 역할을 하며 JWT를 발급한다 +- `api-server` + auth-server가 발급한 JWT를 검증하는 리소스 서버 역할을 한다 + +이 셋이 실제로 어떻게 맞물려 동작하는지 하나의 흐름으로 보면 이렇습니다. + +```mermaid +sequenceDiagram + participant User as 사용자 브라우저 + participant KC as Keycloak
(로그인 브로커) + participant Google as 구글/GitHub 등 + participant Auth as auth-server
(JWT 발급자) + participant API as api-server
(리소스 서버) + + User->>Auth: "소셜 로그인할래요" + Auth->>KC: Keycloak 로그인 페이지로 리다이렉트 + KC->>Google: 소셜 제공자와 OAuth2 통신 + Google-->>KC: 사용자 정보 반환 + KC-->>Auth: OIDC 표준 토큰으로 변환해서 콜백 + Auth->>Auth: 사용자 DB 조회/가입 처리 + 우리 JWT 발급 + Auth-->>User: 우리 서비스 JWT 토큰 반환 + User->>API: JWT를 헤더에 담아 API 호출 + API->>API: JWT 서명 검증 (Vault Transit 공개키로) + API-->>User: API 응답 +``` + +이 그림에서 핵심은 **각자가 맡은 범위가 다르다**는 것입니다. Keycloak은 외부 제공자와의 복잡한 통신만 처리하고, auth-server는 우리 서비스의 JWT만 발급하고, api-server는 그 JWT를 검증만 합니다. 하나의 서비스가 모든 것을 하지 않기 때문에, 각 부분을 독립적으로 교체하거나 수정할 수 있습니다. + +왜 이렇게 나누는가? + +- 소셜 로그인 제공자별 차이를 Keycloak이 흡수하게 하기 위해 +- 우리 서비스의 토큰 정책과 외부 로그인 흐름을 분리하기 위해 +- API 서버가 로그인 로직과 토큰 발급 책임까지 모두 떠안지 않게 하기 위해 + +> 💡 이 흐름의 각 단계가 **구체적으로 어떤 메커니즘**으로 동작하는지는 24장에서 심층적으로 다룹니다. + +이 개념을 이해해야 아래 파일들이 왜 존재하는지 자연스럽게 연결됩니다. + +- `infra/platform/base/keycloak-deployment.yaml` +- `infra/platform/base/keycloak-client-sync-job.yaml` +- `apps/auth-server/overlays/dev/configmap.yaml` +- `apps/api-server/overlays/dev/configmap.yaml` + +### 5-2. 리눅스, 컨테이너, 프로세스, 파일 + +이 저장소의 YAML을 읽을 때 사실상 리눅스 프로세스 개념을 알아야 합니다. +특히 Vault patch를 읽을 때 이 이해가 없으면 `command`, `args`, `. /vault/secrets/runtime-env`, `exec java -jar ...` 같은 부분이 전부 주문처럼 보입니다. + +꼭 이해해야 하는 개념은 아래와 같습니다. + +| 개념 | 뜻 | 이 프로젝트에서 왜 중요한가 | +| ------------------- | --------------------------------------------- | ----------------------------------------------------------------------- | +| Process | 실행 중인 프로그램 | 컨테이너 안에서 결국 Java, Postgres, Keycloak도 모두 프로세스다 | +| PID 1 | 컨테이너 안의 첫 번째 프로세스 | 신호 처리와 종료 동작에 영향이 크다 | +| `command` / `args` | 컨테이너가 실제로 어떤 명령으로 시작할지 정의 | Vault secret을 읽고 나서 원래 앱을 띄우기 위해 자주 재정의한다 | +| `source` (`. file`) | 파일 안의 셸 명령을 현재 셸에 적용 | Vault Agent가 만든 `export ...` 파일을 환경변수로 불러온다 | +| `exec` | 현재 셸 프로세스를 실제 앱 프로세스로 교체 | PID 1을 셸이 아니라 Java/Postgres/Keycloak로 만들기 위해 중요하다 | +| Volume mount | 파일이나 디렉터리를 컨테이너에 붙이는 것 | Vault secret file, init script, config file이 모두 이 방식으로 들어온다 | + +예를 들어 `apps/auth-server/overlays/dev/deployment.vault-patch.yaml`의 핵심은 이 순서입니다. + +1. Vault Agent가 `/vault/secrets/runtime-env` 파일 생성 +2. `/bin/sh -ec` 셸 시작 +3. `. /vault/secrets/runtime-env`로 환경변수 로드 +4. `exec java -jar /app/application.jar`로 실제 앱 프로세스 시작 + +이것을 프로세스 관점에서 그림으로 보면 이렇습니다. + +```mermaid +flowchart TD + subgraph Container["auth-server 컨테이너 내부"] + direction TB + A["/bin/sh -ec 시작
PID 1 = 셸 프로세스"] --> B[". /vault/secrets/runtime-env
export 명령들이 현재 셸에 적용
→ 환경변수가 셸 메모리에 올라감"] + B --> C["exec java -jar /app/application.jar
셸 프로세스가 Java 프로세스로 교체
→ PID 1 = Java (셸은 사라짐)"] + end + + subgraph 만약_exec_없이["만약 exec를 안 쓰면?"] + direction TB + D["PID 1 = 셸 (계속 살아있음)"] --> E["PID 2 = Java (자식 프로세스)"] + E --> F["K8s가 SIGTERM → 셸이 받음
셸은 자식에게 전달 안 할 수 있음
→ Java가 graceful shutdown 못 함"] + end +``` + +`exec`가 왜 중요한지 이 그림이 보여줍니다. K8s가 Pod를 종료할 때 **PID 1에게** SIGTERM 신호를 보냅니다. `exec` 없이 셸이 PID 1이면, 셸은 이 신호를 Java에게 전달하지 않을 수 있습니다. 결과적으로 Java가 연결을 정리하지 못한 채 강제 종료(SIGKILL)됩니다. `exec`를 쓰면 Java가 PID 1이 되어 직접 SIGTERM을 받고, 연결을 정리한 뒤 깔끔하게 종료합니다. + +즉, 여기서 중요한 것은 "Vault가 비밀값을 준다"는 사실만이 아닙니다. +**비밀값을 파일로 렌더링하고, 셸이 그 파일을 읽고, 마지막에 앱 프로세스로 넘어간다**는 실행 모델 전체를 이해해야 합니다. + +이 개념이 없으면 아래 같은 질문에 답하기 어렵습니다. + +- 왜 secret을 환경변수 자체로 바로 안 넣고 파일로 렌더링하나? +- 왜 `exec`를 쓰나? +- 왜 base Deployment에서는 `command`가 없는데 overlay patch에서는 생기나? + +> 💡 PID 1와 시그널 처리, 컨테이너 내부 프로세스 모델에 대한 더 깊은 이해는 23장에서 다룹니다. + +### 5-3. Kubernetes는 "컨테이너 실행기"가 아니라 "원하는 상태를 유지하는 시스템"이다 + +초보자는 Kubernetes를 "도커를 원격으로 띄우는 도구"처럼 이해하기 쉽습니다. 하지만 더 정확히는 **원하는 상태(desired state)를 유지하는 시스템**입니다. + +이 프로젝트에서 꼭 알아야 하는 핵심 리소스는 아래입니다. + +| 리소스 | 무엇을 위한 것인가 | 이 프로젝트의 예시 | +| -------------- | ------------------------------------------- | --------------------------------------------------- | +| Deployment | 계속 살아 있어야 하는 stateless 앱 | `auth-server`, `api-server`, `keycloak`, `vault` | +| StatefulSet | 저장소와 정체성이 중요한 워크로드 | `postgres` | +| Job | 한 번 실행하고 끝나야 하는 작업 | `auth-db-migration`, `keycloak-client-sync` | +| Service | Pod 앞의 고정 네트워크 이름 | `auth-server`, `api-server`, `postgres`, `keycloak` | +| Ingress | HTTP 요청의 진입 규칙 | public host와 Traefik 연결 | +| NetworkPolicy | Pod 간 허용할 통신만 남기는 네트워크 방화벽 | 각 namespace의 default deny 구조 | +| ServiceAccount | Pod의 신분 | Vault Kubernetes auth에서 핵심 | +| ConfigMap | 비민감 설정 | 포트, URL, issuer, host | +| Secret / Vault | 민감 설정 | DB 비밀번호, client secret, token | + +이 리소스들을 왜 구분해서 써야 할까요? + +- 앱은 계속 살아야 하므로 `Deployment` +- DB는 디스크와 이름이 안정적이어야 하므로 `StatefulSet` +- migration은 한 번만 돌고 끝나야 하므로 `Job` + +이 판단을 잘못하면 부작용이 큽니다. + +- DB를 `Deployment`로 만들면 저장소와 이름 안정성이 약해진다 +- migration을 `Deployment`로 만들면 계속 재시작될 수 있다 +- 앱을 `Job`로 만들면 정상 서비스가 유지되지 않는다 + +K8s가 이 리소스들의 상태를 어떻게 유지하는지 핵심 루프를 그림으로 보면 이렇습니다. + +```mermaid +flowchart LR + A["개발자가 선언
replicas: 3"] --> B["API Server에
Desired State 저장"] + B --> C{"Controller Manager
현재 vs 원하는 상태 비교"} + C -->|"Pod 2개 살아있음
1개 부족"| D["Pod 1개 추가 생성"] + C -->|"Pod 4개 살아있음
1개 초과"| E["Pod 1개 삭제"] + C -->|"Pod 3개 살아있음
일치 ✅"| F["아무것도 안 함"] + D --> C + E --> C + F -->|"계속 감시
(Reconciliation Loop)"| C +``` + +이 루프가 **끊임없이** 도는 것이 K8s의 핵심입니다. 사용자가 "3개 돌려라"라고 선언하면, K8s는 현재 상태를 계속 확인하면서 차이를 조정합니다. Pod가 죽어도 자동으로 새로 만듭니다. 이것이 "컨테이너 실행기"가 아니라 "상태 유지 시스템"인 이유입니다. + +> 💡 이 Reconciliation Loop, Watch 메커니즘, Control Plane 각 컴포넌트의 역할은 25장에서 프로세스 수준으로 상세히 다룹니다. + +### 5-4. GitOps, Kustomize, Argo CD + +이 프로젝트는 "좋은 YAML을 써놨다"에서 끝나지 않습니다. +이 YAML을 **누가**, **어떤 기준으로**, **반복적으로** 적용하느냐가 중요합니다. + +#### GitOps + +GitOps는 "실제 클러스터 상태의 기준을 Git에 둔다"는 운영 방식입니다. + +장점: + +- 누가 무엇을 바꿨는지 Git 기록으로 남는다 +- 수동 클릭보다 재현 가능하다 +- 문제가 생기면 선언 기준으로 되돌리기 쉽다 + +주의할 점: + +- 클러스터에서 직접 수정하면 Git과 드리프트가 생긴다 +- Git에 민감값을 넣으면 GitOps의 편의가 보안 리스크로 바뀐다 + +#### Kustomize + +Kustomize는 공통(base)과 환경별 차이(overlay)를 분리합니다. + +- `base`: 환경과 무관한 공통 뼈대 +- `overlay`: dev/prod별 차이 + +이 프로젝트에서 이 구조가 중요한 이유: + +- `auth-server`의 기본 보안 설정, 포트, 프로브는 공통이지만 +- Vault 경로, ingress host, image tag, namespace는 환경별로 다를 수 있기 때문입니다 + +#### Argo CD + +Argo CD는 Git에 있는 선언을 실제 클러스터와 맞추는 실행 주체입니다. + +이 프로젝트에서 Argo CD가 하는 일: + +- `argocd/applications/dev/**`에 정의된 경로를 감시 +- 해당 경로의 manifest를 dev 클러스터에 동기화 +- 드리프트가 생기면 다시 선언 상태로 되돌리려 함 + +즉, `apps/auth-server/overlays/dev`를 수정한다는 것은 단순히 파일을 고치는 것이 아니라 +**Argo CD가 나중에 실제 클러스터 상태를 바꾸게 될 선언을 수정하는 것**입니다. + +이 세 가지(GitOps, Kustomize, Argo CD)가 맞물리는 전체 흐름을 그림으로 보면 이렇습니다. + +```mermaid +flowchart LR + subgraph Developer["개발자"] + A["base/deployment.yaml 수정
또는 overlay/configmap.yaml 수정"] + end + + subgraph Git["Git 저장소 (Source of Truth)"] + B["base/ + overlay/
= 최종 선언"] + end + + subgraph ArgoCD["Argo CD"] + C["Git 감시
변경 감지"] --> D["Kustomize로
base + overlay 합성"] + D --> E["합성 결과와
현재 클러스터 비교"] + end + + subgraph Cluster["K8s 클러스터"] + F["실제 리소스
Deployment, Service 등"] + end + + A -->|"git push"| B + B -->|"Watch"| C + E -->|"차이 있으면
kubectl apply"| F + F -->|"드리프트 발생 시
다시 선언으로 복원"| E +``` + +개발자는 Git만 수정합니다. 클러스터를 직접 건드리지 않습니다. Argo CD가 Git의 선언과 클러스터의 실제 상태를 계속 비교하고, 차이가 있으면 선언 쪽으로 맞춥니다. 만약 누군가 `kubectl edit`으로 클러스터를 직접 수정하면, Argo CD가 그것을 "드리프트"로 감지하고 Git 기준으로 되돌립니다. + +### 5-5. Secret 관리: Kubernetes Secret, SealedSecret, Vault + +이 프로젝트의 핵심 설계 중 하나는 "무엇을 어디에 저장할 것인가"입니다. + +#### Kubernetes Secret + +Kubernetes Secret은 Kubernetes 안에서 secret을 다루기 위한 기본 기능입니다. +하지만 이 프로젝트에서는 runtime secret의 최종 해답으로 보지 않습니다. + +이유: + +- 클러스터 안에 secret 복사본이 많이 생기기 쉽다 +- GitOps 저장소에 그대로 두기 어렵다 +- 장기 자격증명을 쉽게 만들 수 있다 + +#### SealedSecret + +SealedSecret은 "Git에 올릴 수 있게 암호화된 Secret"입니다. + +이 프로젝트에서 SealedSecret이 남아 있는 이유: + +- `ghcr-regcred` 같은 image pull secret은 **Pod가 뜨기 전**에 필요하다 +- Vault Agent는 Pod 생성 이후에 동작한다 +- 즉, 이미지 pull credential은 Vault injection만으로 해결할 수 없다 + +그래서 이 프로젝트는 아래처럼 분리합니다. + +- image pull secret: SealedSecret +- runtime secret: Vault + +#### Vault + +Vault는 "비밀값을 저장하는 곳"이면서 동시에 "권한을 기준으로 필요한 순간에만 비밀을 주는 곳"입니다. + +이 프로젝트에서 Vault를 쓰는 이유: + +- 앱마다 필요한 secret만 읽게 하기 위해 +- 장기 비밀번호를 Git에서 제거하기 위해 +- dynamic DB credential을 발급하기 위해 +- JWT 서명을 key file 없이 transit으로 처리하기 위해 + +#### Secret Zero Problem + +"Vault에 로그인하려면 처음에 무엇으로 인증하나?"라는 질문이 바로 Secret Zero Problem입니다. + +이 프로젝트의 해법은 두 가지입니다. + +- 앱/플랫폼 Pod: Kubernetes auth 사용 +- CI / bootstrap automation: AppRole 사용 + +즉, + +- Pod는 자기 ServiceAccount JWT로 신분을 증명하고 +- CI는 별도의 AppRole credential로 로그인합니다 + +이 두 경로를 그림으로 보면 이렇습니다. + +```mermaid +flowchart TB + subgraph Pod_경로["경로 1: Pod가 Vault에 접근할 때"] + direction LR + P1["Pod 내부의
ServiceAccount JWT"] -->|"자동 마운트됨"| P2["Vault Agent가
JWT를 들고 인증"] + P2 --> P3["Vault가 K8s API에
'이 JWT 진짜야?' 확인"] + P3 --> P4["Secret 발급"] + end + + subgraph CI_경로["경로 2: CI가 Vault에 접근할 때"] + direction LR + C1["GitHub Actions
Secrets에 저장된
Role ID + Secret ID"] --> C2["AppRole 로그인"] + C2 --> C3["Vault 토큰 발급"] + C3 --> C4["Terraform 실행"] + end +``` + +Pod 경로에서는 **K8s가 이미 부여한 신분(ServiceAccount)**을 재활용합니다. 별도의 비밀번호가 필요 없습니다. CI 경로에서는 **GitHub Actions의 Secrets 기능**이 Secret Zero를 담당합니다. 완벽하지는 않지만, 비밀번호를 코드에 직접 쓰는 것보다 훨씬 안전합니다. + +> 💡 각 인증 방식의 핸드셰이크 상세는 바로 아래 5-6에서, 그리고 프로세스 수준의 동작은 26장에서 다룹니다. + +### 5-6. Kubernetes auth, AppRole, Transit, Dynamic Secret + +이 네 개념은 이 저장소를 이해할 때 반드시 구분해야 합니다. + +| 개념 | 무엇인가 | 누가 쓰는가 | 이 프로젝트의 예시 | +| --------------- | ------------------------------------------- | ----------------------------- | -------------------------------------- | +| Kubernetes auth | Pod의 ServiceAccount로 Vault 로그인 | 앱/플랫폼 Pod | `auth-server`, `postgres`, `keycloak` | +| AppRole | 기계용 Vault 로그인 방식 | CI, bootstrap, 운영 자동화 | `vault-dev-reconcile` workflow | +| Transit | 키를 직접 밖으로 꺼내지 않고 암호 연산 제공 | auth-server, auto-unseal 구조 | JWT signing, workload Vault unseal | +| Dynamic secret | 일정 시간이 지나면 만료되는 자격증명 | migration Job, operator | `database/creds/auth-db-migration-dev` | + +이 네 가지를 섞어 생각하면 안 됩니다. + +- Kubernetes auth는 "Pod용 로그인 방식" +- AppRole은 "비Pod 자동화용 로그인 방식" +- Transit은 "암호 기능" +- Dynamic secret은 "짧게 사는 계정" + +이 네 가지가 이 프로젝트의 어디에서 쓰이는지 한 눈에 보면 이렇습니다. + +```mermaid +flowchart TB + subgraph Vault["Workload Vault"] + KV["KV Engine
(고정 secret 저장)"] + DB["Database Engine
(동적 계정 발급)"] + TR["Transit Engine
(암호 연산)"] + KA["K8s Auth
(Pod 로그인)"] + AR["AppRole Auth
(CI 로그인)"] + end + + AUTH["auth-server Pod"] -->|"K8s Auth로 로그인"| KA + AUTH -->|"DB 비밀번호 읽기"| KV + AUTH -->|"JWT 서명 요청"| TR + + MIG["migration Job"] -->|"K8s Auth로 로그인"| KA + MIG -->|"임시 DB 계정 발급"| DB + + CI["GitHub Actions"] -->|"AppRole로 로그인"| AR + CI -->|"Terraform으로
policy/role/secret 설정"| KV +``` + +auth-server는 K8s Auth로 로그인해서 KV(고정 비밀번호)와 Transit(JWT 서명)을 사용합니다. migration Job은 K8s Auth로 로그인해서 Database Engine(임시 계정)을 사용합니다. CI는 AppRole로 로그인해서 Terraform으로 설정을 관리합니다. **같은 Vault지만 로그인 방식과 사용하는 엔진이 다릅니다.** + +> 💡 각 엔진과 인증 방식의 내부 동작은 26장에서 상세히 다룹니다. + +### 5-7. Terraform, State, 멱등성, Bootstrap vs Reconcile + +Terraform은 단순히 "리소스를 만드는 도구"가 아닙니다. +핵심은 **현재 상태와 원하는 상태의 차이를 계산한다**는 점입니다. + +#### State + +Terraform state는 Terraform이 "내가 무엇을 만들었는지" 기억하는 파일입니다. + +이 프로젝트에서 state가 중요한 이유: + +- Vault 안에는 정책, auth backend, AppRole, database role 등 많은 리소스가 있다 +- 이것을 사람이 매번 수동으로 비교하는 것은 어렵다 +- Terraform이 state를 바탕으로 diff를 계산해야 반복 적용이 안전해진다 + +#### 멱등성(Idempotency) + +멱등성이란 "같은 작업을 여러 번 해도 결과가 같게 유지되는 성질"입니다. + +이 프로젝트에서 왜 중요한가? + +- GitHub Actions workflow는 반복 실행될 수 있다 +- Vault reconcile도 여러 번 돌아야 한다 +- 같은 apply가 다시 실행되더라도 리소스가 중복 생성되면 안 된다 + +#### Bootstrap vs Reconcile + +이 프로젝트는 Terraform 루트를 일부러 나눴습니다. + +- bootstrap 루트 + 최초 1회, 강한 권한으로 구조를 세우는 용도 +- reconcile 루트 + 이미 세워진 구조를 반복적으로 안전하게 맞추는 용도 + +왜 이렇게 나눌까? + +- CI에 root 수준 권한을 오래 주지 않기 위해 +- 최초 생성과 일상 동기화의 책임을 분리하기 위해 +- 사람이 승인해야 할 작업과 자동화가 해도 되는 작업을 구분하기 위해 + +이 개념을 먼저 이해해야 뒤에서 나오는: + +- `terraform/vault/dev` +- `terraform/vault/reconcile` +- `terraform/vault-transit/dev` +- `terraform/vault-transit/reconcile` + +이 네 디렉터리의 의미가 선명해집니다. + +이 분리를 권한 경계 관점에서 그림으로 보면 이렇습니다. + +```mermaid +flowchart LR + subgraph Bootstrap["Bootstrap (최초 1회, 사람이 실행)"] + direction TB + B1["vault/dev
mount 생성, auth backend 활성화
root 수준 권한 필요"] + B2["vault-transit/dev
transit key 생성, AppRole 생성
root 수준 권한 필요"] + end + + subgraph Reconcile["Reconcile (반복, CI가 실행)"] + direction TB + R1["vault/reconcile
policy 업데이트, role 업데이트
secret 복사, DB role 설정
제한된 권한으로 충분"] + R2["vault-transit/reconcile
policy 업데이트, role 업데이트
제한된 권한으로 충분"] + end + + Bootstrap -->|"구조가 세워진 뒤
이후는 reconcile만"| Reconcile +``` + +핵심은 **CI(GitHub Actions)가 root 토큰을 갖지 않는다**는 것입니다. Bootstrap은 운영자가 직접, 한 번만 실행합니다. 이후 CI는 reconcile 루트만 반복 실행하며, 최소한의 권한으로 기존 구조를 유지보수합니다. + +> 💡 Terraform의 State 관리, Plan/Apply 사이클, Provider 이중 설정 등 내부 메커니즘은 27장에서 상세히 다룹니다. + +## 6. 이 저장소의 큰 흐름 + +```mermaid +flowchart TD + A[App repo CI] --> B[GitOps repo image tag update] + C[main push or infra change] --> D[.github/workflows/vault-dev-reconcile.yaml] + D --> E[scripts/ci/reconcile-vault-dev.sh] + E --> F[terraform/vault-transit/reconcile] + E --> G[terraform/vault/reconcile] + E --> H[argocd/applications/dev] + H --> I[Argo CD sync] + I --> J[apps/auth-server] + I --> K[apps/api-server] + I --> L[infra/platform] + I --> M[infra/vault] + I --> N[infra/vault-transit] + N --> M + M --> J + M --> L + J --> K +``` + +이 그림을 문장으로 풀면 아래와 같습니다. + +1. 앱 저장소 CI가 새 이미지를 만들면 이 저장소의 overlay 이미지 태그를 바꿉니다. +2. 이 저장소의 `main`에 변경이 들어오면 `vault-dev-reconcile` 워크플로가 실행됩니다. +3. 워크플로는 Bash 스크립트를 통해 먼저 Vault 관련 상태를 맞춥니다. +4. 그 다음 Argo CD Application 정의를 적용합니다. +5. Argo CD가 실제 `apps/`와 `infra/` 폴더를 읽어 클러스터 상태를 맞춥니다. +6. 앱 Pod는 최종적으로 workload Vault에서 secret을 받아 기동합니다. + +## 7. 폴더 지도 + +| 경로 | 역할 | 여기서 반드시 이해해야 하는 것 | +| ----------------------------------- | --------------------------------------- | --------------------------------------------------- | +| `apps/auth-server` | 인증 서버 배포 정의 | DB migration, Vault injection, OAuth/Keycloak 연동 | +| `apps/api-server` | API 서버 배포 정의 | auth-server가 발급한 JWT를 검증하는 구조 | +| `infra/platform` | Postgres, Keycloak, Keycloak sync 정의 | 앱이 의존하는 플랫폼 계층 | +| `infra/vault` | workload Vault 배포 정의 | 앱이 직접 접근하는 Vault | +| `infra/vault-transit` | provider Vault 배포 정의 | workload Vault auto-unseal 지원 | +| `argocd/applications/dev` | Argo CD가 어떤 경로를 적용할지 정의 | sync 순서와 대상 namespace | +| `scripts/ci` | GitHub Actions가 호출하는 자동화 진입점 | reconcile 순서와 안전장치 | +| `scripts/vault/dev` | workload Vault bootstrap/reconcile 보조 | provider Vault에서 bootstrap 정보를 읽는 방식 | +| `scripts/vault-transit/dev` | provider Vault bootstrap 보조 | seed secret 입력, seal token 준비 | +| `terraform/vault/dev` | workload Vault 최초 bootstrap용 루트 | mount, auth backend, policy, role까지 만든다 | +| `terraform/vault/reconcile` | workload Vault routine reconcile용 루트 | CI가 반복 적용하는 루트 | +| `terraform/vault-transit/dev` | provider Vault 최초 bootstrap용 루트 | transit key, AppRole, seal Secret 생성 | +| `terraform/vault-transit/reconcile` | provider Vault routine reconcile용 루트 | CI가 반복 적용하는 provider 쪽 루트 | +| `runbooks/vault/**` | 사람이 직접 bootstrap할 때 보는 문서 | 왜 bootstrap과 reconcile이 분리됐는지 이해해야 한다 | + +## 8. 파일을 읽을 때 항상 던져야 하는 5가지 질문 + +이 저장소의 어떤 파일이든 아래 5가지 질문으로 읽으면 이해가 훨씬 빨라집니다. + +1. 이 파일은 **누가 적용하는가**? +2. 이 파일은 **누가 소비하는가**? +3. 이 파일의 값은 **민감한 값인가 아닌가**? +4. 이 파일은 **최초 1회 bootstrap용인가**, 아니면 **반복 실행되는 reconcile용인가**? +5. 이 파일을 잘못 바꾸면 **어디가 먼저 깨지는가**? + +예를 들어 `apps/auth-server/overlays/dev/deployment.vault-patch.yaml`을 볼 때는 이렇게 읽어야 합니다. + +- 누가 적용하는가: Argo CD +- 누가 소비하는가: Vault Agent Injector와 최종적으로 auth-server 컨테이너 +- 민감한 값인가: 파일 자체는 secret을 담지 않지만 secret 경로를 지정한다 +- bootstrap용인가 reconcile용인가: reconcile 이후 실제 앱 배포에 쓰이는 runtime 정의다 +- 잘못 바꾸면 어디가 깨지는가: Vault secret render 실패, Pod 기동 실패, 로그인 기능 장애 + +## 9. 대표 시나리오 1: auth-server가 DB 비밀번호를 받는 과정 + +이 시나리오를 이해하면 이 프로젝트의 핵심을 절반 이상 이해한 것입니다. + +### 9-1. 실제 흐름 + +1. 운영자 또는 초기 bootstrap 절차가 `scripts/vault-transit/dev/populate-workload-seeds.sh`를 실행합니다. +2. 이 스크립트는 provider Vault의 `kv/dev/workload/platform/postgres/auth-server` 경로에 값을 넣습니다. +3. `terraform/vault/reconcile/main.tf`는 provider Vault에서 이 값을 읽습니다. +4. 같은 Terraform이 workload Vault의 `kv/data/dev/platform/postgres/auth-server` 경로로 값을 복사합니다. +5. `runbooks/vault/dev/policies/auth-server-dev.hcl`는 auth-server가 그 경로를 읽을 수 있게 허용합니다. +6. `terraform/vault/reconcile/main.tf`는 `auth-dev` namespace의 `auth-server` ServiceAccount에 이 정책을 연결하는 Kubernetes auth role도 만듭니다. +7. `apps/auth-server/overlays/dev/namespace.yaml`에는 `vault-injection: enabled` 라벨이 있어서 injector webhook 대상이 됩니다. +8. `apps/auth-server/overlays/dev/deployment.vault-patch.yaml`는 Vault에게 어떤 secret을 어디서 읽을지, 어떤 role로 로그인할지 알려줍니다. +9. Pod가 뜰 때 Vault Agent가 `/vault/secrets/runtime-env` 파일을 만들어 줍니다. +10. auth-server 컨테이너는 그 파일을 `source`한 뒤 `java -jar /app/application.jar`로 기동합니다. + +이 10단계를 하나의 그림으로 보면 이렇습니다. + +```mermaid +sequenceDiagram + participant OP as 운영자/Bootstrap + participant PV as Provider Vault
(vault-transit) + participant TF as Terraform + participant WV as Workload Vault + participant API as K8s API Server + participant INJ as Vault Agent Injector + participant POD as auth-server Pod + + Note over OP,PV: 1~2단계: Seed 입력 + OP->>PV: populate-workload-seeds.sh로
DB 비밀번호 입력 + + Note over TF,WV: 3~6단계: Terraform Reconcile + TF->>PV: provider Vault에서 seed 읽기 + TF->>WV: workload Vault에 secret 복사 + TF->>WV: policy 생성 + K8s auth role 생성 + + Note over API,POD: 7~10단계: Pod 기동 + API->>INJ: "vault 어노테이션 있는 Pod 정의 와슸" + INJ->>INJ: Pod에 Vault Agent 사이드카 추가 + INJ-->>API: 수정된 Pod 정의 반환 + API->>POD: Pod 생성 (원래 컨테이너 + Vault Agent) + POD->>WV: K8s SA JWT로 인증 → secret 요청 + WV-->>POD: /vault/secrets/runtime-env 파일 생성 + POD->>POD: source runtime-env → exec java +``` + +이 그림에서 왼쪽(1~2단계)은 **사람이 최초 1회 하는 일**, 가운데(3~6단계)는 **CI가 반복하는 일**, 오른쪽(7~10단계)는 **매 배포마다 자동으로 일어나는 일**입니다. + +> 💡 각 단계의 내부 동작(Mutating Webhook, K8s Auth Handshake, Go 템플릿 렌더링 등)은 26장에서 상세히 다룹니다. + +### 9-2. 여기서 꼭 이해해야 하는 파일 + +| 파일 | 역할 | 왜 중요한가 | +| ----------------------------------------------------------- | --------------------------------------- | ---------------------------------------------------------- | +| `scripts/vault-transit/dev/populate-workload-seeds.sh` | 최초 seed 입력 | secret의 진짜 출발점이다 | +| `terraform/vault/reconcile/main.tf` | provider Vault -> workload Vault 동기화 | secret이 어디로 복사되는지 결정한다 | +| `runbooks/vault/dev/policies/auth-server-dev.hcl` | 읽기 권한 제한 | auth-server가 무엇을 읽을 수 있는지 최소 권한으로 제한한다 | +| `apps/auth-server/overlays/dev/deployment.vault-patch.yaml` | Pod 기동 시 secret 주입 설정 | secret이 파일로 렌더되고 실행 시점에 읽힌다 | + +### 9-3. 왜 이렇게 복잡하게 하나 + +단순히 Kubernetes Secret에 DB 비밀번호를 넣어도 앱은 뜹니다. 그런데 이 프로젝트는 그렇게 하지 않습니다. + +- secret 원본을 Git에 두지 않기 위해 +- 사람이 직접 장기 토큰을 여기저기 복붙하지 않기 위해 +- Pod마다 필요한 secret만 읽게 하기 위해 +- 이후 동적 계정, transit 서명, 최소 권한 같은 보안 정책을 일관되게 적용하기 위해 + +### 9-4. 잘못 건드렸을 때 생기는 부작용 + +| 잘못된 변경 | 바로 생기는 문제 | 더 큰 문제 | +| ------------------------------------------------------- | ---------------------------------------- | --------------------------------------------------- | +| policy 경로를 너무 좁게 적음 | auth-server가 secret을 못 읽고 기동 실패 | 배포는 성공한 것처럼 보여도 앱은 죽는다 | +| policy 경로를 너무 넓게 적음 | 당장은 잘 동작 | auth-server 침해 시 읽을 수 있는 비밀 범위가 커진다 | +| patch에서 `automountServiceAccountToken: true`를 빼먹음 | Vault Kubernetes auth 실패 | injector가 secret을 렌더링하지 못한다 | +| secret을 `ConfigMap`으로 옮김 | 당장은 쉬워 보임 | 민감한 값이 GitOps 정의 안으로 들어온다 | + +## 10. 대표 시나리오 2: 왜 auth-db-migration은 Job인가 + +`auth-server`는 앱이 하나만 있는 것이 아니라, **DB migration Job + 앱 Deployment** 두 가지로 구성됩니다. + +### 10-1. 관련 파일 + +| 파일 | 역할 | +| ----------------------------------------------------------------- | -------------------------------------------- | +| `apps/auth-server/base/db-migration-job.yaml` | 실제 migration Job 정의 | +| `apps/auth-server/overlays/dev/db-migration-job.vault-patch.yaml` | migration Job에 Vault 기반 동적 DB 계정 주입 | +| `apps/auth-server/base/deployment.yaml` | 실제 API 서버 프로세스 | + +### 10-2. 왜 Job인가 + +DB schema 변경은 "앱이 떠 있는 동안 계속 돌고 있어야 하는 프로세스"가 아닙니다. + +- 한 번 실행해서 끝나야 합니다. +- 앱보다 먼저 끝나야 합니다. +- 실패하면 앱이 뜨기 전에 문제를 알리는 것이 더 안전합니다. + +그래서 `db-migration-job.yaml`에는 아래 Argo CD 어노테이션이 들어 있습니다. + +- `argocd.argoproj.io/hook: PreSync` +- `argocd.argoproj.io/sync-wave: "-1"` + +의미는 이렇습니다. + +- 일반 리소스보다 먼저 돈다 +- 성공하면 지워도 된다 +- 성공 전에는 뒤에 있는 앱 배포가 진행되지 않도록 앞단에서 멈춘다 + +이 타이밍을 그림으로 보면 이렇습니다. + +```mermaid +flowchart LR + subgraph PreSync["① PreSync 단계"] + M["db-migration Job
sync-wave: -1"] --> MC{"Migration
결과"} + MC -->|"성공 ✅"| NEXT["다음 단계로"] + MC -->|"실패 ❌"| STOP["전체 sync 중단
앱 배포 안 함"] + end + + subgraph Sync["② Sync 단계"] + D["auth-server Deployment
앱 배포"] + end + + NEXT --> D +``` + +Migration Job이 성공해야만 auth-server Deployment가 배포됩니다. Migration이 실패하면 앱 배포가 아예 진행되지 않아서, schema가 안 맞는 상태로 앱이 뜨는 위험을 방지합니다. + +> 💡 Flyway의 lock 메커니즘과 Job 분리 전략의 상세는 24장에서 다룹니다. + + +### 10-3. 왜 동적 계정을 쓰는가 + +`apps/auth-server/overlays/dev/db-migration-job.vault-patch.yaml`를 보면 migration Job은 `database/creds/auth-db-migration-dev`를 읽습니다. + +이것은 KV에 저장된 고정 비밀번호가 아니라, **Vault database engine이 짧은 TTL을 가진 계정을 그때그때 발급**한다는 뜻입니다. + +이 구조를 택한 이유는 다음과 같습니다. + +- migration은 고권한 작업일 수 있으므로 장기 계정을 남기고 싶지 않다 +- Job은 짧게 실행되므로 동적 계정과 잘 맞는다 +- 누가 언제 어떤 계정을 발급받았는지 추적하기 쉽다 + +### 10-4. 이 설계에서 고민해야 할 점 + +| 고민 | 왜 해야 하는가 | 이 저장소의 답 | 잘못 선택했을 때 | +| ------------------------------------------ | ---------------------------------------------------------- | ------------------ | ------------------------------- | +| migration을 앱 시작 로직에 넣을까? | 서버 여러 대가 동시에 뜨면 schema 변경 경쟁이 생길 수 있다 | 별도 Job으로 분리 | 동시 실행, 락 충돌, 배포 불안정 | +| migration 계정을 고정할까 동적으로 만들까? | 장기 계정이 유출되면 피해가 길어진다 | 동적 계정 사용 | 유출 시 회수와 추적이 어려움 | +| 실패 시 앱도 뜨게 할까? | schema가 안 맞는데 앱이 뜨면 더 큰 장애를 만든다 | PreSync에서 막는다 | 런타임 예외, 데이터 손상 가능성 | + +## 11. 대표 시나리오 3: auth-server가 JWT를 서명하는 과정 + +이 부분은 처음 보면 특히 헷갈립니다. auth-server는 단순히 DB secret만 읽는 것이 아닙니다. **Vault Transit을 이용해 JWT 서명도 수행**합니다. + +### 11-1. 관련 파일 + +| 파일 | 역할 | +| ----------------------------------------------------------- | -------------------------------------------------------------------------- | +| `apps/auth-server/overlays/dev/configmap.yaml` | auth-server가 Vault transit으로 JWT를 다룬다는 설정 제공 | +| `apps/auth-server/overlays/dev/deployment.vault-patch.yaml` | Vault token file을 컨테이너로 전달 | +| `runbooks/vault/dev/policies/auth-server-dev.hcl` | `transit/keys/project-auth-jwt`, `transit/sign/project-auth-jwt` 접근 허용 | +| `terraform/vault/dev/main.tf` | `project-auth-jwt` transit key 생성 | + +### 11-2. 핵심 이해 포인트 + +- auth-server는 로컬에서 개인키를 직접 생성하지 않습니다. +- Vault transit engine에 "이 key로 서명해 달라"고 요청합니다. +- 그래서 애플리케이션 컨테이너 안에 장기 서명 키 파일이 존재하지 않습니다. + +이 과정을 그림으로 보면 이렇습니다. + +```mermaid +sequenceDiagram + participant User as 사용자 + participant Auth as auth-server + participant Vault as Workload Vault
(Transit Engine) + participant API as api-server + + User->>Auth: "로그인 해줘" + Auth->>Auth: 사용자 확인, JWT 페이로드 준비 + Auth->>Vault: "transit/sign/project-auth-jwt로
이 페이로드에 서명해줘" + Note over Vault: 내부의 RSA 개인키로 서명
개인키는 Vault 밖으로 절대 안 나감 + Vault-->>Auth: 서명된 JWT 반환 + Auth-->>User: JWT 토큰 전달 + + User->>API: JWT를 담아 API 호출 + API->>Vault: "transit/keys/project-auth-jwt로
공개키 읽기" + Vault-->>API: RSA 공개키 반환 + API->>API: 공개키로 JWT 서명 검증 + API-->>User: API 응답 +``` + +핵심은 **auth-server가 개인키를 한 번도 본 적이 없다**는 것입니다. 서명이 필요할 때 Vault API를 호출하고, Vault가 내부에서 서명한 결과만 돌려줍니다. api-server는 공개키만 가져와서 검증합니다. + +이 방식은 운영 난이도는 조금 올라가지만, **키 보관을 중앙화**하고 **키 유출 위험을 줄이는 장점**이 있습니다. + +> 💡 Transit Engine의 키 Rotation, 이중 역할(JWT 서명 + Auto-Unseal), 성능 트레이드오프는 26장에서 상세히 다룹니다. + +## 12. auth-server 파일을 어떻게 읽어야 하는가 + +### 12-1. `apps/auth-server/base/kustomization.yaml` + +이 파일은 auth-server를 구성하는 공통 리소스 목록입니다. + +- `serviceaccount.yaml` +- `auth-db-migration-serviceaccount.yaml` +- `service.yaml` +- `db-migration-job.yaml` +- `deployment.yaml` + +이 단계에서 꼭 봐야 하는 것은 "auth-server는 서비스 하나가 아니라 **서비스 + migration job**으로 구성된다"는 사실입니다. + +### 12-2. `apps/auth-server/base/deployment.yaml` + +이 파일은 secret 주입이 없는 기본 뼈대입니다. 여기서 눈여겨볼 지점은 다음과 같습니다. + +| 항목 | 의미 | 왜 이렇게 했는가 | +| ------------------------------------------------- | ------------------------------------ | --------------------------------------------------------------- | +| `serviceAccountName: auth-server` | 이 Pod의 Kubernetes 신분 | 나중에 Vault Kubernetes auth와 연결된다 | +| `automountServiceAccountToken: false` | 기본적으로 SA 토큰을 Pod에 넣지 않음 | 필요한 경우에만 토큰을 노출하려는 보안 기본값 | +| `runAsNonRoot`, `seccompProfile` | 보안 기본 설정 | root 실행과 과도한 시스템 호출을 줄인다 | +| `envFrom`의 `configMapRef`와 `secretRef` | 기본 설계상 설정/비밀을 받는 자리 | dev에서는 overlay patch로 secret 부분이 Vault 방식으로 대체된다 | +| `readinessProbe`, `livenessProbe`, `startupProbe` | 준비/생존/초기 부팅 상태 확인 | 느린 기동과 장애를 구분하기 위함 | + +중요한 포인트는 **base의 값이 최종값이 아닐 수 있다**는 것입니다. overlay patch가 들어오면 일부 항목은 바뀝니다. + +### 12-3. `apps/auth-server/overlays/dev/kustomization.yaml` + +이 파일은 dev 환경에서 auth-server가 실제로 어떤 모습으로 배포되는지 정합니다. + +주요 포인트: + +- namespace는 `auth-dev` +- dev 전용 `configmap`, `ingress`, `networkpolicy`, `sealedsecret`를 추가 +- `deployment.vault-patch.yaml`, `db-migration-job.vault-patch.yaml`로 base를 덮어씀 +- 이미지 태그는 여기서 관리됨 + +즉, 실제 dev 배포를 이해하려면 **base만 보면 안 되고 overlay까지 합쳐서 봐야** 합니다. + +이 합성 과정을 그림으로 보면 이렇습니다. + +```mermaid +flowchart TD + subgraph Base["base/ (공통 뼈대)"] + B1["deployment.yaml
automountServiceAccountToken: false
command/args: 없음"] + B2["service.yaml"] + B3["serviceaccount.yaml"] + end + + subgraph Overlay["overlays/dev/ (환경별 차이)"] + O1["deployment.vault-patch.yaml
automountServiceAccountToken: true
command/args: 추가"] + O2["configmap.yaml (새로 추가)"] + O3["networkpolicy.yaml (새로 추가)"] + O4["namespace: auth-dev"] + end + + subgraph Result["최종 배포 결과 (Kustomize 합성)"] + R1["deployment.yaml
automountServiceAccountToken: true ← patch로 변경
command/args: Vault 시작 명령 추가"] + end + + B1 --> R1 + O1 -->|"패치 적용
(strategic merge)"| R1 +``` + +base에서 `automountServiceAccountToken: false`이지만, overlay patch가 이것을 `true`로 덮어씁니다. **즉, base만 보고 "토큰이 안 들어가네"라고 판단하면 틀립니다.** overlay까지 합쳐야 실제 동작을 알 수 있습니다. + +### 12-4. `apps/auth-server/overlays/dev/configmap.yaml` + +이 파일은 민감하지 않은 값만 둡니다. + +대표 항목: + +- `APP_DATASOURCE_URL` +- `APP_SECURITY_OAUTH2_KEYCLOAK_ISSUER_URI` +- `APP_SECURITY_JWT_ISSUER` +- `APP_SECURITY_JWT_VAULT_ENABLED` +- `APP_SECURITY_JWT_VAULT_ADDRESS` +- `APP_SECURITY_JWT_VAULT_MOUNT_PATH` + +여기서 중요한 기준은 간단합니다. + +- 값이 공개되어도 치명적이지 않으면 `ConfigMap` +- 비밀번호, client secret, token처럼 노출되면 안 되면 Vault + +### 12-5. `apps/auth-server/overlays/dev/deployment.vault-patch.yaml` + +이 파일은 dev auth-server의 핵심입니다. + +반드시 이해해야 하는 항목: + +| 항목 | 의미 | 놓치면 안 되는 이유 | +| ------------------------------------------- | ----------------------------------------------- | -------------------------------------------------------- | +| `vault.hashicorp.com/agent-inject: "true"` | injector가 이 Pod를 가로채 secret 파일을 만든다 | 이 값이 없으면 Vault 주입이 시작되지 않는다 | +| `agent-inject-secret-runtime-env` | 어떤 Vault 경로에서 값을 읽을지 | secret 출처를 정확히 가리킨다 | +| `agent-inject-template-runtime-env` | secret을 쉘 `export` 형식으로 렌더링 | 앱이 `. /vault/secrets/runtime-env` 로 바로 읽을 수 있다 | +| `vault.hashicorp.com/role: auth-server-dev` | Vault 로그인 시 사용할 역할 이름 | policy와 Kubernetes auth role 이름이 연결된다 | +| `automountServiceAccountToken: true` | 이 Pod만 예외적으로 SA 토큰 허용 | Vault가 Pod 신분을 검증하려면 필요하다 | +| `command`, `args` 재정의 | Java 실행 전에 secret 파일과 token file을 읽음 | Vault 주입 결과를 프로세스 환경에 연결한다 | + +이 파일을 읽을 때 꼭 이해해야 하는 역설이 하나 있습니다. + +- base에서는 `automountServiceAccountToken: false` +- overlay patch에서는 `automountServiceAccountToken: true` + +왜 이런 모순처럼 보이는 구조를 쓰는가? + +- 기본값은 "토큰을 넣지 않는다"가 더 안전하기 때문 +- 하지만 Vault Kubernetes auth를 쓰는 특정 Pod는 로그인에 토큰이 필요하기 때문 +- 그래서 **필요한 워크로드에서만 예외를 허용**한다 + +이게 바로 "보안 기본값은 닫고, 필요한 곳만 연다"는 설계입니다. + +### 12-6. `apps/auth-server/overlays/dev/db-migration-job.vault-patch.yaml` + +이 파일은 더 중요합니다. migration Job은 고정 secret이 아니라 동적 DB 계정을 받기 때문입니다. + +핵심 포인트: + +- `database/creds/auth-db-migration-dev` 사용 +- `agent-pre-populate-only: "true"` 사용 +- 컨테이너 시작 전에 secret 파일을 만들어두고, 사이드카를 계속 띄우지 않음 + +왜 `pre-populate-only`가 어울리는가? + +- Job은 짧게 실행되고 끝난다 +- secret을 한 번 받아 실행하면 충분하다 +- 장시간 sidecar를 유지할 필요가 없다 + +### 12-7. `apps/auth-server/overlays/dev/networkpolicy.yaml` + +이 파일은 "막아두고 필요한 것만 연다"는 철학을 가장 잘 보여줍니다. + +구조는 다음과 같습니다. + +- 기본적으로 ingress, egress 모두 차단 +- DNS만 허용 +- Postgres와 Vault로 가는 egress 허용 +- Traefik에서 들어오는 ingress 허용 + +이 파일을 수정할 때는 항상 이 질문을 해야 합니다. + +- 새로 필요한 네트워크 경로가 정말 있는가? +- 그 경로는 어느 namespace, 어느 label, 어느 port인가? +- DNS는 이미 열려 있는가? + +가장 흔한 실수는 "앱이 안 뜬다"는 문제를 보고 Deployment만 수정하는 것입니다. 실제 원인은 NetworkPolicy일 수 있습니다. + +### 12-8. `namespace.yaml`, `public-access.yaml`, `ghcr-regcred.sealedsecret.yaml` + +이 세 파일은 초보자가 자주 지나치지만, 실제로는 구조 이해에 매우 중요합니다. + +`apps/auth-server/overlays/dev/namespace.yaml` + +- `auth-dev` namespace를 만든다 +- `vault-injection: enabled` 라벨을 준다 +- Pod Security 관련 라벨도 같이 준다 + +여기서 중요한 것은 `vault-injection: enabled`입니다. +`argocd/applications/dev/infra/vault-agent-injector.yaml`를 보면 injector webhook은 **이 라벨이 있는 namespace에만** 동작합니다. + +즉, auth-server가 Vault injection을 받는 이유는 단순히 Deployment patch 때문만이 아니라, **namespace도 injector 대상 조건을 만족**하기 때문입니다. + +반대로 `apps/api-server/overlays/dev/namespace.yaml`에는 이 라벨이 없습니다. api-server는 Vault injection을 쓰지 않기 때문입니다. + +`apps/auth-server/overlays/dev/public-access.yaml` + +- `auth-public`이라는 `ExternalName` Service를 만든다 +- 실제로는 `traefik.kube-system.svc.cluster.local`을 가리킨다 + +이 구조를 두는 이유는 "클러스터 내부에서도 public host 기준으로 접근하게 만들기 위해서"입니다. + +예를 들어 issuer URI나 callback URL은 public host 기준으로 맞추는 편이 일관성이 좋습니다. 그때 내부 Pod가 그 host를 해석했을 때도 Traefik으로 가도록 `ExternalName`을 둡니다. + +`apps/auth-server/overlays/dev/ghcr-regcred.sealedsecret.yaml` + +- GHCR 이미지 pull secret을 암호화된 형태로 Git에 저장한다 + +왜 runtime secret은 Vault로 옮기면서 이건 SealedSecret으로 남겼는가? + +- 이미지 pull secret은 **Pod가 뜨기 전에** 필요하다 +- Vault Agent는 Pod 생성 후에 동작한다 +- 즉, 이미지를 받기도 전에 필요한 자격증명은 Vault injection으로 해결할 수 없다 + +이 차이를 이해해야 "왜 어떤 secret은 Vault고 어떤 secret은 SealedSecret이지?"라는 질문이 풀립니다. + +## 13. api-server는 auth-server와 무엇이 다른가 + +`api-server`는 구조가 더 단순합니다. DB migration도 없고 Vault injection도 없습니다. + +### 13-1. 관련 파일 + +| 파일 | 역할 | +| ------------------------------------------------- | ----------------------------- | +| `apps/api-server/base/deployment.yaml` | API 서버 기본 Deployment | +| `apps/api-server/overlays/dev/configmap.yaml` | JWT issuer URI 등 비민감 설정 | +| `apps/api-server/overlays/dev/networkpolicy.yaml` | Traefik 및 DNS 관련 통신 제어 | +| `argocd/applications/dev/apps/api-server.yaml` | Argo CD 적용 대상 선언 | + +### 13-2. 꼭 봐야 할 포인트 + +- `APP_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI`가 `auth-public.auth-dev.svc.cluster.local`을 가리킨다 +- 즉, api-server는 auth-server가 발급한 토큰을 **검증하는 소비자**다 +- auth-server가 죽거나 issuer 주소가 틀리면 api-server의 인증 기능도 깨질 수 있다 + +이 저장소를 읽을 때 흔히 하는 실수는 `api-server`를 완전히 독립된 서비스로 보는 것입니다. 하지만 인증 관점에서는 **auth-server에 의존**합니다. + +이 의존 관계를 그림으로 보면 이렇습니다. + +```mermaid +flowchart LR + USER["브라우저"] --> API["api-server
JWT 검증만 수행"] + USER --> AUTH["auth-server
JWT 발급"] + AUTH -->|"OIDC"| KC["Keycloak"] + AUTH -->|"JWT 서명"| VAULT["Vault Transit"] + AUTH -->|"DB R/W"| PG["Postgres"] + API -->|"공개키 읽기
(issuer URI 경유)"| AUTH + + style API fill:#e8f5e9 + style AUTH fill:#fff3e0 +``` + +api-server는 auth-server의 issuer URI를 통해 공개키를 가져와 JWT를 검증합니다. auth-server가 죽거나 issuer 주소가 바뀌면, api-server도 인증을 못 합니다. + +## 14. platform 계층을 같이 봐야 하는 이유 + +`auth-server`만 보면 "DB URL이 왜 저기지?", "Keycloak client secret은 어디서 쓰이지?"가 남습니다. 그래서 `infra/platform`도 같이 봐야 합니다. + +### 14-1. Postgres + +`infra/platform/base/postgres-statefulset.yaml`과 `infra/platform/overlays/dev/postgres.vault-patch.yaml`를 같이 봐야 합니다. + +이 조합에서 확인해야 할 것: + +- 왜 `StatefulSet`인가 +- `AUTH_DB_PASSWORD`, `KEYCLOAK_DB_PASSWORD`도 Vault에서 주입받는가 +- 초기 DB 생성 스크립트는 어떻게 들어가는가 + +여기서 배울 점은 "앱만 Vault를 쓰는 게 아니라 **플랫폼 컴포넌트도 Vault를 쓴다**"는 것입니다. + +### 14-2. Keycloak + +`infra/platform/base/keycloak-deployment.yaml`과 `infra/platform/overlays/dev/keycloak.vault-patch.yaml`를 보면 Keycloak도 DB 비밀번호와 bootstrap admin 비밀번호를 Vault에서 받습니다. + +이 구조를 이해해야 `auth-server`의 OAuth 설정이 왜 Keycloak과 맞물리는지 보입니다. + +### 14-3. Keycloak client sync Job + +`infra/platform/base/keycloak-client-sync-job.yaml`과 `infra/platform/overlays/dev/keycloak-client-sync.vault-patch.yaml`는 "Keycloak client 설정도 코드로 맞춘다"는 것을 보여줍니다. + +이 Job이 하는 일: + +- Keycloak admin 계정으로 로그인 +- `project-auth-server` client를 찾음 +- client secret, base URL, redirect URI, web origins를 업데이트 + +즉, 사람 손으로 Keycloak 콘솔을 클릭하지 않고 **코드와 Job으로 클라이언트 설정을 맞추는 구조**입니다. + +이 platform 계층의 의존 관계를 그림으로 정리하면 이렇습니다. + +```mermaid +flowchart TB + VAULT["Workload Vault
(secret 제공)"] --> PG["Postgres
(StatefulSet)"] + VAULT --> KC["Keycloak
(Deployment)"] + PG --> KC_SYNC["Keycloak Client
Sync Job"] + KC --> KC_SYNC + + PG --> AUTH["auth-server"] + KC --> AUTH + VAULT --> AUTH + KC_SYNC -.->|"client secret 설정
redirect URI 설정"| AUTH + + AUTH -->|"JWT issuer"| API["api-server"] +``` + +Vault가 모든 컴포넌트에 secret을 제공하고, Postgres와 Keycloak이 auth-server의 기반이 되며, Keycloak client sync Job이 auth-server와 Keycloak 사이의 설정을 자동으로 맞춥니다. **앱(auth-server)만 보면 이 기반이 보이지 않습니다.** + +## 15. Argo CD는 정확히 무엇을 하는가 + +앱과 인프라 YAML을 실제로 클러스터에 반영하는 주체는 Argo CD입니다. + +### 15-1. 꼭 읽어야 할 파일 + +| 파일 | 의미 | +| --------------------------------------------------------- | ----------------------------- | +| `argocd/applications/dev/infra/vault-transit.yaml` | provider Vault 배포 선언 | +| `argocd/applications/dev/infra/vault.yaml` | workload Vault 배포 선언 | +| `argocd/applications/dev/infra/platform.yaml` | Postgres, Keycloak 배포 선언 | +| `argocd/applications/dev/apps/auth-server.yaml` | auth-server dev 배포 선언 | +| `argocd/applications/dev/apps/api-server.yaml` | api-server dev 배포 선언 | +| `argocd/applications/dev/infra/vault-agent-injector.yaml` | injector Helm chart 배포 선언 | + +### 15-2. sync wave를 꼭 이해해야 하는 이유 + +현재 dev 기준 순서는 대략 아래입니다. + +- Vault, Vault Transit, Vault Agent Injector: `10` +- Platform: `20` +- auth-server: `30` +- api-server: `40` + +왜 이런 순서가 필요한가? + +- Vault와 injector가 먼저 있어야 secret 주입이 가능하다 +- platform이 먼저 있어야 auth-server가 붙을 Postgres와 Keycloak이 준비된다 +- auth-server가 먼저 있어야 api-server가 issuer를 안정적으로 참조할 수 있다 + +순서를 잘못 잡으면 "코드는 맞는데 배포만 실패하는" 문제가 생깁니다. + +이 순서를 타임라인으로 보면 이렇습니다. + +```mermaid +flowchart LR + W10["wave 10
Vault Transit
Vault
Agent Injector"] --> W20["wave 20
Platform
(Postgres, Keycloak)"] + W20 --> W30["wave 30
auth-server"] + W30 --> W40["wave 40
api-server"] + + W10 -.->|"이것 없이 다음 단계로 가면
secret 주입 실패"| W20 + W20 -.->|"이것 없이 다음 단계로 가면
DB 연결 실패"| W30 + W30 -.->|"이것 없이 다음 단계로 가면
JWT 검증 실패"| W40 +``` + +각 wave는 **이전 wave가 완료된 후에** 적용됩니다. wave 10이 완료되어야 Vault가 준비되고, wave 20이 완료되어야 Postgres가 준비되고, 그래야 auth-server가 DB와 Vault에 연결할 수 있습니다. + +## 16. GitHub Actions와 Bash 스크립트는 무엇을 하는가 + +이 저장소에서 YAML만큼 중요한 것이 `scripts/`와 `.github/workflows/`입니다. + +### 16-1. `.github/workflows/vault-dev-reconcile.yaml` + +이 워크플로는 dev 환경의 routine reconcile 진입점입니다. + +주요 단계: + +1. 저장소 checkout +2. 필수 도구 확인 +3. kubeconfig 설정 +4. Argo CD infra 정의 적용 +5. provider Vault reconcile +6. workload Vault reconcile +7. Argo CD app 정의 적용 + +즉, 이 워크플로는 "앱 배포만" 하는 것이 아니라 **Vault 상태를 먼저 맞춘 뒤 앱을 반영**합니다. + +### 16-2. `scripts/ci/reconcile-vault-dev.sh` + +이 스크립트는 실전 운영 로직의 핵심입니다. + +반드시 읽어야 할 이유: + +- `require_cmd`, `require_env`로 선행조건을 강제한다 +- `start_port_forward`와 `trap`으로 백그라운드 프로세스를 정리한다 +- `ensure_transit_state_resource`, `ensure_workload_state_resource`로 state import를 자동 보조한다 +- `prepare-infra`, `reconcile-transit`, `reconcile-workload`, `apply-apps`를 분리해 순서를 명확히 한다 + +이 파일을 이해하지 못하면 "왜 Terraform이 두 번 돌지?", "왜 Argo CD 적용이 나중이지?"가 계속 헷갈립니다. + +이 워크플로 전체 흐름을 그림으로 보면 이렇습니다. + +```mermaid +sequenceDiagram + participant GH as GitHub Actions + participant SH as reconcile-vault-dev.sh + participant TF1 as Terraform
(vault-transit/reconcile) + participant TF2 as Terraform
(vault/reconcile) + participant ARGO as Argo CD + participant K8S as K8s 클러스터 + + GH->>SH: 스크립트 실행 + SH->>SH: require_cmd/require_env
사전조건 검증 + SH->>K8S: Argo CD infra 정의 적용
(Vault, Platform 등) + SH->>K8S: kubectl wait
Vault Pod 준비 대기 + SH->>TF1: Provider Vault reconcile
(policy, role 업데이트) + SH->>TF2: Workload Vault reconcile
(secret 복사, auth role 설정) + SH->>K8S: Argo CD app 정의 적용
(auth-server, api-server) + ARGO->>K8S: 앱 매니페스트 sync +``` + +핵심은 **Vault 상태가 먼저 준비되고, 그 다음에 앱이 배포된다**는 것입니다. 순서가 바뀌면 앱이 secret을 못 받고 기동 실패합니다. + +> 💡 이 스크립트의 방어적 프로그래밍(set -euo pipefail, trap, require_cmd)은 27장에서 한 줄씩 해부합니다. + +## 17. Terraform은 왜 bootstrap 루트와 reconcile 루트가 분리되어 있는가 + +이 질문은 꼭 깊게 고민해야 합니다. + +> 💡 이 장의 내용은 5-7절에서 개념적으로 다뤄으며, 27장에서 Terraform State, import, Provider 이중 설정 등 내부 메커니즘을 상세히 해부합니다. + +### 17-1. 관련 디렉터리 + +| 경로 | 목적 | +| ----------------------------------- | -------------------------------- | +| `terraform/vault-transit/dev` | provider Vault 최초 bootstrap | +| `terraform/vault-transit/reconcile` | provider Vault routine reconcile | +| `terraform/vault/dev` | workload Vault 최초 bootstrap | +| `terraform/vault/reconcile` | workload Vault routine reconcile | + +### 17-2. 왜 분리하는가 + +bootstrap과 reconcile은 필요한 권한이 다릅니다. + +- bootstrap은 auth backend 생성, mount 생성, 초기 root 수준 작업이 들어간다 +- reconcile은 이미 만들어진 구조를 반복적으로 맞추는 데 집중한다 + +이 둘을 섞어버리면 어떤 문제가 생길까? + +- CI가 너무 강한 권한을 가져야 한다 +- 실수로 초기화 수준 작업을 routine workflow가 건드릴 수 있다 +- 운영자가 의도한 수동 승인 절차가 사라진다 + +즉, 이 분리는 단순한 취향이 아니라 **권한 경계와 사고 범위를 줄이기 위한 설계**입니다. + +### 17-3. `terraform/vault/reconcile/main.tf`에서 꼭 봐야 하는 것 + +이 파일은 크게 다섯 가지를 합니다. + +1. Vault policy 생성 +2. Kubernetes auth role 생성 +3. AppRole 생성 +4. provider Vault seed 값을 workload Vault KV로 복사 +5. database engine connection과 dynamic role 생성 + +이 파일을 읽을 때는 리소스를 한 줄씩 보는 것보다, 아래 묶음으로 보는 것이 좋습니다. + +- policy 묶음 +- Kubernetes auth role 묶음 +- provider seed -> workload KV 복사 묶음 +- database backend 묶음 + +### 17-4. `runbooks/vault/dev/policies/*.hcl`는 왜 따로 뒀는가 + +정책을 Terraform 파일 안에 큰 문자열로 넣을 수도 있습니다. 그런데 이 저장소는 정책을 별도 `*.hcl` 파일로 분리했습니다. + +이렇게 한 이유: + +- 역할별 권한을 눈으로 검토하기 쉽다 +- PR 리뷰에서 "이 서비스가 읽는 경로가 넓어졌는가"를 바로 볼 수 있다 +- 정책만 따로 읽어도 서비스 권한 모델을 이해할 수 있다 + +## 18. 왜 `vault-transit`과 `vault`를 둘 다 두는가 + +이것도 반드시 고민해야 하는 포인트입니다. + +### 18-1. 현재 구조 + +- `vault-transit`: provider Vault +- `vault`: workload Vault + +provider Vault는 주로 아래를 담당합니다. + +- workload Vault auto-unseal 지원 +- workload용 seed secret의 source of truth 제공 +- workflow AppRole 정보 제공 + +workload Vault는 주로 아래를 담당합니다. + +- 앱/플랫폼 Pod가 실제로 읽는 runtime secret 제공 +- Kubernetes auth 제공 +- JWT signing transit 제공 +- dynamic DB credential 발급 + +### 18-2. 왜 하나로 합치지 않았는가 + +하나로 합치면 구조는 단순해집니다. 하지만 이 저장소는 분리를 선택했습니다. + +이유: + +- auto-unseal trust boundary를 분리할 수 있다 +- runtime secret 저장소와 unseal provider 역할을 분리할 수 있다 +- CI가 직접 workload Vault root 수준 정보를 오래 들고 있지 않게 만들 수 있다 + +대신 비용도 있습니다. + +- Vault가 2개라 운영 난이도가 올라간다 +- bootstrap 절차가 길어진다 +- 학습 난이도도 높아진다 + +즉, 이 구조는 "무조건 더 좋다"가 아니라 **보안 경계를 얻는 대신 운영 복잡도를 감수한 선택**입니다. + +이 2-Vault 구조의 신뢰 관계를 그림으로 보면 이렇습니다. + +```mermaid +flowchart TB + subgraph Provider["Provider Vault (vault-transit)"] + T_UNSEAL["Transit Key
workload-vault-dev-unseal
(Unseal용)"] + T_JWT["Transit Key
project-auth-jwt
(JWT 서명용)"] + SEED["Seed Secrets
(원본 비밀번호)"] + end + + subgraph Workload["Workload Vault"] + KV["KV Engine
(복사된 runtime secret)"] + DB_ENG["Database Engine
(동적 계정 발급)"] + K8S_AUTH["K8s Auth
(Pod 인증)"] + end + + T_UNSEAL -->|"Auto-Unseal
마스터키 복호화"| Workload + SEED -->|"Terraform이
seed를 복사"| KV + + AUTH_POD["auth-server"] --> K8S_AUTH + AUTH_POD --> KV + AUTH_POD -->|"JWT 서명 요청"| T_JWT + + MIG_POD["migration Job"] --> K8S_AUTH + MIG_POD --> DB_ENG +``` + +Provider Vault는 **Workload Vault의 생명(unseal)**과 **원본 secret(seed)**를 관리합니다. Workload Vault는 **앱이 실제로 사용하는 인터페이스**를 제공합니다. auth-server의 JWT 서명은 Provider Vault의 Transit을 직접 사용하지만, DB 비밀번호 등 runtime secret은 Workload Vault의 KV를 사용합니다. + +> 💡 이 구조의 Seal/Unseal 메커니즘, Transit 이중 역할, Agent Injector 동작은 26장에서 상세히 다룹니다. + + +## 19. 이 프로젝트에서 꼭 고민해야 하는 설계 포인트 + +아래 표는 인턴이 실제로 생각해야 하는 지점을 일부러 늘려 정리한 것입니다. + +| 상황 | 왜 고민해야 하는가 | 이 저장소의 현재 선택 | 그렇게 해야 하는 이유 | 잘못 선택했을 때 부작용 | +| --------------------------------------- | ------------------------------------------ | ---------------------------------------------- | ----------------------------------------------- | ---------------------------------------------------------------- | +| 새 설정값 추가 | 설정마다 민감도가 다르다 | 비민감 값은 `ConfigMap`, 민감 값은 Vault | Git과 manifest에 secret을 남기지 않기 위해 | secret 유출 또는 값 추적 어려움 | +| 새 secret 추가 | source of truth가 어디인지 정해야 한다 | provider Vault seed -> workload Vault 복사 | bootstrap과 runtime을 분리하기 위해 | 경로 불일치, CI와 runtime 동기화 실패 | +| 앱이 DB schema를 건드림 | 앱과 schema 변경 타이밍이 충돌할 수 있다 | 별도 PreSync Job 사용 | 앱보다 먼저, 한 번만 실행되게 하려는 목적 | 락 경쟁, 앱 부팅 실패, 반쪽 배포 | +| 새 워크로드가 Vault를 써야 함 | 기본 보안값과 예외를 정해야 한다 | base는 SA token off, 필요한 overlay만 on | 불필요한 토큰 노출을 막기 위해 | Vault 로그인 실패 또는 토큰 과노출 | +| 새 권한 추가 | 권한 범위를 얼마나 넓힐지 결정해야 한다 | policy 경로를 최소화 | 침해 시 피해 범위를 줄이기 위해 | 과권한 부여 | +| 새 네트워크 경로 추가 | 통신이 되게 하면서도 너무 열지 말아야 한다 | default deny 후 allowlist | 우연한 통신 의존성을 막기 위해 | 앱 기동 실패 또는 불필요한 개방 | +| base와 overlay 어디에 둘지 | 환경 독립성과 환경 특수성을 구분해야 한다 | 공통 구조는 base, dev host/path/tag는 overlay | prod/dev 드리프트를 줄이기 위해 | 다른 환경까지 의도치 않게 바뀜 | +| bootstrap과 reconcile 중 어디를 바꿀지 | 권한 수준과 실행 주체가 다르다 | 최초 생성은 bootstrap, 반복 동기화는 reconcile | CI 권한 최소화와 절차 분리를 위해 | routine workflow가 과도한 권한 요구 | +| Job와 Deployment 중 무엇을 쓸지 | 실행 수명과 재시작 특성이 다르다 | 일회성은 Job, 지속 서비스는 Deployment | 워크로드의 본질에 맞추기 위해 | 완료돼야 할 작업이 계속 재시작되거나, 계속 살아야 할 앱이 종료됨 | +| Deployment와 StatefulSet 중 무엇을 쓸지 | 저장소와 정체성 보장이 필요한지 다르다 | Postgres만 StatefulSet | 데이터 볼륨과 안정적 네트워크 식별자가 필요해서 | 데이터 유실, 스토리지 재연결 문제 | + +## 20. 실전에서 가장 많이 하는 변경과 수정 위치 + +### 20-1. auth-server에 비민감 설정값 1개 추가 + +수정 순서: + +1. `apps/auth-server/overlays/dev/configmap.yaml`에 값 추가 +2. 앱이 그 값을 실제로 읽는지 확인 +3. 필요하면 prod overlay에도 대응 + +이 경우 Vault, Terraform, policy 수정은 보통 필요 없습니다. + +### 20-2. auth-server에 민감한 값 1개 추가 + +수정 순서: + +1. `scripts/vault-transit/dev/populate-workload-seeds.sh`에 seed 입력 항목 추가 +2. `terraform/vault/dev/main.tf` 또는 `terraform/vault/reconcile/main.tf`에서 provider -> workload 복사 로직 추가 +3. `runbooks/vault/dev/policies/auth-server-dev.hcl`에 읽기 권한 추가 +4. `apps/auth-server/overlays/dev/deployment.vault-patch.yaml`의 template에 `export` 추가 +5. 앱 코드가 그 환경변수를 읽는지 확인 + +이 과정 중 하나라도 빠지면 Pod는 떠도 값이 비어 있거나, Vault 권한 오류가 납니다. + +### 20-3. auth-server가 새 DB 권한을 요구함 + +확인 순서: + +1. 단순 런타임 읽기인지 +2. migration처럼 고권한 일회성 작업인지 +3. 사람 운영용 접근인지 + +현재 저장소의 기준: + +- 런타임 앱: KV 기반 고정 secret +- migration / operator: database engine 기반 동적 계정 +- 사람 운영: 짧은 TTL 운영자 토큰 + +### 20-4. 외부 접근 host를 바꾸고 싶음 + +수정 후보: + +- `apps/auth-server/overlays/dev/ingress.yaml` +- `apps/auth-server/overlays/dev/public-access.yaml` +- `apps/api-server/overlays/dev/ingress.yaml` +- `infra/platform/overlays/dev/keycloak-ingress.yaml` +- 관련 `ConfigMap`의 issuer/base URL +- 필요 시 Keycloak client sync 관련 값 + +host 변경은 단순 Ingress 한 파일 수정으로 끝나지 않는 경우가 많습니다. + +## 21. 이 문서를 읽은 뒤 실제로 해봐야 할 연습 문제 + +아래 연습은 "이해했다"고 착각하지 않게 해줍니다. + +1. `APP_SECURITY_OAUTH2_KEYCLOAK_CLIENT_SECRET`가 어디서 시작해서 어느 파일을 거쳐 auth-server Pod까지 들어오는지 경로를 종이에 적어보세요. +2. `auth-db-migration`이 왜 `Deployment`가 아니라 `Job`인지, 그리고 왜 `PreSync`인지 설명해보세요. +3. workload Vault가 재시작되었을 때 누가 어떻게 unseal을 돕는지 설명해보세요. +4. `api-server`가 왜 DB secret이 필요 없는지, 대신 어떤 서비스에 의존하는지 설명해보세요. +5. auth-server에 새 secret을 추가해야 한다고 가정하고, 수정해야 할 파일을 빠짐없이 나열해보세요. + +이 다섯 개를 막힘 없이 말할 수 있으면, 이 저장소를 "눈으로 본 수준"이 아니라 "실제로 수정 가능한 수준"으로 이해한 것입니다. + +## 22. 마지막으로: 이 저장소를 배울 때 절대 잊지 말아야 할 관점 + +이 저장소는 단순히 YAML 모음이 아닙니다. 각 파일은 아래 네 가지 중 하나의 책임을 갖습니다. + +- 클러스터에 무엇을 띄울지 정하는 파일 +- Vault 안에 어떤 권한과 비밀 경로를 만들지 정하는 파일 +- 그 선언을 어떤 순서로 적용할지 정하는 파일 +- 사람이 최초 1회 어떤 절차를 밟아야 하는지 정리한 파일 + +초보자가 가장 많이 하는 실수는 **한 파일만 보고 이해하려는 것**입니다. + +이 저장소는 반드시 연결해서 봐야 합니다. + +- 앱 manifest는 Argo CD와 연결해서 +- Vault patch는 policy와 Terraform과 연결해서 +- ConfigMap은 실제 의존 서비스와 연결해서 +- workflow는 bootstrap/runbook과 연결해서 + +이 관점으로 보면, `apps/`의 YAML, `scripts/`의 Bash, `runbooks/`의 HCL, `terraform/`의 TF가 서로 따로 있는 것이 아니라 **한 배포 시스템의 서로 다른 층**이라는 것이 보이기 시작합니다. + +--- + +# Part II: 심층 해부 — 왜 이 기술을 쓰고, 내부에서 무슨 일이 벌어지는가 + +Part I(1~22장)은 "이 저장소의 파일을 어떻게 읽고 수정하는가"에 집중했습니다. + +Part II는 한 걸음 더 들어가서 **"왜 이런 기술을 선택했고, 그 기술이 내부에서 어떤 원리로 돌아가는가"**를 설명합니다. + +이 파트를 읽고 나면 Part I에서 표면적으로만 이해했던 개념들이 입체적으로 연결되기 시작합니다. + +## 23. 운영체제와 서버 인프라의 근간 + +### 23-1. Linux vs Windows: 왜 서버는 리눅스인가 + +#### 서버에 윈도우를 안 쓰는 진짜 이유 + +우리가 일상에서 쓰는 윈도우 PC를 떠올려 보세요. 부팅하면 바탕화면이 뜨고, 마우스 커서가 나타나고, 시작 메뉴가 보입니다. 이 모든 것이 실행 중인 **프로세스**입니다. + +바탕화면을 렌더링하는 프로세스, 마우스 커서를 따라 그리는 프로세스, 알림 영역을 관리하는 프로세스 등이 부팅 직후 수십 개가 올라옵니다. 이것들이 소비하는 메모리만 해도 최소 2~4GB에 달합니다. + +서버는 다릅니다. **모니터에 무언가를 보여줄 일이 없습니다.** 서버의 유일한 목적은 "네트워크로 들어온 요청을 처리하고 결과를 돌려주는 것"입니다. 그런데 이걸 하겠다고 바탕화면 렌더링에 몇 GB를 쓰는 것은 자원 낭비입니다. + +리눅스 서버는 **텍스트 터미널만** 있습니다. GUI가 없으니 그래픽 프로세스가 전혀 올라오지 않습니다. 같은 하드웨어에서 운영체제가 차지하는 메모리가 수백 MB 수준이라, 나머지 리소스를 전부 실제 서비스(Java, Postgres, Vault 등)에 쓸 수 있습니다. + +하지만 이것만이 리눅스를 서버로 쓰는 이유는 아닙니다. 더 근본적인 이유가 있습니다. + +#### "모든 것이 파일이다" — 리눅스의 설계 철학 + +리눅스에는 **"Everything is a file"**이라는 설계 원칙이 있습니다. + +이게 무슨 뜻인지 구체적으로 보겠습니다. + +| 대상 | 윈도우에서는 | 리눅스에서는 | +|---|---|---| +| 하드디스크 | "디스크 관리" 프로그램으로 관리 | `/dev/sda` 라는 파일로 접근 | +| USB 장치 | 장치 관리자에서 확인 | `/dev/usb/...` 파일로 접근 | +| 네트워크 설정 | 제어판 → 네트워크 설정 GUI | `/proc/net/...` 파일을 읽으면 됨 | +| 실행 중인 프로세스 정보 | 작업 관리자 GUI | `/proc/[PID]/...` 파일을 읽으면 됨 | +| 시스템 로그 | 이벤트 뷰어 GUI | `/var/log/...` 텍스트 파일을 읽으면 됨 | + +하드디스크든, 네트워크 포트든, 실행 중인 프로세스의 정보든, 리눅스에서는 **전부 파일처럼 읽고 쓸 수 있습니다.** 이 통일성이 왜 중요할까요? + +Kubernetes는 컨테이너 안의 리소스 사용량을 모니터링하고, 네트워크를 설정하고, 볼륨을 마운트합니다. 이 모든 작업을 리눅스에서는 "파일을 읽고 쓰는 것"으로 통일해서 처리할 수 있습니다. 만약 하드디스크 접근은 A 방식, 네트워크 접근은 B 방식, 프로세스 접근은 C 방식이라면 Kubernetes 같은 오케스트레이터를 만드는 것 자체가 훨씬 어려워집니다. + +#### 그렇다면 우리 윈도우 개발 PC에서 Docker는 어떻게 돌아가는가? + +여기서 자연스러운 의문이 생깁니다. "서버는 리눅스인데, 내 개발 PC는 윈도우잖아? 그런데 Docker로 리눅스 컨테이너를 돌리고 있잖아? 이게 어떻게 가능하지?" + +답은 **WSL2(Windows Subsystem for Linux 2)**입니다. WSL2는 단순한 에뮬레이터가 아닙니다. 마이크로소프트가 **진짜 리눅스 커널**을 윈도우 안에서 돌리는 구조를 만든 것입니다. + +```mermaid +flowchart TB + subgraph 우리_PC["우리 개발 PC (Windows)"] + direction TB + WIN[Windows NT 커널
바탕화면, 마우스, VS Code 등 실행] + + subgraph HYPERV["Hyper-V 가상화 층"] + direction TB + LINUX[진짜 Linux 커널
WSL2가 제공하는 경량 가상머신] + end + + subgraph DOCKER["Docker Desktop"] + direction TB + ENGINE[Docker Engine 데몬
Linux 커널 위에서 실행됨] + ENGINE --> C1[auth-server 컨테이너] + ENGINE --> C2[postgres 컨테이너] + ENGINE --> C3[vault 컨테이너] + end + end + + WIN -->|"Hyper-V를 통해
리눅스 커널 호스팅"| LINUX + LINUX -->|"커널 기능 제공
(cgroups, namespaces)"| ENGINE +``` + +이 그림을 단계별로 설명하면 이렇습니다. + +1. 우리 윈도우 PC의 `Windows NT 커널`이 먼저 부팅됩니다. 바탕화면, VS Code 등이 여기서 돕니다. +2. Windows에는 `Hyper-V`라는 가상화 기능이 내장되어 있습니다. 이것이 아주 가벼운 가상머신을 하나 만듭니다. +3. 그 가상머신 안에 **진짜 리눅스 커널**이 올라갑니다. 에뮬레이션이 아니라 실제 리눅스 커널 바이너리입니다. +4. Docker Desktop은 이 리눅스 커널 위에서 Docker Engine을 실행합니다. +5. 우리가 `docker run`으로 띄우는 Postgres, Vault, auth-server 컨테이너들은 모두 이 리눅스 커널의 기능(cgroups, namespaces)을 사용합니다. + +그래서 우리 윈도우 PC에서 리눅스 컨테이너가 아무 문제 없이 돌아가는 것입니다. 컨테이너 입장에서는 자기가 리눅스 위에서 돌고 있다고 "느끼는" 것이고, 실제로도 그렇습니다. + +#### 리눅스 계열의 차이: Ubuntu, CentOS, Arch는 뭐가 다른가 + +"리눅스"라고 하면 하나의 운영체제처럼 들리지만, 실제로는 **수십 가지 배포판(Distribution)**이 있습니다. 커널(심장)은 같지만, 그 위에 올라가는 패키지 관리 방식, 기본 설치 범위, 업데이트 정책이 다릅니다. + +비유하자면, 같은 엔진을 쓰는 자동차라도 세단, SUV, 트럭이 다르듯이, 같은 리눅스 커널 위에 어떤 옷을 입혔느냐가 배포판의 차이입니다. + +| 계열 | 대표 배포판 | 패키지 관리 도구 | 특징 | 서버 적합도 | +|---|---|---|---|---| +| Debian 계열 | **Ubuntu**, Debian | `apt` (deb 패키지) | 초보자 친화적, 커뮤니티 크고 자료 많음, 2년마다 LTS(장기 지원) 버전 출시 | ⭐⭐⭐⭐⭐ 가장 많이 사용 | +| RHEL 계열 | CentOS, Rocky Linux, AlmaLinux | `yum` / `dnf` (rpm 패키지) | 기업 환경에서 검증된 안정성, Red Hat의 상업 지원 가능, 보안 인증(FIPS 등) | ⭐⭐⭐⭐⭐ 엔터프라이즈 표준 | +| Arch 계열 | **Arch Linux**, Manjaro | `pacman` | 아무것도 기본 설치하지 않음, 사용자가 직접 모든 것을 구성, 롤링 릴리스(항상 최신) | ⭐⭐ 학습용에 가까움 | +| Alpine | Alpine Linux | `apk` | 극도로 가벼움(5MB급 베이스 이미지), 보안 중심 설계 | ⭐⭐⭐⭐ Docker 이미지 베이스로 인기 | + +이 프로젝트의 K3s 노드는 **Ubuntu** 위에서 돌고 있습니다. Ubuntu를 선택한 이유는 K3s 공식 문서의 1순위 지원 대상이고, 자료가 가장 많고, `apt`로 패키지 설치가 간편하기 때문입니다. + +PPT에서 언급한 **Arch Linux**는 "아무것도 안 해주니까 직접 다 하면서 원리를 배우기 좋다"는 학습 목적으로 시도한 것입니다. 실제 서버 운영에는 Ubuntu나 RHEL 계열을 쓰는 것이 일반적입니다. + +#### 이 주제에서 스스로 던져봐야 할 질문들 + +- 리눅스의 "모든 것이 파일" 원칙이 왜 컨테이너 기술(Docker, K8s)과 궁합이 좋은지, `/proc`와 `/sys` 파일시스템의 역할을 조사해보세요. +- Alpine Linux가 Docker 이미지 베이스로 인기 있는 이유는 무엇일까요? 반대로, Alpine을 쓰면 생기는 단점(glibc vs musl 차이)은 무엇일까요? +- 우리 프로젝트의 `hashicorp/vault:1.18` 이미지는 어떤 리눅스 배포판을 베이스로 사용하고 있을까요? `docker inspect`로 확인해보세요. +- WSL2 없이 윈도우에서 리눅스 컨테이너를 돌릴 수 있는 다른 방법이 있을까요? (힌트: VirtualBox, VMware, Multipass) + +### 23-2. 부팅과 커널의 비밀: Dual Boot와 GRUB 부트로더 + +#### 컴퓨터 전원을 누르면 실제로 무슨 일이 일어나는가 + +우리는 매일 전원 버튼을 누르지만, 전원 버튼을 누른 순간부터 로그인 화면이 뜰 때까지 컴퓨터 내부에서는 **5단계의 정밀한 체인**이 순서대로 실행됩니다. + +이 체인 중 하나라도 실패하면 화면에 아무것도 안 뜹니다. 또는 에러 메시지만 나오고 멈춥니다. + +```mermaid +flowchart TD + A["1단계: 전원 ON
전기가 메인보드에 공급됨"] --> B["2단계: BIOS / UEFI 실행
메인보드 칩에 박혀있는 초소형 프로그램
하드웨어 점검(POST) 수행"] + B --> C["3단계: 부트로더 실행
BIOS가 하드디스크의 약속된 위치에서
부트로더 프로그램을 찾아 실행"] + C --> D{"4단계: OS 선택
부트로더가 설치된 OS 목록을 보여줌
(Dual Boot인 경우)"} + D -->|"Ubuntu 선택"| E["5a단계: Linux 커널 적재
vmlinuz(커널 바이너리)를
RAM에 통째로 올림"] + D -->|"Windows 선택"| F["5b단계: Windows Boot Manager
Windows 커널을 RAM에 올림"] + E --> G["6단계: init / systemd 실행
커널이 PID 1번 프로세스를 생성
이것이 모든 프로세스의 조상"] + G --> H["7단계: 로그인 화면
또는 텍스트 프롬프트"] +``` + +각 단계를 하나씩 풀어보겠습니다. + +**1~2단계: BIOS / UEFI** + +전원을 누르면 가장 먼저 실행되는 것은 윈도우도 리눅스도 아닙니다. **메인보드 칩에 박혀 있는 아주 작은 프로그램**이 먼저 뜹니다. 이것이 BIOS(오래된 방식) 또는 UEFI(최신 방식)입니다. + +이 프로그램이 하는 일은 "하드웨어가 정상인지 확인"하는 것입니다. RAM이 있는지, 하드디스크가 연결되어 있는지, 그래픽카드가 있는지 등을 빠르게 체크합니다(이것을 POST, Power-On Self-Test라고 부릅니다). + +**3단계: 부트로더** + +하드웨어 점검이 끝나면 BIOS/UEFI는 "이제 운영체제를 찾아야 한다"고 판단합니다. 하지만 BIOS 자체는 운영체제를 이해하지 못합니다. 그래서 하드디스크의 **약속된 위치(EFI System Partition)**에서 **부트로더**라는 중간 프로그램을 찾아서 실행합니다. + +부트로더는 쉽게 말하면 **"어떤 운영체제를 켤지 고르는 메뉴판 프로그램"**입니다. + +**4단계: OS 선택 (Dual Boot)** + +만약 하드디스크에 Ubuntu와 Windows가 둘 다 설치되어 있다면, 부트로더가 두 선택지를 보여줍니다. 사용자가 Ubuntu를 선택하면 리눅스 커널을 RAM에 올리고, Windows를 선택하면 Windows Boot Manager에게 제어를 넘깁니다. + +**이것이 듀얼 부팅이 가능한 원리입니다.** 하드디스크에 두 OS가 나란히 깔려 있고, 부트로더가 "어느 쪽 커널을 메모리에 올릴지" 선택하는 것입니다. + +**5~6단계: 커널 → PID 1** + +리눅스 커널(`vmlinuz`)이 RAM에 올라오면, 커널은 시스템의 모든 하드웨어를 초기화하고, 그 다음 **딱 하나의 프로세스**를 생성합니다. 이것이 `systemd` (또는 구형 시스템에서는 `init`)이고, **PID 번호 1번**을 받습니다. + +PID 1번은 **모든 프로세스의 조상**입니다. 이후에 뜨는 SSH 서버, 네트워크 데몬, Docker Engine, K3s kubelet 등은 전부 이 PID 1번의 자손입니다. + +Part I의 5-2에서 "왜 `exec`를 써서 PID 1을 Java 프로세스로 교체하는가"를 설명했습니다. 이제 부팅 과정을 알았으니 그 의미가 더 선명해집니다. 컨테이너 안에서도 PID 1은 특별하고, 운영체제에서의 PID 1이 곧 컨테이너의 PID 1과 같은 개념입니다. + +#### GRUB 부트로더와 다른 부트로더의 차이 + +현재 이 프로젝트의 개발 환경은 **GRUB(GRand Unified Bootloader)**을 사용하고 있습니다. GRUB은 리눅스에서 가장 널리 쓰이는 부트로더입니다. 하지만 세상에는 다른 부트로더도 있습니다. + +| 부트로더 | 특징 | 커스터마이징 | Dual Boot 지원 | 적합한 상황 | +|---|---|---|---|---| +| **GRUB** | 가장 유명, 거의 모든 리눅스 배포판의 기본 부트로더 | 매우 유연(테마, 스크립트, 커널 파라미터 조정 가능) | ⭐⭐⭐⭐⭐ `os-prober`로 다른 OS 감지 | 일반 서버/데스크탑 대부분 | +| **systemd-boot** | systemd 프로젝트의 일부, 설정이 단순 | 설정 파일이 간결하지만 유연성은 낮음 | ⭐⭐⭐ 수동 설정 필요 | UEFI 전용, 단일 OS 서버 | +| **rEFInd** | GUI가 예쁘고, EFI 엔트리를 직접 스캔 | 아이콘/테마 커스터마이징 편리 | ⭐⭐⭐⭐⭐ EFI 파티션 직접 탐색 | 맥 + 리눅스 듀얼부팅 | +| **Syslinux/ISOLINUX** | 극도로 가벼움, 설치 미디어(USB/CD)용 | 제한적 | ⭐⭐ 단일 OS 전용 | 부팅 USB, 임베디드 | + +#### GRUB은 어떻게 Windows를 "자동으로" 감지하는가 + +위 표에서 GRUB이 "다른 OS를 감지한다"고 적었는데, **이것은 마법이 아니라 구체적인 메커니즘**이 있습니다. 이것을 이해해야 "왜 가끔 GRUB이 감지를 못 하는 상황이 생기는지"까지 알 수 있습니다. + +Ubuntu에서 `sudo update-grub` 명령을 실행하면 내부적으로 이런 일이 벌어집니다. + +```mermaid +flowchart TD + A["사용자가 sudo update-grub 실행"] --> B["grub-mkconfig 프로그램 시작
GRUB 메뉴 설정 파일을 자동 생성하는 도구"] + B --> C["1단계: /boot 디렉터리 스캔
리눅스 커널 파일(vmlinuz)을 찾음"] + B --> D["2단계: os-prober 실행
다른 OS가 있는지 디스크를 뒤짐"] + D --> E["os-prober가 하는 일:
① 모든 디스크 파티션을 하나씩 마운트
② 각 파티션에서 OS 흔적을 찾음"] + E --> F{"Windows 흔적 발견?
예: /EFI/Microsoft/Boot/bootmgfw.efi
또는 NTFS 파티션의 bootmgr"} + F -->|"흔적 발견"| G["GRUB 메뉴에 'Windows Boot Manager' 항목 추가"] + F -->|"흔적 없음"| H["GRUB 메뉴에 리눅스만 표시"] + G --> I["결과: /boot/grub/grub.cfg 파일 생성
이 파일이 부팅 시 메뉴로 표시됨"] + H --> I +``` + +핵심은 **`os-prober`**라는 프로그램입니다. 이 프로그램이 하는 일을 쉽게 설명하면 이렇습니다. + +1. 컴퓨터에 연결된 **모든 디스크의 모든 파티션**을 하나씩 열어봅니다. +2. 각 파티션을 임시로 마운트해놓고, 그 안에 **운영체제의 흔적**이 있는지 찾습니다. + - Windows의 흔적: EFI 파티션 안의 `/EFI/Microsoft/Boot/bootmgfw.efi` 파일, 또는 NTFS 파티션의 `bootmgr` 파일 + - 다른 리눅스의 흔적: `/boot/vmlinuz` 커널 파일 + - macOS의 흔적: HFS+ 파티션의 특정 구조 +3. 흔적을 찾으면 "이 파티션에 이런 OS가 있다"고 보고합니다. +4. `grub-mkconfig`가 이 보고를 받아서 GRUB 메뉴 설정 파일(`/boot/grub/grub.cfg`)에 해당 OS 항목을 추가합니다. + +그래서 GRUB의 "자동 감지"란 사실 **디스크의 모든 파티션을 뒤져서 알려진 OS의 파일 패턴을 찾는 것**입니다. 이것을 알면 아래 상황이 왜 생기는지도 이해됩니다. + +- **Windows를 나중에 깔면 GRUB이 사라지는 이유**: Windows 설치 프로그램이 EFI System Partition의 기본 부트 엔트리를 자기 것(Windows Boot Manager)으로 **덮어씁니다**. GRUB 파일이 지워지는 것은 아니지만, UEFI가 "기본으로 실행할 부트로더"를 Windows 것으로 바꿔버리니까 GRUB 메뉴가 안 뜨는 것입니다. 이 경우 Ubuntu 설치 USB로 부팅한 뒤 `sudo grub-install`로 GRUB을 기본 부트 엔트리로 다시 등록하면 복구됩니다. +- **`os-prober`가 비활성화된 최신 Ubuntu에서 Dual Boot 메뉴가 안 뜨는 이유**: Ubuntu 21.10부터 보안상의 이유로 `os-prober`가 기본 비활성화되었습니다. `/etc/default/grub`에 `GRUB_DISABLE_OS_PROBER=false`를 추가하고 `sudo update-grub`을 다시 실행해야 Windows가 메뉴에 나타납니다. + +반면 **rEFInd**는 `os-prober` 같은 별도 프로그램 없이, **EFI System Partition 안의 `.efi` 파일들을 직접 스캔**해서 부팅 가능한 OS 목록을 만듭니다. EFI 표준 자체가 "이 디렉터리 구조에 부팅 파일을 놓아라"는 규칙을 정해놨기 때문에, 그 규칙대로 있는 파일들만 찾으면 되는 것입니다. + +**systemd-boot**는 이런 자동 탐색을 아예 안 합니다. 관리자가 설정 파일에 "Windows는 이 파티션에 있고, 이 loader를 써라"고 직접 적어줘야 합니다. 그래서 Dual Boot 지원이 약한 것입니다. + +GRUB이 압도적으로 많이 쓰이는 이유는 이런 **범용성** 때문입니다. 디스크를 스캔해서 OS를 알아서 찾아주고, 커널 부팅 파라미터를 세밀하게 조정할 수 있고, 복구 모드 진입도 GRUB 메뉴에서 직접 가능합니다. + +서버 운영에서 커널 파라미터 튜닝은 특히 중요합니다. 예를 들어 K8s 노드에서는 `net.bridge.bridge-nf-call-iptables=1` 같은 커널 파라미터를 켜야 CNI 네트워크가 정상 동작합니다. GRUB은 부팅 시 이 파라미터를 커널에 전달하는 역할도 합니다(`/etc/default/grub`의 `GRUB_CMDLINE_LINUX` 항목). + +#### 이 부팅 지식이 K8s 운영에서 왜 중요한가 + +"부팅 과정까지 알아야 하나?" 싶을 수 있습니다. 하지만 서버를 운영하다 보면 이런 상황이 생깁니다. + +- K8s 워커 노드가 갑자기 응답을 멈춤 → **커널 패닉**인지, **systemd 데몬 장애**인지, **kubelet 프로세스 죽음**인지 구분해야 합니다. +- CNI 플러그인이 동작하지 않음 → 원인이 **커널 파라미터**(net.bridge 설정)인지, CNI 바이너리(Flannel/Calico)인지 진단해야 합니다. +- 보안 업데이트 후 재부팅했는데 안 켜짐 → **GRUB 설정이 깨졌는지**, 새 커널에 문제가 있는지 확인해야 합니다. + +이 모든 상황에서 "전원 → BIOS → GRUB → 커널 → systemd → kubelet → containerd"라는 체인을 머릿속에 그릴 수 있어야 어느 층에서 문제가 생겼는지 빠르게 좁혀갈 수 있습니다. + +```mermaid +flowchart LR + A[전원] --> B[BIOS/UEFI] --> C[GRUB] --> D[Linux 커널] --> E[systemd
PID 1] --> F[kubelet
K8s 워커 에이전트] --> G[containerd
컨테이너 런타임] --> H[Pod 안의
Java/Vault/Postgres] +``` + +위 체인에서 왼쪽으로 갈수록 "사무실 건물의 전기"에 가깝고, 오른쪽으로 갈수록 "사무실 안의 사원"에 가깝습니다. 장애 진단은 항상 "어느 층에서 끊겼는가"를 찾는 것입니다. + +#### 이 주제에서 스스로 던져봐야 할 질문들 + +- BIOS와 UEFI의 차이는 무엇인가요? 왜 최신 컴퓨터는 UEFI를 쓰는 걸까요? (힌트: 2TB 이상 디스크 지원, 보안 부팅) +- Dual Boot 환경에서 Ubuntu를 먼저 설치하고 Windows를 나중에 깔면 GRUB이 깨졌다는 말을 자주 듣습니다. 왜 그런 걸까요? (힌트: Windows가 EFI Partition의 부트로더를 자기 것으로 덮어쓰기) +- `systemd`가 PID 1인 이유와, 컨테이너 안에서 PID 1이 `sh`(셸)이면 왜 시그널 처리에 문제가 생기는지 조사해보세요. (Part I 5-2의 `exec` 사용 이유와 연결됩니다) +- 커널 파라미터 `net.bridge.bridge-nf-call-iptables=1`이 왜 K8s CNI에 필수인지 공식 문서에서 찾아보세요. +- K8s 노드가 재부팅될 때, 그 노드 위에 있던 Pod들은 어떻게 되나요? 다른 노드로 옮겨지나요, 아니면 그냥 사라지나요? + +## 24. Spring Security, OAuth2, Keycloak 심층 해부 + +### 24-1. Spring Security 무용론? — 클린 아키텍처와의 조화 + +#### 일반적인 Spring Security 로그인은 어떻게 동작하는가 + +Spring Security를 처음 배우면 이런 흐름을 따릅니다. + +```mermaid +sequenceDiagram + participant User as 사용자 브라우저 + participant Filter as Spring Security Filter Chain
(웹 앞단, 인프라 계층) + participant DAO as DaoAuthenticationProvider + participant UDS as UserDetailsService + participant DB as 데이터베이스 + + User->>Filter: 로그인 요청 (이메일 + 비밀번호) + Filter->>DAO: 인증 시도 + DAO->>UDS: "이 이메일의 사용자 정보 가져와" + UDS->>DB: SELECT * FROM users WHERE email = ? + DB-->>UDS: 사용자 정보 (해시된 비밀번호 포함) + UDS-->>DAO: UserDetails 객체 반환 + DAO->>DAO: 비밀번호 비교 (BCrypt) + DAO-->>Filter: 인증 성공 → Authentication 객체 생성 + Filter-->>User: 로그인 성공, 세션 또는 토큰 발급 +``` + +이 흐름에서 중요한 것은 **Security Filter 안에서 DB 조회가 일어난다는 점**입니다. `UserDetailsService`가 DB에서 사용자를 찾고, `DaoAuthenticationProvider`가 비밀번호를 검증합니다. 이 모든 것이 웹 필터 체인, 즉 **인프라 계층** 안에서 발생합니다. + +작은 프로젝트에서는 이게 간편합니다. Spring이 다 해주니까요. 하지만 이 구조에는 **아키텍처적 문제**가 있습니다. + +#### 왜 우리 프로젝트는 이 방식을 안 쓰는가 — 클린 아키텍처 위반 + +클린 아키텍처(Clean Architecture)의 핵심 규칙은 하나입니다. + +> **의존성은 무조건 바깥에서 안쪽으로만 향해야 한다.** + +이것을 원으로 그리면 이렇습니다. + +```mermaid +flowchart TB + subgraph 가장_바깥["가장 바깥: 인프라/프레젠테이션"] + direction TB + A["Spring Security Filter
Spring MVC Controller
JPA Repository
DB 드라이버"] + end + + subgraph 중간["중간: Application / UseCase"] + direction TB + B["로그인 UseCase
회원가입 UseCase
토큰 발급 UseCase"] + end + + subgraph 가장_안쪽["가장 안쪽: Domain"] + direction TB + C["User 엔티티
비즈니스 규칙
순수 Java 코드"] + end + + A -->|"의존 가능 ✅"| B + B -->|"의존 가능 ✅"| C + C -.-x|"의존 불가 ❌
Domain은 Security를
몰라야 함"| A +``` + +**바깥 원(Security Filter, Controller, DB)**은 안쪽 원(UseCase, Domain)을 호출할 수 있습니다. 하지만 안쪽 원은 바깥 원의 존재를 **전혀 몰라야** 합니다. + +그런데 기본 Spring Security 흐름에서는 **Filter(바깥 원) 안에서 DB 조회(비즈니스 로직)가 직접 실행**됩니다. 이것은 바깥 원이 안쪽 원을 관통해서 직접 DB를 건드리는 것이라, 클린 아키텍처 원칙에 **위배**됩니다. + +Spring 공식 문서도 이 점을 인정합니다. 공식 Web Integration 문서에서는 "웹 계층은 여러 계층 중 하나일 뿐이며, 서비스 계층에 정의된 서비스 객체에 비즈니스 관련 사용 사례를 처리하도록 위임하라"고 권고합니다. + +#### 우리 프로젝트는 어떻게 했는가 + +이 프로젝트의 `auth-server`에서는 Security의 역할을 **문지기로 축소**했습니다. + +```mermaid +flowchart LR + subgraph Security_역할["Spring Security가 하는 일 (문지기)"] + direction TB + S1["CORS 설정
다른 출처의 요청 차단/허용"] + S2["JWT 서명 검증
토큰이 위조되었는지 확인"] + S3["RBAC 권한 체크
일반 유저인지, 관리자인지
API 접근 허용/차단"] + end + + subgraph Service_역할["비즈니스 로직이 하는 일 (UseCase)"] + direction TB + U1["로그인 검증
DB에서 사용자 조회"] + U2["회원 가입 처리
DB에 사용자 저장"] + U3["토큰 발급
Vault Transit으로 서명"] + end + + Security_역할 -->|"Argument Resolver로
토큰 정보만 넘겨줌"| Service_역할 +``` + +구체적으로: + +| 역할 | 일반적인 Spring Security | 이 프로젝트 | +|---|---|---| +| 비밀번호 검증 | Filter 안의 `DaoAuthenticationProvider`가 직접 수행 | UseCase(Service 계층)에서 수행 | +| DB 조회 | `UserDetailsService`가 Filter 안에서 DB 접근 | Controller가 Argument Resolver로 토큰 정보만 파싱한 뒤, **Service 계층에서** DB 접근 | +| 비밀번호 암호화 | Security의 `BCryptPasswordEncoder` 직접 사용 | 도메인이 "비밀번호 검증해줘"라고 **포트(인터페이스)**를 통해 요청 → 바깥의 Security가 BCrypt로 처리 | +| OAuth2 로그인 | Security의 OAuth2LoginFilter가 session에 유저 정보를 밀어넣음 | 토큰 교환까지만 Security가 처리 → 이후 Argument Resolver → Service 계층에서 DB 조회 및 가입 처리 | + +이렇게 하면 **도메인 코드는 Spring Security의 존재를 전혀 모릅니다.** 내일 갑자기 Security를 걷어내고 다른 보안 프레임워크로 바꿔도, 도메인과 UseCase 코드는 수정할 필요가 없습니다. + +#### ArchUnit: 이 규칙을 어떻게 강제하는가 + +사람이 코드를 짜다 보면 실수로 규칙을 어길 수 있습니다. "급하니까 Domain에서 직접 Security 클래스를 import하자"라고 하는 순간 아키텍처가 무너집니다. + +이 프로젝트에서는 **ArchUnit**이라는 라이브러리로 이 규칙을 **테스트 코드로 강제**합니다. + +ArchUnit은 JUnit 테스트처럼 동작합니다. 개발자가 코드를 커밋하고 CI가 돌 때, ArchUnit 테스트가 함께 실행됩니다. 만약 `domain` 패키지의 클래스가 `infrastructure` 패키지의 클래스를 import하고 있으면, **테스트가 실패**합니다. + +즉, ArchUnit은 런타임에 동작하는 것이 아니라 **테스트 시점(CI 빌드 시점)**에 동작합니다. 실제 서비스가 돌고 있을 때 성능에 영향을 주는 것이 아니라, 코드를 올릴 때 "이 코드는 아키텍처 규칙을 어겼으므로 빌드 실패"라고 알려주는 **정적 분석 파수꾼**입니다. + +이 프로젝트의 ConfigMap인 `apps/auth-server/overlays/dev/configmap.yaml`을 보면 Security가 남긴 흔적을 확인할 수 있습니다. + +```yaml +# Vault Transit으로 JWT를 다룬다는 설정 — Security는 이 주소를 알고 서명을 요청할 뿐 +APP_SECURITY_JWT_VAULT_ENABLED: "true" +APP_SECURITY_JWT_VAULT_ADDRESS: http://vault.vault.svc.cluster.local:8200 +APP_SECURITY_JWT_VAULT_TRANSIT_KEY_NAME: project-auth-jwt + +# Keycloak과의 OIDC 연동 — Security는 토큰 교환까지만 담당 +APP_SECURITY_OAUTH2_KEYCLOAK_ISSUER_URI: http://keycloak-public.platform.svc.cluster.local/realms/project-auth +APP_SECURITY_OAUTH2_KEYCLOAK_CLIENT_ID: project-auth-server +``` + +여기서 `APP_SECURITY_JWT_VAULT_ENABLED: "true"`는 "JWT 서명을 로컬에서 하지 않고 Vault Transit에 위임하겠다"는 뜻입니다. Security가 서명의 **실행자**가 아니라 서명을 **중계하는 문지기** 역할만 한다는 것을 설정값으로도 확인할 수 있습니다. + +#### 이 주제에서 스스로 던져봐야 할 질문들 + +- ArchUnit이 검사하는 시점은 언제인가? 런타임인가, CI 빌드 시점인가? (답: 테스트 시점, 즉 JUnit이 돌 때) +- Keycloak이 완전히 다운되면 기존에 발급된 JWT 토큰은 여전히 유효한가? (힌트: JWT는 자기 완결적 — 서명 검증에 외부 호출이 필요 없다면 유효하다) +- `BcryptPasswordEncoder`를 도메인 코드에서 직접 쓰지 않고 포트(인터페이스)로 뺀 이유는 무엇인가? 포트를 안 쓰고 직접 BCrypt를 호출하면 어떤 문제가 생기는가? +- 이 프로젝트에서 `APP_SECURITY_JWT_GENERATE_KEY_PAIR_ON_STARTUP: "false"`로 설정한 이유는 무엇인가? (힌트: 키 생성도 Vault Transit에 맡겼기 때문) +- MVC 패턴과 클린 아키텍처의 가장 큰 차이는 무엇인가? MVC에서 Controller가 직접 DB를 호출하는 것이 왜 큰 프로젝트에서는 문제가 되는가? + +### 24-2. 현업의 OAuth2: Keycloak과 OIDC 내부 동작 + +#### OAuth2의 핵심 아이디어: "비밀번호를 우리가 받지 않는다" + +사용자가 구글 로그인을 누르는 상황을 생각해보세요. 우리 서비스가 사용자의 구글 비밀번호를 직접 받아서 구글에 대신 로그인해주는 것은 **매우 위험**합니다. 사용자 입장에서도 "내 구글 비밀번호를 니네 서비스에 왜 줘야 하지?" 싶습니다. + +OAuth2는 이 문제를 해결합니다. **비밀번호를 우리한테 주는 게 아니라, 구글 로그인 페이지에서 직접 로그인하고, 구글이 "이 사람은 진짜야"라는 증거(토큰)만 우리한테 건네주는 구조**입니다. + +이 흐름을 **인가 코드 부여 방식(Authorization Code Grant)**이라고 부릅니다. + +```mermaid +sequenceDiagram + participant User as 사용자 브라우저 + participant Spring as auth-server
(Spring Security) + participant Google as 구글 로그인 서버 + + User->>Spring: "구글로 로그인할래요" + Spring->>User: 구글 로그인 페이지로 리다이렉트
(우리 서버가 비밀번호를 받지 않음!) + User->>Google: 구글 로그인 페이지에서 직접 로그인 + Google->>User: 로그인 성공! 인가 코드를 들고
우리 서버의 콜백 URL로 돌아가거라 + User->>Spring: 인가 코드(임시 교환권)를 들고 콜백 URL로 돌아옴 + + Note over Spring,Google: 여기서부터는 서버 대 서버 통신 (사용자 브라우저를 거치지 않음) + Spring->>Google: "이 인가 코드, 진짜 너네가 준 거 맞지?
Access Token으로 바꿔줘" + Google-->>Spring: Access Token + ID Token 반환 + Spring->>Spring: ID Token에서 사용자 이메일, 이름 등 추출 + Spring-->>User: 우리 서비스의 JWT 토큰 발급 +``` + +핵심 포인트는 **인가 코드(Authorization Code)**입니다. 이것은 "일회용 교환권"같은 것입니다. + +1. 구글이 사용자에게 인가 코드를 줍니다. +2. 사용자가 그 코드를 우리 서버에 전달합니다. +3. 우리 서버가 구글에 "이 코드 진짜야? 토큰으로 바꿔줘"라고 서버 대 서버 통신을 합니다. + +왜 처음부터 토큰을 안 주고 코드를 먼저 줄까요? 코드는 **사용자의 브라우저를 경유**합니다. 브라우저는 해킹당할 수 있습니다. 그래서 코드는 일회용이고 짧은 시간만 유효합니다. 실제 토큰 교환은 **서버 대 서버 통신**으로 이루어지니 브라우저가 탈취당해도 토큰은 안전합니다. + +#### Keycloak이 없으면 어떤 끔찍한 일이 생기는가 + +위 흐름을 구글, 카카오, 네이버, GitHub 각각에 대해 구현한다고 생각해보세요. + +```mermaid +flowchart TB + subgraph 문제["Keycloak 없이 직접 구현한 경우"] + direction TB + APP[auth-server] + APP --> G[구글 API
응답 형식: A] + APP --> K[카카오 API
응답 형식: B] + APP --> N[네이버 API
응답 형식: C] + APP --> GH[GitHub API
응답 형식: D] + end +``` + +각 소셜 로그인 제공자마다: +- 콜백 URL 형식이 다릅니다. +- 사용자 정보 응답 형식(JSON 구조)이 다릅니다. 구글은 `email` 필드, 카카오는 `kakao_account.email` 필드입니다. +- 토큰 갱신 방식이 다릅니다. + +제공자가 4개면 4가지 파싱 코드를 짜야 합니다. 10개가 되면 10가지입니다. 코드가 폭발합니다. + +#### Keycloak은 이 문제를 어떻게 해결하는가 + +**Keycloak**은 **사설 통합 인증 센터(Identity Provider Broker)**입니다. 쉽게 말하면 "외부 로그인 제공자와의 복잡한 대화를 대신 해주고, 우리한테는 항상 같은 형식으로 결과를 알려주는 중간 통역사"입니다. + +```mermaid +flowchart TB + subgraph 해결["Keycloak을 둔 경우"] + direction TB + APP2[auth-server
Keycloak 하나만 상대하면 됨] + KC[Keycloak
통합 인증 센터] + APP2 -->|"항상 OIDC 표준 형식
하나의 콜백, 하나의 응답 규격"| KC + KC --> G2[구글] + KC --> K2[카카오] + KC --> N2[네이버] + KC --> GH2[GitHub] + end +``` + +이 구조에서 `auth-server`의 Spring Security가 하는 일은 극적으로 줄어듭니다. + +1. 사용자가 "구글로 로그인" 클릭 → Security가 사용자를 **Keycloak**으로 리다이렉트 (구글로 직접이 아님!) +2. Keycloak이 구글과 알아서 통신 → 구글의 응답을 **OIDC 표준 형식(JSON)**으로 변환 +3. Keycloak이 auth-server에 콜백 → Security가 OIDC 표준 토큰을 수신 +4. 여기까지가 Security의 역할 끝 → 이후 Argument Resolver가 토큰에서 사용자 정보 추출 → Service 계층에서 DB 조회 + +**OIDC(OpenID Connect)**란 OAuth2 위에 "사용자 신원 확인" 규격을 얹은 표준입니다. OAuth2만으로는 "이 사람이 어떤 리소스에 접근 가능하다"만 알 수 있지만, OIDC를 쓰면 "이 사람의 이메일은 뭐고 이름은 뭐다"까지 표준화된 형식(ID Token)으로 받을 수 있습니다. + +#### 이 구조가 실제 코드에 어떻게 반영되어 있는가 + +이 저장소의 실제 파일들을 보면 위 아키텍처가 선언으로 드러납니다. + +**1. Keycloak이 auth-server의 인증 상대방이라는 것을 알려주는 설정** + +`apps/auth-server/overlays/dev/configmap.yaml`: +```yaml +# auth-server가 Keycloak을 통해 OIDC 인증을 받겠다는 선언 +APP_SECURITY_OAUTH2_KEYCLOAK_ISSUER_URI: http://keycloak-public.platform.svc.cluster.local/realms/project-auth +APP_SECURITY_OAUTH2_KEYCLOAK_CLIENT_ID: project-auth-server + +# 구글/GitHub 로그인도 Keycloak을 경유한다는 뜻 +# registration ID가 "keycloak-google", "keycloak-github"다 +APP_SECURITY_OAUTH2_GOOGLE_REGISTRATION_ID: keycloak-google +APP_SECURITY_OAUTH2_GITHUB_REGISTRATION_ID: keycloak-github +``` + +`APP_SECURITY_OAUTH2_GOOGLE_REGISTRATION_ID: keycloak-google`이 중요합니다. 이름이 **"keycloak-google"**인 것은 **구글 로그인이지만 Keycloak을 거쳐서 간다**는 뜻입니다. auth-server는 구글 API를 직접 호출하지 않고, Keycloak에게 "구글 쪽으로 브로커링 해줘"라고 힌트(`idp_hint: google`)만 보냅니다. + +**2. Keycloak의 client 설정을 코드로 맞추는 Job** + +`infra/platform/base/keycloak-client-sync-job.yaml`을 보면, 이 Job이 Keycloak Admin API를 호출해서: +- `project-auth-server` 클라이언트의 `secret`을 업데이트하고 +- `baseUrl`을 맞추고 +- `redirectUris`를 코드 기반으로 등록합니다 + +```yaml +# keycloak-client-sync-job.yaml 중 핵심 부분 +/opt/keycloak/bin/kcadm.sh update "clients/${CLIENT_UUID}" \ + -r project-auth \ + -s "secret=$KEYCLOAK_CLIENT_SECRET" \ + -s "baseUrl=$AUTH_SERVER_BASE_URL" \ + -s 'redirectUris=[".../login/oauth2/code/keycloak-google",".../login/oauth2/code/keycloak-github"]' +``` + +사람이 Keycloak 관리 콘솔에서 마우스로 클릭하는 것이 아니라, **K8s Job이 코드로 자동 설정**합니다. 이것이 GitOps 관점에서 중요합니다. 설정이 코드에 있으니 변경 추적이 되고, 재현 가능합니다. + +**3. OAuth2 client secret은 Vault에서 온다** + +Keycloak과 auth-server가 통신하려면 **client secret**(일종의 비밀 비밀번호)이 필요합니다. 이 값은 `deployment.vault-patch.yaml`에서 Vault를 통해 주입됩니다. + +```yaml +# deployment.vault-patch.yaml의 일부 +{{ with secret "kv/data/dev/platform/keycloak/client-auth-server" }} +export APP_SECURITY_OAUTH2_KEYCLOAK_CLIENT_SECRET={{ printf "%q" .Data.data.APP_SECURITY_OAUTH2_KEYCLOAK_CLIENT_SECRET }} +{{ end }} +``` + +즉, client secret은 Git에 없고, Vault에만 있고, Pod가 뜰 때 파일로 주입됩니다. + +#### 이 주제에서 스스로 던져봐야 할 질문들 + +- OAuth2의 "인가 코드"를 사용자 브라우저가 중간에 가로챌 수 있는가? 가로채도 왜 안전한가? (힌트: 코드를 토큰으로 교환할 때 client secret이 필요하고, 이것은 서버만 알고 있다) +- OIDC의 ID Token과 OAuth2의 Access Token은 어떤 차이가 있는가? 둘 다 JWT인가? +- Keycloak이 완전히 다운되면 어떤 일이 생기는가? 이미 로그인한 사용자는 괜찮은가? 새로 로그인하려는 사용자는? +- `redirectUris`가 잘못 설정되면 어떤 보안 문제가 생기는가? (힌트: Open Redirect 공격) +- `keycloak-client-sync-job.yaml`에서 `until ... do sleep 5; done` 루프가 있는 이유는 무엇인가? (힌트: Keycloak이 아직 기동 중일 수 있다) + +### 24-3. DB 버전 관리(Flyway) 분리 배포 작전 + +#### 왜 DB 스키마 변경은 특별하게 다뤄야 하는가 + +일반적인 코드 변경(버그 수정, 기능 추가)은 서버를 새로 배포하면 반영됩니다. 이전 코드가 새 코드로 바뀌는 것뿐이니, 잘못되면 이전 버전으로 롤백하면 됩니다. + +하지만 **DB 스키마 변경**은 다릅니다. 새 컬럼을 추가하거나, 테이블 이름을 바꾸거나, 인덱스를 거는 것은 **데이터 구조 자체를 바꾸는 것**입니다. 한번 바꾸면 되돌리기가 훨씬 어렵습니다. 새 컬럼에 데이터가 이미 쌓였다면 단순 롤백으로는 원상복구가 안 됩니다. + +**Flyway**는 DB 스키마 변경을 **버전 관리**하는 도구입니다. 코드 변경을 Git으로 관리하듯이, DB 변경을 `V1__create_user.sql`, `V2__add_email_column.sql` 같은 번호 붙은 마이그레이션 파일로 관리합니다. + +#### 기존 방식의 문제: Spring Boot 기동 시 Flyway 자동 실행 + +처음에는 Flyway를 Spring Boot 앱 안에 넣었습니다. 서버가 기동될 때 Flyway가 자동으로 실행되어 DB 스키마를 최신으로 맞추는 방식입니다. + +작은 서비스에서는 이것도 괜찮습니다. 하지만 **트래픽이 몰려서 서버를 10대로 늘려야 하는 상황**을 생각해보세요. + +```mermaid +flowchart TB + subgraph 문제_상황["서버 10대가 동시에 기동되는 상황"] + direction TB + S1[auth-server Pod 1] -->|"Flyway: ALTER TABLE users..."| DB[(PostgreSQL)] + S2[auth-server Pod 2] -->|"Flyway: ALTER TABLE users..."| DB + S3[auth-server Pod 3] -->|"Flyway: ALTER TABLE users..."| DB + S4["... Pod 4~10도 동시에"] -->|"Flyway: ALTER TABLE users..."| DB + end + + DB -->|"💥 Lock 경합!
누가 먼저야?
DDL Lock 충돌!"| DEAD[배포 데드락
일부 Pod는 마이그레이션 성공
일부 Pod는 Lock 대기 중 타임아웃] +``` + +Flyway는 내부적으로 DB Lock을 사용해서 중복 실행을 방지하려고 합니다. 하지만 10대의 서버가 **동시에** 일어나면서 모두 "나 먼저 마이그레이션 할게!"라고 달려들면, Lock 경합이 발생합니다. 일부는 성공하고 일부는 타임아웃으로 실패합니다. 실패한 Pod는 기동에 실패합니다. + +#### 이 프로젝트의 해결책: 완전 분리된 K8s Job + +이 프로젝트에서는 Flyway를 **앱 서버에서 완전히 떼어내서 별도의 K8s Job으로 분리**했습니다. + +```mermaid +flowchart TD + subgraph 해결_구조["이 프로젝트의 배포 흐름"] + direction TB + + subgraph PreSync["1단계: PreSync (앱 배포 전)"] + JOB[auth-db-migration Job
Flyway 실행
딱 1개만 실행됨] -->|"스키마 변경 완료"| DB2[(PostgreSQL)] + end + + subgraph MainSync["2단계: Main Sync (스키마 준비 완료 후)"] + S1b[auth-server Pod 1
Flyway 안 함] --> DB2 + S2b[auth-server Pod 2
Flyway 안 함] --> DB2 + S3b[auth-server Pod 3
Flyway 안 함] --> DB2 + end + + PreSync -->|"Job 성공해야
다음 단계 진행"| MainSync + end +``` + +이 구조의 핵심은 Argo CD의 **PreSync Hook**입니다. + +`apps/auth-server/base/db-migration-job.yaml`을 보면: + +```yaml +annotations: + argocd.argoproj.io/hook: PreSync # 일반 리소스보다 먼저 실행 + argocd.argoproj.io/hook-delete-policy: BeforeHookCreation,HookSucceeded # 성공하면 정리 + argocd.argoproj.io/sync-wave: "-1" # 가장 먼저 +``` + +그리고: + +```yaml +spec: + backoffLimit: 1 # 실패하면 1번만 재시도 + template: + spec: + restartPolicy: Never # 완료 후 재시작하지 않음 +``` + +이 어노테이션과 설정을 합치면 이런 동작이 됩니다. + +1. Argo CD가 sync를 시작하면 **PreSync 리소스를 먼저** 실행합니다. +2. migration Job이 **딱 1개** 뜹니다. (Deployment가 아니라 Job이니까 복제본 없음) +3. Flyway가 DB 스키마를 변경합니다. +4. Job이 성공하면(exit code 0) Argo CD가 다음 단계(Deployment 등)를 진행합니다. +5. Job이 실패하면 **배포 전체가 멈춥니다.** 스키마가 반쪽만 적용된 상태로 앱이 뜨는 것을 방지합니다. + +auth-server의 ConfigMap에서 `APP_PERSISTENCE_MIGRATION_RUN_ON_STARTUP: "false"`인 이유도 이것 때문입니다. 앱 서버 자체는 Flyway를 실행하지 않습니다. 마이그레이션은 오직 Job만 합니다. + +#### 분리에 따른 의존성 문제와 해결 + +PPT에서 언급했듯이, 처음에 Flyway를 분리하려 했을 때 문제가 있었습니다. Flyway가 Spring Boot 앱 안에 있다 보니, 관련 설정 Bean들이 앱 기동에 필요했습니다. 단순히 Flyway 실행만 떼어내는 것으로는 의존성이 끊기지 않았습니다. + +최종 해결책은 **Flyway를 아예 별도의 라이브러리 모듈로 분리**하고, Docker 이미지 빌드 시 `migration.jar`라는 독립적인 JAR 파일을 만든 것입니다. + +`db-migration-job.yaml`에서 이것이 드러납니다: + +```yaml +containers: + - name: auth-db-migration + image: ghcr.io/donghyeonka/project-auth-server # 같은 이미지지만 + command: + - java + - -jar + - /app/migration.jar # migration.jar를 따로 실행 + env: + - name: SPRING_MAIN_WEB_APPLICATION_TYPE + value: none # 웹 서버를 띄우지 않음 + - name: APP_PERSISTENCE_MIGRATION_RUN_ON_STARTUP + value: "true" # 이 Job에서만 마이그레이션 실행 +``` + +`SPRING_MAIN_WEB_APPLICATION_TYPE: none`은 "Spring의 웹 서버(Tomcat 등)를 켜지 마라"는 뜻입니다. migration에는 HTTP 서버가 필요 없으니까요. 이렇게 하면 순수하게 Flyway만 돌리고 종료됩니다. + +#### 동적 DB 계정: migration Job은 왜 고정 비밀번호를 안 쓰는가 + +dev overlay의 `db-migration-job.vault-patch.yaml`을 보면, migration Job은 고정 비밀번호가 아니라 **Vault가 그 순간에 만들어주는 임시 DB 계정**을 사용합니다. + +```yaml +vault.hashicorp.com/agent-inject-secret-migration-env: database/creds/auth-db-migration-dev +``` + +경로가 `kv/...`(고정 값 저장소)가 아니라 `database/creds/...`(동적 발급 엔진)인 것이 핵심입니다. + +이 경로로 요청하면 Vault가 **그 순간에** PostgreSQL에 접속해서 임시 사용자를 만들고, 짧은 TTL(수명)이 지나면 자동으로 삭제합니다. + +왜 이렇게 할까요? + +- migration은 **고권한 작업**입니다. 테이블 생성, 컬럼 변경 같은 DDL을 수행합니다. +- 이런 강력한 권한을 가진 계정이 **영구적으로 존재**하면, 유출 시 피해가 큽니다. +- 동적 계정은 Job 실행 후 자동 만료되니, 유출되어봐야 이미 삭제된 계정입니다. + +그리고 `agent-pre-populate-only: "true"` 어노테이션의 의미도 여기서 명확해집니다. Deployment에 붙는 Vault Agent는 sidecar로 계속 살아있으면서 secret을 갱신할 수 있지만, Job은 **짧게 실행되고 끝나는 일회성 작업**이니 사이드카가 계속 떠 있을 필요가 없습니다. 한 번 secret 파일을 만들어두고 바로 사라지는 것입니다. + +#### 이 주제에서 스스로 던져봐야 할 질문들 + +- Flyway 마이그레이션 Job이 중간에 실패해서 테이블은 만들었는데 인덱스는 못 만든 상태라면, 다음 Job 실행 시 어떻게 되는가? Flyway는 이것을 어떻게 감지하는가? (힌트: `flyway_schema_history` 테이블) +- DDL(테이블 구조 변경)과 DML(데이터 변경) 마이그레이션의 위험도 차이는 무엇인가? 왜 DDL 마이그레이션이 더 위험한가? +- `backoffLimit: 1`의 의미는 무엇인가? 왜 migration Job은 횟수를 제한하는가? +- 만약 Flyway가 성공했는데 앱 배포가 실패해서 롤백한다면, DB 스키마는 이전으로 돌아가는가? (답: 아니다. DB 스키마 롤백은 별도 마이그레이션이 필요하다) +- `database/creds/auth-db-migration-dev`와 `kv/data/dev/platform/postgres/auth-server`의 차이는 무엇인가? 왜 runtime 앱은 KV를 쓰고 migration Job은 database engine을 쓰는가? + +## 25. Kubernetes(K3s) 심층 해부와 네트워크 구조 + +### 25-1. K8s vs K3s: 왜 가볍고 왜 선택했는가 + +#### Kubernetes의 기본 구조: 두 종류의 노드 + +Kubernetes를 쉽게 비유하면, **회사의 본사(Control Plane)**와 **공장(Worker Node)**의 관계입니다. + +본사는 "무엇을 어디에 얼마나 만들지" 결정하고, 공장은 실제로 물건(컨테이너)을 생산합니다. 본사가 망하면 새 지시가 안 내려가지만, 이미 돌고 있는 공장은 당장은 계속 돌아갑니다. + +```mermaid +flowchart TB + subgraph CP["Control Plane (본사)"] + direction TB + API["API Server
모든 요청의 창구
kubectl 명령이 여기로 들어옴"] + ETCD["etcd / SQLite
클러스터의 모든 상태를
저장하는 데이터베이스"] + SCHED["Scheduler
새 Pod를 어느 노드에
배치할지 결정"] + CM["Controller Manager
선언된 상태와 현재 상태의
차이를 감지하고 조정"] + + API <--> ETCD + SCHED --> API + CM --> API + end + + subgraph W1["Worker Node 1 (공장)"] + direction TB + KL1["kubelet
API Server의 지시를 받아
컨테이너를 생성/삭제"] + KP1["kube-proxy
네트워크 규칙을 관리
(Service → Pod 라우팅)"] + CR1["containerd
실제 컨테이너를 실행하는
런타임 엔진"] + + KL1 --> CR1 + end + + subgraph W2["Worker Node 2 (공장)"] + direction TB + KL2["kubelet"] + KP2["kube-proxy"] + CR2["containerd"] + KL2 --> CR2 + end + + API -->|"Watch 스트림으로
이벤트 전달"| KL1 + API -->|"Watch 스트림으로
이벤트 전달"| KL2 +``` + +각 컴포넌트가 하는 일을 구체적으로 설명합니다. + +| 컴포넌트 | 어디에 있나 | 무슨 일을 하나 | 없으면 어떻게 되나 | +|---|---|---|---| +| **API Server** | Control Plane | 모든 통신의 중심. `kubectl` 명령, kubelet 보고, Scheduler 요청이 전부 여기를 거침 | 클러스터 전체가 통신 불가. 신규 배포, 스케일링, 조회 모두 불가능 | +| **etcd** (K8s) / **SQLite** (K3s) | Control Plane | "이 클러스터에 무엇이 있고, 어떤 상태인지"를 영구 저장. Deployment 몇 개, Pod 몇 개, Service 설정 등 | 상태 정보가 날아감. 클러스터를 처음부터 다시 만들어야 함 | +| **Scheduler** | Control Plane | 새 Pod가 생겼을 때 "어느 Worker에 배치할지" 결정 (CPU, 메모리, affinity 조건 등 고려) | Pod가 Pending 상태에서 영원히 멈춤 | +| **Controller Manager** | Control Plane | "선언된 상태(Deployment에 replica: 3)"와 "현재 상태(Pod 2개 살아있음)"의 차이를 발견하고 Pod를 1개 더 만듦 | Pod가 죽어도 자동 복구 안 됨, 스케일링 안 됨 | +| **kubelet** | 각 Worker | API Server에게 "제 노드에서 이 Pod를 실행하겠습니다"라는 지시를 받고, containerd에게 실제 실행을 시킴 | 해당 노드에서 컨테이너 생성, 삭제, 모니터링 불가 | +| **kube-proxy** | 각 Worker | Service의 IP로 들어온 트래픽을 실제 Pod의 IP로 전달하는 네트워크 규칙을 관리 | Service를 통한 통신 불가 (Pod IP 직접 지정하면 가능) | +| **containerd** | 각 Worker | 실제 컨테이너 이미지를 다운로드하고, 프로세스를 격리해서 실행 | 컨테이너 실행 자체가 불가 | + +#### kubelet은 API Server에게 어떻게 지시를 받는가 — Watch 메커니즘 + +여기서 중요한 의문이 생깁니다. kubelet은 API Server에게 명령을 어떻게 받을까요? + +두 가지 방식이 가능합니다. + +1. **Polling(폴링)**: kubelet이 1초마다 API Server에 "나한테 새 일 있어?" 하고 물어보는 방식 +2. **Watch(감시)**: kubelet이 API Server에 "내 노드에 변경 생기면 바로 알려줘"라고 한 번 등록해놓고, 변경이 있을 때만 알림을 받는 방식 + +Kubernetes는 **Watch 방식**을 씁니다. 왜냐하면 Worker Node가 100대, 1000대가 되면 모두가 1초마다 물어보는 건 API Server에게 엄청난 부하가 됩니다. Watch는 **변경이 있을 때만** 이벤트를 푸시하니까 훨씬 효율적입니다. + +```mermaid +sequenceDiagram + participant KL as kubelet (Worker Node) + participant API as API Server (Control Plane) + + KL->>API: "내 노드에 관련된 변경사항을
Watch 스트림으로 구독합니다" + Note over KL,API: HTTP Long-Poll 연결이 유지됨 + API-->>KL: (아무 일 없으면 조용) + + Note over API: 사용자가 kubectl apply로
새 Deployment 생성 + API->>API: Scheduler가 "Worker Node 1에 배치" 결정 + API-->>KL: "새 Pod를 실행하세요" 이벤트 푸시 + KL->>KL: containerd에게 컨테이너 생성 요청 + KL-->>API: "Pod 실행 중(Running)" 상태 보고 +``` + +이 구조를 **선언적 상태 관리(Declarative State Management)**라고 합니다. + +사용자는 "auth-server를 3개 돌려라"라고 **원하는 상태를 선언**합니다. Controller Manager가 현재 상태와 원하는 상태를 지속적으로 비교합니다. Pod가 1개 죽으면 "2개인데 3개여야 하니까 1개 더 만들어야지"라고 판단하고, 이것을 **Reconciliation Loop(조정 루프)**라고 부릅니다. 이 루프는 **끊임없이** 돕니다. + +#### K3s는 이 구조를 어떻게 경량화했는가 + +K3s는 위의 Kubernetes와 동일한 개념이지만, 일부를 **다이어트**했습니다. + +```mermaid +flowchart LR + subgraph K8S["K8s (풀 사이즈)"] + direction TB + E1["etcd
분산 합의 알고리즘 (Raft)
별도 클러스터 3~5대 필요
수백 MB 메모리"] + C1["Cloud Controller Manager
AWS, GCP 연동 코드"] + S1["Storage Driver
다양한 CSI 드라이버 포함"] + end + + subgraph K3S["K3s (경량)"] + direction TB + E2["SQLite
단일 파일 DB
단일 바이너리 내장
수 MB 메모리"] + C2["없음
클라우드 연동 코드 제거"] + S2["Local Path Provisioner
기본 탑재, 간단한 로컬 볼륨"] + end + + K8S -->|"이것이 100MB 바이너리 하나로
압축된 것이 K3s"| K3S +``` + +| 차이점 | K8s | K3s | +|---|---|---| +| 상태 저장소 | **etcd** — 분산 합의 알고리즘(Raft)을 자체 구현한 별도 프로세스. HA를 위해 최소 3대 클러스터 필요 | **SQLite** — 파일 하나(`/var/lib/rancher/k3s/server/db/state.db`)로 상태를 저장. 프로세스 추가 없음 | +| 바이너리 크기 | 여러 바이너리 합계 수백 MB | **단일 바이너리 약 100MB** (API Server, Scheduler, Controller Manager, kubelet, kube-proxy 전부 포함) | +| 네트워크 플러그인(CNI) | 별도 설치 필요 | **Flannel 기본 탑재** (설치 없이 바로 Pod 간 통신 가능) | +| 인그레스 컨트롤러 | 별도 설치 (NGINX, Traefik 등) | **Traefik 기본 탑재** | +| 자원 요구량 | Control Plane 최소 2GB RAM | **최소 512MB RAM** | + +PPT에서 "K3s가 etcd 대신 SQLite를 쓰기 때문에 가볍다"고 설명했습니다. 좀 더 깊이 들어가면, etcd는 **여러 노드 간의 데이터 일관성을 보장**하기 위해 Raft 합의 알고리즘을 돌립니다. 3대의 etcd 노드가 "이 데이터를 저장할게"라고 합의하는 과정 자체가 CPU와 메모리를 많이 씁니다. + +K3s의 SQLite는 이런 분산 합의가 **없습니다**. 파일 하나에 기록할 뿐입니다. 대신 트레이드오프가 있습니다. **하나의 Control Plane이 죽으면 상태 저장소가 함께 날아갑니다.** 이것이 K3s가 "실험/개발 환경, IoT 엣지 디바이스"에 적합하고, "대규모 운영 인프라"에는 풀 K8s를 쓰는 이유입니다. + +(다만 K3s도 HA를 원하면 SQLite 대신 외부 DB(MySQL, PostgreSQL 등)를 상태 저장소로 쓸 수 있습니다.) + +#### 이 주제에서 스스로 던져봐야 할 질문들 + +- etcd의 Raft 합의 알고리즘은 왜 노드가 최소 3대 필요한가? 2대면 안 되는 이유는? (힌트: 과반수 투표) +- K3s에서 SQLite를 쓰면 HA 구성이 불가능한가? 가능하다면 어떤 방식인가? +- Reconciliation Loop는 얼마나 자주 도는가? 1초마다? 이벤트가 있을 때만? +- 우리 프로젝트의 `vault-deployment.yaml`에 `strategy: Recreate`로 설정한 이유는 무엇인가? RollingUpdate와 무엇이 다른가? (힌트: 볼륨 동시 마운트 문제) +- API Server가 다운되면 이미 돌고 있는 Pod들은 어떻게 되는가? 바로 죽는가? + +### 25-2. 복잡한 K8s 네트워크의 마법 (CNI와 DNS) + +#### "Service"란 정확히 무엇인가 — 추상적 설명을 넘어서 + +Part I에서 Service를 "Pod 앞에 놓는 고정된 네트워크 진입점"이라고 설명했습니다. 하지만 이것만으로는 **Service가 별도의 프로세스인지, 가상의 설정인지, 물리적으로 어디에 존재하는지** 감이 안 옵니다. + +답부터 말하면: **Service는 독립적인 프로세스나 컨테이너가 아닙니다.** Service는 **kube-proxy가 각 노드의 iptables(또는 IPVS)에 기록해놓은 네트워크 규칙(라우팅 테이블)**입니다. + +이것을 실제 동작으로 풀어보겠습니다. + +```mermaid +sequenceDiagram + participant APP as auth-server Pod
(다른 Pod에서 vault를 호출) + participant IPTABLES as iptables 규칙
(kube-proxy가 관리) + participant POD as vault Pod
(실제 컨테이너) + + Note over APP: configmap에 적힌 주소:
vault.vault.svc.cluster.local:8200 + APP->>APP: DNS 조회: vault.vault.svc.cluster.local
→ CoreDNS가 10.43.x.x (Service의 ClusterIP) 반환 + APP->>IPTABLES: 10.43.x.x:8200으로 패킷 전송 + Note over IPTABLES: kube-proxy가 미리 심어놓은 규칙:
"10.43.x.x:8200 → 실제 Pod IP 10.42.y.y:8200" + IPTABLES->>POD: 실제 Pod의 IP(10.42.y.y:8200)로 전달 + POD-->>APP: 응답 반환 +``` + +이 그림을 단계별로 설명하면 이렇습니다. + +**1단계: Service 생성 시** — `vault-service.yaml`을 `kubectl apply`하면 API Server가 이것을 etcd(K3s는 SQLite)에 기록합니다. 이 순간 Kubernetes가 Service에 **ClusterIP**(예: 10.43.x.x)라는 가상 IP를 할당합니다. 이 IP는 **어떤 노드에도 실제로 바인딩되어 있지 않은 가상 주소**입니다. + +**2단계: kube-proxy가 규칙 설정** — 각 노드의 kube-proxy가 Watch 스트림으로 "새 Service가 생겼다"는 이벤트를 받습니다. kube-proxy는 자기 노드의 **iptables에 규칙을 추가**합니다. "10.43.x.x:8200으로 가는 패킷은 → 실제 Pod IP 10.42.y.y:8200으로 보내라." + +**3단계: Pod가 Service를 호출할 때** — auth-server Pod가 `vault.vault.svc.cluster.local:8200`으로 요청을 보내면, DNS가 이것을 ClusterIP(10.43.x.x)로 해석합니다. 패킷이 노드의 네트워크 스택을 통과할 때, **iptables 규칙에 의해** 실제 Pod IP로 변환됩니다. + +그래서 "Service가 Pod 앞에 놓여있다"는 비유적 표현이고, 실제로는 **각 노드의 iptables 규칙이 트래픽을 중계하는 것**입니다. Service라는 별도 프로세스가 떠서 트래픽을 통과시키는 것이 아닙니다. + +이 프로젝트의 `infra/vault/base/vault-service.yaml`을 보면: + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: vault # 이 이름이 DNS 이름의 일부가 됨 +spec: + selector: + app: vault # label이 "app: vault"인 Pod로 트래픽을 연결 + ports: + - name: http + port: 8200 # Service가 받는 포트 + targetPort: 8200 # Pod가 실제로 리스닝하는 포트 + type: ClusterIP # 클러스터 내부에서만 접근 가능 +``` + +`selector: app: vault`가 핵심입니다. kube-proxy는 `app: vault` 라벨이 달린 **모든 Pod의 IP**를 수집해서 iptables 규칙에 등록합니다. Pod가 죽으면 규칙에서 제거하고, 새 Pod가 뜨면 규칙에 추가합니다. 이것이 "고정된 진입점"이라고 불리는 이유입니다. **Pod는 죽고 태어나면 IP가 바뀌지만, Service의 ClusterIP와 DNS 이름은 바뀌지 않습니다.** + +#### CNI: Pod에 IP를 어떻게 부여하는가 + +각 Pod에는 고유한 IP 주소가 있습니다. 하지만 Pod는 가상의 컨테이너입니다. 물리 서버처럼 NIC(네트워크 카드)가 있는 것이 아닙니다. 그러면 IP는 어떻게 생기는 걸까요? + +이것을 담당하는 것이 **CNI(Container Network Interface)** 플러그인입니다. + +K3s는 기본으로 **Flannel**이라는 CNI를 탑재하고 있습니다. Flannel은 **VXLAN 오버레이 네트워크**를 만듭니다. + +쉽게 비유하면, 물리적으로 다른 건물(노드)에 있는 사무실(Pod)들을 마치 같은 내부 네트워크에 있는 것처럼 **가상 터널**로 연결하는 것입니다. + +```mermaid +flowchart TB + subgraph Node1["Worker Node 1 (물리 IP: 192.168.1.10)"] + direction TB + P1["auth-server Pod
Pod IP: 10.42.0.5"] + P2["migration Job Pod
Pod IP: 10.42.0.6"] + F1["Flannel
VXLAN 터널 엔드포인트"] + end + + subgraph Node2["Worker Node 2 (물리 IP: 192.168.1.11)"] + direction TB + P3["vault Pod
Pod IP: 10.42.1.3"] + P4["postgres Pod
Pod IP: 10.42.1.4"] + F2["Flannel
VXLAN 터널 엔드포인트"] + end + + F1 <-->|"VXLAN 터널
Pod 패킷을 캡슐화해서
물리 네트워크 위로 전달"| F2 + P1 -.-|"10.42.0.5 → 10.42.1.3
다른 노드지만
직접 통신 가능"| P3 +``` + +auth-server Pod(10.42.0.5)가 vault Pod(10.42.1.3)로 패킷을 보내면: +1. 패킷이 Flannel의 VXLAN 인터페이스에 도착합니다. +2. Flannel이 이 패킷을 **UDP로 캡슐화**(원래 패킷을 외부 패킷 안에 넣음)합니다. +3. 물리 네트워크(192.168.1.10 → 192.168.1.11)를 통해 상대 노드로 전달합니다. +4. 상대 노드의 Flannel이 캡슐을 벗기고 vault Pod에게 전달합니다. + +이것이 **다른 물리 서버에 있는 Pod들이 마치 같은 네트워크에 있는 것처럼** 통신할 수 있는 원리입니다. + +#### CoreDNS: vault.vault.svc.cluster.local은 누가 해석하는가 + +이 프로젝트에서는 `vault.vault.svc.cluster.local`이라는 DNS 이름이 여기저기 등장합니다. + +이 이름의 구조를 분해하면: + +| 부분 | 의미 | +|---|---| +| `vault` | Service의 이름 (`metadata.name: vault`) | +| `vault` | Service가 속한 Namespace | +| `svc` | "이것은 Service의 DNS다"라는 고정 접미사 | +| `cluster.local` | 클러스터의 기본 도메인 | + +이 이름을 IP 주소로 변환하는 것은 **CoreDNS**라는 Pod입니다. CoreDNS는 K3s(그리고 K8s)가 기본으로 띄우는 **클러스터 내부 DNS 서버**입니다. + +```mermaid +flowchart LR + A["auth-server Pod가
vault.vault.svc.cluster.local
을 호출"] --> B["Pod의 /etc/resolv.conf에
CoreDNS IP가 적혀있음"] + B --> C["CoreDNS Pod가
K8s API로부터
Service 목록을 Watch"] + C --> D["vault Service의
ClusterIP: 10.43.x.x
를 응답"] + D --> E["iptables가
10.43.x.x를
실제 Pod IP로 변환"] +``` + +모든 Pod 안에는 `/etc/resolv.conf` 파일이 자동으로 생성되고, 여기에 CoreDNS의 IP가 적혀 있습니다. 그래서 Pod 안에서 `vault.vault.svc.cluster.local`을 호출하면 자동으로 CoreDNS에게 물어보게 됩니다. + +`vault.hcl` 설정 파일에서 이 DNS 의존성이 직접 드러납니다: + +```hcl +api_addr = "http://vault.vault.svc.cluster.local:8200" +cluster_addr = "http://vault.vault.svc.cluster.local:8201" +``` + +만약 CoreDNS가 죽으면? DNS 이름 해석이 안 되니, 이 주소로의 통신이 전부 실패합니다. 이미 해석된 IP가 캐시에 있으면 잠시 동안은 되겠지만, 캐시 TTL이 만료되면 끝입니다. + +#### 이 주제에서 스스로 던져봐야 할 질문들 + +- `type: ClusterIP`와 `type: NodePort`, `type: LoadBalancer`의 차이는 무엇인가? 이 프로젝트에서 왜 `ClusterIP`만 쓰는가? +- Flannel(VXLAN)과 Calico(BGP/eBPF)의 핵심 차이는 무엇인가? 왜 K3s는 Flannel을 기본으로 택했는가? +- CoreDNS의 캐시 TTL이 30초인데, Pod가 20초 만에 죽고 새 Pod가 새 IP로 뜨면 DNS 캐시 때문에 오래된 IP로 가지 않는가? +- `vault-service.yaml`에서 `port: 8200`과 `targetPort: 8200`이 같은데, 이 둘을 다르게 설정하면 어떤 일이 생기는가? 언제 다르게 쓰는가? +- NetworkPolicy를 설정하면 iptables 규칙이 바뀌는가, 아니면 CNI 수준에서 별도 방화벽이 생기는가? (힌트: CNI마다 다르다) + +### 25-3. 깡통 서버(Stateless)와 볼륨 클레임(PVC) + +#### Stateless: 컨테이너가 죽으면 내부 데이터는 전부 사라진다 + +K8s에서 컨테이너는 **깡통**입니다. 안에 뭘 저장하든, 컨테이너가 삭제되면 전부 사라집니다. + +이것이 왜 **장점**인지 직관적이지 않을 수 있습니다. "데이터가 사라지면 안 되지 않나?" + +장점은 **교체가 자유롭다**는 것입니다. auth-server Pod가 죽으면 K8s가 새 Pod를 만들어서 교체합니다. 이전 Pod의 "더러운" 상태(메모리 누수, 잘못된 임시 파일 등)가 깨끗이 청소되고, 새 Pod는 완전히 깨끗한 상태에서 시작합니다. 서버를 "수리"하는 것이 아니라 "교체"하는 것입니다. 이것을 **Cattle, not Pets(가축이지 애완동물이 아니다)** 패턴이라고 부릅니다. + +하지만 **반드시 데이터를 유지해야 하는 워크로드**가 있습니다: +- **PostgreSQL**: DB 데이터가 사라지면 끝장입니다. +- **Vault**: Raft 저장소에 seal/unseal 상태와 secret이 저장되어 있습니다. + +이런 워크로드를 위해 **PVC(PersistentVolumeClaim)**가 존재합니다. + +#### PVC → PV → StorageClass: 볼륨이 생기는 메커니즘 + +PVC를 이해하려면 세 가지 개념 사이의 관계를 알아야 합니다. + +```mermaid +flowchart LR + subgraph 사용자_요청["개발자가 선언하는 것"] + PVC["PVC
(PersistentVolumeClaim)
'5GB 볼륨 하나 주세요'"] + end + + subgraph 중간_매개["K8s가 처리하는 것"] + SC["StorageClass
'볼륨을 어떤 방식으로
만들지 정의한 템플릿'"] + end + + subgraph 실제_저장소["실제로 생성되는 것"] + PV["PV
(PersistentVolume)
'실제 5GB 디스크 공간'"] + DISK["노드의 로컬 디스크
또는 NFS/클라우드 EBS"] + end + + PVC -->|"① '5GB 주세요'
StorageClass 참조"| SC + SC -->|"② Provisioner가
실제 볼륨 생성"| PV + PV -->|"③ 바인딩 완료"| PVC + PV -->|"실제 데이터 저장"| DISK +``` + +비유하자면: +- **PVC**는 "5GB짜리 USB 하나 주세요"라는 **요청서** +- **StorageClass**는 "USB는 삼성 제품으로, SSD로 만들겠다"는 **제조 사양서** +- **PV**는 실제로 만들어진 **USB 그 자체** + +이 프로젝트의 `infra/vault/base/vault-pvc.yaml`을 보면: + +```yaml +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: vault-data # 이 이름으로 Deployment에서 참조 +spec: + accessModes: + - ReadWriteOnce # 한 번에 하나의 노드만 읽기/쓰기 가능 + resources: + requests: + storage: 5Gi # 5GB 요청 +``` + +`ReadWriteOnce`는 **이 볼륨을 동시에 두 노드에서 마운트할 수 없다**는 뜻입니다. 하나의 노드에서만 읽고 쓸 수 있습니다. + +이것이 `vault-deployment.yaml`에서 `strategy: Recreate`을 쓰는 이유와 직결됩니다. + +```yaml +# vault-deployment.yaml +spec: + strategy: + type: Recreate # 기존 Pod를 먼저 죽이고 새 Pod를 띄움 +``` + +만약 `RollingUpdate`(기본값)를 쓰면, 새 Pod가 먼저 뜨고 나서 기존 Pod를 죽입니다. 그런데 `ReadWriteOnce` 볼륨은 동시에 두 Pod가 마운트할 수 없으니, 새 Pod는 볼륨을 마운트하지 못하고 **Pending 상태에서 영원히 멈춥니다.** 기존 Pod는 "새 Pod가 Ready가 될 때까지 죽지 마라"고 기다리니, 양쪽 다 데드락에 빠집니다. + +`Recreate`은 이 문제를 막습니다. 기존 Pod를 **먼저 완전히 종료**시키고, 볼륨 마운트가 해제된 다음에야 새 Pod를 띄웁니다. 대신 업데이트 동안 **잠깐 서비스가 중단**됩니다. Vault는 이 짧은 다운타임을 감수할 수 있으니 이 전략을 택한 것입니다. + +#### 이 주제에서 스스로 던져봐야 할 질문들 + +- `ReadWriteOnce`와 `ReadWriteMany`, `ReadOnlyMany`의 차이는 무엇인가? PostgreSQL은 왜 `ReadWriteOnce`를 쓰는가? +- PVC를 실수로 `kubectl delete pvc`로 삭제하면 데이터가 바로 날아가는가? (힌트: `reclaimPolicy`에 따라 다르다 — `Retain` vs `Delete`) +- K3s의 기본 StorageClass인 `local-path-provisioner`는 어떻게 동작하는가? 노드의 어느 디렉터리에 데이터가 저장되는가? +- StatefulSet과 Deployment의 차이는 무엇인가? PostgreSQL은 왜 StatefulSet을 쓰는가? (힌트: Pod 이름 안정성, 볼륨 1:1 대응) +- `strategy: Recreate` 동안 Vault가 잠깐 죽는데, 이 시간 동안 auth-server의 JWT 서명은 어떻게 되는가? + +## 26. HashiCorp Vault Secret 주입 딥다이브 + +### 26-1. Vault 아키텍처: Seal/Unseal과 Storage Backend + +#### Vault는 왜 "봉인(Sealed)" 상태로 시작하는가 + +Vault를 처음 접하면 혼란스러운 개념이 **Sealed/Unsealed** 상태입니다. + +보통의 서비스는 프로세스가 뜨면 바로 사용 가능합니다. 하지만 Vault는 **프로세스가 떠도 "봉인" 상태**이면 아무 요청도 처리하지 못합니다. 모든 읽기/쓰기가 거부됩니다. + +왜 이렇게 설계했을까요? + +Vault가 저장하는 것은 DB 비밀번호, API 키, 암호화 키 등 **최고 기밀 정보**입니다. 이 정보들은 디스크에 **암호화되어** 저장됩니다. Vault가 기동될 때, 이 암호화된 데이터를 복호화하려면 **마스터 키**가 필요합니다. + +Sealed 상태란 **이 마스터 키가 메모리에 없는 상태**입니다. 디스크에 암호화된 데이터가 있지만, 열쇠가 없어서 읽을 수 없습니다. + +```mermaid +flowchart LR + subgraph SEALED["Sealed 상태 (기동 직후)"] + direction TB + S1["디스크: 암호화된 secret들
🔒 잠겨있음"] + S2["메모리: 마스터 키 없음
❌ 복호화 불가"] + S3["모든 API 요청 → 503 거부"] + end + + subgraph UNSEAL["Unseal 과정"] + direction TB + U1["마스터 키를
메모리에 올림"] + end + + subgraph UNSEALED["Unsealed 상태"] + direction TB + US1["디스크: 여전히 암호화 상태"] + US2["메모리: 마스터 키 보유
✅ 요청 시 복호화 가능"] + US3["모든 API 요청 → 정상 처리"] + end + + SEALED -->|"Unseal 과정
(키 제공)"| UNSEAL + UNSEAL --> UNSEALED +``` + +중요한 것은 **디스크의 데이터는 Unsealed 후에도 암호화 상태**라는 점입니다. 마스터 키는 오직 메모리에만 존재합니다. Vault 프로세스가 재시작되면 메모리가 초기화되니, 다시 Sealed 상태로 돌아갑니다. + +#### 이 프로젝트의 Auto-Unseal: Transit 방식 + +사람이 매번 수동으로 unseal 하는 것은 실무에서 불가능합니다. Vault가 Pod 재시작될 때마다 24시간 대기하고 있을 수 없으니까요. + +이 프로젝트에서는 **Transit Auto-Unseal**을 사용합니다. 또 다른 Vault(Provider Vault, 이 프로젝트에서는 vault-transit)가 마스터 키의 암호화/복호화를 대행하는 패턴입니다. + +```mermaid +sequenceDiagram + participant WV as Workload Vault
(우리가 쓰는 Vault) + participant PV as Provider Vault
(vault-transit) + + Note over WV: Pod 기동됨 → Sealed 상태 + WV->>WV: 디스크에서 암호화된
마스터 키를 읽음 + WV->>PV: "이 암호화된 마스터 키를
Transit 엔진으로 복호화해줘" + Note over PV: Transit 키 "workload-vault-dev-unseal"로
복호화 수행 + PV-->>WV: 복호화된 마스터 키 반환 + WV->>WV: 마스터 키를 메모리에 올림
→ Unsealed 상태 전환! + Note over WV: 이제 모든 secret 읽기/쓰기 가능 +``` + +이 구조가 `vault.hcl`에 선언되어 있습니다: + +```hcl +seal "transit" { + address = "http://vault-transit.vault-transit.svc.cluster.local:8200" + disable_renewal = "false" + key_name = "workload-vault-dev-unseal" + mount_path = "transit/" + tls_skip_verify = "true" +} +``` + +- `address`: Provider Vault의 주소입니다. K8s DNS 이름으로 접근합니다. +- `key_name: "workload-vault-dev-unseal"`: Provider Vault의 Transit 엔진에 있는 암호화 키 이름입니다. 이 키가 Workload Vault의 마스터 키를 암호화/복호화합니다. + +여기서 의문이 생깁니다. Workload Vault가 Provider Vault에게 요청을 보내려면 **인증 토큰**이 필요합니다. 이 토큰은 어디서 올까요? + +`vault-deployment.yaml`에서 확인할 수 있습니다: + +```yaml +env: + - name: VAULT_TOKEN + valueFrom: + secretKeyRef: + name: vault-transit-seal # K8s Secret에 저장된 + key: VAULT_TRANSIT_SEAL_TOKEN # Provider Vault의 제한된 토큰 +``` + +이 토큰은 **Provider Vault의 Transit 엔진 사용 권한만** 가진 제한적 토큰입니다. 탈취되어도 할 수 있는 건 "unseal 키 복호화"뿐이며, Vault 안의 secret을 직접 읽는 것은 불가능합니다. 이것이 PPT에서 "토큰의 권한을 최소한으로"라고 언급한 부분입니다. + +#### Raft Storage: Vault의 내부 저장소 + +`vault.hcl`의 다른 섹션을 보면: + +```hcl +storage "raft" { + path = "/vault/data" + node_id = "vault-dev-0" +} +``` + +**Raft**는 분산 합의 알고리즘입니다 (25장의 etcd에서 나온 그것과 같은 알고리즘). Vault는 별도의 외부 DB를 쓰지 않고, **자체적으로 Raft를 돌려서 데이터를 저장**합니다. + +현재 단일 노드(`node_id: vault-dev-0`)로 운영하고 있으니 합의 과정은 사실상 "혼자 결정"이지만, 노드를 추가하면 HA 구성이 가능합니다. + +`/vault/data` 경로는 PVC(`vault-data`)에 마운트되어 있어서, Pod가 재시작되어도 데이터가 유지됩니다. + +#### 이 주제에서 스스로 던져봐야 할 질문들 + +- Provider Vault(vault-transit)도 재시작되면 누가 unseal하는가? 무한 재귀 아닌가? (힌트: Provider Vault는 수동 unseal 또는 별도의 auto-unseal 메커니즘 사용) +- Raft 단일 노드에서 장애가 나면 복구 방법은? (힌트: PVC에 저장된 Raft 스냅샷) +- `vault-transit-seal` K8s Secret이 바로 K8s Secret에 저장된다는 게 딜레마라고 PPT에서 언급했는데, 현업에서는 이걸 어떻게 해결하는가? (힌트: 클라우드의 KMS나 HSM) +- `tls_skip_verify: "true"`는 왜 설정했는가? 운영 환경에서도 이렇게 하면 안 되는 이유는? +- Vault의 Sealed 상태에서 readinessProbe가 fail하면 K8s는 어떻게 반응하는가? + +### 26-2. Vault Agent Injector의 Secret 주입 전체 과정 + +#### Pod에 Secret이 들어가는 메커니즘: Mutating Webhook + +`deployment.vault-patch.yaml`에 `vault.hashicorp.com/agent-inject: "true"` 어노테이션을 달면 마법처럼 secret이 Pod에 들어갑니다. 하지만 이것은 마법이 아닙니다. **K8s의 Mutating Admission Webhook** 메커니즘이 동작하는 것입니다. + +이것이 무엇인지 단계별로 풀어보겠습니다. + +```mermaid +sequenceDiagram + participant USER as kubectl apply
(또는 Argo CD) + participant API as K8s API Server + participant INJECTOR as Vault Agent Injector
(Webhook 서버) + participant KUBELET as kubelet + participant AGENT as Vault Agent
(사이드카 컨테이너) + participant VAULT as Vault Server + + USER->>API: "auth-server Pod를 만들어줘" + API->>API: 어노테이션 확인:
vault.hashicorp.com/agent-inject: "true" + API->>INJECTOR: "이 Pod 정의를 보내는데,
수정할 게 있으면 수정해줘" + + Note over INJECTOR: Pod 정의를 분석
vault 관련 어노테이션 발견 + INJECTOR->>INJECTOR: Pod 정의에 사이드카 컨테이너
(Vault Agent) 추가 + INJECTOR->>INJECTOR: 공유 볼륨
(/vault/secrets) 추가 + INJECTOR-->>API: 수정된 Pod 정의 반환 + + API->>KUBELET: 수정된 Pod를 생성하라 + KUBELET->>KUBELET: 원래 컨테이너 + Vault Agent 사이드카 함께 실행 + + AGENT->>VAULT: K8s ServiceAccount JWT로 인증 요청 + VAULT->>VAULT: K8s API에 "이 JWT 진짜야?" 확인 + VAULT-->>AGENT: Vault 토큰 발급 + AGENT->>VAULT: 토큰으로 secret 요청
"kv/data/dev/platform/postgres/auth-server" + VAULT-->>AGENT: secret 데이터 반환 + AGENT->>AGENT: Go 템플릿으로 렌더링
→ /vault/secrets/runtime-env 파일 생성 + + Note over KUBELET: auth-server 컨테이너가
/vault/secrets/runtime-env 파일을
읽어서 환경변수로 로드 +``` + +핵심 메커니즘: + +**1. Mutating Webhook** — K8s API Server는 Pod가 생성되기 전에 등록된 Webhook 서버에게 "이 Pod 정의를 수정할 기회를 줄게"라고 보냅니다. Vault Agent Injector가 바로 이 Webhook 서버입니다. Injector는 vault 어노테이션이 있는 Pod 정의에 **사이드카 컨테이너(Vault Agent)**와 **공유 볼륨(/vault/secrets)**을 자동으로 추가합니다. + +**2. K8s Auth Handshake** — Vault Agent는 Pod 안에 자동으로 마운트된 **K8s ServiceAccount의 JWT 토큰**을 들고 Vault에 인증합니다. Vault는 이 JWT가 진짜인지 **K8s API Server에 직접 물어서** 확인합니다. 진짜라면 해당 ServiceAccount에 매핑된 policy에 따라 Vault 토큰을 발급합니다. + +`terraform/vault/reconcile/main.tf`에서 이 매핑이 선언되어 있습니다: + +```hcl +resource "vault_kubernetes_auth_backend_role" "auth_server" { + bound_service_account_names = ["auth-server"] # 이 SA만 + bound_service_account_namespaces = ["auth-dev"] # 이 네임스페이스에서만 + role_name = "auth-server-dev" + token_policies = ["auth-server-dev"] # 이 policy의 권한만 부여 +} +``` + +이것은 "auth-dev 네임스페이스의 auth-server ServiceAccount를 가진 Pod만 `auth-server-dev` 정책으로 Vault를 사용할 수 있다"는 뜻입니다. 다른 네임스페이스의 Pod가 같은 이름의 ServiceAccount를 가져도 **접근 불가**입니다. + +**3. Template Rendering** — `deployment.vault-patch.yaml`의 이 부분: + +```yaml +vault.hashicorp.com/agent-inject-template-runtime-env: | + {{ with secret "kv/data/dev/platform/postgres/auth-server" }} + export APP_DATASOURCE_USERNAME={{ printf "%q" .Data.data.APP_DATASOURCE_USERNAME }} + export APP_DATASOURCE_PASSWORD={{ printf "%q" .Data.data.APP_DATASOURCE_PASSWORD }} + {{ end }} +``` + +이것은 **Go 템플릿** 문법입니다. Vault Agent가 secret 데이터를 받아서, 이 템플릿에 맞춰 `/vault/secrets/runtime-env` 파일을 생성합니다. `printf "%q"`는 값을 따옴표로 감싸서 셸에서 안전하게 쓸 수 있도록 이스케이프하는 함수입니다. + +결과 파일은 이런 형태가 됩니다: +```bash +export APP_DATASOURCE_USERNAME="auth_user" +export APP_DATASOURCE_PASSWORD="s3cret!p@ss" +``` + +auth-server 컨테이너의 시작 명령이 `. /vault/secrets/runtime-env`로 이 파일을 source하면, 환경변수로 로드됩니다. + +#### Sidecar vs Pre-Populate-Only: Deployment용과 Job용의 차이 + +| | Deployment (auth-server) | Job (db-migration) | +|---|---|---| +| 어노테이션 | `agent-inject: "true"` (기본값) | `agent-pre-populate-only: "true"` | +| Vault Agent 수명 | **sidecar로 계속 살아있음** | **init container로 한 번 실행 후 종료** | +| Secret 갱신 | 주기적으로 Vault에 재요청하여 파일 갱신 가능 | 불가 (한 번 생성하고 끝) | +| 적합한 워크로드 | 오래 실행되는 서비스. Secret rotation 시 파일이 자동 갱신됨 | 짧게 실행되고 끝나는 작업. sidecar가 계속 떠 있으면 Job이 "완료"로 전환 안 됨 | + +Job에서 `agent-pre-populate-only: "true"`를 안 쓰면 어떻게 될까요? Vault Agent sidecar가 계속 살아있으니까, 메인 컨테이너(migration)가 끝나도 **Pod가 Completed 상태로 전환되지 않습니다.** Job은 Pod가 완료되어야 성공으로 간주하는데, sidecar가 죽지 않으니 Job이 영원히 Running 상태에 머물러서 배포가 멈춥니다. + +#### 이 주제에서 스스로 던져봐야 할 질문들 + +- `vault.hashicorp.com/agent-inject: "true"` 어노테이션이 필요한 것 외에, 네임스페이스에 어떤 라벨이 있어야 Injector가 동작하는가? (힌트: `vault-injection: enabled`) +- Vault Agent가 secret rotation을 감지하면 파일은 갱신되지만, 앱 프로세스는 파일을 다시 읽지 않으면 반영이 안 된다. 이것을 어떻게 해결하는가? +- `printf "%q"`를 안 쓰고 그냥 `.Data.data.PASSWORD`를 출력하면 어떤 문제가 생기는가? (힌트: 비밀번호에 특수문자가 있으면 셸 해석 문제) +- `automountServiceAccountToken: true`가 왜 Vault Agent가 있는 Pod에서 필수인가? +- 이 프로젝트에서 `vault.hashicorp.com/agent-inject-token: "true"` 어노테이션은 왜 auth-server에만 있고 migration Job에는 없는가? + +### 26-3. Transit Engine: JWT 서명과 Auto-Unseal의 이중 역할 + +#### Transit Engine이란: "키를 꺼내주지 않고 대신 연산해주는 서비스" + +일반적인 암호화에서는 키를 가져와서 내 코드에서 직접 암호화/복호화를 합니다. 하지만 이 방식은 키가 애플리케이션 메모리에 올라오니, 메모리 덤프 등으로 키가 유출될 위험이 있습니다. + +Vault의 **Transit Engine**은 다릅니다. **키가 Vault 밖으로 절대 나가지 않습니다.** 대신 "이 데이터를 암호화해줘", "이 데이터에 서명해줘"라고 **API로 요청**하면, Vault가 내부에서 키를 써서 결과만 돌려줍니다. + +```mermaid +flowchart LR + subgraph 위험한_방식["일반적인 방식 (키 유출 위험)"] + A1["앱이 키를 다운로드"] --> A2["앱 메모리에 키 올림"] --> A3["앱이 직접 서명"] + end + + subgraph 안전한_방식["Transit Engine 방식"] + B1["앱이 Vault API 호출
'이 데이터에 서명해줘'"] --> B2["Vault가 내부에서
키로 서명 수행"] --> B3["서명 결과만 반환
키는 Vault 밖으로 안 나감"] + end +``` + +#### 이 프로젝트에서 Transit의 두 가지 역할 + +Transit Engine은 이 프로젝트에서 **두 가지 완전히 다른 목적**으로 사용됩니다. + +**역할 1: auth-server의 JWT 서명** + +auth-server가 사용자에게 JWT 토큰을 발급할 때, RSA 개인키로 서명해야 합니다. 이 키를 auth-server의 메모리에 올리는 대신, **Vault Transit에게 서명을 위임**합니다. + +`runbooks/vault/dev/policies/auth-server-dev.hcl`: +```hcl +path "transit/keys/project-auth-jwt" { + capabilities = ["read"] # 공개키 읽기 (JWT 검증용) +} + +path "transit/sign/project-auth-jwt" { + capabilities = ["update"] # 서명 요청 (JWT 발급용) +} +``` + +auth-server는 `transit/sign/project-auth-jwt`로 "이 JWT 페이로드에 서명해줘"라고 요청합니다. Vault가 `project-auth-jwt`라는 RSA 키로 서명한 결과를 돌려줍니다. auth-server는 개인키를 **한 번도 본 적이 없습니다.** + +**역할 2: Workload Vault의 Auto-Unseal** + +26-1에서 설명한 것처럼, Provider Vault(vault-transit)의 Transit Engine이 Workload Vault의 마스터 키를 암호화/복호화합니다. Transit 키 이름은 `workload-vault-dev-unseal`입니다. + +두 역할을 그림으로 보면: + +```mermaid +flowchart TB + subgraph TRANSIT["Provider Vault의 Transit Engine"] + direction TB + K1["키: project-auth-jwt
(RSA 키, JWT 서명용)"] + K2["키: workload-vault-dev-unseal
(AES 키, Unseal용)"] + end + + AUTH["auth-server Pod"] -->|"'이 JWT에 서명해줘'
transit/sign/project-auth-jwt"| K1 + VAULT["Workload Vault"] -->|"'이 마스터키 복호화해줘'
transit/decrypt/workload-vault-dev-unseal"| K2 +``` + +같은 Transit Engine이지만, **다른 키를 사용해서 완전히 다른 목적**으로 쓰이고 있습니다. `terraform/vault-transit/reconcile/main.tf`(또는 `dev/main.tf`)에서 이 키들이 Terraform으로 선언되어 있습니다. + +#### Transit 키 Rotation: 기존 JWT는 검증 실패하는가? + +Transit 키를 rotation(교체)하면 새 버전의 키가 생깁니다. Vault Transit은 **키 버전 관리**를 합니다. + +- **서명**: 항상 **최신 버전**의 키로 서명합니다. +- **검증**: 서명에 포함된 키 버전 정보를 보고, **해당 버전**의 키로 검증합니다. + +즉, 키를 rotation해도 **이전 버전의 키가 삭제되지 않으면** 기존 JWT 검증은 계속 성공합니다. 이전 버전을 명시적으로 "min_decryption_version"으로 제한하기 전까지는 안전합니다. + +#### 이 주제에서 스스로 던져봐야 할 질문들 + +- Transit Engine으로 JWT 서명을 하면 auth-server가 Vault에 의존하게 됩니다. Vault가 다운되면 JWT 발급이 불가한데, 이 단일 장애점(SPOF)을 어떻게 완화할 수 있는가? +- `transit/keys/project-auth-jwt`에 `read` 권한을 준 이유는? read로 무엇을 가져오는가? (힌트: 공개키를 가져와서 JWT 검증에 사용) +- Transit 키의 `min_encryption_version`과 `min_decryption_version`을 올리면 되돌릴 수 없다. 왜 위험한가? +- auth-server가 Vault API를 호출하는 것과 직접 메모리에서 키로 서명하는 것의 **성능 차이**는 어느 정도인가? 이 트레이드오프는 어떤 상황에서 정당화되는가? +- Provider Vault와 Workload Vault를 왜 분리했는가? 하나의 Vault에서 모든 것을 하면 안 되는 이유는? + +## 27. Terraform IaC와 Bash 자동화 심층 해부 + +### 27-1. Terraform의 State 관리와 Plan/Apply 사이클 + +#### Terraform은 대체 무엇을 하는 도구인가 + +Kubernetes에서 `kubectl apply -f deployment.yaml`을 하면, K8s API Server가 "이 Deployment를 만들어라"라는 지시를 받고 처리합니다. Terraform도 **비슷한 역할**을 하지만, 대상이 K8s가 아닙니다. + +Terraform은 **인프라를 코드로 선언하고, 그 코드의 상태를 추적하면서, 변경이 필요한 부분만 자동으로 적용**하는 도구입니다. + +이 프로젝트에서 Terraform이 관리하는 것들은: +- Vault의 **policy** (누가 어떤 secret을 읽을 수 있는지) +- Vault의 **K8s auth backend role** (어떤 ServiceAccount가 어떤 policy를 받는지) +- Vault의 **KV secret** (Provider Vault에서 Workload Vault로 secret 복사) +- Vault의 **database secret engine** (동적 DB 자격 증명 설정) + +이것들을 사람이 하나씩 `vault` CLI로 수동 설정할 수도 있습니다. 하지만 그러면 **"지금 어디까지 설정했지?"를 기억하는 것이 불가능**합니다. 10개의 policy, 5개의 role, 8개의 secret을 수동으로 관리하면 누락이 생기고, 재현이 안 됩니다. + +#### State 파일: "내가 무엇을 만들었는지" 기억하는 메모장 + +Terraform의 핵심은 **State 파일**입니다. 이것은 "Terraform이 지금까지 무엇을 만들었는지"를 기록한 JSON 파일입니다. + +```mermaid +flowchart TD + subgraph 선언["개발자가 작성한 것 (main.tf)"] + TF["'auth-server-dev policy를 만들어라'
'auth-server K8s auth role을 만들어라'
'postgres secret을 복사해라'"] + end + + subgraph state["State 파일 (.tfstate)"] + ST["'auth-server-dev policy: 만들었음 ✅'
'auth-server K8s auth role: 만들었음 ✅'
'postgres secret: 만들었음 ✅'"] + end + + subgraph 실제["실제 인프라 (Vault)"] + REAL["auth-server-dev policy 존재
auth-server K8s auth role 존재
postgres secret 존재"] + end + + TF -->|"terraform plan
선언 vs State 비교"| ST + ST -->|"terraform apply
차이만 실제에 적용"| REAL + REAL -->|"적용 결과를
State에 기록"| ST +``` + +이 흐름을 구체적으로 설명하면: + +**1. `terraform plan`** — "내가 원하는 상태(main.tf)"와 "지금까지 만든 것(State 파일)"을 비교합니다. 차이가 있으면 "이것을 추가하겠다", "이것을 수정하겠다", "이것을 삭제하겠다"는 계획을 보여줍니다. **아직 아무것도 실행하지 않습니다.** 계획만 보여주는 단계입니다. + +**2. `terraform apply`** — plan에서 나온 차이를 **실제로 적용**합니다. Vault API를 호출해서 policy를 만들고, role을 설정하고, secret을 복사합니다. + +**3. State 업데이트** — apply가 끝나면 "이것을 만들었다"는 기록을 State 파일에 저장합니다. + +다음에 같은 코드로 `terraform apply`를 다시 실행하면, State 파일을 보고 "이미 다 만들어져 있네, 할 일 없음"이라고 판단합니다. **멱등성(idempotency)** — 같은 코드를 여러 번 실행해도 결과가 동일합니다. + +#### State가 손상되거나 사라지면? + +State 파일이 손상되면 Terraform은 **자기가 무엇을 만들었는지 모르는 상태**가 됩니다. 이 상태에서 `terraform apply`를 하면, 이미 존재하는 리소스를 또 만들려고 시도해서 에러가 발생합니다. + +이런 상황을 복구하는 것이 `terraform import`입니다. "이 리소스는 이미 실제로 존재하는데, State에 기록이 없으니 기록해줘"라는 명령입니다. + +이 프로젝트의 `scripts/ci/reconcile-vault-dev.sh`에 이 패턴이 함수로 정의되어 있습니다: + +```bash +ensure_transit_state_resource() { + local address="$1" + local import_id="$2" + + # State에 이 리소스가 있는지 확인 + if ! terraform ... state show "$address" >/dev/null 2>&1; then + # 없으면 import로 기존 리소스를 State에 등록 + log "Importing missing vault-transit state for ${address}" + terraform ... import "$address" "$import_id" + fi +} +``` + +이 함수가 하는 일을 풀어보면: +1. `terraform state show "$address"` — State 파일에서 이 리소스가 기록되어 있는지 확인합니다. +2. 기록이 없으면(`if !`) — `terraform import`로 실제 Vault에 존재하는 리소스를 State에 등록합니다. +3. 기록이 있으면 — 아무것도 안 합니다. + +이것은 **CI 파이프라인이 State 손실에 강해지도록** 만드는 방어적 패턴입니다. CI 환경은 매번 깨끗한 러너에서 실행될 수 있으니, State가 없을 수도 있습니다. 그래도 에러 없이 이어서 할 수 있도록 보장합니다. + +#### Provider 이중 설정: 왜 Vault Provider가 두 개인가 + +`terraform/vault/reconcile/main.tf`의 상단을 보면: + +```hcl +provider "vault" { + address = var.workload_vault_addr # Workload Vault (우리가 쓰는 것) + token = var.workload_vault_token +} + +provider "vault" { + alias = "transit" + address = var.transit_vault_addr # Provider Vault (Transit용) + token = var.transit_vault_token +} +``` + +Terraform의 **하나의 provider 블록은 하나의 서버**에 연결됩니다. 이 프로젝트에는 Vault가 **두 대**(Workload + Transit)이니, provider도 두 개가 필요합니다. + +`alias = "transit"`이 붙은 provider는 Transit Vault에 연결됩니다. 코드에서 `provider = vault.transit`를 지정하면 Transit Vault에 요청을 보내고, 지정하지 않으면 기본 provider(Workload Vault)에 요청을 보냅니다. + +```hcl +# Provider Vault에서 secret 읽기 (transit alias 사용) +data "vault_kv_secret_v2" "provider_postgres_superuser" { + provider = vault.transit # ← Transit Vault에서 읽겠다 + mount = var.seed_kv_mount_path + name = "dev/workload/platform/postgres/superuser" +} + +# Workload Vault에 secret 쓰기 (기본 provider) +resource "vault_kv_secret_v2" "platform_postgres_superuser" { + # provider 지정 없음 → Workload Vault에 쓴다 + mount = var.kv_mount_path + name = "dev/platform/postgres/superuser" + data_json = jsonencode({ + POSTGRES_SUPERUSER_PASSWORD = data.vault_kv_secret_v2.provider_postgres_superuser.data["POSTGRES_SUPERUSER_PASSWORD"] + }) +} +``` + +이 두 블록을 합치면: **Transit Vault에서 secret을 읽어서 → Workload Vault에 복사**하는 것입니다. 이것이 Terraform이 하는 "Secret 브릿징" 역할입니다. + +#### Data Source vs Resource: 읽기 전용과 쓰기의 차이 + +| 키워드 | 의미 | 이 프로젝트 예시 | +|---|---|---| +| `data` | **읽기 전용**. 이미 존재하는 것을 참조만 함. Terraform이 관리하지 않음 | `data "vault_kv_secret_v2"` — Transit Vault의 secret을 **읽기만** | +| `resource` | **생성/수정/삭제**. Terraform이 생명주기를 관리함. State에 기록됨 | `resource "vault_kv_secret_v2"` — Workload Vault에 secret을 **생성** | +| `resource` | | `resource "vault_policy"` — Vault policy를 **생성/업데이트** | +| `resource` | | `resource "vault_kubernetes_auth_backend_role"` — K8s Auth role **생성** | + +`data`로 읽은 값을 `resource`에서 사용하는 것이 **참조 패턴**입니다. Transit Vault에서 `data`로 비밀번호를 읽고, Workload Vault에 `resource`로 복사하는 것이 이 패턴의 전형입니다. + +#### 이 주제에서 스스로 던져봐야 할 질문들 + +- `terraform destroy`를 CI에서 실수로 실행하면 어떻게 되는가? 모든 policy, role, secret이 삭제되면 서비스에 어떤 영향이 있는가? +- State 파일(`.tfstate`)에는 민감한 정보(secret 값 등)가 포함되는가? 그렇다면 이 파일을 어떻게 보호해야 하는가? +- `terraform plan`에서 "변경 없음"이 나왔는데 `terraform apply`를 하면 실제로 변경되는 경우가 있는가? (힌트: provider의 API가 plan 시점과 apply 시점 사이에 바뀌면) +- 이 프로젝트에서 `backend "local"`을 쓰고 있는데, 팀으로 협업할 때는 왜 remote backend(S3, GCS 등)를 써야 하는가? +- `skip_child_token = true`가 provider 설정에 있는 이유는 무엇인가? 이것을 안 쓰면 어떤 문제가 생기는가? + +### 27-2. Bash 방어적 프로그래밍: trap, set, wait + +#### `set -euo pipefail` — 셸 스크립트의 안전벨트 3종 세트 + +이 프로젝트의 모든 Bash 스크립트는 첫 줄 근처에 이것이 있습니다: + +```bash +set -euo pipefail +``` + +이것은 **세 가지 안전장치**를 동시에 거는 것입니다. 각각이 무엇인지, **없으면 어떤 끔찍한 일이 생기는지** 구체적으로 보겠습니다. + +**`-e` (errexit): 에러 발생 시 즉시 종료** + +```bash +# -e 없이 실행하면: +kubectl apply -f wrong-file.yaml # ← 에러 발생! 파일이 없음 +echo "배포 성공!" # ← 이 줄이 실행됨!!! 에러가 무시됨 +vault write secret/data ... # ← 잘못된 상태에서 계속 진행 + +# -e 있으면: +kubectl apply -f wrong-file.yaml # ← 에러 발생! +# 스크립트 즉시 종료. 아래 줄은 실행 안 됨 +``` + +`-e` 없이 스크립트를 짜면, 중간에 에러가 나도 멈추지 않고 **다음 줄로 넘어갑니다.** CI에서 이것은 치명적입니다. 앞 단계가 실패했는데 뒷 단계가 계속 실행되면, 반쪽짜리 인프라가 구성됩니다. + +**`-u` (nounset): 정의되지 않은 변수 사용 시 에러** + +```bash +# -u 없이: +echo "Vault 주소: ${VAULT_ADR}" # ← 오타! VAULT_ADDR가 맞는데 +# → "Vault 주소: " 빈 문자열 출력, 에러 없이 넘어감 +vault login -address="" # ← 빈 주소로 로그인 시도... + +# -u 있으면: +echo "Vault 주소: ${VAULT_ADR}" # ← 에러! "VAULT_ADR: unbound variable" +# 스크립트 즉시 종료. 변수 오타를 바로 잡을 수 있음 +``` + +변수 이름 오타는 누구나 합니다. `-u`가 없으면 오타된 변수가 빈 문자열로 조용히 치환되어, 디버깅하기 극도로 어려운 버그가 됩니다. + +**`-o pipefail`: 파이프라인에서 중간 명령 에러 전파** + +```bash +# pipefail 없이: +vault read secret/data | jq '.data' +# 만약 vault read가 실패해도, jq가 성공(빈 입력에 에러 없이 종료)하면 +# 전체 파이프라인은 "성공"으로 간주됨! + +# pipefail 있으면: +vault read secret/data | jq '.data' +# vault read가 실패하면 → 파이프라인 전체가 실패로 간주 +``` + +`A | B`에서 `-o pipefail` 없이는 **B의 종료 코드만** 확인합니다. A가 실패해도 B가 성공이면 전체가 성공입니다. `pipefail`을 켜면 A, B 중 **하나라도** 실패하면 전체가 실패합니다. + +#### `trap ... EXIT` — 뒷정리 보장 메커니즘 + +`scripts/ci/reconcile-vault-dev.sh`의 `start_port_forward` 함수를 보면: + +```bash +start_port_forward() { + local namespace="$1" + local service="$2" + local local_port="$3" + local remote_port="$4" + local log_file="$5" + + # port-forward를 백그라운드에서 실행 + kubectl -n "$namespace" port-forward "svc/${service}" \ + "${local_port}:${remote_port}" >"$log_file" 2>&1 & + + local pf_pid=$! # 방금 백그라운드로 보낸 프로세스의 PID를 저장 + + # EXIT 트랩: 스크립트가 어떤 이유로든 종료될 때 이 프로세스를 죽임 + trap 'kill "$pf_pid" >/dev/null 2>&1 || true' EXIT + + printf '%s\n' "$pf_pid" +} +``` + +이 코드를 한 줄씩 해부합니다. + +**`... &` (백그라운드 실행)** — `kubectl port-forward`는 끝나지 않는 프로세스입니다. 포트 포워딩을 유지하려고 계속 대기합니다. 이것을 `&`로 백그라운드로 보내야 스크립트의 다음 줄이 실행됩니다. `&` 없이 실행하면 스크립트가 여기서 영원히 멈춥니다. + +**`$!` (마지막 백그라운드 PID)** — 방금 `&`로 보낸 프로세스의 PID(프로세스 번호)를 캡처합니다. 나중에 이 프로세스를 죽이려면 PID를 알아야 합니다. + +**`trap 'kill "$pf_pid" ...' EXIT`** — 이것이 핵심입니다. `trap`은 "특정 신호를 받으면 이 명령을 실행하라"는 뜻입니다. `EXIT`는 "스크립트가 종료될 때"입니다. + +```mermaid +flowchart TD + A["스크립트 시작"] --> B["port-forward 프로세스 시작
PID 저장"] + B --> C["trap 등록:
'종료 시 PID를 kill해라'"] + C --> D["Terraform apply 등
메인 작업 실행"] + D --> E{"결과는?"} + E -->|"성공"| F["스크립트 정상 종료"] + E -->|"에러 발생 (-e로 즉시 종료)"| G["스크립트 에러 종료"] + F --> H["trap 발동:
port-forward 프로세스 kill"] + G --> H + H --> I["깔끔하게 종료됨
좀비 프로세스 없음"] +``` + +trap이 없으면 어떻게 될까요? + +스크립트가 에러로 중단되면 `kill` 명령이 실행되지 않고, **port-forward 프로세스가 좀비처럼 살아남습니다.** CI 러너에서 이런 좀비 프로세스가 쌓이면 포트 충돌이 발생하고, 다음 CI 실행이 실패합니다. + +`|| true`는 "kill이 실패해도(이미 프로세스가 죽어있어도) 에러로 취급하지 마라"는 뜻입니다. `-e`가 켜져 있으니, `kill` 실패가 스크립트 종료를 유발하는 것을 방지합니다. + +#### `require_cmd` / `require_env` — 사전 조건 검증 패턴 + +```bash +require_cmd() { + local cmd="$1" + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "$cmd is required" >&2 + exit 1 + fi +} + +require_env() { + local name="$1" + if [[ -z "${!name:-}" ]]; then + echo "$name must be set" >&2 + exit 1 + fi +} +``` + +이 함수들은 스크립트의 **맨 처음**에 호출됩니다: + +```bash +reconcile_transit() { + require_cmd kubectl + require_cmd vault + require_cmd terraform + require_cmd curl + require_cmd jq + require_env TF_STATE_DIR + require_env TRANSIT_VAULT_ADDR + # ... 여기서 필수 도구와 환경변수가 있는지 먼저 확인 +``` + +이 패턴의 목적: **10분 동안 실행한 뒤에 "jq가 없습니다"로 실패하는 것을 방지**합니다. 필수 조건을 스크립트 시작 시점에 전부 확인하고, 하나라도 빠지면 즉시 종료합니다. 디버깅 시간을 극적으로 줄여줍니다. + +`${!name:-}`의 의미: `${!name}`는 **변수 간접 참조**입니다. `name` 변수에 "VAULT_ADDR"가 들어있으면, `${!name}`는 `$VAULT_ADDR`의 값을 가져옵니다. `:-`는 "변수가 없으면 빈 문자열을 반환"하는 기본값 구문으로, `-u` 옵션에 의한 에러를 방지합니다. + +#### `kubectl wait` vs `sleep` — 조건 기반 대기의 중요성 + +```bash +# 이 프로젝트의 방식 (올바른 방법) +kubectl -n vault-transit wait --for=condition=available \ + deployment/vault-transit --timeout=300s + +# 나쁜 방법 +sleep 60 # 60초면 되겠지...? +``` + +| | `kubectl wait` | `sleep` | +|---|---|---| +| 대기 방식 | **조건을 지속 확인**. 조건 만족 즉시 통과 | 고정 시간만큼 무조건 대기 | +| 리소스가 30초에 준비되면 | 30초에 즉시 다음 단계 진행 | 60초 다 기다림 (30초 낭비) | +| 리소스가 90초에 준비되면 | 90초에 즉시 다음 단계 진행 | 60초에 성공으로 간주... **실제로는 아직 안 됨!** | +| timeout 처리 | `--timeout=300s` 초과 시 에러 코드 반환 → `-e`로 스크립트 종료 | timeout 개념 자체가 없음 | + +`sleep`은 **"이 정도면 되겠지"라는 추측**에 기반합니다. CI 환경의 부하 상태에 따라 리소스 준비 시간이 달라지니, 어떤 때는 되고 어떤 때는 안 되는 **불안정한(flaky) 파이프라인**이 됩니다. + +`kubectl wait`는 **실제 상태를 확인**합니다. Deployment가 Available 조건을 만족하는 순간 통과하고, timeout 안에 안 되면 명확하게 실패합니다. + +#### 이 주제에서 스스로 던져봐야 할 질문들 + +- `trap`이 여러 번 호출되면 어떤 것이 실행되는가? 마지막에 등록된 것만? 전부? (힌트: 같은 신호에 대한 trap은 덮어쓰기됨) +- `-e`가 켜져 있을 때 `if ! command ...` 구문에서 command가 실패하면 스크립트가 종료되는가? (힌트: `if`문 안에서는 `-e`가 일시 중단됨) +- `2>&1`의 의미는 무엇인가? `>&2`와는 무엇이 다른가? (힌트: 표준 에러 리다이렉션 방향의 차이) +- `terraform apply -auto-approve`는 plan 확인 없이 바로 적용한다. CI에서는 왜 이것을 쓰는가? 사람이 직접 실행할 때는 왜 위험한가? +- `reconcile-vault-dev.sh`에서 `transit_tf_token="$(transit_login)"`을 왜 두 번 호출하는가? 한 번이면 안 되는가? (힌트: policy 업데이트 후 새 토큰이 필요) + + + + diff --git a/README.md b/README.md index 5e73a03..31681d1 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,1601 @@ -# project-gitops +# Project-Auth-GitOps +GitOps repo에서는 앱/인프라별 공통(base)과 환경별 차이(overlay)를 관리하고, 실제 운영 선언만 둡니다. +## 현재 최신 Dev 아키텍처 + +최상단 아키텍처는 **항상 최신 dev 기준**만 유지합니다. +아키텍처가 변경되면 이 섹션은 최신 상태로 갱신하고, 변경 이유와 전후 비교는 아래 cycle에 누적 기록합니다. + +```mermaid +flowchart TD + subgraph APP_REPOS[Application Repos] + R1[Project-Auth-Server CI] + R2[Project-Api-Server CI] + end + + subgraph REPO[Project-Auth-GitOps] + subgraph AUTOMATION[CD automation] + U1[.github/workflows/update-image-tag.yaml] + U2[.github/workflows/vault-dev-reconcile.yaml] + end + + subgraph ARGO[argocd/dev] + P1[projects/apps-project.yaml] + P2[projects/infra-project.yaml] + A1[applications/apps/auth-server.yaml] + A2[applications/apps/api-server.yaml] + A3[applications/infra/vault-transit.yaml] + A4[applications/infra/vault.yaml] + A5[applications/infra/platform.yaml] + A6[applications/infra/vault-agent-injector.yaml] + A7[applications/infra/sealed-secrets.yaml] + end + + subgraph MANIFESTS[manifests] + subgraph APPS[apps] + M1[auth-server/overlays/dev] + M2[api-server/overlays/dev] + end + subgraph INFRA[infra] + M3[vault-transit/overlays/dev] + M4[vault/overlays/dev] + M5[platform/overlays/dev] + end + end + end + + subgraph CI[Self-hosted Runner] + C1[reconcile transit provider policy + workflow role] + C2[read workload bootstrap AppRole from provider Vault] + C3[reconcile workload Vault policy + runtime KV + db roles] + C4[apply Argo CD dev apps] + end + + subgraph OPERATOR[Operator Workstation] + O1[runbooks/vault-transit/dev bootstrap] + O2[runbooks/vault/dev bootstrap] + end + + subgraph CLUSTER[Dev Cluster] + N1[namespace: auth-dev] + N2[namespace: api-dev] + N3[namespace: platform] + N4[namespace: vault] + N5[namespace: vault-transit] + N6[namespace: kube-system] + W1[auth-server] + W2[api-server] + W3[postgres] + W4[keycloak] + W5["vault-transit
Transit unseal provider"] + W6["vault
KV + Kubernetes auth + Transit JWT"] + W7[vault-agent-injector] + W8[sealed-secrets-controller] + W9[traefik ingress] + end + + R1 -->|new auth image tag| U1 + R2 -->|new api image tag| U1 + U1 --> M1 + U1 --> M2 + U2 --> C1 + C1 --> C2 + C2 --> C3 + C3 --> C4 + + P1 --> A1 + P1 --> A2 + P2 --> A3 + P2 --> A4 + P2 --> A5 + P2 --> A6 + P2 --> A7 + + A1 --> M1 + A2 --> M2 + A3 --> M3 + A4 --> M4 + A5 --> M5 + A6 --> N4 + + M1 --> N1 + M2 --> N2 + M3 --> N5 + M4 --> N4 + M5 --> N3 + A7 --> N6 + O1 --> W5 + O2 --> W6 + C1 --> W5 + C2 --> W5 + C3 --> W6 + C4 --> A1 + C4 --> A2 + + N1 --> W1 + N2 --> W2 + N3 --> W3 + N3 --> W4 + N4 --> W6 + N4 --> W7 + N6 --> W8 + N6 --> W9 + N5 --> W5 + + O2 -. browser/API access via local hosts mapping .-> W9 + W9 --> W1 + W9 --> W2 + W9 --> W4 + W2 -. JWT issuer .-> W1 + W1 -. datasource .-> W3 + W1 -. oauth2 provider .-> W4 + W6 -. transit auto-unseal .-> W5 + W1 -. kubernetes auth + transit .-> W6 + W3 -. injector secret render .-> W6 + W4 -. injector secret render .-> W6 +``` + +## 이 저장소의 역할 + +이 저장소는 **CI가 아니라 CD 중심 GitOps repo**입니다. + +- Kubernetes manifest 관리 +- Argo CD `Application` / `AppProject` 관리 +- 이미지 태그 업데이트 반영 +- 환경별 overlay 관리 +- 실제 배포 반영 + +## 현재 CD 반영 흐름 + +1. 앱 repo(`Project-Auth-Server`, `Project-Api-Server`)에서 `feature -> main/develop` 병합 후 CI를 실행합니다. +2. CI가 테스트 통과 뒤 이미지를 build/push하고 새 이미지 태그를 만듭니다. +3. 앱 repo CI는 이미지 push 뒤 `repository_dispatch`로 이 저장소의 `.github/workflows/update-image-tag.yaml`을 호출해 dev overlay 태그를 갱신합니다. +4. 최초 1회 bootstrap 또는 복구가 필요할 때는 운영자가 runbook으로 privileged token을 사용해 transit/workload Vault bootstrap을 수행합니다. +5. 평상시에는 self-hosted runner의 `.github/workflows/vault-dev-reconcile.yaml` 이 **bootstrap readiness 확인 후** transit/workload Vault reconcile 과 Argo CD dev 정의 적용을 자동 수행합니다. +6. Argo CD가 GitOps repo와 Application 변경을 감지합니다. +7. `vault-transit` provider가 workload Vault의 transit auto-unseal을 지원합니다. +8. Argo CD가 cluster에 실제 배포를 반영합니다. + +즉, 앱 repo는 **CI 책임**, GitOps repo는 **CD 책임**을 갖고, 이미지 태그는 앱 repo가 자기 repo manifest를 수정하는 대신 **GitOps repo를 갱신하는 방식**으로 반영합니다. + +## Bootstrap vs Reconcile + +- manual bootstrap runbook + - 목적: privileged token으로 transit/workload Vault를 최초 1회 bootstrap 하거나 provider bootstrap path를 복구 + - 실행 방식: 운영자 로컬/관리자 터미널 수동 실행 +- `vault-dev-reconcile` + - 목적: 이미 bootstrap이 끝난 Vault를 workflow AppRole 기준으로 안전하게 reconcile + - 전제: `kv/dev/workload/bootstrap` 과 provider seed path가 이미 준비돼 있어야 함 + +즉 routine CI는 bootstrap을 “대신 수행”하지 않고, bootstrap이 끝났는지 확인한 뒤 그 상태를 유지/동기화하는 역할만 맡습니다. + +## 현재 Secret Lifecycle + +- `ghcr-regcred`처럼 **image pull secret**이 필요한 항목만 `SealedSecret`을 유지합니다. +- `auth-server`, `postgres`, `keycloak`의 **runtime secret**은 더 이상 Git이나 workflow secret에 넣지 않고 workload Vault KV(`kv/dev/...`)에 저장합니다. +- workload KV의 seed 값과 workload Vault bootstrap token은 `vault-transit` provider Vault KV가 source of truth 역할을 합니다. +- `vault-transit` provider와 workload Vault는 각각 클러스터 밖 runbook으로 1회 init/bootstrap 합니다. +- 이후 dev 자동화는 self-hosted runner의 CI secret store에 저장한 **transit provider workflow AppRole 정보**만 사용하고, 실제 workload secret 값은 provider Vault에서 읽습니다. +- root token은 bootstrap 직후 revoke하는 것을 기본값으로 두고, Kubernetes 안에는 저장하지 않습니다. +- 애플리케이션과 플랫폼 워크로드는 Vault Agent Injector와 Kubernetes auth로 인증하고 secret file을 렌더링받습니다. +- workload Vault는 `vault-transit` provider가 발급한 최소 권한 transit token으로 auto-unseal 합니다. +- `auth-server`는 Injector가 공유한 Vault token file을 사용해 workload Vault Transit을 계속 호출합니다. + +## Auto-unseal 상태 + +- 현재 dev 환경은 **Vault 2개 구조의 Transit auto-unseal** 을 전제로 합니다. +- `vault-transit` provider가 `workload-vault-dev-unseal` transit key를 제공하고, workload Vault는 `vault-transit-seal` secret의 최소 권한 token으로 auto-unseal 합니다. +- CI secret store는 provider Vault workflow AppRole 같은 최소 자동화 정보만 보관합니다. +- 이 구조는 단일 Vault보다 운영 난이도는 높지만, root token 직접 사용 최소화와 trust boundary 분리에 더 유리합니다. + +## 현재 Dev 접근 경로 + +- `auth-server`, `api-server`, `keycloak` 은 여전히 `ClusterIP` 로 유지하고, dev에서는 `traefik` `Ingress` 를 north-south 진입점으로 둡니다. +- public host는 `auth-public.auth-dev.svc.cluster.local`, `api-public.api-dev.svc.cluster.local`, `keycloak-public.platform.svc.cluster.local` 로 분리하고, namespace 내부 DNS에서는 `ExternalName -> traefik` 경로로 같은 호스트를 해석합니다. +- 외부 접근이 필요할 때는 운영자 노트북의 `hosts` 파일을 현재 Traefik `LoadBalancer` IP 또는 dev node IP로 매핑해 callback/redirect 와 브라우저 접근을 엽니다. +- 예시: ` auth-public.auth-dev.svc.cluster.local api-public.api-dev.svc.cluster.local keycloak-public.platform.svc.cluster.local` +- dev public ingress는 HTTP만 사용합니다. +- `auth-server` 와 `api-server` 는 namespace 내부에서도 같은 public host를 HTTP로 호출합니다. +- east-west는 `auth-dev`, `api-dev`, `platform`, `vault` namespace에 `default deny + allowlist` `NetworkPolicy` 를 적용해 필요한 흐름만 열어둡니다. + +## README 작성 원칙 + +이 저장소의 README에는 `ops` 관련 내용만 기록합니다. + +문서 작성은 1회성 정리가 아니라 **변경 이력 누적 방식**으로 관리합니다. +즉, 기존에 작성한 구조/문제점/개선 내용을 지우고 새로 덮어쓰지 않고, **항상 기존 내용 아래에 이어서 추가**합니다. +다만 README 최상단의 `현재 최신 Dev 아키텍처` 섹션은 예외적으로 **항상 최신 상태로 갱신**합니다. + +이 README에는 아래 사이클을 반복해서 계속 누적 작성합니다. + +1. 처음 구조 `mermaid` +2. 해당 구조의 문제점 +3. 변경된 후 구조 `mermaid` +4. 이전 구조 대비 변경된 점 +5. 변경으로 해결된 내용 + +## 기록 규칙 + +- 이전 사이클은 삭제하거나 수정해서 덮어쓰지 않습니다. +- README 최상단의 `현재 최신 Dev 아키텍처`는 최신 상태만 유지하고, 예전 아키텍처는 cycle로 추적합니다. +- 새로운 `ops` 변경이 생기면 README의 가장 아래에 새 사이클을 추가합니다. +- 아키텍처 변경이 생기면 먼저 최상단 `현재 최신 Dev 아키텍처`를 갱신하고, 같은 변경을 새 cycle에 기록합니다. +- 각 사이클은 당시의 구조, 문제, 개선 결과가 모두 보이도록 독립적으로 작성합니다. +- 구조 설명은 가능하면 `mermaid` 다이어그램으로 남깁니다. +- 변경된 점은 반드시 **이전 구조와 비교**해서 작성합니다. +- 해결 내용은 어떤 문제가 어떻게 해소되었는지 명확하게 작성합니다. +- 런타임 장애나 수동 운영 이슈를 해결했으면, README 하단 cycle에 **재현 명령, 핵심 관찰값, 판단 근거, 수정 내용, 검증 명령** 을 함께 남깁니다. +- 트러블슈팅 명령은 가능하면 실제로 사용한 형태 그대로 남기고, 왜 그 명령을 쳤는지 한 줄로 설명합니다. +- secret, token, kubeconfig 본문처럼 민감한 값은 절대 그대로 기록하지 않고, 값의 존재 여부나 길이만 요약합니다. + +## 작성 템플릿 + +아래 형식을 반복해서 README 하단에 계속 추가합니다. + +````md +## Cycle N + +### 1. 초기 구조 +```mermaid +flowchart TD + A[example] +``` + +### 2. 문제점 +- 문제 1 +- 문제 2 + +### 3. 변경 후 구조 +```mermaid +flowchart TD + A[changed-example] +``` + +### 4. 이전 구조 대비 변경점 +- 변경점 1 +- 변경점 2 + +### 5. 해결된 내용 +- 해결 1 +- 해결 2 + +### 6. 트러블슈팅 메모 +- 재현/확인 명령 +- 핵심 관찰값 +- 판단 근거 +- 수정 또는 조치 +- 검증 명령 +```` + +## 트러블슈팅 메모 작성 예시 + +- 재현/확인 명령: `kubectl -n argocd describe application vault-transit-dev` +- 핵심 관찰값: `authentication required: Repository not found` +- 판단 근거: Argo CD app spec 자체는 존재하지만 repo-server가 source repo를 읽지 못해 manifest generation 전에 실패한다고 봤습니다. +- 수정 또는 조치: `argocd` namespace에 `repo-creds` secret을 선언형으로 적용했습니다. +- 검증 명령: `kubectl -n argocd annotate application vault-transit-dev argocd.argoproj.io/refresh=hard --overwrite` + +## Cycle 1 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph AUTH[Project-Auth-Server] + A1[argocd/*.yaml] + A2[k8s/dev] + A3[k8s/platform-dev] + end + + subgraph API[Project-Api-Server] + B1[argocd/*.yaml] + B2[k8s/dev] + end + + subgraph GITOPS_BEFORE[Project-Auth-GitOps] + C1[README only] + end +``` + +### 2. 문제점 +- 운영 선언이 `Project-Auth-Server`와 `Project-Api-Server`에 분산되어 있어서 GitOps 저장소가 실제 단일 운영 기준점이 아니었습니다. +- `auth-server`는 `k8s/dev`와 `k8s/platform-dev`가 분리돼 있었지만, 현재 GitOps 저장소 기준의 공통 `base`와 환경별 `overlay` 구조가 없었습니다. +- Argo CD `Application`의 source repo가 각 서비스 repo를 가리키고 있어, 운영 경로를 한 저장소에서 일관되게 추적하기 어려웠습니다. +- 두 서비스 모두 `prod`를 수용할 고정 overlay 진입점이 없어 이후 환경 확장 시 구조가 다시 흔들릴 수 있었습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph GITOPS_AFTER[Project-Auth-GitOps] + subgraph APPS[apps] + subgraph AUTH_APP[auth-server] + D1[base] + D2[overlays/dev] + D3[overlays/dev/platform] + D4[overlays/prod] + end + + subgraph API_APP[api-server] + E1[base] + E2[overlays/dev] + E3[overlays/prod] + end + end + + subgraph ARGO[argocd] + F1[projects/*.yaml] + F2[applications/*.yaml] + end + end +``` + +### 4. 이전 구조 대비 변경점 +- `auth-server`와 `api-server`의 Kubernetes 운영 매니페스트를 현재 GitOps repo의 `apps/` 아래로 이관했습니다. +- 앱 공통 리소스는 `base`로 분리하고, namespace/configmap/sealed secret/image tag 같은 환경 값은 `overlays/dev`로 분리했습니다. +- `auth-server`의 `platform-dev` 리소스는 `apps/auth-server/overlays/dev/platform`으로 옮겨 기존 dev platform 운영 구성을 유지했습니다. +- Argo CD `AppProject`와 `Application`도 현재 GitOps repo 기준으로 재배치하고, `repoURL`과 `path`를 새 구조에 맞게 변경했습니다. +- 원본 repo에 `prod` 운영 매니페스트는 없었기 때문에, 이번 변경에서는 `overlays/prod`에 namespace와 kustomization 골격만 먼저 추가했습니다. + +### 5. 해결된 내용 +- 이제 `Project-Auth-GitOps`가 `auth-server`, `api-server`, `platform-dev`의 운영 선언을 모으는 단일 저장소 역할을 하게 되었습니다. +- 서비스마다 서로 다른 운영 경로를 읽지 않아도 되어, 변경 리뷰와 Argo CD 추적 기준이 단순해졌습니다. +- 이후 환경이 늘어나더라도 `apps//base`와 `apps//overlays/` 패턴으로 같은 방식의 확장이 가능해졌습니다. +- `auth-dev` 프로젝트에 `SealedSecret` 허용 리소스를 추가해, 이관된 sealed secret 리소스가 Argo CD 정책과 맞지 않던 문제도 함께 정리했습니다. + +## Cycle 2 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph ARGOCD_BEFORE[argocd] + A1[applications/auth-server-dev.yaml] + A2[applications/api-server-dev.yaml] + A3[applications/platform-dev.yaml] + A4[applications/sealed-secrets-dev.yaml] + B1[projects/auth-dev-project.yaml] + B2[projects/api-dev-project.yaml] + B3[projects/infra-dev-project.yaml] + end +``` + +### 2. 문제점 +- `applications`와 `projects`가 파일 단위로 평평하게 놓여 있어서 `dev/prod` 경계와 `apps/infra` 경계가 디렉터리 구조에 드러나지 않았습니다. +- 앱용 프로젝트가 `auth-dev`, `api-dev`로 분산돼 있어, 같은 성격의 애플리케이션을 한 번에 파악하기 어려웠습니다. +- `prod`용 Argo CD 진입점이 구조상 준비돼 있지 않아 환경 확장 시 다시 디렉터리 재정리가 필요했습니다. +- 파일 수가 늘어날수록 어떤 선언이 서비스용인지 인프라용인지 찾는 비용이 계속 커질 구조였습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph ARGOCD_AFTER[argocd] + subgraph APPS[applications] + subgraph DEV_APPS[dev] + C1[apps/auth-server.yaml] + C2[apps/api-server.yaml] + C3[infra/platform.yaml] + C4[infra/sealed-secrets.yaml] + end + subgraph PROD_APPS[prod] + C5[apps/] + C6[infra/] + end + end + + subgraph PROJECTS[projects] + subgraph DEV_PROJECTS[dev] + D1[apps-project.yaml] + D2[infra-project.yaml] + end + subgraph PROD_PROJECTS[prod] + D3[apps-project.yaml] + D4[infra-project.yaml] + end + end + end +``` + +### 4. 이전 구조 대비 변경점 +- Argo CD 선언을 `argocd/applications//`와 `argocd/projects/` 구조로 재배치했습니다. +- `auth-server`와 `api-server`는 `dev/apps` 아래로, `platform`과 `sealed-secrets`는 `dev/infra` 아래로 나눠 목적별 경계를 디렉터리에서 바로 보이게 했습니다. +- 기존 `auth-dev`와 `api-dev` AppProject는 `apps-dev` 하나로 통합하고, `platform`과 `sealed-secrets`는 `infra-dev` 프로젝트로 정리했습니다. +- `prod`는 아직 실제 Application이 없지만, `applications/prod`와 `projects/prod` 골격을 미리 만들어 이후 추가 위치를 고정했습니다. +- `argocd/README.md`를 추가해 이 구조 규칙을 디렉터리 안에서도 바로 확인할 수 있게 했습니다. + +### 5. 해결된 내용 +- 이제 Argo CD 선언만 보더라도 환경별 구분과 성격별 구분이 동시에 드러나서 탐색 비용이 줄었습니다. +- 서비스 애플리케이션과 공용 인프라가 각자 어떤 AppProject를 쓰는지 일관되게 정리되어 관리 포인트가 단순해졌습니다. +- `prod` 확장 시 새 파일을 어디에 둬야 하는지 미리 정해져 있어, 다음 변경에서도 구조를 다시 흔들 필요가 없어졌습니다. +- `argocd` 자체도 README 기반의 누적 관리 대상이 되면서, 구조 변경 이유를 README와 디렉터리 문서에서 함께 추적할 수 있게 됐습니다. + +## Cycle 3 + +### 1. 초기 구조 +```mermaid +flowchart TD + A[README 소개] + B[README 작성 원칙] + C[Cycle 1] + D[Cycle 2] + + A --> B --> C --> D +``` + +### 2. 문제점 +- 현재 운영 중인 dev 아키텍처를 README 최상단에서 바로 볼 수 있는 기준 그림이 없었습니다. +- 변경 이력은 누적되고 있었지만, 최신 구조를 한 번에 확인하려면 여러 cycle을 직접 읽어야 했습니다. +- README의 누적 기록 규칙만 있고, `최신 아키텍처는 어디를 기준으로 볼지`에 대한 별도 원칙이 없었습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + A[README 소개] + B[현재 최신 Dev 아키텍처] + C[README 작성 원칙] + D[Cycle 1] + E[Cycle 2] + F[Cycle 3] + + A --> B --> C --> D --> E --> F +``` + +### 4. 이전 구조 대비 변경점 +- README 최상단에 현재 기준의 **최신 dev 아키텍처**를 `mermaid`로 추가했습니다. +- 최상단 아키텍처는 항상 최신 상태로 갱신하고, 이전 구조 변화는 cycle로 누적 기록한다는 규칙을 명시했습니다. +- 현재 단계에서는 요청하신 대로 `prod`는 제외하고 `dev` 운영 구조만 아키텍처에 반영했습니다. +- 아키텍처 그림 안에는 Argo CD project/application, GitOps manifest 경로, dev cluster 주요 namespace와 런타임 의존 관계를 함께 드러내도록 정리했습니다. + +### 5. 해결된 내용 +- 이제 README를 열면 가장 먼저 현재 dev 운영 구조를 확인할 수 있어 최신 상태 파악이 훨씬 빨라졌습니다. +- 최신 구조와 변경 이력을 분리해, 상단은 현재 기준점으로 쓰고 하단 cycle은 히스토리로 쓰는 역할이 명확해졌습니다. +- 이후 dev 아키텍처가 바뀌더라도 어떤 내용을 갱신하고 어떤 내용을 누적해야 하는지 README 규칙만 보고 바로 따라갈 수 있게 됐습니다. + +## Cycle 4 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph APP_REPOS[Application Repos] + A1[CI builds image] + end + + subgraph GITOPS_BEFORE[Project-Auth-GitOps] + B1[apps/*/overlays/*/kustomization.yaml] + B2[argocd/dev/*] + end + + APP_REPOS -. image tag info .-> B1 +``` + +### 2. 문제점 +- GitOps repo가 CD 중심 저장소라는 역할은 정리됐지만, 이미지 태그를 **어떤 진입점으로 갱신할지**가 이 저장소 안에 아직 명시돼 있지 않았습니다. +- 앱 repo가 이미지를 push한 뒤 GitOps repo를 어떻게 업데이트해야 하는지 표준 스크립트나 workflow가 없어, 저장소마다 방식이 달라질 수 있었습니다. +- README에도 이 저장소가 `CI`가 아니라 `CD`를 담당한다는 운영 원칙과 실제 반영 흐름이 구조적으로 정리돼 있지 않았습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph APP_REPOS[Application Repos] + A1[Project-Auth-Server CI] + A2[Project-Api-Server CI] + end + + subgraph GITOPS_AFTER[Project-Auth-GitOps] + B1[.github/workflows/update-image-tag.yaml] + B2[scripts/update-image-tag.sh] + B3[apps/*/overlays/*/kustomization.yaml] + B4[argocd/dev/*] + end + + A1 -->|new image tag| B1 + A2 -->|new image tag| B1 + B1 --> B2 + B2 --> B3 + B4 --> B3 +``` + +### 4. 이전 구조 대비 변경점 +- GitOps repo에 이미지 태그 갱신용 스크립트 `scripts/update-image-tag.sh`를 추가했습니다. +- GitOps repo 내부에서 직접 태그 갱신 commit/push를 수행할 수 있도록 `.github/workflows/update-image-tag.yaml` workflow를 추가했습니다. +- workflow는 `workflow_dispatch`와 `repository_dispatch` 둘 다 받을 수 있게 구성해, 수동 실행과 앱 repo CI 연동 둘 다 가능하도록 했습니다. +- README 최상단 dev 아키텍처에도 앱 repo CI에서 GitOps repo로 태그가 반영되는 흐름을 함께 반영했습니다. +- README에 이 저장소의 역할과 현재 CD 반영 흐름을 별도 섹션으로 정리했습니다. + +### 5. 해결된 내용 +- 이제 이 저장소 안에 `이미지 태그 업데이트`를 수행하는 공식 진입점이 생겨, CD 반영 방식이 문서와 파일 기준으로 일치하게 되었습니다. +- 앱 repo는 자기 저장소의 manifest를 다시 수정하지 않고, GitOps repo를 갱신하는 방식으로 역할이 명확히 분리되었습니다. +- 이후 앱 repo CI는 새 이미지 태그만 전달하면 되고, 실제 배포 반영은 GitOps repo와 Argo CD 흐름 안에서 일어나도록 정리되었습니다. + +## Cycle 5 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph APPS_BEFORE[apps] + A1[auth-server/base] + A2[auth-server/overlays/dev] + A3[auth-server/overlays/dev/platform] + A4[auth-server/overlays/prod] + B1[api-server/base] + B2[api-server/overlays/dev] + B3[api-server/overlays/prod] + end +``` + +### 2. 문제점 +- `platform`이 `apps/auth-server` 하위에 있어, 앱 배포와 공용 인프라 배포의 책임 경계가 디렉터리 구조상 섞여 있었습니다. +- `postgres`, `keycloak`, `vault`는 `auth-server`의 일부라기보다 공용 infra인데도 앱 overlay에 포함돼 있어 탐색과 확장이 불편했습니다. +- `dev`와 `prod`를 분리할 때도 `platform`이 앱 트리 안에 있으면 인프라 확장 경로가 일관되지 않았습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph APPS_AFTER[apps] + A1[auth-server/base] + A2[auth-server/overlays/dev] + A3[auth-server/overlays/prod] + B1[api-server/base] + B2[api-server/overlays/dev] + B3[api-server/overlays/prod] + end + + subgraph INFRA_AFTER[infra] + C1[platform/base] + C2[platform/overlays/dev] + C3[platform/overlays/prod] + end +``` + +### 4. 이전 구조 대비 변경점 +- `apps/auth-server/overlays/dev/platform`에 있던 리소스를 `infra/platform/base`와 `infra/platform/overlays/dev`로 분리했습니다. +- `postgres`, `keycloak`, `vault` 워크로드와 공통 생성 파일은 `infra/platform/base`로 옮기고, namespace/configmap/sealed secret은 `infra/platform/overlays/dev`로 분리했습니다. +- `infra/platform/overlays/prod`도 함께 추가해 `platform-prod` namespace와 prod용 config skeleton을 둘 수 있게 했습니다. +- Argo CD `platform-dev` Application의 source path를 새 infra 경로로 변경했습니다. +- README 최상단 최신 dev 아키텍처도 `apps`와 `infra`가 분리된 현재 구조 기준으로 갱신했습니다. + +### 5. 해결된 내용 +- 이제 `platform`은 앱 하위 부속이 아니라 독립된 infra 영역으로 보이기 때문에 구조 해석이 훨씬 자연스러워졌습니다. +- 앱 배포 경로와 인프라 배포 경로가 분리되어, 이후 `infra` 확장이나 세분화로 이어가기가 쉬워졌습니다. +- `dev`뿐 아니라 `prod`도 같은 `infra/platform/base -> overlays/` 패턴으로 관리할 준비가 갖춰졌습니다. + +## Cycle 6 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph BEFORE[GitOps secret lifecycle] + A1[auth-server SealedSecret] + A2[platform SealedSecret] + A3[app/platform pod env from Secret] + A4[sealed-secrets-controller] + end + + A1 --> A3 + A2 --> A3 + A4 --> A1 + A4 --> A2 +``` + +### 2. 문제점 +- `SealedSecret`으로 평문을 Git에 직접 넣지는 않았지만, secret source 자체가 여전히 GitOps 저장소 안의 정적 파일이었습니다. +- secret rotation과 변경 이력이 결국 Git commit 중심이 되어, 운영형 secret lifecycle이라고 보기 어려웠습니다. +- `auth-server`는 Vault Transit을 사용하면서도 접근 토큰을 정적 Kubernetes Secret으로 주입받고 있어 Kubernetes auth 기반 접근으로 전환되지 못했습니다. +- `postgres`, `keycloak`도 `platform-secret` 하나에 묶인 채 정적 secret에 의존하고 있어, 역할별 최소 권한 분리가 어려웠습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph AFTER[GitOps secret lifecycle] + B1["vault-bootstrap Secret
manual / out-of-git"] + B2[Vault KV kv/dev/platform/postgres/*] + B3[Vault KV kv/dev/platform/keycloak/*] + B4[Vault Kubernetes auth roles per workload] + B5[Vault Agent Injector] + B6[auth-server / auth-db-migration / postgres / keycloak / keycloak-client-sync] + B7[ghcr-regcred SealedSecret only] + end + + B1 --> B2 + B1 --> B3 + B1 --> B4 + B1 --> B5 + B5 --> B6 + B6 --> B4 + B4 --> B2 + B4 --> B3 +``` + +### 4. 이전 구조 대비 변경점 +- `auth-server-secret.sealedsecret.yaml`과 `platform-secret.sealedsecret.yaml`을 제거하고, runtime secret source를 Vault KV로 전환했습니다. +- HashiCorp 공식 Helm chart를 사용하는 `vault-agent-injector` Argo CD Application을 추가했습니다. +- `auth-server`, `auth-db-migration`, `postgres`, `keycloak`, `keycloak-client-sync`에 Vault Agent Injector annotation을 적용하고 Kubernetes auth role 기반으로 secret을 주입받도록 변경했습니다. +- runtime KV path를 `platform/postgres/superuser`, `platform/postgres/auth-server`, `platform/postgres/keycloak`, `platform/keycloak/bootstrap-admin`, `platform/keycloak/client-auth-server`처럼 목적/소유권 기준으로 세분화했습니다. +- `auth-db-migration`과 `keycloak-client-sync`를 전용 ServiceAccount와 Vault role로 분리해 app/runtime 권한을 job과 분리했습니다. +- base/prod 매니페스트의 Kubernetes Secret 계약도 `postgres-superuser-credentials`, `postgres-auth-server-credentials`, `postgres-keycloak-credentials`, `keycloak-bootstrap-admin`, `keycloak-client-auth-server`처럼 목적별 이름으로 분해했습니다. +- `vault-bootstrap`은 Git에 넣지 않는 수동 bootstrap secret으로 분리하고, Vault server는 부팅 시 Kubernetes auth/policy/role을 자동 구성하도록 바꿨습니다. +- 현재 범위는 dev 운영 환경이므로 Vault role/policy 이름도 `*-dev` 기준으로만 구성했습니다. +- `ghcr-regcred`는 image pull secret 특성상 Injector로 대체할 수 없어서 SealedSecret으로 유지했습니다. + +### 5. 해결된 내용 +- 이제 앱/플랫폼 runtime secret의 기준점이 Git의 암호화 파일이 아니라 Vault가 되어, secret lifecycle이 Git commit 중심 구조에서 벗어났습니다. +- Vault Kubernetes auth와 role 분리를 통해 `auth-server`, `auth-db-migration`, `postgres`, `keycloak`, `keycloak-client-sync`가 각자 필요한 범위만 읽도록 최소 권한 구조를 만들었습니다. +- 정적 Kubernetes Secret 계약도 blob 두세 개 대신 목적별 credential 단위로 나뉘어, rotation과 접근 제어 범위를 더 좁힐 수 있게 됐습니다. +- `auth-server`는 Injector가 제공한 Vault token file을 통해 Vault Transit을 계속 사용할 수 있게 되어, 정적 Vault token SealedSecret 없이도 동작할 기반이 생겼습니다. +- 현재 구조에서 Git에 남는 비밀 항목은 bootstrap과 image pull 같은 예외 케이스로 좁혀졌고, 운영 secret 흐름과 예외 secret 흐름을 구분할 수 있게 됐습니다. + +## Cycle 7 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph BEFORE[Vault bootstrap in cluster] + A1[vault-bootstrap Secret] + A2[vault postStart bootstrap script] + A3[policies mounted by ConfigMap] + A4[Vault dev mode] + end + + A1 --> A2 + A3 --> A2 + A2 --> A4 +``` + +### 2. 문제점 +- root token이 Kubernetes Secret 형태로 클러스터 안에 남아 있어, 운영자가 원한 `root token out of cluster` 조건을 만족하지 못했습니다. +- Vault policy/role bootstrap이 pod lifecycle에 묶여 있어, 초기화 작업이 GitOps 런타임과 섞여 있었습니다. +- Vault server가 `-dev` 모드로 실행되고 있어, 수동 init/unseal과 root token revoke 기반 운영 절차를 적용할 수 없었습니다. +- bootstrap 절차와 KV 입력 절차가 overlay 파일 안에 섞여 있어, 실제 운영 runbook과 배포 manifest의 경계가 불분명했습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph AFTER[Vault bootstrap outside cluster] + B1["Vault server
raft + PVC"] + B2[scripts/vault/dev/bootstrap-runbook.sh] + B3[runbooks/vault/dev/policies/*.hcl] + B4[runbooks and scripts] + B5["root token
operator local only"] + B6[platform-admin-dev token] + end + + B5 --> B2 + B3 --> B2 + B2 --> B1 + B2 --> B6 + B6 --> B4 +``` + +### 4. 이전 구조 대비 변경점 +- Vault bootstrap용 `vault-bootstrap` Kubernetes Secret과 in-cluster `postStart` bootstrap 흐름을 제거했습니다. +- Vault server는 `-dev` 모드 대신 config file 기반 단일-node raft 저장소와 PVC를 사용하도록 변경했습니다. +- Vault policy와 bootstrap 로직을 GitOps overlay 밖의 `runbooks/vault/dev/`로 이동해, 운영자가 클러스터 밖에서 직접 실행하는 구조로 바꿨습니다. +- `bootstrap-runbook.sh`는 root token으로 1회 bootstrap을 수행한 뒤 `platform-admin-dev` orphan token을 만들고 root token revoke까지 처리하도록 바꿨습니다. +- KV 값 입력 예시도 overlay에서 제거하고 runbook 디렉터리로 이동시켜, manifest와 운영 절차를 분리했습니다. + +### 5. 해결된 내용 +- 이제 root token이 Kubernetes 안에 저장되지 않고, 초기 bootstrap에만 클러스터 밖에서 사용되도록 구조가 정리되었습니다. +- Vault bootstrap이 pod 기동 과정과 분리되어, GitOps manifest는 런타임 배포에만 집중하고 초기 운영 절차는 runbook으로 분리되었습니다. +- Vault 운영 흐름이 `init/unseal -> bootstrap -> admin token 발급 -> root revoke` 순서로 명확해져 dev 운영 환경 기준에 더 가까워졌습니다. +- 이후에는 `platform-admin-dev` 같은 제한된 운영 토큰으로 KV 갱신과 정책 보조 작업을 할 수 있어, root token을 상시 들고 있을 필요가 없어졌습니다. + +## Cycle 8 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph BEFORE[Dev Vault automation] + A1[Operator runbook only] + A2[manual bootstrap script] + A3[manual seed input] + A4[Argo CD apps applied separately] + end + + A1 --> A2 --> A3 --> A4 +``` + +### 2. 문제점 +- runbook만으로는 dev Vault KV 입력과 auth/policy/role reconcile이 계속 운영자 수동 절차에 묶여 있었습니다. +- GitOps repo 안에 자동 파이프라인이 없어, KV 준비와 Argo CD dev Application 적용 순서를 일관되게 맞추기 어려웠습니다. +- 현재 단일 Vault 구조에서는 진짜 Transit auto-unseal을 바로 적용할 수 없는데, 문서상으로는 그 경계가 충분히 드러나지 않았습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph AFTER[Dev Vault automation] + B1[self-hosted runner] + B2[vault-dev-reconcile workflow] + B3[ensure-unsealed.sh] + B4[reconcile.sh] + B5[populate-kv.sh] + B6[apply-argocd-dev-*.sh] + B7[runbooks only for first bootstrap / recovery] + end + + B1 --> B2 + B2 --> B3 + B3 --> B4 + B4 --> B5 + B5 --> B6 + B7 -. fallback .-> B2 +``` + +### 4. 이전 구조 대비 변경점 +- self-hosted runner 전용 workflow `.github/workflows/vault-dev-reconcile.yaml` 을 추가했습니다. +- workflow는 당시 CI secret store에 저장된 비밀값을 사용해 unseal/reconcile/populate/apply를 자동 수행했습니다. +- 기존 runbook의 정책과 절차를 재사용할 수 있도록 `scripts/vault/dev/` 아래에 automation용 스크립트를 분리했습니다. +- README와 runbook에 자동화에 필요한 CI secret 목록과 현재 자동화 범위를 명시했습니다. +- Transit auto-unseal은 적용했다고 표기하지 않고, 별도 unseal provider Vault/HSM/KMS가 필요한 후속 아키텍처 작업임을 분명히 남겼습니다. + +### 5. 해결된 내용 +- 이제 dev 환경에서는 Vault KV 준비와 Argo CD dev 정의 적용이 self-hosted runner workflow로 자동 수행될 수 있게 되었습니다. +- 운영자는 최초 bootstrap 또는 복구 시에만 runbook을 보고 개입하면 되고, 평소 dev reconcile은 CI secret store 기반 자동화로 넘길 수 있습니다. +- 현재 구조에서 자동화된 부분과 아직 별도 아키텍처가 필요한 부분(Transit auto-unseal)이 명확히 분리되어, 다음 변경 방향을 혼동하지 않게 됐습니다. + +## Cycle 9 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph BEFORE[Single Vault dev] + A1[workload Vault only] + A2[CI secret store based unseal] + A3[KV + Transit JWT in same Vault] + A4[no dedicated unseal provider] + end + + A2 --> A1 + A3 --> A1 +``` + +### 2. 문제점 +- 단일 Vault 구조에서는 runtime secret은 Vault로 옮길 수 있어도, Vault 서버 자체의 unseal trust boundary는 여전히 CI secret store에 크게 의존했습니다. +- `root token 직접 사용 금지`, `bootstrap 최소화`, `trust boundary를 Vault 쪽으로 이동` 같은 현업형 dev 운영 방향을 만족시키려면 별도 unseal provider가 필요했습니다. +- README와 자동화 흐름도 아직 단일 Vault 기준 설명이 남아 있어, 실제 운영 구조와 설명이 어긋날 위험이 있었습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph AFTER[Two Vault dev] + B1[vault-transit provider] + B2[workload Vault] + B3[Vault Agent Injector] + B4[CI runner automation] + B5[runbooks for first bootstrap] + end + + B1 -->|transit auto-unseal| B2 + B2 -->|KV + Kubernetes auth + Transit JWT| B3 + B4 --> B1 + B4 --> B2 + B5 --> B1 + B5 --> B2 +``` + +### 4. 이전 구조 대비 변경점 +- `infra/vault-transit/overlays/dev` 와 `argocd/applications/dev/infra/vault-transit.yaml` 을 추가해 unseal provider Vault를 별도 infra로 분리했습니다. +- workload Vault config에 transit seal stanza를 추가하고, `vault-transit-seal` 최소 권한 token으로 auto-unseal 하도록 변경했습니다. +- self-hosted runner workflow는 provider bootstrap -> workload reconcile -> KV populate -> app apply 순서로 재구성했습니다. +- `scripts/vault-transit/dev/` 와 `runbooks/vault-transit/dev/` 를 추가해 provider Vault 전용 bootstrap/policy 경로를 분리했습니다. +- README 최상단 최신 아키텍처, secret lifecycle, auto-unseal 설명을 2-Vault 구조 기준으로 갱신했습니다. + +### 5. 해결된 내용 +- 이제 workload Vault의 unseal trust boundary가 단순 CI secret store 의존에서 `vault-transit` provider Vault 기반 구조로 한 단계 올라갔습니다. +- runtime secret, JWT transit signing, workload Vault 운영, unseal provider 역할이 분리되어 현업형 dev 운영 방향에 더 가까워졌습니다. +- 단일 Vault보다 운영 난이도는 높아졌지만, root token 직접 사용 최소화와 운영 신뢰 경계 분리 측면에서는 더 나은 dev 구조를 갖추게 됐습니다. + +## Cycle 10 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph BEFORE[2-Vault but workflow secrets remain] + A1[CI secret store] + A2[provider Vault] + A3[workload Vault] + end + + A1 -->|app secret values + bootstrap tokens| A3 + A1 -->|provider token| A2 +``` + +### 2. 문제점 +- Vault를 2개로 나눴어도, workflow에 앱 비밀값과 workload Vault bootstrap token이 남아 있으면 여전히 GitOps CI가 secret source처럼 보일 수 있었습니다. +- 다른 앱 repo workflow에는 없는 민감값이 이 repo workflow에만 남아 있어, 운영 구조 일관성이 떨어졌습니다. +- 목표였던 `비밀 배포의 신뢰 경계가 SealedSecret/CI가 아니라 Vault에 있어야 한다`는 방향이 완전히 충족되지 않았습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph AFTER[Provider Vault as source of truth] + B1[CI secret store] + B2[vault-transit provider Vault] + B3[workload Vault] + end + + B1 -->|provider unseal/bootstrap only| B2 + B2 -->|seed values + workload bootstrap token| B3 +``` + +### 4. 이전 구조 대비 변경점 +- `vault-dev-reconcile` workflow에서 앱 비밀값과 workload Vault bootstrap token을 제거했습니다. +- `scripts/vault/dev/populate-kv.sh` 와 `scripts/vault/dev/reconcile.sh` 는 provider Vault KV에서 값을 읽어 workload Vault에 반영하도록 변경했습니다. +- provider Vault에 workload seed 값을 넣는 실행 스크립트 `scripts/vault-transit/dev/populate-workload-seeds.sh` 와 참고용 `runbooks/vault-transit/dev/populate-workload-seeds.example.sh` 를 분리했습니다. +- README와 runbook에서 CI secret store에는 provider Vault 접근용 최소값만 남고, 실제 workload secret source는 provider Vault라는 점을 명시했습니다. + +### 5. 해결된 내용 +- 이제 workflow는 orchestration만 담당하고, 실제 앱/플랫폼 secret 값은 provider Vault에서 workload Vault로 흘러가는 구조가 되었습니다. +- CI secret store에 남는 민감값 범위가 줄어들어, `Vault 안에 secret source of truth를 두자`는 목표에 더 가까워졌습니다. +- 다른 앱 repo 기준으로 봐도 이 저장소만 workflow에 앱 비밀값을 직접 쥐고 있던 불균형이 해소되었습니다. + +## Cycle 11 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph BEFORE[Infra layout] + A1[infra/platform = postgres + keycloak + workload vault] + A2[infra/vault-transit = transit provider] + end +``` + +### 2. 문제점 +- `vault-transit` 만 따로 빠져 있고 workload Vault는 `platform` 안에 남아 있어, infra 경계가 namespace/역할 기준으로 일관되지 않았습니다. +- `platform` 이라는 이름만 보면 postgres/keycloak 묶음으로 이해되는데, 여기에 workload Vault까지 들어 있어 해석이 애매했습니다. +- 구조가 애매하면 문서와 운영 흐름을 볼 때도 `platform` 과 `vault` 의 책임이 섞여 보이기 쉬웠습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph AFTER[Infra layout] + A1[infra/platform = postgres + keycloak] + A2[infra/vault = workload vault] + A3[infra/vault-transit = transit provider] + end +``` + +### 4. 이전 구조 대비 변경점 +- workload Vault 리소스를 `infra/platform` 에서 분리해 `infra/vault/base|overlays` 로 이동했습니다. +- Argo CD infra app도 `vault-transit`, `vault`, `platform` 3개로 역할이 드러나도록 나눴습니다. +- workload Vault service 주소를 `vault.vault.svc.cluster.local` 기준으로 정리하고, 관련 config/script/document를 모두 새 namespace 기준으로 갱신했습니다. +- `platform` 은 이제 postgres/keycloak 영역만 담당하고, `vault` 는 workload secret/runtime auth/transit JWT를 담당하도록 구조를 고정했습니다. + +### 5. 해결된 내용 +- 이제 infra 디렉터리와 namespace가 역할 기준으로 일치해, 구조 해석과 운영 설명이 훨씬 자연스러워졌습니다. +- `platform`, `vault`, `vault-transit` 이 각각 무엇을 위한 스택인지 경로만 봐도 바로 드러납니다. +- 이후 더 깊게 파고들 때도 어떤 변경이 어느 스택의 책임인지 구분하기 쉬워졌습니다. + +## Cycle 12 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph BEFORE[Internal-only dev app network] + A1[auth-server ClusterIP] + A2[api-server ClusterIP] + A3[keycloak ClusterIP] + A4[cluster-local URLs only] + end + + A4 --> A1 + A4 --> A2 + A4 --> A3 +``` + +### 2. 문제점 +- 앱 접근 경로가 사실상 cluster 내부 DNS에만 묶여 있어, 브라우저 callback/redirect 를 거는 dev 플로우를 노트북 관점에서 설명하기 어려웠습니다. +- `auth-server`, `api-server`, `keycloak` 이 모두 포트가 드러난 내부 URL에 결합돼 있어, north-south 진입점을 붙이기 전제도 약했습니다. +- 리뷰에서 지적한 `연결만 되면 되는 구조` 에서 최소한의 ingress 경계와 외부 접근 경로가 부족했습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph AFTER[Ingress-backed dev app network] + B1[traefik ingress] + B2[auth-server ClusterIP:80] + B3[api-server ClusterIP:80] + B4[keycloak ClusterIP:80] + B5[local hosts mapping on operator laptop] + end + + B5 --> B1 + B1 --> B2 + B1 --> B3 + B1 --> B4 +``` + +### 4. 이전 구조 대비 변경점 +- `apps/auth-server/overlays/dev`, `apps/api-server/overlays/dev`, `infra/platform/overlays/dev` 에 `Ingress` 를 추가해 north-south 진입점을 만들었습니다. +- `auth-server`, `api-server`, `keycloak` 서비스 포트를 각각 `80 -> targetPort` 형태로 정리해 내부/외부에서 같은 호스트 표기를 쓰기 쉽게 맞췄습니다. +- dev 설정의 issuer/base URL 에서 `:8080/:8081/:8082` 포트 결합을 제거하고, ingress 가능한 host 기준으로 정리했습니다. +- 노트북 dev 한계를 감안해 별도 split-horizon DNS 대신 현재 서비스 FQDN 을 ingress host로 재사용하고, 운영자 로컬 `hosts` 매핑으로 외부 접근을 여는 절충안을 택했습니다. + +### 5. 해결된 내용 +- 이제 dev도 최소한 `ClusterIP 뒤 Ingress` 구조가 되어, callback/redirect 가 필요한 앱 접근 경로를 north-south 관점에서 설명할 수 있게 됐습니다. +- 앱 설정이 내부 포트에 덜 결합돼, 이후 별도 dev 사설 도메인을 붙일 때도 변경 폭이 줄어듭니다. +- namespace 간 통신 제한은 이후 사이클에서 더 세분화할 수 있도록 기반만 먼저 깔았습니다. + +## Cycle 13 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph BEFORE[HTTP ingress only] + A1[auth/api/keycloak ingress] + A2[internal service DNS] + A3[no namespace traffic policy] + end + + A1 --> A2 + A3 --> A2 +``` + +### 2. 문제점 +- public host와 internal host가 완전히 분리되지 않아, dev 기준 public 경로를 일관되게 설명하기 어려웠습니다. +- east-west 제한이 전혀 없으면, namespace를 나눠도 실제 통신 경계가 거의 없는 상태와 다르지 않았습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph AFTER[HTTP + east-west guardrail] + B1[public ExternalName host] + B2[traefik web ingress] + B3[namespace NetworkPolicy allowlist] + end + + B1 --> B2 + B3 --> B2 +``` + +### 4. 이전 구조 대비 변경점 +- `auth-public`, `api-public`, `keycloak-public` `ExternalName` 서비스를 추가해 cluster 내부에서도 public host를 `traefik` 경유로 해석할 수 있게 했습니다. +- `Ingress` 는 `web` entrypoint 기반의 HTTP 경로로 단순화했습니다. +- `keycloak` 은 public hostname과 proxy header를 인지하도록 패치했습니다. +- `auth-dev`, `api-dev`, `platform` 에는 `default deny + allowlist` `NetworkPolicy` 를 추가해 ingress, DNS, Vault, Postgres, Traefik 경로만 열어두었습니다. + +### 5. 해결된 내용 +- 이제 dev도 north-south 경로를 public host 하나로 일관되게 쓸 수 있습니다. +- namespace 분리가 단순 디렉터리/리소스 분리만이 아니라 실제 통신 허용 범위로도 반영되기 시작했습니다. + +## Cycle 14 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph BEFORE[Ingress bootstrap leftovers] + A1[plain Secret in Git] + A2[vault namespace out of policy scope] + A3[local access steps undocumented] + end +``` + +### 2. 문제점 +- ingress 관련 secret이 일반 Secret 평문으로 repo에 남아 있으면 Git에 올릴 수 있는 상태라고 보기 어려웠습니다. +- `vault` namespace는 가장 민감한 통신 경계 중 하나인데, 정책 범위에서 빠져 있으면 east-west 제한이 덜 완성된 상태였습니다. +- 운영자 로컬 접근 절차가 문서화되지 않으면 브라우저/CLI 검증이 사람마다 달라질 수 있었습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph AFTER[Git-safe ingress config] + B1[Git-safe ingress secret handling] + B2[vault server allowlist policy] + end + + B1 --> B2 +``` + +### 4. 이전 구조 대비 변경점 +- `vault` overlay에 workload Vault ingress/egress 정책과 injector webhook ingress 정책을 추가했습니다. + +### 5. 해결된 내용 +- `vault` 도 최소한 서버 트래픽과 webhook ingress 경계가 정책에 반영돼, east-west 제한 범위가 더 자연스러워졌습니다. + +## Cycle 15 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph BEFORE[Dev bootstrap blockers] + A1[Argo CD repo auth missing] + A2[self-hosted runner tool mismatch] + A3[vault-transit raft config incomplete] + end + + A1 --> A3 + A2 --> A3 +``` + +### 2. 문제점 +- Argo CD가 `Project-Auth-GitOps` private repo를 읽지 못해 `vault-transit-dev`, `platform-dev`, `auth-server-dev`, `api-server-dev` 가 모두 `ComparisonError` 상태에 머물렀습니다. +- self-hosted runner는 등록됐지만 `vault`, `terraform` 같은 필수 CLI가 없어 workflow가 `Validate required tools` 단계에서 바로 실패했습니다. +- `vault-transit` deployment가 생성된 뒤에도 Vault가 `Cluster address must be set when using raft storage` 에러로 죽어 bootstrap을 진행할 수 없었습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph AFTER[Diagnosable bootstrap flow] + B1[argocd repo-creds secret] + B2[self-hosted runner with required CLIs] + B3[vault-transit raft api/cluster addr] + B4[repeatable troubleshooting notes] + end + + B1 --> B3 + B2 --> B3 + B3 --> B4 +``` + +### 4. 이전 구조 대비 변경점 +- Argo CD GitHub 인증은 UI 대신 `repo-creds` secret으로 선언형 등록하는 절차를 사용했습니다. +- runner 이슈는 GitHub Actions 로그만 보지 않고, runner 호스트에서 `command -v ...` 로 실제 설치 여부를 확인하는 방식으로 정리했습니다. +- `infra/vault-transit/base/files/vault/vault.hcl` 과 `infra/vault/base/files/vault/vault.hcl` 에 `api_addr`, `cluster_addr`, `cluster_address` 를 추가하고, 두 service/deployment에 `8201` cluster 포트를 열었습니다. +- README에 트러블슈팅 메모 규칙을 추가해, 이후에도 명령과 판단 근거를 함께 누적 기록할 수 있게 했습니다. + +### 5. 해결된 내용 +- Argo CD repo 인증 문제는 선언형 secret 적용 후 `vault-transit-dev` 가 `Synced` 로 전환되는 것으로 원인을 분리할 수 있게 됐습니다. +- self-hosted runner 이슈는 "workflow 코드 문제"와 "runner 환경 문제"를 구분해서 진단하는 기준이 생겼습니다. +- `vault-transit` CrashLoopBackOff 는 raft 설정 누락이 원인임을 로그로 확인했고, 동일 패턴이 `vault` 에 재발하지 않도록 base config까지 함께 보완했습니다. + +### 6. 트러블슈팅 메모 +- 재현/확인 명령: `kubectl -n argocd describe application vault-transit-dev` + 핵심 관찰값: `Failed to load target state`, `authentication required: Repository not found` +- 판단 근거: app/project 객체는 존재하지만 repo-server가 GitHub repo를 읽지 못해 sync 이전 단계에서 실패한다고 판단했습니다. +- 수정 또는 조치: `/tmp/argocd-github-repo-creds.yaml` 로 `argocd.argoproj.io/secret-type=repo-creds` secret을 적용하고 `argocd.argoproj.io/refresh=hard` 로 강제 refresh 했습니다. +- 검증 명령: `kubectl -n argocd describe application vault-transit-dev` + 검증 결과: `OperationCompleted`, `Sync Status: Synced`, `namespace/vault-transit created` +- 재현/확인 명령: runner 호스트에서 `command -v kubectl vault jq base64 curl terraform` + 핵심 관찰값: `vault`, `terraform` 이 비어 있었고 workflow 로그도 `vault is required on the self-hosted runner` 에서 종료됐습니다. +- 판단 근거: job이 GitHub-hosted가 아니라 runner 로컬 셸에서 실행되므로, 해당 머신에 CLI가 실제 설치돼 있어야 한다고 판단했습니다. +- 수정 또는 조치: runner 호스트에 HashiCorp apt repo를 추가하고 `vault`, `terraform` 을 설치했습니다. +- 검증 명령: `vault version`, `terraform version` +- 재현/확인 명령: `kubectl -n vault-transit rollout status deploy/vault-transit --timeout=180s`, `kubectl -n vault-transit logs deploy/vault-transit --tail=200` + 핵심 관찰값: `CrashLoopBackOff`, `Cluster address must be set when using raft storage` +- 판단 근거: 이미지 pull/PVC 문제는 아니고 Vault 프로세스가 raft listener 설정 부족 때문에 바로 종료된다고 판단했습니다. +- 수정 또는 조치: `vault-transit` 와 `vault` base `vault.hcl`, deployment, service에 raft cluster 주소와 `8201` 포트를 추가했습니다. +- 검증 명령: `kubectl kustomize infra/vault-transit/overlays/dev`, `kubectl kustomize infra/vault/overlays/dev` + +## Cycle 16 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph BEFORE[Vault transit unstable startup] + A1[raft address incomplete] + A2[image entrypoint touching read-only config] + A3[RollingUpdate on single PVC] + end + + A1 --> A2 + A2 --> A3 +``` + +### 2. 문제점 +- `vault-transit` application이 sync된 뒤에도 pod가 `CrashLoopBackOff` 에 빠져 bootstrap을 시작할 수 없었습니다. +- 로그에는 `Cluster address must be set when using raft storage` 와 `Could not chown /vault/config` 가 함께 보여, 설정 누락과 기동 방식 문제가 섞여 있었습니다. +- 단일 replica와 단일 PVC를 쓰는 `vault`/`vault-transit` 을 `RollingUpdate` 로 굴리면 old/new pod가 겹치면서 rollout 안정성이 떨어졌습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph AFTER[Stable single-node Vault startup] + B1[raft api_addr + cluster_addr] + B2[listener cluster_address + 8201 port] + B3[copy config to /tmp before start] + B4[Recreate deployment strategy] + end + + B1 --> B2 + B2 --> B3 + B3 --> B4 +``` + +### 4. 이전 구조 대비 변경점 +- `infra/vault-transit/base/files/vault/vault.hcl` 과 `infra/vault/base/files/vault/vault.hcl` 에 `api_addr`, `cluster_addr`, listener `cluster_address` 를 추가했습니다. +- 두 service/deployment에 `8201` cluster 포트를 추가했습니다. +- `vault` 와 `vault-transit` deployment를 `strategy: Recreate` 로 바꿨습니다. +- 두 deployment 모두 ConfigMap의 `vault.hcl` 을 `/tmp/vault.hcl` 로 복사한 뒤 `vault server -config=/tmp/vault.hcl` 로 실행하도록 바꿨습니다. + +### 5. 해결된 내용 +- raft storage 필수 설정 누락으로 인한 즉시 종료 원인을 코드에서 제거했습니다. +- read-only ConfigMap mount와 이미지 entrypoint 충돌 가능성을 줄여, 기동 경로가 더 단순해졌습니다. +- 단일 PVC 기반 Vault rollout에서 old/new pod 겹침을 최소화하는 방향으로 배포 전략을 정리했습니다. + +### 6. 트러블슈팅 메모 +- 재현/확인 명령: `kubectl -n vault-transit rollout status deploy/vault-transit --timeout=180s` + 핵심 관찰값: `deployment "vault-transit" exceeded its progress deadline` +- 재현/확인 명령: `kubectl -n vault-transit get deploy,pods -o wide` + 핵심 관찰값: pod가 `CrashLoopBackOff` +- 재현/확인 명령: `kubectl -n vault-transit logs deploy/vault-transit --tail=200` + 핵심 관찰값: `Cluster address must be set when using raft storage`, `Could not chown /vault/config` +- 판단 근거: config 값 부족만이 아니라, Vault 이미지 기본 entrypoint와 read-only ConfigMap mount 조합도 불안정 요인이라고 판단했습니다. +- 수정 또는 조치: raft 주소/포트 보강, `Recreate` 전략 적용, `/tmp` 복사 후 실행 방식으로 deployment를 단순화했습니다. +- 검증 명령: `kubectl kustomize infra/vault-transit/overlays/dev`, `kubectl kustomize infra/vault/overlays/dev` + +## Cycle 17 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph BEFORE[Workload Vault auth and secret convergence] + A1[vault healthy but auth login 403] + A2[vault egress missing] + A3[legacy DB state vs new Vault values] + A4[Vault Agent template newline breakage] + end + + A1 --> A2 + A2 --> A3 + A3 --> A4 +``` + +### 2. 문제점 +- `postgres`, `auth-db-migration`, `keycloak` pod의 Vault Agent가 `auth/kubernetes/login` 에서 `403 permission denied` 를 내며 secret을 못 받았습니다. +- `vault` namespace default-deny egress 때문에 workload Vault가 Kubernetes API와 PostgreSQL에 나가지 못했습니다. +- Vault KV 값은 최신으로 바뀌었지만 PostgreSQL PVC는 기존 사용자 비밀번호를 유지하고 있어, 앱이 주입받은 값과 DB 내부 상태가 어긋났습니다. +- Vault Agent template의 whitespace trim 때문에 `export` 문이 줄바꿈 없이 붙어서 잘못된 env 파일이 렌더링됐습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph AFTER[Restored workload secret path] + B1[workload vault kubernetes auth restored] + B2[vault -> kubernetes api egress] + B3[vault -> postgres egress] + B4[KV values aligned with runtime state] + B5[template newlines preserved] + end + + B1 --> B2 + B1 --> B3 + B2 --> B4 + B3 --> B4 + B4 --> B5 +``` + +### 4. 이전 구조 대비 변경점 +- `terraform/vault/dev` 로 workload Vault의 `auth/kubernetes`, role, database, transit 구성을 다시 reconcile 했습니다. +- `infra/vault/overlays/dev/networkpolicy.yaml` 에 Kubernetes API egress와 PostgreSQL egress를 추가했습니다. +- provider/workload Vault KV와 실제 PostgreSQL 사용자 상태를 다시 맞추는 절차를 수행했습니다. +- `apps/auth-server/overlays/dev/deployment.vault-patch.yaml`, `infra/platform/overlays/dev/keycloak.vault-patch.yaml`, `infra/platform/overlays/dev/keycloak-client-sync.vault-patch.yaml`, `infra/platform/overlays/dev/postgres.vault-patch.yaml` 에서 Vault template 줄바꿈이 유지되도록 수정했습니다. + +### 5. 해결된 내용 +- Workload Vault가 앱 service account JWT를 받아들여 Vault Agent 인증이 진행되기 시작했습니다. +- `postgres` init container는 Vault Agent 인증을 통과했고, DB credential 발급 단계로 넘어갈 수 있게 됐습니다. +- Keycloak/Auth가 읽는 injected env 파일이 shell 문법상 유효한 형태로 렌더링되기 시작했습니다. +- 남은 앱 health 문제를 “Vault auth 실패”가 아니라 “DB credential mismatch / runtime convergence” 단계로 좁힐 수 있게 됐습니다. + +### 6. 트러블슈팅 메모 +- 재현/확인 명령: `kubectl -n platform logs postgres-0 -c vault-agent-init --tail=80`, `kubectl -n auth-dev logs -c vault-agent-init --tail=80` + 핵심 관찰값: `auth/kubernetes/login` 에서 `403 permission denied` +- 재현/확인 명령: `vault auth list`, `vault read auth/kubernetes/config`, `vault read auth/kubernetes/role/` + 핵심 관찰값: `kubernetes` auth mount가 한때 사라졌고, backend/role을 다시 복구해야 했음 +- 재현/확인 명령: `curl .../tokenreviews` with reviewer token + 핵심 관찰값: Kubernetes `TokenReview` 자체는 성공했고, 문제를 Vault backend / network 쪽으로 좁힐 수 있었음 +- 재현/확인 명령: `kubectl -n vault exec deploy/vault -- nslookup postgres.platform.svc.cluster.local` + 핵심 관찰값: workload Vault pod에서는 headless service 대표 이름이 `NXDOMAIN` 이었고, `postgres-0.postgres.platform.svc.cluster.local` 은 해석됨 +- 판단 근거: `vault` 가 TokenReview와 DB dynamic credential 발급을 하려면 Kubernetes API / PostgreSQL egress가 모두 필요했고, 둘 중 하나라도 막히면 downstream pod가 전부 `Init` 단계에 머문다고 판단했습니다. +- 수정 또는 조치: + - `infra/vault/overlays/dev/networkpolicy.yaml` 에 Kubernetes API / PostgreSQL egress 추가 + - workload Vault auth backend 재생성 및 role 재적용 + - provider/workload Vault KV와 PostgreSQL 실제 사용자 비밀번호 재정렬 + - Vault template 줄바꿈 수정 +- 검증 명령: + - `vault read database/creds/auth-db-migration-dev` + - `kubectl -n platform exec postgres-0 -c postgres -- psql ...` + - `kubectl -n platform exec -c vault-agent -- cat /vault/secrets/keycloak-env` + - `kubectl -n argocd get applications platform-dev auth-server-dev api-server-dev -o wide` + +## Cycle 18 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph BEFORE[CI local state drift] + A1[terraform local backend] + A2[runner cannot see existing state] + A3[workflow AppRole lacks bootstrap privileges] + end + + A1 --> A2 + A2 --> A3 +``` + +### 2. 문제점 +- CI의 `terraform/vault-transit/dev apply` 가 매번 `Plan: 11 to add` 로 시작하며 이미 존재하는 `kv/`, `transit/`, `approle` 을 다시 만들려 했습니다. +- `vault-transit` workflow AppRole 토큰은 기존 리소스 reconcile 용이지, 최초 bootstrap 수준의 `sys/auth/*` / `auth/token/create` 권한까지 갖지 않아 `403 permission denied` 가 났습니다. +- 원인은 runner가 `vault-transit-dev.tfstate` 를 못 보고 local backend state 없이 실행되고 있었기 때문이었습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph AFTER[Fail-fast CI reconcile] + B1[persistent terraform state path] + B2[init -reconfigure with explicit backend path] + B3[empty/missing state guard] + B4[workflow token only reconciles existing resources] + end + + B1 --> B2 + B2 --> B3 + B3 --> B4 +``` + +### 4. 이전 구조 대비 변경점 +- `.github/workflows/vault-dev-reconcile.yaml` 에 `TF_STATE_DIR` 을 추가해 runner workspace의 `.terraform-state` 를 명시적으로 사용하도록 바꿨습니다. +- `terraform init` 에 `-reconfigure -backend-config=path=...` 를 넣어 매 실행마다 state 경로를 명시적으로 고정했습니다. +- `vault-transit` state 파일이 없거나 비어 있으면 bootstrap처럼 create 시도하지 않고, 명확한 에러로 중단하도록 가드를 추가했습니다. + +### 5. 해결된 내용 +- CI가 state 없이 기존 리소스를 다시 만들려다가 실패하는 패턴을 조기에 차단할 수 있게 됐습니다. +- workflow AppRole 토큰이 “기존 리소스 reconcile” 용도라는 점을 workflow 자체에 반영해, bootstrap과 reconcile 경계를 분명히 했습니다. + +### 6. 트러블슈팅 메모 +- 재현/확인 명령: workflow 로그에서 `path is already in use at kv/`, `path is already in use at transit/`, `permission denied` 확인 +- 재현/확인 명령: `terraform -chdir=terraform/vault-transit/dev state list` + 핵심 관찰값: 로컬에는 state가 있지만 CI 실행 컨텍스트에서는 state를 못 보는 패턴이었음 +- 판단 근거: state가 없으니 Terraform이 기존 mount/auth backend를 신규 생성 대상으로 보고, workflow AppRole 토큰은 bootstrap 권한이 없어 403이 난다고 판단했습니다. +- 수정 또는 조치: workflow에서 state path를 고정하고, missing/empty state일 때 fail-fast 하도록 변경했습니다. +- 검증 명령: 다음 CI 실행에서 `Missing vault-transit Terraform state ...` 또는 정상 `state list` 통과 여부 확인 + +## Cycle 19 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph BEFORE[Post-bootstrap runtime drift] + A1[vault auth backend drift] + A2[vault egress gaps] + A3[persisted postgres state] + A4[malformed injected env files] + A5[CI local state missing] + end + + A1 --> A2 + A2 --> A3 + A3 --> A4 + A5 --> A1 +``` + +### 2. 문제점 +- workload Vault는 살아 있었지만 `auth/kubernetes/login` 이 `403 permission denied` 를 내며 `postgres`, `auth-db-migration`, `keycloak` 의 Vault Agent init이 모두 막혔습니다. +- `vault` namespace egress가 Kubernetes API와 PostgreSQL까지 열려 있지 않아 TokenReview와 DB dynamic credential 발급이 실패했습니다. +- PostgreSQL PVC를 유지한 상태에서 Vault KV 값만 바꾸면 DB 내부 사용자 비밀번호와 새 주입값이 어긋나 Keycloak/Auth가 계속 로그인에 실패했습니다. +- Vault Agent template에서 aggressive trim을 써서 `export` 문이 붙어 렌더링되고, 실제 injected env 파일이 shell 문법상 깨졌습니다. +- CI는 local backend state를 못 본 채 기존 `vault-transit` 리소스를 다시 만들려 해서 `path is already in use` / `permission denied` 로 실패했습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph AFTER[Recoverable runtime convergence] + B1[workload vault auth recreated] + B2[vault -> kubernetes api egress] + B3[vault -> postgres egress] + B4[provider/workload KV re-aligned] + B5[postgres/keycloak runtime state re-aligned] + B6[template newlines preserved] + B7[CI imports or reuses local state] + end + + B1 --> B2 + B1 --> B3 + B2 --> B4 + B3 --> B5 + B4 --> B5 + B5 --> B6 + B7 --> B1 +``` + +### 4. 이전 구조 대비 변경점 +- `terraform/vault/dev` 로 workload Vault의 `kubernetes` auth backend와 관련 role을 다시 복구했습니다. +- `infra/vault/overlays/dev/networkpolicy.yaml` 에 Kubernetes API egress, PostgreSQL egress를 추가했습니다. +- provider/workload Vault KV 값을 실제 persisted DB 상태와 비교해 다시 정렬하고, 필요 시 PostgreSQL 사용자 비밀번호도 직접 맞췄습니다. +- `apps/auth-server/overlays/dev/deployment.vault-patch.yaml`, `infra/platform/overlays/dev/keycloak.vault-patch.yaml`, `infra/platform/overlays/dev/keycloak-client-sync.vault-patch.yaml`, `infra/platform/overlays/dev/postgres.vault-patch.yaml` 의 Vault template 줄바꿈을 보존하도록 수정했습니다. +- `.github/workflows/vault-dev-reconcile.yaml` 에 local state 경로 고정, empty state guard, import 준비 경로를 추가해 CI가 bootstrap 리소스를 새로 만들려 하지 않도록 정리했습니다. + +### 5. 해결된 내용 +- `postgres` Vault Agent init은 최종적으로 인증 성공까지 확인됐고, `postgres-0` 는 `2/2 Running` 으로 회복됐습니다. +- `keycloak` 은 malformed env / DB auth 문제를 분리해서 볼 수 있게 됐고, bootstrap admin/DB credential 정합성까지 운영 관점에서 정리할 수 있게 됐습니다. +- `auth-server` 는 DB 연결 성공과 Spring Boot 초기화 단계까지 올라와, Vault transit/JWT 쪽 남은 런타임 오류만 분리해 볼 수 있게 됐습니다. +- CI는 최소한 state 부재를 모른 채 bootstrap을 다시 시도하는 패턴을 fail-fast 하도록 바뀌었습니다. + +### 6. 트러블슈팅 메모 +- 재현/확인 명령: `kubectl -n platform logs postgres-0 -c vault-agent-init --tail=80`, `kubectl -n auth-dev logs -c vault-agent-init --tail=80` + 핵심 관찰값: `auth/kubernetes/login` 에서 `403 permission denied` +- 재현/확인 명령: `vault auth list`, `vault read auth/kubernetes/config`, `vault read auth/kubernetes/role/` + 핵심 관찰값: `kubernetes` auth mount가 한때 없어졌고, role/config를 다시 복구해야 했음 +- 재현/확인 명령: `curl .../tokenreviews` + 핵심 관찰값: Kubernetes `TokenReview` 는 성공하므로 SA JWT 자체보다 Vault auth/backend/network 문제로 좁혀졌음 +- 재현/확인 명령: `kubectl -n vault exec deploy/vault -- nslookup postgres.platform.svc.cluster.local` + 핵심 관찰값: 대표 headless service 이름은 `NXDOMAIN`, `postgres-0.postgres.platform.svc.cluster.local` 은 해석 가능 +- 재현/확인 명령: `kubectl -n platform exec -c vault-agent -- cat /vault/secrets/keycloak-env` + 핵심 관찰값: `export KC_DB_PASSWORD=...export KC_BOOTSTRAP_ADMIN_PASSWORD=...` 처럼 줄바꿈이 깨져 있었음 +- 재현/확인 명령: `kubectl -n platform exec postgres-0 -c postgres -- psql ...`, `vault kv get ...` + 핵심 관찰값: PostgreSQL 내부 비밀번호와 Vault KV 주입값이 달라 PVC 기반 기존 상태와 새 입력값이 충돌하고 있었음 +- 재현/확인 명령: CI 로그에서 `Plan: 11 to add`, `path is already in use`, `permission denied` + 핵심 관찰값: runner가 local backend state를 못 보고 기존 `vault-transit` 리소스를 신규 생성 대상으로 보고 있었음 + +## Cycle 20 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph BEFORE[Partial state drift] + A1[self-hosted runner workspace] + A2[local backend state exists but is incomplete] + A3[terraform sees some resources, misses others] + A4[workflow token tries create on missing state entries] + end + + A1 --> A2 + A2 --> A3 + A3 --> A4 +``` + +### 2. 문제점 +- CI는 더 이상 완전히 빈 state는 아니었지만, `vault-transit` state에 정책 일부만 남고 mount/auth/token 같은 핵심 리소스가 빠진 **partial state** 상태로 실행되고 있었습니다. +- 기존 workflow는 `state list` 가 완전히 비어 있을 때만 import 하도록 되어 있어, partial state일 때는 import 분기가 전혀 돌지 않았습니다. +- 그래서 Terraform은 빠진 리소스만 신규 생성 대상으로 보고 `kv/`, `transit/`, `approle/` 를 다시 만들려 했고, seal token 생성 단계에서는 `403 permission denied` 가 났습니다. +- 추가로 `vault_approle_auth_backend_role_secret_id` 는 Terraform provider가 import를 지원하지 않아, partial state 복구 시 이 리소스만은 다른 managed resource처럼 state로 되살릴 수 없었습니다. +- imported `vault_mount.kv` 는 live 상태에서 `type = "kv"` + `options.version = "2"` 로 읽히는데, 선언은 `type = "kv-v2"` 였기 때문에 partial state 복구 후에도 mount replacement가 다시 발생했습니다. +- imported `vault_token.seal` 은 기존 accessor revoke가 필요한데, workflow 정책에 `auth/token/revoke-accessor` 권한이 빠져 있었습니다. +- 더 근본적으로는 `vault_mount`, `vault_auth_backend`, `vault_token.seal` 같은 bootstrap 성격의 리소스를 routine CI reconcile에 계속 묶어두면, provider import/state round-trip 차이만으로도 불필요한 replacement가 반복될 수 있었습니다. +- 같은 패턴은 이후 `terraform/vault/dev` 에도 다시 터질 수 있는 구조였습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph AFTER[Per-resource state reconciliation] + B1[terraform init with explicit backend path] + B2[state show per managed resource] + B3[missing resources imported individually] + B4[apply runs only after state convergence] + end + + B1 --> B2 + B2 --> B3 + B3 --> B4 +``` + +### 4. 이전 구조 대비 변경점 +- `.github/workflows/vault-dev-reconcile.yaml` 의 transit 단계 import 로직을 **empty-state 전용 가드**에서 **리소스 단위 ensure/import** 방식으로 변경했습니다. +- transit import 전에 live `vault-transit-automation-dev` policy를 현재 파일 내용으로 한 번 덮어쓰고, 새 토큰으로 다시 로그인하도록 바꿔 `auth/token/lookup-accessor` 같은 새 권한이 import 전에 즉시 반영되게 했습니다. +- import를 지원하지 않는 `vault_approle_auth_backend_role_secret_id.workflow` 는 ensure/import 대상에서 제외하고, state에 없으면 `apply` 때 새 secret ID를 발급하도록 정리했습니다. +- `terraform/vault-transit/dev/main.tf`, `terraform/vault/dev/main.tf` 의 KV mount 선언을 `type = "kv"` + `options = { version = "2" }` 로 바꾸고, mount에는 `prevent_destroy = true` 를 추가했습니다. +- transit/workload KV mount에는 `ignore_changes = [type, options]` 를 추가해 import 표현 차이로 replacement가 반복되지 않게 했습니다. +- `vault_token.seal` 은 routine CI에서 매번 rotation/replacement 하지 않도록 `lifecycle { ignore_changes = all }` 로 바꿨고, Kubernetes secret에는 `wait_for_service_account_token = true` 를 명시해 provider 기본값 드리프트를 줄였습니다. +- `vault-transit` 에 대해 아래 리소스를 매 실행마다 `state show` 로 확인하고, 빠진 경우만 import 하도록 바꿨습니다. + - `vault_mount.kv`, `vault_mount.transit` + - `vault_auth_backend.approle` + - `vault_policy.*` + - `vault_approle_auth_backend_role.workflow` + - `vault_approle_auth_backend_role_secret_id.workflow` + - `vault_token.seal` + - `vault_transit_secret_backend_key.workload_unseal` + - `kubernetes_secret_v1.vault_transit_seal` +- 같은 방식으로 `terraform/vault/dev` 에도 workload Vault managed resource별 import 보강을 추가했습니다. +- `runbooks/vault-transit/dev/policies/vault-transit-automation-dev.hcl` 에 `auth/token/lookup-accessor`, `auth/token/revoke-accessor` 권한을 추가해 token import/replacement cleanup이 가능하도록 보강했습니다. + +### 5. 해결된 내용 +- self-hosted runner가 이전 실패 실행 때문에 **부분적으로만 남은 state** 를 가지고 있어도, 다음 실행에서 빠진 리소스를 개별 import 하며 수렴할 수 있게 됐습니다. +- CI가 “state가 조금이라도 있으니 안전하다”고 오판하고 bootstrap API를 다시 두드리는 경로를 막았습니다. +- transit 단계뿐 아니라 workload 단계도 같은 형태의 local backend drift에 대비할 수 있게 됐습니다. +- AppRole secret-id 리소스는 import 대신 재생성으로 수렴시키되, 기존 secret-id는 즉시 무효화되지 않으므로 현재 CI 로그인에 쓰는 값과 공존할 수 있게 했습니다. +- KV mount 선언과 live import 결과를 맞춰 mount replacement를 제거했고, mount에는 `prevent_destroy` 를 걸어 CI가 provider/workload KV를 다시 지우지 못하게 했습니다. +- seal token replacement가 필요한 경우에도 accessor revoke 권한이 있어 cleanup 단계까지 마칠 수 있게 했습니다. +- bootstrap 성격의 리소스는 CI가 “계속 바꿔야 하는 대상”이 아니라 “존재를 확인하고 drift를 최소화해야 하는 대상”으로 취급하도록 방향을 바꿨습니다. + +### 6. 트러블슈팅 메모 +- 재현/확인 명령: CI 로그에서 `Plan: 8 to add, 1 to change`, `path is already in use at kv/`, `path is already in use at approle/`, `permission denied` + 핵심 관찰값: 완전 빈 state라면 `Plan: 11 to add` 이어야 하는데, 일부 정책만 state에 남아 있어 **partial state** 였음 +- 재현/확인 명령: `terraform -chdir=terraform/vault-transit/dev state list` + 핵심 관찰값: 로컬 정상 state에는 11개 managed resource가 모두 있었음 +- 판단 근거: 기존 workflow는 `state list` 가 비었을 때만 import를 수행하므로, partial state에서는 import가 건너뛰어지고 빠진 리소스를 신규 생성 대상으로 보게 된다고 판단했습니다. +- 판단 근거: `vault_approle_auth_backend_role_secret_id` 는 provider가 import 미지원이므로, 그 항목까지 import 대상으로 유지하면 partial state 복구가 그 단계에서 항상 멈춘다고 판단했습니다. +- 판단 근거: `vault_mount.kv` plan에 `type "kv" -> "kv-v2"` replacement가 보인 것은 선언 방식 mismatch 때문이고, `vault_token.seal` 삭제 실패는 `auth/token/revoke-accessor` 권한 부재 때문이라고 판단했습니다. +- 판단 근거: CI가 bootstrap 리소스를 계속 교체하려 들수록 state/import/provider 표현 차이의 영향을 크게 받으므로, 현업에서는 이런 리소스를 bootstrap 단계와 routine reconcile 단계로 분리하는 편이 안정적이라고 판단했습니다. +- 수정 또는 조치: + - workflow에 `ensure_transit_state_resource`, `ensure_workload_state_resource` 함수를 추가 + - 필요한 import ID를 accessor/path 기준으로 계산해 빠진 리소스만 import + - transit import 전에 `vault policy write vault-transit-automation-dev ...` 후 재로그인 + - transit automation policy에 `auth/token/lookup-accessor`, `auth/token/revoke-accessor` 추가 + - import 미지원인 `vault_approle_auth_backend_role_secret_id` 는 ensure 대상에서 제외 + - transit/workload KV mount 선언을 `kv` + `options.version=2` 로 수정 + - transit/workload mount에 `prevent_destroy = true` 추가 + - transit/workload KV mount에 `ignore_changes = [type, options]` 추가 + - `vault_token.seal` 에 `ignore_changes = all` 추가 + - `kubernetes_secret_v1.vault_transit_seal` 에 `wait_for_service_account_token = true` 명시 +- 검증 명령: 다음 CI 실행에서 `Importing missing vault-transit state for ...` / `Importing missing workload-vault state for ...` 로그가 먼저 나오고, 그 뒤 `terraform apply` 가 create 대신 reconcile로 수렴하는지 확인 + +## Cycle 21 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph BEFORE[Mixed bootstrap + reconcile] + A1[vault-dev-reconcile] + A2[partial state import] + A3[provider bootstrap path missing] + A4[CI tries to continue with routine token] + end + + A1 --> A2 + A2 --> A3 + A3 --> A4 +``` + +### 2. 문제점 +- `vault-dev-reconcile` 가 bootstrap과 reconcile 책임을 같이 지다 보니, provider bootstrap path(`kv/dev/workload/bootstrap`) 가 없을 때도 routine workflow 안에서 해결하려는 구조였습니다. +- 이 구조는 workflow AppRole과 local backend state 특성에 지나치게 민감했고, bootstrap 미완료/복구 상황에서 CI가 불필요하게 복잡해졌습니다. +- 실제로 transit 단계가 통과된 뒤에도 workload bootstrap credential 부재 때문에 workflow가 중간에 실패했습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph AFTER[Separated bootstrap + reconcile] + B1[manual bootstrap runbook] + B2[privileged workload bootstrap] + B3[vault-dev-reconcile] + B4[routine CI reconcile only] + end + + B1 --> B2 + B2 --> B3 + B3 --> B4 +``` + +### 4. 이전 구조 대비 변경점 +- `scripts/vault/dev/bootstrap-runbook.sh`, `scripts/vault-transit/dev/bootstrap-runbook.sh` 가 `TF_STATE_DIR` override를 받아 runner/local 어디서든 같은 state 규칙으로 bootstrap 하도록 맞췄습니다. +- `.github/workflows/vault-dev-reconcile.yaml` 은 transit reconcile 후 provider bootstrap path 존재 여부를 먼저 확인하고, 없으면 manual bootstrap runbook 으로 넘기도록 정리했습니다. + +### 5. 해결된 내용 +- routine CI가 bootstrap까지 억지로 끌고 가다 실패하는 구조를 끊고, “privileged bootstrap” 과 “least-privilege reconcile” 을 역할별로 분리했습니다. +- 운영자는 bootstrap이 필요할 때만 수동 runbook을 실행하고, 평상시 CI는 bootstrap readiness 확인 뒤 안전한 reconcile만 수행하게 됐습니다. + +### 6. 트러블슈팅 메모 +- 재현/확인 명령: CI 로그에서 `Workload Vault bootstrap AppRole is missing from provider Vault.` + 핵심 관찰값: transit reconcile은 성공했지만 provider bootstrap path가 비어 있어 workload routine reconcile 토큰을 만들 수 없었음 +- 판단 근거: bootstrap credential 부재는 privileged bootstrap으로만 해결해야 하고, workflow AppRole 기반 reconcile 단계에서 해결하려고 하면 책임이 섞여 구조가 계속 복잡해진다고 판단했습니다. +- 수정 또는 조치: + - `scripts/vault/dev/bootstrap-runbook.sh`, `scripts/vault-transit/dev/bootstrap-runbook.sh` 에 `TF_STATE_DIR` 지원 추가 + - `vault-dev-reconcile.yaml` 에 manual bootstrap runbook 안내 문구 추가 +- 검증 명령: + - 운영자 터미널에서 `scripts/vault/dev/bootstrap-runbook.sh` 실행 + - 이후 `vault-dev-reconcile` 재실행 + - `kubectl -n argocd get applications -o wide` + +## Cycle 22 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph BEFORE[CI still touched bootstrap-shaped resources] + A1[full terraform roots in CI] + A2[mount/auth/token drift handling] + A3[bootstrap path dependency in reconcile] + end + + A1 --> A2 + A2 --> A3 +``` + +### 2. 문제점 +- `vault-dev-reconcile` 는 bootstrap path가 비어 있으면 명확히 멈추긴 했지만, 내부적으로는 여전히 full Terraform root와 비슷한 책임을 일부 끌고 있었습니다. +- 현업식 권장 구조로 보려면 routine CI는 import 가능한 reconcile 리소스만 다루고, bootstrap-shaped resource는 아예 다른 루트/다른 절차로 분리되는 편이 더 안정적입니다. +- bootstrap workflow를 GitHub에 남겨두는 것도 “privileged bootstrap은 로컬 수동” 원칙과 살짝 어긋났습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph AFTER[Manual bootstrap + reconcile-only CI] + B1[terraform/vault-transit/dev] + B2[terraform/vault/dev] + B3[manual runbooks] + B4[terraform/vault-transit/reconcile] + B5[terraform/vault/reconcile] + B6[vault-dev-reconcile workflow] + end + + B3 --> B1 + B3 --> B2 + B1 --> B4 + B2 --> B5 + B4 --> B6 + B5 --> B6 +``` + +### 4. 이전 구조 대비 변경점 +- routine CI 전용 root를 추가했습니다. + - `terraform/vault-transit/reconcile` + - `terraform/vault/reconcile` +- `vault-dev-reconcile` 는 더 이상 bootstrap 전용 리소스(`mount`, `auth backend`, `secret_id`, `seal token`)를 관리하지 않고, reconcile-safe 리소스만 import/apply 합니다. +- `vault-dev-bootstrap` workflow는 제거하고, bootstrap은 수동 runbook만 사용하도록 정리했습니다. + +### 5. 해결된 내용 +- CI가 bootstrap-shaped 리소스 때문에 계속 state/import/provider 차이에 흔들리던 구조를 끊었습니다. +- routine CI는 least-privilege AppRole과 import 가능한 reconcile 리소스만 다루는 더 안정적인 경로로 수렴했습니다. +- privileged bootstrap token은 GitHub secret store에 넣지 않고 운영자 로컬에서만 쓰는 쪽으로 구조를 정리했습니다. + +### 6. 트러블슈팅 메모 +- 재현/확인 명령: reconcile workflow 로그에서 bootstrap path 부재 확인 + 핵심 관찰값: provider bootstrap path가 없으면 routine CI가 더 진행할 수 없음 +- 판단 근거: bootstrap과 reconcile을 코드 레벨의 Terraform root부터 분리해야 CI가 덜 흔들린다고 판단했습니다. +- 수정 또는 조치: + - `terraform/vault-transit/reconcile`, `terraform/vault/reconcile` 추가 + - `.github/workflows/vault-dev-bootstrap.yaml` 삭제 + - `vault-dev-reconcile.yaml` 을 reconcile-only 루트 기준으로 수정 + - runbook/README/AGENT 를 manual bootstrap 구조에 맞게 갱신 +- 검증 명령: + - 수동 bootstrap 후 `vault-dev-reconcile` 재실행 + - CI plan에서 mount/auth backend/seal token 리소스가 더 이상 나오지 않는지 확인 + +## Cycle 23 + +### 1. 초기 구조 +```mermaid +flowchart TD + subgraph BEFORE[Wrapper-less CI] + A1[vault-dev-reconcile YAML] + A2[long inline bash] + A3[full Terraform responsibility] + A4[GitHub context mixed in bash] + end + + A1 --> A2 + A2 --> A3 + A2 --> A4 +``` + +### 2. 문제점 +- `vault-dev-reconcile.yaml` 안에 긴 bash 로직이 직접 들어 있어 테스트/리뷰/수정 포인트가 YAML과 강하게 결합돼 있었습니다. +- routine CI가 bootstrap용 full Terraform root를 직접 만지면서 state/import 결합도가 높았습니다. +- `update-image-tag.yaml` 는 GitHub context를 inline bash 안에서 분기 처리하고 있어 리뷰 시 변수 흐름을 파악하기 불편했습니다. +- `for ... sleep` 폴링이 남아 있어 가시성과 타임아웃 해석이 불편했습니다. +- 여전히 Vault 접근 경로는 `port-forward` 에 의존하므로, 네트워크 관점의 최종 권장 구조까지는 아직 가지 못했습니다. + +### 3. 변경 후 구조 +```mermaid +flowchart TD + subgraph AFTER[Thin workflow wrappers] + B1[vault-dev-reconcile.yaml] + B2[scripts/ci/reconcile-vault-dev.sh] + B3[terraform/vault-transit/reconcile] + B4[terraform/vault/reconcile] + B5[update-image-tag.yaml] + B6[scripts/ci/update-image-tag.sh] + end + + B1 --> B2 + B2 --> B3 + B2 --> B4 + B5 --> B6 +``` + +### 4. 이전 구조 대비 변경점 +- `scripts/ci/reconcile-vault-dev.sh` 를 추가하고, `vault-dev-reconcile.yaml` 은 단계별 wrapper(step)만 남기도록 줄였습니다. +- routine CI가 쓰는 Terraform 루트를 `terraform/vault-transit/reconcile`, `terraform/vault/reconcile` 로 분리했습니다. +- `kubectl wait --for=condition=available ...` 로 deployment 대기를 정리했습니다. +- `scripts/ci/update-image-tag.sh` 를 추가하고, `update-image-tag.yaml` 은 GitHub context를 `env` 로만 전달하도록 바꿨습니다. +- bootstrap workflow는 제거하고, bootstrap은 수동 runbook 전용으로 정리했습니다. +- `vault-dev-reconcile.yaml` 은 `VAULT_DEV_RECONCILE_RUNS_ON`, `RECONCILE_USE_PORT_FORWARD`, `TRANSIT_VAULT_ADDR`, `WORKLOAD_VAULT_ADDR` GitHub Variables 로 실행 위치와 네트워크 방식을 바꿀 수 있게 정리했습니다. + +### 5. 해결된 내용 +- workflow YAML은 orchestration wrapper 역할에 집중하고, 실제 로직은 저장소 안의 테스트 가능한 스크립트로 이동했습니다. +- routine CI가 bootstrap 리소스를 직접 다루지 않게 되어 Terraform state/import 결합도가 줄었습니다. +- `update-image-tag` 의 변수 해석 흐름이 스크립트 기준으로 단순해져 협업/리뷰 가독성이 좋아졌습니다. +- `kubectl wait` 기반으로 대기 로직이 조금 더 직관적으로 바뀌었습니다. +- `scripts/ci/reconcile-vault-dev.sh` 는 `RECONCILE_USE_PORT_FORWARD=false` 와 in-cluster service URL을 주면 port-forward 없이도 실행할 수 있게 바꿔, 향후 ARC/Job 전환 시 재사용할 수 있게 했습니다. +- 따라서 ARC 도입 시에는 workflow YAML을 다시 뜯기보다 GitHub Variables 만 바꿔 in-cluster service 경로와 runner scale set 이름으로 전환할 수 있게 됐습니다. + +### 6. 트러블슈팅 메모 +- 재현/확인 명령: `sed -n '1,260p' .github/workflows/vault-dev-reconcile.yaml` + 핵심 관찰값: YAML 내부에 긴 inline bash가 남아 있으면 CI 디버깅과 변경 추적이 어려움 +- 판단 근거: GitHub Actions는 wrapper, 실제 로직은 repo의 script/terraform root가 맡는 편이 팀 협업과 유지보수에 유리하다고 판단했습니다. +- 수정 또는 조치: + - `scripts/ci/reconcile-vault-dev.sh` 추가 + - `scripts/ci/update-image-tag.sh` 추가 + - routine reconcile용 Terraform root 추가 + - `vault-dev-reconcile.yaml`, `update-image-tag.yaml` 을 wrapper형으로 축소 + - `reconcile-vault-dev.sh` 에 in-cluster direct access 모드(`RECONCILE_USE_PORT_FORWARD=false`) 추가 + - `vault-dev-reconcile.yaml` 에 runner label / Vault 주소 / port-forward 사용 여부를 GitHub Variables 로 주입하는 경로 추가 +- 검증 명령: + - `git diff --check` + - 다음 CI 실행에서 단계별 step 실패 지점이 UI에 분리되어 보이는지 확인 + - `update-image-tag` 수동 실행으로 env/입력 해석이 정상인지 확인 diff --git a/apps/api-server/base/deployment.yaml b/apps/api-server/base/deployment.yaml new file mode 100644 index 0000000..fb4321d --- /dev/null +++ b/apps/api-server/base/deployment.yaml @@ -0,0 +1,69 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: api-server +spec: + replicas: 1 + selector: + matchLabels: + app: api-server + template: + metadata: + labels: + app: api-server + spec: + serviceAccountName: api-server + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + seccompProfile: + type: RuntimeDefault + containers: + - name: api-server + image: ghcr.io/donghyeonka/project-api-server + imagePullPolicy: Always + ports: + - containerPort: 8082 + name: http + envFrom: + - configMapRef: + name: api-server-config + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: false + resources: + requests: + cpu: 250m + memory: 256Mi + limits: + cpu: 1000m + memory: 512Mi + readinessProbe: + httpGet: + path: /actuator/health/readiness + port: http + initialDelaySeconds: 15 + timeoutSeconds: 3 + failureThreshold: 3 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /actuator/health/liveness + port: http + initialDelaySeconds: 20 + timeoutSeconds: 3 + failureThreshold: 5 + periodSeconds: 15 + startupProbe: + httpGet: + path: /actuator/health/liveness + port: http + initialDelaySeconds: 5 + timeoutSeconds: 3 + periodSeconds: 10 + failureThreshold: 12 diff --git a/apps/api-server/base/kustomization.yaml b/apps/api-server/base/kustomization.yaml new file mode 100644 index 0000000..85a5b98 --- /dev/null +++ b/apps/api-server/base/kustomization.yaml @@ -0,0 +1,7 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - serviceaccount.yaml + - service.yaml + - deployment.yaml diff --git a/apps/api-server/base/service.yaml b/apps/api-server/base/service.yaml new file mode 100644 index 0000000..80321e7 --- /dev/null +++ b/apps/api-server/base/service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: api-server +spec: + selector: + app: api-server + ports: + - name: http + port: 80 + targetPort: 8082 + type: ClusterIP diff --git a/apps/api-server/base/serviceaccount.yaml b/apps/api-server/base/serviceaccount.yaml new file mode 100644 index 0000000..d996eea --- /dev/null +++ b/apps/api-server/base/serviceaccount.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: api-server +automountServiceAccountToken: false +imagePullSecrets: + - name: ghcr-regcred diff --git a/apps/api-server/overlays/dev/configmap.yaml b/apps/api-server/overlays/dev/configmap.yaml new file mode 100644 index 0000000..89c643d --- /dev/null +++ b/apps/api-server/overlays/dev/configmap.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: api-server-config +data: + APP_SERVER_PORT: "8082" + APP_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: http://auth-public.auth-dev.svc.cluster.local diff --git a/apps/api-server/overlays/dev/ghcr-regcred.sealedsecret.yaml b/apps/api-server/overlays/dev/ghcr-regcred.sealedsecret.yaml new file mode 100644 index 0000000..954147d --- /dev/null +++ b/apps/api-server/overlays/dev/ghcr-regcred.sealedsecret.yaml @@ -0,0 +1,14 @@ +--- +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + name: ghcr-regcred + namespace: api-dev +spec: + encryptedData: + .dockerconfigjson: AgC7O6nq0aej7pxKXYyJy01HomK4s4jqkrwJ2iiVqjWrv4LjMqYY/8XiPnv7eyOAhBlpnSjULZ0pNPnQjUh6TUkcfbV0VOXjha6qkFIRZXLTlySvooS4FJH3lGjYBlli5bDfVw2KZTy9zR3qcpclOVJo5eEGJ+3rrl6NCUV7CzXfhpMsdATXtDRK1reSlpS2qWgLYJ8Dt0mdaz9yt1baGC3PwjeohnbmeCxRDi8HWOgWTXj0NqBtnpHnzQz5dox5NduQQCDh4ZrP7XK4CgeC3OIuTy1BokpTD5xfN3e0apiiZcUKQCN2I4NJ+B/JqgxceLxVXl3kYswlH+MjkzuejB3OPcGJsfQAs3gJUPB460mzT2S21xm4GloqL73utz+JuyE4pmCuPbFzgaJFNEamtAWtwZRiUwpjBG0HHLg2JPuLc07ljzfSiO9IqOA2zFmgYTX1dLdhrgSzIO6jaWFvctKTqavPxTRHNjBG9as+9gbQ7caH7vcV4k7q7XyEWpeEv9rCyVWcuRvZ7QNkHU1ss44uimr5I43JkEnsmacFRanxIlB80t357iGNiGNGCJFgfiVnh1PqmPlCvEtA42iTt+++2aU+x1SmiS4LYEs4YowVmanm0t1dBWSOMC7y6kM91W/fGPEo5i0lWbdvDKFuNS8RAWu4mXTgLjgbWz9a5PMcdioh0JMxV78FkDkU1jte/hFeIMOmA/xwrzIXVu7JTJK5a+6A9Dmhjpuiunv1nhDsxg1okPjiJ7W4T2pc0JpU+7XjhsXipE0FCwiNxwsh0GvI5MFGmipQwKbcSJuQJrXqAbTZZyDpa+0rUyc9YhYxAgxKf8AzxffMgDKm68MuS8KVdvIhC0dKUOccRnmrSYN8GMJSlZE065MOjY5FGsVfPRPHMYLhQnkNQPXSDB34UXl97mrUIz6mR3mnZar0PKqm9d5vZ19ujIwV7ZQEar8Sf6ZbT8n25hLUmq6JkGzsRiHo42S2JoVl1QlHgzQbv6i/JPsjzQcKfVhtAIXiyMIMvsbYLCjX9df6yVU3e0pCBVl3sGSMbOf4/QVrJtCqog== + template: + metadata: + name: ghcr-regcred + namespace: api-dev + type: kubernetes.io/dockerconfigjson diff --git a/apps/api-server/overlays/dev/ingress.yaml b/apps/api-server/overlays/dev/ingress.yaml new file mode 100644 index 0000000..504262e --- /dev/null +++ b/apps/api-server/overlays/dev/ingress.yaml @@ -0,0 +1,19 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: api-server + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: web +spec: + ingressClassName: traefik + rules: + - host: api-public.api-dev.svc.cluster.local + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: api-server + port: + number: 80 diff --git a/apps/api-server/overlays/dev/kustomization.yaml b/apps/api-server/overlays/dev/kustomization.yaml new file mode 100644 index 0000000..627dae5 --- /dev/null +++ b/apps/api-server/overlays/dev/kustomization.yaml @@ -0,0 +1,21 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: api-dev + +resources: + - ../../base + - namespace.yaml + - configmap.yaml + - ingress.yaml + - public-access.yaml + - networkpolicy.yaml + - ghcr-regcred.sealedsecret.yaml + +generatorOptions: + disableNameSuffixHash: true + +images: + - name: ghcr.io/donghyeonka/project-api-server + newName: ghcr.io/donghyeonka/project-api-server + newTag: fd097c9 diff --git a/apps/api-server/overlays/dev/namespace.yaml b/apps/api-server/overlays/dev/namespace.yaml new file mode 100644 index 0000000..208d429 --- /dev/null +++ b/apps/api-server/overlays/dev/namespace.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: api-dev + labels: + pod-security.kubernetes.io/enforce: baseline + pod-security.kubernetes.io/enforce-version: latest + pod-security.kubernetes.io/warn: restricted + pod-security.kubernetes.io/warn-version: latest + pod-security.kubernetes.io/audit: restricted + pod-security.kubernetes.io/audit-version: latest diff --git a/apps/api-server/overlays/dev/networkpolicy.yaml b/apps/api-server/overlays/dev/networkpolicy.yaml new file mode 100644 index 0000000..f736777 --- /dev/null +++ b/apps/api-server/overlays/dev/networkpolicy.yaml @@ -0,0 +1,75 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: api-dev-default-deny +spec: + podSelector: {} + policyTypes: + - Ingress + - Egress +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: api-dev-allow-dns-egress +spec: + podSelector: {} + policyTypes: + - Egress + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: api-dev-allow-public-egress +spec: + podSelector: + matchLabels: + app: api-server + policyTypes: + - Egress + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + app.kubernetes.io/name: traefik + ports: + - protocol: TCP + port: 8000 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: api-dev-allow-ingress-from-traefik +spec: + podSelector: + matchLabels: + app: api-server + policyTypes: + - Ingress + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + app.kubernetes.io/name: traefik + ports: + - protocol: TCP + port: 8082 diff --git a/apps/api-server/overlays/dev/public-access.yaml b/apps/api-server/overlays/dev/public-access.yaml new file mode 100644 index 0000000..eeea423 --- /dev/null +++ b/apps/api-server/overlays/dev/public-access.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: api-public +spec: + type: ExternalName + externalName: traefik.kube-system.svc.cluster.local + ports: + - name: http + port: 80 + targetPort: 80 diff --git a/apps/api-server/overlays/prod/kustomization.yaml b/apps/api-server/overlays/prod/kustomization.yaml new file mode 100644 index 0000000..d6334b3 --- /dev/null +++ b/apps/api-server/overlays/prod/kustomization.yaml @@ -0,0 +1,13 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: api-prod + +resources: + - ../../base + - namespace.yaml + +images: + - name: ghcr.io/donghyeonka/project-api-server + newName: ghcr.io/donghyeonka/project-api-server + newTag: fd097c9 diff --git a/apps/api-server/overlays/prod/namespace.yaml b/apps/api-server/overlays/prod/namespace.yaml new file mode 100644 index 0000000..e400559 --- /dev/null +++ b/apps/api-server/overlays/prod/namespace.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: api-prod + labels: + pod-security.kubernetes.io/enforce: baseline + pod-security.kubernetes.io/enforce-version: latest + pod-security.kubernetes.io/warn: restricted + pod-security.kubernetes.io/warn-version: latest + pod-security.kubernetes.io/audit: restricted + pod-security.kubernetes.io/audit-version: latest diff --git a/apps/auth-server/base/auth-db-migration-serviceaccount.yaml b/apps/auth-server/base/auth-db-migration-serviceaccount.yaml new file mode 100644 index 0000000..c434495 --- /dev/null +++ b/apps/auth-server/base/auth-db-migration-serviceaccount.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: auth-db-migration + annotations: + argocd.argoproj.io/hook: PreSync + argocd.argoproj.io/sync-wave: "-2" +automountServiceAccountToken: false +imagePullSecrets: + - name: ghcr-regcred diff --git a/apps/auth-server/base/db-migration-job.yaml b/apps/auth-server/base/db-migration-job.yaml new file mode 100644 index 0000000..3d1060b --- /dev/null +++ b/apps/auth-server/base/db-migration-job.yaml @@ -0,0 +1,69 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: auth-db-migration + annotations: + argocd.argoproj.io/hook: PreSync + argocd.argoproj.io/hook-delete-policy: BeforeHookCreation,HookSucceeded + argocd.argoproj.io/sync-wave: "-1" +spec: + backoffLimit: 1 + template: + metadata: + labels: + app: auth-db-migration + spec: + serviceAccountName: auth-db-migration + automountServiceAccountToken: false + restartPolicy: Never + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + seccompProfile: + type: RuntimeDefault + containers: + - name: auth-db-migration + image: ghcr.io/donghyeonka/project-auth-server + imagePullPolicy: IfNotPresent + command: + - java + - -jar + - /app/migration.jar + env: + - name: SPRING_MAIN_WEB_APPLICATION_TYPE + value: none + - name: APP_PERSISTENCE_MIGRATION_RUN_ON_STARTUP + value: "true" + - name: APP_PERSISTENCE_MIGRATION_LOCATION + value: classpath:db/migration + - name: APP_DATASOURCE_URL + valueFrom: + configMapKeyRef: + name: auth-server-config + key: APP_DATASOURCE_URL + - name: APP_DATASOURCE_USERNAME + valueFrom: + secretKeyRef: + name: postgres-auth-server-credentials + key: APP_DATASOURCE_USERNAME + - name: APP_DATASOURCE_PASSWORD + valueFrom: + secretKeyRef: + name: postgres-auth-server-credentials + key: APP_DATASOURCE_PASSWORD + - name: APP_DATASOURCE_DRIVER_CLASS_NAME + value: org.postgresql.Driver + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: false + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi diff --git a/apps/auth-server/base/deployment.yaml b/apps/auth-server/base/deployment.yaml new file mode 100644 index 0000000..f54c091 --- /dev/null +++ b/apps/auth-server/base/deployment.yaml @@ -0,0 +1,78 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: auth-server + annotations: + argocd.argoproj.io/sync-wave: "0" + +spec: + replicas: 1 + selector: + matchLabels: + app: auth-server + + template: + metadata: + labels: + app: auth-server + + spec: + serviceAccountName: auth-server + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + seccompProfile: + type: RuntimeDefault + containers: + - name: auth-server + image: ghcr.io/donghyeonka/project-auth-server + imagePullPolicy: IfNotPresent + ports: + - containerPort: 8080 + name: http + envFrom: + - configMapRef: + name: auth-server-config + - secretRef: + name: postgres-auth-server-credentials + - secretRef: + name: keycloak-client-auth-server + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: false + resources: + requests: + cpu: 250m + memory: 512Mi + limits: + cpu: 1000m + memory: 1024Mi + readinessProbe: + httpGet: + path: /actuator/health/readiness + port: http + initialDelaySeconds: 20 + timeoutSeconds: 3 + failureThreshold: 3 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /actuator/health/liveness + port: http + initialDelaySeconds: 30 + timeoutSeconds: 3 + failureThreshold: 5 + periodSeconds: 15 + startupProbe: + httpGet: + path: /actuator/health/liveness + port: http + initialDelaySeconds: 10 + timeoutSeconds: 3 + periodSeconds: 10 + failureThreshold: 12 diff --git a/apps/auth-server/base/kustomization.yaml b/apps/auth-server/base/kustomization.yaml new file mode 100644 index 0000000..9fca14e --- /dev/null +++ b/apps/auth-server/base/kustomization.yaml @@ -0,0 +1,9 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - serviceaccount.yaml + - auth-db-migration-serviceaccount.yaml + - service.yaml + - db-migration-job.yaml + - deployment.yaml diff --git a/apps/auth-server/base/service.yaml b/apps/auth-server/base/service.yaml new file mode 100644 index 0000000..d76bea8 --- /dev/null +++ b/apps/auth-server/base/service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: auth-server +spec: + selector: + app: auth-server + ports: + - name: http + port: 80 + targetPort: 8080 + type: ClusterIP diff --git a/apps/auth-server/base/serviceaccount.yaml b/apps/auth-server/base/serviceaccount.yaml new file mode 100644 index 0000000..2172948 --- /dev/null +++ b/apps/auth-server/base/serviceaccount.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: auth-server +automountServiceAccountToken: false +imagePullSecrets: + - name: ghcr-regcred diff --git a/apps/auth-server/overlays/dev/configmap.yaml b/apps/auth-server/overlays/dev/configmap.yaml new file mode 100644 index 0000000..32636df --- /dev/null +++ b/apps/auth-server/overlays/dev/configmap.yaml @@ -0,0 +1,26 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: auth-server-config +data: + SPRING_PROFILES_ACTIVE: dev + SERVER_FORWARD_HEADERS_STRATEGY: framework + APP_DOCS_TITLE: Project Auth Server API + APP_DOCS_DESCRIPTION: dev auth-server OpenAPI + APP_DOCS_VERSION: v1 + APP_DATASOURCE_URL: jdbc:postgresql://postgres.platform.svc.cluster.local:5432/project_auth + APP_PERSISTENCE_MIGRATION_RUN_ON_STARTUP: "false" + APP_SECURITY_OAUTH2_KEYCLOAK_ISSUER_URI: http://keycloak-public.platform.svc.cluster.local/realms/project-auth + APP_SECURITY_OAUTH2_KEYCLOAK_CLIENT_ID: project-auth-server + APP_SECURITY_OAUTH2_GOOGLE_REGISTRATION_ID: keycloak-google + APP_SECURITY_OAUTH2_GOOGLE_IDP_HINT: google + APP_SECURITY_OAUTH2_GITHUB_REGISTRATION_ID: keycloak-github + APP_SECURITY_OAUTH2_GITHUB_IDP_HINT: github + APP_SECURITY_JWT_ISSUER: http://auth-public.auth-dev.svc.cluster.local + APP_SECURITY_JWT_ACTIVE_KEY_ID: dev-vault-rsa-1 + APP_SECURITY_JWT_GENERATE_KEY_PAIR_ON_STARTUP: "false" + APP_SECURITY_JWT_ACCESS_TOKEN_EXPIRATION: PT30M + APP_SECURITY_JWT_VAULT_ENABLED: "true" + APP_SECURITY_JWT_VAULT_ADDRESS: http://vault.vault.svc.cluster.local:8200 + APP_SECURITY_JWT_VAULT_MOUNT_PATH: transit + APP_SECURITY_JWT_VAULT_TRANSIT_KEY_NAME: project-auth-jwt diff --git a/apps/auth-server/overlays/dev/db-migration-job.vault-patch.yaml b/apps/auth-server/overlays/dev/db-migration-job.vault-patch.yaml new file mode 100644 index 0000000..30622da --- /dev/null +++ b/apps/auth-server/overlays/dev/db-migration-job.vault-patch.yaml @@ -0,0 +1,45 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: auth-db-migration +spec: + template: + metadata: + annotations: + vault.hashicorp.com/agent-inject: "true" + vault.hashicorp.com/agent-inject-secret-migration-env: database/creds/auth-db-migration-dev + vault.hashicorp.com/agent-inject-template-migration-env: | + {{- with secret "database/creds/auth-db-migration-dev" -}} + export APP_DATASOURCE_USERNAME={{ printf "%q" .Data.username }} + export APP_DATASOURCE_PASSWORD={{ printf "%q" .Data.password }} + {{- end }} + vault.hashicorp.com/agent-pre-populate-only: "true" + vault.hashicorp.com/agent-run-as-group: "10001" + vault.hashicorp.com/agent-run-as-user: "10001" + vault.hashicorp.com/role: auth-db-migration-dev + spec: + automountServiceAccountToken: true + containers: + - name: auth-db-migration + command: + - /bin/sh + - -ec + args: + - | + . /vault/secrets/migration-env + exec java -jar /app/migration.jar + env: + - $patch: replace + - name: SPRING_MAIN_WEB_APPLICATION_TYPE + value: none + - name: APP_PERSISTENCE_MIGRATION_RUN_ON_STARTUP + value: "true" + - name: APP_PERSISTENCE_MIGRATION_LOCATION + value: classpath:db/migration + - name: APP_DATASOURCE_URL + valueFrom: + configMapKeyRef: + name: auth-server-config + key: APP_DATASOURCE_URL + - name: APP_DATASOURCE_DRIVER_CLASS_NAME + value: org.postgresql.Driver diff --git a/apps/auth-server/overlays/dev/deployment.vault-patch.yaml b/apps/auth-server/overlays/dev/deployment.vault-patch.yaml new file mode 100644 index 0000000..54caea2 --- /dev/null +++ b/apps/auth-server/overlays/dev/deployment.vault-patch.yaml @@ -0,0 +1,39 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: auth-server +spec: + template: + metadata: + annotations: + vault.hashicorp.com/agent-cache-enable: "true" + vault.hashicorp.com/agent-inject: "true" + vault.hashicorp.com/agent-inject-secret-runtime-env: kv/data/dev/platform/postgres/auth-server + vault.hashicorp.com/agent-inject-template-runtime-env: | + {{ with secret "kv/data/dev/platform/postgres/auth-server" }} + export APP_DATASOURCE_USERNAME={{ printf "%q" .Data.data.APP_DATASOURCE_USERNAME }} + export APP_DATASOURCE_PASSWORD={{ printf "%q" .Data.data.APP_DATASOURCE_PASSWORD }} + {{ end }} + + {{ with secret "kv/data/dev/platform/keycloak/client-auth-server" }} + export APP_SECURITY_OAUTH2_KEYCLOAK_CLIENT_SECRET={{ printf "%q" .Data.data.APP_SECURITY_OAUTH2_KEYCLOAK_CLIENT_SECRET }} + {{ end }} + vault.hashicorp.com/agent-inject-token: "true" + vault.hashicorp.com/agent-run-as-group: "10001" + vault.hashicorp.com/agent-run-as-user: "10001" + vault.hashicorp.com/role: auth-server-dev + spec: + automountServiceAccountToken: true + containers: + - name: auth-server + command: + - /bin/sh + - -ec + args: + - | + . /vault/secrets/runtime-env + export APP_SECURITY_JWT_VAULT_TOKEN="$(cat /vault/secrets/token)" + exec java -jar /app/application.jar + envFrom: + - configMapRef: + name: auth-server-config diff --git a/apps/auth-server/overlays/dev/ghcr-regcred.sealedsecret.yaml b/apps/auth-server/overlays/dev/ghcr-regcred.sealedsecret.yaml new file mode 100644 index 0000000..ba7fea9 --- /dev/null +++ b/apps/auth-server/overlays/dev/ghcr-regcred.sealedsecret.yaml @@ -0,0 +1,14 @@ +--- +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + name: ghcr-regcred + namespace: auth-dev +spec: + encryptedData: + .dockerconfigjson: AgAIKHR/abtMIrkphJPG63jmOCXIMSBdDMdMUYjx37FRV9SpCA/j3UTHhhZlDSK5gXK/p9FGsR1mBjME+imHEa7/rqBMzfSoJjRQCln3TjD1GT+XJ9J8/qS9AQl+GG9kcJfVMTfGslFbQc1993uURdb/CDupe8XsJosMfzvV5h+SpSdytU2Y8CrhZSDNtz7/2Y+x0LQNfyafjXCxsc1kS5qt3WzPFD/zyk70+e2oq868ETBaij2dHEAQXH+XVz2ilqyzLE633X3/Wf/ADRMJ1i7XWaMir01mSXaB97U2+OPaSRJDf1TAfikn6D4BydNsTgI2UQ839edrgr9EXz1Sr4oYdpWjOpNmtH6OjfiiRj2r0LMvNKR/C1ruROfBcEV7y3kXpKRM5kwG+WM1/1B0ziodu0LZJl23QAgfNnTJrYlJJXJNSFNapPMWEcq91TMeauSIyRgbtLUZEemmlTVybt2SV86Yr+w9SlNAsBJGQaW34Ljfmu7X9H0JwTDkphWUotSPmmKj2GFdpVaNwL0Xvi6OoytiWu0rJWVkGar3mnyLPrkVLb5AlOZYPKxfPhZJfwZxPXBqgWj+6kHGs1SpH6v55wVIz1MdMoGcVqcjlOiMNFd2leOyejYQvRBXKRW3wgzgB6J0OdL1S8z1cOfblO4egx9C6RsaxDC//VDK36aAYvdc55Hsk2GC4iZKsug6EAdXWiiJjdM9yfAcGcv7LemQneA3OslJTdDTpWUdbREiIDXBX4PjEQz0G4oI5YDNYvS8NxUOWZ1ly0BH+uHbcm69tnD9ATSVvjvtKlLiP7/BCYZAguCzNEYkmf1neTtHYlVsoPmIuTkifaf9qRPU831MYj8wTfz56YVT1+aSbfQBK4XP49+bIf5AVo9K5JuEQETqMhZ0G05VjmlwUrgtQ60oZLu8dbvFyt/0VxfWBGcCi+AW+nmro3c14XmA4k1d09xtuoa+8QEObtOTEow3+tyqL46FlJgl7UjX8dd4p7gUAF/iADw8a0M32kIN0NHJ9o2EsFe85rn9qBoDLGrogDXRXJ4iCxE0Zgf7bw6tFA== + template: + metadata: + name: ghcr-regcred + namespace: auth-dev + type: kubernetes.io/dockerconfigjson diff --git a/apps/auth-server/overlays/dev/ingress.yaml b/apps/auth-server/overlays/dev/ingress.yaml new file mode 100644 index 0000000..53ddbc6 --- /dev/null +++ b/apps/auth-server/overlays/dev/ingress.yaml @@ -0,0 +1,19 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: auth-server + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: web +spec: + ingressClassName: traefik + rules: + - host: auth-public.auth-dev.svc.cluster.local + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: auth-server + port: + number: 80 diff --git a/apps/auth-server/overlays/dev/kustomization.yaml b/apps/auth-server/overlays/dev/kustomization.yaml new file mode 100644 index 0000000..9496b49 --- /dev/null +++ b/apps/auth-server/overlays/dev/kustomization.yaml @@ -0,0 +1,25 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: auth-dev + +resources: + - ../../base + - namespace.yaml + - configmap.yaml + - ingress.yaml + - public-access.yaml + - networkpolicy.yaml + - ghcr-regcred.sealedsecret.yaml + +patches: + - path: deployment.vault-patch.yaml + - path: db-migration-job.vault-patch.yaml + +generatorOptions: + disableNameSuffixHash: true + +images: +- name: ghcr.io/donghyeonka/project-auth-server + newName: ghcr.io/donghyeonka/project-auth-server + newTag: 1f47f2c diff --git a/apps/auth-server/overlays/dev/namespace.yaml b/apps/auth-server/overlays/dev/namespace.yaml new file mode 100644 index 0000000..8a61529 --- /dev/null +++ b/apps/auth-server/overlays/dev/namespace.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: auth-dev + labels: + vault-injection: enabled + pod-security.kubernetes.io/enforce: baseline + pod-security.kubernetes.io/enforce-version: latest + pod-security.kubernetes.io/warn: restricted + pod-security.kubernetes.io/warn-version: latest + pod-security.kubernetes.io/audit: restricted + pod-security.kubernetes.io/audit-version: latest diff --git a/apps/auth-server/overlays/dev/networkpolicy.yaml b/apps/auth-server/overlays/dev/networkpolicy.yaml new file mode 100644 index 0000000..62f73c6 --- /dev/null +++ b/apps/auth-server/overlays/dev/networkpolicy.yaml @@ -0,0 +1,105 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: auth-dev-default-deny +spec: + podSelector: {} + policyTypes: + - Ingress + - Egress +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: auth-dev-allow-dns-egress +spec: + podSelector: {} + policyTypes: + - Egress + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: auth-dev-allow-platform-and-vault-egress +spec: + podSelector: {} + policyTypes: + - Egress + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: platform + podSelector: + matchLabels: + app: postgres + ports: + - protocol: TCP + port: 5432 + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: vault + podSelector: + matchLabels: + app: vault + ports: + - protocol: TCP + port: 8200 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: auth-dev-allow-public-egress +spec: + podSelector: + matchLabels: + app: auth-server + policyTypes: + - Egress + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + app.kubernetes.io/name: traefik + ports: + - protocol: TCP + port: 8000 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: auth-dev-allow-ingress-from-traefik +spec: + podSelector: + matchLabels: + app: auth-server + policyTypes: + - Ingress + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + app.kubernetes.io/name: traefik + ports: + - protocol: TCP + port: 8080 diff --git a/apps/auth-server/overlays/dev/public-access.yaml b/apps/auth-server/overlays/dev/public-access.yaml new file mode 100644 index 0000000..6945106 --- /dev/null +++ b/apps/auth-server/overlays/dev/public-access.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: auth-public +spec: + type: ExternalName + externalName: traefik.kube-system.svc.cluster.local + ports: + - name: http + port: 80 + targetPort: 80 diff --git a/apps/auth-server/overlays/prod/kustomization.yaml b/apps/auth-server/overlays/prod/kustomization.yaml new file mode 100644 index 0000000..8d80640 --- /dev/null +++ b/apps/auth-server/overlays/prod/kustomization.yaml @@ -0,0 +1,13 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: auth-prod + +resources: + - ../../base + - namespace.yaml + +images: + - name: ghcr.io/donghyeonka/project-auth-server + newName: ghcr.io/donghyeonka/project-auth-server + newTag: 5648fd2 diff --git a/apps/auth-server/overlays/prod/namespace.yaml b/apps/auth-server/overlays/prod/namespace.yaml new file mode 100644 index 0000000..56deb73 --- /dev/null +++ b/apps/auth-server/overlays/prod/namespace.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: auth-prod + labels: + pod-security.kubernetes.io/enforce: baseline + pod-security.kubernetes.io/enforce-version: latest + pod-security.kubernetes.io/warn: restricted + pod-security.kubernetes.io/warn-version: latest + pod-security.kubernetes.io/audit: restricted + pod-security.kubernetes.io/audit-version: latest diff --git a/argocd/README.md b/argocd/README.md new file mode 100644 index 0000000..14832b6 --- /dev/null +++ b/argocd/README.md @@ -0,0 +1,18 @@ +## Argo CD Structure + +`argocd/` 디렉터리는 환경(`dev`, `prod`)과 성격(`apps`, `infra`) 기준으로 나눠 관리합니다. + +- `applications//apps`: 서비스 애플리케이션 선언 +- `applications//infra`: 공용 인프라/컨트롤러 선언 +- `projects//apps-project.yaml`: 서비스 애플리케이션용 AppProject +- `projects//infra-project.yaml`: 공용 인프라용 AppProject + +현재 `dev`에는 실제 선언을 두고, `prod`는 이후 운영 확장을 위한 구조와 프로젝트 골격을 먼저 유지합니다. + +현재 dev `infra`에는 대표적으로 아래 Application이 포함됩니다. + +- `vault-transit`: workload Vault transit auto-unseal provider +- `vault`: workload Vault +- `platform`: postgres, keycloak +- `vault-agent-injector`: workload secret injection +- `sealed-secrets`: image pull secret 같은 예외 secret 처리 diff --git a/argocd/applications/dev/apps/api-server.yaml b/argocd/applications/dev/apps/api-server.yaml new file mode 100644 index 0000000..99237f6 --- /dev/null +++ b/argocd/applications/dev/apps/api-server.yaml @@ -0,0 +1,34 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: api-server-dev + namespace: argocd + annotations: + argocd.argoproj.io/sync-wave: "40" + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + project: apps-dev + source: + repoURL: https://github.com/DongHyeonka/Project-Auth-GitOps + targetRevision: main + path: apps/api-server/overlays/dev + destination: + server: https://kubernetes.default.svc + namespace: api-dev + syncPolicy: + automated: + enabled: true + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true + - PruneLast=true + - ApplyOutOfSyncOnly=true + retry: + limit: 5 + backoff: + duration: 5s + factor: 2 + maxDuration: 3m + revisionHistoryLimit: 5 diff --git a/argocd/applications/dev/apps/auth-server.yaml b/argocd/applications/dev/apps/auth-server.yaml new file mode 100644 index 0000000..7ca1f29 --- /dev/null +++ b/argocd/applications/dev/apps/auth-server.yaml @@ -0,0 +1,34 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: auth-server-dev + namespace: argocd + annotations: + argocd.argoproj.io/sync-wave: "30" + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + project: apps-dev + source: + repoURL: https://github.com/DongHyeonka/Project-Auth-GitOps + targetRevision: main + path: apps/auth-server/overlays/dev + destination: + server: https://kubernetes.default.svc + namespace: auth-dev + syncPolicy: + automated: + enabled: true + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true + - PruneLast=true + - ApplyOutOfSyncOnly=true + retry: + limit: 5 + backoff: + duration: 5s + factor: 2 + maxDuration: 3m + revisionHistoryLimit: 5 diff --git a/argocd/applications/dev/infra/platform.yaml b/argocd/applications/dev/infra/platform.yaml new file mode 100644 index 0000000..c2a25d2 --- /dev/null +++ b/argocd/applications/dev/infra/platform.yaml @@ -0,0 +1,34 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: platform-dev + namespace: argocd + annotations: + argocd.argoproj.io/sync-wave: "20" + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + project: infra-dev + source: + repoURL: https://github.com/DongHyeonka/Project-Auth-GitOps + targetRevision: main + path: infra/platform/overlays/dev + destination: + server: https://kubernetes.default.svc + namespace: platform + syncPolicy: + automated: + enabled: true + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true + - PruneLast=true + - ApplyOutOfSyncOnly=true + retry: + limit: 5 + backoff: + duration: 5s + factor: 2 + maxDuration: 3m + revisionHistoryLimit: 5 diff --git a/argocd/applications/dev/infra/sealed-secrets.yaml b/argocd/applications/dev/infra/sealed-secrets.yaml new file mode 100644 index 0000000..eb2adef --- /dev/null +++ b/argocd/applications/dev/infra/sealed-secrets.yaml @@ -0,0 +1,35 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: sealed-secrets-dev + namespace: argocd + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + project: infra-dev + source: + repoURL: https://bitnami-labs.github.io/sealed-secrets + chart: sealed-secrets + targetRevision: 2.17.9 + helm: + values: | + fullnameOverride: sealed-secrets-controller + destination: + server: https://kubernetes.default.svc + namespace: kube-system + syncPolicy: + automated: + enabled: true + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true + - PruneLast=true + - ApplyOutOfSyncOnly=true + retry: + limit: 5 + backoff: + duration: 5s + factor: 2 + maxDuration: 3m + revisionHistoryLimit: 5 diff --git a/argocd/applications/dev/infra/vault-agent-injector.yaml b/argocd/applications/dev/infra/vault-agent-injector.yaml new file mode 100644 index 0000000..39e925b --- /dev/null +++ b/argocd/applications/dev/infra/vault-agent-injector.yaml @@ -0,0 +1,59 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: vault-agent-injector-dev + namespace: argocd + annotations: + argocd.argoproj.io/sync-wave: "10" + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + project: infra-dev + source: + repoURL: https://helm.releases.hashicorp.com + chart: vault + targetRevision: 0.32.0 + helm: + values: | + global: + externalVaultAddr: http://vault.vault.svc.cluster.local:8200 + tlsDisable: true + server: + enabled: false + injector: + enabled: true + authPath: auth/kubernetes + webhook: + failurePolicy: Fail + namespaceSelector: + matchLabels: + vault-injection: enabled + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 250m + memory: 256Mi + agentImage: + repository: hashicorp/vault + tag: "1.18" + destination: + server: https://kubernetes.default.svc + namespace: vault + syncPolicy: + automated: + enabled: true + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true + - PruneLast=true + - ApplyOutOfSyncOnly=true + retry: + limit: 5 + backoff: + duration: 5s + factor: 2 + maxDuration: 3m + revisionHistoryLimit: 5 diff --git a/argocd/applications/dev/infra/vault-transit.yaml b/argocd/applications/dev/infra/vault-transit.yaml new file mode 100644 index 0000000..ca2e7ef --- /dev/null +++ b/argocd/applications/dev/infra/vault-transit.yaml @@ -0,0 +1,34 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: vault-transit-dev + namespace: argocd + annotations: + argocd.argoproj.io/sync-wave: "10" + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + project: infra-dev + source: + repoURL: https://github.com/DongHyeonka/Project-Auth-GitOps + targetRevision: main + path: infra/vault-transit/overlays/dev + destination: + server: https://kubernetes.default.svc + namespace: vault-transit + syncPolicy: + automated: + enabled: true + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true + - PruneLast=true + - ApplyOutOfSyncOnly=true + retry: + limit: 5 + backoff: + duration: 5s + factor: 2 + maxDuration: 3m + revisionHistoryLimit: 5 diff --git a/argocd/applications/dev/infra/vault.yaml b/argocd/applications/dev/infra/vault.yaml new file mode 100644 index 0000000..6153fb9 --- /dev/null +++ b/argocd/applications/dev/infra/vault.yaml @@ -0,0 +1,34 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: vault-dev + namespace: argocd + annotations: + argocd.argoproj.io/sync-wave: "10" + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + project: infra-dev + source: + repoURL: https://github.com/DongHyeonka/Project-Auth-GitOps + targetRevision: main + path: infra/vault/overlays/dev + destination: + server: https://kubernetes.default.svc + namespace: vault + syncPolicy: + automated: + enabled: true + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true + - PruneLast=true + - ApplyOutOfSyncOnly=true + retry: + limit: 5 + backoff: + duration: 5s + factor: 2 + maxDuration: 3m + revisionHistoryLimit: 5 diff --git a/argocd/applications/prod/apps/.gitkeep b/argocd/applications/prod/apps/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/argocd/applications/prod/apps/.gitkeep @@ -0,0 +1 @@ + diff --git a/argocd/applications/prod/infra/.gitkeep b/argocd/applications/prod/infra/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/argocd/applications/prod/infra/.gitkeep @@ -0,0 +1 @@ + diff --git a/argocd/projects/dev/apps-project.yaml b/argocd/projects/dev/apps-project.yaml new file mode 100644 index 0000000..ecf1a30 --- /dev/null +++ b/argocd/projects/dev/apps-project.yaml @@ -0,0 +1,50 @@ +apiVersion: argoproj.io/v1alpha1 +kind: AppProject +metadata: + name: apps-dev + namespace: argocd + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + description: Dev application workloads managed by Argo CD + sourceRepos: + - https://github.com/DongHyeonka/Project-Auth-GitOps + destinations: + - namespace: auth-dev + server: https://kubernetes.default.svc + - namespace: api-dev + server: https://kubernetes.default.svc + clusterResourceWhitelist: + - group: "" + kind: Namespace + namespaceResourceWhitelist: + - group: "" + kind: ConfigMap + - group: "" + kind: Secret + - group: "" + kind: Service + - group: "" + kind: ServiceAccount + - group: "" + kind: PersistentVolumeClaim + - group: "bitnami.com" + kind: SealedSecret + - group: "apps" + kind: Deployment + - group: "apps" + kind: StatefulSet + - group: "apps" + kind: ReplicaSet + - group: "autoscaling" + kind: HorizontalPodAutoscaler + - group: "batch" + kind: Job + - group: "networking.k8s.io" + kind: Ingress + - group: "networking.k8s.io" + kind: NetworkPolicy + - group: "policy" + kind: PodDisruptionBudget + orphanedResources: + warn: true diff --git a/argocd/projects/dev/infra-project.yaml b/argocd/projects/dev/infra-project.yaml new file mode 100644 index 0000000..e0f1214 --- /dev/null +++ b/argocd/projects/dev/infra-project.yaml @@ -0,0 +1,68 @@ +apiVersion: argoproj.io/v1alpha1 +kind: AppProject +metadata: + name: infra-dev + namespace: argocd + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + description: Dev shared infrastructure managed by Argo CD + sourceRepos: + - https://github.com/DongHyeonka/Project-Auth-GitOps + - https://bitnami-labs.github.io/sealed-secrets + - https://helm.releases.hashicorp.com + destinations: + - namespace: platform + server: https://kubernetes.default.svc + - namespace: vault + server: https://kubernetes.default.svc + - namespace: vault-transit + server: https://kubernetes.default.svc + - namespace: kube-system + server: https://kubernetes.default.svc + clusterResourceWhitelist: + - group: "" + kind: Namespace + - group: "apiextensions.k8s.io" + kind: CustomResourceDefinition + - group: "rbac.authorization.k8s.io" + kind: ClusterRole + - group: "rbac.authorization.k8s.io" + kind: ClusterRoleBinding + - group: "admissionregistration.k8s.io" + kind: MutatingWebhookConfiguration + namespaceResourceWhitelist: + - group: "" + kind: ConfigMap + - group: "" + kind: Secret + - group: "" + kind: Service + - group: "" + kind: ServiceAccount + - group: "" + kind: PersistentVolumeClaim + - group: "bitnami.com" + kind: SealedSecret + - group: "rbac.authorization.k8s.io" + kind: Role + - group: "rbac.authorization.k8s.io" + kind: RoleBinding + - group: "apps" + kind: Deployment + - group: "apps" + kind: StatefulSet + - group: "apps" + kind: ReplicaSet + - group: "autoscaling" + kind: HorizontalPodAutoscaler + - group: "batch" + kind: Job + - group: "networking.k8s.io" + kind: Ingress + - group: "networking.k8s.io" + kind: NetworkPolicy + - group: "policy" + kind: PodDisruptionBudget + orphanedResources: + warn: true diff --git a/argocd/projects/prod/apps-project.yaml b/argocd/projects/prod/apps-project.yaml new file mode 100644 index 0000000..bed65db --- /dev/null +++ b/argocd/projects/prod/apps-project.yaml @@ -0,0 +1,50 @@ +apiVersion: argoproj.io/v1alpha1 +kind: AppProject +metadata: + name: apps-prod + namespace: argocd + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + description: Prod application workloads managed by Argo CD + sourceRepos: + - https://github.com/DongHyeonka/Project-Auth-GitOps + destinations: + - namespace: auth-prod + server: https://kubernetes.default.svc + - namespace: api-prod + server: https://kubernetes.default.svc + clusterResourceWhitelist: + - group: "" + kind: Namespace + namespaceResourceWhitelist: + - group: "" + kind: ConfigMap + - group: "" + kind: Secret + - group: "" + kind: Service + - group: "" + kind: ServiceAccount + - group: "" + kind: PersistentVolumeClaim + - group: "bitnami.com" + kind: SealedSecret + - group: "apps" + kind: Deployment + - group: "apps" + kind: StatefulSet + - group: "apps" + kind: ReplicaSet + - group: "autoscaling" + kind: HorizontalPodAutoscaler + - group: "batch" + kind: Job + - group: "networking.k8s.io" + kind: Ingress + - group: "networking.k8s.io" + kind: NetworkPolicy + - group: "policy" + kind: PodDisruptionBudget + orphanedResources: + warn: true diff --git a/argocd/projects/prod/infra-project.yaml b/argocd/projects/prod/infra-project.yaml new file mode 100644 index 0000000..1f08cba --- /dev/null +++ b/argocd/projects/prod/infra-project.yaml @@ -0,0 +1,61 @@ +apiVersion: argoproj.io/v1alpha1 +kind: AppProject +metadata: + name: infra-prod + namespace: argocd + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + description: Prod shared infrastructure managed by Argo CD + sourceRepos: + - https://github.com/DongHyeonka/Project-Auth-GitOps + - https://bitnami-labs.github.io/sealed-secrets + destinations: + - namespace: platform-prod + server: https://kubernetes.default.svc + - namespace: kube-system + server: https://kubernetes.default.svc + clusterResourceWhitelist: + - group: "" + kind: Namespace + - group: "apiextensions.k8s.io" + kind: CustomResourceDefinition + - group: "rbac.authorization.k8s.io" + kind: ClusterRole + - group: "rbac.authorization.k8s.io" + kind: ClusterRoleBinding + namespaceResourceWhitelist: + - group: "" + kind: ConfigMap + - group: "" + kind: Secret + - group: "" + kind: Service + - group: "" + kind: ServiceAccount + - group: "" + kind: PersistentVolumeClaim + - group: "bitnami.com" + kind: SealedSecret + - group: "rbac.authorization.k8s.io" + kind: Role + - group: "rbac.authorization.k8s.io" + kind: RoleBinding + - group: "apps" + kind: Deployment + - group: "apps" + kind: StatefulSet + - group: "apps" + kind: ReplicaSet + - group: "autoscaling" + kind: HorizontalPodAutoscaler + - group: "batch" + kind: Job + - group: "networking.k8s.io" + kind: Ingress + - group: "networking.k8s.io" + kind: NetworkPolicy + - group: "policy" + kind: PodDisruptionBudget + orphanedResources: + warn: true diff --git a/infra/platform/base/files/keycloak/project-auth-realm.json b/infra/platform/base/files/keycloak/project-auth-realm.json new file mode 100644 index 0000000..422766e --- /dev/null +++ b/infra/platform/base/files/keycloak/project-auth-realm.json @@ -0,0 +1,22 @@ +{ + "realm": "project-auth", + "enabled": true, + "displayName": "Project Auth", + "sslRequired": "NONE", + "registrationAllowed": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": true, + "clients": [ + { + "clientId": "project-auth-server", + "name": "project-auth-server", + "enabled": true, + "protocol": "openid-connect", + "publicClient": false, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false + } + ] +} diff --git a/infra/platform/base/files/postgres/01-init-project-auth-databases.sh b/infra/platform/base/files/postgres/01-init-project-auth-databases.sh new file mode 100644 index 0000000..effac6b --- /dev/null +++ b/infra/platform/base/files/postgres/01-init-project-auth-databases.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu + +psql -v ON_ERROR_STOP=1 --username "${POSTGRES_USER}" --dbname "${POSTGRES_DB}" <<-EOSQL + CREATE USER ${AUTH_DB_USER} WITH PASSWORD '${AUTH_DB_PASSWORD}'; + CREATE DATABASE ${AUTH_DB_NAME} OWNER ${AUTH_DB_USER}; + + CREATE USER ${KEYCLOAK_DB_USER} WITH PASSWORD '${KEYCLOAK_DB_PASSWORD}'; + CREATE DATABASE ${KEYCLOAK_DB_NAME} OWNER ${KEYCLOAK_DB_USER}; +EOSQL diff --git a/infra/platform/base/keycloak-client-sync-job.yaml b/infra/platform/base/keycloak-client-sync-job.yaml new file mode 100644 index 0000000..592e52b --- /dev/null +++ b/infra/platform/base/keycloak-client-sync-job.yaml @@ -0,0 +1,70 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: keycloak-client-sync + annotations: + argocd.argoproj.io/sync-wave: "4" +spec: + backoffLimit: 5 + template: + metadata: + labels: + app: keycloak-client-sync + spec: + serviceAccountName: keycloak-client-sync + restartPolicy: OnFailure + containers: + - name: keycloak-client-sync + image: quay.io/keycloak/keycloak:26.5.5 + command: + - /bin/sh + - -c + - | + set -eu + + until /opt/keycloak/bin/kcadm.sh config credentials \ + --server http://keycloak.platform.svc.cluster.local \ + --realm master \ + --user "$KC_BOOTSTRAP_ADMIN_USERNAME" \ + --password "$KC_BOOTSTRAP_ADMIN_PASSWORD" >/dev/null 2>&1; do + sleep 5 + done + + CLIENT_UUID=$(/opt/keycloak/bin/kcadm.sh get clients \ + -r project-auth \ + -q clientId="$KEYCLOAK_CLIENT_ID" | sed -n 's/.*"id" : "\([^"]*\)".*/\1/p' | head -n 1) + + test -n "$CLIENT_UUID" + + /opt/keycloak/bin/kcadm.sh update "clients/${CLIENT_UUID}" \ + -r project-auth \ + -s "secret=$KEYCLOAK_CLIENT_SECRET" \ + -s "baseUrl=$AUTH_SERVER_BASE_URL" \ + -s 'redirectUris=["'"$AUTH_SERVER_BASE_URL"'/login/oauth2/code/keycloak-google","'"$AUTH_SERVER_BASE_URL"'/login/oauth2/code/keycloak-github"]' \ + -s 'webOrigins=["'"$AUTH_SERVER_BASE_URL"'"]' + env: + - name: KC_BOOTSTRAP_ADMIN_USERNAME + valueFrom: + configMapKeyRef: + name: platform-config + key: KEYCLOAK_BOOTSTRAP_ADMIN_USERNAME + - name: KC_BOOTSTRAP_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: keycloak-bootstrap-admin + key: KEYCLOAK_BOOTSTRAP_ADMIN_PASSWORD + - name: KEYCLOAK_CLIENT_ID + valueFrom: + configMapKeyRef: + name: platform-config + key: KEYCLOAK_CLIENT_ID + - name: KEYCLOAK_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: keycloak-client-auth-server + key: KEYCLOAK_CLIENT_SECRET + - name: AUTH_SERVER_BASE_URL + valueFrom: + configMapKeyRef: + name: platform-config + key: AUTH_SERVER_BASE_URL diff --git a/infra/platform/base/keycloak-client-sync-serviceaccount.yaml b/infra/platform/base/keycloak-client-sync-serviceaccount.yaml new file mode 100644 index 0000000..3bacb9d --- /dev/null +++ b/infra/platform/base/keycloak-client-sync-serviceaccount.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: keycloak-client-sync +automountServiceAccountToken: false diff --git a/infra/platform/base/keycloak-deployment.yaml b/infra/platform/base/keycloak-deployment.yaml new file mode 100644 index 0000000..1fd3266 --- /dev/null +++ b/infra/platform/base/keycloak-deployment.yaml @@ -0,0 +1,104 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: keycloak + annotations: + argocd.argoproj.io/sync-wave: "3" +spec: + replicas: 1 + selector: + matchLabels: + app: keycloak + template: + metadata: + labels: + app: keycloak + spec: + serviceAccountName: keycloak + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + containers: + - name: keycloak + image: quay.io/keycloak/keycloak:26.5.5 + args: + - start-dev + - --import-realm + - --http-port=8080 + ports: + - containerPort: 8080 + name: http + - containerPort: 9000 + name: management + env: + - name: KC_DB + value: postgres + - name: KC_DB_URL + value: jdbc:postgresql://postgres.platform.svc.cluster.local:5432/keycloak + - name: KC_DB_USERNAME + valueFrom: + configMapKeyRef: + name: platform-config + key: KEYCLOAK_DB_USER + - name: KC_DB_PASSWORD + valueFrom: + secretKeyRef: + name: postgres-keycloak-credentials + key: KEYCLOAK_DB_PASSWORD + - name: KC_HEALTH_ENABLED + value: "true" + - name: KC_BOOTSTRAP_ADMIN_USERNAME + valueFrom: + configMapKeyRef: + name: platform-config + key: KEYCLOAK_BOOTSTRAP_ADMIN_USERNAME + - name: KC_BOOTSTRAP_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: keycloak-bootstrap-admin + key: KEYCLOAK_BOOTSTRAP_ADMIN_PASSWORD + volumeMounts: + - name: keycloak-realm-import + mountPath: /opt/keycloak/data/import/project-auth-realm.json + subPath: project-auth-realm.json + readinessProbe: + httpGet: + path: /health/ready + port: management + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 6 + livenessProbe: + httpGet: + path: /health/live + port: management + initialDelaySeconds: 60 + periodSeconds: 15 + timeoutSeconds: 3 + failureThreshold: 6 + startupProbe: + httpGet: + path: /health/ready + port: management + initialDelaySeconds: 20 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 30 + resources: + requests: + cpu: 250m + memory: 768Mi + limits: + cpu: 1000m + memory: 1536Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + volumes: + - name: keycloak-realm-import + configMap: + name: keycloak-realm-import diff --git a/infra/platform/base/keycloak-service.yaml b/infra/platform/base/keycloak-service.yaml new file mode 100644 index 0000000..b4ff53b --- /dev/null +++ b/infra/platform/base/keycloak-service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: keycloak +spec: + selector: + app: keycloak + ports: + - name: http + port: 80 + targetPort: 8080 + type: ClusterIP diff --git a/infra/platform/base/keycloak-serviceaccount.yaml b/infra/platform/base/keycloak-serviceaccount.yaml new file mode 100644 index 0000000..08c2341 --- /dev/null +++ b/infra/platform/base/keycloak-serviceaccount.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: keycloak +automountServiceAccountToken: false diff --git a/infra/platform/base/kustomization.yaml b/infra/platform/base/kustomization.yaml new file mode 100644 index 0000000..540e5da --- /dev/null +++ b/infra/platform/base/kustomization.yaml @@ -0,0 +1,22 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - postgres-serviceaccount.yaml + - keycloak-serviceaccount.yaml + - keycloak-client-sync-serviceaccount.yaml + - postgres-service.yaml + - postgres-statefulset.yaml + - keycloak-service.yaml + - keycloak-deployment.yaml + - keycloak-client-sync-job.yaml +generatorOptions: + disableNameSuffixHash: true + +configMapGenerator: + - name: postgres-init-script + files: + - files/postgres/01-init-project-auth-databases.sh + - name: keycloak-realm-import + files: + - files/keycloak/project-auth-realm.json diff --git a/infra/platform/base/postgres-service.yaml b/infra/platform/base/postgres-service.yaml new file mode 100644 index 0000000..b0601cf --- /dev/null +++ b/infra/platform/base/postgres-service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: postgres +spec: + clusterIP: None + selector: + app: postgres + ports: + - name: postgres + port: 5432 + targetPort: 5432 + type: ClusterIP diff --git a/infra/platform/base/postgres-serviceaccount.yaml b/infra/platform/base/postgres-serviceaccount.yaml new file mode 100644 index 0000000..930fd01 --- /dev/null +++ b/infra/platform/base/postgres-serviceaccount.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: postgres +automountServiceAccountToken: false diff --git a/infra/platform/base/postgres-statefulset.yaml b/infra/platform/base/postgres-statefulset.yaml new file mode 100644 index 0000000..22f7507 --- /dev/null +++ b/infra/platform/base/postgres-statefulset.yaml @@ -0,0 +1,126 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: postgres + annotations: + argocd.argoproj.io/sync-wave: "2" +spec: + serviceName: postgres + replicas: 1 + selector: + matchLabels: + app: postgres + template: + metadata: + labels: + app: postgres + spec: + serviceAccountName: postgres + securityContext: + fsGroup: 999 + seccompProfile: + type: RuntimeDefault + containers: + - name: postgres + image: postgres:16-alpine + ports: + - containerPort: 5432 + name: postgres + env: + - name: PGDATA + value: /var/lib/postgresql/data/pgdata + - name: POSTGRES_USER + valueFrom: + configMapKeyRef: + name: platform-config + key: POSTGRES_SUPERUSER + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: postgres-superuser-credentials + key: POSTGRES_SUPERUSER_PASSWORD + - name: POSTGRES_DB + valueFrom: + configMapKeyRef: + name: platform-config + key: POSTGRES_DEFAULT_DB + - name: AUTH_DB_NAME + valueFrom: + configMapKeyRef: + name: platform-config + key: AUTH_DB_NAME + - name: AUTH_DB_USER + valueFrom: + configMapKeyRef: + name: platform-config + key: AUTH_DB_USER + - name: AUTH_DB_PASSWORD + valueFrom: + secretKeyRef: + name: postgres-auth-server-credentials + key: AUTH_DB_PASSWORD + - name: KEYCLOAK_DB_NAME + valueFrom: + configMapKeyRef: + name: platform-config + key: KEYCLOAK_DB_NAME + - name: KEYCLOAK_DB_USER + valueFrom: + configMapKeyRef: + name: platform-config + key: KEYCLOAK_DB_USER + - name: KEYCLOAK_DB_PASSWORD + valueFrom: + secretKeyRef: + name: postgres-keycloak-credentials + key: KEYCLOAK_DB_PASSWORD + securityContext: + volumeMounts: + - name: postgres-data + mountPath: /var/lib/postgresql/data + - name: postgres-init-script + mountPath: /docker-entrypoint-initdb.d/01-init-project-auth-databases.sh + subPath: 01-init-project-auth-databases.sh + - name: postgres-run + mountPath: /var/run/postgresql + readinessProbe: + exec: + command: + - sh + - -c + - pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + livenessProbe: + exec: + command: + - sh + - -c + - pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" + initialDelaySeconds: 20 + periodSeconds: 15 + timeoutSeconds: 3 + resources: + requests: + cpu: 250m + memory: 512Mi + limits: + cpu: 1000m + memory: 1024Mi + volumes: + - name: postgres-init-script + configMap: + name: postgres-init-script + defaultMode: 0555 + - name: postgres-run + emptyDir: {} + volumeClaimTemplates: + - metadata: + name: postgres-data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 5Gi diff --git a/infra/platform/overlays/dev/configmap.yaml b/infra/platform/overlays/dev/configmap.yaml new file mode 100644 index 0000000..6031ef4 --- /dev/null +++ b/infra/platform/overlays/dev/configmap.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: platform-config +data: + POSTGRES_SUPERUSER: postgres + POSTGRES_DEFAULT_DB: postgres + AUTH_DB_NAME: project_auth + AUTH_DB_USER: project_auth + KEYCLOAK_DB_NAME: keycloak + KEYCLOAK_DB_USER: keycloak + KEYCLOAK_BOOTSTRAP_ADMIN_USERNAME: admin + KEYCLOAK_CLIENT_ID: project-auth-server + AUTH_SERVER_BASE_URL: http://auth-public.auth-dev.svc.cluster.local diff --git a/infra/platform/overlays/dev/keycloak-client-sync.vault-patch.yaml b/infra/platform/overlays/dev/keycloak-client-sync.vault-patch.yaml new file mode 100644 index 0000000..28fa567 --- /dev/null +++ b/infra/platform/overlays/dev/keycloak-client-sync.vault-patch.yaml @@ -0,0 +1,70 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: keycloak-client-sync +spec: + template: + metadata: + annotations: + vault.hashicorp.com/agent-inject: "true" + vault.hashicorp.com/agent-inject-perms-keycloak-sync-env: "0644" + vault.hashicorp.com/agent-inject-secret-keycloak-sync-env: kv/data/dev/platform/keycloak/bootstrap-admin + vault.hashicorp.com/agent-inject-template-keycloak-sync-env: | + {{ with secret "kv/data/dev/platform/keycloak/bootstrap-admin" }} + export KC_BOOTSTRAP_ADMIN_PASSWORD={{ printf "%q" .Data.data.KC_BOOTSTRAP_ADMIN_PASSWORD }} + {{ end }} + + {{ with secret "kv/data/dev/platform/keycloak/client-auth-server" }} + export KEYCLOAK_CLIENT_SECRET={{ printf "%q" .Data.data.KEYCLOAK_CLIENT_SECRET }} + {{ end }} + vault.hashicorp.com/agent-pre-populate-only: "true" + vault.hashicorp.com/role: keycloak-client-sync-dev + spec: + automountServiceAccountToken: true + containers: + - name: keycloak-client-sync + command: + - /bin/sh + - -ec + args: + - | + . /vault/secrets/keycloak-sync-env + set -eu + + until /opt/keycloak/bin/kcadm.sh config credentials \ + --server http://keycloak.platform.svc.cluster.local \ + --realm master \ + --user "$KC_BOOTSTRAP_ADMIN_USERNAME" \ + --password "$KC_BOOTSTRAP_ADMIN_PASSWORD" >/dev/null 2>&1; do + sleep 5 + done + + CLIENT_UUID=$(/opt/keycloak/bin/kcadm.sh get clients \ + -r project-auth \ + -q clientId="$KEYCLOAK_CLIENT_ID" | sed -n 's/.*"id" : "\([^"]*\)".*/\1/p' | head -n 1) + + test -n "$CLIENT_UUID" + + /opt/keycloak/bin/kcadm.sh update "clients/${CLIENT_UUID}" \ + -r project-auth \ + -s "secret=$KEYCLOAK_CLIENT_SECRET" \ + -s "baseUrl=$AUTH_SERVER_BASE_URL" \ + -s 'redirectUris=["'"$AUTH_SERVER_BASE_URL"'/login/oauth2/code/keycloak-google","'"$AUTH_SERVER_BASE_URL"'/login/oauth2/code/keycloak-github"]' \ + -s 'webOrigins=["'"$AUTH_SERVER_BASE_URL"'"]' + env: + - $patch: replace + - name: KC_BOOTSTRAP_ADMIN_USERNAME + valueFrom: + configMapKeyRef: + name: platform-config + key: KEYCLOAK_BOOTSTRAP_ADMIN_USERNAME + - name: KEYCLOAK_CLIENT_ID + valueFrom: + configMapKeyRef: + name: platform-config + key: KEYCLOAK_CLIENT_ID + - name: AUTH_SERVER_BASE_URL + valueFrom: + configMapKeyRef: + name: platform-config + key: AUTH_SERVER_BASE_URL diff --git a/infra/platform/overlays/dev/keycloak-ingress.yaml b/infra/platform/overlays/dev/keycloak-ingress.yaml new file mode 100644 index 0000000..a53dcd6 --- /dev/null +++ b/infra/platform/overlays/dev/keycloak-ingress.yaml @@ -0,0 +1,19 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: keycloak + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: web +spec: + ingressClassName: traefik + rules: + - host: keycloak-public.platform.svc.cluster.local + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: keycloak + port: + number: 80 diff --git a/infra/platform/overlays/dev/keycloak.public-url-patch.yaml b/infra/platform/overlays/dev/keycloak.public-url-patch.yaml new file mode 100644 index 0000000..128254d --- /dev/null +++ b/infra/platform/overlays/dev/keycloak.public-url-patch.yaml @@ -0,0 +1,16 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: keycloak +spec: + template: + spec: + containers: + - name: keycloak + env: + - name: KC_HOSTNAME + value: keycloak-public.platform.svc.cluster.local + - name: KC_HOSTNAME_STRICT + value: "false" + - name: KC_PROXY_HEADERS + value: xforwarded diff --git a/infra/platform/overlays/dev/keycloak.vault-patch.yaml b/infra/platform/overlays/dev/keycloak.vault-patch.yaml new file mode 100644 index 0000000..f965913 --- /dev/null +++ b/infra/platform/overlays/dev/keycloak.vault-patch.yaml @@ -0,0 +1,49 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: keycloak +spec: + template: + metadata: + annotations: + vault.hashicorp.com/agent-inject: "true" + vault.hashicorp.com/agent-inject-perms-keycloak-env: "0644" + vault.hashicorp.com/agent-inject-secret-keycloak-env: kv/data/dev/platform/postgres/keycloak + vault.hashicorp.com/agent-inject-template-keycloak-env: | + {{ with secret "kv/data/dev/platform/postgres/keycloak" }} + export KC_DB_PASSWORD={{ printf "%q" .Data.data.KEYCLOAK_DB_PASSWORD }} + {{ end }} + + {{ with secret "kv/data/dev/platform/keycloak/bootstrap-admin" }} + export KC_BOOTSTRAP_ADMIN_PASSWORD={{ printf "%q" .Data.data.KC_BOOTSTRAP_ADMIN_PASSWORD }} + {{ end }} + vault.hashicorp.com/role: keycloak-dev + spec: + automountServiceAccountToken: true + containers: + - name: keycloak + command: + - /bin/sh + - -ec + args: + - | + . /vault/secrets/keycloak-env + exec /opt/keycloak/bin/kc.sh start-dev --import-realm --http-port=8080 + env: + - $patch: replace + - name: KC_DB + value: postgres + - name: KC_DB_URL + value: jdbc:postgresql://postgres.platform.svc.cluster.local:5432/keycloak + - name: KC_DB_USERNAME + valueFrom: + configMapKeyRef: + name: platform-config + key: KEYCLOAK_DB_USER + - name: KC_HEALTH_ENABLED + value: "true" + - name: KC_BOOTSTRAP_ADMIN_USERNAME + valueFrom: + configMapKeyRef: + name: platform-config + key: KEYCLOAK_BOOTSTRAP_ADMIN_USERNAME diff --git a/infra/platform/overlays/dev/kustomization.yaml b/infra/platform/overlays/dev/kustomization.yaml new file mode 100644 index 0000000..f5aaf9a --- /dev/null +++ b/infra/platform/overlays/dev/kustomization.yaml @@ -0,0 +1,18 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: platform + +resources: + - ../../base + - namespace.yaml + - configmap.yaml + - keycloak-ingress.yaml + - public-access.yaml + - networkpolicy.yaml + +patches: + - path: postgres.vault-patch.yaml + - path: keycloak.vault-patch.yaml + - path: keycloak-client-sync.vault-patch.yaml + - path: keycloak.public-url-patch.yaml diff --git a/infra/platform/overlays/dev/namespace.yaml b/infra/platform/overlays/dev/namespace.yaml new file mode 100644 index 0000000..f101fca --- /dev/null +++ b/infra/platform/overlays/dev/namespace.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: platform + labels: + vault-injection: enabled + pod-security.kubernetes.io/enforce: baseline + pod-security.kubernetes.io/enforce-version: latest + pod-security.kubernetes.io/warn: restricted + pod-security.kubernetes.io/warn-version: latest + pod-security.kubernetes.io/audit: restricted + pod-security.kubernetes.io/audit-version: latest diff --git a/infra/platform/overlays/dev/networkpolicy.yaml b/infra/platform/overlays/dev/networkpolicy.yaml new file mode 100644 index 0000000..6c88f78 --- /dev/null +++ b/infra/platform/overlays/dev/networkpolicy.yaml @@ -0,0 +1,161 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: platform-default-deny +spec: + podSelector: {} + policyTypes: + - Ingress + - Egress +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: platform-allow-dns-egress +spec: + podSelector: {} + policyTypes: + - Egress + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: platform-allow-vault-egress +spec: + podSelector: {} + policyTypes: + - Egress + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: vault + podSelector: + matchLabels: + app: vault + ports: + - protocol: TCP + port: 8200 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: platform-allow-keycloak-egress-to-postgres +spec: + podSelector: + matchLabels: + app: keycloak + policyTypes: + - Egress + egress: + - to: + - podSelector: + matchLabels: + app: postgres + ports: + - protocol: TCP + port: 5432 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: platform-allow-keycloak-client-sync-to-keycloak +spec: + podSelector: + matchLabels: + app: keycloak-client-sync + policyTypes: + - Egress + egress: + - to: + - podSelector: + matchLabels: + app: keycloak + ports: + - protocol: TCP + port: 8080 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: platform-allow-keycloak-ingress +spec: + podSelector: + matchLabels: + app: keycloak + policyTypes: + - Ingress + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + app.kubernetes.io/name: traefik + ports: + - protocol: TCP + port: 8080 + - from: + - podSelector: + matchLabels: + app: keycloak-client-sync + ports: + - protocol: TCP + port: 8080 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: platform-allow-postgres-ingress +spec: + podSelector: + matchLabels: + app: postgres + policyTypes: + - Ingress + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: auth-dev + podSelector: + matchExpressions: + - key: app + operator: In + values: + - auth-server + - auth-db-migration + ports: + - protocol: TCP + port: 5432 + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: vault + podSelector: + matchLabels: + app: vault + ports: + - protocol: TCP + port: 5432 + - from: + - podSelector: + matchLabels: + app: keycloak + ports: + - protocol: TCP + port: 5432 diff --git a/infra/platform/overlays/dev/postgres.vault-patch.yaml b/infra/platform/overlays/dev/postgres.vault-patch.yaml new file mode 100644 index 0000000..fae496c --- /dev/null +++ b/infra/platform/overlays/dev/postgres.vault-patch.yaml @@ -0,0 +1,69 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: postgres +spec: + template: + metadata: + annotations: + vault.hashicorp.com/agent-inject: "true" + vault.hashicorp.com/agent-inject-perms-postgres-env: "0644" + vault.hashicorp.com/agent-inject-secret-postgres-env: kv/data/dev/platform/postgres/superuser + vault.hashicorp.com/agent-inject-template-postgres-env: | + {{ with secret "kv/data/dev/platform/postgres/superuser" }} + export POSTGRES_PASSWORD={{ printf "%q" .Data.data.POSTGRES_SUPERUSER_PASSWORD }} + {{ end }} + + {{ with secret "kv/data/dev/platform/postgres/auth-server" }} + export AUTH_DB_PASSWORD={{ printf "%q" .Data.data.AUTH_DB_PASSWORD }} + {{ end }} + + {{ with secret "kv/data/dev/platform/postgres/keycloak" }} + export KEYCLOAK_DB_PASSWORD={{ printf "%q" .Data.data.KEYCLOAK_DB_PASSWORD }} + {{ end }} + vault.hashicorp.com/role: postgres-dev + spec: + automountServiceAccountToken: true + containers: + - name: postgres + command: + - /bin/sh + - -ec + args: + - | + . /vault/secrets/postgres-env + exec docker-entrypoint.sh postgres + env: + - $patch: replace + - name: PGDATA + value: /var/lib/postgresql/data/pgdata + - name: POSTGRES_USER + valueFrom: + configMapKeyRef: + name: platform-config + key: POSTGRES_SUPERUSER + - name: POSTGRES_DB + valueFrom: + configMapKeyRef: + name: platform-config + key: POSTGRES_DEFAULT_DB + - name: AUTH_DB_NAME + valueFrom: + configMapKeyRef: + name: platform-config + key: AUTH_DB_NAME + - name: AUTH_DB_USER + valueFrom: + configMapKeyRef: + name: platform-config + key: AUTH_DB_USER + - name: KEYCLOAK_DB_NAME + valueFrom: + configMapKeyRef: + name: platform-config + key: KEYCLOAK_DB_NAME + - name: KEYCLOAK_DB_USER + valueFrom: + configMapKeyRef: + name: platform-config + key: KEYCLOAK_DB_USER diff --git a/infra/platform/overlays/dev/public-access.yaml b/infra/platform/overlays/dev/public-access.yaml new file mode 100644 index 0000000..0c979dc --- /dev/null +++ b/infra/platform/overlays/dev/public-access.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: keycloak-public +spec: + type: ExternalName + externalName: traefik.kube-system.svc.cluster.local + ports: + - name: http + port: 80 + targetPort: 80 diff --git a/infra/platform/overlays/prod/configmap.yaml b/infra/platform/overlays/prod/configmap.yaml new file mode 100644 index 0000000..8c44c3b --- /dev/null +++ b/infra/platform/overlays/prod/configmap.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: platform-config +data: + POSTGRES_SUPERUSER: postgres + POSTGRES_DEFAULT_DB: postgres + AUTH_DB_NAME: project_auth + AUTH_DB_USER: project_auth + KEYCLOAK_DB_NAME: keycloak + KEYCLOAK_DB_USER: keycloak + KEYCLOAK_BOOTSTRAP_ADMIN_USERNAME: admin + KEYCLOAK_CLIENT_ID: project-auth-server + AUTH_SERVER_BASE_URL: http://auth-server.auth-prod.svc.cluster.local + VAULT_TRANSIT_KEY_NAME: project-auth-jwt diff --git a/infra/platform/overlays/prod/kustomization.yaml b/infra/platform/overlays/prod/kustomization.yaml new file mode 100644 index 0000000..3734425 --- /dev/null +++ b/infra/platform/overlays/prod/kustomization.yaml @@ -0,0 +1,9 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: platform-prod + +resources: + - ../../base + - namespace.yaml + - configmap.yaml diff --git a/infra/platform/overlays/prod/namespace.yaml b/infra/platform/overlays/prod/namespace.yaml new file mode 100644 index 0000000..64b79ac --- /dev/null +++ b/infra/platform/overlays/prod/namespace.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: platform-prod + labels: + pod-security.kubernetes.io/enforce: baseline + pod-security.kubernetes.io/enforce-version: latest + pod-security.kubernetes.io/warn: restricted + pod-security.kubernetes.io/warn-version: latest + pod-security.kubernetes.io/audit: restricted + pod-security.kubernetes.io/audit-version: latest diff --git a/infra/vault-transit/base/deployment.yaml b/infra/vault-transit/base/deployment.yaml new file mode 100644 index 0000000..9031125 --- /dev/null +++ b/infra/vault-transit/base/deployment.yaml @@ -0,0 +1,73 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: vault-transit +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app: vault-transit + template: + metadata: + labels: + app: vault-transit + spec: + containers: + - name: vault-transit + image: hashicorp/vault:1.18 + command: + - /bin/sh + - -ec + env: + - name: VAULT_ADDR + value: http://127.0.0.1:8200 + args: + - | + cp /vault/config/vault.hcl /tmp/vault.hcl + exec vault server -config=/tmp/vault.hcl + ports: + - containerPort: 8200 + name: http + - containerPort: 8201 + name: cluster + volumeMounts: + - name: vault-transit-config + mountPath: /vault/config + readOnly: true + - name: vault-transit-data + mountPath: /vault/data + readinessProbe: + exec: + command: + - sh + - -c + - vault status -address=http://127.0.0.1:8200 >/dev/null 2>&1; code=$?; [ "$code" -eq 0 ] || [ "$code" -eq 2 ] + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 5 + livenessProbe: + exec: + command: + - sh + - -c + - vault status -address=http://127.0.0.1:8200 >/dev/null 2>&1; code=$?; [ "$code" -eq 0 ] || [ "$code" -eq 2 ] + initialDelaySeconds: 20 + periodSeconds: 15 + timeoutSeconds: 5 + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + volumes: + - name: vault-transit-config + configMap: + name: vault-transit-config + defaultMode: 0555 + - name: vault-transit-data + persistentVolumeClaim: + claimName: vault-transit-data diff --git a/infra/vault-transit/base/files/vault/vault.hcl b/infra/vault-transit/base/files/vault/vault.hcl new file mode 100644 index 0000000..9c5d21b --- /dev/null +++ b/infra/vault-transit/base/files/vault/vault.hcl @@ -0,0 +1,15 @@ +ui = true +disable_mlock = true +api_addr = "http://vault-transit.vault-transit.svc.cluster.local:8200" +cluster_addr = "http://vault-transit.vault-transit.svc.cluster.local:8201" + +listener "tcp" { + address = "0.0.0.0:8200" + cluster_address = "0.0.0.0:8201" + tls_disable = 1 +} + +storage "raft" { + path = "/vault/data" + node_id = "vault-transit-dev-0" +} diff --git a/infra/vault-transit/base/kustomization.yaml b/infra/vault-transit/base/kustomization.yaml new file mode 100644 index 0000000..5383a68 --- /dev/null +++ b/infra/vault-transit/base/kustomization.yaml @@ -0,0 +1,15 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - service.yaml + - deployment.yaml + - pvc.yaml + +generatorOptions: + disableNameSuffixHash: true + +configMapGenerator: + - name: vault-transit-config + files: + - files/vault/vault.hcl diff --git a/infra/vault-transit/base/pvc.yaml b/infra/vault-transit/base/pvc.yaml new file mode 100644 index 0000000..1a3d517 --- /dev/null +++ b/infra/vault-transit/base/pvc.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: vault-transit-data +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 5Gi diff --git a/infra/vault-transit/base/service.yaml b/infra/vault-transit/base/service.yaml new file mode 100644 index 0000000..d8f62d5 --- /dev/null +++ b/infra/vault-transit/base/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: vault-transit +spec: + selector: + app: vault-transit + ports: + - name: http + port: 8200 + targetPort: 8200 + - name: cluster + port: 8201 + targetPort: 8201 + type: ClusterIP diff --git a/infra/vault-transit/overlays/dev/kustomization.yaml b/infra/vault-transit/overlays/dev/kustomization.yaml new file mode 100644 index 0000000..297af33 --- /dev/null +++ b/infra/vault-transit/overlays/dev/kustomization.yaml @@ -0,0 +1,8 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: vault-transit + +resources: + - ../../base + - namespace.yaml diff --git a/infra/vault-transit/overlays/dev/namespace.yaml b/infra/vault-transit/overlays/dev/namespace.yaml new file mode 100644 index 0000000..789f06f --- /dev/null +++ b/infra/vault-transit/overlays/dev/namespace.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: vault-transit + labels: + pod-security.kubernetes.io/enforce: baseline + pod-security.kubernetes.io/enforce-version: latest + pod-security.kubernetes.io/warn: restricted + pod-security.kubernetes.io/warn-version: latest + pod-security.kubernetes.io/audit: restricted + pod-security.kubernetes.io/audit-version: latest diff --git a/infra/vault/base/files/vault/vault.hcl b/infra/vault/base/files/vault/vault.hcl new file mode 100644 index 0000000..0677b83 --- /dev/null +++ b/infra/vault/base/files/vault/vault.hcl @@ -0,0 +1,23 @@ +ui = true +disable_mlock = true +api_addr = "http://vault.vault.svc.cluster.local:8200" +cluster_addr = "http://vault.vault.svc.cluster.local:8201" + +listener "tcp" { + address = "0.0.0.0:8200" + cluster_address = "0.0.0.0:8201" + tls_disable = 1 +} + +seal "transit" { + address = "http://vault-transit.vault-transit.svc.cluster.local:8200" + disable_renewal = "false" + key_name = "workload-vault-dev-unseal" + mount_path = "transit/" + tls_skip_verify = "true" +} + +storage "raft" { + path = "/vault/data" + node_id = "vault-dev-0" +} diff --git a/infra/vault/base/kustomization.yaml b/infra/vault/base/kustomization.yaml new file mode 100644 index 0000000..29f15c2 --- /dev/null +++ b/infra/vault/base/kustomization.yaml @@ -0,0 +1,17 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - vault-serviceaccount.yaml + - vault-auth-delegator.clusterrolebinding.yaml + - vault-pvc.yaml + - vault-service.yaml + - vault-deployment.yaml + +generatorOptions: + disableNameSuffixHash: true + +configMapGenerator: + - name: vault-config + files: + - files/vault/vault.hcl diff --git a/infra/vault/base/vault-auth-delegator.clusterrolebinding.yaml b/infra/vault/base/vault-auth-delegator.clusterrolebinding.yaml new file mode 100644 index 0000000..c72d89b --- /dev/null +++ b/infra/vault/base/vault-auth-delegator.clusterrolebinding.yaml @@ -0,0 +1,12 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: vault-server-auth-delegator +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: system:auth-delegator +subjects: + - kind: ServiceAccount + name: vault-server + namespace: vault diff --git a/infra/vault/base/vault-deployment.yaml b/infra/vault/base/vault-deployment.yaml new file mode 100644 index 0000000..1b9ec95 --- /dev/null +++ b/infra/vault/base/vault-deployment.yaml @@ -0,0 +1,79 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: vault +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app: vault + template: + metadata: + labels: + app: vault + spec: + serviceAccountName: vault-server + containers: + - name: vault + image: hashicorp/vault:1.18 + command: + - /bin/sh + - -ec + env: + - name: VAULT_ADDR + value: http://127.0.0.1:8200 + - name: VAULT_TOKEN + valueFrom: + secretKeyRef: + name: vault-transit-seal + key: VAULT_TRANSIT_SEAL_TOKEN + args: + - | + cp /vault/config/vault.hcl /tmp/vault.hcl + exec vault server -config=/tmp/vault.hcl + ports: + - containerPort: 8200 + name: http + - containerPort: 8201 + name: cluster + volumeMounts: + - name: vault-config + mountPath: /vault/config + readOnly: true + - name: vault-data + mountPath: /vault/data + readinessProbe: + exec: + command: + - sh + - -c + - vault status -address=http://127.0.0.1:8200 >/dev/null 2>&1; code=$?; [ "$code" -eq 0 ] || [ "$code" -eq 2 ] + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 5 + livenessProbe: + exec: + command: + - sh + - -c + - vault status -address=http://127.0.0.1:8200 >/dev/null 2>&1; code=$?; [ "$code" -eq 0 ] || [ "$code" -eq 2 ] + initialDelaySeconds: 20 + periodSeconds: 15 + timeoutSeconds: 5 + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + volumes: + - name: vault-config + configMap: + name: vault-config + defaultMode: 0555 + - name: vault-data + persistentVolumeClaim: + claimName: vault-data diff --git a/infra/vault/base/vault-pvc.yaml b/infra/vault/base/vault-pvc.yaml new file mode 100644 index 0000000..2e414b5 --- /dev/null +++ b/infra/vault/base/vault-pvc.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: vault-data +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 5Gi diff --git a/infra/vault/base/vault-service.yaml b/infra/vault/base/vault-service.yaml new file mode 100644 index 0000000..26b6332 --- /dev/null +++ b/infra/vault/base/vault-service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: vault +spec: + selector: + app: vault + ports: + - name: http + port: 8200 + targetPort: 8200 + - name: cluster + port: 8201 + targetPort: 8201 + type: ClusterIP diff --git a/infra/vault/base/vault-serviceaccount.yaml b/infra/vault/base/vault-serviceaccount.yaml new file mode 100644 index 0000000..b204dab --- /dev/null +++ b/infra/vault/base/vault-serviceaccount.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: vault-server diff --git a/infra/vault/overlays/dev/kustomization.yaml b/infra/vault/overlays/dev/kustomization.yaml new file mode 100644 index 0000000..5bc665f --- /dev/null +++ b/infra/vault/overlays/dev/kustomization.yaml @@ -0,0 +1,9 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: vault + +resources: + - ../../base + - namespace.yaml + - networkpolicy.yaml diff --git a/infra/vault/overlays/dev/namespace.yaml b/infra/vault/overlays/dev/namespace.yaml new file mode 100644 index 0000000..de14352 --- /dev/null +++ b/infra/vault/overlays/dev/namespace.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: vault + labels: + pod-security.kubernetes.io/enforce: baseline + pod-security.kubernetes.io/enforce-version: latest + pod-security.kubernetes.io/warn: restricted + pod-security.kubernetes.io/warn-version: latest + pod-security.kubernetes.io/audit: restricted + pod-security.kubernetes.io/audit-version: latest diff --git a/infra/vault/overlays/dev/networkpolicy.yaml b/infra/vault/overlays/dev/networkpolicy.yaml new file mode 100644 index 0000000..671bf26 --- /dev/null +++ b/infra/vault/overlays/dev/networkpolicy.yaml @@ -0,0 +1,140 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: vault-server-default-deny +spec: + podSelector: + matchLabels: + app: vault + policyTypes: + - Ingress + - Egress +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: vault-server-allow-dns-egress +spec: + podSelector: + matchLabels: + app: vault + policyTypes: + - Egress + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: vault-server-allow-transit-egress +spec: + podSelector: + matchLabels: + app: vault + policyTypes: + - Egress + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: vault-transit + podSelector: + matchLabels: + app: vault-transit + ports: + - protocol: TCP + port: 8200 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: vault-server-allow-postgres-egress +spec: + podSelector: + matchLabels: + app: vault + policyTypes: + - Egress + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: platform + podSelector: + matchLabels: + app: postgres + ports: + - protocol: TCP + port: 5432 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: vault-server-allow-kubernetes-api-egress +spec: + podSelector: + matchLabels: + app: vault + policyTypes: + - Egress + egress: + - to: + - ipBlock: + cidr: 10.43.0.1/32 + ports: + - protocol: TCP + port: 443 + - to: + - ipBlock: + cidr: 10.208.141.98/32 + ports: + - protocol: TCP + port: 6443 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: vault-server-allow-ingress-from-workloads +spec: + podSelector: + matchLabels: + app: vault + policyTypes: + - Ingress + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: auth-dev + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: platform + ports: + - protocol: TCP + port: 8200 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: vault-agent-injector-webhook-ingress +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: vault-agent-injector + policyTypes: + - Ingress + ingress: + - ports: + - protocol: TCP + port: 8080 diff --git a/infra/vault/overlays/prod/kustomization.yaml b/infra/vault/overlays/prod/kustomization.yaml new file mode 100644 index 0000000..c7865ec --- /dev/null +++ b/infra/vault/overlays/prod/kustomization.yaml @@ -0,0 +1,8 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: vault-prod + +resources: + - ../../base + - namespace.yaml diff --git a/infra/vault/overlays/prod/namespace.yaml b/infra/vault/overlays/prod/namespace.yaml new file mode 100644 index 0000000..b83e1e3 --- /dev/null +++ b/infra/vault/overlays/prod/namespace.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: vault-prod + labels: + pod-security.kubernetes.io/enforce: baseline + pod-security.kubernetes.io/enforce-version: latest + pod-security.kubernetes.io/warn: restricted + pod-security.kubernetes.io/warn-version: latest + pod-security.kubernetes.io/audit: restricted + pod-security.kubernetes.io/audit-version: latest diff --git a/runbooks/vault-transit/dev/README.md b/runbooks/vault-transit/dev/README.md new file mode 100644 index 0000000..a82d109 --- /dev/null +++ b/runbooks/vault-transit/dev/README.md @@ -0,0 +1,149 @@ +## Vault Transit Dev Bootstrap + +이 문서는 dev 환경의 **unseal provider Vault** 를 Terraform으로 선언적으로 bootstrap 하는 절차를 정리합니다. + +이 Vault는 애플리케이션 secret을 직접 저장하지 않고, 업무용 Vault의 transit auto-unseal provider와 workload seed source 역할만 담당합니다. + +### 준비물 + +- `kubectl` +- `vault` +- `jq` +- `terraform` +- dev 클러스터에 접근 가능한 kubeconfig + +### 0. 기동 상태 확인 + +`init / unseal` 전에 먼저 `vault-transit` pod가 실제로 기동 가능한 상태인지 확인합니다. + +```bash +kubectl -n vault-transit get deploy,pods,svc,pvc +kubectl -n vault-transit rollout status deploy/vault-transit --timeout=180s +``` + +정상 기준: + +- `deployment/vault-transit` 이 `1/1 Ready` +- pod가 `Running` +- `CrashLoopBackOff` 가 아님 + +기동이 안 되면 아래를 먼저 봅니다. + +```bash +kubectl -n vault-transit describe deployment vault-transit +kubectl -n vault-transit logs deploy/vault-transit --tail=200 +kubectl -n vault-transit get events --sort-by=.lastTimestamp | tail -n 30 +``` + +최근 dev 기준 대표 원인은 아래였습니다. + +- `Cluster address must be set when using raft storage` + 원인: raft storage 사용 시 `api_addr`, `cluster_addr`, listener `cluster_address` 가 빠져 있었음 +- `Could not chown /vault/config` + 원인: ConfigMap mount가 read-only 인데 이미지 entrypoint가 해당 경로를 `chown` 하려 함 + +현재 base manifest는 위 이슈를 피하기 위해: + +- `api_addr`, `cluster_addr`, `cluster_address` 추가 +- service/deployment `8201` cluster 포트 추가 +- deployment `strategy: Recreate` +- `/vault/config/vault.hcl` 을 `/tmp/vault.hcl` 로 복사 후 `vault server` 실행 + +형태로 정리되어 있습니다. + +### 1. 포트 포워딩 + +```bash +kubectl port-forward -n vault-transit svc/vault-transit 18200:8200 +``` + +### 2. init / unseal + +```bash +export VAULT_ADDR=http://127.0.0.1:18200 +vault operator init -format=json > .local/vault-transit-dev-init.json +vault operator unseal "$(jq -r '.unseal_keys_b64[0]' .local/vault-transit-dev-init.json)" +export TF_VAR_vault_token="$(jq -r '.root_token' .local/vault-transit-dev-init.json)" +export TF_VAR_vault_addr="$VAULT_ADDR" +``` + +### 3. Terraform bootstrap + +```bash +terraform -chdir=terraform/vault-transit/dev init -input=false +terraform -chdir=terraform/vault-transit/dev apply -input=false -auto-approve +``` + +이 apply 는 아래를 선언적으로 맞춥니다. + +- `kv` / `transit` secrets engine 활성화 +- workload Vault auto-unseal key 생성 +- `workload-vault-transit-dev`, `vault-transit-admin-dev`, `vault-transit-automation-dev` policy reconcile +- workflow용 `vault-transit-dev-workflow` AppRole reconcile +- `vault/vault-transit-seal` Kubernetes Secret 갱신 + +필요한 CI credential은 Terraform output 으로 확인합니다. + +```bash +terraform -chdir=terraform/vault-transit/dev output workflow_role_id +terraform -chdir=terraform/vault-transit/dev output -raw workflow_secret_id +``` + +이 두 값은 `VAULT_TRANSIT_DEV_ROLE_ID`, `VAULT_TRANSIT_DEV_SECRET_ID` 로 CI secret store 에 저장합니다. + +### 4. 이후 역할 + +- 업무용 Vault([vault app](/home/donghyeon/dev/Project-Auth-GitOps/argocd/applications/dev/infra/vault.yaml)) 는 `vault-transit-seal` Secret 의 토큰으로 transit auto-unseal 을 수행합니다. +- `vault-transit` provider Vault 자체는 dev/on-prem 전제에서 여전히 **수동 unseal** 입니다. +- 수동 bootstrap 은 `terraform/vault-transit/dev`, `terraform/vault/dev` 를 사용합니다. +- self-hosted runner routine reconcile 은 provider AppRole 로 로그인한 뒤 `terraform/vault-transit/reconcile`, `terraform/vault/reconcile` 를 차례로 `apply` 합니다. + +### 5. Workload seed 값 입력 + +provider Vault는 여전히 workload seed source of truth 이므로, app 비밀값은 한 번 입력해야 합니다. + +```bash +export VAULT_ADDR=http://127.0.0.1:18200 +export VAULT_TOKEN="$(vault write -field=token auth/approle/login \ + role_id="" \ + secret_id="")" + +./scripts/vault-transit/dev/populate-workload-seeds.sh +``` + +이 스크립트는 값을 **프롬프트로 입력받기 때문에 shell history에 실제 secret이 남지 않습니다.** + +주의: + +- 이 스크립트는 `TF_VAR_transit_vault_token` 이 아니라 **`VAULT_TOKEN`** 을 사용합니다. +- `VAULT_ADDR` 는 provider Vault 포트포워드인 `http://127.0.0.1:18200` 이어야 합니다. + +`populate-workload-seeds.example.sh` 는 필요한 key 구조를 보여주는 참고용 예시입니다. + +입력 경로는 목적 기준으로 나뉩니다. + +- `kv/dev/workload/platform/postgres/superuser` +- `kv/dev/workload/platform/postgres/auth-server` +- `kv/dev/workload/platform/postgres/keycloak` +- `kv/dev/workload/platform/keycloak/bootstrap-admin` +- `kv/dev/workload/platform/keycloak/client-auth-server` + +`kv/dev/workload/bootstrap` 의 workload AppRole credential 은 manual bootstrap 단계의 `terraform/vault/dev` 가 자동으로 씁니다. 사람이 따로 넣지 않습니다. + +### 6. 운영자 토큰이 필요한 경우 + +장기 토큰을 Terraform state 에 저장하지 않기 위해 `vault-transit-admin-dev` 토큰은 자동 발급하지 않습니다. +직접 점검이 필요하면 privileged token 으로 아래처럼 짧은 토큰을 발급합니다. + +```bash +export VAULT_ADDR=http://127.0.0.1:18200 +export VAULT_TOKEN="$TF_VAR_vault_token" + +vault token create -orphan -policy=vault-transit-admin-dev -ttl=1h +``` + +### 주의 + +- manual bootstrap state 는 [`.terraform-state/vault-transit-dev.tfstate`](/home/donghyeon/dev/Project-Auth-GitOps/.terraform-state/vault-transit-dev.tfstate) 에 저장됩니다. +- routine reconcile state 는 [`.terraform-state/vault-transit-reconcile.tfstate`](/home/donghyeon/dev/Project-Auth-GitOps/.terraform-state/vault-transit-reconcile.tfstate) 에 저장됩니다. +- Vault provider state 에는 민감한 값이 들어가므로 self-hosted runner 와 로컬 작업 디렉터리를 동일하게 보호해야 합니다. diff --git a/runbooks/vault-transit/dev/policies/vault-transit-admin-dev.hcl b/runbooks/vault-transit/dev/policies/vault-transit-admin-dev.hcl new file mode 100644 index 0000000..ad7574a --- /dev/null +++ b/runbooks/vault-transit/dev/policies/vault-transit-admin-dev.hcl @@ -0,0 +1,39 @@ +path "transit/*" { + capabilities = ["create", "read", "update", "delete", "list"] +} + +path "kv/*" { + capabilities = ["create", "read", "update", "delete", "list"] +} + +path "sys/internal/ui/mounts/*" { + capabilities = ["read"] +} + +path "sys/mounts/*" { + capabilities = ["create", "read", "update", "delete", "list", "sudo"] +} + +path "sys/auth/*" { + capabilities = ["create", "read", "update", "delete", "list", "sudo"] +} + +path "sys/policies/acl/*" { + capabilities = ["create", "read", "update", "delete", "list"] +} + +path "auth/approle/*" { + capabilities = ["create", "read", "update", "delete", "list"] +} + +path "auth/token/create-orphan" { + capabilities = ["update"] +} + +path "auth/token/revoke" { + capabilities = ["update"] +} + +path "auth/token/lookup" { + capabilities = ["update"] +} diff --git a/runbooks/vault-transit/dev/policies/vault-transit-automation-dev.hcl b/runbooks/vault-transit/dev/policies/vault-transit-automation-dev.hcl new file mode 100644 index 0000000..ce9a541 --- /dev/null +++ b/runbooks/vault-transit/dev/policies/vault-transit-automation-dev.hcl @@ -0,0 +1,59 @@ +path "transit/*" { + capabilities = ["create", "read", "update", "delete", "list"] +} + +path "kv/data/dev/workload/*" { + capabilities = ["create", "read", "update", "delete", "list"] +} + +path "kv/metadata/dev/workload/*" { + capabilities = ["read", "delete", "list"] +} + +path "sys/internal/ui/mounts/*" { + capabilities = ["read"] +} + +path "sys/mounts/*" { + capabilities = ["create", "read", "update", "delete", "list", "sudo"] +} + +path "sys/auth/*" { + capabilities = ["create", "read", "update", "delete", "list", "sudo"] +} + +path "sys/policies/acl/*" { + capabilities = ["create", "read", "update", "delete", "list"] +} + +path "auth/approle/*" { + capabilities = ["create", "read", "update", "delete", "list"] +} + +path "auth/token/create" { + capabilities = ["update"] +} + +path "auth/token/create-orphan" { + capabilities = ["update"] +} + +path "auth/token/revoke" { + capabilities = ["update"] +} + +path "auth/token/revoke-accessor" { + capabilities = ["update"] +} + +path "auth/token/lookup" { + capabilities = ["update"] +} + +path "auth/token/lookup-accessor" { + capabilities = ["update"] +} + +path "auth/token/lookup-self" { + capabilities = ["read"] +} diff --git a/runbooks/vault-transit/dev/policies/workload-vault-transit-dev.hcl b/runbooks/vault-transit/dev/policies/workload-vault-transit-dev.hcl new file mode 100644 index 0000000..3815834 --- /dev/null +++ b/runbooks/vault-transit/dev/policies/workload-vault-transit-dev.hcl @@ -0,0 +1,15 @@ +path "transit/encrypt/workload-vault-dev-unseal" { + capabilities = ["update"] +} + +path "transit/decrypt/workload-vault-dev-unseal" { + capabilities = ["update"] +} + +path "transit/rewrap/workload-vault-dev-unseal" { + capabilities = ["update"] +} + +path "transit/keys/workload-vault-dev-unseal" { + capabilities = ["read"] +} diff --git a/runbooks/vault-transit/dev/populate-workload-seeds.example.sh b/runbooks/vault-transit/dev/populate-workload-seeds.example.sh new file mode 100644 index 0000000..b379e00 --- /dev/null +++ b/runbooks/vault-transit/dev/populate-workload-seeds.example.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env sh + +set -eu + +vault kv put kv/dev/workload/platform/postgres/superuser \ + POSTGRES_SUPERUSER_PASSWORD=change-me + +vault kv put kv/dev/workload/platform/postgres/auth-server \ + APP_DATASOURCE_USERNAME=project_auth \ + APP_DATASOURCE_PASSWORD=change-me \ + AUTH_DB_PASSWORD=change-me + +vault kv put kv/dev/workload/platform/postgres/keycloak \ + KEYCLOAK_DB_PASSWORD=change-me + +vault kv put kv/dev/workload/platform/keycloak/bootstrap-admin \ + KC_BOOTSTRAP_ADMIN_PASSWORD=change-me + +vault kv put kv/dev/workload/platform/keycloak/client-auth-server \ + APP_SECURITY_OAUTH2_KEYCLOAK_CLIENT_SECRET=change-me \ + KEYCLOAK_CLIENT_SECRET=change-me diff --git a/runbooks/vault/dev/README.md b/runbooks/vault/dev/README.md new file mode 100644 index 0000000..9029fdb --- /dev/null +++ b/runbooks/vault/dev/README.md @@ -0,0 +1,174 @@ +## Vault Dev Bootstrap + +이 문서는 dev 환경의 **workload Vault** 를 Terraform으로 선언적으로 bootstrap / reconcile 하는 절차를 정리합니다. + +이제 책임은 두 단계로 분리합니다. + +- bootstrap + - privileged workload token 필요 + - 수동 runbook 사용 +- reconcile + - bootstrap 완료 후 `vault-dev-reconcile` workflow 가 routine apply 수행 + +사전 조건: + +- [vault-transit bootstrap runbook](/home/donghyeon/dev/Project-Auth-GitOps/runbooks/vault-transit/dev/README.md) 을 먼저 완료해야 합니다. +- `vault` namespace에 `vault-transit-seal` Secret 이 준비되어 있어야 workload Vault 가 auto-unseal 됩니다. + +현재 저장소의 source of truth 는 아래 두 Terraform 루트로 분리합니다. + +- bootstrap/manual: `terraform/vault/dev` +- reconcile/CI: `terraform/vault/reconcile` + +`bootstrap-runbook.sh` 는 bootstrap 루트를, routine workflow 는 reconcile 루트를 호출합니다. + +### 준비물 + +- `kubectl` +- `vault` +- `jq` +- `terraform` +- dev 클러스터에 접근 가능한 kubeconfig + +### 1. Vault 포트 포워딩 + +```bash +kubectl port-forward -n vault svc/vault 8200:8200 +``` + +### 2. Vault init / bootstrap token 준비 + +```bash +export VAULT_ADDR=http://127.0.0.1:8200 +vault operator init -format=json > .local/vault-dev-init.json +export TF_VAR_workload_vault_addr="$VAULT_ADDR" +export TF_VAR_workload_vault_token="$(jq -r '.root_token' .local/vault-dev-init.json)" +``` + +Transit auto-unseal 구조이므로 정상 상태에서는 `vault operator unseal` 을 반복하지 않습니다. + +### 3. Provider seed credential 연결 + +workload Terraform 은 provider Vault 에서 seed 값을 읽고, 생성한 workload workflow AppRole credential 을 다시 provider Vault bootstrap path 로 써넣습니다. + +```bash +export TF_VAR_transit_vault_addr=http://127.0.0.1:18200 +export TF_VAR_transit_vault_token="$(VAULT_ADDR="$TF_VAR_transit_vault_addr" vault write -field=token auth/approle/login \ + role_id="" \ + secret_id="")" +``` + +### 4. Terraform bootstrap + +```bash +terraform -chdir=terraform/vault/dev init -input=false +terraform -chdir=terraform/vault/dev apply -input=false -auto-approve +``` + +이 apply 는 아래를 선언적으로 맞춥니다. + +- `kv`, `database`, `transit` mount 활성화 +- Kubernetes auth backend / role reconcile +- AppRole backend / workflow AppRole reconcile +- dev runtime KV 를 provider Vault seed 에 맞춰 동기화 +- JWT transit key 생성 +- Postgres database backend / dynamic role 정의 +- `kv/dev/workload/bootstrap` 에 workload workflow AppRole credential publish + +필요하면 output 으로 workload workflow AppRole 값을 직접 확인할 수 있습니다. + +```bash +terraform -chdir=terraform/vault/dev output workflow_role_id +terraform -chdir=terraform/vault/dev output -raw workflow_secret_id +``` + +### 정책 분리 + +현재 dev 정책은 아래처럼 역할별로 나눕니다. + +- `auth-server-dev` + 이유: 앱 런타임은 `platform/postgres/auth-server`, `platform/keycloak/client-auth-server`, JWT transit signing만 접근하면 충분합니다. +- `auth-db-migration-dev` + 이유: migration job 은 `database/creds/auth-db-migration-dev` 로 짧은 DB credential 을 받아 실행합니다. +- `postgres-dev` + 이유: DB pod 는 `platform/postgres/superuser`, `platform/postgres/auth-server`, `platform/postgres/keycloak` 만 읽으면 됩니다. +- `keycloak-dev` + 이유: Keycloak pod 는 `platform/postgres/keycloak`, `platform/keycloak/bootstrap-admin` 만 읽으면 됩니다. +- `keycloak-client-sync-dev` + 이유: client sync job 은 `platform/keycloak/bootstrap-admin`, `platform/keycloak/client-auth-server` 만 읽으면 됩니다. +- `workload-automation-dev` + 이유: Terraform apply 가 policy, role, auth, transit, KV, AppRole, database 설정을 모두 reconcile 합니다. +- `platform-admin-dev` + 이유: 사람이 비상 복구나 수동 운영 작업을 할 때 쓰는 운영자 정책입니다. +- `postgres-operator-dev` + 이유: 사람이 dev DB 에 직접 접속할 때는 `database/creds/postgres-operator-dev` 만 읽는 짧은 토큰으로 제한합니다. +- `keycloak-operator-dev` + 이유: 사람이 Keycloak 에 직접 로그인할 때는 bootstrap admin credential 만 읽는 짧은 토큰으로 제한합니다. + +### 자동화용 CI Secret + +`vault-dev-reconcile` workflow 를 사용하려면 최소 아래 secret 이 필요합니다. + +- `KUBECONFIG_DEV_B64` +- `VAULT_TRANSIT_DEV_ROLE_ID` +- `VAULT_TRANSIT_DEV_SECRET_ID` + +권장 흐름은 아래와 같습니다. + +1. `vault-transit` runbook 으로 provider Vault 를 1회 init / unseal / bootstrap 합니다. +2. provider Vault 에 app seed 값을 입력합니다. +3. workload Vault 를 1회 init 합니다. +4. `terraform/vault/dev` 를 root token 으로 1회 apply 합니다. +5. 이 apply 가 workload workflow AppRole credential 을 `kv/dev/workload/bootstrap` 에 써넣습니다. +6. 이후부터는 workflow 가 provider bootstrap 확인, `terraform/vault/reconcile` apply, Argo CD app apply 를 자동 수행합니다. + +bootstrap용 privileged token은 GitHub secret에 올리지 않고 운영자 로컬에서만 사용하는 것을 권장합니다. + +### 정적 seed 로 최초 1회 넣어야 하는 값 + +아래 값들은 최초 1회 사람이 입력하거나 상위 secret source 에서 sync 해야 합니다. + +- `kv/dev/workload/platform/postgres/superuser` + 값: `POSTGRES_SUPERUSER_PASSWORD` +- `kv/dev/workload/platform/postgres/auth-server` + 값: `APP_DATASOURCE_USERNAME`, `APP_DATASOURCE_PASSWORD`, `AUTH_DB_PASSWORD` +- `kv/dev/workload/platform/postgres/keycloak` + 값: `KEYCLOAK_DB_PASSWORD` +- `kv/dev/workload/platform/keycloak/bootstrap-admin` + 값: `KC_BOOTSTRAP_ADMIN_PASSWORD` +- `kv/dev/workload/platform/keycloak/client-auth-server` + 값: `APP_SECURITY_OAUTH2_KEYCLOAK_CLIENT_SECRET`, `KEYCLOAK_CLIENT_SECRET` + +이 값들은 provider Vault seed path 가 source of truth 이고, workload Terraform apply 가 이를 workload Vault KV 로 동기화합니다. + +### 사람 직접 접근용 토큰 발급 + +장기 운영자 토큰은 Terraform state 에 저장하지 않습니다. +필요할 때 privileged token 으로 `platform-admin-dev` 토큰을 짧게 발급한 뒤 아래 스크립트를 사용합니다. + +```bash +export VAULT_ADDR=http://127.0.0.1:8200 +export VAULT_TOKEN= + +vault token create -orphan -policy=platform-admin-dev -ttl=1h +``` + +발급된 `platform-admin-dev` 토큰으로: + +```bash +export VAULT_TOKEN= +./scripts/vault/dev/issue-operator-tokens.sh +``` + +이후: + +- `postgres-operator-dev`: `vault read database/creds/postgres-operator-dev` +- `keycloak-operator-dev`: `vault kv get kv/dev/platform/keycloak/bootstrap-admin` + +를 수행해 dev 접속 정보를 확인할 수 있습니다. + +### 주의 + +- manual bootstrap state 는 [`.terraform-state/vault-dev.tfstate`](/home/donghyeon/dev/Project-Auth-GitOps/.terraform-state/vault-dev.tfstate) 에 저장됩니다. +- routine reconcile state 는 [`.terraform-state/vault-reconcile.tfstate`](/home/donghyeon/dev/Project-Auth-GitOps/.terraform-state/vault-reconcile.tfstate) 에 저장됩니다. +- Vault provider state 에는 민감한 값이 들어가므로 self-hosted runner 와 로컬 작업 디렉터리를 동일하게 보호해야 합니다. diff --git a/runbooks/vault/dev/policies/auth-db-migration-dev.hcl b/runbooks/vault/dev/policies/auth-db-migration-dev.hcl new file mode 100644 index 0000000..5c1a283 --- /dev/null +++ b/runbooks/vault/dev/policies/auth-db-migration-dev.hcl @@ -0,0 +1,3 @@ +path "database/creds/auth-db-migration-dev" { + capabilities = ["read"] +} diff --git a/runbooks/vault/dev/policies/auth-server-dev.hcl b/runbooks/vault/dev/policies/auth-server-dev.hcl new file mode 100644 index 0000000..d0112f6 --- /dev/null +++ b/runbooks/vault/dev/policies/auth-server-dev.hcl @@ -0,0 +1,15 @@ +path "kv/data/dev/platform/postgres/auth-server" { + capabilities = ["read"] +} + +path "kv/data/dev/platform/keycloak/client-auth-server" { + capabilities = ["read"] +} + +path "transit/keys/project-auth-jwt" { + capabilities = ["read"] +} + +path "transit/sign/project-auth-jwt" { + capabilities = ["update"] +} diff --git a/runbooks/vault/dev/policies/keycloak-client-sync-dev.hcl b/runbooks/vault/dev/policies/keycloak-client-sync-dev.hcl new file mode 100644 index 0000000..4c81320 --- /dev/null +++ b/runbooks/vault/dev/policies/keycloak-client-sync-dev.hcl @@ -0,0 +1,7 @@ +path "kv/data/dev/platform/keycloak/bootstrap-admin" { + capabilities = ["read"] +} + +path "kv/data/dev/platform/keycloak/client-auth-server" { + capabilities = ["read"] +} diff --git a/runbooks/vault/dev/policies/keycloak-dev.hcl b/runbooks/vault/dev/policies/keycloak-dev.hcl new file mode 100644 index 0000000..e272070 --- /dev/null +++ b/runbooks/vault/dev/policies/keycloak-dev.hcl @@ -0,0 +1,7 @@ +path "kv/data/dev/platform/postgres/keycloak" { + capabilities = ["read"] +} + +path "kv/data/dev/platform/keycloak/bootstrap-admin" { + capabilities = ["read"] +} diff --git a/runbooks/vault/dev/policies/keycloak-operator-dev.hcl b/runbooks/vault/dev/policies/keycloak-operator-dev.hcl new file mode 100644 index 0000000..d83ae81 --- /dev/null +++ b/runbooks/vault/dev/policies/keycloak-operator-dev.hcl @@ -0,0 +1,3 @@ +path "kv/data/dev/platform/keycloak/bootstrap-admin" { + capabilities = ["read"] +} diff --git a/runbooks/vault/dev/policies/platform-admin-dev.hcl b/runbooks/vault/dev/policies/platform-admin-dev.hcl new file mode 100644 index 0000000..39aa9c3 --- /dev/null +++ b/runbooks/vault/dev/policies/platform-admin-dev.hcl @@ -0,0 +1,35 @@ +path "kv/*" { + capabilities = ["create", "read", "update", "delete", "list"] +} + +path "auth/kubernetes/*" { + capabilities = ["create", "read", "update", "delete", "list"] +} + +path "sys/auth/*" { + capabilities = ["create", "read", "update", "delete", "list", "sudo"] +} + +path "sys/mounts/*" { + capabilities = ["create", "read", "update", "delete", "list", "sudo"] +} + +path "sys/policies/acl/*" { + capabilities = ["create", "read", "update", "delete", "list"] +} + +path "database/*" { + capabilities = ["create", "read", "update", "delete", "list"] +} + +path "transit/*" { + capabilities = ["create", "read", "update", "delete", "list"] +} + +path "auth/token/create-orphan" { + capabilities = ["update"] +} + +path "auth/token/lookup" { + capabilities = ["update"] +} diff --git a/runbooks/vault/dev/policies/postgres-dev.hcl b/runbooks/vault/dev/policies/postgres-dev.hcl new file mode 100644 index 0000000..7eda9eb --- /dev/null +++ b/runbooks/vault/dev/policies/postgres-dev.hcl @@ -0,0 +1,11 @@ +path "kv/data/dev/platform/postgres/superuser" { + capabilities = ["read"] +} + +path "kv/data/dev/platform/postgres/auth-server" { + capabilities = ["read"] +} + +path "kv/data/dev/platform/postgres/keycloak" { + capabilities = ["read"] +} diff --git a/runbooks/vault/dev/policies/postgres-operator-dev.hcl b/runbooks/vault/dev/policies/postgres-operator-dev.hcl new file mode 100644 index 0000000..527409e --- /dev/null +++ b/runbooks/vault/dev/policies/postgres-operator-dev.hcl @@ -0,0 +1,3 @@ +path "database/creds/postgres-operator-dev" { + capabilities = ["read"] +} diff --git a/runbooks/vault/dev/policies/workload-automation-dev.hcl b/runbooks/vault/dev/policies/workload-automation-dev.hcl new file mode 100644 index 0000000..faeace2 --- /dev/null +++ b/runbooks/vault/dev/policies/workload-automation-dev.hcl @@ -0,0 +1,71 @@ +path "kv/data/dev/platform/postgres/superuser" { + capabilities = ["create", "read", "update", "delete", "list"] +} + +path "kv/metadata/dev/platform/postgres/superuser" { + capabilities = ["read", "delete", "list"] +} + +path "kv/data/dev/platform/postgres/auth-server" { + capabilities = ["create", "read", "update", "delete", "list"] +} + +path "kv/metadata/dev/platform/postgres/auth-server" { + capabilities = ["read", "delete", "list"] +} + +path "kv/data/dev/platform/postgres/keycloak" { + capabilities = ["create", "read", "update", "delete", "list"] +} + +path "kv/metadata/dev/platform/postgres/keycloak" { + capabilities = ["read", "delete", "list"] +} + +path "kv/data/dev/platform/keycloak/bootstrap-admin" { + capabilities = ["create", "read", "update", "delete", "list"] +} + +path "kv/metadata/dev/platform/keycloak/bootstrap-admin" { + capabilities = ["read", "delete", "list"] +} + +path "kv/data/dev/platform/keycloak/client-auth-server" { + capabilities = ["create", "read", "update", "delete", "list"] +} + +path "kv/metadata/dev/platform/keycloak/client-auth-server" { + capabilities = ["read", "delete", "list"] +} + +path "auth/kubernetes/*" { + capabilities = ["create", "read", "update", "delete", "list"] +} + +path "auth/approle/*" { + capabilities = ["create", "read", "update", "delete", "list"] +} + +path "sys/auth/*" { + capabilities = ["create", "read", "update", "delete", "list", "sudo"] +} + +path "sys/mounts/*" { + capabilities = ["create", "read", "update", "delete", "list", "sudo"] +} + +path "sys/policies/acl/*" { + capabilities = ["create", "read", "update", "delete", "list"] +} + +path "database/config/*" { + capabilities = ["create", "read", "update", "delete", "list"] +} + +path "database/roles/*" { + capabilities = ["create", "read", "update", "delete", "list"] +} + +path "transit/*" { + capabilities = ["create", "read", "update", "delete", "list"] +} diff --git a/scripts/ci/reconcile-vault-dev.sh b/scripts/ci/reconcile-vault-dev.sh new file mode 100644 index 0000000..532b105 --- /dev/null +++ b/scripts/ci/reconcile-vault-dev.sh @@ -0,0 +1,292 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +source "${REPO_ROOT}/scripts/vault/dev/provider-lib.sh" + +usage() { + cat <<'EOF' +Usage: scripts/ci/reconcile-vault-dev.sh + +Commands: + prepare-infra + reconcile-transit + reconcile-workload + apply-apps +EOF +} + +log() { + printf '[reconcile-vault-dev] %s\n' "$*" +} + +require_cmd() { + local cmd="$1" + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "$cmd is required" >&2 + exit 1 + fi +} + +require_env() { + local name="$1" + if [[ -z "${!name:-}" ]]; then + echo "$name must be set" >&2 + exit 1 + fi +} + +wait_http_ready() { + local url="$1" + local label="$2" + if ! curl -fsS --retry 30 --retry-delay 2 --retry-connrefused "$url" >/dev/null 2>&1; then + echo "${label} is not reachable at ${url}" >&2 + return 1 + fi +} + +start_port_forward() { + local namespace="$1" + local service="$2" + local local_port="$3" + local remote_port="$4" + local log_file="$5" + + kubectl -n "$namespace" port-forward "svc/${service}" "${local_port}:${remote_port}" >"$log_file" 2>&1 & + local pf_pid=$! + trap 'kill "$pf_pid" >/dev/null 2>&1 || true' EXIT + printf '%s\n' "$pf_pid" +} + +use_port_forward() { + [[ "${RECONCILE_USE_PORT_FORWARD:-true}" == "true" ]] +} + +transit_login() { + VAULT_ADDR="${TRANSIT_VAULT_ADDR}" \ + vault write -field=token auth/approle/login \ + role_id="${VAULT_TRANSIT_DEV_ROLE_ID}" \ + secret_id="${VAULT_TRANSIT_DEV_SECRET_ID}" +} + +ensure_transit_state_resource() { + local address="$1" + local import_id="$2" + + if ! terraform -chdir="${REPO_ROOT}/terraform/vault-transit/reconcile" state show "$address" >/dev/null 2>&1; then + log "Importing missing vault-transit state for ${address}" + terraform -chdir="${REPO_ROOT}/terraform/vault-transit/reconcile" import "$address" "$import_id" + fi +} + +ensure_workload_state_resource() { + local address="$1" + local import_id="$2" + + if ! terraform -chdir="${REPO_ROOT}/terraform/vault/reconcile" state show "$address" >/dev/null 2>&1; then + log "Importing missing workload-vault state for ${address}" + terraform -chdir="${REPO_ROOT}/terraform/vault/reconcile" import "$address" "$import_id" + fi +} + +prepare_infra() { + require_cmd kubectl + require_env TF_STATE_DIR + + mkdir -p "${TF_STATE_DIR}" + "${REPO_ROOT}/scripts/vault/dev/apply-argocd-dev-infra.sh" + + kubectl -n vault-transit wait --for=create deployment/vault-transit --timeout=300s + kubectl -n vault-transit wait --for=condition=available deployment/vault-transit --timeout=300s + kubectl -n vault wait --for=create deployment/vault --timeout=300s + kubectl -n vault wait --for=condition=available deployment/vault --timeout=300s +} + +reconcile_transit() { + require_cmd kubectl + require_cmd vault + require_cmd terraform + require_cmd curl + require_cmd jq + require_env TF_STATE_DIR + require_env TRANSIT_VAULT_ADDR + require_env VAULT_TRANSIT_DEV_ROLE_ID + require_env VAULT_TRANSIT_DEV_SECRET_ID + + if use_port_forward; then + start_port_forward vault-transit vault-transit 18200 8200 /tmp/vault-transit-port-forward.log >/dev/null + fi + wait_http_ready "${TRANSIT_VAULT_ADDR}/v1/sys/health" "Transit provider Vault API" + + VAULT_ADDR="${TRANSIT_VAULT_ADDR}" "${REPO_ROOT}/scripts/vault-transit/dev/ensure-unsealed.sh" + + local transit_tf_token + transit_tf_token="$(transit_login)" + + VAULT_ADDR="${TRANSIT_VAULT_ADDR}" \ + VAULT_TOKEN="${transit_tf_token}" \ + vault policy write \ + vault-transit-automation-dev \ + "${REPO_ROOT}/runbooks/vault-transit/dev/policies/vault-transit-automation-dev.hcl" + + transit_tf_token="$(transit_login)" + + local transit_state_path="${TF_STATE_DIR}/vault-transit-reconcile.tfstate" + + TF_VAR_vault_addr="${TRANSIT_VAULT_ADDR}" \ + TF_VAR_vault_token="${transit_tf_token}" \ + terraform -chdir="${REPO_ROOT}/terraform/vault-transit/reconcile" init \ + -input=false \ + -reconfigure \ + -backend-config="path=${transit_state_path}" + + if ! terraform -chdir="${REPO_ROOT}/terraform/vault-transit/reconcile" state list >/dev/null 2>&1; then + echo "vault-transit Terraform state is unreadable. Re-seed ${transit_state_path}." >&2 + exit 1 + fi + + export TF_VAR_vault_addr="${TRANSIT_VAULT_ADDR}" + export TF_VAR_vault_token="${transit_tf_token}" + + ensure_transit_state_resource vault_policy.workload_vault_transit_dev workload-vault-transit-dev + ensure_transit_state_resource vault_policy.vault_transit_admin_dev vault-transit-admin-dev + ensure_transit_state_resource vault_policy.vault_transit_automation_dev vault-transit-automation-dev + ensure_transit_state_resource vault_approle_auth_backend_role.workflow auth/approle/role/vault-transit-dev-workflow + + TF_VAR_vault_addr="${TRANSIT_VAULT_ADDR}" \ + TF_VAR_vault_token="${transit_tf_token}" \ + terraform -chdir="${REPO_ROOT}/terraform/vault-transit/reconcile" apply -input=false -auto-approve +} + +reconcile_workload() { + require_cmd kubectl + require_cmd vault + require_cmd terraform + require_cmd curl + require_env TF_STATE_DIR + require_env TRANSIT_VAULT_ADDR + require_env WORKLOAD_VAULT_ADDR + require_env VAULT_TRANSIT_DEV_ROLE_ID + require_env VAULT_TRANSIT_DEV_SECRET_ID + + if use_port_forward; then + start_port_forward vault vault 8200 8200 /tmp/vault-workload-port-forward.log >/dev/null + fi + wait_http_ready "${WORKLOAD_VAULT_ADDR}/v1/sys/health" "Workload Vault API" + + VAULT_ADDR="${WORKLOAD_VAULT_ADDR}" "${REPO_ROOT}/scripts/vault/dev/ensure-unsealed.sh" + + local transit_tf_token + transit_tf_token="$(transit_login)" + + local workload_role_id + local workload_secret_id + workload_role_id="$( + VAULT_ADDR="${TRANSIT_VAULT_ADDR}" \ + VAULT_TOKEN="${transit_tf_token}" \ + vault kv get -field=VAULT_WORKLOAD_DEV_ROLE_ID kv/dev/workload/bootstrap 2>/dev/null || true + )" + workload_secret_id="$( + VAULT_ADDR="${TRANSIT_VAULT_ADDR}" \ + VAULT_TOKEN="${transit_tf_token}" \ + vault kv get -field=VAULT_WORKLOAD_DEV_SECRET_ID kv/dev/workload/bootstrap 2>/dev/null || true + )" + + if [[ -z "${workload_role_id}" || -z "${workload_secret_id}" ]]; then + echo "Workload Vault bootstrap AppRole is missing from provider Vault." >&2 + echo "Execute scripts/vault/dev/bootstrap-runbook.sh manually with a privileged workload token to seed kv/dev/workload/bootstrap." >&2 + exit 1 + fi + + local workload_tf_token + workload_tf_token="$( + VAULT_ADDR="${WORKLOAD_VAULT_ADDR}" \ + vault write -field=token auth/approle/login \ + role_id="${workload_role_id}" \ + secret_id="${workload_secret_id}" + )" + + local workload_state_path="${TF_STATE_DIR}/vault-reconcile.tfstate" + + TF_VAR_workload_vault_addr="${WORKLOAD_VAULT_ADDR}" \ + TF_VAR_workload_vault_token="${workload_tf_token}" \ + TF_VAR_transit_vault_addr="${TRANSIT_VAULT_ADDR}" \ + TF_VAR_transit_vault_token="${transit_tf_token}" \ + terraform -chdir="${REPO_ROOT}/terraform/vault/reconcile" init \ + -input=false \ + -reconfigure \ + -backend-config="path=${workload_state_path}" + + export TF_VAR_workload_vault_addr="${WORKLOAD_VAULT_ADDR}" + export TF_VAR_workload_vault_token="${workload_tf_token}" + export TF_VAR_transit_vault_addr="${TRANSIT_VAULT_ADDR}" + export TF_VAR_transit_vault_token="${transit_tf_token}" + + ensure_workload_state_resource vault_policy.auth_server auth-server-dev + ensure_workload_state_resource vault_policy.auth_db_migration auth-db-migration-dev + ensure_workload_state_resource vault_policy.postgres postgres-dev + ensure_workload_state_resource vault_policy.keycloak keycloak-dev + ensure_workload_state_resource vault_policy.keycloak_client_sync keycloak-client-sync-dev + ensure_workload_state_resource vault_policy.postgres_operator postgres-operator-dev + ensure_workload_state_resource vault_policy.keycloak_operator keycloak-operator-dev + ensure_workload_state_resource vault_policy.platform_admin platform-admin-dev + ensure_workload_state_resource vault_policy.workload_automation workload-automation-dev + ensure_workload_state_resource vault_kubernetes_auth_backend_role.auth_server auth/kubernetes/role/auth-server-dev + ensure_workload_state_resource vault_kubernetes_auth_backend_role.auth_db_migration auth/kubernetes/role/auth-db-migration-dev + ensure_workload_state_resource vault_kubernetes_auth_backend_role.postgres auth/kubernetes/role/postgres-dev + ensure_workload_state_resource vault_kubernetes_auth_backend_role.keycloak auth/kubernetes/role/keycloak-dev + ensure_workload_state_resource vault_kubernetes_auth_backend_role.keycloak_client_sync auth/kubernetes/role/keycloak-client-sync-dev + ensure_workload_state_resource vault_approle_auth_backend_role.workflow auth/approle/role/workload-dev-workflow + ensure_workload_state_resource vault_database_secret_backend_connection.platform_postgres database/config/platform-postgres-dev + ensure_workload_state_resource vault_database_secret_backend_role.auth_db_migration database/roles/auth-db-migration-dev + ensure_workload_state_resource vault_database_secret_backend_role.postgres_operator database/roles/postgres-operator-dev + ensure_workload_state_resource vault_kv_secret_v2.platform_postgres_superuser kv/data/dev/platform/postgres/superuser + ensure_workload_state_resource vault_kv_secret_v2.platform_postgres_auth_server kv/data/dev/platform/postgres/auth-server + ensure_workload_state_resource vault_kv_secret_v2.platform_postgres_keycloak kv/data/dev/platform/postgres/keycloak + ensure_workload_state_resource vault_kv_secret_v2.platform_keycloak_bootstrap_admin kv/data/dev/platform/keycloak/bootstrap-admin + ensure_workload_state_resource vault_kv_secret_v2.platform_keycloak_client_auth_server kv/data/dev/platform/keycloak/client-auth-server + + TF_VAR_workload_vault_addr="${WORKLOAD_VAULT_ADDR}" \ + TF_VAR_workload_vault_token="${workload_tf_token}" \ + TF_VAR_transit_vault_addr="${TRANSIT_VAULT_ADDR}" \ + TF_VAR_transit_vault_token="${transit_tf_token}" \ + terraform -chdir="${REPO_ROOT}/terraform/vault/reconcile" apply -input=false -auto-approve + + if kubectl -n vault get deployment -l app.kubernetes.io/name=vault-agent-injector -o name | grep -q .; then + kubectl -n vault wait --for=condition=available deployment -l app.kubernetes.io/name=vault-agent-injector --timeout=180s + fi +} + +apply_apps() { + require_cmd kubectl + "${REPO_ROOT}/scripts/vault/dev/apply-argocd-dev-apps.sh" +} + +main() { + require_cmd bash + require_cmd kubectl + + case "${1:-}" in + prepare-infra) + prepare_infra + ;; + reconcile-transit) + reconcile_transit + ;; + reconcile-workload) + reconcile_workload + ;; + apply-apps) + apply_apps + ;; + *) + usage >&2 + exit 1 + ;; + esac +} + +main "$@" diff --git a/scripts/ci/update-image-tag.sh b/scripts/ci/update-image-tag.sh new file mode 100644 index 0000000..d5c26f6 --- /dev/null +++ b/scripts/ci/update-image-tag.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash + +set -euo pipefail + +resolve_inputs() { + local service="" + local target_env="" + local image_tag="" + local image_name="" + local kustomization="" + + if [[ "${GITHUB_EVENT_NAME:-}" == "workflow_dispatch" ]]; then + service="${INPUT_SERVICE:-}" + target_env="${INPUT_TARGET_ENV:-}" + image_tag="${INPUT_IMAGE_TAG:-}" + else + service="${EVENT_SERVICE:-}" + target_env="${EVENT_TARGET_ENV:-}" + image_tag="${EVENT_IMAGE_TAG:-}" + fi + + if [[ -z "$service" || -z "$target_env" || -z "$image_tag" ]]; then + echo "Missing service/target_env/image_tag input" >&2 + exit 1 + fi + + case "$service" in + auth-server) + image_name="ghcr.io/donghyeonka/project-auth-server" + ;; + api-server) + image_name="ghcr.io/donghyeonka/project-api-server" + ;; + *) + echo "Unsupported service: $service" >&2 + exit 1 + ;; + esac + + case "$target_env" in + dev|prod) + ;; + *) + echo "Unsupported environment: $target_env" >&2 + exit 1 + ;; + esac + + if [[ ! "$image_tag" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]]; then + echo "Unsupported image tag format: $image_tag" >&2 + exit 1 + fi + + kustomization="apps/${service}/overlays/${target_env}/kustomization.yaml" + + { + echo "SERVICE=$service" + echo "TARGET_ENV=$target_env" + echo "IMAGE_TAG=$image_tag" + echo "IMAGE_NAME=$image_name" + echo "KUSTOMIZATION=$kustomization" + } >> "$GITHUB_ENV" +} + +update_kustomization() { + if ! command -v kustomize >/dev/null 2>&1; then + echo "kustomize is required to update the image tag" >&2 + exit 1 + fi + + if [[ ! -f "${KUSTOMIZATION:-}" ]]; then + echo "Kustomization not found: ${KUSTOMIZATION:-}" >&2 + exit 1 + fi + + cd "$(dirname "$KUSTOMIZATION")" + kustomize edit set image "${IMAGE_NAME}=${IMAGE_NAME}:${IMAGE_TAG}" +} + +validate_overlay() { + if ! command -v kubectl >/dev/null 2>&1; then + echo "kubectl is required to validate the overlay" >&2 + exit 1 + fi + + kubectl kustomize "$(dirname "$KUSTOMIZATION")" >/dev/null +} + +check_changes() { + if git diff --quiet -- "$KUSTOMIZATION"; then + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "No changes to commit" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + git diff -- "$KUSTOMIZATION" + fi +} + +commit_dev() { + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add "$KUSTOMIZATION" + git commit -m "chore(gitops): update ${SERVICE} ${TARGET_ENV} image to ${IMAGE_TAG}" + git push origin "HEAD:${DEFAULT_BRANCH}" +} + +main() { + case "${1:-}" in + resolve-inputs) + resolve_inputs + ;; + update-kustomization) + update_kustomization + ;; + validate-overlay) + validate_overlay + ;; + check-changes) + check_changes + ;; + commit-dev) + commit_dev + ;; + *) + echo "Usage: scripts/ci/update-image-tag.sh " >&2 + exit 1 + ;; + esac +} + +main "$@" diff --git a/scripts/vault-transit/dev/bootstrap-runbook.sh b/scripts/vault-transit/dev/bootstrap-runbook.sh new file mode 100755 index 0000000..44448c6 --- /dev/null +++ b/scripts/vault-transit/dev/bootstrap-runbook.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" + +source "${SCRIPT_DIR}/../../vault/dev/provider-lib.sh" + +if ! command -v terraform >/dev/null 2>&1; then + echo "terraform CLI is required" >&2 + exit 1 +fi + +export VAULT_TRANSIT_ADDR="${VAULT_TRANSIT_ADDR:-${VAULT_ADDR:-http://127.0.0.1:18200}}" +export TF_VAR_vault_addr="$VAULT_TRANSIT_ADDR" +export TF_VAR_vault_token="$(provider_vault_token)" + +if [[ -n "${SEAL_KEY_NAME:-}" ]]; then + export TF_VAR_seal_key_name="$SEAL_KEY_NAME" +fi + +if [[ -n "${SEAL_TOKEN_PERIOD:-}" ]]; then + export TF_VAR_seal_token_period="$SEAL_TOKEN_PERIOD" +fi + +if [[ -n "${TARGET_NAMESPACE:-}" ]]; then + export TF_VAR_target_namespace="$TARGET_NAMESPACE" +fi + +if [[ -n "${TARGET_SECRET_NAME:-}" ]]; then + export TF_VAR_target_secret_name="$TARGET_SECRET_NAME" +fi + +if [[ -n "${WORKFLOW_POLICY_NAME:-}" ]]; then + export TF_VAR_workflow_policy_name="$WORKFLOW_POLICY_NAME" +fi + +if [[ -n "${WORKFLOW_ROLE_NAME:-}" ]]; then + export TF_VAR_workflow_role_name="$WORKFLOW_ROLE_NAME" +fi + +mkdir -p "${REPO_ROOT}/.terraform-state" + +if [[ -n "${TF_STATE_DIR:-}" ]]; then + mkdir -p "${TF_STATE_DIR}" + terraform -chdir="${REPO_ROOT}/terraform/vault-transit/dev" init \ + -input=false \ + -reconfigure \ + -backend-config="path=${TF_STATE_DIR}/vault-transit-dev.tfstate" +else + terraform -chdir="${REPO_ROOT}/terraform/vault-transit/dev" init -input=false +fi + +terraform -chdir="${REPO_ROOT}/terraform/vault-transit/dev" apply -input=false -auto-approve diff --git a/scripts/vault-transit/dev/ensure-unsealed.sh b/scripts/vault-transit/dev/ensure-unsealed.sh new file mode 100755 index 0000000..217719b --- /dev/null +++ b/scripts/vault-transit/dev/ensure-unsealed.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash + +set -euo pipefail + +VAULT_ADDR="${VAULT_ADDR:-http://127.0.0.1:18200}" + +export VAULT_ADDR + +if ! command -v vault >/dev/null 2>&1; then + echo "vault CLI is required" >&2 + exit 1 +fi + +status_json="$(mktemp)" +trap 'rm -f "$status_json"' EXIT + +if vault status -format=json >"$status_json" 2>/dev/null; then + : +else + if [[ ! -s "$status_json" ]]; then + echo "Vault transit provider is unreachable at ${VAULT_ADDR}" >&2 + exit 1 + fi +fi + +initialized="$(jq -r '.initialized' "$status_json")" +sealed="$(jq -r '.sealed' "$status_json")" + +if [[ "$initialized" != "true" ]]; then + echo "Transit provider Vault is not initialized." >&2 + exit 1 +fi + +if [[ "$sealed" == "true" ]]; then + cat >&2 <<'EOF' +Transit provider Vault is sealed. +Manual unseal is required before the CI reconcile workflow can continue. +Run the provider Vault runbook from runbooks/vault-transit/dev/README.md and unseal the vault-transit instance first. +EOF + exit 1 +fi diff --git a/scripts/vault-transit/dev/populate-workload-seeds.sh b/scripts/vault-transit/dev/populate-workload-seeds.sh new file mode 100755 index 0000000..4db5715 --- /dev/null +++ b/scripts/vault-transit/dev/populate-workload-seeds.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if ! command -v vault >/dev/null 2>&1; then + echo "vault CLI is required" >&2 + exit 1 +fi + +prompt_secret() { + local var_name="$1" + local label="$2" + local value="" + local confirm="" + + while true; do + read -r -s -p "${label}: " value + echo + read -r -s -p "${label} (confirm): " confirm + echo + + if [[ "$value" != "$confirm" ]]; then + echo "Values did not match. Try again." >&2 + continue + fi + + if [[ -z "$value" ]]; then + echo "Value must not be empty." >&2 + continue + fi + + printf -v "$var_name" '%s' "$value" + break + done +} + +prompt_value() { + local var_name="$1" + local label="$2" + local default_value="${3:-}" + local value="" + + if [[ -n "$default_value" ]]; then + read -r -p "${label} [${default_value}]: " value + value="${value:-$default_value}" + else + read -r -p "${label}: " value + fi + + if [[ -z "$value" ]]; then + echo "Value must not be empty." >&2 + exit 1 + fi + + printf -v "$var_name" '%s' "$value" +} + +echo "Populate provider Vault workload seeds" +echo "VAULT_ADDR=${VAULT_ADDR:-unset}" +echo "This script prompts securely so values are not exposed in shell history." +echo "The workload workflow AppRole bootstrap path is managed by Terraform." +echo + +prompt_secret PLATFORM_POSTGRES_SUPERUSER_PASSWORD "Platform Postgres superuser password" +prompt_value AUTH_SERVER_DATASOURCE_USERNAME "Postgres auth-server username" "project_auth" +prompt_secret AUTH_SERVER_DATASOURCE_PASSWORD "Postgres auth-server password" +prompt_secret PLATFORM_KEYCLOAK_DB_PASSWORD "Postgres keycloak password" +prompt_secret PLATFORM_KEYCLOAK_BOOTSTRAP_ADMIN_PASSWORD "Platform Keycloak bootstrap admin password" +prompt_secret AUTH_SERVER_KEYCLOAK_CLIENT_SECRET "Keycloak auth-server client secret" + +vault kv put kv/dev/workload/platform/postgres/superuser \ + POSTGRES_SUPERUSER_PASSWORD="$PLATFORM_POSTGRES_SUPERUSER_PASSWORD" >/dev/null + +vault kv put kv/dev/workload/platform/postgres/auth-server \ + APP_DATASOURCE_USERNAME="$AUTH_SERVER_DATASOURCE_USERNAME" \ + APP_DATASOURCE_PASSWORD="$AUTH_SERVER_DATASOURCE_PASSWORD" \ + AUTH_DB_PASSWORD="$AUTH_SERVER_DATASOURCE_PASSWORD" >/dev/null + +vault kv put kv/dev/workload/platform/postgres/keycloak \ + KEYCLOAK_DB_PASSWORD="$PLATFORM_KEYCLOAK_DB_PASSWORD" >/dev/null + +vault kv put kv/dev/workload/platform/keycloak/bootstrap-admin \ + KC_BOOTSTRAP_ADMIN_PASSWORD="$PLATFORM_KEYCLOAK_BOOTSTRAP_ADMIN_PASSWORD" >/dev/null + +vault kv put kv/dev/workload/platform/keycloak/client-auth-server \ + APP_SECURITY_OAUTH2_KEYCLOAK_CLIENT_SECRET="$AUTH_SERVER_KEYCLOAK_CLIENT_SECRET" \ + KEYCLOAK_CLIENT_SECRET="$AUTH_SERVER_KEYCLOAK_CLIENT_SECRET" >/dev/null + +echo +echo "Provider Vault workload seed values updated." diff --git a/scripts/vault/dev/apply-argocd-dev-apps.sh b/scripts/vault/dev/apply-argocd-dev-apps.sh new file mode 100755 index 0000000..63bbd1f --- /dev/null +++ b/scripts/vault/dev/apply-argocd-dev-apps.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash + +set -euo pipefail + +for cmd in kubectl; do + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "$cmd is required" >&2 + exit 1 + fi +done + +kubectl apply -f argocd/projects/dev +kubectl apply -f argocd/applications/dev/infra/platform.yaml +kubectl apply -f argocd/applications/dev/apps diff --git a/scripts/vault/dev/apply-argocd-dev-infra.sh b/scripts/vault/dev/apply-argocd-dev-infra.sh new file mode 100755 index 0000000..691a641 --- /dev/null +++ b/scripts/vault/dev/apply-argocd-dev-infra.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +set -euo pipefail + +for cmd in kubectl; do + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "$cmd is required" >&2 + exit 1 + fi +done + +kubectl apply -f argocd/projects/dev +kubectl apply -f argocd/applications/dev/infra/sealed-secrets.yaml +kubectl apply -f argocd/applications/dev/infra/vault-transit.yaml +kubectl apply -f argocd/applications/dev/infra/vault.yaml +kubectl apply -f argocd/applications/dev/infra/vault-agent-injector.yaml diff --git a/scripts/vault/dev/bootstrap-runbook.sh b/scripts/vault/dev/bootstrap-runbook.sh new file mode 100755 index 0000000..162464a --- /dev/null +++ b/scripts/vault/dev/bootstrap-runbook.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" + +source "${SCRIPT_DIR}/provider-lib.sh" +source "${SCRIPT_DIR}/workload-auth-lib.sh" + +if ! command -v terraform >/dev/null 2>&1; then + echo "terraform CLI is required" >&2 + exit 1 +fi + +export VAULT_WORKLOAD_ADDR="${VAULT_WORKLOAD_ADDR:-${VAULT_ADDR:-http://127.0.0.1:8200}}" +export VAULT_TRANSIT_ADDR="${VAULT_TRANSIT_ADDR:-http://127.0.0.1:18200}" +export TF_VAR_workload_vault_addr="$VAULT_WORKLOAD_ADDR" +export TF_VAR_workload_vault_token="$(workload_vault_token)" +export TF_VAR_transit_vault_addr="$VAULT_TRANSIT_ADDR" +export TF_VAR_transit_vault_token="$(provider_vault_token)" + +mkdir -p "${REPO_ROOT}/.terraform-state" + +if [[ -n "${TF_STATE_DIR:-}" ]]; then + mkdir -p "${TF_STATE_DIR}" + terraform -chdir="${REPO_ROOT}/terraform/vault/dev" init \ + -input=false \ + -reconfigure \ + -backend-config="path=${TF_STATE_DIR}/vault-dev.tfstate" +else + terraform -chdir="${REPO_ROOT}/terraform/vault/dev" init -input=false +fi + +terraform -chdir="${REPO_ROOT}/terraform/vault/dev" apply -input=false -auto-approve diff --git a/scripts/vault/dev/ensure-unsealed.sh b/scripts/vault/dev/ensure-unsealed.sh new file mode 100755 index 0000000..f37e0bc --- /dev/null +++ b/scripts/vault/dev/ensure-unsealed.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash + +set -euo pipefail + +VAULT_ADDR="${VAULT_ADDR:-http://127.0.0.1:8200}" + +export VAULT_ADDR + +if ! command -v vault >/dev/null 2>&1; then + echo "vault CLI is required" >&2 + exit 1 +fi + +status_json="$(mktemp)" +trap 'rm -f "$status_json"' EXIT + +if vault status -format=json >"$status_json" 2>/dev/null; then + : +else + if [[ ! -s "$status_json" ]]; then + echo "Vault is unreachable at ${VAULT_ADDR}" >&2 + exit 1 + fi +fi + +initialized="$(jq -r '.initialized' "$status_json")" +sealed="$(jq -r '.sealed' "$status_json")" + +if [[ "$initialized" != "true" ]]; then + echo "Workload Vault is not initialized." >&2 + exit 1 +fi + +if [[ "$sealed" == "true" ]]; then + echo "Workload Vault is still sealed. Check the transit unseal provider and vault-transit-seal secret." >&2 + exit 1 +fi diff --git a/scripts/vault/dev/issue-operator-tokens.sh b/scripts/vault/dev/issue-operator-tokens.sh new file mode 100755 index 0000000..bf48176 --- /dev/null +++ b/scripts/vault/dev/issue-operator-tokens.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash + +set -euo pipefail + +VAULT_ADDR="${VAULT_ADDR:-http://127.0.0.1:8200}" +VAULT_TOKEN="${VAULT_TOKEN:-}" +TOKEN_TTL="${TOKEN_TTL:-1h}" + +if [[ -z "$VAULT_TOKEN" ]]; then + echo "VAULT_TOKEN must be set to a platform-admin-dev token." >&2 + exit 1 +fi + +for cmd in vault; do + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "$cmd is required" >&2 + exit 1 + fi +done + +export VAULT_ADDR +export VAULT_TOKEN + +postgres_token="$( + vault token create -orphan -policy=postgres-operator-dev -ttl="$TOKEN_TTL" -field=token +)" + +keycloak_token="$( + vault token create -orphan -policy=keycloak-operator-dev -ttl="$TOKEN_TTL" -field=token +)" + +echo "" +echo "postgres-operator-dev token:" +echo "$postgres_token" +echo "" +echo "keycloak-operator-dev token:" +echo "$keycloak_token" +echo "" diff --git a/scripts/vault/dev/provider-lib.sh b/scripts/vault/dev/provider-lib.sh new file mode 100644 index 0000000..8b4de79 --- /dev/null +++ b/scripts/vault/dev/provider-lib.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash + +__provider_vault_token_cache="${__provider_vault_token_cache:-}" + +provider_vault_addr() { + printf '%s\n' "${VAULT_TRANSIT_ADDR:-http://127.0.0.1:18200}" +} + +provider_vault_login_with_approle() { + local role_id="$1" + local secret_id="$2" + + if [[ -n "$__provider_vault_token_cache" ]]; then + printf '%s\n' "$__provider_vault_token_cache" + return 0 + fi + + __provider_vault_token_cache="$( + VAULT_ADDR="$(provider_vault_addr)" \ + vault write -field=token auth/approle/login \ + role_id="$role_id" \ + secret_id="$secret_id" + )" + + printf '%s\n' "$__provider_vault_token_cache" +} + +provider_vault_token() { + if [[ -n "${VAULT_TRANSIT_DEV_ROLE_ID:-}" && -n "${VAULT_TRANSIT_DEV_SECRET_ID:-}" ]]; then + provider_vault_login_with_approle \ + "$VAULT_TRANSIT_DEV_ROLE_ID" \ + "$VAULT_TRANSIT_DEV_SECRET_ID" + return 0 + fi + + if [[ -n "${VAULT_TRANSIT_DEV_BOOTSTRAP_TOKEN:-}" ]]; then + printf '%s\n' "$VAULT_TRANSIT_DEV_BOOTSTRAP_TOKEN" + return 0 + fi + + if [[ -n "${VAULT_TOKEN:-}" ]]; then + printf '%s\n' "$VAULT_TOKEN" + return 0 + fi + + echo "VAULT_TRANSIT_DEV_BOOTSTRAP_TOKEN or VAULT_TRANSIT_DEV_ROLE_ID/VAULT_TRANSIT_DEV_SECRET_ID or VAULT_TOKEN must be set." >&2 + return 1 +} + +provider_kv_get_json() { + local path="$1" + VAULT_ADDR="$(provider_vault_addr)" \ + VAULT_TOKEN="$(provider_vault_token)" \ + vault kv get -format=json "$path" +} + +provider_read_workload_bootstrap_token() { + provider_kv_get_json "kv/dev/workload/bootstrap" | jq -r '.data.data.VAULT_WORKLOAD_DEV_BOOTSTRAP_TOKEN // empty' +} + +provider_read_workload_role_id() { + provider_kv_get_json "kv/dev/workload/bootstrap" | jq -r '.data.data.VAULT_WORKLOAD_DEV_ROLE_ID // empty' +} + +provider_read_workload_secret_id() { + provider_kv_get_json "kv/dev/workload/bootstrap" | jq -r '.data.data.VAULT_WORKLOAD_DEV_SECRET_ID // empty' +} diff --git a/scripts/vault/dev/workload-auth-lib.sh b/scripts/vault/dev/workload-auth-lib.sh new file mode 100644 index 0000000..3c5b828 --- /dev/null +++ b/scripts/vault/dev/workload-auth-lib.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash + +__workload_vault_token_cache="${__workload_vault_token_cache:-}" + +workload_vault_login_with_approle() { + local role_id="$1" + local secret_id="$2" + + if [[ -n "$__workload_vault_token_cache" ]]; then + printf '%s\n' "$__workload_vault_token_cache" + return 0 + fi + + __workload_vault_token_cache="$( + VAULT_ADDR="${VAULT_WORKLOAD_ADDR:-${VAULT_ADDR:-http://127.0.0.1:8200}}" \ + vault write -field=token auth/approle/login \ + role_id="$role_id" \ + secret_id="$secret_id" + )" + + printf '%s\n' "$__workload_vault_token_cache" +} + +workload_vault_token() { + local role_id="" + local secret_id="" + local token="" + + if [[ -n "${VAULT_WORKLOAD_DEV_BOOTSTRAP_TOKEN:-}" ]]; then + printf '%s\n' "$VAULT_WORKLOAD_DEV_BOOTSTRAP_TOKEN" + return 0 + fi + + if [[ -n "${VAULT_WORKLOAD_DEV_ROLE_ID:-}" && -n "${VAULT_WORKLOAD_DEV_SECRET_ID:-}" ]]; then + workload_vault_login_with_approle \ + "$VAULT_WORKLOAD_DEV_ROLE_ID" \ + "$VAULT_WORKLOAD_DEV_SECRET_ID" + return 0 + fi + + role_id="$(provider_read_workload_role_id 2>/dev/null || true)" + secret_id="$(provider_read_workload_secret_id 2>/dev/null || true)" + if [[ -n "$role_id" && -n "$secret_id" ]]; then + workload_vault_login_with_approle "$role_id" "$secret_id" + return 0 + fi + + token="$(provider_read_workload_bootstrap_token 2>/dev/null || true)" + if [[ -n "$token" ]]; then + printf '%s\n' "$token" + return 0 + fi + + if [[ -n "${VAULT_TOKEN:-}" ]]; then + printf '%s\n' "$VAULT_TOKEN" + return 0 + fi + + echo "VAULT_WORKLOAD_DEV_ROLE_ID/VAULT_WORKLOAD_DEV_SECRET_ID or VAULT_WORKLOAD_DEV_BOOTSTRAP_TOKEN or VAULT_TOKEN must be set." >&2 + return 1 +} diff --git a/terraform/vault-transit/dev/.terraform.lock.hcl b/terraform/vault-transit/dev/.terraform.lock.hcl new file mode 100644 index 0000000..bbb7f8a --- /dev/null +++ b/terraform/vault-transit/dev/.terraform.lock.hcl @@ -0,0 +1,42 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/kubernetes" { + version = "2.38.0" + constraints = "~> 2.32" + hashes = [ + "h1:5CkveFo5ynsLdzKk+Kv+r7+U9rMrNjfZPT3a0N/fhgE=", + "zh:0af928d776eb269b192dc0ea0f8a3f0f5ec117224cd644bdacdc682300f84ba0", + "zh:1be998e67206f7cfc4ffe77c01a09ac91ce725de0abaec9030b22c0a832af44f", + "zh:326803fe5946023687d603f6f1bab24de7af3d426b01d20e51d4e6fbe4e7ec1b", + "zh:4a99ec8d91193af961de1abb1f824be73df07489301d62e6141a656b3ebfff12", + "zh:5136e51765d6a0b9e4dbcc3b38821e9736bd2136cf15e9aac11668f22db117d2", + "zh:63fab47349852d7802fb032e4f2b6a101ee1ce34b62557a9ad0f0f0f5b6ecfdc", + "zh:924fb0257e2d03e03e2bfe9c7b99aa73c195b1f19412ca09960001bee3c50d15", + "zh:b63a0be5e233f8f6727c56bed3b61eb9456ca7a8bb29539fba0837f1badf1396", + "zh:d39861aa21077f1bc899bc53e7233262e530ba8a3a2d737449b100daeb303e4d", + "zh:de0805e10ebe4c83ce3b728a67f6b0f9d18be32b25146aa89116634df5145ad4", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + "zh:faf23e45f0090eef8ba28a8aac7ec5d4fdf11a36c40a8d286304567d71c1e7db", + ] +} + +provider "registry.terraform.io/hashicorp/vault" { + version = "4.8.0" + constraints = "~> 4.8.0" + hashes = [ + "h1:aHqgWQhDBMeZO9iUKwJYMlh4q+xNMUlMIcjRbF4d02Y=", + "zh:269ab13433f67684012ae7e15876532b0312f5d0d2002a9cf9febb1279ce5ea6", + "zh:4babc95bf0c40eb85005db1dc2ca403c46be4a71dd3e409db3711a56f7a5ca0e", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:86e27c1c625ecc24446a11eeffc3ac319b36c2b4e51251db8579256a0dbcf136", + "zh:a32f31da94824009e26b077374440b52098aecb93c92ff55dc3d31dd37c4ea25", + "zh:be0a18c6c0425518bab4fbffd82078b82036a88503b5d76064de551c9f646cbf", + "zh:be5a77fdfd36863ebeec79cd12b1d13322ffad6821d157a0b279789fa06b5937", + "zh:be8317d142a3caad74c7d936039ae27076a1b2b8312ef5208e2871a5f525977c", + "zh:c94a84895a3d9954b80e983eed4603330a5cdbbd8eef5b3c99278c2d1402ef3c", + "zh:de1fb712784dd8415f011ca5346a34f87fab6046c730557615247e511dbc7d98", + "zh:e3eafae7da550f86cae395d6660b2a0e93ec8d2b0e0e5ef982ec762e961fc952", + "zh:ff35fb1ab6add288f0f368981e56f780b50405accd1937131cba1137999c8d83", + ] +} diff --git a/terraform/vault-transit/dev/.terraform/providers/registry.terraform.io/hashicorp/kubernetes/2.38.0/linux_amd64/LICENSE.txt b/terraform/vault-transit/dev/.terraform/providers/registry.terraform.io/hashicorp/kubernetes/2.38.0/linux_amd64/LICENSE.txt new file mode 100644 index 0000000..b9ac071 --- /dev/null +++ b/terraform/vault-transit/dev/.terraform/providers/registry.terraform.io/hashicorp/kubernetes/2.38.0/linux_amd64/LICENSE.txt @@ -0,0 +1,375 @@ +Copyright (c) 2017 HashiCorp, Inc. + +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/terraform/vault-transit/dev/.terraform/providers/registry.terraform.io/hashicorp/kubernetes/2.38.0/linux_amd64/terraform-provider-kubernetes_v2.38.0_x5 b/terraform/vault-transit/dev/.terraform/providers/registry.terraform.io/hashicorp/kubernetes/2.38.0/linux_amd64/terraform-provider-kubernetes_v2.38.0_x5 new file mode 100755 index 0000000..a02e3e5 Binary files /dev/null and b/terraform/vault-transit/dev/.terraform/providers/registry.terraform.io/hashicorp/kubernetes/2.38.0/linux_amd64/terraform-provider-kubernetes_v2.38.0_x5 differ diff --git a/terraform/vault-transit/dev/.terraform/providers/registry.terraform.io/hashicorp/vault/4.8.0/linux_amd64/LICENSE.txt b/terraform/vault-transit/dev/.terraform/providers/registry.terraform.io/hashicorp/vault/4.8.0/linux_amd64/LICENSE.txt new file mode 100644 index 0000000..b9ac071 --- /dev/null +++ b/terraform/vault-transit/dev/.terraform/providers/registry.terraform.io/hashicorp/vault/4.8.0/linux_amd64/LICENSE.txt @@ -0,0 +1,375 @@ +Copyright (c) 2017 HashiCorp, Inc. + +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/terraform/vault-transit/dev/.terraform/providers/registry.terraform.io/hashicorp/vault/4.8.0/linux_amd64/terraform-provider-vault_v4.8.0_x5 b/terraform/vault-transit/dev/.terraform/providers/registry.terraform.io/hashicorp/vault/4.8.0/linux_amd64/terraform-provider-vault_v4.8.0_x5 new file mode 100755 index 0000000..60f3e12 Binary files /dev/null and b/terraform/vault-transit/dev/.terraform/providers/registry.terraform.io/hashicorp/vault/4.8.0/linux_amd64/terraform-provider-vault_v4.8.0_x5 differ diff --git a/terraform/vault-transit/dev/.terraform/terraform.tfstate b/terraform/vault-transit/dev/.terraform/terraform.tfstate new file mode 100644 index 0000000..30d7688 --- /dev/null +++ b/terraform/vault-transit/dev/.terraform/terraform.tfstate @@ -0,0 +1,12 @@ +{ + "version": 3, + "terraform_version": "1.14.8", + "backend": { + "type": "local", + "config": { + "path": "../../../.terraform-state/vault-transit-dev.tfstate", + "workspace_dir": null + }, + "hash": 2685574802 + } +} \ No newline at end of file diff --git a/terraform/vault-transit/dev/main.tf b/terraform/vault-transit/dev/main.tf new file mode 100644 index 0000000..8fe87f2 --- /dev/null +++ b/terraform/vault-transit/dev/main.tf @@ -0,0 +1,141 @@ +terraform { + required_version = ">= 1.6.0" + + required_providers { + kubernetes = { + source = "hashicorp/kubernetes" + version = "~> 2.32" + } + vault = { + source = "hashicorp/vault" + version = "~> 4.8.0" + } + } + + backend "local" { + path = "../../../.terraform-state/vault-transit-dev.tfstate" + } +} + +provider "vault" { + address = var.vault_addr + skip_child_token = true + token = var.vault_token +} + +provider "kubernetes" { + config_path = var.kubeconfig_path +} + +locals { + workflow_policy_path = "${path.module}/../../../runbooks/vault-transit/dev/policies/vault-transit-automation-dev.hcl" + workload_policy_path = "${path.module}/../../../runbooks/vault-transit/dev/policies/workload-vault-transit-dev.hcl" + admin_policy_path = "${path.module}/../../../runbooks/vault-transit/dev/policies/vault-transit-admin-dev.hcl" +} + +resource "vault_mount" "kv" { + path = var.kv_mount_path + type = "kv" + options = { + version = "2" + } + + lifecycle { + prevent_destroy = true + ignore_changes = [type, options] + } +} + +resource "vault_mount" "transit" { + path = var.transit_mount_path + type = "transit" + + lifecycle { + prevent_destroy = true + } +} + +resource "vault_transit_secret_backend_key" "workload_unseal" { + backend = vault_mount.transit.path + name = var.seal_key_name + type = "aes256-gcm96" +} + +resource "vault_policy" "workload_vault_transit_dev" { + name = var.workload_policy_name + policy = file(local.workload_policy_path) +} + +resource "vault_policy" "vault_transit_admin_dev" { + name = var.admin_policy_name + policy = file(local.admin_policy_path) +} + +resource "vault_policy" "vault_transit_automation_dev" { + name = var.workflow_policy_name + policy = file(local.workflow_policy_path) +} + +resource "vault_auth_backend" "approle" { + path = var.approle_auth_path + type = "approle" +} + +resource "vault_approle_auth_backend_role" "workflow" { + backend = vault_auth_backend.approle.path + role_name = var.workflow_role_name + secret_id_num_uses = 0 + secret_id_ttl = 0 + token_max_ttl = var.workflow_token_max_ttl_seconds + token_policies = [vault_policy.vault_transit_automation_dev.name] + token_ttl = var.workflow_token_ttl_seconds +} + +resource "vault_approle_auth_backend_role_secret_id" "workflow" { + backend = vault_auth_backend.approle.path + role_name = vault_approle_auth_backend_role.workflow.role_name +} + +resource "vault_token" "seal" { + display_name = "workload-vault-dev-unseal" + no_parent = true + period = var.seal_token_period + policies = [vault_policy.workload_vault_transit_dev.name] + renewable = true + + lifecycle { + ignore_changes = all + } +} + +resource "kubernetes_secret_v1" "vault_transit_seal" { + wait_for_service_account_token = true + + metadata { + name = var.target_secret_name + namespace = var.target_namespace + } + + data = { + VAULT_TRANSIT_SEAL_TOKEN = vault_token.seal.client_token + } + + type = "Opaque" +} + +output "workflow_role_id" { + description = "Vault transit workflow AppRole role_id." + value = vault_approle_auth_backend_role.workflow.role_id +} + +output "workflow_secret_id" { + description = "Vault transit workflow AppRole secret_id." + value = vault_approle_auth_backend_role_secret_id.workflow.secret_id + sensitive = true +} + +output "vault_transit_seal_token" { + description = "Periodic seal token written into the vault-transit-seal Kubernetes Secret." + value = vault_token.seal.client_token + sensitive = true +} diff --git a/terraform/vault-transit/dev/variables.tf b/terraform/vault-transit/dev/variables.tf new file mode 100644 index 0000000..d64c608 --- /dev/null +++ b/terraform/vault-transit/dev/variables.tf @@ -0,0 +1,95 @@ +variable "admin_policy_name" { + description = "Name of the operator policy kept for manual vault-transit maintenance." + type = string + default = "vault-transit-admin-dev" +} + +variable "approle_auth_path" { + description = "Path where the AppRole auth backend is mounted." + type = string + default = "approle" +} + +variable "kubeconfig_path" { + description = "Path to the kubeconfig used for managing the seal Secret." + type = string + default = "~/.kube/config" +} + +variable "kv_mount_path" { + description = "Mount path for the provider KV-v2 engine." + type = string + default = "kv" +} + +variable "seal_key_name" { + description = "Transit key used by workload Vault auto-unseal." + type = string + default = "workload-vault-dev-unseal" +} + +variable "seal_token_period" { + description = "Periodic renewal interval for the workload auto-unseal token." + type = string + default = "24h" +} + +variable "target_namespace" { + description = "Namespace that receives the vault-transit seal Secret." + type = string + default = "vault" +} + +variable "target_secret_name" { + description = "Name of the Kubernetes Secret holding the workload auto-unseal token." + type = string + default = "vault-transit-seal" +} + +variable "transit_mount_path" { + description = "Mount path for the provider transit engine." + type = string + default = "transit" +} + +variable "vault_addr" { + description = "Address of the vault-transit API." + type = string + default = "http://127.0.0.1:18200" +} + +variable "vault_token" { + description = "Privileged token used to reconcile the vault-transit configuration." + type = string + sensitive = true +} + +variable "workflow_policy_name" { + description = "Policy granted to the vault-transit workflow AppRole." + type = string + default = "vault-transit-automation-dev" +} + +variable "workflow_role_name" { + description = "Name of the workflow AppRole used by CI." + type = string + default = "vault-transit-dev-workflow" +} + +variable "workflow_token_max_ttl_seconds" { + description = "Maximum TTL, in seconds, for the workflow AppRole login token." + type = number + default = 14400 +} + +variable "workflow_token_ttl_seconds" { + description = "Default TTL, in seconds, for the workflow AppRole login token." + type = number + default = 3600 +} + +variable "workload_policy_name" { + description = "Policy name granted to the workload Vault auto-unseal token." + type = string + default = "workload-vault-transit-dev" +} diff --git a/terraform/vault-transit/reconcile/.terraform.lock.hcl b/terraform/vault-transit/reconcile/.terraform.lock.hcl new file mode 100644 index 0000000..bbb7f8a --- /dev/null +++ b/terraform/vault-transit/reconcile/.terraform.lock.hcl @@ -0,0 +1,42 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/kubernetes" { + version = "2.38.0" + constraints = "~> 2.32" + hashes = [ + "h1:5CkveFo5ynsLdzKk+Kv+r7+U9rMrNjfZPT3a0N/fhgE=", + "zh:0af928d776eb269b192dc0ea0f8a3f0f5ec117224cd644bdacdc682300f84ba0", + "zh:1be998e67206f7cfc4ffe77c01a09ac91ce725de0abaec9030b22c0a832af44f", + "zh:326803fe5946023687d603f6f1bab24de7af3d426b01d20e51d4e6fbe4e7ec1b", + "zh:4a99ec8d91193af961de1abb1f824be73df07489301d62e6141a656b3ebfff12", + "zh:5136e51765d6a0b9e4dbcc3b38821e9736bd2136cf15e9aac11668f22db117d2", + "zh:63fab47349852d7802fb032e4f2b6a101ee1ce34b62557a9ad0f0f0f5b6ecfdc", + "zh:924fb0257e2d03e03e2bfe9c7b99aa73c195b1f19412ca09960001bee3c50d15", + "zh:b63a0be5e233f8f6727c56bed3b61eb9456ca7a8bb29539fba0837f1badf1396", + "zh:d39861aa21077f1bc899bc53e7233262e530ba8a3a2d737449b100daeb303e4d", + "zh:de0805e10ebe4c83ce3b728a67f6b0f9d18be32b25146aa89116634df5145ad4", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + "zh:faf23e45f0090eef8ba28a8aac7ec5d4fdf11a36c40a8d286304567d71c1e7db", + ] +} + +provider "registry.terraform.io/hashicorp/vault" { + version = "4.8.0" + constraints = "~> 4.8.0" + hashes = [ + "h1:aHqgWQhDBMeZO9iUKwJYMlh4q+xNMUlMIcjRbF4d02Y=", + "zh:269ab13433f67684012ae7e15876532b0312f5d0d2002a9cf9febb1279ce5ea6", + "zh:4babc95bf0c40eb85005db1dc2ca403c46be4a71dd3e409db3711a56f7a5ca0e", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:86e27c1c625ecc24446a11eeffc3ac319b36c2b4e51251db8579256a0dbcf136", + "zh:a32f31da94824009e26b077374440b52098aecb93c92ff55dc3d31dd37c4ea25", + "zh:be0a18c6c0425518bab4fbffd82078b82036a88503b5d76064de551c9f646cbf", + "zh:be5a77fdfd36863ebeec79cd12b1d13322ffad6821d157a0b279789fa06b5937", + "zh:be8317d142a3caad74c7d936039ae27076a1b2b8312ef5208e2871a5f525977c", + "zh:c94a84895a3d9954b80e983eed4603330a5cdbbd8eef5b3c99278c2d1402ef3c", + "zh:de1fb712784dd8415f011ca5346a34f87fab6046c730557615247e511dbc7d98", + "zh:e3eafae7da550f86cae395d6660b2a0e93ec8d2b0e0e5ef982ec762e961fc952", + "zh:ff35fb1ab6add288f0f368981e56f780b50405accd1937131cba1137999c8d83", + ] +} diff --git a/terraform/vault-transit/reconcile/main.tf b/terraform/vault-transit/reconcile/main.tf new file mode 100644 index 0000000..7fa32db --- /dev/null +++ b/terraform/vault-transit/reconcile/main.tf @@ -0,0 +1,51 @@ +terraform { + required_version = ">= 1.6.0" + + required_providers { + vault = { + source = "hashicorp/vault" + version = "~> 4.8.0" + } + } + + backend "local" { + path = "../../../.terraform-state/vault-transit-reconcile.tfstate" + } +} + +provider "vault" { + address = var.vault_addr + skip_child_token = true + token = var.vault_token +} + +locals { + workflow_policy_path = "${path.module}/../../../runbooks/vault-transit/dev/policies/vault-transit-automation-dev.hcl" + workload_policy_path = "${path.module}/../../../runbooks/vault-transit/dev/policies/workload-vault-transit-dev.hcl" + admin_policy_path = "${path.module}/../../../runbooks/vault-transit/dev/policies/vault-transit-admin-dev.hcl" +} + +resource "vault_policy" "workload_vault_transit_dev" { + name = var.workload_policy_name + policy = file(local.workload_policy_path) +} + +resource "vault_policy" "vault_transit_admin_dev" { + name = var.admin_policy_name + policy = file(local.admin_policy_path) +} + +resource "vault_policy" "vault_transit_automation_dev" { + name = var.workflow_policy_name + policy = file(local.workflow_policy_path) +} + +resource "vault_approle_auth_backend_role" "workflow" { + backend = var.approle_auth_path + role_name = var.workflow_role_name + secret_id_num_uses = 0 + secret_id_ttl = 0 + token_max_ttl = var.workflow_token_max_ttl_seconds + token_policies = [vault_policy.vault_transit_automation_dev.name] + token_ttl = var.workflow_token_ttl_seconds +} diff --git a/terraform/vault-transit/reconcile/variables.tf b/terraform/vault-transit/reconcile/variables.tf new file mode 100644 index 0000000..48a3e95 --- /dev/null +++ b/terraform/vault-transit/reconcile/variables.tf @@ -0,0 +1,53 @@ +variable "admin_policy_name" { + description = "Name of the operator policy kept for manual vault-transit maintenance." + type = string + default = "vault-transit-admin-dev" +} + +variable "approle_auth_path" { + description = "Path where the AppRole auth backend is mounted." + type = string + default = "approle" +} + +variable "vault_addr" { + description = "Address of the vault-transit API." + type = string + default = "http://127.0.0.1:18200" +} + +variable "vault_token" { + description = "Workflow token used to reconcile vault-transit policies and workflow AppRole." + type = string + sensitive = true +} + +variable "workflow_policy_name" { + description = "Policy granted to the vault-transit workflow AppRole." + type = string + default = "vault-transit-automation-dev" +} + +variable "workflow_role_name" { + description = "Name of the workflow AppRole used by CI." + type = string + default = "vault-transit-dev-workflow" +} + +variable "workflow_token_max_ttl_seconds" { + description = "Maximum TTL, in seconds, for the workflow AppRole login token." + type = number + default = 14400 +} + +variable "workflow_token_ttl_seconds" { + description = "Default TTL, in seconds, for the workflow AppRole login token." + type = number + default = 3600 +} + +variable "workload_policy_name" { + description = "Policy name granted to the workload Vault auto-unseal token." + type = string + default = "workload-vault-transit-dev" +} diff --git a/terraform/vault/dev/.terraform.lock.hcl b/terraform/vault/dev/.terraform.lock.hcl new file mode 100644 index 0000000..5ea6fcd --- /dev/null +++ b/terraform/vault/dev/.terraform.lock.hcl @@ -0,0 +1,22 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/vault" { + version = "4.8.0" + constraints = "~> 4.8.0" + hashes = [ + "h1:aHqgWQhDBMeZO9iUKwJYMlh4q+xNMUlMIcjRbF4d02Y=", + "zh:269ab13433f67684012ae7e15876532b0312f5d0d2002a9cf9febb1279ce5ea6", + "zh:4babc95bf0c40eb85005db1dc2ca403c46be4a71dd3e409db3711a56f7a5ca0e", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:86e27c1c625ecc24446a11eeffc3ac319b36c2b4e51251db8579256a0dbcf136", + "zh:a32f31da94824009e26b077374440b52098aecb93c92ff55dc3d31dd37c4ea25", + "zh:be0a18c6c0425518bab4fbffd82078b82036a88503b5d76064de551c9f646cbf", + "zh:be5a77fdfd36863ebeec79cd12b1d13322ffad6821d157a0b279789fa06b5937", + "zh:be8317d142a3caad74c7d936039ae27076a1b2b8312ef5208e2871a5f525977c", + "zh:c94a84895a3d9954b80e983eed4603330a5cdbbd8eef5b3c99278c2d1402ef3c", + "zh:de1fb712784dd8415f011ca5346a34f87fab6046c730557615247e511dbc7d98", + "zh:e3eafae7da550f86cae395d6660b2a0e93ec8d2b0e0e5ef982ec762e961fc952", + "zh:ff35fb1ab6add288f0f368981e56f780b50405accd1937131cba1137999c8d83", + ] +} diff --git a/terraform/vault/dev/.terraform/providers/registry.terraform.io/hashicorp/vault/4.8.0/linux_amd64/LICENSE.txt b/terraform/vault/dev/.terraform/providers/registry.terraform.io/hashicorp/vault/4.8.0/linux_amd64/LICENSE.txt new file mode 100644 index 0000000..b9ac071 --- /dev/null +++ b/terraform/vault/dev/.terraform/providers/registry.terraform.io/hashicorp/vault/4.8.0/linux_amd64/LICENSE.txt @@ -0,0 +1,375 @@ +Copyright (c) 2017 HashiCorp, Inc. + +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/terraform/vault/dev/.terraform/providers/registry.terraform.io/hashicorp/vault/4.8.0/linux_amd64/terraform-provider-vault_v4.8.0_x5 b/terraform/vault/dev/.terraform/providers/registry.terraform.io/hashicorp/vault/4.8.0/linux_amd64/terraform-provider-vault_v4.8.0_x5 new file mode 100755 index 0000000..60f3e12 Binary files /dev/null and b/terraform/vault/dev/.terraform/providers/registry.terraform.io/hashicorp/vault/4.8.0/linux_amd64/terraform-provider-vault_v4.8.0_x5 differ diff --git a/terraform/vault/dev/.terraform/terraform.tfstate b/terraform/vault/dev/.terraform/terraform.tfstate new file mode 100644 index 0000000..009559f --- /dev/null +++ b/terraform/vault/dev/.terraform/terraform.tfstate @@ -0,0 +1,12 @@ +{ + "version": 3, + "terraform_version": "1.14.8", + "backend": { + "type": "local", + "config": { + "path": "../../../.terraform-state/vault-dev.tfstate", + "workspace_dir": null + }, + "hash": 3604471491 + } +} \ No newline at end of file diff --git a/terraform/vault/dev/main.tf b/terraform/vault/dev/main.tf new file mode 100644 index 0000000..3a15a67 --- /dev/null +++ b/terraform/vault/dev/main.tf @@ -0,0 +1,364 @@ +terraform { + required_version = ">= 1.6.0" + + required_providers { + vault = { + source = "hashicorp/vault" + version = "~> 4.8.0" + } + } + + backend "local" { + path = "../../../.terraform-state/vault-dev.tfstate" + } +} + +provider "vault" { + address = var.workload_vault_addr + skip_child_token = true + token = var.workload_vault_token +} + +provider "vault" { + alias = "transit" + address = var.transit_vault_addr + skip_child_token = true + token = var.transit_vault_token +} + +locals { + policy_dir = "${path.module}/../../../runbooks/vault/dev/policies" + + auth_server_policy_name = "auth-server-dev" + auth_db_migration_policy_name = "auth-db-migration-dev" + postgres_policy_name = "postgres-dev" + keycloak_policy_name = "keycloak-dev" + keycloak_client_sync_policy_name = "keycloak-client-sync-dev" + postgres_operator_policy_name = "postgres-operator-dev" + keycloak_operator_policy_name = "keycloak-operator-dev" + platform_admin_policy_name = "platform-admin-dev" + workload_automation_policy_name = "workload-automation-dev" + workload_workflow_role_name = "workload-dev-workflow" + auth_db_migration_role_name = "auth-db-migration-dev" + postgres_operator_role_name = "postgres-operator-dev" + + migration_creation_statements = [ + <<-EOT + CREATE ROLE "{{name}}" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; + GRANT "${var.auth_db_role}" TO "{{name}}"; + EOT + ] + + migration_revocation_statements = [ + <<-EOT + REASSIGN OWNED BY "{{name}}" TO "${var.auth_db_role}"; + DROP OWNED BY "{{name}}"; + REVOKE "${var.auth_db_role}" FROM "{{name}}"; + DROP ROLE IF EXISTS "{{name}}"; + EOT + ] + + operator_creation_statements = [ + <<-EOT + CREATE ROLE "{{name}}" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; + GRANT "${var.auth_db_role}" TO "{{name}}"; + EOT + ] + + operator_revocation_statements = [ + <<-EOT + REASSIGN OWNED BY "{{name}}" TO "${var.auth_db_role}"; + DROP OWNED BY "{{name}}"; + REVOKE "${var.auth_db_role}" FROM "{{name}}"; + DROP ROLE IF EXISTS "{{name}}"; + EOT + ] +} + +resource "vault_mount" "kv" { + path = var.kv_mount_path + type = "kv" + options = { + version = "2" + } + + lifecycle { + prevent_destroy = true + ignore_changes = [type, options] + } +} + +resource "vault_mount" "database" { + path = var.database_mount_path + type = "database" + + lifecycle { + prevent_destroy = true + } +} + +resource "vault_mount" "transit" { + path = var.transit_mount_path + type = "transit" + + lifecycle { + prevent_destroy = true + } +} + +resource "vault_auth_backend" "kubernetes" { + path = var.kubernetes_auth_path + type = "kubernetes" +} + +resource "vault_kubernetes_auth_backend_config" "cluster" { + backend = vault_auth_backend.kubernetes.path + disable_iss_validation = true + kubernetes_host = "https://kubernetes.default.svc.cluster.local:443" + kubernetes_ca_cert = var.kubernetes_ca_cert + token_reviewer_jwt = var.kubernetes_token_reviewer_jwt +} + +resource "vault_auth_backend" "approle" { + path = var.approle_auth_path + type = "approle" +} + +resource "vault_policy" "auth_server" { + name = local.auth_server_policy_name + policy = file("${local.policy_dir}/auth-server-dev.hcl") +} + +resource "vault_policy" "auth_db_migration" { + name = local.auth_db_migration_policy_name + policy = file("${local.policy_dir}/auth-db-migration-dev.hcl") +} + +resource "vault_policy" "postgres" { + name = local.postgres_policy_name + policy = file("${local.policy_dir}/postgres-dev.hcl") +} + +resource "vault_policy" "keycloak" { + name = local.keycloak_policy_name + policy = file("${local.policy_dir}/keycloak-dev.hcl") +} + +resource "vault_policy" "keycloak_client_sync" { + name = local.keycloak_client_sync_policy_name + policy = file("${local.policy_dir}/keycloak-client-sync-dev.hcl") +} + +resource "vault_policy" "postgres_operator" { + name = local.postgres_operator_policy_name + policy = file("${local.policy_dir}/postgres-operator-dev.hcl") +} + +resource "vault_policy" "keycloak_operator" { + name = local.keycloak_operator_policy_name + policy = file("${local.policy_dir}/keycloak-operator-dev.hcl") +} + +resource "vault_policy" "platform_admin" { + name = local.platform_admin_policy_name + policy = file("${local.policy_dir}/platform-admin-dev.hcl") +} + +resource "vault_policy" "workload_automation" { + name = local.workload_automation_policy_name + policy = file("${local.policy_dir}/workload-automation-dev.hcl") +} + +resource "vault_kubernetes_auth_backend_role" "auth_server" { + backend = vault_auth_backend.kubernetes.path + bound_service_account_names = ["auth-server"] + bound_service_account_namespaces = ["auth-dev"] + role_name = local.auth_server_policy_name + token_policies = [vault_policy.auth_server.name] + token_ttl = var.kubernetes_role_ttl_seconds +} + +resource "vault_kubernetes_auth_backend_role" "auth_db_migration" { + backend = vault_auth_backend.kubernetes.path + bound_service_account_names = ["auth-db-migration"] + bound_service_account_namespaces = ["auth-dev"] + role_name = local.auth_db_migration_policy_name + token_policies = [vault_policy.auth_db_migration.name] + token_ttl = var.kubernetes_role_ttl_seconds +} + +resource "vault_kubernetes_auth_backend_role" "postgres" { + backend = vault_auth_backend.kubernetes.path + bound_service_account_names = ["postgres"] + bound_service_account_namespaces = ["platform"] + role_name = local.postgres_policy_name + token_policies = [vault_policy.postgres.name] + token_ttl = var.kubernetes_role_ttl_seconds +} + +resource "vault_kubernetes_auth_backend_role" "keycloak" { + backend = vault_auth_backend.kubernetes.path + bound_service_account_names = ["keycloak"] + bound_service_account_namespaces = ["platform"] + role_name = local.keycloak_policy_name + token_policies = [vault_policy.keycloak.name] + token_ttl = var.kubernetes_role_ttl_seconds +} + +resource "vault_kubernetes_auth_backend_role" "keycloak_client_sync" { + backend = vault_auth_backend.kubernetes.path + bound_service_account_names = ["keycloak-client-sync"] + bound_service_account_namespaces = ["platform"] + role_name = local.keycloak_client_sync_policy_name + token_policies = [vault_policy.keycloak_client_sync.name] + token_ttl = var.kubernetes_role_ttl_seconds +} + +resource "vault_transit_secret_backend_key" "project_auth_jwt" { + backend = vault_mount.transit.path + name = var.jwt_transit_key_name + type = "rsa-2048" +} + +resource "vault_approle_auth_backend_role" "workflow" { + backend = vault_auth_backend.approle.path + role_name = local.workload_workflow_role_name + secret_id_num_uses = 0 + secret_id_ttl = 0 + token_max_ttl = var.workflow_token_max_ttl_seconds + token_policies = [vault_policy.workload_automation.name] + token_ttl = var.workflow_token_ttl_seconds +} + +resource "vault_approle_auth_backend_role_secret_id" "workflow" { + backend = vault_auth_backend.approle.path + role_name = vault_approle_auth_backend_role.workflow.role_name +} + +data "vault_kv_secret_v2" "provider_postgres_superuser" { + provider = vault.transit + mount = var.seed_kv_mount_path + name = "dev/workload/platform/postgres/superuser" +} + +data "vault_kv_secret_v2" "provider_postgres_auth_server" { + provider = vault.transit + mount = var.seed_kv_mount_path + name = "dev/workload/platform/postgres/auth-server" +} + +data "vault_kv_secret_v2" "provider_postgres_keycloak" { + provider = vault.transit + mount = var.seed_kv_mount_path + name = "dev/workload/platform/postgres/keycloak" +} + +data "vault_kv_secret_v2" "provider_keycloak_bootstrap_admin" { + provider = vault.transit + mount = var.seed_kv_mount_path + name = "dev/workload/platform/keycloak/bootstrap-admin" +} + +data "vault_kv_secret_v2" "provider_keycloak_client_auth_server" { + provider = vault.transit + mount = var.seed_kv_mount_path + name = "dev/workload/platform/keycloak/client-auth-server" +} + +resource "vault_kv_secret_v2" "workload_bootstrap" { + provider = vault.transit + mount = var.seed_kv_mount_path + name = "dev/workload/bootstrap" + data_json = jsonencode({ + VAULT_WORKLOAD_DEV_ROLE_ID = vault_approle_auth_backend_role.workflow.role_id + VAULT_WORKLOAD_DEV_SECRET_ID = vault_approle_auth_backend_role_secret_id.workflow.secret_id + }) +} + +resource "vault_kv_secret_v2" "platform_postgres_superuser" { + mount = vault_mount.kv.path + name = "dev/platform/postgres/superuser" + data_json = jsonencode({ + POSTGRES_SUPERUSER_PASSWORD = data.vault_kv_secret_v2.provider_postgres_superuser.data["POSTGRES_SUPERUSER_PASSWORD"] + }) +} + +resource "vault_kv_secret_v2" "platform_postgres_auth_server" { + mount = vault_mount.kv.path + name = "dev/platform/postgres/auth-server" + data_json = jsonencode({ + APP_DATASOURCE_PASSWORD = data.vault_kv_secret_v2.provider_postgres_auth_server.data["APP_DATASOURCE_PASSWORD"] + APP_DATASOURCE_USERNAME = data.vault_kv_secret_v2.provider_postgres_auth_server.data["APP_DATASOURCE_USERNAME"] + AUTH_DB_PASSWORD = data.vault_kv_secret_v2.provider_postgres_auth_server.data["AUTH_DB_PASSWORD"] + }) +} + +resource "vault_kv_secret_v2" "platform_postgres_keycloak" { + mount = vault_mount.kv.path + name = "dev/platform/postgres/keycloak" + data_json = jsonencode({ + KEYCLOAK_DB_PASSWORD = data.vault_kv_secret_v2.provider_postgres_keycloak.data["KEYCLOAK_DB_PASSWORD"] + }) +} + +resource "vault_kv_secret_v2" "platform_keycloak_bootstrap_admin" { + mount = vault_mount.kv.path + name = "dev/platform/keycloak/bootstrap-admin" + data_json = jsonencode({ + KC_BOOTSTRAP_ADMIN_PASSWORD = data.vault_kv_secret_v2.provider_keycloak_bootstrap_admin.data["KC_BOOTSTRAP_ADMIN_PASSWORD"] + }) +} + +resource "vault_kv_secret_v2" "platform_keycloak_client_auth_server" { + mount = vault_mount.kv.path + name = "dev/platform/keycloak/client-auth-server" + data_json = jsonencode({ + APP_SECURITY_OAUTH2_KEYCLOAK_CLIENT_SECRET = data.vault_kv_secret_v2.provider_keycloak_client_auth_server.data["APP_SECURITY_OAUTH2_KEYCLOAK_CLIENT_SECRET"] + KEYCLOAK_CLIENT_SECRET = data.vault_kv_secret_v2.provider_keycloak_client_auth_server.data["KEYCLOAK_CLIENT_SECRET"] + }) +} + +resource "vault_database_secret_backend_connection" "platform_postgres" { + allowed_roles = [local.auth_db_migration_role_name, local.postgres_operator_role_name] + backend = vault_mount.database.path + name = var.database_config_name + plugin_name = "postgresql-database-plugin" + verify_connection = false + + postgresql { + connection_url = "postgresql://{{username}}:{{password}}@${var.postgres_host}:${var.postgres_port}/${var.postgres_admin_database}?sslmode=disable" + password = vault_kv_secret_v2.platform_postgres_superuser.data["POSTGRES_SUPERUSER_PASSWORD"] + username = var.postgres_superuser + } +} + +resource "vault_database_secret_backend_role" "auth_db_migration" { + backend = vault_mount.database.path + creation_statements = local.migration_creation_statements + db_name = vault_database_secret_backend_connection.platform_postgres.name + default_ttl = var.auth_db_migration_default_ttl_seconds + max_ttl = var.auth_db_migration_max_ttl_seconds + name = local.auth_db_migration_role_name + revocation_statements = local.migration_revocation_statements +} + +resource "vault_database_secret_backend_role" "postgres_operator" { + backend = vault_mount.database.path + creation_statements = local.operator_creation_statements + db_name = vault_database_secret_backend_connection.platform_postgres.name + default_ttl = var.postgres_operator_default_ttl_seconds + max_ttl = var.postgres_operator_max_ttl_seconds + name = local.postgres_operator_role_name + revocation_statements = local.operator_revocation_statements +} + +output "workflow_role_id" { + description = "Workload Vault workflow AppRole role_id." + value = vault_approle_auth_backend_role.workflow.role_id +} + +output "workflow_secret_id" { + description = "Workload Vault workflow AppRole secret_id." + value = vault_approle_auth_backend_role_secret_id.workflow.secret_id + sensitive = true +} diff --git a/terraform/vault/dev/variables.tf b/terraform/vault/dev/variables.tf new file mode 100644 index 0000000..59b6c68 --- /dev/null +++ b/terraform/vault/dev/variables.tf @@ -0,0 +1,157 @@ +variable "approle_auth_path" { + description = "Path where the workload AppRole auth backend is mounted." + type = string + default = "approle" +} + +variable "auth_db_migration_default_ttl_seconds" { + description = "Default TTL, in seconds, for auth DB migration credentials." + type = number + default = 3600 +} + +variable "auth_db_migration_max_ttl_seconds" { + description = "Maximum TTL, in seconds, for auth DB migration credentials." + type = number + default = 86400 +} + +variable "auth_db_role" { + description = "Existing PostgreSQL role granted to dynamic auth users." + type = string + default = "project_auth" +} + +variable "database_config_name" { + description = "Name of the Vault database connection configuration." + type = string + default = "platform-postgres-dev" +} + +variable "database_mount_path" { + description = "Mount path for the workload database secrets engine." + type = string + default = "database" +} + +variable "jwt_transit_key_name" { + description = "Transit key name used for JWT signing." + type = string + default = "project-auth-jwt" +} + +variable "kubernetes_auth_path" { + description = "Path where the Kubernetes auth backend is mounted." + type = string + default = "kubernetes" +} + +variable "kubernetes_ca_cert" { + description = "CA certificate used by the workload Vault Kubernetes auth backend." + type = string + default = null + sensitive = true +} + +variable "kubernetes_token_reviewer_jwt" { + description = "Reviewer JWT used by the workload Vault Kubernetes auth backend." + type = string + default = null + sensitive = true +} + +variable "kubernetes_role_ttl_seconds" { + description = "TTL, in seconds, granted to workload Kubernetes auth logins." + type = number + default = 86400 +} + +variable "kv_mount_path" { + description = "Mount path for the workload KV-v2 engine." + type = string + default = "kv" +} + +variable "postgres_admin_database" { + description = "Administrative PostgreSQL database used by the database secret engine." + type = string + default = "postgres" +} + +variable "postgres_host" { + description = "DNS name of the platform PostgreSQL service." + type = string + default = "postgres-0.postgres.platform.svc.cluster.local" +} + +variable "postgres_operator_default_ttl_seconds" { + description = "Default TTL, in seconds, for the operator PostgreSQL role." + type = number + default = 3600 +} + +variable "postgres_operator_max_ttl_seconds" { + description = "Maximum TTL, in seconds, for the operator PostgreSQL role." + type = number + default = 28800 +} + +variable "postgres_port" { + description = "Port of the platform PostgreSQL service." + type = number + default = 5432 +} + +variable "postgres_superuser" { + description = "PostgreSQL superuser used by Vault database secrets." + type = string + default = "postgres" +} + +variable "seed_kv_mount_path" { + description = "Mount path for the provider KV-v2 seed data." + type = string + default = "kv" +} + +variable "transit_mount_path" { + description = "Mount path for the workload transit engine." + type = string + default = "transit" +} + +variable "transit_vault_addr" { + description = "Address of the vault-transit API." + type = string + default = "http://127.0.0.1:18200" +} + +variable "transit_vault_token" { + description = "Token used to read provider seed data and publish workload bootstrap credentials." + type = string + sensitive = true +} + +variable "workflow_token_max_ttl_seconds" { + description = "Maximum TTL, in seconds, for the workload workflow AppRole login token." + type = number + default = 14400 +} + +variable "workflow_token_ttl_seconds" { + description = "Default TTL, in seconds, for the workload workflow AppRole login token." + type = number + default = 3600 +} + +variable "workload_vault_addr" { + description = "Address of the workload Vault API." + type = string + default = "http://127.0.0.1:8200" +} + +variable "workload_vault_token" { + description = "Privileged token used to reconcile workload Vault." + type = string + sensitive = true +} diff --git a/terraform/vault/reconcile/.terraform.lock.hcl b/terraform/vault/reconcile/.terraform.lock.hcl new file mode 100644 index 0000000..5ea6fcd --- /dev/null +++ b/terraform/vault/reconcile/.terraform.lock.hcl @@ -0,0 +1,22 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/vault" { + version = "4.8.0" + constraints = "~> 4.8.0" + hashes = [ + "h1:aHqgWQhDBMeZO9iUKwJYMlh4q+xNMUlMIcjRbF4d02Y=", + "zh:269ab13433f67684012ae7e15876532b0312f5d0d2002a9cf9febb1279ce5ea6", + "zh:4babc95bf0c40eb85005db1dc2ca403c46be4a71dd3e409db3711a56f7a5ca0e", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:86e27c1c625ecc24446a11eeffc3ac319b36c2b4e51251db8579256a0dbcf136", + "zh:a32f31da94824009e26b077374440b52098aecb93c92ff55dc3d31dd37c4ea25", + "zh:be0a18c6c0425518bab4fbffd82078b82036a88503b5d76064de551c9f646cbf", + "zh:be5a77fdfd36863ebeec79cd12b1d13322ffad6821d157a0b279789fa06b5937", + "zh:be8317d142a3caad74c7d936039ae27076a1b2b8312ef5208e2871a5f525977c", + "zh:c94a84895a3d9954b80e983eed4603330a5cdbbd8eef5b3c99278c2d1402ef3c", + "zh:de1fb712784dd8415f011ca5346a34f87fab6046c730557615247e511dbc7d98", + "zh:e3eafae7da550f86cae395d6660b2a0e93ec8d2b0e0e5ef982ec762e961fc952", + "zh:ff35fb1ab6add288f0f368981e56f780b50405accd1937131cba1137999c8d83", + ] +} diff --git a/terraform/vault/reconcile/main.tf b/terraform/vault/reconcile/main.tf new file mode 100644 index 0000000..83d4f30 --- /dev/null +++ b/terraform/vault/reconcile/main.tf @@ -0,0 +1,283 @@ +terraform { + required_version = ">= 1.6.0" + + required_providers { + vault = { + source = "hashicorp/vault" + version = "~> 4.8.0" + } + } + + backend "local" { + path = "../../../.terraform-state/vault-reconcile.tfstate" + } +} + +provider "vault" { + address = var.workload_vault_addr + skip_child_token = true + token = var.workload_vault_token +} + +provider "vault" { + alias = "transit" + address = var.transit_vault_addr + skip_child_token = true + token = var.transit_vault_token +} + +locals { + policy_dir = "${path.module}/../../../runbooks/vault/dev/policies" + + auth_server_policy_name = "auth-server-dev" + auth_db_migration_policy_name = "auth-db-migration-dev" + postgres_policy_name = "postgres-dev" + keycloak_policy_name = "keycloak-dev" + keycloak_client_sync_policy_name = "keycloak-client-sync-dev" + postgres_operator_policy_name = "postgres-operator-dev" + keycloak_operator_policy_name = "keycloak-operator-dev" + platform_admin_policy_name = "platform-admin-dev" + workload_automation_policy_name = "workload-automation-dev" + workload_workflow_role_name = "workload-dev-workflow" + auth_db_migration_role_name = "auth-db-migration-dev" + postgres_operator_role_name = "postgres-operator-dev" + + migration_creation_statements = [ + <<-EOT + CREATE ROLE "{{name}}" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; + GRANT "${var.auth_db_role}" TO "{{name}}"; + EOT + ] + + migration_revocation_statements = [ + <<-EOT + REASSIGN OWNED BY "{{name}}" TO "${var.auth_db_role}"; + DROP OWNED BY "{{name}}"; + REVOKE "${var.auth_db_role}" FROM "{{name}}"; + DROP ROLE IF EXISTS "{{name}}"; + EOT + ] + + operator_creation_statements = [ + <<-EOT + CREATE ROLE "{{name}}" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; + GRANT "${var.auth_db_role}" TO "{{name}}"; + EOT + ] + + operator_revocation_statements = [ + <<-EOT + REASSIGN OWNED BY "{{name}}" TO "${var.auth_db_role}"; + DROP OWNED BY "{{name}}"; + REVOKE "${var.auth_db_role}" FROM "{{name}}"; + DROP ROLE IF EXISTS "{{name}}"; + EOT + ] +} + +resource "vault_policy" "auth_server" { + name = local.auth_server_policy_name + policy = file("${local.policy_dir}/auth-server-dev.hcl") +} + +resource "vault_policy" "auth_db_migration" { + name = local.auth_db_migration_policy_name + policy = file("${local.policy_dir}/auth-db-migration-dev.hcl") +} + +resource "vault_policy" "postgres" { + name = local.postgres_policy_name + policy = file("${local.policy_dir}/postgres-dev.hcl") +} + +resource "vault_policy" "keycloak" { + name = local.keycloak_policy_name + policy = file("${local.policy_dir}/keycloak-dev.hcl") +} + +resource "vault_policy" "keycloak_client_sync" { + name = local.keycloak_client_sync_policy_name + policy = file("${local.policy_dir}/keycloak-client-sync-dev.hcl") +} + +resource "vault_policy" "postgres_operator" { + name = local.postgres_operator_policy_name + policy = file("${local.policy_dir}/postgres-operator-dev.hcl") +} + +resource "vault_policy" "keycloak_operator" { + name = local.keycloak_operator_policy_name + policy = file("${local.policy_dir}/keycloak-operator-dev.hcl") +} + +resource "vault_policy" "platform_admin" { + name = local.platform_admin_policy_name + policy = file("${local.policy_dir}/platform-admin-dev.hcl") +} + +resource "vault_policy" "workload_automation" { + name = local.workload_automation_policy_name + policy = file("${local.policy_dir}/workload-automation-dev.hcl") +} + +resource "vault_kubernetes_auth_backend_role" "auth_server" { + backend = var.kubernetes_auth_path + bound_service_account_names = ["auth-server"] + bound_service_account_namespaces = ["auth-dev"] + role_name = local.auth_server_policy_name + token_policies = [vault_policy.auth_server.name] + token_ttl = var.kubernetes_role_ttl_seconds +} + +resource "vault_kubernetes_auth_backend_role" "auth_db_migration" { + backend = var.kubernetes_auth_path + bound_service_account_names = ["auth-db-migration"] + bound_service_account_namespaces = ["auth-dev"] + role_name = local.auth_db_migration_policy_name + token_policies = [vault_policy.auth_db_migration.name] + token_ttl = var.kubernetes_role_ttl_seconds +} + +resource "vault_kubernetes_auth_backend_role" "postgres" { + backend = var.kubernetes_auth_path + bound_service_account_names = ["postgres"] + bound_service_account_namespaces = ["platform"] + role_name = local.postgres_policy_name + token_policies = [vault_policy.postgres.name] + token_ttl = var.kubernetes_role_ttl_seconds +} + +resource "vault_kubernetes_auth_backend_role" "keycloak" { + backend = var.kubernetes_auth_path + bound_service_account_names = ["keycloak"] + bound_service_account_namespaces = ["platform"] + role_name = local.keycloak_policy_name + token_policies = [vault_policy.keycloak.name] + token_ttl = var.kubernetes_role_ttl_seconds +} + +resource "vault_kubernetes_auth_backend_role" "keycloak_client_sync" { + backend = var.kubernetes_auth_path + bound_service_account_names = ["keycloak-client-sync"] + bound_service_account_namespaces = ["platform"] + role_name = local.keycloak_client_sync_policy_name + token_policies = [vault_policy.keycloak_client_sync.name] + token_ttl = var.kubernetes_role_ttl_seconds +} + +resource "vault_approle_auth_backend_role" "workflow" { + backend = var.approle_auth_path + role_name = local.workload_workflow_role_name + secret_id_num_uses = 0 + secret_id_ttl = 0 + token_max_ttl = var.workflow_token_max_ttl_seconds + token_policies = [vault_policy.workload_automation.name] + token_ttl = var.workflow_token_ttl_seconds +} + +data "vault_kv_secret_v2" "provider_postgres_superuser" { + provider = vault.transit + mount = var.seed_kv_mount_path + name = "dev/workload/platform/postgres/superuser" +} + +data "vault_kv_secret_v2" "provider_postgres_auth_server" { + provider = vault.transit + mount = var.seed_kv_mount_path + name = "dev/workload/platform/postgres/auth-server" +} + +data "vault_kv_secret_v2" "provider_postgres_keycloak" { + provider = vault.transit + mount = var.seed_kv_mount_path + name = "dev/workload/platform/postgres/keycloak" +} + +data "vault_kv_secret_v2" "provider_keycloak_bootstrap_admin" { + provider = vault.transit + mount = var.seed_kv_mount_path + name = "dev/workload/platform/keycloak/bootstrap-admin" +} + +data "vault_kv_secret_v2" "provider_keycloak_client_auth_server" { + provider = vault.transit + mount = var.seed_kv_mount_path + name = "dev/workload/platform/keycloak/client-auth-server" +} + +resource "vault_kv_secret_v2" "platform_postgres_superuser" { + mount = var.kv_mount_path + name = "dev/platform/postgres/superuser" + data_json = jsonencode({ + POSTGRES_SUPERUSER_PASSWORD = data.vault_kv_secret_v2.provider_postgres_superuser.data["POSTGRES_SUPERUSER_PASSWORD"] + }) +} + +resource "vault_kv_secret_v2" "platform_postgres_auth_server" { + mount = var.kv_mount_path + name = "dev/platform/postgres/auth-server" + data_json = jsonencode({ + APP_DATASOURCE_PASSWORD = data.vault_kv_secret_v2.provider_postgres_auth_server.data["APP_DATASOURCE_PASSWORD"] + APP_DATASOURCE_USERNAME = data.vault_kv_secret_v2.provider_postgres_auth_server.data["APP_DATASOURCE_USERNAME"] + AUTH_DB_PASSWORD = data.vault_kv_secret_v2.provider_postgres_auth_server.data["AUTH_DB_PASSWORD"] + }) +} + +resource "vault_kv_secret_v2" "platform_postgres_keycloak" { + mount = var.kv_mount_path + name = "dev/platform/postgres/keycloak" + data_json = jsonencode({ + KEYCLOAK_DB_PASSWORD = data.vault_kv_secret_v2.provider_postgres_keycloak.data["KEYCLOAK_DB_PASSWORD"] + }) +} + +resource "vault_kv_secret_v2" "platform_keycloak_bootstrap_admin" { + mount = var.kv_mount_path + name = "dev/platform/keycloak/bootstrap-admin" + data_json = jsonencode({ + KC_BOOTSTRAP_ADMIN_PASSWORD = data.vault_kv_secret_v2.provider_keycloak_bootstrap_admin.data["KC_BOOTSTRAP_ADMIN_PASSWORD"] + }) +} + +resource "vault_kv_secret_v2" "platform_keycloak_client_auth_server" { + mount = var.kv_mount_path + name = "dev/platform/keycloak/client-auth-server" + data_json = jsonencode({ + APP_SECURITY_OAUTH2_KEYCLOAK_CLIENT_SECRET = data.vault_kv_secret_v2.provider_keycloak_client_auth_server.data["APP_SECURITY_OAUTH2_KEYCLOAK_CLIENT_SECRET"] + KEYCLOAK_CLIENT_SECRET = data.vault_kv_secret_v2.provider_keycloak_client_auth_server.data["KEYCLOAK_CLIENT_SECRET"] + }) +} + +resource "vault_database_secret_backend_connection" "platform_postgres" { + allowed_roles = [local.auth_db_migration_role_name, local.postgres_operator_role_name] + backend = var.database_mount_path + name = var.database_config_name + plugin_name = "postgresql-database-plugin" + verify_connection = false + + postgresql { + connection_url = "postgresql://{{username}}:{{password}}@${var.postgres_host}:${var.postgres_port}/${var.postgres_admin_database}?sslmode=disable" + password = vault_kv_secret_v2.platform_postgres_superuser.data["POSTGRES_SUPERUSER_PASSWORD"] + username = var.postgres_superuser + } +} + +resource "vault_database_secret_backend_role" "auth_db_migration" { + backend = var.database_mount_path + creation_statements = local.migration_creation_statements + db_name = vault_database_secret_backend_connection.platform_postgres.name + default_ttl = var.auth_db_migration_default_ttl_seconds + max_ttl = var.auth_db_migration_max_ttl_seconds + name = local.auth_db_migration_role_name + revocation_statements = local.migration_revocation_statements +} + +resource "vault_database_secret_backend_role" "postgres_operator" { + backend = var.database_mount_path + creation_statements = local.operator_creation_statements + db_name = vault_database_secret_backend_connection.platform_postgres.name + default_ttl = var.postgres_operator_default_ttl_seconds + max_ttl = var.postgres_operator_max_ttl_seconds + name = local.postgres_operator_role_name + revocation_statements = local.operator_revocation_statements +} diff --git a/terraform/vault/reconcile/variables.tf b/terraform/vault/reconcile/variables.tf new file mode 100644 index 0000000..fdf3528 --- /dev/null +++ b/terraform/vault/reconcile/variables.tf @@ -0,0 +1,131 @@ +variable "approle_auth_path" { + description = "Path where the workload AppRole auth backend is mounted." + type = string + default = "approle" +} + +variable "auth_db_migration_default_ttl_seconds" { + description = "Default TTL, in seconds, for auth DB migration credentials." + type = number + default = 3600 +} + +variable "auth_db_migration_max_ttl_seconds" { + description = "Maximum TTL, in seconds, for auth DB migration credentials." + type = number + default = 86400 +} + +variable "auth_db_role" { + description = "Existing PostgreSQL role granted to dynamic auth users." + type = string + default = "project_auth" +} + +variable "database_config_name" { + description = "Name of the Vault database connection configuration." + type = string + default = "platform-postgres-dev" +} + +variable "database_mount_path" { + description = "Mount path for the workload database secrets engine." + type = string + default = "database" +} + +variable "kubernetes_auth_path" { + description = "Path where the Kubernetes auth backend is mounted." + type = string + default = "kubernetes" +} + +variable "kubernetes_role_ttl_seconds" { + description = "TTL, in seconds, granted to workload Kubernetes auth logins." + type = number + default = 86400 +} + +variable "kv_mount_path" { + description = "Mount path for the workload KV-v2 engine." + type = string + default = "kv" +} + +variable "postgres_admin_database" { + description = "Administrative PostgreSQL database used by the database secret engine." + type = string + default = "postgres" +} + +variable "postgres_host" { + description = "DNS name of the platform PostgreSQL service." + type = string + default = "postgres-0.postgres.platform.svc.cluster.local" +} + +variable "postgres_operator_default_ttl_seconds" { + description = "Default TTL, in seconds, for the operator PostgreSQL role." + type = number + default = 3600 +} + +variable "postgres_operator_max_ttl_seconds" { + description = "Maximum TTL, in seconds, for the operator PostgreSQL role." + type = number + default = 28800 +} + +variable "postgres_port" { + description = "Port of the platform PostgreSQL service." + type = number + default = 5432 +} + +variable "postgres_superuser" { + description = "PostgreSQL superuser used by Vault database secrets." + type = string + default = "postgres" +} + +variable "seed_kv_mount_path" { + description = "Mount path for the provider KV-v2 seed data." + type = string + default = "kv" +} + +variable "transit_vault_addr" { + description = "Address of the vault-transit API." + type = string + default = "http://127.0.0.1:18200" +} + +variable "transit_vault_token" { + description = "Token used to read provider seed data." + type = string + sensitive = true +} + +variable "workflow_token_max_ttl_seconds" { + description = "Maximum TTL, in seconds, for the workload workflow AppRole login token." + type = number + default = 14400 +} + +variable "workflow_token_ttl_seconds" { + description = "Default TTL, in seconds, for the workload workflow AppRole login token." + type = number + default = 3600 +} + +variable "workload_vault_addr" { + description = "Address of the workload Vault API." + type = string + default = "http://127.0.0.1:8200" +} + +variable "workload_vault_token" { + description = "Workflow token used to reconcile workload Vault." + type = string + sensitive = true +}