Add platform infrastructure configuration
This commit is contained in:
+43
@@ -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/
|
||||
@@ -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 캐시와 렌더 산출물 대신 재현 가능한 선언만 버전 관리합니다.
|
||||
|
||||
@@ -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은 비밀값이
|
||||
아니므로 기록할 수 있다.
|
||||
@@ -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과 명시 승인을
|
||||
필요로 한다.
|
||||
@@ -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을 따릅니다.
|
||||
@@ -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 회전을 수행하지 않는다.
|
||||
@@ -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 1–6
|
||||
checkbox를 체크하지 않고 Task 8을 시작하지 않습니다.
|
||||
@@ -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에서 참조한다.
|
||||
|
||||
+69
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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 변경을 수행하지 않는다.
|
||||
@@ -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
Reference in New Issue
Block a user