Add platform infrastructure configuration

This commit is contained in:
donghyeon-ka
2026-08-28 17:35:41 +09:00
parent fa76531e5b
commit 16c337bcc9
302 changed files with 83259 additions and 1 deletions
+43
View File
@@ -0,0 +1,43 @@
# Local environment and editor files
.env
.env.*
!.env.example
.idea/
.vscode/
*.swp
*.swo
*~
# Credentials and private keys
*.key
*.pem
*.p12
*.pfx
*.jks
*.crt
*.kubeconfig
kubeconfig
**/secrets/*.yaml
**/secrets/*.yml
!**/secrets/.gitkeep
# Host-specific recovery hardware identity (UUID, serial, WWN)
infrastructure/security/k3s/local-recovery.env
# Generated and temporary files
__pycache__/
*.py[cod]
.pytest_cache/
*.tmp
*.bak
*.orig
*.rej
.DS_Store
# Helm and validation artifacts
charts/
*.tgz
.helm/
rendered/
dist/
.superpowers/
+242 -1
View File
@@ -1,2 +1,243 @@
# platform-core
# 플랫폼 인프라
단일 노드 k3s에서 사용하는 공통 플랫폼 인프라의 선언형 구성을 관리하는
저장소입니다. 상세 아키텍처, 운영 절차, 의사결정 기록은
[중앙 Platform 문서](/home/donghyeon/workspace/docs/platform)에서 관리합니다.
## 현재 상태
Phase 1과 Phase 2의 선언 및 수동 초기 구축 진입점이 구현되어 있습니다.
Phase 1에는 namespace, SSD Local PV, CloudNativePG, 공용 PostgreSQL, Gitea,
Traefik Ingress와 NetworkPolicy가 포함됩니다. Phase 2 선언에는 Keycloak, 공용
PostgreSQL의 Keycloak DB·Role, AIStor Operator, 단일 MinIO AIStor ObjectStore,
namespace와 Local PV가 포함됩니다.
Phase 1은 실제 클러스터에 적용했습니다. CloudNativePG·PostgreSQL·Gitea,
PV/PVC와 내부 Traefik health가 정상이며 Gitea 네 컨테이너의 restricted
securityContext도 검증했습니다. Host Nginx의 Gitea proxy 전환과 로컬·public
HTTP/HTTPS 검증도 통과했습니다.
Phase 2는 전체를 한 번에 적용하지 않았습니다. **Keycloak-only 범위**인 전용 DB와
Role, NetworkPolicy, 공식 Operator, 단일 Keycloak 인스턴스, `hyeonworks` realm,
confidential `gitea` client와 `gitea/gitea-keycloak-oidc` Secret까지 실제 적용을
완료했습니다. Traefik `web` entrypoint에는 실제 관측한 Host Nginx source
`10.42.0.1/32`만 forwarded-header trusted IP로 적용했습니다.
`id.learn.hyeonworks.com`의 Host Nginx static hold를 Keycloak proxy로 바꾸는
전환과 Gitea OIDC·브랜딩 rollout도 완료했습니다. Host Nginx 후보 SHA-256은
`5c5cd74b4992f537fd27c50cf2209573a80a9904e0b19b58c3154717be6ff4a5`, 백업은
`/etc/nginx/sites-available/learn-services.before-keycloak-20260723160519`이며,
적용한 Gitea manifest SHA-256은 `d25a757...a157`입니다. 활성 OAuth source,
외부 인증 전용 가입 정책, authorization-code redirect와 브랜딩 자동 검증을 모두
통과했습니다. 실제 realm 사용자의 브라우저 login/callback/logout와 비상 관리자
로그인은 별도 수용 시험으로 남아 있습니다.
AIStor는 Keycloak 경로와 분리해 실제 적용했습니다. `aistor``object-storage`
namespace, 900Gi Retain Local PV, 공식 Operator와 단일 ObjectStore, 기본 차단
NetworkPolicy가 동작 중입니다. ObjectStore는 `Initialized/green`, PVC는
`aistor-data-local-pv`에 Bound이며 S3와 Console은 `ClusterIP`로만 노출됩니다.
고정 다이제스트의 공식 AIStor Client로 버킷 생성, 객체 쓰기·읽기 checksum,
객체·버킷 삭제까지 인증된 스모크 테스트를 통과했습니다.
| 구성요소 | 고정 버전 | 상태 |
| --- | --- | --- |
| CloudNativePG | Operator `1.30.0`, Chart `0.29.0` | Phase 1 적용, Ready/Available `1/1` |
| PostgreSQL | `17.9-standard-trixie` | Phase 1 적용, healthy `1/1`, PVC Bound |
| Gitea | 애플리케이션 `1.27.0`, Chart `12.7.0` | OIDC·브랜딩 rollout 완료; OAuth source·정책·redirect·자산 자동 검증 통과 |
| Keycloak | Operator·애플리케이션 `26.7.0` | Ready `1/1`; realm·client·OIDC Secret·Host Nginx 공개 전환 완료 |
| Traefik | Chart `40.1.3+up40.1.0`, 이미지 `3.7.4` | NodePort `30080/30443`, `web` trust `10.42.0.1/32` 적용 |
| AIStor Operator | Chart `5.10.0` | 적용, Operator·AdminJob·Webhook Ready `1/1` |
| AIStor ObjectStore | Chart `1.0.16` | 적용, `Initialized/green`, 900Gi PVC Bound, 인증 S3 스모크 통과 |
| 렌더 도구 | Kustomize `5.8.1`, Helm `3.19.4` | 정확히 일치해야 함 |
Argo CD와 Istio는 현재 단계에서 구현하거나 배포하지 않습니다.
## 실제 Kustomize 빌드 루트
Phase 1 최초 적용은 다음 경로만 빌드 루트로 사용합니다.
| 순서 | 빌드 루트 | 렌더 방식 |
| ---: | --- | --- |
| 1 | `infrastructure/namespaces/overlays/home` | Kustomize |
| 2 | `infrastructure/storage/ssd-local-pv` | Kustomize |
| 3 | `infrastructure/controllers/cloudnative-pg` | Kustomize + Helm |
| 4 | `services/platform-postgres` | Kustomize |
| 5 | `services/gitea` | Kustomize + Helm |
`services/gitea`는 신규 설치용 baseline 빌드 루트이며 렌더 결과는
`gitea.yaml`입니다. Keycloak OIDC와 브랜딩은 최초 Phase 1 적용 대상이 아니며,
Host Nginx ID 전환 뒤 전용 빌드 루트 `services/gitea/profiles/oidc`에서
`gitea-oidc.yaml`로 렌더링하고 수명주기 스크립트로만 적용합니다.
Phase 2는 Phase 1 전체를 먼저 검증한 뒤 다음 빌드 루트를 추가로 렌더합니다.
| 순서 | 빌드 루트 | 렌더 방식 |
| ---: | --- | --- |
| 1 | `infrastructure/namespaces/phase2` | Kustomize |
| 2 | `infrastructure/storage/aistor-local-pv` | Kustomize |
| 3 | `infrastructure/controllers/keycloak-operator` | Kustomize 원격 리소스 |
| 4 | `services/platform-postgres-keycloak` | Kustomize |
| 5 | `services/keycloak` | Kustomize |
| 6 | `infrastructure/controllers/aistor-operator` | Kustomize + Helm |
| 7 | `services/minio-aistor` | Kustomize + Helm |
하위 `base``overlays/home`는 직접 적용 대상이 아닐 수 있습니다. Helm values와
환경 patch가 형제 디렉터리에 있는 구성은 Kustomize 기본 `RootOnly` 제한을
유지하기 위해 서비스 또는 컨트롤러 루트에서 렌더합니다.
## Secret 원칙
평문 Secret, 비밀번호, 라이선스, 개인 키와 소스 관리되는 `kind: Secret` YAML을
저장소에 두지 않습니다. 각 단계의 초기 구축 스크립트가 계약 전체의
존재·타입·키·교차 namespace 동일성을 검증하고, 전체가 없을 때만 명시 확인 후
생성합니다. 일부만 존재하면 중단하며 자동 회전하지 않습니다.
최초 생성은 여러 Kubernetes API 요청으로 수행되므로 중간 실패 시 일부 객체가 남을
수 있습니다. 후속 실행은 이를 삭제하거나 덮어쓰지 않고 부분 상태로 감지해
중단합니다.
Phase 1 계약:
- `platform-data/gitea-db-credentials`
- `gitea/gitea-db-credentials`
- `gitea/gitea-admin`
Phase 2 계약:
- `platform-data/keycloak-db-credentials`
- `keycloak/keycloak-db-credentials`
- `gitea/gitea-keycloak-oidc`
- `aistor/minio-license`
- `object-storage/aistor-root-configuration`
`gitea/gitea-keycloak-oidc``Opaque` 유형이며 정확히 `key`, `secret` 두 key만
갖습니다. 현재 Keycloak-only 작업으로 생성됐지만 payload는 문서, values와 렌더
결과에 기록하지 않습니다.
자세한 계약은 [Secret 문서](infrastructure/security/secrets/README.md)를 따릅니다.
Helm 차트가 렌더하는 초기화 스크립트·비민감 설정용 내부 Secret은 애플리케이션
자격 증명 계약과 구분합니다.
## k3s Secret 저장 암호화와 복구 증거
k3s Secret encryption at rest의 상태 확인과 fail-stop 활성화 절차는
[암호화 수동 운영 절차](bootstrap/manual/k3s-secret-encryption.md)를 따릅니다. datastore나
snapshot 단독 탈취를 완화하지만 같은 host의 root 침해나 full-disk 탈취를 해결하지는
않습니다. server token과 backup을 함께 얻으면 복구·복호화할 수 있으므로 recovery bundle
전체를 Secret으로 취급합니다.
2026-08-09 live 실행에서 수동 LUKS header 복구 proof, recovery close·잔류 없음 검사,
closed validator, Secret encryption `Enabled/reencrypt_finished`, hash·integrity·API·node
검사와 post bundle 기록을 모두 통과했고 최신 bundle marker도 검증했습니다. 다만 일반
lifecycle 자동화, 격리 restore drill, off-host 복제와 암호화 escrow 검증은 아직
완료되지 않았으므로 관측성 Phase 4 gate는 열리지 않았습니다.
Phase 4 Secret 생성 전에는 live `--expect-reencrypted`와 restore evidence `--check`를 매번
독립 호출합니다. backend별 수동 복구, 일회용 격리 환경, 세 mode 결과 생성·등록·검사와
파기 기준은 [k3s Secret 복구 drill](bootstrap/manual/k3s-secret-encryption-restore-drill.md),
중앙 보호 경계는
[보안과 Secret 관리](/home/donghyeon/workspace/docs/platform/architecture/security-and-secrets.md)와
[백업과 복구 설계](/home/donghyeon/workspace/docs/platform/architecture/backup-and-recovery.md)를
따릅니다. 완료된 live hardening의 비민감 정정은
[2026-08-08 복구 명령 원장](/home/donghyeon/workspace/docs/platform/runbooks/2026-08-08-k3s-local-recovery-command-log.md)의
2026-08-09 addendum과
[2026-08-09 로컬 정책 기록](/home/donghyeon/workspace/docs/platform/runbooks/2026-08-09-k3s-secret-encryption-local-policy.md)에
남긴다. 별도의 Task 6 실행 runbook은 만들지 않았으며, 이를 암시하지 않는다.
## 검증과 수동 적용
아무 리소스도 적용하지 않는 렌더 검증 명령은 다음과 같습니다.
```sh
cd /home/donghyeon/workspace/platform
bash scripts/validate/render-phase1.sh
bash scripts/validate/render-phase2.sh
```
Phase 1 검증은 baseline과 OIDC profile을 포함한 manifest 여섯 개를 생성합니다.
최초 구축 스크립트는 그중 `gitea.yaml` baseline만, OIDC 수명주기 스크립트는
`gitea-oidc.yaml` 하나만 각각 적용합니다.
Helm이 시스템 `PATH`에 없다면 검증된 `3.19.4` 실행 파일의 절대 경로를
`PLATFORM_HELM_BIN`으로 지정합니다. Phase 2 검증은 Phase 1을 먼저 검증한 뒤
공식 AIStor Chart 패키지 다이제스트, Keycloak 단일 인스턴스·HTTP hostname/Ingress,
AIStor 1x1·900Gi·PVC 보호·ClusterIP, RootOnly 유지와 Secret 소스·렌더링 부재를
확인합니다. 실제 클러스터의 ObjectStore CRD는 로컬 렌더에 필요하지 않습니다.
Phase 1 상세 절차는
[중앙 Phase 1 런북](/home/donghyeon/workspace/docs/platform/runbooks/2026-07-22-phase1-gitea-bootstrap.md)과
[짧은 실행 진입점](bootstrap/manual/phase1-gitea.md)을 따릅니다.
Keycloak에서 Gitea OIDC까지의 실제 실행 순서와 현재 완료·대기 경계는
[중앙 Keycloak-Gitea OIDC 전환 런북](/home/donghyeon/workspace/docs/platform/runbooks/2026-07-23-keycloak-gitea-oidc-cutover.md)에
기록합니다. 실행 진입점은 다음 네 개입니다.
```sh
cd /home/donghyeon/workspace/platform
# 완료: Keycloak DB·Operator·인스턴스
bash scripts/bootstrap/apply-keycloak.sh --execute
# 완료: realm·confidential Gitea client·OIDC Secret
bash scripts/bootstrap/configure-keycloak-gitea-oidc.sh --execute
# 완료: id.learn.hyeonworks.com Host Nginx 전환
sudo bash scripts/bootstrap/apply-host-nginx-keycloak.sh --execute
# 완료: 고정 SHA 렌더 결과의 gitea-oidc.yaml만 적용하고 OIDC·브랜딩 검증
PLATFORM_HELM_BIN=/home/donghyeon/.local/bin/helm \
bash scripts/bootstrap/apply-gitea-oidc.sh --execute
```
Traefik 신뢰 경계의 적용 값과 첫 실패·복구 내용은
[중앙 Traefik trust 런북](/home/donghyeon/workspace/docs/platform/runbooks/2026-07-23-traefik-forwarded-header-trust-boundary.md)에
기록합니다. AIStor의 실제 배포 명령·결과·실패와 복구 과정은
[중앙 AIStor 배포 기록](/home/donghyeon/workspace/docs/platform/runbooks/2026-07-23-aistor-deployment.md)과
[Phase 2 수동 진입점](bootstrap/manual/phase2-keycloak-aistor.md)에 기록합니다.
AIStor 재검증 진입점은 다음과 같습니다.
```sh
cd /home/donghyeon/workspace/platform
PLATFORM_HELM_BIN=/home/donghyeon/.local/bin/helm \
bash scripts/bootstrap/apply-aistor.sh \
--license-file /home/donghyeon/.secrets/aistor/minio.license \
--root-config-file /home/donghyeon/.secrets/aistor/root.env \
--execute
bash scripts/validate/aistor-s3-smoke.sh --execute
```
첫 명령은 기존 Secret과 Retain PV를 회전·삭제하지 않고 계약과 readiness를
재검증한다. 두 번째 명령은 임시 버킷과 객체를 만들었다가 삭제하며, 자격 증명
payload는 출력하지 않는다.
## 호스트 Nginx
[Gitea Nginx 설정](infrastructure/networking/host-nginx/learn-services.conf)을
기반으로 한 Gitea proxy는 현재 Host Nginx에 적용되어 있고 로컬·public HTTPS
health를 통과했습니다. `id.learn.hyeonworks.com`
[Keycloak 포함 설정](infrastructure/networking/host-nginx/learn-services-keycloak.conf)으로
전환되어 공개 discovery issuer 검사를 통과했습니다.
ID 전환은 `scripts/bootstrap/apply-host-nginx-keycloak.sh --execute`가 공개
discovery 사전 검사, 기존 설정 백업, `nginx -t`, 다시 불러오기와 HTTPS 사후
검사를 수행하도록 합니다. 실패 시 후보를 방치하지 않고 백업 설정을 복원합니다.
실제 전환 성공 뒤 `scripts/bootstrap/apply-gitea-oidc.sh --execute`로 Gitea
OIDC source와 브랜딩도 live Deployment에 적용했습니다. 세부 경계는
[Nginx 수동 전환 절차](infrastructure/networking/host-nginx/README.md)를 따릅니다.
## 저장소 규칙
- Kubernetes 리소스는 Kustomize `base`와 환경별 `overlays`로 구분합니다.
- 공식 Helm 차트 원본은 수정하지 않고 고정 버전, SHA-256, 저장소 관리
values·patch로 설정합니다.
- 클러스터 진입점과 향후 GitOps 애플리케이션 선언은 `clusters/home`에서
관리합니다.
- 공통 컨트롤러와 기반 리소스는 `infrastructure`, 플랫폼 서비스는
`services`에서 관리합니다.
- 반복 정책과 향후 서비스 메시 확장 지점은 `components`에서 관리합니다.
- 자동화는 `scripts`, 사람이 검토하는 최초 적용 절차는 `bootstrap/manual`,
상세 운영 기록은 중앙 문서에 둡니다.
- 생성된 Helm 캐시와 렌더 산출물 대신 재현 가능한 선언만 버전 관리합니다.
+25
View File
@@ -0,0 +1,25 @@
# Argo CD 초기 구축 경계
Argo CD는 1차 Gitea 수동 구축과 플랫폼 저장소 푸시가 끝난 뒤 설치한다. 이
디렉터리는 그 인계 지점을 보존하며, 현재 단계에는 설치 매니페스트가 없다.
후속 단계의 기준은 다음과 같다.
- Argo CD `v3.4.2`를 검토·고정한다.
- 해당 버전에 포함된 Kustomize `v5.8.1`, Helm `v3.19.4`와 로컬 검증 버전을
일치시킨다.
- `argocd-cm``kustomize.buildOptions`에는 `--enable-helm`만 설정한다.
- `--load-restrictor LoadRestrictionsNone`는 사용하지 않는다.
- 공식 Chart 원본을 수정하거나 저장소에 복제하지 않는다.
- Application의 소스 경로는 각 실제 빌드 루트를 가리킨다.
- `infrastructure/controllers/cloudnative-pg`
- `services/platform-postgres`
- `services/gitea`
- Namespace, CRD/Operator, PostgreSQL, Gitea의 의존 순서를 별도 Application과
동기화 순서(sync wave)로 표현한다.
- 수동 생성한 Secret은 Secret 관리 ADR이 확정되기 전까지 Argo CD 관리 대상으로
가져오지 않는다.
기존 수동 리소스를 인계하기 전에는 적용할 차이가 삭제나 재생성을 유발하지 않는지 확인하고,
특히 `Retain` Local PV/PVC와 PostgreSQL Cluster를 먼저 보호한다.
@@ -0,0 +1,127 @@
# k3s Secret 암호화 복구 drill
이 문서는 승인된 k3s Secret hardening의 post recovery bundle을 실제 복구할 수 있는지
검증하고, 파기 확인 뒤 운영 host에 비민감 evidence를 등록하는 수동 진입점이다. 운영
server에서 restore를 실행하는 runbook이 아니며 서비스, datastore, backup 또는 Secret을
자동으로 변경하지 않는다.
## 승인과 책임 경계
- 운영 장애 복구와 drill은 서로 다른 변경 승인으로 다룬다.
- SQLite와 embedded etcd restore 명령은 이 저장소의 자동 실행 스크립트로 제공하지 않는다.
- drill은 운영 host와 network, datastore, hostname이 격리된 일회용 VM/host에서 post bundle
복사본으로만 한다.
- recovery bundle에는 복구·복호화에 필요한 자료가 함께 있으므로 bundle 전체를 Secret으로
취급한다. 경로, token/config 내용, payload, hash와 escrow 위치를 terminal 결과나 원장에
기록하지 않는다.
- result만 반출하고 일회용 환경과 bundle 복사본을 파기한 뒤 운영 host에서 evidence를
등록한다.
## backend별 수동 복구 기준
SQLite 장애 복구 순서는 k3s 중지, 현재 DB의 timestamp quarantine 이동, 같은 server
token·config와 검증된 backup DB 복원, k3s 시작이다. 현재 DB를 즉시 삭제하거나 backup으로
덮어쓰지 않는다. 모든 단계는 별도 승인된 장애 runbook에서 사람이 대상과 rollback 지점을
확인한다.
embedded etcd는 현재 K3s 공식
[`--cluster-reset-restore-path` snapshot 복구 절차](https://docs.k3s.io/cli/etcd-snapshot#restoring-snapshots)를
사용한다. 단일 server와 다중 server 절차, token/config 일치 조건, reset 후 정상 시작 조건을
실행 시점의 공식 문서와 다시 대조한다. 보조 tutorial을 복구 권위로 사용하지 않는다.
## 일회용 환경의 격리 조건
복구 전에 다음 조건을 모두 만족시킨다.
- 운영 server와 다른 hostname, network와 datastore를 사용한다.
- hypervisor private switch 또는 network namespace에 default route와 upstream DNS가 없다.
- 원본 운영 API/datastore로 향하는 route가 없다.
- host firewall egress가 default-deny다.
- LAN, Internet, Slack, AIStor와 운영 API 연결 시험이 모두 실패한다.
- 복구된 workload/controller가 격리망 밖으로 통신할 수 없고 외부 host port를 열 수 없다.
- 운영과 같은 정확한 k3s version, server token과 config를 post bundle 복사본에서 사용한다.
복구 뒤에는 다음을 확인한다.
- k3s API ready
- node Ready
- encryption status가 `Enabled/reencrypt_finished`
- server hash와 local integrity가 모두 일치
- `kubectl get secrets --all-namespaces -o json` 조회가 오류 없이 완료
- bundle metadata의 복구 전 전체 Secret object count와 복구 후 count가 일치
- 위 격리 연결 시험을 다시 실행해 모두 실패
격리 조건과 live 검사가 모두 끝나기 전에는 result의 `isolation=pass`를 만들지 않는다.
## 세 mode 사용
모든 mode는 먼저 일반 사용자의 현재 kube context를 확정한다. 전체 script를 `sudo`
실행하지 않는다. root 권한은 운영 host의 권위 파일 stat/read/install에만 좁게 사용되므로
필요하면 같은 terminal에서 사전에 sudo credential을 갱신한다.
격리 복구 host에서 post metadata와 결과 출력 위치를 직접 지정한다. 출력은 기존 파일을
덮어쓰지 않는다.
```sh
bash scripts/validate/k3s-secret-encryption-restore-evidence.sh \
--emit-result --bundle-metadata BUNDLE_METADATA_FILE --output RESULT_FILE
```
script는 live API ready, node Ready, `Enabled/reencrypt_finished`, server hash, local integrity,
version/backend와 Secret object count를 직접 검사한다. 수동 격리 시험을 마친 현재 context를
추가 확인한 뒤에만 result를 만든다. result에는 credential, 경로, 민감 filename이나 hash가
없고 `destroyed`도 없다.
결과 파일을 운영 host로 반입한 뒤 일회용 환경과 bundle 복사본을 먼저 파기한다. 그 다음
운영 host에서 등록한다.
```sh
bash scripts/validate/k3s-secret-encryption-restore-evidence.sh \
--record --bundle-metadata BUNDLE_METADATA_FILE --result-file RESULT_FILE
```
`--record`는 post phase, metadata/result의 bundle-id·version·backend, 24시간 age와 운영
host 권위 post metadata의 일곱 field 전체를 확인한다. evidence 대상이 이미 있으면 중단한다.
prompt에는 정확히 `DESTROYED default`를 입력한다. 파기하지 않았거나 확인할 수 없으면
등록하지 않는다.
Phase 4의 읽기 전용 gate는 다음과 같다.
```sh
bash scripts/validate/k3s-secret-encryption-restore-evidence.sh --check
```
`--check`는 exact evidence field, 현재 권위 bundle-id/version/backend, live
`reencrypt_finished`와 local integrity, 30일 age, `destroyed=confirmed`를 모두 요구한다.
2026-08-09 live 전환은 `Enabled/reencrypt_finished`, hash·integrity·API·node 검사,
post bundle 기록과 최신 marker 검증까지 통과했다. 그러나 격리 restore·파기 evidence가
아직 없고 lifecycle 자동화, off-host 복제와 암호화 escrow 검증도 미완료다. 따라서 실제
운영 `--check`는 아직 성공으로 기록하지 않으며, 이 gate들이 완료되기 전에는 관측성
Phase 4를 열지 않는다.
## 입력 파일 보안
외부 metadata/result는 현재 사용자 소유 regular non-symlink, mode `0600`, non-empty여야
한다. parser는 exact allowlist `KEY=VALUE` 한 줄만 받아들이며 duplicate/unknown/empty key,
control character, malformed line, trailing data, `$(`와 backtick을 거부한다. 파일을 shell로
source하거나 `eval`하지 않는다. 입력 실패는 어떤 privileged install도 수행하기 전에
종료한다.
result와 evidence는 같은 directory의 mode `0600` temporary file을 완성하고 exact field를
다시 검사한 뒤 기존 target을 덮어쓰지 않는 atomic install로 만든다.
## 실행 원장
완료된 live hardening의 비민감 정정은
[2026-08-08 복구 명령 원장](/home/donghyeon/workspace/docs/platform/runbooks/2026-08-08-k3s-local-recovery-command-log.md)의
2026-08-09 addendum과
[2026-08-09 로컬 정책 기록](/home/donghyeon/workspace/docs/platform/runbooks/2026-08-09-k3s-secret-encryption-local-policy.md)에
있다. 아래 표는 아직 수행하지 않은 격리 restore drill의 실행 원장 형식이며, 완료된
live hardening에 별도 Task 6 실행 runbook은 만들지 않았고, 이를 암시하지 않는다.
| 시각 | 사전 상태 | backend | backup 검증 | 명령 | 종료 코드 | 후속 상태 |
|---|---|---|---|---|---:|---|
backup 위치, token/hash payload, 민감 파일 hash, encryption config 내용과 escrow 위치는
`검증 완료` 또는 `미완료`로만 쓴다. bundle-id, restore drill 시각과 pass/fail은 비밀값이
아니므로 기록할 수 있다.
+118
View File
@@ -0,0 +1,118 @@
# k3s Secret 암호화 수동 운영 절차
> **현재 경계(2026-08-09):** bootstrap의
> `--recovery-policy local-separate-disk-luks`, 물리 디스크 lineage validator,
> `LOCAL_RISK_ACCEPTED`, pre/post 동적 용량 gate는 구현·fixture 검증을 마쳤다.
> 수동 LUKS header 복구 proof, recovery close·잔류 없음 검사와 closed validator를
> 통과한 뒤 live 전환도 완료했다. 현재 Secret encryption은
> `Enabled/reencrypt_finished`이며 hash·integrity·API·node 검사와 post bundle 기록,
> 최신 bundle marker 검증이 모두 통과했다. lifecycle 자동화, 격리 restore drill,
> off-host 복제와 암호화 escrow 검증은 별도 미완료 gate이므로 관측성 Phase 4를 열지 않는다.
이 문서는 단일 k3s server에서 Kubernetes Secret 저장 암호화를 fail-stop 방식으로
전환하는 수동 절차다. 아래 상태 순서의 **live 전 역사적 기준선**은
`v1.36.2+k3s1`, 단일 Ready 노드 `donghyeon-system-product-name`, SQLite, Secret
encryption Disabled, API `readyz` pass였다. 현재 live 상태는 위 banner의
`Enabled/reencrypt_finished`다. 모든 단계는 운영자가 명시적으로 실행하고 확인한다.
자동화는 상태가 불명확하거나 검증이 실패하면 다음 단계로 진행하지 않는다.
## 상태 순서
다음 순서와 각 화살표 사이의 검증을 바꾸지 않는다.
```text
disabled_no_config
-> k3s secrets-encrypt enable
-> drop-in install + restart
-> transition_start + hashes_match
-> k3s secrets-encrypt rotate-keys
-> bounded wait for reencrypt_finished
-> final restart
-> Enabled/reencrypt_finished + server_hashes_match + local_integrity_match
```
각 상태 전환 전후에는 Task 2 validator를 해당 기대 상태로 실행해 version, 단일
Ready server, datastore, API `readyz`, status와 hash/integrity를 확인한다. validator
출력이나 운영 로그에 server token, 비밀번호, encryption config 본문을 기록하지
않는다.
## 사전 판정과 소유 위치
이미 `enabled_stable`로 분류되면 Task 1 effective-source resolver를 사용하여
`ExecStart`, `Environment`/`EnvironmentFile`, systemd drop-in, default 또는
alternate config 및 그 config drop-in을 순서대로 판정한다. 여기서
`secrets-encryption`과 provider의 **유효 소유 위치만** 확인한다.
계획한 `40-secrets-encryption.yaml`이 아닌 기존 위치가 owner이면 그 위치를 그대로
보존하고 config rewrite를 하지 않는다. 서로 충돌하는 두 owner, provider 판정 불가,
또는 기존 provider가 `aescbc`가 아닌 경우에는 자동 변경하지 않는다. 이 경우에는
별도 ADR을 먼저 승인해야 한다.
`enabled_stable`의 stage가 `reencrypt_finished`이면 rotation을 건너뛴다. stage가
`start`이면 Phase 4 전에 `--rotate-existing`을 사용한 명시 승인 재암호화만 수행한다.
## Bootstrap 진입 명령
인자 없는 명령은 상태만 읽고 변경하지 않는다.
```sh
cd /home/donghyeon/workspace/platform
bash scripts/bootstrap/apply-k3s-secret-encryption.sh
```
실제 전환 명령은 recovery volume이 열린 상태에서 backup root를 직접 지정하고 로컬
정책을 명시한다. 다음 명령은 maintenance 승인 전에는 실행하지 않는다.
```sh
cd /home/donghyeon/workspace/platform
bash scripts/bootstrap/apply-k3s-secret-encryption.sh \
--execute \
--backup-root /srv/recovery/k3s \
--recovery-policy local-separate-disk-luks
```
실행 확인 순서는 `APPLY <context>` → 자동 root·lineage·용량 검증 →
`RECOVERY <context>``ENCRYPTED <context>`
`LOCAL_RISK_ACCEPTED <context>`다. 어느 검사나 확인이 실패해도 다음 mutation으로
진행하지 않는다. 비밀번호나 복구 키를 이 명령의 인자·환경변수로 전달하지 않는다.
## 실행 단계
1. `disabled_no_config`을 validator로 확인한 뒤에만 `k3s secrets-encrypt enable`
실행한다.
2. repository의 host artifact를 root 소유, mode `0644`로 설치한다.
```sh
install -o root -g root -m 0644 \
infrastructure/security/k3s/40-secrets-encryption.yaml \
/etc/rancher/k3s/config.yaml.d/40-secrets-encryption.yaml
```
3. k3s를 재시작하고, `transition_start + hashes_match`가 validator로 확인될 때까지
중단한다.
4. 확인 뒤에만 `k3s secrets-encrypt rotate-keys`를 한 번 실행한다.
5. 제한된 시간 동안 `reencrypt_finished`를 기다린다. 시간 초과, API 실패, hash
mismatch 또는 local integrity mismatch이면 중단하고 조사한다.
6. 완료 상태를 확인한 후 final restart를 하고,
`Enabled/reencrypt_finished + server_hashes_match + local_integrity_match`를
다시 확인한다.
> **금지 및 중단 조건**
>
> - `transition_start` 확인 전에는 `rotate-keys`를 실행하지 않는다.
> - 중간 stage에서는 다른 rotation 명령을 실행하지 않는다.
> - drop-in을 자동 삭제하지 않는다.
> - live datastore 자동 restore 금지: 복구 판단과 수행은 별도 승인 절차다.
> - server token 또는 encryption key material을 명령 인자, 로그, ticket, Git에
> 남기지 않는다.
post bundle의 격리 복구, 결과 반출·파기와 evidence 등록은
[k3s Secret 암호화 복구 drill](k3s-secret-encryption-restore-drill.md)을 따른다.
## 재시도와 복구 경계
상태가 `disabled_no_config`, `transition_start`, `enabled_stable` 중 하나로 명확히
판정되지 않으면 재시도나 설정 변경을 하지 않는다. 특히 hash mismatch, provider
불명확, owner 충돌, API `readyz` 실패는 자동 보정 대상이 아니다. 라이브 datastore를
되돌리거나 Secret을 변경하는 동작도 이 절차의 권한 밖이며, 별도 ADR과 명시 승인을
필요로 한다.
+50
View File
@@ -0,0 +1,50 @@
# Phase 1 Gitea 수동 부트스트랩 진입점
상세 절차와 검증·롤백 기준의 단일 원본은
[중앙 Phase 1 Gitea bootstrap runbook](/home/donghyeon/workspace/docs/platform/runbooks/2026-07-22-phase1-gitea-bootstrap.md)입니다.
이 파일은 저장소에서 실행 명령을 찾기 위한 짧은 진입점만 제공합니다.
2026-07-23 기준 Phase 1 클러스터 적용과 내부 health, Gitea restricted
securityContext 검증을 완료했습니다. 이 진입점은 **신규 클러스터의 Gitea
baseline 최초 구축 전용**입니다. baseline에는 Keycloak OIDC, `id` host alias,
Keycloak 전용 egress 및 선언형 브랜딩을 포함하지 않습니다.
기존 `Deployment/gitea` 또는 `Secret/gitea-keycloak-oidc`가 있는 클러스터에는 이
스크립트를 재실행하지 않습니다. 전용 안전장치도 둘 중 하나를 발견하면 적용을
거부합니다. Keycloak OIDC와 브랜딩을 포함한 현재 목표 상태는 public discovery
전환을 마친 뒤 `scripts/bootstrap/apply-gitea-oidc.sh --execute`로만 적용하며,
2026-07-23 실제 클러스터에는 이 전용 경로로 적용을 완료했습니다. 현재
OIDC-enabled 클러스터에서 baseline 스크립트를 복구 수단으로 재실행하지 않습니다.
## 실행 순서
저장소 루트 `/home/donghyeon/workspace/platform`에서 중앙 runbook을 확인한
뒤 다음 순서를 지킵니다.
1. 전체 렌더·정적 검증
```sh
bash scripts/validate/render-phase1.sh
```
2. 신규 클러스터 Phase 1 baseline 최초 적용
```sh
bash scripts/bootstrap/apply-phase1-gitea.sh --execute
```
`apply-phase1-gitea.sh`는 안전장치로 검증을 다시 실행합니다. 검증기가 Chart
SHA-256 확인을 마친 여섯 manifest를 제한된 `/tmp` handoff 경로로 받아
`gitea.yaml` baseline만 적용하며, 함께 검증한 OIDC 목표 상태
`gitea-oidc.yaml`은 적용하지 않습니다. 검증 뒤 Chart를 다시 내려받거나
Kustomize를 다시 렌더링하지 않으며 취소·실패·신호 종료 때 임시 파일을
정리합니다.
내부 5단계에서는 `create-phase1-secrets.sh --execute`를 호출합니다. helper는 세
Secret이 모두 없을 때에만 최초 생성하고, 모두 있으면 값이 같은 완전한 계약인지
검증한 뒤 그대로 재사용합니다. 일부만 존재하면 중단합니다. 자격 증명 회전은
별도 runbook이 마련될 때까지 지원하지 않으므로 이 helper를 갱신 용도로 단독
실행하지 않습니다.
OIDC 적용·자격 증명 회전·클러스터 검증·Nginx 전환·롤백 방법은 이 파일에
복제하지 않고 중앙 runbook을 따릅니다.
+139
View File
@@ -0,0 +1,139 @@
# Phase 2 Keycloak·MinIO AIStor 수동 배포 진입점
상세 설계는 중앙 문서 `/home/donghyeon/workspace/docs/platform`에서 관리한다.
이 파일은 선언의 의존 순서와 검증 진입점만 기록한다.
## 상태
2026-07-23 기준 Keycloak-only 경로는 DB Secret 두 사본, DatabaseRole·Database,
additive NetworkPolicy, Operator·Server `26.7.0`, Ingress, `hyeonworks` realm,
confidential `gitea` client와 OIDC Secret까지 실제 적용했다. Traefik 최소 trust도
완료했다. Host Nginx의 `id.learn.hyeonworks.com` proxy와 Gitea OIDC·브랜딩 live
적용도 완료했고 OAuth source·정책·redirect·브랜딩 자동 검증을 통과했다. 실제
realm 사용자의 브라우저 login/callback/logout, 비상 관리자 로그인과 재시작
지속성 검증은 남아 있다. MinIO AIStor는 NetworkPolicy, namespace, AIStor 전용
두 Secret, 900Gi Retain Local PV, 공식 Operator와 단일 ObjectStore까지 실제
적용했다. `Initialized/green`, PVC Bound, ClusterIP-only와 인증된 S3
put/get/delete checksum 검증을 통과했다.
## 적용 전 렌더 검증
Helm `3.19.4``PATH`에 없다면 검증된 실행 파일의 절대 경로를 지정한다.
```sh
cd /home/donghyeon/workspace/platform
PLATFORM_HELM_BIN=/absolute/path/to/helm \
bash scripts/validate/render-phase2.sh
```
이 명령은 Phase 1 검증을 먼저 수행한 뒤 Phase 2 전체를 로컬 렌더한다. 실제
클러스터의 ObjectStore CRD 설치 여부와 무관하며 어떤 리소스도 적용하지 않는다.
## Keycloak-only 적용 진입점
AIStor license와 ObjectStore 준비를 기다리지 않고 Keycloak만 먼저 배포할 때는
다음 전용 스크립트를 사용한다. Helm과 AIStor Secret은 요구하지 않는다.
```sh
cd /home/donghyeon/workspace/platform
bash scripts/bootstrap/apply-keycloak.sh --execute
```
스크립트는 Keycloak namespace, Keycloak DB Secret 두 개, 공용 PostgreSQL의
`DatabaseRole`·`Database`·`NetworkPolicy`, Keycloak Operator, Keycloak CR과
Ingress를 순서대로 렌더·검증·적용하고 각 readiness를 기다린다. 먼저 현재
Kubernetes context와 API server를 출력한 뒤 `APPLY`를 정확히 입력해야 한다.
두 DB Secret이 모두 없으면 비밀번호를 화면에 표시하지 않고 두 번 입력받은 뒤
`APPLY KEYCLOAK SECRETS`를 한 번 더 확인한다. 두 Secret이 이미 있으면 값을
바꾸지 않고 계약만 검증해 재사용한다.
자동 적용 전에 두 Secret을 안전하게 미리 생성하려면 다음 명시적 비대화형 모드를
사용할 수 있다.
```sh
kubectl apply --filename=infrastructure/namespaces/phase2/keycloak.yaml
bash scripts/bootstrap/create-keycloak-secrets.sh --generate --execute
bash scripts/bootstrap/apply-keycloak.sh --execute
```
`--generate --execute`는 OpenSSL CSPRNG로 32바이트 난수를 생성해 mode `0600`
임시 파일에만 기록한다. 생성값은 argv나 화면에 출력하지 않으며 두 플래그 자체를
자동 생성 승인으로 취급한다. 두 Secret이 이미 있으면 새 값을 만들거나 회전하지
않고 기존 계약을 검증해 그대로 재사용한다.
독립 실행하는 Secret 생성기는 `platform-data``keycloak` namespace가 먼저
존재해야 하므로 위 첫 명령을 생략하지 않는다.
위 Keycloak-only 명령은 실제 완료됐으며 재실행 시 기존 Secret을 회전하지 않고
계약과 readiness를 다시 검증한다. 실제 결과는 중앙
`runbooks/2026-07-23-keycloak-gitea-oidc-cutover.md`에 기록한다.
중간 단계가 실패해도 namespace, Secret, DB 리소스, Operator 또는 Keycloak
리소스를 자동 삭제하지 않는다. DB reclaim policy는 `Retain`으로 유지하며 원인을
해결한 뒤 같은 명령을 다시 실행하는 것이 복구 경로다. Host Nginx의 Keycloak
정적 hold 제거와 Gitea OIDC 인증 소스 등록은 이 스크립트 범위가 아니다.
## AIStor 실제 적용 및 재검증
실제 적용에는 다음 진입점을 사용했다.
```sh
cd /home/donghyeon/workspace/platform
PLATFORM_HELM_BIN=/home/donghyeon/.local/bin/helm \
bash scripts/bootstrap/apply-aistor.sh \
--license-file /home/donghyeon/.secrets/aistor/minio.license \
--root-config-file /home/donghyeon/.secrets/aistor/root.env \
--execute
bash scripts/validate/aistor-s3-smoke.sh --execute
```
첫 스크립트가 수행하고 검증한 순서는 다음과 같다.
1. Phase 1과 Keycloak-only 리소스가 정상이며 Host Nginx·Gitea OIDC 작업과 AIStor
작업이 서로 독립임을 확인한다.
2. AIStor Operator·ObjectStore용 저장소 소유 NetworkPolicy를 먼저 렌더하고
k3s CNI의 승인 흐름과 차단 흐름을 검증한다. webhook,
Kubernetes API, license 확인, DNS, ObjectStore 내부 통신과 승인된 S3 소비자를
빠뜨리지 않는다.
- `infrastructure/controllers/aistor-operator`
- `services/minio-aistor`
3. 정확히 `aistor`, `object-storage` namespace만 검토·적용한다.
4. `/srv/k3s/aistor`의 XFS mount·권한과 예상 밖 StorageClass 소비자가 없음을
확인한 뒤 `infrastructure/storage/aistor-local-pv`를 적용한다.
5. AIStor 두 Secret만 two-or-none으로 관리하는 전용 helper를 실행한다.
기존 four-or-none `create-phase2-secrets.sh`는 현재 Keycloak-only 상태에서
실행하지 않는다.
6. NetworkPolicy gate 통과 후 `infrastructure/controllers/aistor-operator`와 해당
정책을 적용하고 CRD, 두 Operator, admission webhook이 준비될 때까지 기다린다.
7. `services/minio-aistor`와 해당 정책을 적용하고 하나의 서버·하나의 900Gi 볼륨,
`ClusterIP` S3/Console, 예상 PVC·PV binding을 확인한다. 실제 workload에서도
승인·차단 흐름을 다시 시험한다.
8. S3 put/get/delete와 checksum, 관리 포트 비노출을 확인한다.
1~8의 자동 검증은 완료했다. ObjectStore Pod 재생성 뒤 기존 영구 객체가 유지되는지
보는 별도 지속성 시험은 운영 데이터와 구분된 시험 버킷·승인 창을 정한 뒤 수행한다.
Argo CD 인계와 Host Nginx 변경은 이 Phase 2 수동 적용 범위에 포함하지 않는다.
Istio와 sidecar injection도 적용하지 않는다.
## Secret 안전 조건
- Keycloak-only 경로의 두 DB Secret 중 일부만 존재하면
`create-keycloak-secrets.sh`가 생성·회전을 거부한다. 두 Secret은
`platform-data``keycloak` namespace에 동일한 `keycloak` 사용자와 동일한
비밀번호로 존재해야 한다.
- Keycloak-only 스크립트는 AIStor license와 ObjectStore root Secret을 읽거나
요구하거나 수정하지 않는다.
- Keycloak DB Secret과 AIStor 두 Secret은 모두 존재하지만 서로 독립 계약이다.
- 기존 four-or-none helper는 이 상태에서 중단하는 것이 정상이며 우회하거나
Keycloak Secret을 삭제하지 않는다.
- AIStor 전용 helper는 AIStor 두 Secret이 모두 없거나 모두 존재할 때만
진행하고 one-of-two 상태에서는 자동 삭제·덮어쓰기 없이 중단한다.
- 두 AIStor Secret이 모두 존재하면 타입·정확한 키, license file 동일성, root
configuration 형식만 검증하고 값을 바꾸지 않는다.
- license payload, root 사용자·비밀번호, DB 비밀번호는 Git, argv, 표준 출력에
기록하지 않는다.
- bootstrap helper는 credential 또는 license 회전을 수행하지 않는다.
+189
View File
@@ -0,0 +1,189 @@
# Phase 3 비공개 관리 UI 적용 절차
상세 설계와 실행 원장은 /home/donghyeon/workspace/docs/platform에서 관리합니다.
이 문서는 실제 적용 순서와 입력 문자열만 요약합니다.
## 현재 완료 경계
2026-07-24 기준 선언형 YAML, 고정 차트 검증, dry-run 스크립트는 구현·검증했습니다.
pgAdmin bootstrap password는 다음 경로에 `0600`으로 생성했습니다.
- /home/donghyeon/.secrets/pgadmin/bootstrap-password
private DNS, Gitea CoreDNS 전환, 공유기 DNS와 Tailscale split DNS 등록,
Cloudflare DNS-01 관리 인증서, Keycloak 관리 OIDC, AIStor Console OIDC
profile, pgAdmin, Host Nginx cutover와 자동 수용 시험을 실제 적용하고
검증했습니다.
현재 AIStor는 `green`, pgAdmin Deployment는 `1/1`, PVC는 2Gi SSD Local PV에
`Bound`입니다. 인증서 갱신 dry-run과 S3 bucket/object CRUD도 통과했습니다.
남은 단계는 브라우저 OIDC 수동 시험뿐입니다.
- /home/donghyeon/.secrets/certbot/cloudflare.ini
공유기 DHCP DNS와 Tailscale 관리 화면도 서버에서 자동 변경하지 않습니다.
## 1. 정적 렌더
cd /home/donghyeon/workspace/platform
PLATFORM_HELM_BIN=/home/donghyeon/.local/bin/helm \
bash scripts/validate/render-admin-services.sh
pgAdmin Chart archive와 OCI digest, AIStor ObjectStore Chart archive를 검증한 뒤
Secret 리소스가 없는 manifest만 렌더합니다.
## 2. Keycloak 외부망 시간 초과 대조
실패 단말에서 시각을 기록하고 다음을 실행합니다.
date --iso-8601=seconds
dig @1.1.1.1 A id.learn.hyeonworks.com
dig @1.1.1.1 AAAA id.learn.hyeonworks.com
curl -4 -vkI --connect-timeout 10 https://id.learn.hyeonworks.com/
curl -4 -vk --connect-timeout 10 \
https://id.learn.hyeonworks.com/realms/hyeonworks/.well-known/openid-configuration
같은 시간대 Host Nginx access log에 요청이 없으면 서버 설정이 아니라 실패
단말의 VPN·보안 필터·통신사 경로 문제로 판정합니다.
## 3. private DNS
상태: 2026-07-24 실제 적용 완료. Gitea live `hostAliases` 제거도 완료.
먼저 dry-run을 실행한 뒤 실제 적용합니다.
bash scripts/bootstrap/apply-private-dns.sh
bash scripts/bootstrap/apply-private-dns.sh --execute
확인 문자열은 APPLY default입니다. 이후 공유기 DHCP DNS를 192.168.0.107,
Tailscale의 learn.hyeonworks.com 제한 nameserver를 100.92.240.34로 등록합니다.
공개 DNS에는 두 admin 도메인을 만들지 않습니다.
서버의 공유기는 TP-Link 계열로 식별됐습니다. 정확한 TP-Link·Tailscale 메뉴와
검증 방법은 infrastructure/networking/private-dns/host/README.md를 따릅니다.
CoreDNS 전환 뒤 Gitea OIDC profile을 다시 적용하면 기존 Pod의 임시
hostAliases도 제거됩니다.
PLATFORM_HELM_BIN=/home/donghyeon/.local/bin/helm \
bash scripts/bootstrap/apply-gitea-oidc.sh --execute
## 4. 운영자 Secret 파일
Cloudflare Dashboard의 `My Profile > API Tokens > Create Token`에서
`Edit Zone DNS` 템플릿을 선택합니다. 권한은 `Zone:DNS:Edit`, Zone resource는
`Include > Specific zone > hyeonworks.com`으로 제한합니다.
공식 절차:
https://developers.cloudflare.com/fundamentals/api/get-started/create-token/
파일 내용은 `dns_cloudflare_api_token = ...` 한 줄이며 저장소에 만들지 않습니다.
sudo install -d -o root -g root -m 0700 /home/donghyeon/.secrets/certbot
sudoedit /home/donghyeon/.secrets/certbot/cloudflare.ini
sudo chown root:root /home/donghyeon/.secrets/certbot/cloudflare.ini
sudo chmod 0600 /home/donghyeon/.secrets/certbot/cloudflare.ini
pgAdmin 내부 비상 관리자 비밀번호는 이미 준비했습니다. 재생성이 필요한 경우에만
다음을 실행합니다. 실행하면 기존 비밀번호 파일이 교체됩니다.
install -d -m 0700 /home/donghyeon/.secrets/pgadmin
umask 077
openssl rand -base64 32 | tr -d '\n' \
> /home/donghyeon/.secrets/pgadmin/bootstrap-password
chmod 0600 /home/donghyeon/.secrets/pgadmin/bootstrap-password
파일 값은 출력하거나 runbook에 복사하지 않습니다.
## 5. admin 인증서
상태: 2026-07-24 실제 발급 완료. 만료일 2026-10-21, 자동 갱신 등록.
서비스 적용 전에 exact SAN 두 개만 발급합니다.
bash scripts/bootstrap/apply-host-nginx-admin.sh \
--execute \
--certificate-only \
--certbot-email you@example.com
확인 문자열은 APPLY입니다. 최초 실행은 공식 Cloudflare Certbot snap plugin을
설치하고 deploy hook을 등록합니다. wildcard 인증서는 만들지 않습니다.
## 6. Keycloak client와 그룹
상태: 2026-07-24 실제 적용 완료. `donghyeon.kang` 두 관리자 그룹 배정.
먼저 dry-run으로 대상을 확인합니다. donghyeon.kang은 실제 realm 사용자명으로
바꿀 수 있습니다.
bash scripts/bootstrap/configure-keycloak-admin-oidc.sh
bash scripts/bootstrap/configure-keycloak-admin-oidc.sh \
--execute \
--object-admin donghyeon.kang \
--db-admin donghyeon.kang
확인 문자열은 APPLY default입니다. 두 OIDC Secret의 값은 출력하지 않습니다.
## 7. 관리 서비스
상태: 2026-07-24 실제 적용 완료.
최종 검증 결과:
object-storage/minio-aistor: green, drivesOnline=1
platform-admin/pgadmin: 1/1
platform-admin/pgadmin PVC: Bound, pgadmin-data-local-pv
storage-admin Traefik HTTP: 200
db-admin Traefik HTTP: 302
PLATFORM_HELM_BIN=/home/donghyeon/.local/bin/helm \
bash scripts/bootstrap/apply-admin-services.sh \
--execute \
--pgadmin-password-file \
/home/donghyeon/.secrets/pgadmin/bootstrap-password
확인 문자열은 APPLY default입니다. 실패 시 AIStor spec은 복원하고 pgAdmin은
0 replica로 내리지만 pgAdmin PVC/PV와 AIStor 데이터는 삭제하지 않습니다.
적용 중 pgAdmin rollout 뒤 출력이 잠시 없으면 AIStor health를 확인하는
구간입니다. 현재 스크립트는 15초마다 상태와 경과 시간을 출력합니다. 300초 뒤
실패하면 자동 rollback하므로 강제로 다시 적용하기 전에 중앙 runbook의
`pgAdmin 완료 뒤 AIStor health 대기` 항목을 확인합니다.
## 8. Nginx 최종 전환
상태: 2026-07-24 실제 적용 완료.
활성 설정 SHA-256:
f07d558d7ebbe09bdd61f3422fe8dcdaaaa6d1160d0872c06538d2bee193908f
bash scripts/bootstrap/apply-host-nginx-admin.sh --execute
확인 문자열은 APPLY입니다. 설정을 timestamp backup한 뒤 LAN 요청의 정상 응답,
허용 목록 밖 loopback 요청의 403, unknown SNI 거부와 인증서 갱신 dry-run을
검사합니다.
## 9. 수용 시험
상태: 2026-07-24 자동 시험 완료.
READ-ONLY CHECK PASS
AISTOR_S3_SMOKE_PASS
ADMIN UI SMOKE PASS
bash scripts/validate/admin-ui-smoke.sh --execute --run-s3
확인 문자열은 APPLY이며 S3 하위 검사에서도 APPLY를 한 번 더 입력합니다.
재검증이 필요할 때만 다시 실행합니다.
마지막으로 LAN 또는 Tailscale 연결 상태에서 브라우저로 다음을 시험합니다.
- https://storage-admin.learn.hyeonworks.com
- `/platform-object-admins` 사용자의 Keycloak OIDC 로그인 성공
- 해당 group이 없는 사용자의 관리 기능 접근 거부
- https://db-admin.learn.hyeonworks.com
- `/platform-db-admins` 사용자의 Keycloak OIDC 로그인 성공
- 해당 group이 없는 사용자의 로그인 거부
pgAdmin 내부 비상 관리자 로그인은 OIDC 장애 대응용으로만 유지합니다.
@@ -0,0 +1,701 @@
# Phase 4 관측성 접근·알림 전환 절차
상세 설계와 실행 원장은 `/home/donghyeon/workspace/docs/platform`에서 관리합니다.
이 문서는 현재 live substrate를 보존하면서 rules·alerts와 Host Nginx를 마지막에
전환하는 권위 실행 순서를 요약합니다. Secret 값, token, Cookie, 사용자 ID와
webhook 원문은 출력하거나 문서에 복사하지 않습니다.
## 현재 완료 경계
2026-08-15 기준 Blackbox substrate, metric target과 rules-alerts는 live입니다.
- Prometheus active target `30`, healthy `30`, unhealthy `0`
- Grafana와 Blackbox Exporter Deployment 각각 `1/1` Ready
- Host Nginx는 Grafana deny-only guard 상태
- Grafana OIDC workload와 `grafana-keycloak-oidc` Secret 참조는 live
- Slack Secret과 risk deployment evidence는 live이며 off-host Slack DR은 deferred
- platform dashboard ConfigMap `5`, platform PrometheusRule `4`,
`AlertmanagerConfig/platform-alertmanager` `1`이 rollback ID `20260814T145009Z`에서 accepted
- generated receiver는
`observability/platform-alertmanager/platform-slack` exact singleton
- full Nginx cutover와 browser OIDC·Slack firing/resolved·external-client acceptance는 미실행
rules-alerts 실행 때 다음 두 선행 조건은 모두 충족됐습니다. 이후 재실행이나 복구에서도
같은 gate를 생략하지 않습니다.
1. 아래 runbook URL이 HTTPS `200`으로 도달하고 모든 alert의 `runbook_url`과 일치한다.
2. 운영자가 만든 Slack webhook 입력 파일과 recovery evidence가 준비된다.
```text
https://git.learn.hyeonworks.com/donghyeon.kang/project-infra/src/branch/main/docs/runbooks/2026-07-31-observability-access-cutover.md
```
## 1. 공통 rollback transaction
하나의 shell에서 같은 rollback ID를 끝까지 유지합니다.
```bash
cd /home/donghyeon/workspace/platform
OBS_ROLLBACK_ID="$(date -u +%Y%m%dT%H%M%SZ)"
[[ "$OBS_ROLLBACK_ID" =~ ^[0-9]{8}T[0-9]{6}Z$ ]]
[[ "$OBS_ROLLBACK_ID" != 20260814T080303Z ]]
OBS_ROLLBACK_ROOT="/var/lib/hyeonworks/platform-rollbacks/observability-$OBS_ROLLBACK_ID"
sudo -n /usr/bin/test ! -e "$OBS_ROLLBACK_ROOT"
sudo -n /usr/bin/test ! -L "$OBS_ROLLBACK_ROOT"
sudo -n /usr/bin/mkdir --mode=0700 -- "$OBS_ROLLBACK_ROOT"
[[ "$(sudo -n /usr/bin/stat -c '%F|%u:%g|%a' -- "$OBS_ROLLBACK_ROOT")" == \
'directory|0:0|700' ]]
export PLATFORM_OBSERVABILITY_ROLLBACK_ID="$OBS_ROLLBACK_ID"
[[ "$PLATFORM_OBSERVABILITY_ROLLBACK_ID" == "$OBS_ROLLBACK_ID" ]]
```
pre-absence, 단 한 번의 `mkdir`, metadata 또는 active-ID equality가 실패하면 STOP하고 그
collision/error ID를 재사용하지 않습니다. 자동 rollback 뒤에도 생성된 root를 삭제하지 않습니다.
## 2. 기존 substrate와 deny guard 확인
```bash
bash scripts/bootstrap/apply-private-dns.sh
bash scripts/bootstrap/apply-host-nginx-observability.sh
bash scripts/validate/validate-blackbox-edge-source.sh
```
private DNS와 metrics/deny guard 자체를 다시 적용해야 할 때만 다음 mutation을
순서대로 실행합니다. 각 명령의 화면 지시와 정확히 일치하는 확인 문자열만 입력합니다.
```bash
: "${CERTBOT_EMAIL:?set the operator-managed Certbot contact email}"
bash scripts/bootstrap/apply-private-dns.sh --execute
bash scripts/bootstrap/apply-host-nginx-observability.sh \
--execute --metrics-guard-only
metrics_status="$(curl --disable --silent --show-error --output /dev/null \
--write-out '%{http_code}' \
--resolve git.learn.hyeonworks.com:443:127.0.0.1 \
https://git.learn.hyeonworks.com/metrics)"
[[ "$metrics_status" == 404 ]]
bash scripts/bootstrap/apply-host-nginx-observability.sh \
--execute --certificate-only --certbot-email "$CERTBOT_EMAIL"
bash scripts/bootstrap/apply-host-nginx-observability.sh \
--execute --grafana-deny-guard-only
```
DNS/guard를 다시 적용했는지와 무관하게, **새 rollback ID마다** Blackbox source
proof를 반드시 새로 만듭니다. Task 6 retry에서는 failed ID `20260814T080303Z`를 거부하고
controller가 발급한 fresh ID와 현재 active environment가 exact equality인지 먼저 확인합니다.
```bash
FRESH_TASK6_ROLLBACK_ID="$OBS_ROLLBACK_ID"
[[ "$FRESH_TASK6_ROLLBACK_ID" != 20260814T080303Z ]]
[[ "${PLATFORM_OBSERVABILITY_ROLLBACK_ID:?active rollback ID is required}" == \
"$FRESH_TASK6_ROLLBACK_ID" ]]
set +e
bash scripts/validate/validate-blackbox-edge-source.sh \
--execute --context default
BLACKBOX_RC=$?
set -e
printf 'BLACKBOX_RC=%d\n' "$BLACKBOX_RC"
[[ "$BLACKBOX_RC" -eq 0 ]]
[[ "$(sudo -n /usr/bin/stat -c '%F|%u:%g|%a|%h' -- \
"/var/lib/hyeonworks/platform-rollbacks/observability-${FRESH_TASK6_ROLLBACK_ID}/blackbox-source-proof.env")" == \
'regular file|0:0|600|1' ]]
```
이 proof는 같은 rollback ID, active deny hash, 24시간 이내 시각과 세 private
hostname의 exact `403`에 결속되어야 합니다. validator는 한 번만 호출하고 operator가 exact
`PROVE BLACKBOX PRIVATE EDGE default`를 입력한 뒤 `BLACKBOX PRIVATE EDGE SOURCE PASS`, immediate
RC `0`, normalized proof metadata `regular|0:0|600|1`를 모두 확인합니다. 하나라도 실패하거나
불명확하면 STOP하고 new ID를 보존하며 같은 ID로 validator나 Task 6를 재시도하지 않습니다.
proof content는 읽지 않습니다.
preflight/live residue는 absolute zero가 아니라 다음 attested preexisting name-only baseline의
unchanged 계약입니다.
```text
/tmp/platform-k3s-encryption.Mskzy3
/tmp/platform-observability-access-apply.oeNcfI
/tmp/platform-observability-access-apply.Im02dz
/tmp/platform-observability-slack-gate.LYhYbv
```
`Mskzy3`는 8/1 empty evidence, `oeNcfI`/`Im02dz`는 8/12 recorded evidence,
`LYhYbv`는 private filename 두 개만 attested된 failed-live evidence입니다. 네 root는 content를
읽거나 삭제하지 않습니다. baseline name set unchanged, matching executable process `0`, current
preflight/live newly-created matching-root delta `0`을 요구합니다. unknown/new root는 broad
delete하지 않고 STOP/identity review합니다.
## 3. Secret과 Grafana OIDC recovery evidence
먼저 K3s encryption과 restore evidence를 각각 새 process에서 검사합니다.
```bash
bash scripts/validate/k3s-secret-encryption.sh --expect-reencrypted
bash scripts/validate/k3s-secret-encryption-restore-evidence.sh --check
```
입력 파일은 현재 사용자 소유 `0600`, non-symlink, link count 1이어야 합니다.
값을 shell 변수, argv, stdout 또는 runbook에 넣지 않습니다.
```bash
bash scripts/bootstrap/create-observability-secrets.sh \
--execute --grafana-admin \
--grafana-admin-user-file /home/donghyeon/.secrets/grafana/admin-user \
--grafana-admin-password-file /home/donghyeon/.secrets/grafana/admin-password
```
Slack Secret bootstrap 직전에 별도 내장 Windows SSD의 기존 KDBX에 same-host encrypted
disaster-recovery copy를 준비합니다. 이것은 일반 K3s restart나 host reboot용 사본이 아니라
datastore·Secret·bootstrap state 손실 때를 위한 것입니다. 기본 no-argument 실행은 고정
contract만 출력하며 SSD, KDBX, webhook, sudo에 접근하지 않습니다. 지원되는 interface는
다음 두 개뿐입니다.
```bash
bash scripts/bootstrap/backup-slack-webhook-recovery.sh
bash scripts/bootstrap/backup-slack-webhook-recovery.sh \
--execute \
--slack-webhook-file /home/donghyeon/.secrets/alertmanager/slack-webhook
```
execute에서 `SLACK_KEEPASS_RECOVERY=NOOP`는 exact entry의 verified read-only no-op이고,
`SLACK_KEEPASS_RECOVERY=COMMITTED`는 durable pre-change backup을 만든 verified commit입니다.
둘 다 source-based unmount proof와 private work/socket/helper cleanup 뒤에만 성공하며
`WINDOWS_SSD_UNMOUNTED=PASS`, `OFF_HOST_RECOVERY_SATISFIED=NO`를 출력합니다. lost response,
post-commit verification failure 또는 cleanup/unmount ambiguity는 자동 재시도하지 않고
`SLACK_KEEPASS_RECOVERY=MANUAL_RECOVERY_REQUIRED`로 중단하며 main과 backup을 보존합니다.
webhook payload, KeePassXC master password, hash·encoding·size·URL component 또는 protected
KDBX output을 terminal, argv, environment, log, runbook이나 plaintext 파일에 남기지 않습니다.
This local encrypted copy does not authorize RECOVERY SLACK default when the
approved gate requires off-host escrow. Do not continue the Secret bootstrap
until that independent prerequisite is literally true.
Slack에는 서로 다른 두 경로가 있습니다. off-host disaster recovery를 완료로 판정할 때만
strict recovery evidence를 검사합니다.
```bash
bash scripts/bootstrap/create-observability-secrets.sh \
--check-slack-recovery-evidence
```
현재 사용자가 승인한 operational risk path는 off-host Slack DR이 아직 deferred인 사실을
기록하고 deployment evidence를 만듭니다. 이 경로는 DR-complete을 주장하지 않습니다.
```bash
bash scripts/bootstrap/create-observability-secrets.sh \
--execute --slack-webhook \
--slack-webhook-file /home/donghyeon/.secrets/alertmanager/slack-webhook \
--accept-no-off-host-slack-recovery
```
도구가 요구하는 정확한 확인은 `ACCEPT NO OFF-HOST SLACK RECOVERY default`입니다.
이 risk path 밖에서 kubectl로 Secret을 수동 생성하지 않습니다. off-host 복구 증거가
없을 때 거짓 `RECOVERY SLACK default` 확인을 입력하지 않습니다.
위 risk path 또는 실제 off-host recovery evidence가 준비된 경우 Slack Secret bootstrap은
이미 완료된 상태이므로, deployment gate와 다음 checker만 실행합니다.
```bash
bash scripts/bootstrap/configure-keycloak-grafana-oidc.sh --execute
bash scripts/bootstrap/create-observability-secrets.sh \
--check-grafana-recovery-evidence
bash scripts/bootstrap/create-observability-secrets.sh \
--check-slack-deployment-evidence
bash scripts/bootstrap/configure-keycloak-grafana-oidc.sh \
--check-recovery-evidence
```
기존 Secret의 payload가 다르면 자동 rotation하지 않고 중단합니다. UID drift나 API
결과 불명도 자동 삭제로 처리하지 않습니다.
Grafana admin/OIDC object가 이미 exact live state이면 도구는 credential을 회전하거나
workload를 다시 쓰지 않고 기존 payload를 재사용하며 recovery evidence만 검증·갱신합니다.
exact state가 아닌데 권위 prior와 ownership을 증명할 수 없으면 자동 수렴시키지 않습니다.
## 4. 권위 inventory로 rules-alerts handoff 생성
현재 Blackbox substrate가 이미 live이므로 `target-initial`을 지금 다시 캡처하지
않습니다. 실행 당시 보존한 두 phase만 새 `0700` output root에 복제하고 renderer가
schema, phase, mode, link count와 hash를 다시 검증하게 합니다.
먼저 공개 runbook이 실제로 게시되었는지 확인합니다. `200`이 아니면 renderer와
rules-alerts apply를 실행하지 않습니다.
```bash
RUNBOOK_URL='https://git.learn.hyeonworks.com/donghyeon.kang/project-infra/src/branch/main/docs/runbooks/2026-07-31-observability-access-cutover.md'
runbook_status="$(curl --disable --silent --show-error --location --output /dev/null \
--write-out '%{http_code}' --connect-timeout 3 --max-time 10 \
"$RUNBOOK_URL")"
[[ "$runbook_status" == 200 ]]
```
```bash
SOURCE_METRIC_ROOT=/tmp/platform-observability-metrics.VUpsZn
METRIC_ROOT="$(mktemp -d /tmp/platform-observability-metrics.XXXXXX)"
chmod 0700 "$METRIC_ROOT"
[[ "$(realpath --canonicalize-existing -- "$SOURCE_METRIC_ROOT")" == "$SOURCE_METRIC_ROOT" ]]
[[ "$(stat -c '%F|%u:%g|%a|%h' -- "$SOURCE_METRIC_ROOT")" == \
'directory|1000:1000|700|4' ]]
declare -A SOURCE_METRIC_IDENTITY=()
for phase in target-initial post-substrate; do
[[ -d "$SOURCE_METRIC_ROOT/$phase" && ! -L "$SOURCE_METRIC_ROOT/$phase" ]]
[[ "$(realpath --canonicalize-existing -- "$SOURCE_METRIC_ROOT/$phase")" == \
"$SOURCE_METRIC_ROOT/$phase" ]]
[[ "$(stat -c '%F|%u:%g|%a|%h' -- "$SOURCE_METRIC_ROOT/$phase")" == \
'directory|1000:1000|700|2' ]]
install -d -m 0700 -- "$METRIC_ROOT/$phase"
for file in inventory.json inventory.sha256; do
source_file="$SOURCE_METRIC_ROOT/$phase/$file"
destination_file="$METRIC_ROOT/$phase/$file"
[[ -f "$source_file" && ! -L "$source_file" ]]
[[ "$(realpath --canonicalize-existing -- "$source_file")" == "$source_file" ]]
[[ "$(stat -c '%F|%u:%g|%a|%h' -- "$source_file")" == \
'regular file|1000:1000|600|1' ]]
SOURCE_METRIC_IDENTITY["$phase/$file"]="$(stat -c '%d:%i|%F|%u:%g|%a|%h|%s|%Y|%Z' -- \
"$source_file")|$(sha256sum -- "$source_file" | awk '{print $1}')"
cp --no-dereference --reflink=never -- "$source_file" "$destination_file"
chmod 0600 "$destination_file"
[[ "$(stat -c '%F|%u:%g|%a|%h' -- "$destination_file")" == \
'regular file|1000:1000|600|1' ]]
cmp -s -- "$source_file" "$destination_file"
done
done
[[ "$(find "$METRIC_ROOT" -mindepth 1 -maxdepth 2 -printf '%P\n' | LC_ALL=C sort)" == \
$'post-substrate\npost-substrate/inventory.json\npost-substrate/inventory.sha256\ntarget-initial\ntarget-initial/inventory.json\ntarget-initial/inventory.sha256' ]]
for phase in target-initial post-substrate; do
for file in inventory.json inventory.sha256; do
source_file="$SOURCE_METRIC_ROOT/$phase/$file"
[[ "$(stat -c '%d:%i|%F|%u:%g|%a|%h|%s|%Y|%Z' -- "$source_file")|$(sha256sum -- \
"$source_file" | awk '{print $1}')" == "${SOURCE_METRIC_IDENTITY["$phase/$file"]}" ]]
done
done
PLATFORM_HELM_BIN=/home/donghyeon/.local/bin/helm \
bash scripts/validate/render-observability-access.sh \
--component rules-alerts --verified-output-dir "$METRIC_ROOT"
```
failed transaction handoff `/tmp/platform-observability-metrics.LNzksC`는 read-only immutable
evidence로 보존하고 source, destination 또는 live apply input으로 재사용하지 않습니다.
기존 attested path/fingerprint identity만 보존·비교하며 inventory body나 private file content를
다시 읽지 않습니다. fresh destination은 renderer 전 exact two phase directories만 가집니다.
renderer와 live apply는 이 절에서 byte-preserving copy와 metadata/hash/count revalidation을
마친 fresh `$METRIC_ROOT`만 사용합니다.
위 exact six-entry gate는 renderer 전 destination이 두 phase directory와 네 file만 갖는지
확인합니다. 각 source/destination pair는 byte-equal이며 copy 뒤 source inode/metadata/size/hash가
copy 전 fingerprint와 같아야 합니다. 이어지는 renderer는 current production pins와 exact target
counts `21/30`, checksum/schema/semantic contract, 그리고 reviewed rendered manifest set을 다시
검증합니다. source/destination metadata, fingerprint, byte equality, pins, counts 또는 entry set
중 하나라도 다르면 fresh root를 apply input으로 사용하지 않고 STOP합니다.
권위 inventory hash는 다음과 같습니다.
```text
target-initial: 79688d017d38eec9a6f100f8d0f784a5474e79802046ef1c2c11b30d170b0b0c
post-substrate: b1c3049206a1a88165ee672ae9aceac7945673a3bb9c3cf3670b7f0d56c3f291
```
이 두 SHA는 현재 cluster freshness artifact가 아니라 변경할 수 없는 historical
metric/label provenance pair입니다. `target-initial``post-substrate`는 각각 exact
target count `21``30`을 initial gate와 confirmation 뒤 first mutation 직전
last gate 모두에서 다시 검증합니다. `captured_at_utc`는 exact UTC-second
형식과 유효한 UTC calendar로 parse되어야 하며, 현재보다 300초를 초과해
미래인 시각만 거부합니다. 이 exact pair에는 과거 방향 24시간 상한을
적용하지 않으며, timestamp나 checksum을 현재 시각에 맞게 다시 쓰거나
inventory를 재수집해서는 안 됩니다. 이 예외는 2절 Blackbox source proof의
기존 24시간 freshness 계약에는 적용되지 않습니다.
## 5. rules-alerts 적용 — 2026-08-15 terminal PASS
먼저 no-argument dry-run과 focused test를 실행합니다. 둘 중 하나라도 끝나지 않거나
실패하면 mutation을 실행하지 않습니다.
```bash
PLATFORM_HELM_BIN=/home/donghyeon/.local/bin/helm \
bash scripts/bootstrap/apply-observability-access.sh
bash scripts/validate/test-apply-observability-access.sh
```
성공한 뒤에만 다음을 실행합니다.
```bash
case $- in *e*) TASK6_APPLY_ERREXIT_WAS_SET=1 ;; *) TASK6_APPLY_ERREXIT_WAS_SET=0 ;; esac
set +e
PLATFORM_HELM_BIN=/home/donghyeon/.local/bin/helm bash scripts/bootstrap/apply-observability-access.sh --execute --rules-alerts --verified-output-dir "$METRIC_ROOT"
TASK6_APPLY_RC=$?
printf 'TASK6_APPLY_RC=%d\n' "$TASK6_APPLY_RC"
(( TASK6_APPLY_ERREXIT_WAS_SET == 0 )) || set -e
[[ "$TASK6_APPLY_RC" -eq 0 ]]
```
operator만 exact `APPLY` confirmation을 입력합니다. apply는 위 exact one-line command로 한 번만
실행하고, 바로 다음 statement가 다른 command 없이 `TASK6_APPLY_RC=$?`를 capture합니다.
immediate printed RC `0`과 exact `OBSERVABILITY_ACCESS_RULES_ALERTS=PASS`를 모두 확인한 경우에만
후속 acceptance를 진행합니다. nonzero, missing/ambiguous RC 또는 PASS, response loss, rollback
ambiguity는 STOP하고 rollback ID와 evidence를 보존하며 같은 ID로 재시도하지 않습니다.
성공 조건은 다음 전부입니다.
- Prometheus와 Alertmanager owner/controller Ready
- Prometheus API의 desired alert·record exact set과 evaluation health 정상
- Alertmanager generated config의 `observability/platform-alertmanager/platform-slack` receiver exact singleton
- Grafana sidecar의 exact dashboard 5개와 source content hash 일치
- 기존 target·Grafana·Blackbox·Probe·Ingress·PVC·Secret 보존
- acceptance marker는 모든 증거 뒤에만 root-only로 기록
apply 도구는 deployment checker를 confirmation 전과 Slack Secret-consuming mutation 직전에
두 번 호출한다. rules-alerts acceptance ledger schema는
`platform-observability-rules-alerts-v2`이며 Slack deployment gate 값은 bare `RECOVERY` 또는
`RISK_ACCEPTED`만 기록한다. 이는 operational acceptance이며 off-host Slack DR-complete을
의미하지 않는다.
실제 terminal transaction은 fresh rollback ID `20260814T145009Z`와 fresh handoff
`/tmp/platform-observability-metrics.dw5gLZ`를 사용했다. argv는 exact six-element array로
attest됐고, operator가 exact `APPLY`를 입력한 단 한 번의 실행에서 다음 safe marker를 확인했다.
```text
target-initial SHA-256 = 79688d017d38eec9a6f100f8d0f784a5474e79802046ef1c2c11b30d170b0b0c
post-substrate SHA-256 = b1c3049206a1a88165ee672ae9aceac7945673a3bb9c3cf3670b7f0d56c3f291
OBSERVABILITY_ACCESS_RULES_ALERTS=PASS
TASK6_APPLY_RC=0
```
payload-free terminal audit는 dashboard `5`, platform PrometheusRule `4`, 전체 desired rule
`23`(`22` alerts + `1` recording) healthy, runbook URL `22/22`, AlertmanagerConfig `1`, exact
NetworkPolicy, target `30/30`, Grafana·Blackbox·Prometheus·Alertmanager Ready와 qualified receiver
exact singleton을 확인했다. acceptance schema와 `RISK_ACCEPTED` gate, root-only ledger의
object/mutation line `13/13` 및 metadata contract도 통과했다. 성공 transaction에는 rollback이
호출되지 않았고 rollback root와 handoff는 Task 7 종료까지 보존한다.
failed rollback ID `20260814T080303Z`와 argument paste가 파싱 전에 중단된
`20260814T140953Z`는 immutable evidence로 보존하고 재사용하지 않는다. 후자는
`--verified-output-dir` token이 줄바꿈으로 분리돼 usage RC `2`, shell-level RC `127`로 끝났으며
ledger·acceptance·Kubernetes mutation은 생성되지 않았다.
## 6. Task 7 operator boundary와 Host Nginx first cutover
Task 7은 성공 rollback ID `20260814T145009Z`와 original handoff
`/tmp/platform-observability-metrics.dw5gLZ`를 그대로 보존합니다. active state는 exact deny-only
SHA-256 `dbef6d443bcba58b26a5351ea76f6d09f6da8c2ef07a806e22745cf26c88f518`,
desired full은 `7d2de2a92c3597a0859775da1d2ccbf5a3d72c0b2af5cac2439c82311361f801`여야
합니다. full이 이미 active이거나 third state이면 STOP합니다.
어떤 external preparation command보다 먼저 fixed PATH를 export하고 command cache를 비운 뒤
reviewed command/launcher inventory를 byte-equal로 재검증합니다. ambient PATH command로 prep를
시작하지 않습니다. exact canonical `/usr/bin/sudo``regular|0:0|4755|1`을 요구하는
유일한 owner-setuid 예외입니다. setgid·group/world write는 금지되고 다른 allowlisted
executable은 setuid/setgid를 모두 금지합니다.
```bash
TASK7_OPERATOR_PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
PATH=$TASK7_OPERATOR_PATH
export PATH
hash -r
cd /home/donghyeon/workspace/platform
TASK7_ID=20260814T145009Z
TASK7_METRIC_ROOT=/tmp/platform-observability-metrics.dw5gLZ
METRIC_ROOT=$TASK7_METRIC_ROOT
export PLATFORM_OBSERVABILITY_ROLLBACK_ID="$TASK7_ID"
[[ "$PLATFORM_OBSERVABILITY_ROLLBACK_ID" == "$TASK7_ID" ]]
[[ "$METRIC_ROOT" == "$TASK7_METRIC_ROOT" ]]
[[ "$(/usr/bin/readlink -f -- "$TASK7_METRIC_ROOT")" == "$TASK7_METRIC_ROOT" ]]
TASK7_HOST_DRY=(/usr/bin/bash)
TASK7_HOST_DRY+=(/home/donghyeon/workspace/platform/scripts/bootstrap/apply-host-nginx-observability.sh)
[[ "${#TASK7_HOST_DRY[@]}" -eq 2 ]]
case $- in *e*) TASK7_HOST_DRY_ERREXIT=1 ;; *) TASK7_HOST_DRY_ERREXIT=0 ;; esac
set +e
"${TASK7_HOST_DRY[@]}"
TASK7_HOST_DRY_RC=$?
printf 'TASK7_HOST_DRY_RC=%d\n' "$TASK7_HOST_DRY_RC"
(( TASK7_HOST_DRY_ERREXIT == 0 )) || set -e
```
no-arg는 source/hash/no-contact만 검증합니다. certificate/SAN, DNS, Kubernetes, proof
ID/age, NodePort와 network boundary는 execute 내부의 `APPLY` prompt 전 live gate입니다.
dry-run은 Host mutation `0`과 다음 exact output을 요구합니다.
```text
HOST_NGINX_ACTIVE_SHA256=dbef6d443bcba58b26a5351ea76f6d09f6da8c2ef07a806e22745cf26c88f518
HOST_NGINX_GRAFANA_DENY_GUARD_SHA256=dbef6d443bcba58b26a5351ea76f6d09f6da8c2ef07a806e22745cf26c88f518
HOST_NGINX_FULL_SHA256=7d2de2a92c3597a0859775da1d2ccbf5a3d72c0b2af5cac2439c82311361f801
HOST_NGINX_CERTIFICATE_EXPECTED_SAN=grafana.learn.hyeonworks.com
HOST_NGINX_CERTIFICATE_SAN=NOT_CHECKED_DRY_RUN
HOST_NGINX_GRAFANA_PUBLIC_DNS=NOT_CHECKED_DRY_RUN
HOST_NGINX_OBSERVABILITY_DRY_RUN=PASS
TASK7_HOST_DRY_RC=0
```
`HOST_NGINX_CERTIFICATE_EXACT_SAN=``HOST_NGINX_GRAFANA_PUBLIC_DNS=ABSENT`를 dry-run
결과로 받으면 STOP합니다. source proof는 exact ID/deny hash/status/IP/time에 bind되며
과거 24시간, 미래 300초 경계를 벗어나면 fresh rollback ID, fresh proof, complete Task 6를
다시 수행합니다. 같은 ID에서 proof만 바꾸거나 timestamp를 다시 쓰지 않습니다.
execute가 `APPLY`를 표시하기 전에 다음 네 path가 각각 `test -e`/`test -L` 모두에서
absent임을 no-follow, name-only 순서로 입증해야 합니다.
```text
/var/lib/hyeonworks/platform-rollbacks/observability-20260814T145009Z/host-nginx
/var/lib/hyeonworks/platform-rollbacks/observability-20260814T145009Z/host-nginx/stages.tsv
/var/lib/hyeonworks/platform-rollbacks/observability-20260814T145009Z/host-nginx/payloads
/var/lib/hyeonworks/platform-rollbacks/observability-20260814T145009Z/host-nginx/payloads/full-prior-0001.conf
```
일반 directory/file로 남은 ledger/payload도 reusable recovery state가 아니라 STOP residue입니다.
reviewed sudo identity를 다시 확인한 뒤에만 operator가 credential를 refresh합니다.
```bash
/usr/bin/sudo -v
/usr/bin/sudo -n /usr/bin/true
printf 'SUDO_READY\n'
TASK7_VOD=--verified
TASK7_VOD+=-output-dir
TASK7_HOST=(/usr/bin/bash)
TASK7_HOST+=(/home/donghyeon/workspace/platform/scripts/bootstrap/apply-host-nginx-observability.sh)
TASK7_HOST+=(--execute)
TASK7_HOST+=("$TASK7_VOD")
TASK7_HOST+=("$TASK7_METRIC_ROOT")
[[ "${#TASK7_HOST[@]}" -eq 5 ]]
printf 'TASK7_HOST_ARGC=%d\n' "${#TASK7_HOST[@]}"
case $- in *e*) TASK7_HOST_ERREXIT=1 ;; *) TASK7_HOST_ERREXIT=0 ;; esac
set +e
"${TASK7_HOST[@]}"
TASK7_HOST_RC=$?
printf 'TASK7_HOST_RC=%d\n' "$TASK7_HOST_RC"
(( TASK7_HOST_ERREXIT == 0 )) || set -e
```
operator만 exact `APPLY`를 입력합니다. 성공은 exact `HOST_NGINX_FULL_STAGE=PASS`
`TASK7_HOST_RC=0`이 모두 있을 때뿐입니다. `ALREADY_ACTIVE`, missing/ambiguous marker,
nonzero RC, response loss는 모두 실패이며 같은 ID로 재실행하지 않습니다.
1. prompt 전 실패: active deny unchanged, Host ledger/mutation `0`, rollback N/A.
2. prompt 뒤 `rollback_armed=true` 전 실패: active config/reload mutation `0`, root-owned
ledger/payload 또는 timestamp backup staging은 남을 수 있으며 rollback N/A. 전체 evidence를
보존합니다.
3. active install 뒤 실패: exact `HOST_NGINX_OBSERVABILITY_ROLLBACK=PASS`와 deny hash
복원을 요구합니다.
4. `ROLLBACK=FAIL`, `MANUAL_RECOVERY_REQUIRED=YES`, unknown stage/hash: 모든 후속 gate를
STOP합니다.
`stages.tsv`는 prior-payload recovery ledger이지 success marker가 아닙니다. ledger/payload/timestamp
staging 생성·검증·설치 중 하나라도 실패하면 현 ID/root를 보존하고 fresh ID,
source proof, complete Task 6를 다시 수행합니다. staged evidence를 repair/reuse하거나
failed fresh ID를 재사용하지 않습니다.
## 7. OIDC membership과 사람/external readiness
Host PASS 뒤 mutation 전에 서로 다른 admin, viewer, no-group, membership-removal test
identity, local break-glass 접근, Slack firing/resolved view, LAN/Tailscale 밖 proxy-disabled
external client를 모두 준비합니다. 하나라도 없으면 membership와 smoke를 시작하지
않습니다. username은 stdin으로만 받고 기록하지 않습니다.
```bash
read -r -p 'Grafana organization admin realm username: ' OBS_ADMIN_USER
read -r -p 'Grafana viewer realm username: ' OBS_VIEWER_USER
TASK7_OIDC=(/usr/bin/bash /home/donghyeon/workspace/platform/scripts/bootstrap/configure-keycloak-grafana-oidc.sh)
TASK7_OIDC+=(--execute)
TASK7_OIDC+=(--admin "$OBS_ADMIN_USER")
TASK7_OIDC+=(--viewer "$OBS_VIEWER_USER")
case $- in *e*) TASK7_OIDC_ERREXIT=1 ;; *) TASK7_OIDC_ERREXIT=0 ;; esac
set +e
"${TASK7_OIDC[@]}"
TASK7_OIDC_RC=$?
unset OBS_ADMIN_USER OBS_VIEWER_USER TASK7_OIDC
printf 'TASK7_OIDC_RC=%d\n' "$TASK7_OIDC_RC"
(( TASK7_OIDC_ERREXIT == 0 )) || set -e
```
operator는 exact `APPLY default``RECOVERY KEYCLOAK default`를 입력합니다.
`GRAFANA_OIDC_TRANSACTION=PASS`와 RC `0`을 모두 요구합니다.
`transaction_active=true` 전 실패는 managed Keycloak/OIDC Secret/membership mutation `0`,
rollback N/A입니다. active failure는 exact `GRAFANA_OIDC_ROLLBACK=PASS`를 요구합니다.
rollback FAIL, manual recovery 또는 unknown stage는 STOP입니다. OIDC rollback은 실행 중 private
snapshot을 사용하는 in-process rollback입니다. 성공 뒤 복원용 persistent Task 6 Keycloak
reversal ledger가 있다고 주장하거나 탐색하지 않습니다.
## 8. monolithic observability smoke exactly once
no-arg는 acceptance가 아닌 계획 확인으로 한 번만 실행합니다.
```bash
TASK7_SMOKE_DRY=(/usr/bin/bash)
TASK7_SMOKE_DRY+=(/home/donghyeon/workspace/platform/scripts/validate/observability-smoke.sh)
[[ "${#TASK7_SMOKE_DRY[@]}" -eq 2 ]]
case $- in *e*) TASK7_SMOKE_DRY_ERREXIT=1 ;; *) TASK7_SMOKE_DRY_ERREXIT=0 ;; esac
set +e
"${TASK7_SMOKE_DRY[@]}"
TASK7_SMOKE_DRY_RC=$?
printf 'TASK7_SMOKE_DRY_RC=%d\n' "$TASK7_SMOKE_DRY_RC"
(( TASK7_SMOKE_DRY_ERREXIT == 0 )) || set -e
```
RC `0`, `OBSERVABILITY_SMOKE_DRY_RUN=PASS`, `HUMAN_EXTERNAL_CLIENT=required`,
`MUTATION=NOT_REQUESTED`를 요구합니다. 그 뒤 machine, OIDC human/session, Slack
firing/resolved, true external-client attestation을 하나의 execute에서만 수행합니다.
```bash
TASK7_SMOKE=(/usr/bin/bash)
TASK7_SMOKE+=(/home/donghyeon/workspace/platform/scripts/validate/observability-smoke.sh)
TASK7_SMOKE+=(--execute)
case $- in *e*) TASK7_SMOKE_ERREXIT=1 ;; *) TASK7_SMOKE_ERREXIT=0 ;; esac
set +e
"${TASK7_SMOKE[@]}"
TASK7_SMOKE_RC=$?
printf 'TASK7_SMOKE_RC=%d\n' "$TASK7_SMOKE_RC"
(( TASK7_SMOKE_ERREXIT == 0 )) || set -e
```
operator만 requested identity와 exact dynamic confirmation을 입력합니다. 성공은 다음 전체
marker와 immediate RC를 요구합니다.
```text
OBSERVABILITY_MACHINE_ACCEPTANCE=PASS
OBSERVABILITY_OIDC_ACCEPTANCE=PASS
OBSERVABILITY_SLACK_ACCEPTANCE=PASS
OBSERVABILITY_EXTERNAL_BOUNDARY=PASS
OBSERVABILITY_SMOKE=PASS
TASK7_SMOKE_RC=0
```
RC `2` 또는 `OBSERVABILITY_EXTERNAL_BOUNDARY=PARTIAL`은 Task 7을 `부분 구현`으로 남깁니다.
external result를 server-side, LAN 또는 Tailscale probe로 대체하지 않습니다. cleanup ambiguity는
owned-object review 전 automatic rerun을 금지합니다.
## 9. fresh inventory-only renderer root와 단일 회귀 pass
fixed PATH를 다시 설치하고 `hash -r`, command inventory byte equality를 external prep 전에
확인합니다. original `dw5gLZ`의 canonical path, owner/mode/nlink, exact entry set, two
inventory hash와 three Task 6 YAML fingerprint를 보존합니다. complete publication에 original
root를 사용하지 않습니다.
```bash
TASK7_RENDER_ROOT="$(/usr/bin/mktemp -d /tmp/platform-observability-metrics.XXXXXX)"
/usr/bin/chmod 0700 "$TASK7_RENDER_ROOT"
for phase in target-initial post-substrate; do
/usr/bin/install -d -m 0700 -- "$TASK7_RENDER_ROOT/$phase"
for file in inventory.json inventory.sha256; do
source_file="$TASK7_METRIC_ROOT/$phase/$file"
destination_file="$TASK7_RENDER_ROOT/$phase/$file"
[[ -f "$source_file" && ! -L "$source_file" ]]
/usr/bin/cp --no-dereference --reflink=never -- "$source_file" "$destination_file"
/usr/bin/chmod 0600 "$destination_file"
/usr/bin/cmp -s -- "$source_file" "$destination_file"
done
done
unset source_file destination_file
```
destination은 exact six-entry topology, current owner, root/phase `0700`, file `0600`, nlink `1`,
byte equality와 known inventory hash를 요구합니다. copy 뒤 original fingerprint가 unchanged여야
합니다. 실패한 fresh root는 evidence로 보존하고 repair/reuse하지 않습니다.
core와 complete renderer, admin UI, AIStor S3, phase1, phase2, admin renderer를 다음 exact
array/envelope로 각각 한 번만 실행합니다.
```bash
TASK7_CORE=(/usr/bin/bash)
TASK7_CORE+=(/home/donghyeon/workspace/platform/scripts/validate/render-observability-core.sh)
[[ "${#TASK7_CORE[@]}" -eq 2 ]]
case $- in *e*) TASK7_CORE_ERREXIT=1 ;; *) TASK7_CORE_ERREXIT=0 ;; esac
set +e
PLATFORM_HELM_BIN=/home/donghyeon/.local/bin/helm "${TASK7_CORE[@]}"
TASK7_CORE_RC=$?
(( TASK7_CORE_ERREXIT == 0 )) || set -e
printf 'TASK7_CORE_RC=%d\n' "$TASK7_CORE_RC"
TASK7_VOD=--verified
TASK7_VOD+=-output-dir
TASK7_COMPLETE=(/usr/bin/bash)
TASK7_COMPLETE+=(/home/donghyeon/workspace/platform/scripts/validate/render-observability-access.sh)
TASK7_COMPLETE+=(--component complete)
TASK7_COMPLETE+=("$TASK7_VOD" "$TASK7_RENDER_ROOT")
[[ "${#TASK7_COMPLETE[@]}" -eq 6 ]]
case $- in *e*) TASK7_COMPLETE_ERREXIT=1 ;; *) TASK7_COMPLETE_ERREXIT=0 ;; esac
set +e
PLATFORM_HELM_BIN=/home/donghyeon/.local/bin/helm "${TASK7_COMPLETE[@]}"
TASK7_COMPLETE_RC=$?
(( TASK7_COMPLETE_ERREXIT == 0 )) || set -e
printf 'TASK7_COMPLETE_RC=%d\n' "$TASK7_COMPLETE_RC"
TASK7_ADMIN_UI=(/usr/bin/bash)
TASK7_ADMIN_UI+=(/home/donghyeon/workspace/platform/scripts/validate/admin-ui-smoke.sh)
[[ "${#TASK7_ADMIN_UI[@]}" -eq 2 ]]
case $- in *e*) TASK7_ADMIN_UI_ERREXIT=1 ;; *) TASK7_ADMIN_UI_ERREXIT=0 ;; esac
set +e
"${TASK7_ADMIN_UI[@]}"
TASK7_ADMIN_UI_RC=$?
(( TASK7_ADMIN_UI_ERREXIT == 0 )) || set -e
printf 'TASK7_ADMIN_UI_RC=%d\n' "$TASK7_ADMIN_UI_RC"
TASK7_AISTOR_S3=(/usr/bin/bash)
TASK7_AISTOR_S3+=(/home/donghyeon/workspace/platform/scripts/validate/aistor-s3-smoke.sh)
TASK7_AISTOR_S3+=(--execute)
[[ "${#TASK7_AISTOR_S3[@]}" -eq 3 ]]
case $- in *e*) TASK7_AISTOR_S3_ERREXIT=1 ;; *) TASK7_AISTOR_S3_ERREXIT=0 ;; esac
set +e
"${TASK7_AISTOR_S3[@]}"
TASK7_AISTOR_S3_RC=$?
(( TASK7_AISTOR_S3_ERREXIT == 0 )) || set -e
printf 'TASK7_AISTOR_S3_RC=%d\n' "$TASK7_AISTOR_S3_RC"
TASK7_PHASE1=(/usr/bin/bash)
TASK7_PHASE1+=(/home/donghyeon/workspace/platform/scripts/validate/render-phase1.sh)
[[ "${#TASK7_PHASE1[@]}" -eq 2 ]]
case $- in *e*) TASK7_PHASE1_ERREXIT=1 ;; *) TASK7_PHASE1_ERREXIT=0 ;; esac
set +e
PLATFORM_HELM_BIN=/home/donghyeon/.local/bin/helm "${TASK7_PHASE1[@]}"
TASK7_PHASE1_RC=$?
(( TASK7_PHASE1_ERREXIT == 0 )) || set -e
printf 'TASK7_PHASE1_RC=%d\n' "$TASK7_PHASE1_RC"
TASK7_PHASE2=(/usr/bin/bash)
TASK7_PHASE2+=(/home/donghyeon/workspace/platform/scripts/validate/render-phase2.sh)
[[ "${#TASK7_PHASE2[@]}" -eq 2 ]]
case $- in *e*) TASK7_PHASE2_ERREXIT=1 ;; *) TASK7_PHASE2_ERREXIT=0 ;; esac
set +e
PLATFORM_HELM_BIN=/home/donghyeon/.local/bin/helm "${TASK7_PHASE2[@]}"
TASK7_PHASE2_RC=$?
(( TASK7_PHASE2_ERREXIT == 0 )) || set -e
printf 'TASK7_PHASE2_RC=%d\n' "$TASK7_PHASE2_RC"
TASK7_ADMIN_RENDER=(/usr/bin/bash)
TASK7_ADMIN_RENDER+=(/home/donghyeon/workspace/platform/scripts/validate/render-admin-services.sh)
[[ "${#TASK7_ADMIN_RENDER[@]}" -eq 2 ]]
case $- in *e*) TASK7_ADMIN_RENDER_ERREXIT=1 ;; *) TASK7_ADMIN_RENDER_ERREXIT=0 ;; esac
set +e
PLATFORM_HELM_BIN=/home/donghyeon/.local/bin/helm "${TASK7_ADMIN_RENDER[@]}"
TASK7_ADMIN_RENDER_RC=$?
(( TASK7_ADMIN_RENDER_ERREXIT == 0 )) || set -e
printf 'TASK7_ADMIN_RENDER_RC=%d\n' "$TASK7_ADMIN_RENDER_RC"
```
다섯 renderer의 exact Helm assignment을 생략하지 않습니다. 특히 phase1/phase2는 fixed
PATH에 Helm이 없으므로 `command -v helm` fallback을 허용하지 않습니다. 전체 RC `0`,
expected terminal PASS, complete seven-artifact publication, original fingerprint unchanged와 new
residue `0`을 요구합니다. 존재하지 않는 core smoke를 호출하지 않고 monolithic smoke
execute를 다시 실행하지 않습니다.
## 10. rollback·STOP 경계
Host/rules recovery evidence는 성공 Task 6 root에서 각 transaction 소유 범위만 사용합니다.
OIDC는 persistent Task 6 reversal ledger가 아니라 in-process private snapshot으로만 rollback합니다.
PVC, Secret, CRD, PV, Loki/Tempo object·bucket은 자동 삭제하지 않습니다. API timeout,
response loss, UID drift, third-state, controller 비수렴, ledger mismatch, rollback ambiguity는
`MANUAL_RECOVERY_REQUIRED=YES`로 STOP하고 evidence/root를 보존합니다.
## 11. 전체 완료 판정
Task 7은 Host/OIDC/smoke/renderer의 실제 RC·marker·cleanup과 independent review가 모두 있을
때만 완료로 표시합니다. admin, viewer, no-group, membership-removal, break-glass, Slack
firing/resolved, true external client 중 하나라도 미실행/실패면 `부분 구현`을 유지합니다.
Slack off-host DR은 Task 7 PASS와 무관하게 `deferred / not complete`이며 active exception을
유지합니다. 실제 terminal evidence의 independent review 전에는 중앙 Task 7 Step 16
checkbox를 체크하지 않고 Task 8을 시작하지 않습니다.
View File
View File
+17
View File
@@ -0,0 +1,17 @@
# 서비스 메시 확장 지점
Istio나 다른 서비스 메시는 현재 설치하지 않는다. 이 디렉터리는 후속 검토에서
공통 Kustomize Component를 둘 위치만 예약한다.
현재 리소스에는 다음 설정을 넣지 않는다.
- 사이드카 주입용 namespace label
- `sidecar.istio.io/inject` annotation
- Istio `Gateway`, `VirtualService`, `DestinationRule`
- 애플리케이션 간 mTLS 강제 정책
향후 서비스 메시를 검토할 때에는 단일 노드의 CPU·메모리 비용, Host Nginx → Traefik
경로와의 역할 중복, PostgreSQL·AIStor 트래픽 영향, 장애 시 우회 경로를 먼저
검증한다. 채택하더라도 서비스 base를 직접 수정하지 않고 이 위치의 선택적
Component를 환경 Overlay에서 참조한다.
View File
@@ -0,0 +1,69 @@
# Observability Alertmanager Receiver Postcheck Correction
## Status
Approved approach: require the exact Prometheus Operator-generated receiver identity and reject every other receiver topology.
## Problem
Task 6 applied the rules/alerts resources but its reconcile postcheck rejected the live Alertmanager receiver and rolled the transaction back. The rendered `AlertmanagerConfig` correctly uses the local receiver name `platform-slack`. Prometheus Operator v0.93.0 transforms that local name for the generated Alertmanager configuration using:
```text
<namespace>/<AlertmanagerConfig name>/<local receiver name>
```
The runtime receiver returned by Alertmanager is therefore exactly:
```text
observability/platform-alertmanager/platform-slack
```
The existing postcheck and its fake API response incorrectly expect the unqualified local name `platform-slack`.
## Scope
Change only the Task 6 runtime receiver oracle and its focused fake/test matrix. Do not change the `AlertmanagerConfig`, route, Slack Secret selector, webhook data, rendered resources, inventory files, or Kubernetes substrate.
The failed rollback ID `20260814T080303Z`, its ledger, and the preserved handoff remain immutable evidence and must not be reused.
## Runtime contract
The Alertmanager `/api/v2/receivers` response must be a JSON array containing exactly one receiver object, and that object's name must be exactly:
```text
observability/platform-alertmanager/platform-slack
```
Fail closed when the response is malformed or when the receiver set is empty, contains the raw local name, contains the qualified name more than once, contains a differently qualified name, or contains any additional receiver.
This check remains part of the existing combined reconcile gate. Failure writes no acceptance marker and invokes the existing UID-bound rollback path.
## Test-first implementation
First change the fake receiver API to model the pinned Operator's qualified runtime name. On unchanged production, the existing success transaction must fail at reconcile; this is the required RED.
Then add explicit behavioral cases:
- exact qualified singleton: pass;
- raw local name only: fail and rollback;
- empty response or `null` only: fail and rollback;
- qualified duplicate: fail and rollback;
- qualified receiver plus any extra receiver: fail and rollback;
- wrong namespace, AlertmanagerConfig name, or local receiver segment: fail and rollback;
- malformed JSON/topology: fail and rollback.
The minimal production change replaces the raw-name count predicate with exact singleton equality. No new production test seam is permitted.
## Verification and live recovery
Run Bash syntax checks, the complete focused apply suite, the focused sensitive-source scanner, and the bounded full source scan required by the source-freeze workflow. Perform an independent read-only review of the exact frozen hashes and confirm no Secret payload, webhook value, or rendered credential was read.
After review, create a fresh source manifest and a new root-owned rollback ID. Revalidate Slack deployment evidence, encryption, restore evidence, inventory identity, handoff identity, API readiness, and rollback-root metadata before exactly one new Task 6 execution. Success requires the reconcile checks, acceptance marker, and payload-free live resource counts to pass before Task 7 begins.
## Non-goals
- accepting both raw and qualified names;
- dynamically discovering arbitrary receiver identities;
- changing Operator or Alertmanager versions;
- changing the Slack routing resource;
- retrying the failed rollback ID.
@@ -0,0 +1,380 @@
# Observability Alertmanager Receiver Postcheck Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Correct Task 6's Alertmanager runtime receiver oracle, prove the correction against the pinned Operator behavior, and authorize one fresh rules/alerts transaction without weakening any Secret, rollback, inventory, or reconciliation boundary.
**Architecture:** Keep the rendered `AlertmanagerConfig` unchanged and correct only the runtime `/api/v2/receivers` acceptance predicate. The postcheck accepts an exact singleton receiver named `observability/platform-alertmanager/platform-slack`; focused fakes model that downstream Operator transformation. A fresh source freeze and rollback ID are required before another live execution.
**Tech Stack:** Bash 5.2, Python 3 JSON validation embedded in Bash, Kubernetes/kubectl, Prometheus Operator v0.93.0, Alertmanager v0.33.1, private file-backed source manifests.
## Global Constraints
- Never read, print, hash, copy, or disclose Secret data, the Slack webhook value, or a rendered credential.
- Do not change `services/observability/alerting/platform-alertmanager.yaml`, its raw route/receiver name, or its Secret selector.
- Runtime receiver identity is exactly `observability/platform-alertmanager/platform-slack`.
- `/api/v2/receivers` must contain exactly one receiver object; raw, absent, duplicate, differently qualified, extra, or malformed topologies fail closed.
- Failed rollback ID `20260814T080303Z`, its root ledger, and `/tmp/platform-observability-metrics.LNzksC` remain preserved and are never reused for mutation.
- The missing `kubectl.kubernetes.io/last-applied-configuration` warnings on the two pre-existing resources are not the root cause and require no source change.
- No live retry occurs before RED, GREEN, bounded scans, exact source freeze, and independent review all pass.
- This workspace is non-Git; do not commit, create a branch, or claim a commit. Freeze exact hashes/modes in reports instead.
---
### Task 1: Production-faithful receiver regression
**Files:**
- Modify: `scripts/validate/test-apply-observability-access.sh:230-240`
- Report: `.superpowers/sdd/2026-08-14-observability-alertmanager-receiver-postcheck/task-1-report.md`
**Interfaces:**
- Consumes: fake kubectl `/api/v2/receivers` response and `run_apply` environment forwarding.
- Produces: a focused fake whose default response is the exact Operator-generated singleton and whose closed modes drive negative reconciliation cases.
- [ ] **Step 1: Freeze the unchanged production/test identities**
Record SHA-256, mode, UID:GID, nlink, type, and size for:
```text
scripts/bootstrap/apply-observability-access.sh
scripts/validate/test-apply-observability-access.sh
```
Expected starting SHA-256 values:
```text
apply = 1e1c44e349e46229dfe79d461e711940e1f2b07ea8c0b91e907eeb7dc8740a11
test = 6f8ec85010b6c0ae1fea0579a343c28a03d3278bb58b99513f58bef4be3f5644
```
- [ ] **Step 2: Change only the fake default and observe RED**
Replace the fake's receiver response with the production-faithful literal:
```python
if raw_path.endswith("/proxy/api/v2/receivers"):
print(json.dumps([
{"name": "observability/platform-alertmanager/platform-slack"},
]))
raise SystemExit(0)
```
Do not edit production. Run:
```bash
bash -n scripts/bootstrap/apply-observability-access.sh
bash -n scripts/validate/test-apply-observability-access.sh
timeout --signal=TERM --kill-after=5s 240s \
bash scripts/validate/test-apply-observability-access.sh
```
Expected: syntax RC `0`; suite RC nonzero because a success transaction reaches reconcile, the unchanged raw-name predicate rejects the qualified singleton, and no acceptance marker is created.
- [ ] **Step 3: Add closed fake receiver modes**
Use only a test-file fake mode, defaulting to `qualified`:
```python
mode = os.environ.get("PLATFORM_TEST_ALERTMANAGER_RECEIVER_MODE", "qualified")
qualified = "observability/platform-alertmanager/platform-slack"
receiver_cases = {
"qualified": [{"name": qualified}],
"raw": [{"name": "platform-slack"}],
"empty": [],
"null": [{"name": "null"}],
"duplicate": [{"name": qualified}, {"name": qualified}],
"extra": [{"name": qualified}, {"name": "null"}],
"wrong-namespace": [{"name": "other/platform-alertmanager/platform-slack"}],
"wrong-config": [{"name": "observability/other/platform-slack"}],
"wrong-local": [{"name": "observability/platform-alertmanager/other"}],
}
if mode == "malformed":
print('{"name":')
elif mode in receiver_cases:
print(json.dumps(receiver_cases[mode]))
else:
raise SystemExit(76)
raise SystemExit(0)
```
Forward `PLATFORM_TEST_ALERTMANAGER_RECEIVER_MODE` through `run_apply`. Retain the existing receiver-drop case by mapping it to `empty` or replacing that test with the explicit matrix; do not leave two contradictory controls.
- [ ] **Step 4: Add exact behavioral assertions**
Add one positive exact-singleton transaction and table-driven negative transactions for:
```text
raw empty null duplicate extra wrong-namespace wrong-config wrong-local malformed
```
Each negative must assert transaction RC nonzero, `OBSERVABILITY_ACCESS_RULES_ALERTS_ROLLBACK=PASS`, absent `acceptance.env`, and no leftover newly-created fake state. The positive must still be RED until production changes.
- [ ] **Step 5: Record Task 1 RED evidence**
Write the exact baseline hashes, command, RC, expected failing leaf, acceptance absence, fake-state cleanup, and process/temp residue counts to the Task 1 report. Do not include API bodies beyond the safe receiver-name literals listed in this plan.
---
### Task 2: Minimal exact-singleton production correction
**Files:**
- Modify: `scripts/bootstrap/apply-observability-access.sh:1209-1215`
- Test: `scripts/validate/test-apply-observability-access.sh`
- Append: `.superpowers/sdd/2026-08-14-observability-alertmanager-receiver-postcheck/task-1-report.md`
**Interfaces:**
- Consumes: JSON bytes returned from Alertmanager `/api/v2/receivers`.
- Produces: reconcile success only for the exact qualified singleton; all other topologies return failure to the existing rollback coordinator.
- [ ] **Step 1: Implement the minimal predicate**
Replace the raw-name counter with:
```python
receiver_payload = json.loads(receivers_api.read_text(encoding="utf-8"))
expected_receiver = "observability/platform-alertmanager/platform-slack"
if (
not isinstance(receiver_payload, list)
or len(receiver_payload) != 1
or not isinstance(receiver_payload[0], dict)
or receiver_payload[0].get("name") != expected_receiver
):
raise SystemExit(1)
```
Do not add a production environment seam, fallback raw name, prefix match, wildcard, or dynamic discovery.
- [ ] **Step 2: Run GREEN syntax and full focused suite**
Run exactly:
```bash
bash -n scripts/bootstrap/apply-observability-access.sh
bash -n scripts/validate/test-apply-observability-access.sh
timeout --signal=TERM --kill-after=5s 330s \
bash scripts/validate/test-apply-observability-access.sh
```
The receiver matrix adds nine complete rollback transactions to the prior 176-second suite. Set the test-only internal `SUITE_WALL_BOUND_SECONDS` to `300`; the measured unchanged-bound RED is `222 > 220`. Expected: both syntax RC `0`; focused suite RC `0` within the new internal 300-second bound and outer 330-second supervisor; exact terminal PASS; every receiver negative rolls back; no suite-owned orphan process or fixture residue.
- [ ] **Step 3: Mutation-check the tests**
In a private temporary copy only, substitute each of the following and prove at least one focused assertion fails for each mutation:
```text
expected_receiver = "platform-slack"
len(receiver_payload) >= 1
receiver_payload[0].get("name", "").endswith("/platform-slack")
```
Delete only the private temporary copy afterward. Do not edit production for this check.
- [ ] **Step 4: Append GREEN evidence**
Append final source/test hashes and modes, the full assertion count, terminal PASS, wall time, orphan count, and residue audit to the Task 1 report.
---
### Task 3: Documentation, scans, review, and retry freeze
**Files:**
- Modify: `bootstrap/manual/phase4-observability-access.md:242-247`
- Modify: `/home/donghyeon/workspace/docs/platform/plans/2026-08-14-observability-authoritative-metric-inventory-implementation.md`
- Regenerate: `.superpowers/sdd/2026-08-14-observability-authoritative-metric-inventory-implementation/task-3-brief.md`
- Append: `.superpowers/sdd/2026-08-14-observability-authoritative-metric-inventory-implementation/task-2-report.md`
- Append: `/home/donghyeon/workspace/docs/platform/runbooks/2026-08-13-observability-phase4-resume-worklog.md`
- Preserve old authority under: `.superpowers/sdd/2026-08-14-observability-alertmanager-receiver-postcheck/baseline/failed-task6-20260814T080303Z/`
- Regenerate: `.superpowers/sdd/2026-08-14-observability-slack-risk-acceptance-implementation/baseline/task-6-prelive-source-manifest.txt`
- Regenerate: `.superpowers/sdd/2026-08-14-observability-slack-risk-acceptance-implementation/baseline/task-6-prelive-source-manifest.sha256`
**Interfaces:**
- Consumes: Task 2 exact hashes and passing evidence.
- Produces: reviewed execution authority for one fresh Task 6 transaction.
- [ ] **Step 1: Correct human and agent acceptance wording**
Replace only runtime acceptance wording from raw `platform-slack` to:
```text
generated receiver observability/platform-alertmanager/platform-slack = exact singleton
```
Keep the `AlertmanagerConfig` source receiver documented as raw `platform-slack`. Record the failed live RC `1`, stage `reconcile`, rollback `PASS`, no acceptance claim, exact root cause, and preservation of rollback ID `20260814T080303Z`.
- [ ] **Step 2: Regenerate the derived Task 3 brief deterministically**
Run the canonical extractor once to the derived brief and once to a private comparison file:
```bash
EXTRACTOR=/home/donghyeon/.codex/plugins/cache/openai-curated-remote/superpowers/6.2.0/skills/subagent-driven-development/scripts/task-brief
CENTRAL_PLAN=/home/donghyeon/workspace/docs/platform/plans/2026-08-14-observability-authoritative-metric-inventory-implementation.md
DERIVED_BRIEF=/home/donghyeon/workspace/platform/.superpowers/sdd/2026-08-14-observability-authoritative-metric-inventory-implementation/task-3-brief.md
PRIVATE_BRIEF="$(mktemp /tmp/platform-task6-derived-brief.XXXXXX)"
"$EXTRACTOR" "$CENTRAL_PLAN" 3 "$DERIVED_BRIEF"
"$EXTRACTOR" "$CENTRAL_PLAN" 3 "$PRIVATE_BRIEF"
cmp -s -- "$DERIVED_BRIEF" "$PRIVATE_BRIEF"
```
Require `cmp` RC `0`, record the derived SHA-256, then remove only `PRIVATE_BRIEF`. Do not manually edit the derived brief.
- [ ] **Step 3: Run focused scanner gates**
Run:
```bash
bash -n scripts/validate/scan-platform-sensitive-source.sh
timeout --signal=TERM --kill-after=5s 120s \
bash scripts/validate/test-scan-platform-sensitive-source.sh
```
Expected: RC `0` and `PLATFORM SENSITIVE SOURCE ASSERTION TEST PASS` with no credential output.
- [ ] **Step 4: Run one new authoritative full scan**
A source change authorizes exactly one new full scan. With tracing disabled, run:
```bash
TASK6_FIX_SCAN_ROOT="$(mktemp -d /tmp/platform-task6-receiver-fix-scan.XXXXXX)"
chmod 0700 "$TASK6_FIX_SCAN_ROOT"
: >"$TASK6_FIX_SCAN_ROOT/scan.log"
chmod 0600 "$TASK6_FIX_SCAN_ROOT/scan.log"
start_ms="$(date +%s%3N)"
set +e
timeout --signal=TERM --kill-after=5s 240s \
bash scripts/validate/scan-platform-sensitive-source.sh \
>"$TASK6_FIX_SCAN_ROOT/scan.log" 2>&1
scan_rc=$?
set -e
end_ms="$(date +%s%3N)"
printf 'RC=%d\nWALL_MS=%d\n' "$scan_rc" "$((end_ms - start_ms))" \
>"$TASK6_FIX_SCAN_ROOT/result"
chmod 0600 "$TASK6_FIX_SCAN_ROOT/result"
```
Require RC `0`, exactly one `PLATFORM RENDERED SECRET SCAN PASS`, exactly one `PLATFORM SENSITIVE SOURCE SCAN PASS`, and zero new scanner/renderer processes or temporary artifacts.
- [ ] **Step 5: Preserve the failed execution authority before regeneration**
Attest the current prelive manifest/sidecar as regular, non-symlink, mode `0664`, UID:GID `1000:1000`, nlink `1`; copy them byte-for-byte into the failed-transaction baseline directory with mode `0600`; verify `cmp -s` and record both old hashes. Amend the central plan to state that this preserved copy is the historical authority for rollback ID `20260814T080303Z` and the canonical prelive path is superseded only for the fresh retry.
- [ ] **Step 6: Regenerate and strictly validate the canonical prelive manifest**
Rebuild the existing exact 15-row manifest grammar:
```text
sha256|mode4|uid:gid|nlink|regular file|size|/canonical/absolute/path
```
Keep exactly the verifier's `EXPECTED_ROWS`, each once and in order. Regenerate the canonical sidecar, then invoke the unchanged verifier with literal absolute paths and externally reviewed lowercase SHA values. Require 15 rows, 15 canonical unique paths, no row mismatch, and sidecar binding PASS.
- [x] **Step 7: Independent read-only review**
Review exact source/test/docs/manifest hashes against this plan and the approved design. Required verdicts: spec compliance, Critical/Important/Minor counts, TDD RED provenance, exact-singleton contract, rollback preservation, scan evidence, and live retry Ready YES/NO. Any Critical or Important finding returns to the same implementer for a bounded fix/re-review loop.
---
### Task 4: One fresh live Task 6 transaction
**Files:**
- Preserve: `/var/lib/hyeonworks/platform-rollbacks/observability-20260814T080303Z`
- Preserve read-only: `/tmp/platform-observability-metrics.LNzksC`
- Create: one fresh `/var/lib/hyeonworks/platform-rollbacks/observability-<new UTC ID>` root
- Create and revalidate: one fresh `/tmp/platform-observability-metrics.XXXXXX` root
- Append after terminal result: Task 2 report, central worklog, central implementation plan
- Create after terminal success: `task-6-final-source-manifest.txt` and `.sha256`
**Interfaces:**
- Consumes: reviewed Task 3 freeze and a fresh byte-preserved/revalidated private handoff.
- Produces: Task 6 acceptance schema `platform-observability-rules-alerts-v2` or a preserved fail-closed transaction with no retry.
- [x] **Step 1: Create, attest, and bind a fresh rollback root**
In the existing attached tmux operator pane, refresh sudo and generate one strict UTC ID. Reject
`20260814T080303Z`; require its exact rollback path pre-ABSENT and non-symlink; invoke
`sudo -n /usr/bin/mkdir --mode=0700 -- "$TASK6_ROLLBACK_ROOT"` exactly once; then require
`directory|0:0|700` and export it as `PLATFORM_OBSERVABILITY_ROLLBACK_ID`. Require exact equality
between the fresh ID and active exported ID. Any collision, create error, metadata drift, or
equality failure stops and forbids retry with that ID.
- [x] **Step 2: Re-establish per-ID and global gates**
For the exact active new ID, run the Blackbox private-edge proof exactly once. Only the operator
enters exact confirmation `PROVE BLACKBOX PRIVATE EDGE default`. Require exact
`BLACKBOX PRIVATE EDGE SOURCE PASS`, immediate RC `0`, and normalized proof metadata
`regular|0:0|600|1` (`regular file|0:0|600|1` from the exact `stat` fields). Any missing,
ambiguous, or nonzero result stops the transaction, preserves the new ID, and forbids validator
or Task 6 retry under that ID. Then require encryption RC `0`, restore RC `0`, Slack deployment
gate `RISK_ACCEPTED`, Secret name-only presence, API `readyz`, and rollback-root metadata.
Residue is a delta contract. Preserve without reading/deleting the exact preexisting roots
`/tmp/platform-k3s-encryption.Mskzy3`,
`/tmp/platform-observability-access-apply.oeNcfI`,
`/tmp/platform-observability-access-apply.Im02dz`, and
`/tmp/platform-observability-slack-gate.LYhYbv`. The attested classifications are respectively
8/1 empty evidence, two 8/12 recorded evidence roots, and today's failed-live evidence containing
only two private filenames. Require the name-only baseline unchanged, matching executable
processes `0`, and current preflight/live newly-created matching-root delta `0`. Unknown/new
residue stops for identity review and is never broadly deleted. Do not read Secret data, proof
contents, or these evidence-root contents.
- [x] **Step 3: Create a fresh handoff and revalidate source authority**
Run the strict absolute three-argument source-manifest verifier with the newly reviewed verifier
and manifest hashes. Preserve `/tmp/platform-observability-metrics.LNzksC` read-only and never use
it as source, destination, renderer input, or apply input. From canonical preserved source
`/tmp/platform-observability-metrics.VUpsZn`, create a new mode `0700`
`/tmp/platform-observability-metrics.XXXXXX` root, copy the two phase inventory pairs with
`cp --no-dereference --reflink=never`, and set files mode `0600`. Before rendering, require the
new destination to contain exactly the two phase directories and no other entry; the renderer
then adds only the reviewed manifest set. Recheck source/destination
canonical path, owner/mode/nlink, exact two inventory pins/counts `21/30`, exact entry set and
rendered file set, and source identity unchanged. Bind the resulting exact path to `$METRIC_ROOT`.
For `LNzksC`, preserve and compare only the already-attested path/fingerprint identity; do not
reopen inventory bodies or private file content. Any copy or identity gate failure stops the
transaction without using either handoff for apply.
- [x] **Step 4: Execute exactly once**
Send this as one unsplit line to the verified idle tmux pane:
```bash
PLATFORM_HELM_BIN=/home/donghyeon/.local/bin/helm bash scripts/bootstrap/apply-observability-access.sh --execute --rules-alerts --verified-output-dir "$METRIC_ROOT"
```
The operator types exactly `APPLY` at the prompt. Immediately after return, run `echo "TASK6_APPLY_RC=$?"`. Any nonzero RC, missing PASS, response loss, or rollback ambiguity stops and preserves the new ID without retry.
- [x] **Step 5: Validate terminal acceptance payload-free**
Require:
```text
AlertmanagerConfig platform-alertmanager = 1
platform PrometheusRule exact set = 4
dashboard ConfigMap exact set = 5
receiver list = [observability/platform-alertmanager/platform-slack]
desired rules evaluation health = ok
Grafana / Blackbox / target readiness unchanged
acceptance schema = platform-observability-rules-alerts-v2
acceptance slack_deployment_gate = RISK_ACCEPTED
```
Verify acceptance and ledger metadata only; never read the Slack URL or Secret data.
Terminal evidence: rollback ID `20260814T145009Z`, fresh handoff
`/tmp/platform-observability-metrics.dw5gLZ`, exact six-element argv attestation, inventory
pins/counts `21/30`, apply RC `0`, exact terminal PASS, dashboard `5`, platform rules `4`, desired
rule health `23/23`, runbook URL `22/22`, AlertmanagerConfig `1`, receiver exact qualified
singleton, target `30/30`, Ready workloads, v2 acceptance with `RISK_ACCEPTED`, ledger
object/mutation lines `13/13`, and residue delta `0`. Success did not invoke rollback.
- [x] **Step 6: Terminal documentation and final manifest**
Append the new rollback ID, RC, exact resource counts, inventory pins/counts, qualified receiver identity, acceptance schema, and residue result. Check only genuinely completed Task 6 steps. Regenerate the supported final manifest/sidecar from terminal bytes and run the strict verifier. Task 7 may begin only after final independent review returns Ready YES.
Post-review closeout: final manifest SHA-256
`52c2230f23d0cd7733c2ae685737e7c93d837182b01a4c25ff755e780df308f3`, sidecar file SHA-256
`655d5b1efc2a6ce04715f11fdc646db392ef3951b24b5fe5f2b21c9703c15ee3`, exact rows/unique paths
`15/15`, strict literal absolute three-argument verifier RC `0` twice. Independent terminal review
returned Critical/Important/Minor `0/0/1`, Spec `PASS`, Quality `Approved`, Task 6 complete `YES`,
and Task 7 start `YES`. The historical-numbering Minor is deferred to Task 8. Task 7 has not been
executed by this closeout.
@@ -0,0 +1,373 @@
# Structured Slack Observability Notifications Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace Alertmanager's terse default Slack attachment with a structured, severity-aware message and route `InfoInhibitor` to a null receiver while preserving the existing webhook, transactional apply, and rollback boundaries.
**Architecture:** Keep formatting inside `AlertmanagerConfig/platform-alertmanager`; do not add a formatter service. Strengthen the existing source, core-render, access-render, and apply assertions so the same exact configuration is enforced at every boundary. Runtime acceptance changes from one qualified receiver to the exact qualified pair for Slack and null.
**Tech Stack:** Kubernetes `AlertmanagerConfig` v1alpha1, Prometheus Alertmanager 0.33.1 Go templates, Bash, embedded Python/PyYAML, existing renderer and rules-alerts transaction.
## Global Constraints
- Approved design: `/home/donghyeon/workspace/docs/platform/plans/2026-08-15-observability-structured-slack-notifications-design.md`.
- The Slack Secret selector remains exactly `alertmanager-slack-webhook` / `url`; never read, print, copy, decode, or persist its payload.
- Do not set a Slack `channel`, automatic mention, webhook literal, or new credential source.
- Route grouping is the exact set `cluster`, `namespace`, `alertname`, `severity`; timing remains `30s`, `5m`, `4h`.
- Source receivers are exactly `platform-null` and `platform-slack`; `platform-null` has no integrations.
- Runtime receivers are the order-independent exact set `observability/platform-alertmanager/platform-null` and `observability/platform-alertmanager/platform-slack`.
- `InfoInhibitor` routes to `platform-null`; its inhibition rule targets `severity=info` with equality on `namespace`. Preserve the upstream conditional semantics rather than claiming all info alerts are always dropped.
- Colors are resolved=`good`, emergency/critical=`danger`, warning=`warning`, and info/unknown=`#439FE0`.
- Target text uses all labels after removing group-label names, sorted by label name; missing values use explicit fallbacks.
- No alert expression, threshold, duration, NetworkPolicy, dashboard JSON, retention setting, or Slack destination changes.
- No new formatter workload or generic automation script.
- This is a non-Git workspace. Record starting/final SHA-256 and metadata in task reports instead of creating commits or worktrees.
- Production changes require a real failing test first. Run each expensive focused suite at most once for RED and once for final GREEN unless a genuine failure requires a fix.
- Do not execute live apply until source review is clean. Direct `kubectl apply` is forbidden; use the existing rules-alerts transaction exactly once with a fresh rollback ID and fresh verified handoff.
## Exact AlertmanagerConfig Contract
The canonical source and every exact oracle use this `spec` value. YAML block scalars preserve the template text exactly.
```yaml
spec:
route:
receiver: platform-slack
groupBy:
- cluster
- namespace
- alertname
- severity
groupWait: 30s
groupInterval: 5m
repeatInterval: 4h
routes:
- receiver: platform-null
matchers:
- name: alertname
matchType: "="
value: InfoInhibitor
inhibitRules:
- sourceMatch:
- name: alertname
matchType: "="
value: InfoInhibitor
targetMatch:
- name: severity
matchType: "="
value: info
equal:
- namespace
receivers:
- name: platform-null
- name: platform-slack
slackConfigs:
- apiURL:
name: alertmanager-slack-webhook
key: url
sendResolved: true
linkNames: false
mrkdwnIn:
- text
- fields
fallback: >-
{{ if eq .Status "firing" }}FIRING{{ else }}RESOLVED{{ end }}: {{ if .CommonLabels.severity }}{{ .CommonLabels.severity | toUpper }}{{ else }}UNKNOWN{{ end }} · {{ .CommonLabels.alertname }}
title: >-
{{ if eq .Status "firing" }}[FIRING:{{ .Alerts.Firing | len }}]{{ else }}[RESOLVED]{{ end }} {{ if .CommonLabels.severity }}{{ .CommonLabels.severity | toUpper }}{{ else }}UNKNOWN{{ end }} · {{ .CommonLabels.alertname }}
titleLink: https://grafana.learn.hyeonworks.com/
color: >-
{{ if eq .Status "resolved" }}good{{ else if or (eq .CommonLabels.severity "emergency") (eq .CommonLabels.severity "critical") }}danger{{ else if eq .CommonLabels.severity "warning" }}warning{{ else }}#439FE0{{ end }}
fields:
- title: Status
value: '{{ .Status | toUpper }}'
short: true
- title: Severity
value: '{{ if .CommonLabels.severity }}{{ .CommonLabels.severity | toUpper }}{{ else }}UNKNOWN{{ end }}'
short: true
- title: Location
value: '{{ if .CommonLabels.cluster }}{{ .CommonLabels.cluster }}{{ else }}unknown-cluster{{ end }} / {{ if .CommonLabels.namespace }}{{ .CommonLabels.namespace }}{{ else }}cluster-scoped{{ end }}'
short: true
- title: Alert count
value: '{{ len .Alerts }}'
short: true
text: |-
{{ range .Alerts }}
*Alert status:* {{ .Status | toUpper }}
*Target:* {{ $target := .Labels.Remove $.GroupLabels.Names }}{{ if $target }}{{ range $target.SortedPairs }}{{ .Name }}={{ .Value }} {{ end }}{{ else }}unknown{{ end }}
*Summary:* {{ with .Annotations.summary }}{{ . }}{{ else }}No summary provided{{ end }}
*Details:* {{ with .Annotations.description }}{{ . }}{{ else }}No description provided{{ end }}
*Started:* {{ .StartsAt.Format "2006-01-02T15:04:05Z07:00" }}
{{ if eq .Status "resolved" }}*Ended:* {{ .EndsAt.Format "2006-01-02T15:04:05Z07:00" }}{{ end }}
{{ with .Annotations.runbook_url }}*Runbook:* <{{ . }}|대응 절차 열기>{{ end }}
{{ end }}
<https://grafana.learn.hyeonworks.com/|Grafana> · <https://grafana.learn.hyeonworks.com/explore|Explore>
footer: hyeonworks observability · Alertmanager
```
---
### Task 1: Source and Core Renderer Exact Contract
**Files:**
- Modify: `scripts/validate/test-observability-alerting.sh`
- Modify: `scripts/validate/test-render-observability-core.sh`
- Modify: `services/observability/alerting/platform-alertmanager.yaml`
- Modify: `scripts/validate/render-observability-core.sh`
- Create: `.superpowers/sdd/2026-08-15-observability-structured-slack-notifications/task-1-report.md`
**Interfaces:**
- Consumes: the exact AlertmanagerConfig contract above.
- Produces: source YAML and core renderer assertion that accept only that contract; later tasks rely on `assert_alertmanager_routing_contract` as the canonical oracle.
- [ ] **Step 1: Record the starting identities**
Record SHA-256, mode, owner, nlink, and size for the four modified files in the task report. Starting SHA-256 values must be:
```text
platform-alertmanager.yaml 76db5e6dd3653bc8afb9ecffe3472fc57eb07c5a2b33d218f3f4a41fb202caa2
test-observability-alerting.sh c5c578435cfeaac0d4221dd335797e09aaa010a9f63c397d21936a252e461db2
render-observability-core.sh 14460b985489ca59ba2377a61300a187d84fe795c8307fe2ba32bee9674d4bb2
test-render-observability-core.sh 9dd935e403968bf063ec0ddd44cd32ba853fe9f121a2764fbb01206d3c561d35
```
- [ ] **Step 2: Write the failing source and core tests**
In `test-observability-alerting.sh`, replace the old exact `alert` oracle with the exact contract above, retaining exact metadata and API identity. In `test-render-observability-core.sh`, update the valid Alertmanager fixture to the exact contract and add independently hand-authored negative fixtures for:
```text
missing severity from groupBy
missing title
color that maps critical to warning
InfoInhibitor child route pointing to platform-slack
missing inhibit rule
platform-null containing a Slack integration
channel override
text containing @channel
extra receiver
```
Each negative must exercise `assert_alertmanager_routing_contract` and expect rejection.
- [ ] **Step 3: Run RED against unchanged production**
Run:
```bash
bash -n scripts/validate/test-observability-alerting.sh
bash -n scripts/validate/test-render-observability-core.sh
bash scripts/validate/test-observability-alerting.sh
bash scripts/validate/test-render-observability-core.sh
```
Expected: both syntax checks return 0; at least one behavioral command returns nonzero because production still has the old three-key group and terse singleton receiver. Capture the exact first failing leaf. A syntax or fixture-construction error is not an acceptable RED.
- [ ] **Step 4: Implement the minimal source and canonical oracle**
Use `apply_patch`. Replace only the `spec` in `platform-alertmanager.yaml` with the exact contract above. Replace `render-observability-core.sh` mode `alertmanager_routing`'s `expected_config` with the exact full object, including receiver order, every template string, child route, and inhibit rule. Do not change the NetworkPolicy oracle or Alertmanager global-reference checks.
- [ ] **Step 5: Run GREEN and mutation checks**
Run both commands from Step 3 again. Expected exact terminal markers:
```text
OBSERVABILITY ALERTING STATIC CONTRACT PASS
OBSERVABILITY ALERTMANAGER GLOBAL RENDER CONTRACT PASS
OBSERVABILITY CORE STATIC RENDER PASS
```
Confirm every negative fixture returns nonzero while the canonical fixture returns zero. Record assertion counts and final identities in `task-1-report.md`.
### Task 2: Access Renderer and Transaction Runtime Contract
**Files:**
- Modify: `scripts/validate/test-render-observability-access.sh`
- Modify: `scripts/validate/render-observability-access.sh`
- Modify: `scripts/validate/test-apply-observability-access.sh`
- Modify: `scripts/bootstrap/apply-observability-access.sh`
- Create: `.superpowers/sdd/2026-08-15-observability-structured-slack-notifications/task-2-report.md`
**Interfaces:**
- Consumes: Task 1's `assert_alertmanager_routing_contract` and exact source configuration.
- Produces: an access handoff that rejects altered templates/routes and a transaction postcheck that accepts only the qualified receiver pair.
- [ ] **Step 1: Record starting identities**
Starting SHA-256 values must be:
```text
render-observability-access.sh ae5373b1b20be42e573d58e7384dcc654b1d3cac75a6c269cf684f4a14f3ca1a
test-render-observability-access.sh 5dfdc52b9f72b6d2e754893e799f5baf27c2b87e701958452dc170627ff21435
apply-observability-access.sh f797c3c678e45adb08c407f9a55b6da10fa49d93038cd0b301f589555b11263c
test-apply-observability-access.sh dd78ef4601c89652b9270a7fcb257232a9f54431f65b96669062cc0efa920e4f
```
- [ ] **Step 2: Write access RED cases**
Update the valid fixtures to the exact Task 1 contract. In `test-render-observability-access.sh`, add mutations that remove `title`, change `groupBy`, route `InfoInhibitor` to Slack, add `channel`, and add an extra receiver. Each must fail at the published handoff assertion with no partial output mutation.
Run:
```bash
bash -n scripts/validate/test-render-observability-access.sh
bash scripts/validate/test-render-observability-access.sh
```
Expected: syntax 0 and behavioral nonzero against the current loose `rules-alerts` access assertion; capture the leaf showing an invalid structured contract was accepted.
- [ ] **Step 3: Write apply RED cases**
Update the fake `/api/v2/receivers` default to the qualified pair. Define literal cases:
```python
qualified_pair = [
{"name": "observability/platform-alertmanager/platform-null"},
{"name": "observability/platform-alertmanager/platform-slack"},
]
```
Add one accepted reversed-order case and rejected cases for old Slack singleton, null singleton, empty list, raw pair, duplicate member, extra receiver, malformed JSON, and wrong namespace/config/local name for either member. Add a structured-handoff mutation that must fail before prompt, ledger creation, dry-run, or apply.
Run the smallest named positive case or the bounded focused suite until the new qualified pair reaches the old singleton predicate. Expected RED is nonzero at the receiver postcheck, not a fixture or syntax error.
- [ ] **Step 4: Strengthen production access and apply checks**
In `render-observability-access.sh`, replace the one-line shell wrapper with:
```bash
assert_access_rules_alerts_contract() {
(( $# == 3 )) || return 1
_access_assert rules-alerts "$@" || return 1
assert_alertmanager_routing_contract "$3"
}
```
Keep the existing dashboard, rule, runbook, scope, and NetworkPolicy checks.
In the apply postcheck Python, replace singleton logic with:
```python
expected_receivers = {
"observability/platform-alertmanager/platform-null",
"observability/platform-alertmanager/platform-slack",
}
if (
not isinstance(receiver_payload, list)
or len(receiver_payload) != 2
or any(not isinstance(item, dict) or set(item) != {"name"} for item in receiver_payload)
or {item["name"] for item in receiver_payload} != expected_receivers
):
raise SystemExit(1)
```
Do not change rollback, ledger, resource rows, Slack gate, Secret name/key validation, or acceptance schema.
- [ ] **Step 5: Run final focused GREEN once**
Run syntax for all four files, then:
```bash
bash scripts/validate/test-render-observability-access.sh
timeout 330s bash scripts/validate/test-apply-observability-access.sh
```
Expected: access terminal PASS; apply terminal `APPLY OBSERVABILITY ACCESS TEST PASS`, the documented assertion count, suite wall at most 300 seconds, outer RC0, and relevant orphan count 0. Verify every receiver mutation fails at its intended leaf. Record final identities and evidence in `task-2-report.md`.
### Task 3: Integrated Schema, Template, and Independent Source Review
**Files:**
- Modify only if the RED demands it: `scripts/validate/test-observability-alerting.sh`
- Create: `.superpowers/sdd/2026-08-15-observability-structured-slack-notifications/task-3-report.md`
**Interfaces:**
- Consumes: Tasks 1-2 frozen candidate bytes.
- Produces: pre-live Ready/Not Ready verdict and exact candidate identities.
- [ ] **Step 1: Run complete syntax and source-focused matrix**
Run Bash syntax on all eight changed scripts. Run `test-observability-alerting.sh` without live mode once, then the already-green core/access/apply commands only if their final Task reports do not contain unambiguous RC0 evidence for the same hashes. Do not rerun an expensive suite merely to duplicate evidence.
- [ ] **Step 2: Validate CRD admission without mutation**
Run:
```bash
PLATFORM_KUBECTL_BIN=/usr/local/bin/kubectl \
bash scripts/validate/test-observability-alerting.sh --server-dry-run
```
Expected: both server-side dry runs and all static markers pass. This is schema evidence only.
- [ ] **Step 3: Render real templates with pinned Alertmanager 0.33.1**
Extract `fallback`, `title`, `color`, all field values, and `text` from the candidate YAML without exposing any Secret value. Use `/bin/amtool` in the existing Alertmanager 0.33.1 container with `amtool template render`; pass a hand-authored JSON fixture through `/dev/stdin`, not a persistent pod file. The fixture must cover:
```text
warning firing with cluster and namespace
resolved alert with EndsAt
missing cluster, namespace, summary, description, and runbook
two alerts with different non-group labels
```
Assert literal results include the correct severity color, `unknown-cluster`, `cluster-scoped`, sorted target labels, both fallbacks, FIRING count, RESOLVED title, runbook when present, Grafana links, and footer. No Secret or webhook URL enters the fixture or output.
- [ ] **Step 4: Perform independent read-only review**
The reviewer reads the design, plan, task reports, final changed files, and current CRD. Required verdict fields are Spec PASS/FAIL, Critical/Important/Minor counts, and Ready for one transactional live apply YES/NO. Any Critical or Important finding must be fixed with a new failing test and re-reviewed before Task 4.
- [ ] **Step 5: Freeze pre-live identities**
Record SHA-256 and metadata for all eight source/test files plus the design, plan, and Task reports. Confirm no webhook literal, decoded Secret, `channel`, `@channel`, or `@here` occurs in governed source. Record process/temp residue and mark Task 3 Ready only when all checks pass.
### Task 4: Transactional Rollout, Synthetic Delivery, and Documentation
**Files:**
- Modify after observed results: `/home/donghyeon/workspace/docs/platform/observability/03-alerting-and-slack.md`
- Modify after observed results: `/home/donghyeon/workspace/docs/platform/observability/07-build-history-and-decisions.md`
- Create: `.superpowers/sdd/2026-08-15-observability-structured-slack-notifications/task-4-report.md`
**Interfaces:**
- Consumes: Task 3 Ready verdict and frozen candidate identities.
- Produces: live structured Slack configuration, observed FIRING/RESOLVED acceptance, cleanup evidence, and final operator documentation.
- [ ] **Step 1: Prepare one fresh transaction**
Use a fresh UTC rollback ID unequal to every previous ID and create its root once as root-owned mode 0700. Preserve all prior rollback roots. Generate one fresh private handoff from the authoritative immutable inventory pair:
```text
target-initial 79688d017d38eec9a6f100f8d0f784a5474e79802046ef1c2c11b30d170b0b0c 21
post-substrate b1c3049206a1a88165ee672ae9aceac7945673a3bb9c3cf3670b7f0d56c3f291 30
```
Render rules-alerts into the fresh root and verify exact source hashes, metadata, topology, and new structured AlertmanagerConfig. Run the no-argument dry mode once. Do not reuse a prior handoff or rollback ID.
- [ ] **Step 2: Execute the existing transaction once**
Build a six-token Bash array to avoid paste splitting:
```bash
TASK4_CMD=(bash)
TASK4_CMD+=(scripts/bootstrap/apply-observability-access.sh)
TASK4_CMD+=(--execute)
TASK4_CMD+=(--rules-alerts)
TASK4_CMD+=(--verified-output-dir)
TASK4_CMD+=("$METRIC_ROOT")
```
Export the fresh `PLATFORM_OBSERVABILITY_ROLLBACK_ID` and exact `PLATFORM_HELM_BIN=/home/donghyeon/.local/bin/helm`. Invoke `"${TASK4_CMD[@]}"` once, enter exact `APPLY`, and make the immediate next statement `TASK4_APPLY_RC=$?`. Require RC0, exact transaction PASS, both inventory SHA markers, no rollback ambiguity, and accepted v2 ledger. A nonzero or ambiguous result means preserve evidence and stop; never retry the same ID.
- [ ] **Step 3: Verify runtime reconciliation**
Require Alertmanager CR Available/Reconciled, StatefulSet ready, `alertmanager_config_last_reload_successful == 1`, no relevant operator/config-reloader error, production rules healthy, and the exact qualified receiver pair. AlertmanagerConfig itself has no usable status condition and must not be reported as Available/Reconciled.
- [ ] **Step 4: Verify one warning FIRING and RESOLVED message**
Create one uniquely named, temporary warning `PrometheusRule` with the required `observability.hyeonworks.com/instance=home` selector label, literal summary/description/runbook, and a unique target label. Wait until Prometheus and Alertmanager each show exactly one matching firing alert. The user verifies that Slack contains the agreed title, severity, fields, target labels, summary, details, start time, runbook, Grafana links, and footer.
Delete the temporary rule once. Wait for the matching RESOLVED message and have the user verify its resolved title, green color, end time, and same context. The synthetic rule name and labels must never collide with production rules.
- [ ] **Step 5: Clean up and document only observed facts**
Require synthetic resource absent, matching pending/firing/active alerts zero, production rules healthy, receiver pair exact, and no mutator process or task-created temp residue. Confirm `InfoInhibitor` is not delivered to Slack. Document the conditional upstream info-inhibition semantics; do not claim every info alert is always suppressed.
Update chapter 03 with the exact source contract and observed FIRING/RESOLVED results. Update chapter 07 with the original terse-message problem, the decision to keep formatting in Alertmanager, and the final transaction result. Record final file identities and any limitation, including long grouped Slack text, in `task-4-report.md`.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,56 @@
# AIStor Operator
상태(2026-07-23): 실제 클러스터 적용 완료. ObjectStore Operator, AdminJob
Operator와 admission webhook이 모두 Ready `1/1`이며 단일 ObjectStore를
`Initialized/green`으로 관리한다.
이 디렉터리는 AIStor Operator의 유일한 Kustomize 빌드 루트다. 업스트림 템플릿을
복제하거나 수정하지 않고 공식 Chart를 렌더링한다. 홈 클러스터용 재정의 값은
`values/home.yaml`에 둔다.
## 고정된 원본
| 항목 | 값 |
| --- | --- |
| Chart 저장소 | `https://helm.min.io/` |
| Chart | `aistor-operator` |
| Chart 버전 | `5.10.0` |
| 애플리케이션 버전 | `v20260721224148.0.0` |
| Chart 패키지 SHA-256 | `e5534f5ae4f6f12a3528a8cda5954d73280cba1a8e979e1628fbf3aac76babd1` |
Chart 캐시는 Git에서 제외된 `.helm/charts/` 아래에 작성된다. 모든 입력 파일이 이
빌드 루트 아래에 있으므로 Kustomize의 기본 `RootOnly` 로드 제한으로 충분하다.
## 라이선스 계약
Chart 값 `license`는 빈 문자열로 유지해야 한다. 이 빌드 루트를 적용하기 전에
`aistor` namespace에 다음 키를 가진 `Secret/minio-license`를 생성한다.
| Secret | 유형 | 필수 키 |
| --- | --- | --- |
| `aistor/minio-license` | `Opaque` | `minio.license` |
라이선스 본문을 Git, values 파일, Shell 기록 또는 렌더링 산출물에 절대 남기지
않는다. 이 값을 비워 두면 Chart는 미리 생성한 Secret을 참조하지만 라이선스
Secret 자체를 렌더링하지 않는다.
Operator, admin-job Operator와 admission webhook은 각각
`donghyeon-system-product-name`에서 1 replica로 시작한다. 더 이상 권장되지 않는
AIHub, Prompt 및 Warp는 비활성화한다. TokenReview는 Chart의 표준 Kubernetes 인증
방식이므로 활성 상태를 유지한다.
Helm으로 직접 설치하지 않고 Kustomize 렌더와 보호 스크립트로만 적용한다.
```sh
kubectl kustomize --enable-helm infrastructure/controllers/aistor-operator
```
공식 Chart가 webhook `caBundle`을 렌더하지만 실행 중인 Operator가 이 값을
주입·회전한다. 재적용 때 server-side apply 소유권 충돌이 발생하지 않도록
`kustomization.yaml`에서 초기 빈 `caBundle` 필드를 제거한다. 실제 적용과
readiness 재검증은 `scripts/bootstrap/apply-aistor.sh --execute`가 수행한다.
## 공식 참고 문서
- <https://docs.min.io/aistor/reference/kubernetes/object-store-operator-helm-chart/>
- <https://docs.min.io/aistor/installation/kubernetes/install/deploy-aistor-on-kubernetes/>
@@ -0,0 +1,5 @@
# 기본 확장 지점
업스트림 리소스는 컨트롤러 디렉터리 루트에 선언된 Helm Chart에서 가져온다.
Chart와 무관하고 환경에 종속되지 않는 보조 리소스가 필요할 때만 이곳에 추가하며,
Chart 템플릿을 저장소에 복제하지 않는다.
@@ -0,0 +1,29 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: aistor
helmGlobals:
chartHome: .helm/charts
helmCharts:
- name: aistor-operator
repo: https://helm.min.io/
version: 5.10.0
releaseName: aistor
namespace: aistor
valuesFile: values/home.yaml
includeCRDs: true
skipTests: true
# The Operator injects and rotates this runtime CA. Do not let the Helm render
# claim field ownership with an empty value, which would break idempotent SSA.
patches:
- target:
group: admissionregistration.k8s.io
version: v1
kind: MutatingWebhookConfiguration
name: object-store-operator-webhook
patch: |-
- op: remove
path: /webhooks/0/clientConfig/caBundle
@@ -0,0 +1,4 @@
# 홈 환경 Overlay 확장 지점
홈 클러스터 Chart 설정은 `../../values/home.yaml`에 둔다. 업스트림 Chart 값으로
표현할 수 없는 Kustomize Patch만 이 디렉터리에 둔다.
@@ -0,0 +1,37 @@
# The official chart always looks for Secret/aistor/minio-license and mounts the
# minio.license key. Keep this empty so Helm never renders the license into Git.
license: ""
global:
fipsMode: false
clusterDomain: cluster.local
operator:
replicas: 1
nodeSelector:
kubernetes.io/hostname: donghyeon-system-product-name
resources:
requests:
cpu: 100m
memory: 128Mi
ephemeral-storage: 100Mi
limits:
cpu: 500m
memory: 512Mi
ephemeral-storage: 1Gi
operators:
adminjob:
disabled: false
aihub:
disabled: true
object-store:
disabled: false
replicas: 1
tokenValidation: TokenReview
webhook:
enabled: true
replicas: 1
prompt:
disabled: true
warp:
disabled: true
@@ -0,0 +1,32 @@
# CloudNativePG Operator
이 디렉터리는 Operator의 유일한 Kustomize 빌드 루트다. 공식
`cloudnative-pg` Helm Chart를 복제하거나 수정하지 않고 렌더링한다. 홈 클러스터
변경 사항은 `values/home.yaml`에 둔다.
## 고정된 원본
- Chart 저장소: <https://cloudnative-pg.github.io/charts>
- Chart 버전: `0.29.0`
- Operator 애플리케이션 버전: `1.30.0`
- 공식 저장소 인덱스에 있는 Chart 패키지 SHA-256:
`668e065ff53508d58238788fd35b355a925060843629a951df0e6a9362e6d32f`
Operator는 전체 클러스터를 감시하지만, 자체 namespace 리소스는 `cnpg-system`에서
실행된다. CRD와 클러스터 RBAC은 Chart가 설치한다. 모니터링 스택과 관련 CRD가
준비될 때까지 PodMonitor와 Grafana Dashboard 리소스는 비활성화한다.
저장소 공통 규칙에 따라 다음과 같이 렌더링한다.
```bash
kubectl kustomize --enable-helm infrastructure/controllers/cloudnative-pg
```
Kustomize는 Git에서 제외된 이 디렉터리의 `.helm/charts/` 아래에 Chart를 내려받는다.
values 파일도 빌드 루트 아래에 있으므로 기본 `RootOnly` 로드 제한으로 충분하다.
`--load-restrictor=LoadRestrictionsNone`는 사용하지 않는다.
## 공식 참고 문서
- <https://cloudnative-pg.io/docs/1.30/installation_upgrade/>
- <https://github.com/cloudnative-pg/charts>
@@ -0,0 +1,5 @@
# 기본 확장 지점
업스트림 Operator는 컨트롤러 디렉터리 루트에 선언된 Helm Chart로 공급한다.
Chart와 무관하고 환경에 종속되지 않는 보조 리소스가 실제로 필요할 때만 이곳에
추가하며, Chart 템플릿을 저장소에 복제하지 않는다.
@@ -0,0 +1,17 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: cnpg-system
helmGlobals:
chartHome: .helm/charts
helmCharts:
- name: cloudnative-pg
repo: https://cloudnative-pg.github.io/charts
version: 0.29.0
releaseName: cloudnative-pg
namespace: cnpg-system
valuesFile: values/home.yaml
includeCRDs: true
skipTests: true
@@ -0,0 +1,5 @@
# 홈 환경 Overlay
현재 홈 클러스터용 Operator Overlay는 `../../values/home.yaml`의 Chart values
파일로 표현한다. Helm values와 내려받은 Chart가 Kustomize의 기본 로드 제한 범위
안에 있도록 컨트롤러 디렉터리를 빌드 루트로 유지한다.
@@ -0,0 +1,31 @@
# Home-cluster overrides for the official CloudNativePG operator chart.
# Chart: cloudnative-pg 0.29.0 / appVersion 1.30.0
replicaCount: 1
crds:
create: true
config:
create: true
secret: false
clusterWide: true
rbac:
create: true
aggregateClusterRoles: false
serviceAccount:
create: true
monitoring:
podMonitorEnabled: false
grafanaDashboard:
create: false
resources:
requests:
cpu: 50m
memory: 100Mi
limits:
cpu: 500m
memory: 256Mi
@@ -0,0 +1,35 @@
# Keycloak Operator
상태: `26.7.0` 실제 적용, Operator와 Keycloak Server 각각 `1/1` Ready
namespace는 `baseline` enforce·`restricted` warn이다. 공식 Operator 적용 시
restricted 필드 경고가 있었지만 baseline 정책에서 rollout은 성공했으므로 이를
restricted 준수 완료로 표현하지 않는다.
이 디렉터리는 Keycloak Operator의 유일한 Kustomize 빌드 루트다. 공식 Keycloak
Kubernetes 리소스를 직접 참조하며 업스트림 매니페스트를 복제하거나 수정하지
않는다.
## 고정된 원본
- 저장소: <https://github.com/keycloak/keycloak-k8s-resources>
- 리소스 경로: `kubernetes`
- Keycloak 및 Operator 버전: `26.7.0`
- 설치 모드: `keycloak` namespace 범위
업스트림 namespace 범위 Deployment는 각 컨트롤러의 감시 namespace를
`JOSDK_WATCH_CURRENT`로 설정한다. 이 단일 인스턴스 홈 클러스터에서는 프리뷰
상태인 `cluster-wide` Overlay로 전환하지 않는다.
이 디렉터리에서 다음과 같이 렌더링한다.
```bash
kubectl kustomize infrastructure/controllers/keycloak-operator
```
remote 참조가 Kustomize 캐시에 없으면 네트워크 접근이 필요하다. Argo CD가 고정된
GitHub 원본을 가져올 수 있어야 한다.
## 공식 참고 문서
- <https://www.keycloak.org/operator/installation>
- <https://github.com/keycloak/keycloak-k8s-resources/tree/26.7.0/kubernetes>
@@ -0,0 +1,5 @@
# 기본 확장 지점
공식 namespace 범위 Operator는 컨트롤러 디렉터리 루트의 Kustomization에서
참조한다. 환경에 종속되지 않는 보조 리소스만 이곳에 추가하며, 생성된 CRD,
RBAC 또는 Operator Deployment를 저장소에 복제하지 않는다.
@@ -0,0 +1,12 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: keycloak
resources:
- github.com/keycloak/keycloak-k8s-resources/kubernetes?ref=26.7.0
labels:
- pairs:
app.kubernetes.io/part-of: platform
includeSelectors: false
@@ -0,0 +1,5 @@
# 홈 환경 Overlay 확장 지점
Keycloak Operator 26.7.0에는 홈 환경 전용 Patch가 필요하지 않다. 고정한 업스트림
리소스를 로컬 Kustomize와 향후 Argo CD에서 일관되게 렌더링하도록 컨트롤러
디렉터리를 유일한 빌드 루트로 유지한다.
@@ -0,0 +1,5 @@
# Prometheus Operator CRDs
`prometheus-operator-crds` chart `31.0.0`만 독립적으로 렌더한다. CRD는
cluster-scoped API와 저장 객체의 스키마 경계이므로 workload 실패 시에도 자동 삭제하지
않는다. `kube-prometheus-stack` 쪽에서는 CRD 생성을 별도로 비활성화한다.
@@ -0,0 +1,15 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
helmGlobals:
chartHome: .helm/charts
helmCharts:
- name: prometheus-operator-crds
repo: https://prometheus-community.github.io/helm-charts
version: 31.0.0
releaseName: prometheus-operator-crds
namespace: observability
valuesFile: values/home.yaml
includeCRDs: true
skipTests: true
@@ -0,0 +1,22 @@
crds:
annotations: {}
alertmanagerconfigs:
enabled: true
alertmanagers:
enabled: true
podmonitors:
enabled: true
probes:
enabled: true
prometheusagents:
enabled: true
prometheuses:
enabled: true
prometheusrules:
enabled: true
scrapeconfigs:
enabled: true
servicemonitors:
enabled: true
thanosrulers:
enabled: true
@@ -0,0 +1,7 @@
# 관리 도구 네임스페이스
`platform-admin`은 pgAdmin처럼 운영자가 사용하는 비공개 관리 도구만
수용합니다. 애플리케이션, 데이터베이스, 모니터링 워크로드와 분리하며
Pod Security `restricted`를 강제합니다.
이 디렉터리에는 Secret을 두지 않습니다.
@@ -0,0 +1,5 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
@@ -0,0 +1,13 @@
apiVersion: v1
kind: Namespace
metadata:
name: platform-admin
labels:
app.kubernetes.io/part-of: platform
platform.hyeonworks.com/purpose: private-admin-tools
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: latest
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/audit-version: latest
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/warn-version: latest
+17
View File
@@ -0,0 +1,17 @@
# 플랫폼 네임스페이스
이 base는 첫 Gitea 배포에 필요한 namespace만 선언한다.
- `cnpg-system`: CloudNativePG Operator
- `platform-data`: 공통 플랫폼 PostgreSQL Cluster
- `gitea`: Gitea 애플리케이션
초기 구축 중 업스트림 Operator와 Helm Chart가 차단되지 않도록 Pod Security
Admission은 `baseline`을 강제한다. 더 엄격한 `restricted` 프로파일 위반은 감사
이벤트와 kubectl 경고를 통해 계속 표시한다. namespace에서 `restricted` 강제로
전환하기 전에는 렌더링된 매니페스트 검사와 런타임 Smoke Test를 먼저 통과해야
한다.
`latest` 정책 버전은 이 단일 노드 클러스터에 설치된 Kubernetes 버전을 따른다.
향후 클러스터 업그레이드와 정책 변경을 분리해서 진행해야 한다면 먼저 Kubernetes
minor 버전으로 고정한다.
@@ -0,0 +1,13 @@
apiVersion: v1
kind: Namespace
metadata:
name: cnpg-system
labels:
app.kubernetes.io/part-of: platform
platform.hyeonworks.com/purpose: database-controller
pod-security.kubernetes.io/enforce: baseline
pod-security.kubernetes.io/enforce-version: latest
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/audit-version: latest
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/warn-version: latest
+13
View File
@@ -0,0 +1,13 @@
apiVersion: v1
kind: Namespace
metadata:
name: gitea
labels:
app.kubernetes.io/part-of: platform
platform.hyeonworks.com/purpose: source-control
pod-security.kubernetes.io/enforce: baseline
pod-security.kubernetes.io/enforce-version: latest
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/audit-version: latest
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/warn-version: latest
@@ -0,0 +1,7 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- cnpg-system.yaml
- platform-data.yaml
- gitea.yaml
@@ -0,0 +1,13 @@
apiVersion: v1
kind: Namespace
metadata:
name: platform-data
labels:
app.kubernetes.io/part-of: platform
platform.hyeonworks.com/purpose: platform-databases
pod-security.kubernetes.io/enforce: baseline
pod-security.kubernetes.io/enforce-version: latest
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/audit-version: latest
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/warn-version: latest
@@ -0,0 +1,17 @@
# Observability namespaces
This kustomization owns exactly the `observability` and `observability-agent`
Namespace objects. Both opt into observability discovery through
`observability.hyeonworks.com/enabled: "true"`.
`observability` enforces, audits, and warns at `restricted:latest`.
`observability-agent` enforces `privileged:latest` so the narrowly scoped agent
workloads can use their approved read-only host mounts, while audit and warn
remain `restricted:latest`.
`discovery-namespaces.txt` is an inventory for the later apply workflow. The
five listed namespaces already exist and are deliberately not rendered here.
That workflow may own only the
`observability.hyeonworks.com/enabled` discovery label on those namespaces. It
must preserve their existing Pod Security and purpose labels, and rollback must
restore or remove only that exact discovery-label key.
@@ -0,0 +1,5 @@
kube-system
gitea
keycloak
platform-data
object-storage
@@ -0,0 +1,6 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- observability.yaml
- observability-agent.yaml
@@ -0,0 +1,12 @@
apiVersion: v1
kind: Namespace
metadata:
name: observability-agent
labels:
observability.hyeonworks.com/enabled: "true"
pod-security.kubernetes.io/enforce: privileged
pod-security.kubernetes.io/enforce-version: latest
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/audit-version: latest
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/warn-version: latest
@@ -0,0 +1,12 @@
apiVersion: v1
kind: Namespace
metadata:
name: observability
labels:
observability.hyeonworks.com/enabled: "true"
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: latest
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/audit-version: latest
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/warn-version: latest
@@ -0,0 +1,5 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
@@ -0,0 +1,22 @@
# Phase 2 네임스페이스
상태: `keycloak`만 실제 적용, `aistor`·`object-storage` 미적용
현재 AIStor NetworkPolicy gate가 남아 있으므로 디렉터리 전체를 apply하지 않고
Keycloak-only 스크립트가 `keycloak.yaml` 하나만 정확히 적용했다.
이 빌드 루트는 Phase 1 namespace 빌드 루트를 변경하지 않고 인증과 오브젝트
스토리지 단계에 추가되는 namespace를 선언한다.
- `keycloak`: Keycloak Operator와 단일 Keycloak 인스턴스
- `aistor`: AIStor Operator와 `minio-license` Secret 계약
- `object-storage`: standalone AIStor ObjectStore와 해당 PVC
Phase 2의 컨트롤러나 서비스를 적용하기 전에 이 디렉터리를 별도로 렌더링한다.
```sh
kubectl kustomize infrastructure/namespaces/phase2
```
이 namespace들은 Pod Security `baseline` 프로파일을 강제하고 `restricted`
위반을 감사하고 경고한다. `restricted` 강제로 전환하기 전에는 고정된 업스트림
Operator의 렌더링과 런타임 시험을 먼저 통과해야 한다.
@@ -0,0 +1,13 @@
apiVersion: v1
kind: Namespace
metadata:
name: aistor
labels:
app.kubernetes.io/part-of: platform
platform.hyeonworks.com/purpose: object-storage-control-plane
pod-security.kubernetes.io/enforce: baseline
pod-security.kubernetes.io/enforce-version: latest
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/audit-version: latest
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/warn-version: latest
@@ -0,0 +1,13 @@
apiVersion: v1
kind: Namespace
metadata:
name: keycloak
labels:
app.kubernetes.io/part-of: platform
platform.hyeonworks.com/purpose: identity
pod-security.kubernetes.io/enforce: baseline
pod-security.kubernetes.io/enforce-version: latest
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/audit-version: latest
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/warn-version: latest
@@ -0,0 +1,7 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- keycloak.yaml
- aistor.yaml
- object-storage.yaml
@@ -0,0 +1,13 @@
apiVersion: v1
kind: Namespace
metadata:
name: object-storage
labels:
app.kubernetes.io/part-of: platform
platform.hyeonworks.com/purpose: object-storage-data-plane
pod-security.kubernetes.io/enforce: baseline
pod-security.kubernetes.io/enforce-version: latest
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/audit-version: latest
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/warn-version: latest
@@ -0,0 +1,20 @@
# AIStor 네트워크 정책
이 디렉터리는 AIStor 제어 영역(`aistor`)과 데이터 영역(`object-storage`)의
NetworkPolicy를 한 곳에서 관리한다.
- 두 namespace의 모든 Pod는 기본적으로 ingress와 egress가 차단된다.
- DNS와 k3s Kubernetes API 접근만 명시적으로 허용한다.
- API server에서 admission webhook `8443/TCP`로 들어오는 트래픽을 허용한다.
- ObjectStore Pod와 Operator 사이에는 관리 포트만 허용한다.
- S3 `9000/TCP`와 HTTP Console `9090/TCP``object-storage` namespace 내부
클라이언트만 사용할 수 있다.
- Operator와 sidecar 관리에는 `4221/TCP`, `4444/TCP`만 허용한다.
- 외부 인터넷, Traefik, NodePort, LoadBalancer, Host Nginx 경로는 열지 않는다.
`learning-project`가 S3를 사용하게 될 때에는 이 파일을 넓히지 않고, 소비
namespace와 ServiceAccount 또는 Pod label을 고정한 별도 정책을 추가한다.
```bash
kubectl kustomize infrastructure/networking/aistor
```
@@ -0,0 +1,5 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- network-policies.yaml
@@ -0,0 +1,313 @@
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: aistor-default-deny
namespace: aistor
labels:
app.kubernetes.io/part-of: platform
app.kubernetes.io/component: object-storage-control-plane
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: aistor-allow-dns
namespace: aistor
labels:
app.kubernetes.io/part-of: platform
app.kubernetes.io/component: object-storage-control-plane
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: aistor-allow-kubernetes-api
namespace: aistor
labels:
app.kubernetes.io/part-of: platform
app.kubernetes.io/component: object-storage-control-plane
spec:
podSelector: {}
policyTypes:
- Egress
egress:
- to:
- ipBlock:
cidr: 10.43.0.1/32
ports:
- protocol: TCP
port: 443
- to:
- ipBlock:
cidr: 192.168.0.107/32
ports:
- protocol: TCP
port: 6443
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: aistor-allow-api-server-admission-webhook
namespace: aistor
labels:
app.kubernetes.io/part-of: platform
app.kubernetes.io/component: object-storage-control-plane
spec:
podSelector:
matchLabels:
aistor.min.io/name: object-store-webhook
policyTypes:
- Ingress
ingress:
- from:
- ipBlock:
cidr: 192.168.0.107/32
ports:
- protocol: TCP
port: 8443
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: aistor-allow-object-store-management
namespace: aistor
labels:
app.kubernetes.io/part-of: platform
app.kubernetes.io/component: object-storage-control-plane
spec:
podSelector:
matchExpressions:
- key: aistor.min.io/name
operator: In
values:
- adminjob-operator
- object-store-operator
policyTypes:
- Ingress
- Egress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: object-storage
podSelector:
matchLabels:
aistor.min.io/objectStore: minio-aistor
ports:
- protocol: TCP
port: 4221
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: object-storage
podSelector:
matchLabels:
aistor.min.io/objectStore: minio-aistor
ports:
- protocol: TCP
port: 9000
- protocol: TCP
port: 4444
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: object-storage-allow-labeled-clients
namespace: object-storage
labels:
app.kubernetes.io/part-of: platform
app.kubernetes.io/component: object-storage
spec:
podSelector:
matchLabels:
platform.hyeonworks.com/aistor-client: "true"
policyTypes:
- Egress
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: object-storage
podSelector:
matchLabels:
aistor.min.io/objectStore: minio-aistor
ports:
- protocol: TCP
port: 80
- protocol: TCP
port: 9000
- protocol: TCP
port: 9090
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: object-storage-default-deny
namespace: object-storage
labels:
app.kubernetes.io/part-of: platform
app.kubernetes.io/component: object-storage
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: object-storage-allow-dns
namespace: object-storage
labels:
app.kubernetes.io/part-of: platform
app.kubernetes.io/component: object-storage
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: object-storage-allow-kubernetes-api
namespace: object-storage
labels:
app.kubernetes.io/part-of: platform
app.kubernetes.io/component: object-storage
spec:
podSelector: {}
policyTypes:
- Egress
egress:
- to:
- ipBlock:
cidr: 10.43.0.1/32
ports:
- protocol: TCP
port: 443
- to:
- ipBlock:
cidr: 192.168.0.107/32
ports:
- protocol: TCP
port: 6443
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: object-storage-allow-internal-clients
namespace: object-storage
labels:
app.kubernetes.io/part-of: platform
app.kubernetes.io/component: object-storage
spec:
podSelector:
matchLabels:
aistor.min.io/objectStore: minio-aistor
policyTypes:
- Ingress
- Egress
ingress:
- from:
- podSelector:
matchLabels:
aistor.min.io/objectStore: minio-aistor
- podSelector:
matchLabels:
platform.hyeonworks.com/aistor-client: "true"
ports:
- protocol: TCP
port: 9000
- protocol: TCP
port: 9090
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: object-storage
podSelector:
matchLabels:
aistor.min.io/objectStore: minio-aistor
ports:
- protocol: TCP
port: 9000
- protocol: TCP
port: 9090
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: object-storage-allow-aistor-operator
namespace: object-storage
labels:
app.kubernetes.io/part-of: platform
app.kubernetes.io/component: object-storage
spec:
podSelector:
matchLabels:
aistor.min.io/objectStore: minio-aistor
policyTypes:
- Ingress
- Egress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: aistor
podSelector:
matchExpressions:
- key: aistor.min.io/name
operator: In
values:
- adminjob-operator
- object-store-operator
ports:
- protocol: TCP
port: 9000
- protocol: TCP
port: 4444
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: aistor
podSelector:
matchLabels:
aistor.min.io/name: object-store-operator
ports:
- protocol: TCP
port: 4221
@@ -0,0 +1,309 @@
# 호스트 Nginx 전환
이 디렉터리에는 호스트 수준 Nginx 신뢰 경계의 설정 원본이 있다. 이 파일은
Kubernetes 리소스가 아니며 Argo CD에서 조정하지 않는다.
2026-07-23 현재 활성 설정은 Gitea와 Keycloak을 함께 proxy하는
[learn-services-keycloak.conf](./learn-services-keycloak.conf)이며 SHA-256은
`5c5cd74b4992f537fd27c50cf2209573a80a9904e0b19b58c3154717be6ff4a5`다.
전환 전 설정은
`/etc/nginx/sites-available/learn-services.before-keycloak-20260723160519`
백업했다. Nginx proxy 상태는 두 번의 probe 뒤 안정화됐고 Gitea health, Keycloak
discovery issuer, HTTPS cookie·redirect와 미등록 hostname 거부 검사를 통과했다.
후속 Gitea OIDC·브랜딩 rollout도 manifest SHA-256 `d25a757...a157`로 완료했으며
OAuth source·정책·authorization-code redirect·브랜딩 자동 검증을 통과했다. 실제
realm 사용자의 브라우저 login/callback/logout, 비상 관리자 실제 로그인과 Pod
재시작 뒤 설정 지속성은 별도 수용 시험으로 남아 있다. 인증이 필요한 Git
clone/push/reclone 시험도 아직 남아 있다.
## 계약
- 공개 포트 `80``443`은 Host Nginx에서 종료한다.
- TLS는 Host Nginx에서만 종료한다.
- `git.learn.hyeonworks.com`은 HTTP를 통해 Traefik의 loopback NodePort인
`127.0.0.1:30080`으로 proxy한다.
- Host Nginx는 들어오는 `Host`, `X-Forwarded-*`, `X-Real-IP` 값을 교체한다. 특히
신뢰할 수 없는 클라이언트가 제공한 `X-Forwarded-For` chain을 이어 붙이지 않는다.
- `id.learn.hyeonworks.com`도 같은 loopback NodePort의 Keycloak Host route로
proxy한다.
- Traefik HTTPS NodePort인 `30443`은 이 경로에서 사용하지 않는다.
2026-07-23 현재 kube-proxy의 `nodePort-addresses=127.0.0.0/8` 설정을 적용했다.
서버의 loopback `127.0.0.1:30080`은 Traefik에 도달하지만, 서버와 같은 LAN의
노트북에서 `192.168.0.107:30080``30443` 연결은 모두 거부되는 것을 확인했다.
따라서 Host Nginx를 우회하는 LAN NodePort 경로는 현재 닫혀 있다.
현재 설정 원본은
[learn-services-keycloak.conf](./learn-services-keycloak.conf)다. 전환 전
[learn-services.conf](./learn-services.conf)는 Keycloak 정적 hold가 포함된
rollback 기준으로 보존한다. 등록되지 않은 TLS hostname이 첫 번째 virtual host인
Gitea로 흘러가지 않도록 별도의 `default_server``ssl_reject_handshake on`으로
handshake를 거부한다.
활성 Keycloak 설정은 같은 Gitea proxy를 보존하면서 `id.learn.hyeonworks.com`
`http://127.0.0.1:30080`의 Traefik Host route로 바꾼다. 이는 Keycloak을 모든
서비스 앞의 인증 middleware로 두는 구성이 아니다. Gitea가 Keycloak을 독립 OIDC
Provider로 사용하는 데 필요한 네트워크 reverse proxy다.
2026-07-23 확인한 인증서는 CN이 `git.learn.hyeonworks.com`이고 SAN에
`git.learn.hyeonworks.com`, `id.learn.hyeonworks.com`을 모두 포함한다. 발급자는
Let's Encrypt YE2, 유효기간은 2026-07-18부터 2026-10-16까지다. Snap Certbot
5.7.0과 `snap.certbot.renew.timer` 활성 상태도 확인했다. 이 값은 점검 시점의
스냅샷이므로 설정 또는 인증서가 변경될 때 다시 검사한다.
## 사전 조건
다음 검사를 모두 통과하기 전에는 Nginx를 전환하지 않는다.
1. Gitea 워크로드, Service 및 Ingress가 Ready 상태다.
2. Ingress는 `git.learn.hyeonworks.com`을 사용하며 Kubernetes TLS block이 없다.
3. 다음 Traefik 직접 probe가 Gitea health 응답을 반환한다.
```sh
curl --fail-with-body \
--header 'Host: git.learn.hyeonworks.com' \
http://127.0.0.1:30080/api/healthz
```
4. 별도 LAN 호스트에서 `192.168.0.107:30080`과 `192.168.0.107:30443`의 TCP
연결이 모두 거부되거나 timeout되는지 확인한다.
```sh
nc -vz -w 3 192.168.0.107 30080
nc -vz -w 3 192.168.0.107 30443
```
TCP 연결이나 HTTP 응답이 하나라도 성공하면 이 차단 조건을 통과하지 못한 것이다. Traefik `404`는
포트가 차단된 것이 아니라 정상적으로 도달했다는 증거다. 포트 하나라도
도달할 수 있으면 전환을 중지하고 `../traefik/README.md`의 제한 지침을 따른다.
5. 활성 인증서가 이 파일에 남아 있는 두 hostname을 모두 포함한다.
```sh
sudo certbot certificates
```
6. `sudo nginx -t`와 Nginx 서비스 상태 검사를 통과한다.
## 최초 Gitea 전환 절차(과거 기록)
다음 절차는 Keycloak 전환 전 Gitea-only 설정을 처음 적용했을 때의 기록이다.
현재 활성 Keycloak 설정에 이 스크립트를 재실행하지 않는다. 당시에는 Argo CD가
아니라 의도적인 호스트 작업으로 저장소 루트에서 다음 스크립트를 실행했다.
```sh
cd /home/donghyeon/workspace/platform
sudo bash scripts/bootstrap/apply-host-nginx-gitea.sh --execute
```
스크립트가 출력한 활성/후보 SHA-256과 백업 경로를 확인한 뒤 prompt에 정확히
`APPLY`를 입력한다. 스크립트는 다음 작업을 한 단위로 수행한다.
- 기존 활성 파일을 timestamp가 붙은 root 소유 파일로 백업하고 해시를 검증한다.
- 후보를 root:root, mode 0644로 설치한 뒤 `nginx -t`, reload, active 상태를 검사한다.
- reload 직후 기존 placeholder fingerprint만 bounded retry하고, 정상 Gitea health
JSON을 두 번 연속 확인해야 다음 검사로 진행한다. 다른 `200` 비JSON 응답은
오라우팅으로 즉시 실패한다.
- loopback TLS 경로의 Gitea health, HTTP→HTTPS 301, 로그인 쿠키의 `Secure`,
Keycloak hold 응답, 미등록 TLS hostname 거부를 검사한다.
- 활성 파일 변경 뒤 포착 가능한 오류가 발생하거나 INT/TERM signal로 중단되면
정확한 백업을 자동 복원하고 `nginx -t`와 reload를 다시 수행한다.
- SIGKILL 또는 전원 장애처럼 trap이 실행될 수 없는 중단은 자동 복구 대상이 아니며,
재접속 후 출력된 백업 경로로 수동 복구한다.
서버에서는 NAT hairpin이 지원되지 않아 공인 주소를 향한 요청이 timeout될 수 있다.
따라서 서버 로컬 검증은 스크립트처럼 `--resolve ...:127.0.0.1`을 사용한다. 실제
공개 경로는 외부망(예: 모바일 핫스팟)에 연결된 별도 클라이언트 또는 외부 probe에서
`--resolve` 없이 검증한다. 아래 명령과 정적 hold 기대값은 최초 Gitea-only 전환
당시의 검사이며 현재 Keycloak 공개 경로의 수용 기준이 아니다.
```sh
curl --fail-with-body https://git.learn.hyeonworks.com/api/healthz
curl --fail-with-body https://id.learn.hyeonworks.com/
```
두 번째 응답은 계속 `Keycloak domain reached Nginx successfully`여야 한다. 실패 시
스크립트가 출력한 정확한 백업 경로를 사용해 다음 순서로 수동 복구한다.
```sh
sudo install -o root -g root -m 0644 BACKUP_PATH /etc/nginx/sites-available/learn-services
sudo nginx -t
sudo systemctl reload nginx
```
전환 기록에는 활성/후보/백업 SHA-256, 실제 백업 경로, `nginx -t`, reload, 로컬
health와 redirect, 값은 숨긴 Cookie 속성, 외부 health, Git clone/push/reclone 결과를
남긴다.
2026-07-23 1차 전환의 reload readiness race와 자동 롤백, 2차 전환 성공, 공개
경로 검증 결과는
[중앙 실행 기록](../../../../docs/platform/runbooks/2026-07-23-host-nginx-gitea-cutover.md)에
보존한다. 사용자 인증이 필요한 Git clone/push/reclone은 아직 남아 있으므로 그
결과도 같은 문서에 추가한다.
## Keycloak proxy 전환
### 완료 상태와 선행 조건
Keycloak 후보는 2026-07-23 활성화했다. 다음 항목은 전환 전에 모두 통과한
선행 조건이다.
1. `keycloak` namespace의 공식 Keycloak Operator와 Server `26.7.0`이 Ready다.
2. `hyeonworks` realm의 내부 discovery가 JSON으로 응답하고 issuer가 정확히
`https://id.learn.hyeonworks.com/realms/hyeonworks`다.
```sh
curl --disable --noproxy '*' \
--fail-with-body --silent --show-error \
--header 'Host: id.learn.hyeonworks.com' \
--header 'X-Forwarded-Host: id.learn.hyeonworks.com' \
--header 'X-Forwarded-Proto: https' \
--header 'X-Forwarded-Port: 443' \
http://127.0.0.1:30080/realms/hyeonworks/.well-known/openid-configuration \
| jq --exit-status \
'.issuer == "https://id.learn.hyeonworks.com/realms/hyeonworks"'
```
3. Traefik `web` entrypoint가 Host Nginx 경로에서 실제로 관측한 한 주소
`10.42.0.1/32`만 신뢰한다. `forwardedHeaders.insecure`와 `websecure` trust는
없어야 한다.
```sh
kubectl -n kube-system get helmchartconfig traefik \
-o jsonpath='{.spec.valuesContent}'
kubectl -n kube-system get deployment traefik -o json \
| jq --raw-output \
'.spec.template.spec.containers[]
| select(.name == "traefik")
| .args[]' \
| rg 'forwardedHeaders|accesslog'
```
기대하는 trust runtime 인자는 다음 한 줄이다.
```text
--entryPoints.web.forwardedHeaders.trustedIPs=10.42.0.1/32
```
4. Keycloak Ingress backend는 `keycloak-service:8080`뿐이며 관리 포트 `9000`은
Ingress, NodePort, LoadBalancer와 Host Nginx 후보에 연결되지 않는다.
```sh
kubectl -n keycloak get ingress keycloak-http -o wide
kubectl -n keycloak get service keycloak-service -o wide
rg -n '9000|keycloak-service|proxy_pass' \
infrastructure/networking/host-nginx/learn-services-keycloak.conf
```
`keycloak-service` 자체가 내부 `ClusterIP`에서 `9000`을 제공하는 것은
Operator의 관리 interface 계약이다. 실패 조건은 이 포트를 외부 경로에 연결한
Ingress, NodePort, LoadBalancer 또는 Nginx `proxy_pass`가 존재하는 경우다.
5. Gitea health, NodePort loopback 경계, 두 hostname을 포함하는 인증서,
Nginx active 상태와 `nginx -t`가 계속 통과한다.
2026-07-23 내부 `hyeonworks` discovery의 issuer·endpoint, Traefik의
`10.42.0.1/32` 최소 trust와 관리 포트 `9000` 미노출을 확인한 뒤 public
cutover를 완료했다. 활성 Nginx SHA-256은
`5c5cd74b4992f537fd27c50cf2209573a80a9904e0b19b58c3154717be6ff4a5`다.
### 실제 실행 명령과 결과
저장소 루트에서 다음 root 작업을 실행했다.
```sh
cd /home/donghyeon/workspace/platform
sudo bash scripts/bootstrap/apply-host-nginx-keycloak.sh --execute
```
스크립트가 보여 준 후보·활성 파일과 SHA-256, backup 경로를 확인한 뒤 prompt에
정확히 다음을 입력했다.
```text
APPLY
```
스크립트는 후보 SHA-256
`5c5cd74b4992f537fd27c50cf2209573a80a9904e0b19b58c3154717be6ff4a5`와
전환 전 활성 SHA-256
`de7ebd4f69cd7d2204ee633e074bf6a4370a6f5e3f3fac9067b099d5d75269b5`를
고정 gate로 확인한다. 예상하지 않은 활성 설정이면 덮어쓰지 않고 중단한다.
실제 backup은
`/etc/nginx/sites-available/learn-services.before-keycloak-20260723160519`이며,
두 번의 probe 뒤 proxy 상태가 안정화됐다. Gitea health, Keycloak discovery
issuer, HTTPS cookie·redirect와 미등록 hostname 거부 자동 검사를 모두 통과했다.
### 자동 backup, 검증과 rollback
전환 스크립트는 다음을 한 단위로 수행한다.
- 변경 전에 직접 Traefik 경로의 Gitea health와 `hyeonworks` discovery issuer를
검증한다.
- 활성 `/etc/nginx/sites-available/learn-services`를
`learn-services.before-keycloak-<timestamp>`로 백업하고 digest를 보존한다.
- 후보를 `root:root 0644`로 설치하고 `nginx -t`가 성공한 경우에만 reload한다.
- reload 뒤 Gitea health와 Keycloak discovery가 함께 수렴할 때까지 bounded
retry한다.
- Git과 ID hostname의 HTTP→HTTPS `301`, Gitea 로그인 Cookie의 `Secure`,
Keycloak discovery issuer·endpoint, 미등록 TLS hostname 거부와 node IP의
`30080/30443` 차단을 검사한다.
- 활성 파일을 바꾼 뒤 오류 또는 INT/TERM이 발생하면 정확한 backup을 복원하고
`nginx -t`, reload, Gitea health와 기존 Keycloak static hold가 돌아왔는지
다시 확인한다.
자동 rollback이 성공하면 전환 전 Gitea proxy와 Keycloak static hold 상태로
복귀한다. SIGKILL이나 전원 장애처럼 trap이 실행되지 않은 경우에는 스크립트가
출력한 정확한 backup 경로를 사용한다.
```sh
sudo install -o root -g root -m 0644 \
BACKUP_PATH \
/etc/nginx/sites-available/learn-services
sudo nginx -t
sudo systemctl reload nginx
```
전환 성공 후 Gitea OIDC source와 브랜딩을 포함한 manifest도 적용했고 자동
수용 검사를 통과했다. 실제 realm 사용자의 브라우저 login/callback/logout,
비상 관리자 실제 로그인과 Pod 재시작 뒤 설정 지속성은 별도 단계다. 완료 상태와
후속 수용 기준은
[중앙 Keycloak·Gitea OIDC 실행 기록](../../../../docs/platform/runbooks/2026-07-23-keycloak-gitea-oidc-cutover.md)에
보존한다.
## Observability 단계 전환
Observability 전환은 기존 site 전체를 임의로 재생성하지 않고 다음 네 mode만 사용한다.
인자 없는 호출은 공개 Grafana DNS 부재와 source/active SHA-256만 읽는 dry-run이다.
```sh
cd /home/donghyeon/workspace/platform
bash scripts/bootstrap/apply-host-nginx-observability.sh
bash scripts/bootstrap/apply-host-nginx-observability.sh --execute --metrics-guard-only
bash scripts/bootstrap/apply-host-nginx-observability.sh \
--execute --certificate-only --certbot-email you@example.com
bash scripts/bootstrap/apply-host-nginx-observability.sh --execute --grafana-deny-guard-only
bash scripts/bootstrap/apply-host-nginx-observability.sh \
--execute --verified-output-dir "$METRIC_ROOT"
```
모든 config mutation은 `PLATFORM_OBSERVABILITY_ROLLBACK_ID`가 가리키는 root-only
rollback root 아래 `host-nginx/stages.tsv`와 `host-nginx/payloads/`에 직전 active
bytes와 양쪽 SHA-256을 먼저 기록한다. 실패 시 timestamp backup이 아니라 이 payload를
hash 검증해 복원한다. full mode는 active deny-guard SHA 외에도 같은 rollback ID의
`blackbox-source-proof.env`, `access-rules-alerts/acceptance.env`와
`$METRIC_ROOT/{target-initial,post-substrate}/inventory.sha256` 결합이 정확해야만 prompt를
표시한다. 인증서 private key와 Cloudflare token은 ledger에 기록하지 않는다.
## 범위 경계
Debian/Ubuntu의 `sites-enabled/*` 파일은 Nginx의 `http` context에서 include되므로
`map` directive를 사용할 수 있다. 이 include 구조가 변경되면 설치 전에 다시
검증한다. 두 hostname 중 어느 쪽에도 활성 server block을 하나 더 만들지 말고 기존
`learn-services` 파일을 하나의 단위로 교체한다.
NodePort 주소 제한은 k3s의
`/etc/rancher/k3s/config.yaml.d/30-nodeport-loopback.yaml`에서 관리한다. 변경
후에는 Host Nginx의 `127.0.0.1:30080` 접근과 LAN의 node-IP 접근 거부를 항상 함께
재검증한다. Router firewall/NAT는 이 저장소가 자동으로 변경하지 않는다.
@@ -0,0 +1,165 @@
# Phase 3 candidate for /etc/nginx/sites-available/learn-services.
# Host Nginx terminates TLS. Traefik receives HTTP only on loopback NodePort.
map $http_upgrade $hyeonworks_connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server ipv6only=on;
ssl_reject_handshake on;
}
server {
server_name git.learn.hyeonworks.com;
location / {
proxy_pass http://127.0.0.1:30080;
proxy_http_version 1.1;
proxy_set_header Host git.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Host git.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $hyeonworks_connection_upgrade;
proxy_request_buffering off;
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_redirect off;
client_max_body_size 512m;
}
listen 443 ssl;
listen [::]:443 ssl;
ssl_certificate /etc/letsencrypt/live/git.learn.hyeonworks.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/git.learn.hyeonworks.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}
server {
server_name id.learn.hyeonworks.com;
location / {
proxy_pass http://127.0.0.1:30080;
proxy_http_version 1.1;
proxy_set_header Host id.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Host id.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_request_buffering off;
proxy_buffering off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_redirect off;
client_max_body_size 10m;
}
listen 443 ssl;
listen [::]:443 ssl;
ssl_certificate /etc/letsencrypt/live/git.learn.hyeonworks.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/git.learn.hyeonworks.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}
server {
server_name storage-admin.learn.hyeonworks.com;
location / {
allow 192.168.0.0/24;
allow 100.64.0.0/10;
deny all;
proxy_pass http://127.0.0.1:30080;
proxy_http_version 1.1;
proxy_set_header Host storage-admin.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Host storage-admin.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $hyeonworks_connection_upgrade;
proxy_request_buffering off;
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_redirect off;
client_max_body_size 5g;
}
listen 443 ssl;
listen [::]:443 ssl;
ssl_certificate /etc/letsencrypt/live/storage-admin.learn.hyeonworks.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/storage-admin.learn.hyeonworks.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}
server {
server_name db-admin.learn.hyeonworks.com;
location / {
allow 192.168.0.0/24;
allow 100.64.0.0/10;
deny all;
proxy_pass http://127.0.0.1:30080;
proxy_http_version 1.1;
proxy_set_header Host db-admin.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Host db-admin.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_request_buffering off;
proxy_buffering off;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
proxy_redirect off;
client_max_body_size 100m;
}
listen 443 ssl;
listen [::]:443 ssl;
ssl_certificate /etc/letsencrypt/live/storage-admin.learn.hyeonworks.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/storage-admin.learn.hyeonworks.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}
server {
listen 80;
listen [::]:80;
server_name git.learn.hyeonworks.com;
return 301 https://git.learn.hyeonworks.com$request_uri;
}
server {
listen 80;
listen [::]:80;
server_name id.learn.hyeonworks.com;
return 301 https://id.learn.hyeonworks.com$request_uri;
}
server {
listen 80;
listen [::]:80;
server_name storage-admin.learn.hyeonworks.com;
return 301 https://storage-admin.learn.hyeonworks.com$request_uri;
}
server {
listen 80;
listen [::]:80;
server_name db-admin.learn.hyeonworks.com;
return 301 https://db-admin.learn.hyeonworks.com$request_uri;
}
@@ -0,0 +1,214 @@
# Phase 3 candidate for /etc/nginx/sites-available/learn-services.
# Host Nginx terminates TLS. Traefik receives HTTP only on loopback NodePort.
map $http_upgrade $hyeonworks_connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server ipv6only=on;
ssl_reject_handshake on;
}
server {
server_name git.learn.hyeonworks.com;
location = /metrics {
return 404;
}
location / {
proxy_pass http://127.0.0.1:30080;
proxy_http_version 1.1;
proxy_set_header Host git.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Host git.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $hyeonworks_connection_upgrade;
proxy_request_buffering off;
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_redirect off;
client_max_body_size 512m;
}
listen 443 ssl;
listen [::]:443 ssl;
ssl_certificate /etc/letsencrypt/live/git.learn.hyeonworks.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/git.learn.hyeonworks.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}
server {
server_name id.learn.hyeonworks.com;
location / {
proxy_pass http://127.0.0.1:30080;
proxy_http_version 1.1;
proxy_set_header Host id.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Host id.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_request_buffering off;
proxy_buffering off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_redirect off;
client_max_body_size 10m;
}
listen 443 ssl;
listen [::]:443 ssl;
ssl_certificate /etc/letsencrypt/live/git.learn.hyeonworks.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/git.learn.hyeonworks.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}
server {
server_name storage-admin.learn.hyeonworks.com;
location / {
allow 192.168.0.0/24;
allow 100.64.0.0/10;
deny all;
proxy_pass http://127.0.0.1:30080;
proxy_http_version 1.1;
proxy_set_header Host storage-admin.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Host storage-admin.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $hyeonworks_connection_upgrade;
proxy_request_buffering off;
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_redirect off;
client_max_body_size 5g;
}
listen 443 ssl;
listen [::]:443 ssl;
ssl_certificate /etc/letsencrypt/live/storage-admin.learn.hyeonworks.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/storage-admin.learn.hyeonworks.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}
server {
server_name db-admin.learn.hyeonworks.com;
location / {
allow 192.168.0.0/24;
allow 100.64.0.0/10;
deny all;
proxy_pass http://127.0.0.1:30080;
proxy_http_version 1.1;
proxy_set_header Host db-admin.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Host db-admin.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_request_buffering off;
proxy_buffering off;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
proxy_redirect off;
client_max_body_size 100m;
}
listen 443 ssl;
listen [::]:443 ssl;
ssl_certificate /etc/letsencrypt/live/storage-admin.learn.hyeonworks.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/storage-admin.learn.hyeonworks.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}
server {
listen 80;
listen [::]:80;
server_name git.learn.hyeonworks.com;
return 301 https://git.learn.hyeonworks.com$request_uri;
}
server {
listen 80;
listen [::]:80;
server_name id.learn.hyeonworks.com;
return 301 https://id.learn.hyeonworks.com$request_uri;
}
server {
listen 80;
listen [::]:80;
server_name storage-admin.learn.hyeonworks.com;
return 301 https://storage-admin.learn.hyeonworks.com$request_uri;
}
server {
listen 80;
listen [::]:80;
server_name db-admin.learn.hyeonworks.com;
return 301 https://db-admin.learn.hyeonworks.com$request_uri;
}
server {
server_name grafana.learn.hyeonworks.com;
location = /metrics {
return 404;
}
location / {
allow 192.168.0.0/24;
allow 100.64.0.0/10;
deny all;
proxy_pass http://127.0.0.1:1;
proxy_http_version 1.1;
proxy_set_header Host grafana.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Host grafana.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $hyeonworks_connection_upgrade;
proxy_request_buffering off;
proxy_buffering off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_redirect off;
client_max_body_size 10m;
}
listen 443 ssl;
listen [::]:443 ssl;
ssl_certificate /etc/letsencrypt/live/grafana.learn.hyeonworks.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/grafana.learn.hyeonworks.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}
server {
listen 80;
listen [::]:80;
server_name grafana.learn.hyeonworks.com;
return 301 https://grafana.learn.hyeonworks.com$request_uri;
}
@@ -0,0 +1,104 @@
# Phase 2 candidate for /etc/nginx/sites-available/learn-services.
#
# Host Nginx is the only public TLS endpoint. Both virtual hosts forward to the
# loopback-only Traefik HTTP NodePort; Keycloak is an OIDC provider, not an
# authentication proxy in front of Gitea.
map $http_upgrade $gitea_connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server ipv6only=on;
ssl_reject_handshake on;
}
server {
server_name git.learn.hyeonworks.com;
location / {
proxy_pass http://127.0.0.1:30080;
proxy_http_version 1.1;
proxy_set_header Host git.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Host git.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $gitea_connection_upgrade;
proxy_request_buffering off;
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_redirect off;
client_max_body_size 512m;
}
listen 443 ssl; # managed by Certbot
listen [::]:443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/git.learn.hyeonworks.com/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/git.learn.hyeonworks.com/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
server {
server_name id.learn.hyeonworks.com;
location / {
proxy_pass http://127.0.0.1:30080;
proxy_http_version 1.1;
proxy_set_header Host id.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Host id.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_request_buffering off;
proxy_buffering off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_redirect off;
client_max_body_size 10m;
}
listen 443 ssl; # managed by Certbot
listen [::]:443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/git.learn.hyeonworks.com/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/git.learn.hyeonworks.com/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
server {
if ($host = git.learn.hyeonworks.com) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
listen [::]:80;
server_name git.learn.hyeonworks.com;
return 404; # managed by Certbot
}
server {
if ($host = id.learn.hyeonworks.com) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
listen [::]:80;
server_name id.learn.hyeonworks.com;
return 404; # managed by Certbot
}
@@ -0,0 +1,169 @@
# Phase 3 candidate for /etc/nginx/sites-available/learn-services.
# Host Nginx terminates TLS. Traefik receives HTTP only on loopback NodePort.
map $http_upgrade $hyeonworks_connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server ipv6only=on;
ssl_reject_handshake on;
}
server {
server_name git.learn.hyeonworks.com;
location = /metrics {
return 404;
}
location / {
proxy_pass http://127.0.0.1:30080;
proxy_http_version 1.1;
proxy_set_header Host git.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Host git.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $hyeonworks_connection_upgrade;
proxy_request_buffering off;
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_redirect off;
client_max_body_size 512m;
}
listen 443 ssl;
listen [::]:443 ssl;
ssl_certificate /etc/letsencrypt/live/git.learn.hyeonworks.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/git.learn.hyeonworks.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}
server {
server_name id.learn.hyeonworks.com;
location / {
proxy_pass http://127.0.0.1:30080;
proxy_http_version 1.1;
proxy_set_header Host id.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Host id.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_request_buffering off;
proxy_buffering off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_redirect off;
client_max_body_size 10m;
}
listen 443 ssl;
listen [::]:443 ssl;
ssl_certificate /etc/letsencrypt/live/git.learn.hyeonworks.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/git.learn.hyeonworks.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}
server {
server_name storage-admin.learn.hyeonworks.com;
location / {
allow 192.168.0.0/24;
allow 100.64.0.0/10;
deny all;
proxy_pass http://127.0.0.1:30080;
proxy_http_version 1.1;
proxy_set_header Host storage-admin.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Host storage-admin.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $hyeonworks_connection_upgrade;
proxy_request_buffering off;
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_redirect off;
client_max_body_size 5g;
}
listen 443 ssl;
listen [::]:443 ssl;
ssl_certificate /etc/letsencrypt/live/storage-admin.learn.hyeonworks.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/storage-admin.learn.hyeonworks.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}
server {
server_name db-admin.learn.hyeonworks.com;
location / {
allow 192.168.0.0/24;
allow 100.64.0.0/10;
deny all;
proxy_pass http://127.0.0.1:30080;
proxy_http_version 1.1;
proxy_set_header Host db-admin.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Host db-admin.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_request_buffering off;
proxy_buffering off;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
proxy_redirect off;
client_max_body_size 100m;
}
listen 443 ssl;
listen [::]:443 ssl;
ssl_certificate /etc/letsencrypt/live/storage-admin.learn.hyeonworks.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/storage-admin.learn.hyeonworks.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}
server {
listen 80;
listen [::]:80;
server_name git.learn.hyeonworks.com;
return 301 https://git.learn.hyeonworks.com$request_uri;
}
server {
listen 80;
listen [::]:80;
server_name id.learn.hyeonworks.com;
return 301 https://id.learn.hyeonworks.com$request_uri;
}
server {
listen 80;
listen [::]:80;
server_name storage-admin.learn.hyeonworks.com;
return 301 https://storage-admin.learn.hyeonworks.com$request_uri;
}
server {
listen 80;
listen [::]:80;
server_name db-admin.learn.hyeonworks.com;
return 301 https://db-admin.learn.hyeonworks.com$request_uri;
}
@@ -0,0 +1,214 @@
# Phase 3 candidate for /etc/nginx/sites-available/learn-services.
# Host Nginx terminates TLS. Traefik receives HTTP only on loopback NodePort.
map $http_upgrade $hyeonworks_connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server ipv6only=on;
ssl_reject_handshake on;
}
server {
server_name git.learn.hyeonworks.com;
location = /metrics {
return 404;
}
location / {
proxy_pass http://127.0.0.1:30080;
proxy_http_version 1.1;
proxy_set_header Host git.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Host git.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $hyeonworks_connection_upgrade;
proxy_request_buffering off;
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_redirect off;
client_max_body_size 512m;
}
listen 443 ssl;
listen [::]:443 ssl;
ssl_certificate /etc/letsencrypt/live/git.learn.hyeonworks.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/git.learn.hyeonworks.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}
server {
server_name id.learn.hyeonworks.com;
location / {
proxy_pass http://127.0.0.1:30080;
proxy_http_version 1.1;
proxy_set_header Host id.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Host id.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_request_buffering off;
proxy_buffering off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_redirect off;
client_max_body_size 10m;
}
listen 443 ssl;
listen [::]:443 ssl;
ssl_certificate /etc/letsencrypt/live/git.learn.hyeonworks.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/git.learn.hyeonworks.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}
server {
server_name storage-admin.learn.hyeonworks.com;
location / {
allow 192.168.0.0/24;
allow 100.64.0.0/10;
deny all;
proxy_pass http://127.0.0.1:30080;
proxy_http_version 1.1;
proxy_set_header Host storage-admin.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Host storage-admin.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $hyeonworks_connection_upgrade;
proxy_request_buffering off;
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_redirect off;
client_max_body_size 5g;
}
listen 443 ssl;
listen [::]:443 ssl;
ssl_certificate /etc/letsencrypt/live/storage-admin.learn.hyeonworks.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/storage-admin.learn.hyeonworks.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}
server {
server_name db-admin.learn.hyeonworks.com;
location / {
allow 192.168.0.0/24;
allow 100.64.0.0/10;
deny all;
proxy_pass http://127.0.0.1:30080;
proxy_http_version 1.1;
proxy_set_header Host db-admin.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Host db-admin.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_request_buffering off;
proxy_buffering off;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
proxy_redirect off;
client_max_body_size 100m;
}
listen 443 ssl;
listen [::]:443 ssl;
ssl_certificate /etc/letsencrypt/live/storage-admin.learn.hyeonworks.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/storage-admin.learn.hyeonworks.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}
server {
listen 80;
listen [::]:80;
server_name git.learn.hyeonworks.com;
return 301 https://git.learn.hyeonworks.com$request_uri;
}
server {
listen 80;
listen [::]:80;
server_name id.learn.hyeonworks.com;
return 301 https://id.learn.hyeonworks.com$request_uri;
}
server {
listen 80;
listen [::]:80;
server_name storage-admin.learn.hyeonworks.com;
return 301 https://storage-admin.learn.hyeonworks.com$request_uri;
}
server {
listen 80;
listen [::]:80;
server_name db-admin.learn.hyeonworks.com;
return 301 https://db-admin.learn.hyeonworks.com$request_uri;
}
server {
server_name grafana.learn.hyeonworks.com;
location = /metrics {
return 404;
}
location / {
allow 192.168.0.0/24;
allow 100.64.0.0/10;
deny all;
proxy_pass http://127.0.0.1:30080;
proxy_http_version 1.1;
proxy_set_header Host grafana.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Host grafana.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $hyeonworks_connection_upgrade;
proxy_request_buffering off;
proxy_buffering off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_redirect off;
client_max_body_size 10m;
}
listen 443 ssl;
listen [::]:443 ssl;
ssl_certificate /etc/letsencrypt/live/grafana.learn.hyeonworks.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/grafana.learn.hyeonworks.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}
server {
listen 80;
listen [::]:80;
server_name grafana.learn.hyeonworks.com;
return 301 https://grafana.learn.hyeonworks.com$request_uri;
}
@@ -0,0 +1,94 @@
# Candidate configuration for /etc/nginx/sites-available/learn-services.
#
# This file is source-controlled configuration, not the active host configuration.
# Review the accompanying README before installing it.
map $http_upgrade $gitea_connection_upgrade {
default upgrade;
'' close;
}
# Do not let an unknown TLS hostname fall through to the first named virtual
# host. Nginx can reject the handshake without presenting either certificate.
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server ipv6only=on;
ssl_reject_handshake on;
}
server {
server_name git.learn.hyeonworks.com;
# TLS terminates here. The hop to the k3s Traefik web NodePort is HTTP.
location / {
proxy_pass http://127.0.0.1:30080;
proxy_http_version 1.1;
# Replace all externally supplied forwarding headers at the trust boundary.
proxy_set_header Host git.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Host git.learn.hyeonworks.com;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
# Keep Git operations and WebSocket-capable endpoints streaming.
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $gitea_connection_upgrade;
proxy_request_buffering off;
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_redirect off;
client_max_body_size 512m;
}
listen 443 ssl; # managed by Certbot
listen [::]:443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/git.learn.hyeonworks.com/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/git.learn.hyeonworks.com/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
# Keycloak is intentionally left on its current static response until its phase.
server {
server_name id.learn.hyeonworks.com;
location / {
default_type text/plain;
return 200 "Keycloak domain reached Nginx successfully\n";
}
listen 443 ssl; # managed by Certbot
listen [::]:443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/git.learn.hyeonworks.com/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/git.learn.hyeonworks.com/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
server {
if ($host = git.learn.hyeonworks.com) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
listen [::]:80;
server_name git.learn.hyeonworks.com;
return 404; # managed by Certbot
}
server {
if ($host = id.learn.hyeonworks.com) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
listen [::]:80;
server_name id.learn.hyeonworks.com;
return 404; # managed by Certbot
}
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -Eeuo pipefail
/usr/sbin/nginx -t
/usr/bin/systemctl reload nginx
@@ -0,0 +1,53 @@
# 호스트 Split DNS
두 dnsmasq 프로세스가 서로 다른 주소에만 바인딩됩니다.
- LAN: 192.168.0.107:53
- Tailscale: 100.92.240.34:53
공유기 DHCP DNS에는 192.168.0.107을 지정하고, Tailscale 관리 화면에는
learn.hyeonworks.com 제한 nameserver로 100.92.240.34를 등록합니다.
이 두 관리 화면 변경은 자동화하지 않습니다.
현재 gateway `192.168.0.1`의 MAC OUI와 관리 페이지는 TP-Link 계열이다.
dnsmasq 실제 적용이 성공한 뒤 다음 순서로 설정한다.
## TP-Link LAN
1. LAN 단말에서 http://192.168.0.1/ 에 로그인한다.
2. `Advanced > Network > DHCP Server`에 DNS 필드가 있으면 Primary DNS를
`192.168.0.107`로 지정하고 Secondary DNS는 비워 둔다.
3. 해당 메뉴에 DNS 필드가 없으면 `Advanced > Network > Internet`에서
`Use the following DNS addresses`를 선택하고 Primary DNS를
`192.168.0.107`로 지정한다.
4. 저장한 뒤 공유기를 재부팅하고 단말의 Wi-Fi를 다시 연결해 DHCP lease를
갱신한다.
공개 DNS를 Secondary에 넣으면 단말이 private resolver를 우회할 수 있으므로
사용하지 않는다. 정확한 메뉴는 TP-Link firmware에 따라 둘 중 하나다.
## Tailscale
1. https://login.tailscale.com/admin/dns 에 로그인한다.
2. `Nameservers > Add nameserver > Custom`을 선택한다.
3. nameserver에 `100.92.240.34`를 입력한다.
4. `Restrict to search domain`을 켜고 `learn.hyeonworks.com`을 입력해 저장한다.
5. 단말에서 Tailscale DNS 수락을 켜고 다시 연결한다.
검증:
nslookup git.learn.hyeonworks.com
nslookup id.learn.hyeonworks.com
nslookup storage-admin.learn.hyeonworks.com
nslookup db-admin.learn.hyeonworks.com
nslookup grafana.learn.hyeonworks.com
LAN에서는 다섯 이름이 `192.168.0.107`, Tailscale 경로에서는
`100.92.240.34`로 응답해야 한다.
공식 참고:
- TP-Link DNS 설정: https://www.tp-link.com/ca/support/faq/1712/
- Tailscale split DNS: https://tailscale.com/learn/why-split-dns
공개 DNS에는 storage-admin, db-admin, grafana A/AAAA 레코드를 만들지 않습니다.
@@ -0,0 +1,16 @@
# Hyeonworks LAN 전용 dnsmasq 인스턴스
port=53
listen-address=192.168.0.107
bind-interfaces
no-hosts
no-resolv
server=1.1.1.1
server=1.0.0.1
cache-size=1000
domain-needed
bogus-priv
address=/git.learn.hyeonworks.com/192.168.0.107
address=/id.learn.hyeonworks.com/192.168.0.107
address=/storage-admin.learn.hyeonworks.com/192.168.0.107
address=/db-admin.learn.hyeonworks.com/192.168.0.107
address=/grafana.learn.hyeonworks.com/192.168.0.107
@@ -0,0 +1,16 @@
# Hyeonworks Tailscale 전용 dnsmasq 인스턴스
port=53
listen-address=100.92.240.34
bind-interfaces
no-hosts
no-resolv
server=1.1.1.1
server=1.0.0.1
cache-size=1000
domain-needed
bogus-priv
address=/git.learn.hyeonworks.com/100.92.240.34
address=/id.learn.hyeonworks.com/100.92.240.34
address=/storage-admin.learn.hyeonworks.com/100.92.240.34
address=/db-admin.learn.hyeonworks.com/100.92.240.34
address=/grafana.learn.hyeonworks.com/100.92.240.34
@@ -0,0 +1,23 @@
[Unit]
Description=Hyeonworks LAN private DNS
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/sbin/dnsmasq --keep-in-foreground --user=dnsmasq --group=nogroup --conf-file=/etc/dnsmasq-hyeonworks/lan.conf --pid-file=/run/hyeonworks-dnsmasq-lan.pid
Restart=on-failure
RestartSec=2s
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_SETGID CAP_SETUID
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,23 @@
[Unit]
Description=Hyeonworks Tailscale private DNS
After=network-online.target tailscaled.service
Wants=network-online.target tailscaled.service
[Service]
Type=simple
ExecStart=/usr/sbin/dnsmasq --keep-in-foreground --user=dnsmasq --group=nogroup --conf-file=/etc/dnsmasq-hyeonworks/tailscale.conf --pid-file=/run/hyeonworks-dnsmasq-tailscale.pid
Restart=on-failure
RestartSec=2s
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_SETGID CAP_SETUID
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,8 @@
# k3s 내부 Split DNS
k3s CoreDNS의 기존 /etc/coredns/custom/*.server 확장점을 이용해
learn.hyeonworks.com의 다섯 서비스 이름을 호스트 Nginx가 있는
192.168.0.107로 해석합니다.
이 구성이 적용되고 Pod 검증이 끝나면 Gitea의 임시 hostAliases는
필요하지 않습니다. 다른 DNS 영역은 기존 CoreDNS 전달 경로를 유지합니다.
@@ -0,0 +1,24 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: coredns-custom
namespace: kube-system
labels:
app.kubernetes.io/name: coredns
app.kubernetes.io/component: private-dns
app.kubernetes.io/part-of: platform
data:
learn-hyeonworks.server: |
learn.hyeonworks.com:53 {
errors
cache 30
hosts {
192.168.0.107 git.learn.hyeonworks.com
192.168.0.107 id.learn.hyeonworks.com
192.168.0.107 storage-admin.learn.hyeonworks.com
192.168.0.107 db-admin.learn.hyeonworks.com
192.168.0.107 grafana.learn.hyeonworks.com
fallthrough
}
forward . /etc/resolv.conf
}
@@ -0,0 +1,5 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- coredns-custom.yaml
+287
View File
@@ -0,0 +1,287 @@
# Traefik 경계 계약과 적용 기록
Traefik은 k3s가 관리하는 클러스터 내부 Ingress Controller다. 공개 요청은 반드시
Host Nginx에서 TLS를 종료한 뒤 loopback NodePort를 통해 Traefik의 `web`
entrypoint로 들어온다. 이 저장소는 두 번째 Ingress Controller를 설치하지 않으며,
애플리케이션 Ingress에 클러스터 내부 TLS를 중복 구성하지 않는다.
2026-07-23 현재 `kube-system/traefik` `HelmChartConfig`에는 trust overlay가 실제로
적용돼 있다. Host Nginx 경유 관측에서 확인한 `ClientHost` `10.42.0.1` 한 주소만
`10.42.0.1/32`로 신뢰하며, Gitea의 site manifest도 외부 HTTPS URL을 생성한다.
## 현재 live 상태
| 항목 | 확인된 값 |
|---|---|
| k3s Traefik Chart | `40.1.3+up40.1.0` |
| Traefik 이미지 | `v3.7.4` |
| `HelmChartConfig` | trust overlay와 일치, live 적용됨 |
| Service 유형 | `NodePort`, `externalTrafficPolicy: Cluster` |
| `web` | Service `80`, NodePort `30080` |
| `websecure` | Service `443`, NodePort `30443` |
| NodePort bind 범위 | `127.0.0.0/8` |
| JSON access log | 활성화, request header 기록 제외 |
| 관측 `ClientHost` | `10.42.0.1` |
| `web` trusted CIDR | `10.42.0.1/32` |
| `websecure` forwarded-header trust | 없음 |
| `forwardedHeaders.insecure` | 없음 |
| Gitea site manifest | `start_url`과 icon URL 모두 `https://git.learn.hyeonworks.com/` 기준 |
| 실제 ingress 경로 | `Host Nginx :443 -> 127.0.0.1:30080 -> Traefik web` |
다음 명령으로 변할 수 있는 live 상태를 다시 확인한다.
```sh
kubectl -n kube-system get helmchartconfig.helm.cattle.io/traefik
kubectl -n kube-system get service/traefik \
-o custom-columns='NAME:.metadata.name,TYPE:.spec.type,PORTS:.spec.ports[*].port,NODEPORTS:.spec.ports[*].nodePort'
kubectl -n kube-system get deployment/traefik -o json |
jq -r '.spec.template.spec.containers[] | select(.name == "traefik") | .args[]'
```
`web=30080`, `websecure=30443`, Service `NodePort` 중 하나라도 다르면 Host Nginx를
새 포트로 임의 변경하지 말고 중지한다. 선언과 live 상태가 왜 달라졌는지 먼저
확인한다.
## 트래픽과 노출 경계
- 애플리케이션 Ingress가 hostname에서 Service로 이어지는 routing을 소유한다.
- 모든 Ingress는 `spec.ingressClassName: traefik``web` entrypoint를 명시한다.
- 공개 TLS는 Host Nginx가 종료하므로 애플리케이션 Ingress에 `spec.tls`를 넣지 않는다.
- `websecure` NodePort `30443`은 Service 계약상 고정하지만 현재 Host Nginx upstream은
사용하지 않는다. 이 entrypoint에는 forwarded-header trust도 설정하지 않는다.
- Traefik Dashboard와 관리 endpoint는 공개하지 않는다.
- k3s drop-in의 `nodeport-addresses=127.0.0.0/8`이 LAN에서 NodePort에 직접
접근하는 우회 경로를 차단한다.
서버 node IP와 별도 LAN 클라이언트에서는 다음 연결이 거부되거나 timeout이어야
한다. Traefik `404`도 TCP 연결에 성공했다는 뜻이므로 실패다.
```sh
nc -vz -w 3 192.168.0.107 30080
nc -vz -w 3 192.168.0.107 30443
```
반대로 서버 loopback에서는 두 포트가 listening 상태여야 하며 Host 기반 Gitea
health가 통과해야 한다.
```sh
nc -vz -w 3 127.0.0.1 30080
nc -vz -w 3 127.0.0.1 30443
curl --fail-with-body \
--header 'Host: git.learn.hyeonworks.com' \
http://127.0.0.1:30080/api/healthz
```
UFW는 현재 inactive다. 인터넷 측 고포트 차단 여부는 LAN 결과에서 추론하지 않고
router 규칙 또는 별도 외부망 검사로 확인한다.
## 선언 구조와 각 overlay의 역할
```text
infrastructure/networking/traefik/
├── base/
│ └── helm-chart-config.yaml
├── overlays/
│ ├── baseline/
│ │ └── service-boundary-only-patch.yaml
│ ├── observe/
│ └── trust/
│ └── trusted-proxy-cidr-patch.yaml
└── scripts/
├── apply-observe.sh
├── observe-client-host.sh
├── apply-trust.sh
├── rollback-to-observe.sh
└── validate.sh
```
세 overlay는 모두 Service `NodePort`, `externalTrafficPolicy: Cluster`
`30080/30443`을 명시적으로 소유한다.
- `baseline`: Service 경계만 남긴다. access log와 forwarded-header trust는 없다.
- `observe`: Service 경계와 header를 버리는 JSON access log를 적용한다. trust는 없다.
- `trust`: observe 설정에 `web.forwardedHeaders.trustedIPs=10.42.0.1/32`만 추가한다.
루트 `kustomization.yaml`은 의도적으로 안전한 `observe` overlay를 가리킨다. 현재
live 상태는 `trust`이므로 루트에 단순히 `kubectl apply -k`를 실행하면 trust 제거를
요청하게 된다. 상태 전환은 아래 guarded script와 정확한 overlay를 사용한다.
Chart 원본이나 k3s가 소유한 `HelmChart`는 직접 수정하지 않는다.
## 첫 observe 적용 실패와 복구
첫 observe 적용 때 `HelmChartConfig`에는 access log만 있고 Traefik Service values가
없었다. k3s Helm Controller가 전체 Chart를 기본값으로 다시 조정하면서 다음 drift가
발생했다.
```text
기존: NodePort web=30080, websecure=30443
변경: LoadBalancer web=31251, websecure=30997
```
이어진 loopback listener 검사가 실패했다. 당시 실패 처리도 새
`HelmChartConfig`를 삭제했을 뿐, desired state에 없던 수동 Service spec은 복원하지
못했다. Gitea·PostgreSQL·PV/PVC는 건드리지 않고 Traefik Service만 다음 명령으로
즉시 원래 경계에 복구했다.
```sh
kubectl -n kube-system patch service traefik \
--type=merge \
--patch '{"spec":{"type":"NodePort","externalTrafficPolicy":"Cluster","ports":[{"name":"web","port":80,"protocol":"TCP","targetPort":"web","nodePort":30080},{"name":"websecure","port":443,"protocol":"TCP","targetPort":"websecure","nodePort":30443}]}}'
```
그 뒤 다음을 영구 보완했다.
- `base`, `baseline`, `observe`, `trust`가 Service type과 정확한 NodePort를 선언한다.
- `baseline` overlay를 추가해 access log나 trust 없이도 NodePort desired state를
유지한다.
- observe 실패 시 `HelmChartConfig`를 삭제하지 않고 baseline을 적용한다.
- trust 실패 또는 표준 trust 롤백 시 observe를 적용한다.
- rollout 뒤 NodePort listener와 Gitea health가 수렴할 때까지 bounded wait를 한다.
- 검증기는 세 overlay에서 LoadBalancer 부재와 `30080/30443`을 강제한다.
따라서 `HelmChartConfig` 삭제는 더 이상 롤백 방법이 아니다. 삭제하면 Chart 기본값이
다시 Service를 소유해 같은 drift를 재발시킬 수 있다.
## 전달 헤더 최소 신뢰 적용 결과
Host Nginx는 외부 요청의 기존 forwarded chain을 이어 붙이지 않고 신뢰 경계에서
다음 값을 새로 만든다.
- `Host`는 선택한 공개 hostname으로 고정한다.
- `X-Real-IP``X-Forwarded-For`는 Nginx가 실제로 본 client address로 교체한다.
- `X-Forwarded-Proto``https`, `X-Forwarded-Port``443`으로 고정한다.
observe 단계에서 다음 probe가 Host Nginx를 반드시 통과하는 고유 요청을 만들고
Traefik JSON access log의 한 router 기록만 읽었다. request header와 자격 증명은
로그에 남기지 않았다.
```sh
bash infrastructure/networking/traefik/scripts/observe-client-host.sh
```
확인 결과는 다음과 같다.
```text
ClientHost: 10.42.0.1
Minimum trusted CIDR: 10.42.0.1/32
```
Pod CIDR 전체, loopback 전체 또는 LAN CIDR을 추정해 넓히지 않고 이 한 주소만 trust
overlay에 기록했다. 적용 명령과 승인 문자열은 다음과 같았다.
```sh
bash infrastructure/networking/traefik/scripts/apply-trust.sh \
--observed-client-host '10.42.0.1' \
--execute
```
```text
APPLY default TRUST 10.42.0.1/32
```
현재 runtime에는 다음 trust 인자 하나만 존재한다.
```text
--entryPoints.web.forwardedHeaders.trustedIPs=10.42.0.1/32
```
`entryPoints.websecure.forwardedHeaders.*``forwardedHeaders.insecure` 인자는 없다.
적용 후 `/assets/site-manifest.json``start_url`과 두 icon URL이 모두 HTTPS로
확인됐고 Gitea health의 status·database·cache 검사도 통과했다.
## 검증과 상태 전환
소스와 세 overlay의 정적 계약은 다음 명령으로 검증한다.
```sh
cd /home/donghyeon/workspace/platform
bash infrastructure/networking/traefik/scripts/validate.sh
```
검증기는 다음 조건을 강제한다.
- 세 overlay의 Service가 `NodePort`, `externalTrafficPolicy: Cluster`,
`30080/30443`을 유지한다.
- observe와 trust access log는 JSON이고 request header를 기록하지 않는다.
- trust CIDR은 관측한 단일 host `/32` 또는 `/128` 형식이다.
- `forwardedHeaders.insecure`, `websecure` trust, `LoadBalancer`가 없다.
새 설치처럼 `HelmChartConfig`가 없거나 이미 observe 상태인 경우에는 다음 guarded
script로 observe 구성을 확인하거나 적용한다.
```sh
bash infrastructure/networking/traefik/scripts/apply-observe.sh --execute
# 승인: APPLY <현재-context> OBSERVE
```
현재 live trust에서 다시 관측하려면 먼저 아래 표준 롤백으로 observe를 적용한 뒤
probe를 실행한다. trust 상태에서 `apply-observe.sh`를 바로 실행하지 않는다.
```sh
bash infrastructure/networking/traefik/scripts/rollback-to-observe.sh --execute
bash infrastructure/networking/traefik/scripts/observe-client-host.sh
```
관측값이 달라지면 기존 CIDR을 넓히지 말고 trust patch를 exact host CIDR로 갱신한 뒤
`apply-trust.sh`를 실행한다. Chart, 이미지, context, API server, NodePort 경계 또는
재관측 값이 기대와 다르면 스크립트가 적용을 중단한다.
### 롤백
trust만 제거하고 JSON access log를 남기는 표준 롤백은 observe overlay를 적용한다.
```sh
bash infrastructure/networking/traefik/scripts/rollback-to-observe.sh --execute
# 승인: ROLLBACK <현재-context> OBSERVE
```
observe 적용 자체가 실패하면 `apply-observe.sh`가 NodePort-only baseline overlay를
적용한다. access log와 trust를 모두 제거해야 하는 명시적 유지보수에서는 live
context와 대상 overlay를 재확인한 뒤 baseline을 적용한다.
```sh
kubectl apply --kustomize \
infrastructure/networking/traefik/overlays/baseline
```
어느 경우에도 `HelmChartConfig`를 삭제해 롤백하지 않는다. baseline 또는 observe를
적용해 Service `30080/30443`을 계속 desired state로 남긴다.
k3s·kube-proxy·CNI·Service traffic policy나 Host Nginx 경로를 바꾸면
`ClientHost`가 달라질 수 있다. 이때는 observe로 돌아가 다시 관측하고 정확한 한
주소만 trust한다.
## 종단 간 인수 조건
다음 로컬 검사는 Host Nginx와 Traefik을 함께 통과해야 한다.
```sh
curl --fail-with-body \
--resolve git.learn.hyeonworks.com:443:127.0.0.1 \
https://git.learn.hyeonworks.com/api/healthz
curl --fail-with-body \
--resolve git.learn.hyeonworks.com:443:127.0.0.1 \
https://git.learn.hyeonworks.com/assets/site-manifest.json |
jq -e '
.start_url == "https://git.learn.hyeonworks.com/" and
([.icons[].src | startswith("https://git.learn.hyeonworks.com/")] | all)
'
```
별도 LAN 클라이언트에서 `192.168.0.107:30080/30443`이 거부되는지 다시 확인하고,
독립 외부망에서는 공개 HTTP→HTTPS redirect와 두 서비스의 HTTPS 응답을 검사한다.
서버에서 공인 FQDN으로 향하는 NAT hairpin timeout만으로 공개 실패를 판정하지 않는다.
구현 근거는 [k3s HelmChartConfig](https://docs.k3s.io/helm),
[k3s 내장 Traefik](https://docs.k3s.io/networking/networking-services),
[Traefik forwarded headers](https://doc.traefik.io/traefik/reference/install-configuration/entrypoints/),
[Traefik access log](https://doc.traefik.io/traefik/observe/logs-and-access-logs/),
[Traefik Chart 40.1.0 values](https://github.com/traefik/traefik-helm-chart/blob/v40.1.0/traefik/values.yaml)다.
첫 실패, 수동 복구, 영구 보완, 관측값과 trust 적용의 전체 명령·출력은
[중앙 실행 기록](../../../../docs/platform/runbooks/2026-07-23-traefik-forwarded-header-trust-boundary.md)에
보존한다.
@@ -0,0 +1,45 @@
apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
name: traefik
namespace: kube-system
labels:
app.kubernetes.io/part-of: platform
app.kubernetes.io/managed-by: kustomize
spec:
failurePolicy: abort
valuesContent: |-
deployment:
podAnnotations:
prometheus.io/path: /metrics
prometheus.io/port: "9100"
prometheus.io/scrape: "true"
service:
spec:
type: NodePort
externalTrafficPolicy: Cluster
ports:
web:
nodePort: 30080
websecure:
nodePort: 30443
metrics:
prometheus:
service:
enabled: true
serviceMonitor:
enabled: true
additionalLabels:
observability.hyeonworks.com/instance: home
jobLabel: app.kubernetes.io/name
interval: 30s
scrapeTimeout: 10s
logs:
access:
enabled: true
format: json
fields:
general:
defaultmode: keep
headers:
defaultmode: drop
@@ -0,0 +1,5 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- helm-chart-config.yaml
@@ -0,0 +1,5 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- overlays/observe
@@ -0,0 +1,14 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
patches:
- path: service-boundary-only-patch.yaml
target:
group: helm.cattle.io
version: v1
kind: HelmChartConfig
name: traefik
namespace: kube-system
@@ -0,0 +1,28 @@
- op: replace
path: /spec/valuesContent
value: |-
deployment:
podAnnotations:
prometheus.io/path: /metrics
prometheus.io/port: "9100"
prometheus.io/scrape: "true"
service:
spec:
type: NodePort
externalTrafficPolicy: Cluster
ports:
web:
nodePort: 30080
websecure:
nodePort: 30443
metrics:
prometheus:
service:
enabled: true
serviceMonitor:
enabled: true
additionalLabels:
observability.hyeonworks.com/instance: home
jobLabel: app.kubernetes.io/name
interval: 30s
scrapeTimeout: 10s
@@ -0,0 +1,5 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
@@ -0,0 +1,14 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
patches:
- path: trusted-proxy-cidr-patch.yaml
target:
group: helm.cattle.io
version: v1
kind: HelmChartConfig
name: traefik
namespace: kube-system
@@ -0,0 +1,41 @@
- op: replace
path: /spec/valuesContent
value: |-
deployment:
podAnnotations:
prometheus.io/path: /metrics
prometheus.io/port: "9100"
prometheus.io/scrape: "true"
service:
spec:
type: NodePort
externalTrafficPolicy: Cluster
ports:
web:
nodePort: 30080
forwardedHeaders:
trustedIPs:
# 2026-07-23 Host Nginx 경유 probe에서 관측한 Traefik ClientHost이다.
- "10.42.0.1/32"
websecure:
nodePort: 30443
metrics:
prometheus:
service:
enabled: true
serviceMonitor:
enabled: true
additionalLabels:
observability.hyeonworks.com/instance: home
jobLabel: app.kubernetes.io/name
interval: 30s
scrapeTimeout: 10s
logs:
access:
enabled: true
format: json
fields:
general:
defaultmode: keep
headers:
defaultmode: drop
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env bash
set -Eeuo pipefail
set +x
umask 077
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
# shellcheck source=common.sh
source "${SCRIPT_DIR}/common.sh"
usage() {
cat <<'USAGE'
Usage: bash infrastructure/networking/traefik/scripts/apply-observe.sh --execute
Applies JSON access logging to the k3s-managed Traefik HelmChartConfig.
It does not trust any forwarded header. The existing loopback-only
NodePort 30080/30443 boundary is declared explicitly so Helm reconciliation
cannot replace it with chart defaults.
USAGE
}
[[ "${1:-}" == "--execute" && "$#" -eq 1 ]] || {
usage
exit 2
}
require_commands kubectl jq rg curl nc python3 awk cmp find bash
bash "${SCRIPT_DIR}/validate.sh"
work_dir="$(mktemp -d "${TMPDIR:-/tmp}/platform-traefik-observe.XXXXXX")"
rollback_required=false
selected_context=""
cleanup() {
local exit_code=$?
trap - EXIT
if [[ "$exit_code" -ne 0 && "$rollback_required" == "true" ]]; then
printf '\nROLLBACK: restoring the durable NodePort-only baseline.\n' >&2
set +e
kubectl --context "$selected_context" apply --kustomize "$BASELINE_OVERLAY"
wait_for_runtime baseline
kubectl --context "$selected_context" --namespace kube-system \
rollout status deployment/traefik --timeout=5m
wait_for_nodeport_boundary_and_health
printf 'ROLLBACK complete. NodePort 30080/30443 remains pinned.\n' >&2
set -e
fi
case "$work_dir" in
/tmp/platform-traefik-observe.*|"${TMPDIR:-/tmp}"/platform-traefik-observe.*)
rm -rf -- "$work_dir"
;;
*)
printf 'WARNING: refusing to remove unexpected directory: %s\n' \
"$work_dir" >&2
;;
esac
exit "$exit_code"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
observe_render="${work_dir}/observe.yaml"
render_overlay "$OBSERVE_OVERLAY" "$observe_render"
assert_live_baseline
assert_nodeport_boundary_and_health
selected_context="$(current_context)"
selected_api_server="$(current_api_server)"
printf '\nKubernetes context: %s\nAPI server: %s\nTarget node: %s\n' \
"$selected_context" "$selected_api_server" "$TARGET_NODE"
already_applied=false
if kubectl --namespace kube-system \
get helmchartconfig.helm.cattle.io traefik >/dev/null 2>&1; then
assert_live_hcc_matches_overlay "$OBSERVE_OVERLAY" \
"${work_dir}/live-observe-compare.yaml"
already_applied=true
printf 'The live HelmChartConfig already matches the observation overlay.\n'
fi
printf 'Type APPLY %s OBSERVE to enable JSON access logs: ' "$selected_context"
read -r confirmation
[[ "$confirmation" == "APPLY ${selected_context} OBSERVE" ]] || \
fail "cancelled"
[[ "$(current_context)" == "$selected_context" ]] || \
fail "kubectl context changed after confirmation"
[[ "$(current_api_server)" == "$selected_api_server" ]] || \
fail "Kubernetes API server changed after confirmation"
assert_live_baseline
assert_nodeport_boundary_and_health
if [[ "$already_applied" == "false" ]]; then
rollback_required=true
kubectl apply --filename "$observe_render"
fi
wait_for_runtime observe
kubectl --namespace kube-system rollout status deployment/traefik --timeout=5m
assert_runtime observe
assert_live_hcc_matches_overlay "$OBSERVE_OVERLAY" \
"${work_dir}/post-apply-observe.yaml"
assert_live_baseline
wait_for_nodeport_boundary_and_health
manifest_start_url="$(
curl --fail-with-body --silent --show-error \
--resolve "${GITEA_HOST}:443:127.0.0.1" \
"$GITEA_MANIFEST_URL" |
jq --raw-output '.start_url'
)"
rollback_required=false
printf '\nOBSERVATION PHASE READY\n'
printf 'Traefik JSON access logging: enabled\n'
printf 'forwardedHeaders trust: absent\n'
printf 'Current Gitea manifest start_url: %s\n' "$manifest_start_url"
printf 'Next: bash %s/observe-client-host.sh\n' "$SCRIPT_DIR"
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env bash
set -Eeuo pipefail
set +x
umask 077
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
# shellcheck source=common.sh
source "${SCRIPT_DIR}/common.sh"
usage() {
cat <<'USAGE'
Usage:
bash infrastructure/networking/traefik/scripts/apply-trust.sh \
--observed-client-host <IP> --execute
The IP must be the ClientHost printed by observe-client-host.sh. The trust
overlay must already contain that exact IP as /32 (IPv4) or /128 (IPv6).
USAGE
}
[[ "$#" -eq 3 && "$1" == "--observed-client-host" && "$3" == "--execute" ]] || {
usage
exit 2
}
readonly REVIEWED_CLIENT_HOST="$2"
require_commands kubectl jq rg curl nc python3 awk sort date cmp find bash
reviewed_cidr="$(host_to_exact_cidr "$REVIEWED_CLIENT_HOST")"
declared_cidr="$(source_trusted_proxy_cidr)"
declared_cidr="$(normalize_exact_host_cidr "$declared_cidr")"
[[ "$declared_cidr" != "$SENTINEL_TRUSTED_PROXY_CIDR" ]] || \
fail "trust overlay still contains the non-routable sentinel CIDR"
[[ "$declared_cidr" == "$reviewed_cidr" ]] || \
fail "declared CIDR ${declared_cidr} does not match ClientHost ${REVIEWED_CLIENT_HOST}"
bash "${SCRIPT_DIR}/validate.sh"
work_dir="$(mktemp -d "${TMPDIR:-/tmp}/platform-traefik-trust.XXXXXX")"
rollback_required=false
selected_context=""
cleanup() {
local exit_code=$?
trap - EXIT
if [[ "$exit_code" -ne 0 && "$rollback_required" == "true" ]]; then
printf '\nROLLBACK: restoring the access-log-only observation overlay.\n' >&2
set +e
kubectl --context "$selected_context" apply --kustomize "$OBSERVE_OVERLAY"
wait_for_runtime observe
kubectl --context "$selected_context" --namespace kube-system \
rollout status deployment/traefik --timeout=5m
wait_for_nodeport_boundary_and_health
printf 'ROLLBACK complete. Forwarded-header trust removal was requested.\n' >&2
set -e
fi
case "$work_dir" in
/tmp/platform-traefik-trust.*|"${TMPDIR:-/tmp}"/platform-traefik-trust.*)
rm -rf -- "$work_dir"
;;
*)
printf 'WARNING: refusing to remove unexpected directory: %s\n' \
"$work_dir" >&2
;;
esac
exit "$exit_code"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
trust_render="${work_dir}/trust.yaml"
render_overlay "$TRUST_OVERLAY" "$trust_render"
assert_live_baseline
assert_runtime observe
assert_live_hcc_matches_overlay "$OBSERVE_OVERLAY" \
"${work_dir}/live-observe.yaml"
assert_nodeport_boundary_and_health
fresh_client_host="$(observe_host_nginx_client_host)"
fresh_cidr="$(host_to_exact_cidr "$fresh_client_host")"
[[ "$fresh_cidr" == "$reviewed_cidr" ]] || \
fail "fresh ClientHost ${fresh_client_host} differs from reviewed ${REVIEWED_CLIENT_HOST}"
selected_context="$(current_context)"
selected_api_server="$(current_api_server)"
printf '\nKubernetes context: %s\nAPI server: %s\nTarget node: %s\n' \
"$selected_context" "$selected_api_server" "$TARGET_NODE"
printf 'Fresh ClientHost: %s\nExact trusted CIDR: %s\n' \
"$fresh_client_host" "$declared_cidr"
printf 'Type APPLY %s TRUST %s to continue: ' \
"$selected_context" "$declared_cidr"
read -r confirmation
[[ "$confirmation" == "APPLY ${selected_context} TRUST ${declared_cidr}" ]] || \
fail "cancelled"
[[ "$(current_context)" == "$selected_context" ]] || \
fail "kubectl context changed after confirmation"
[[ "$(current_api_server)" == "$selected_api_server" ]] || \
fail "Kubernetes API server changed after confirmation"
assert_live_baseline
assert_runtime observe
assert_nodeport_boundary_and_health
assert_live_hcc_matches_overlay "$OBSERVE_OVERLAY" \
"${work_dir}/pre-apply-observe.yaml"
rollback_required=true
kubectl apply --filename "$trust_render"
wait_for_runtime trust "$declared_cidr"
kubectl --namespace kube-system rollout status deployment/traefik --timeout=5m
assert_runtime trust "$declared_cidr"
assert_live_hcc_matches_overlay "$TRUST_OVERLAY" \
"${work_dir}/post-apply-trust.yaml"
assert_live_baseline
wait_for_nodeport_boundary_and_health
assert_manifest_https
rollback_required=false
printf '\nTRUST PHASE READY\n'
printf 'Traefik web trusted CIDR: %s\n' "$declared_cidr"
printf 'Traefik websecure trusted CIDR: absent\n'
printf 'forwardedHeaders.insecure: absent\n'
printf 'Gitea health and HTTPS site-manifest checks: PASS\n'
printf 'Repeat the 30080/30443 refusal check from a separate LAN client.\n'
+464
View File
@@ -0,0 +1,464 @@
#!/usr/bin/env bash
# 이 파일은 같은 디렉터리의 실행 스크립트에서만 source한다.
readonly TRAEFIK_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)"
readonly BASELINE_OVERLAY="${TRAEFIK_ROOT}/overlays/baseline"
readonly OBSERVE_OVERLAY="${TRAEFIK_ROOT}/overlays/observe"
readonly TRUST_OVERLAY="${TRAEFIK_ROOT}/overlays/trust"
readonly TRUST_PATCH="${TRUST_OVERLAY}/trusted-proxy-cidr-patch.yaml"
readonly SENTINEL_TRUSTED_PROXY_CIDR="192.0.2.1/32"
readonly TARGET_NODE="donghyeon-system-product-name"
readonly K3S_NODEPORT_CONFIG="/etc/rancher/k3s/config.yaml.d/30-nodeport-loopback.yaml"
readonly EXPECTED_K3S_CHART="https://%{KUBERNETES_API}%/static/charts/traefik-40.1.3+up40.1.0.tgz"
readonly EXPECTED_CHART_LABEL="traefik-40.1.3_up40.1.0"
readonly EXPECTED_TRAEFIK_IMAGE="rancher/mirrored-library-traefik:3.7.4"
readonly GITEA_HOST="git.learn.hyeonworks.com"
readonly GITEA_HEALTH_URL="http://127.0.0.1:30080/api/healthz"
readonly GITEA_HTTPS_HEALTH_URL="https://${GITEA_HOST}/api/healthz"
readonly GITEA_MANIFEST_URL="https://${GITEA_HOST}/assets/site-manifest.json"
fail() {
printf 'ERROR: %s\n' "$*" >&2
return 1
}
require_commands() {
local command_name
for command_name in "$@"; do
command -v "$command_name" >/dev/null 2>&1 || \
fail "${command_name} is required"
done
}
render_overlay() {
local overlay="$1"
local output="$2"
kubectl kustomize "$overlay" >"$output"
[[ -s "$output" ]] || fail "rendered manifest is empty: ${overlay}"
}
manifest_values_content() {
local manifest="$1"
awk '
/^ valuesContent: \|-$/ {
found = 1
next
}
found {
sub(/^ /, "")
print
}
' "$manifest"
}
source_trusted_proxy_cidr() {
local -a values=()
mapfile -t values < <(
awk -F'"' '/^[[:space:]]*-[[:space:]]*"/ { print $2 }' "$TRUST_PATCH"
)
[[ "${#values[@]}" -eq 1 ]] || \
fail "trust patch must contain exactly one quoted trusted CIDR"
printf '%s\n' "${values[0]}"
}
host_to_exact_cidr() {
local host="$1"
python3 - "$host" <<'PY'
import ipaddress
import sys
value = sys.argv[1]
if "/" in value:
raise SystemExit("ClientHost must be one IP address, not a CIDR")
address = ipaddress.ip_address(value)
if address.is_unspecified or address.is_multicast:
raise SystemExit("ClientHost cannot be unspecified or multicast")
prefix = 32 if address.version == 4 else 128
print(f"{address.compressed}/{prefix}")
PY
}
normalize_exact_host_cidr() {
local cidr="$1"
python3 - "$cidr" <<'PY'
import ipaddress
import sys
network = ipaddress.ip_network(sys.argv[1], strict=True)
required_prefix = 32 if network.version == 4 else 128
if network.prefixlen != required_prefix:
raise SystemExit(
f"trusted proxy range must be one exact host /{required_prefix}, "
f"not {network.with_prefixlen}"
)
print(network.with_prefixlen)
PY
}
current_context() {
kubectl config current-context
}
current_api_server() {
kubectl config view --minify --output=jsonpath='{.clusters[0].cluster.server}'
}
deployment_args() {
kubectl --namespace kube-system get deployment traefik --output=json |
jq --raw-output '
.spec.template.spec.containers[]
| select(.name == "traefik")
| .args[]
'
}
runtime_matches() {
local mode="$1"
local trusted_cidr="${2:-}"
local args
args="$(deployment_args 2>/dev/null)" || return 1
if rg --quiet --ignore-case -- 'forwardedheaders\.insecure' <<<"$args"; then
return 1
fi
if [[ "$mode" != "baseline" ]]; then
rg --quiet --fixed-strings --line-regexp -- '--accesslog=true' <<<"$args" || \
return 1
rg --quiet --fixed-strings --line-regexp -- '--accesslog.format=json' <<<"$args" || \
return 1
fi
case "$mode" in
observe)
! rg --quiet --ignore-case -- 'forwardedheaders\.trustedips' <<<"$args"
;;
trust)
rg --quiet --fixed-strings --line-regexp -- \
"--entryPoints.web.forwardedHeaders.trustedIPs=${trusted_cidr}" <<<"$args" || \
return 1
! rg --quiet --ignore-case -- \
'entrypoints\.websecure\.forwardedheaders\.(trustedips|insecure)' <<<"$args"
;;
baseline)
! rg --quiet --ignore-case -- \
'accesslog|forwardedheaders\.(trustedips|insecure)' <<<"$args"
;;
*)
return 1
;;
esac
}
assert_runtime() {
local mode="$1"
local trusted_cidr="${2:-}"
local args
local trusted_count
args="$(deployment_args)"
rg --quiet --fixed-strings --line-regexp -- '--accesslog=true' <<<"$args" || \
fail "Traefik runtime is missing --accesslog=true"
rg --quiet --fixed-strings --line-regexp -- '--accesslog.format=json' <<<"$args" || \
fail "Traefik runtime is missing JSON access-log format"
if rg --quiet --ignore-case -- 'forwardedheaders\.insecure' <<<"$args"; then
fail "forwardedHeaders.insecure must never be present"
fi
trusted_count="$(
rg --count --ignore-case -- 'forwardedheaders\.trustedips' <<<"$args" || true
)"
trusted_count="${trusted_count:-0}"
case "$mode" in
observe)
[[ "$trusted_count" == "0" ]] || \
fail "observation phase must not trust forwarded headers"
;;
trust)
[[ "$trusted_count" == "1" ]] || \
fail "trust phase must render exactly one trustedIPs argument"
rg --quiet --fixed-strings --line-regexp -- \
"--entryPoints.web.forwardedHeaders.trustedIPs=${trusted_cidr}" <<<"$args" || \
fail "web entrypoint does not contain the reviewed exact-host CIDR"
if rg --quiet --ignore-case -- \
'entrypoints\.websecure\.forwardedheaders\.(trustedips|insecure)' <<<"$args"; then
fail "websecure must not receive forwarded-header trust"
fi
;;
*)
fail "unsupported runtime assertion mode: ${mode}"
;;
esac
}
wait_for_runtime() {
local mode="$1"
local trusted_cidr="${2:-}"
local attempt
for ((attempt = 1; attempt <= 120; attempt++)); do
if runtime_matches "$mode" "$trusted_cidr"; then
return 0
fi
sleep 2
done
fail "Traefik runtime did not reach ${mode} state within 240 seconds"
}
assert_live_baseline() {
local chart
local chart_label
local image
local nodeport_matches
local service_json
kubectl get node "$TARGET_NODE" >/dev/null
chart="$(
kubectl --namespace kube-system get helmchart.helm.cattle.io traefik \
--output=jsonpath='{.spec.chart}'
)"
[[ "$chart" == "$EXPECTED_K3S_CHART" ]] || \
fail "unexpected packaged Traefik chart: ${chart}"
chart_label="$(
kubectl --namespace kube-system get deployment traefik \
--output=jsonpath='{.metadata.labels.helm\.sh/chart}'
)"
[[ "$chart_label" == "$EXPECTED_CHART_LABEL" ]] || \
fail "unexpected live Traefik chart label: ${chart_label}"
image="$(
kubectl --namespace kube-system get deployment traefik --output=json |
jq --raw-output '
.spec.template.spec.containers[]
| select(.name == "traefik")
| .image
'
)"
[[ "$image" == "$EXPECTED_TRAEFIK_IMAGE" ]] || \
fail "unexpected live Traefik image: ${image}"
[[ -r "$K3S_NODEPORT_CONFIG" ]] || \
fail "cannot read the k3s nodeport-addresses drop-in: ${K3S_NODEPORT_CONFIG}"
rg --quiet --fixed-strings --line-regexp -- \
' - "nodeport-addresses=127.0.0.0/8"' "$K3S_NODEPORT_CONFIG" || \
fail "k3s nodeport-addresses is not pinned to 127.0.0.0/8"
nodeport_matches="$(
rg --no-heading --line-number -- 'nodeport-addresses[=:]' \
/etc/rancher/k3s/config.yaml \
/etc/rancher/k3s/config.yaml.d 2>/dev/null || true
)"
[[ "$(wc -l <<<"$nodeport_matches" | tr -d '[:space:]')" == "1" ]] || \
fail "nodeport-addresses must have exactly one k3s configuration owner"
rg --quiet --fixed-strings -- "$K3S_NODEPORT_CONFIG" <<<"$nodeport_matches" || \
fail "nodeport-addresses is owned by an unexpected k3s configuration file"
service_json="$(
kubectl --namespace kube-system get service traefik --output=json
)"
jq --exit-status '
.spec.type == "NodePort"
and .spec.externalTrafficPolicy == "Cluster"
and (.spec.ports | length) == 2
and any(.spec.ports[];
.name == "web"
and .port == 80
and .nodePort == 30080
and .protocol == "TCP")
and any(.spec.ports[];
.name == "websecure"
and .port == 443
and .nodePort == 30443
and .protocol == "TCP")
' >/dev/null <<<"$service_json" || \
fail "Traefik Service no longer matches the 80/30080 and 443/30443 boundary"
}
assert_health_body() {
local body="$1"
jq --exit-status '
.status == "pass"
and (.checks["database:ping"] | length) > 0
and all(.checks["database:ping"][]; .status == "pass")
and (.checks["cache:ping"] | length) > 0
and all(.checks["cache:ping"][]; .status == "pass")
' >/dev/null <<<"$body" || fail "Gitea database/cache health is not pass"
}
assert_nodeport_boundary_and_health() {
local body
local node_ip
local port
node_ip="$(
kubectl get node "$TARGET_NODE" --output=json |
jq --raw-output '
[.status.addresses[] | select(.type == "InternalIP") | .address]
| if length == 1 then .[0] else empty end
'
)"
[[ -n "$node_ip" && "$node_ip" != "127.0.0.1" ]] || \
fail "could not resolve exactly one non-loopback node InternalIP"
for port in 30080 30443; do
nc -z -w 3 127.0.0.1 "$port" >/dev/null 2>&1 || \
fail "loopback NodePort is not listening: 127.0.0.1:${port}"
if nc -z -w 3 "$node_ip" "$port" >/dev/null 2>&1; then
fail "NodePort escaped the loopback boundary: ${node_ip}:${port}"
fi
done
body="$(
curl --fail-with-body --silent --show-error \
--header "Host: ${GITEA_HOST}" \
"$GITEA_HEALTH_URL"
)"
assert_health_body "$body"
body="$(
curl --fail-with-body --silent --show-error \
--resolve "${GITEA_HOST}:443:127.0.0.1" \
"$GITEA_HTTPS_HEALTH_URL"
)"
assert_health_body "$body"
}
wait_for_nodeport_boundary_and_health() {
local attempt
for ((attempt = 1; attempt <= 60; attempt++)); do
if assert_nodeport_boundary_and_health >/dev/null 2>&1; then
return 0
fi
sleep 2
done
# 마지막 검사는 오류 원인을 숨기지 않고 그대로 출력한다.
assert_nodeport_boundary_and_health
fail "Traefik NodePort boundary and Gitea health did not recover within 120 seconds"
}
observe_host_nginx_client_host() {
local http_code
local logs
local matched
local nonce
local observed
local probe_path
local router_count
local since
local -a client_hosts=()
local attempt
since="$(date --utc '+%Y-%m-%dT%H:%M:%SZ')"
nonce="$(date --utc '+%Y%m%dT%H%M%S')-${BASHPID}"
probe_path="/api/healthz/traefik-source-${nonce}"
http_code="$(
curl --silent --show-error \
--resolve "${GITEA_HOST}:443:127.0.0.1" \
--output /dev/null \
--write-out '%{http_code}' \
"https://${GITEA_HOST}${probe_path}"
)"
[[ "$http_code" == "404" ]] || \
fail "unique Host Nginx observation request returned HTTP ${http_code}, expected 404"
matched=""
for ((attempt = 1; attempt <= 20; attempt++)); do
logs="$(
kubectl --namespace kube-system logs deployment/traefik \
--since-time "$since"
)"
matched="$(
jq --raw-input --compact-output --arg path "$probe_path" '
fromjson?
| select(.RequestPath == $path)
' <<<"$logs"
)"
[[ -n "$matched" ]] && break
sleep 1
done
[[ -n "$matched" ]] || \
fail "the unique request was not found in Traefik JSON access logs"
router_count="$(
jq --slurp '
[
.[]
| select(
((.RouterName // "") | ascii_downcase | contains("gitea"))
)
]
| length
' <<<"$matched"
)"
[[ "$router_count" -ge 1 ]] || \
fail "the observation log did not traverse a Gitea router"
mapfile -t client_hosts < <(
jq --raw-output '
select((.RouterName // "") | ascii_downcase | contains("gitea"))
| .ClientHost // empty
' <<<"$matched" |
sort --unique
)
[[ "${#client_hosts[@]}" -eq 1 && -n "${client_hosts[0]}" ]] || \
fail "expected one distinct Traefik ClientHost for the unique request"
observed="${client_hosts[0]}"
host_to_exact_cidr "$observed" >/dev/null
printf '%s\n' "$observed"
}
assert_manifest_https() {
local body
local attempt
for ((attempt = 1; attempt <= 30; attempt++)); do
body="$(
curl --fail-with-body --silent --show-error \
--resolve "${GITEA_HOST}:443:127.0.0.1" \
"$GITEA_MANIFEST_URL"
)"
if jq --exit-status --arg expected "https://${GITEA_HOST}/" '
.start_url == $expected
and all(.icons[]; (.src | startswith("https://")))
' >/dev/null <<<"$body"; then
return 0
fi
sleep 2
done
fail "Gitea site manifest did not stabilize on HTTPS URLs"
}
assert_live_hcc_matches_overlay() {
local overlay="$1"
local render_file="$2"
local actual
local expected
render_overlay "$overlay" "$render_file"
expected="$(manifest_values_content "$render_file")"
actual="$(
kubectl --namespace kube-system \
get helmchartconfig.helm.cattle.io traefik --output=json |
jq --raw-output '.spec.valuesContent'
)"
[[ "$actual" == "$expected" ]] || \
fail "live Traefik HelmChartConfig does not match the expected overlay"
}
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
set -Eeuo pipefail
set +x
umask 077
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
# shellcheck source=common.sh
source "${SCRIPT_DIR}/common.sh"
usage() {
cat <<'USAGE'
Usage: bash infrastructure/networking/traefik/scripts/observe-client-host.sh
Sends one unique HTTPS request through Host Nginx and extracts the corresponding
ClientHost from Traefik JSON access logs. It performs no cluster mutation.
USAGE
}
[[ "$#" -eq 0 ]] || {
usage
exit 2
}
require_commands kubectl jq rg curl nc python3 awk sort date
work_dir="$(mktemp -d "${TMPDIR:-/tmp}/platform-traefik-clienthost.XXXXXX")"
cleanup() {
case "$work_dir" in
/tmp/platform-traefik-clienthost.*|"${TMPDIR:-/tmp}"/platform-traefik-clienthost.*)
rm -rf -- "$work_dir"
;;
*)
printf 'WARNING: refusing to remove unexpected directory: %s\n' \
"$work_dir" >&2
;;
esac
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
bash "${SCRIPT_DIR}/validate.sh"
assert_live_baseline
assert_runtime observe
assert_live_hcc_matches_overlay "$OBSERVE_OVERLAY" \
"${work_dir}/live-observe.yaml"
assert_nodeport_boundary_and_health
observed_client_host="$(observe_host_nginx_client_host)"
trusted_proxy_cidr="$(host_to_exact_cidr "$observed_client_host")"
printf '\nTRAEFIK SOURCE OBSERVED\n'
printf 'ClientHost: %s\n' "$observed_client_host"
printf 'Minimum trusted CIDR: %s\n' "$trusted_proxy_cidr"
printf 'Record that CIDR in:\n%s\n' "$TRUST_PATCH"
printf 'Replace only the sentinel %s, then run apply-trust.sh with this ClientHost.\n' \
"$SENTINEL_TRUSTED_PROXY_CIDR"
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
set -Eeuo pipefail
set +x
umask 077
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
# shellcheck source=common.sh
source "${SCRIPT_DIR}/common.sh"
usage() {
cat <<'USAGE'
Usage:
bash infrastructure/networking/traefik/scripts/rollback-to-observe.sh --execute
Removes forwarded-header trust while retaining JSON access logging.
USAGE
}
[[ "${1:-}" == "--execute" && "$#" -eq 1 ]] || {
usage
exit 2
}
require_commands kubectl jq rg curl nc python3 awk cmp find bash
bash "${SCRIPT_DIR}/validate.sh"
assert_live_baseline
kubectl --namespace kube-system \
get helmchartconfig.helm.cattle.io traefik >/dev/null
assert_nodeport_boundary_and_health
selected_context="$(current_context)"
selected_api_server="$(current_api_server)"
printf '\nKubernetes context: %s\nAPI server: %s\nTarget node: %s\n' \
"$selected_context" "$selected_api_server" "$TARGET_NODE"
printf 'Type ROLLBACK %s OBSERVE to remove forwarded-header trust: ' \
"$selected_context"
read -r confirmation
[[ "$confirmation" == "ROLLBACK ${selected_context} OBSERVE" ]] || \
fail "cancelled"
[[ "$(current_context)" == "$selected_context" ]] || \
fail "kubectl context changed after confirmation"
[[ "$(current_api_server)" == "$selected_api_server" ]] || \
fail "Kubernetes API server changed after confirmation"
assert_live_baseline
kubectl apply --kustomize "$OBSERVE_OVERLAY"
wait_for_runtime observe
kubectl --namespace kube-system rollout status deployment/traefik --timeout=5m
assert_runtime observe
work_dir="$(mktemp -d "${TMPDIR:-/tmp}/platform-traefik-rollback.XXXXXX")"
cleanup() {
case "$work_dir" in
/tmp/platform-traefik-rollback.*|"${TMPDIR:-/tmp}"/platform-traefik-rollback.*)
rm -rf -- "$work_dir"
;;
*)
printf 'WARNING: refusing to remove unexpected directory: %s\n' \
"$work_dir" >&2
;;
esac
}
trap cleanup EXIT
assert_live_hcc_matches_overlay "$OBSERVE_OVERLAY" \
"${work_dir}/post-rollback-observe.yaml"
assert_live_baseline
wait_for_nodeport_boundary_and_health
printf '\nROLLBACK COMPLETE\n'
printf 'JSON access logging remains enabled.\n'
printf 'forwardedHeaders trust is absent from both entrypoints.\n'
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env bash
set -Eeuo pipefail
set +x
umask 077
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
# shellcheck source=common.sh
source "${SCRIPT_DIR}/common.sh"
assert_count() {
local file="$1"
local pattern="$2"
local expected="$3"
local description="$4"
local actual
actual="$(rg --count --no-filename -- "$pattern" "$file" || true)"
actual="${actual:-0}"
[[ "$actual" == "$expected" ]] || \
fail "${description}: expected ${expected}, found ${actual}"
}
require_commands kubectl rg awk cmp python3 find bash
render_dir="$(mktemp -d "${TMPDIR:-/tmp}/platform-traefik-render.XXXXXX")"
cleanup() {
case "$render_dir" in
/tmp/platform-traefik-render.*|"${TMPDIR:-/tmp}"/platform-traefik-render.*)
rm -rf -- "$render_dir"
;;
*)
printf 'WARNING: refusing to remove unexpected directory: %s\n' \
"$render_dir" >&2
;;
esac
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
root_render="${render_dir}/root.yaml"
baseline_render="${render_dir}/baseline.yaml"
observe_render="${render_dir}/observe.yaml"
trust_render="${render_dir}/trust.yaml"
render_overlay "$TRAEFIK_ROOT" "$root_render"
render_overlay "$BASELINE_OVERLAY" "$baseline_render"
render_overlay "$OBSERVE_OVERLAY" "$observe_render"
render_overlay "$TRUST_OVERLAY" "$trust_render"
cmp --silent "$root_render" "$observe_render" || \
fail "the Traefik root must render the observation phase"
for manifest in "$baseline_render" "$observe_render" "$trust_render"; do
assert_count "$manifest" '^apiVersion: helm\.cattle\.io/v1$' 1 \
"HelmChartConfig API version"
assert_count "$manifest" '^kind: HelmChartConfig$' 1 \
"HelmChartConfig kind"
assert_count "$manifest" '^ name: traefik$' 1 \
"HelmChartConfig name"
assert_count "$manifest" '^ namespace: kube-system$' 1 \
"HelmChartConfig namespace"
assert_count "$manifest" '^ failurePolicy: abort$' 1 \
"Helm failure policy must preserve the running release on upgrade failure"
assert_count "$manifest" '^ deployment:$' 1 \
"Traefik Deployment values root"
assert_count "$manifest" '^ podAnnotations:$' 1 \
"Traefik Pod annotations block"
assert_count "$manifest" '^ prometheus\.io/path: /metrics$' 1 \
"Traefik legacy scrape path preservation"
assert_count "$manifest" '^ prometheus\.io/port: "9100"$' 1 \
"Traefik legacy scrape port preservation"
assert_count "$manifest" '^ prometheus\.io/scrape: "true"$' 1 \
"Traefik legacy scrape enablement preservation"
assert_count "$manifest" '[Ii]nsecure' 0 \
"insecure forwarded-header mode"
assert_count "$manifest" '^ service:$' 1 \
"Traefik Service values root"
assert_count "$manifest" '^ type: NodePort$' 1 \
"loopback NodePort Service type preservation"
assert_count "$manifest" '^ externalTrafficPolicy: Cluster$' 1 \
"Traefik externalTrafficPolicy preservation"
assert_count "$manifest" '^ nodePort: 30080$' 1 \
"Traefik web NodePort preservation"
assert_count "$manifest" '^ nodePort: 30443$' 1 \
"Traefik websecure NodePort preservation"
assert_count "$manifest" '^ metrics:$' 1 \
"Traefik metrics values root"
assert_count "$manifest" '^ prometheus:$' 1 \
"Traefik Prometheus metrics block"
assert_count "$manifest" '^ serviceMonitor:$' 1 \
"Traefik ServiceMonitor block"
assert_count "$manifest" '^ jobLabel: app\.kubernetes\.io/name$' 1 \
"Traefik ServiceMonitor job label"
assert_count "$manifest" '^ observability\.hyeonworks\.com/instance: home$' 1 \
"Traefik ServiceMonitor selector label"
assert_count "$manifest" '^ interval: 30s$' 1 \
"Traefik ServiceMonitor interval"
assert_count "$manifest" '^ scrapeTimeout: 10s$' 1 \
"Traefik ServiceMonitor timeout"
assert_count "$manifest" '^ enabled: true$' 2 \
"Traefik metrics Service and ServiceMonitor enablement"
assert_count "$manifest" '^[[:space:]]*(type|serviceType):[[:space:]]*LoadBalancer' 0 \
"LoadBalancer exposure"
done
assert_count "$baseline_render" '^[[:space:]]*logs:' 0 \
"baseline access logs"
assert_count "$baseline_render" '^[[:space:]]*forwardedHeaders:' 0 \
"baseline forwarded-header trust"
for manifest in "$observe_render" "$trust_render"; do
assert_count "$manifest" '^ enabled: true$' 1 \
"access log enablement"
assert_count "$manifest" '^ format: json$' 1 \
"JSON access log format"
assert_count "$manifest" '^ defaultmode: keep$' 1 \
"access-log general-field policy"
assert_count "$manifest" '^ defaultmode: drop$' 1 \
"access-log header policy"
done
assert_count "$observe_render" '^[[:space:]]*forwardedHeaders:' 0 \
"observation-phase forwarded-header trust"
assert_count "$observe_render" '^[[:space:]]*trustedIPs:' 0 \
"observation-phase trusted IP list"
assert_count "$trust_render" '^ ports:$' 1 \
"trust-phase ports values root"
assert_count "$trust_render" '^ web:$' 1 \
"trust-phase web entrypoint"
assert_count "$trust_render" '^ forwardedHeaders:$' 1 \
"trust-phase forwarded-header block"
assert_count "$trust_render" '^ trustedIPs:$' 1 \
"trust-phase trusted IP list"
trusted_cidr="$(source_trusted_proxy_cidr)"
normalized_cidr="$(normalize_exact_host_cidr "$trusted_cidr")"
[[ "$trusted_cidr" == "$normalized_cidr" ]] || \
fail "trusted proxy CIDR must use canonical exact-host notation"
assert_count "$trust_render" \
"^[[:space:]]*-[[:space:]]*\"${trusted_cidr//./\\.}\"[[:space:]]*$" 1 \
"rendered exact-host trusted proxy CIDR"
while IFS= read -r -d '' script_path; do
bash -n "$script_path"
done < <(
find "$SCRIPT_DIR" -maxdepth 1 -type f -name '*.sh' -print0
)
printf 'Traefik observation and trust overlays rendered successfully.\n'
printf 'Access logs are JSON and request headers are dropped.\n'
printf 'No insecure mode, websecure trust, or LoadBalancer exposure was found.\n'
printf 'The existing NodePort 30080/30443 boundary is declared in both phases.\n'
if [[ "$trusted_cidr" == "$SENTINEL_TRUSTED_PROXY_CIDR" ]]; then
printf 'Trust overlay remains intentionally blocked by sentinel CIDR %s.\n' \
"$SENTINEL_TRUSTED_PROXY_CIDR"
else
printf 'Trust overlay contains reviewed exact-host CIDR %s.\n' "$trusted_cidr"
fi
@@ -0,0 +1,2 @@
secrets-encryption: true
secrets-encryption-provider: aescbc
+34
View File
@@ -0,0 +1,34 @@
# k3s Secret 암호화 host drop-in
`40-secrets-encryption.yaml`은 Kubernetes API에 적용하는 Kustomize resource가
아니라, k3s server host의 설정 조각이다. bootstrap 담당자는 이 파일을 다음처럼
live drop-in 경로에 설치한다.
```sh
install -o root -g root -m 0644 \
infrastructure/security/k3s/40-secrets-encryption.yaml \
/etc/rancher/k3s/config.yaml.d/40-secrets-encryption.yaml
```
따라서 이 디렉터리에는 `kustomization.yaml`을 만들지 않는다. 이 파일은 정확히
`secrets-encryption``secrets-encryption-provider` 두 key만 가지며, Kubernetes
manifest metadata나 다른 host 설정을 넣지 않는다.
실제 적용 순서, validator 판정, 서버 token 취급, 재암호화 대기와 중단 조건은
[`bootstrap/manual/k3s-secret-encryption.md`](../../../bootstrap/manual/k3s-secret-encryption.md)를
따른다. 이 artifact 자체를 자동 삭제하거나 기존 운영 설정을 덮어쓰지 않는다.
## 로컬 recovery 하드웨어 계약
`local-recovery.env`는 로컬 암호화 recovery 저장소에 사용할 디스크 identity,
mount 경로와 용량 기준만 담는 비밀 없는 계약 파일이다. 이 파일은 shell에서
`source`하지 않는다. `scripts/lib/k3s-local-recovery.sh`의 parser가 허용된 key가
정확히 한 번씩 있는지, 각 행의 `KEY=VALUE` 형식과 값 문법이 맞는지를 확인한 뒤
데이터로만 읽는다. 알 수 없는 key, 중복·누락 key, 상대 장치 경로, 공백·인용부호·명령
치환을 포함한 값은 모두 실패 처리한다.
용량 helper는 Bash signed 64-bit 정수 범위를 넘기기 전에 실패한다. 내부 recovery
공간은 각 backup phase의 125%를 두 번과 10GiB 최소 여유로 계산한다. 외부 NTFS
공간 판정은 최종 container와 header-proof용 완전 할당 copy, 10GiB 최소 여유,
전체 용량의 20% 잔여 공간을 함께 예약한다. 이 단계는 순수 parser·계산만 제공하며
mount, format, package 설치, `cryptsetup`, k3s 변경을 수행하지 않는다.
+92
View File
@@ -0,0 +1,92 @@
# 플랫폼 Secret 계약
상태: Phase 1·Keycloak DB·Gitea OIDC·AIStor Secret 적용
이 디렉터리에는 Secret 값이나 Secret 매니페스트를 저장하지 않는다. 수동
초기 구축 스크립트가 터미널 또는 지정된 파일에서 값을 읽어 Kubernetes API로
직접 전달한다.
## Phase 1 Secret 계약
| Namespace | 이름 | 타입 | 키 | 소비자 |
| --- | --- | --- | --- | --- |
| `platform-data` | `gitea-db-credentials` | `kubernetes.io/basic-auth` | `username`, `password` | CloudNativePG `DatabaseRole` 및 최초 `initdb` |
| `gitea` | `gitea-db-credentials` | `kubernetes.io/basic-auth` | `username`, `password` | Gitea |
| `gitea` | `gitea-admin` | `kubernetes.io/basic-auth` | `username`, `password` | Gitea 초기 관리자 생성 |
두 namespace의 `gitea-db-credentials`는 동일한 `username=gitea`와 동일한
비밀번호를 가져야 한다. Kubernetes Secret은 namespace를 넘어서 참조할 수
없기 때문에 두 객체가 필요하다. `scripts/bootstrap/create-phase1-secrets.sh`
세 Secret이 모두 없을 때만 최초 생성하고, 모두 있으면 계약을 검증해 재사용하며,
일부만 존재하면 중단한다.
## Keycloak-only Secret 계약
| Namespace | 이름 | 타입 | 키 | 소비자 |
| --- | --- | --- | --- | --- |
| `platform-data` | `keycloak-db-credentials` | `kubernetes.io/basic-auth` | `username`, `password` | CloudNativePG `DatabaseRole` |
| `keycloak` | `keycloak-db-credentials` | `kubernetes.io/basic-auth` | `username`, `password` | Keycloak |
| `gitea` | `gitea-keycloak-oidc` | `Opaque` | `key`, `secret` | Gitea OAuth2 source |
두 DB Secret은 `username=keycloak`과 동일한 비밀번호를 가져야 하며
`platform-data` 객체에는 `cnpg.io/reload=true` 라벨이 필요하다.
`create-keycloak-secrets.sh`는 두 DB Secret만 two-or-none으로 생성·검증한다.
`configure-keycloak-gitea-oidc.sh`는 Keycloak의 기존 confidential client secret을
새로 회전하지 않고 Gitea namespace의 `gitea-keycloak-oidc`에 전달한다. `key`
client ID `gitea`이며 `secret` payload는 출력·디코드·문서화하지 않는다. 세 객체는
실제 적용됐고 타입과 정확한 key 이름만 검증했다.
```sh
bash scripts/bootstrap/create-keycloak-secrets.sh --generate --execute
bash scripts/bootstrap/configure-keycloak-gitea-oidc.sh --execute
```
Keycloak Operator가 만든 `keycloak/keycloak-initial-admin`도 실제 존재한다. 이
Secret은 저장소가 렌더하는 자격 증명 manifest가 아니다. named 관리자와 MFA를
검증한 뒤 초기 관리자의 보존·비활성화 정책을 별도 운영 절차로 결정한다.
## AIStor Secret 계약
| Namespace | 이름 | 타입 | 키 | 소비자 |
| --- | --- | --- | --- | --- |
| `aistor` | `minio-license` | `Opaque` | `minio.license` | AIStor Operator |
| `object-storage` | `aistor-root-configuration` | `Opaque` | `config.env` | AIStor ObjectStore |
두 AIStor Secret은 실제 적용됐다. `minio.license`는 사용자가 지정한 읽기 가능한
절대 regular file에서 직접 읽는다. `config.env`에는 강한 `MINIO_ROOT_USER`,
`MINIO_ROOT_PASSWORD` export를 각각 이중 따옴표 형식으로 저장한다.
```sh
export MINIO_ROOT_USER="<redacted>"
export MINIO_ROOT_PASSWORD="<redacted>"
```
실제 값은 이 문서나 Git에 기록하지 않는다. AIStor 전용
`create-aistor-secrets.sh`는 두 객체를 two-or-none으로 생성·검증하고, 현재
클러스터에서는 기존 값을 회전하지 않고 재사용한다.
기존 `create-phase2-secrets.sh`는 Keycloak DB 두 객체와 AIStor 두 객체를
four-or-none으로 관리한다. 현재 Keycloak DB 두 객체만 의도적으로 존재하므로 이
스크립트는 실행하지 않는다. Keycloak Secret을 삭제하거나 검사를 우회하지 않는다.
기존 four-or-none helper는 호환용으로만 남긴다. AIStor 작업에는 전용 helper만
사용하며 one-of-two 상태에서는 자동 삭제·보정·덮어쓰기 없이 중단한다.
## 공통 운영 규칙
- 평문, Base64 값, 실제 Secret YAML, 라이선스 본문, 개인 키를 Git에 커밋하지
않는다.
- 스크립트는 호출자가 `bash -x`를 사용해도 명령 추적을 즉시 비활성화한다.
- 최초 생성 경로의 임시 파일은 `mktemp` 전용 디렉터리에 `umask 077`로 만들고
정상 종료·오류·신호 종료 시 삭제한다.
- 비밀값 대신 권한이 제한된 임시 파일 경로만 `kubectl` 인자로 전달한다. 생성
YAML은 두 `kubectl` 프로세스 사이의 파이프로만 전달하며 터미널에 출력하지
않는다.
- Secret 이름과 키만 선언형 리소스에서 참조한다.
- `gitea-admin`은 Keycloak OIDC 도입 후에도 비상용 로컬 관리자 계정으로
유지한다. `initialOnlyRequireReset` 모드이므로 Secret 값만 바꿔도 이미 생성된
관리자 계정 비밀번호는 바뀌지 않는다.
- Argo CD 전환 전 별도 ADR로 SOPS, Sealed Secrets 또는 외부 Secret 동기화
방식을 선택한다.
- 초기 구축 스크립트는 DB 비밀번호, 관리자 비밀번호, AIStor root 자격 증명,
라이선스를 회전하지 않는다. 회전은 소비자 재시작과 실제 접속 검증을 포함한
별도 운영 절차로 수행한다.
@@ -0,0 +1,38 @@
# AIStor 정적 Local PersistentVolume
상태(2026-07-23): 실제 클러스터 적용 및 바인딩 완료
이 기반 구성은 `/srv/k3s/aistor`에 마운트된 전용 XFS 파일시스템을 하나의 정적
Local PV에 연결한다.
| 리소스 | 값 |
| --- | --- |
| PersistentVolume | `aistor-data-local-pv` |
| StorageClass | `aistor-local-xfs-retain` |
| Kubernetes에 표시되는 용량 | `900Gi` |
| 접근 모드 | `ReadWriteOnce` |
| Reclaim 정책 | `Retain` |
| 바인딩 모드 | `WaitForFirstConsumer` |
| 필수 노드 | `donghyeon-system-product-name` |
ObjectStore pool은 이 StorageClass에 정확히 하나의 `900Gi` Claim을 요청한다.
`WaitForFirstConsumer`는 해당 Claim과 Local PV node affinity를 함께 반영한다.
StorageClass와 PV 모두 `Retain`을 사용하므로 Claim을 삭제해도 XFS 데이터는
지워지지 않지만, 복구와 재바인딩은 수동 작업이다.
적용하기 전에 호스트에서 다음 항목을 모두 확인한다.
```sh
findmnt -no SOURCE,TARGET,FSTYPE,OPTIONS /srv/k3s/aistor
df -hT /srv/k3s/aistor
kubectl get node donghyeon-system-product-name
```
첫 번째 명령은 전용 XFS 파티션이 정확한 경로에 마운트됐다고 출력해야 한다.
PV의 `900Gi` 용량은 하드 파일시스템 quota가 아니라 Kubernetes 바인딩
메타데이터다. 이 단일 노드 Local PV는 복제나 재해 복구를 제공하지 않는다.
현재 `data0-minio-aistor-pool-0-0` PVC가 `aistor-data-local-pv`에 Bound 상태이며
ObjectStore health는 `green`이다. `/srv/k3s/aistor`의 기존 XFS 메타데이터 사용량은
AIStor 객체 데이터와 별개일 수 있으므로 `du` 합계와 `df` 사용량을 동일시하지
않는다.
@@ -0,0 +1,27 @@
apiVersion: v1
kind: PersistentVolume
metadata:
name: aistor-data-local-pv
labels:
app.kubernetes.io/part-of: platform
app.kubernetes.io/component: object-storage
platform.hyeonworks.com/storage-tier: hdd
platform.hyeonworks.com/filesystem: xfs
spec:
capacity:
storage: 900Gi
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: aistor-local-xfs-retain
local:
path: /srv/k3s/aistor
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- donghyeon-system-product-name
@@ -0,0 +1,6 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- storage-class.yaml
- aistor-data-pv.yaml
@@ -0,0 +1,12 @@
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: aistor-local-xfs-retain
labels:
app.kubernetes.io/part-of: platform
app.kubernetes.io/component: object-storage
platform.hyeonworks.com/storage-tier: hdd
provisioner: kubernetes.io/no-provisioner
reclaimPolicy: Retain
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: false
@@ -0,0 +1,32 @@
# Observability Local PersistentVolumes
This kustomization reserves six workload-specific Local PV binding contracts on
the root SSD. Each owner has a dedicated `kubernetes.io/no-provisioner`
StorageClass with `WaitForFirstConsumer`, `Retain`, and volume expansion
disabled. Every PV is `ReadWriteOnce`, retains its data after claim deletion,
and is affined to `donghyeon-system-product-name`.
| Owner | Host path | Capacity | StorageClass | Directory owner |
| --- | --- | ---: | --- | --- |
| Prometheus | `/srv/k3s/ssd/observability/prometheus` | `20Gi` | `ssd-local-observability-prometheus-retain` | `1000:2000` |
| Grafana | `/srv/k3s/ssd/observability/grafana` | `2Gi` | `ssd-local-observability-grafana-retain` | `472:472` |
| Alertmanager | `/srv/k3s/ssd/observability/alertmanager` | `1Gi` | `ssd-local-observability-alertmanager-retain` | `1000:2000` |
| Alloy | `/srv/k3s/ssd/observability/alloy` | `2Gi` | `ssd-local-observability-alloy-retain` | `473:473` |
| Loki | `/srv/k3s/ssd/observability/loki` | `5Gi` | `ssd-local-observability-loki-retain` | `10001:10001` |
| Tempo | `/srv/k3s/ssd/observability/tempo` | `5Gi` | `ssd-local-observability-tempo-retain` | `10001:10001` |
The declared 35 GiB is a Kubernetes binding contract, not an ext4 directory
quota. Project quota is not active. All six directories share root-filesystem
free space, so the later operational protections remain mandatory:
- Prometheus `retentionSize=16GB` is not a whole-filesystem cap and does not cap
WAL plus head data.
- Loki's 512 MB setting limits WAL replay RAM; it is not a disk quota.
- Loki `disk_full_threshold=0.90` uses `Statfs` on the WAL filesystem.
- Tempo 2.10.7 has no WAL byte limit or disk-full guard.
- Root-filesystem 75%, 85%, and 90% alerts, apply/smoke `du` and `df` caps, and
acceptance of the root-90%-full ingest-stop runbook are still required.
Run `bash scripts/bootstrap/prepare-observability-local-paths.sh` to inspect the
read-only plan. The separately confirmed `--execute` mode prepares the exact
paths at mode `0770`; it does not apply Kubernetes resources.
@@ -0,0 +1,6 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- storage-classes.yaml
- persistent-volumes.yaml
@@ -0,0 +1,155 @@
apiVersion: v1
kind: PersistentVolume
metadata:
name: observability-prometheus-local-pv
labels:
app.kubernetes.io/part-of: observability
app.kubernetes.io/component: prometheus
spec:
capacity:
storage: 20Gi
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: ssd-local-observability-prometheus-retain
local:
path: /srv/k3s/ssd/observability/prometheus
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- donghyeon-system-product-name
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: observability-grafana-local-pv
labels:
app.kubernetes.io/part-of: observability
app.kubernetes.io/component: grafana
spec:
capacity:
storage: 2Gi
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: ssd-local-observability-grafana-retain
local:
path: /srv/k3s/ssd/observability/grafana
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- donghyeon-system-product-name
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: observability-alertmanager-local-pv
labels:
app.kubernetes.io/part-of: observability
app.kubernetes.io/component: alertmanager
spec:
capacity:
storage: 1Gi
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: ssd-local-observability-alertmanager-retain
local:
path: /srv/k3s/ssd/observability/alertmanager
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- donghyeon-system-product-name
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: observability-alloy-local-pv
labels:
app.kubernetes.io/part-of: observability
app.kubernetes.io/component: alloy
spec:
capacity:
storage: 2Gi
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: ssd-local-observability-alloy-retain
local:
path: /srv/k3s/ssd/observability/alloy
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- donghyeon-system-product-name
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: observability-loki-local-pv
labels:
app.kubernetes.io/part-of: observability
app.kubernetes.io/component: loki
spec:
capacity:
storage: 5Gi
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: ssd-local-observability-loki-retain
local:
path: /srv/k3s/ssd/observability/loki
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- donghyeon-system-product-name
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: observability-tempo-local-pv
labels:
app.kubernetes.io/part-of: observability
app.kubernetes.io/component: tempo
spec:
capacity:
storage: 5Gi
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: ssd-local-observability-tempo-retain
local:
path: /srv/k3s/ssd/observability/tempo
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- donghyeon-system-product-name
@@ -0,0 +1,53 @@
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: ssd-local-observability-prometheus-retain
provisioner: kubernetes.io/no-provisioner
reclaimPolicy: Retain
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: false
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: ssd-local-observability-grafana-retain
provisioner: kubernetes.io/no-provisioner
reclaimPolicy: Retain
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: false
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: ssd-local-observability-alertmanager-retain
provisioner: kubernetes.io/no-provisioner
reclaimPolicy: Retain
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: false
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: ssd-local-observability-alloy-retain
provisioner: kubernetes.io/no-provisioner
reclaimPolicy: Retain
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: false
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: ssd-local-observability-loki-retain
provisioner: kubernetes.io/no-provisioner
reclaimPolicy: Retain
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: false
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: ssd-local-observability-tempo-retain
provisioner: kubernetes.io/no-provisioner
reclaimPolicy: Retain
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: false
@@ -0,0 +1,12 @@
# pgAdmin SSD Local PV
pgAdmin 설정 데이터베이스와 세션 상태를 위한 2Gi Local PV입니다.
- 호스트 경로: /srv/k3s/ssd/pgadmin
- 노드: donghyeon-system-product-name
- StorageClass: ssd-local-pgadmin-retain
- 회수 정책: Retain
PVC나 배포를 제거해도 PV와 호스트 데이터는 자동 삭제하지 않습니다.
호스트 디렉터리는 적용 스크립트가 소유권과 파일시스템 경계를 검증한 뒤
생성합니다.
@@ -0,0 +1,6 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- storage-class.yaml
- pgadmin-pv.yaml

Some files were not shown because too many files have changed in this diff Show More