Add platform infrastructure configuration

This commit is contained in:
donghyeon-ka
2026-08-28 17:35:41 +09:00
parent fa76531e5b
commit 16c337bcc9
302 changed files with 83259 additions and 1 deletions
@@ -0,0 +1,127 @@
# k3s Secret 암호화 복구 drill
이 문서는 승인된 k3s Secret hardening의 post recovery bundle을 실제 복구할 수 있는지
검증하고, 파기 확인 뒤 운영 host에 비민감 evidence를 등록하는 수동 진입점이다. 운영
server에서 restore를 실행하는 runbook이 아니며 서비스, datastore, backup 또는 Secret을
자동으로 변경하지 않는다.
## 승인과 책임 경계
- 운영 장애 복구와 drill은 서로 다른 변경 승인으로 다룬다.
- SQLite와 embedded etcd restore 명령은 이 저장소의 자동 실행 스크립트로 제공하지 않는다.
- drill은 운영 host와 network, datastore, hostname이 격리된 일회용 VM/host에서 post bundle
복사본으로만 한다.
- recovery bundle에는 복구·복호화에 필요한 자료가 함께 있으므로 bundle 전체를 Secret으로
취급한다. 경로, token/config 내용, payload, hash와 escrow 위치를 terminal 결과나 원장에
기록하지 않는다.
- result만 반출하고 일회용 환경과 bundle 복사본을 파기한 뒤 운영 host에서 evidence를
등록한다.
## backend별 수동 복구 기준
SQLite 장애 복구 순서는 k3s 중지, 현재 DB의 timestamp quarantine 이동, 같은 server
token·config와 검증된 backup DB 복원, k3s 시작이다. 현재 DB를 즉시 삭제하거나 backup으로
덮어쓰지 않는다. 모든 단계는 별도 승인된 장애 runbook에서 사람이 대상과 rollback 지점을
확인한다.
embedded etcd는 현재 K3s 공식
[`--cluster-reset-restore-path` snapshot 복구 절차](https://docs.k3s.io/cli/etcd-snapshot#restoring-snapshots)를
사용한다. 단일 server와 다중 server 절차, token/config 일치 조건, reset 후 정상 시작 조건을
실행 시점의 공식 문서와 다시 대조한다. 보조 tutorial을 복구 권위로 사용하지 않는다.
## 일회용 환경의 격리 조건
복구 전에 다음 조건을 모두 만족시킨다.
- 운영 server와 다른 hostname, network와 datastore를 사용한다.
- hypervisor private switch 또는 network namespace에 default route와 upstream DNS가 없다.
- 원본 운영 API/datastore로 향하는 route가 없다.
- host firewall egress가 default-deny다.
- LAN, Internet, Slack, AIStor와 운영 API 연결 시험이 모두 실패한다.
- 복구된 workload/controller가 격리망 밖으로 통신할 수 없고 외부 host port를 열 수 없다.
- 운영과 같은 정확한 k3s version, server token과 config를 post bundle 복사본에서 사용한다.
복구 뒤에는 다음을 확인한다.
- k3s API ready
- node Ready
- encryption status가 `Enabled/reencrypt_finished`
- server hash와 local integrity가 모두 일치
- `kubectl get secrets --all-namespaces -o json` 조회가 오류 없이 완료
- bundle metadata의 복구 전 전체 Secret object count와 복구 후 count가 일치
- 위 격리 연결 시험을 다시 실행해 모두 실패
격리 조건과 live 검사가 모두 끝나기 전에는 result의 `isolation=pass`를 만들지 않는다.
## 세 mode 사용
모든 mode는 먼저 일반 사용자의 현재 kube context를 확정한다. 전체 script를 `sudo`
실행하지 않는다. root 권한은 운영 host의 권위 파일 stat/read/install에만 좁게 사용되므로
필요하면 같은 terminal에서 사전에 sudo credential을 갱신한다.
격리 복구 host에서 post metadata와 결과 출력 위치를 직접 지정한다. 출력은 기존 파일을
덮어쓰지 않는다.
```sh
bash scripts/validate/k3s-secret-encryption-restore-evidence.sh \
--emit-result --bundle-metadata BUNDLE_METADATA_FILE --output RESULT_FILE
```
script는 live API ready, node Ready, `Enabled/reencrypt_finished`, server hash, local integrity,
version/backend와 Secret object count를 직접 검사한다. 수동 격리 시험을 마친 현재 context를
추가 확인한 뒤에만 result를 만든다. result에는 credential, 경로, 민감 filename이나 hash가
없고 `destroyed`도 없다.
결과 파일을 운영 host로 반입한 뒤 일회용 환경과 bundle 복사본을 먼저 파기한다. 그 다음
운영 host에서 등록한다.
```sh
bash scripts/validate/k3s-secret-encryption-restore-evidence.sh \
--record --bundle-metadata BUNDLE_METADATA_FILE --result-file RESULT_FILE
```
`--record`는 post phase, metadata/result의 bundle-id·version·backend, 24시간 age와 운영
host 권위 post metadata의 일곱 field 전체를 확인한다. evidence 대상이 이미 있으면 중단한다.
prompt에는 정확히 `DESTROYED default`를 입력한다. 파기하지 않았거나 확인할 수 없으면
등록하지 않는다.
Phase 4의 읽기 전용 gate는 다음과 같다.
```sh
bash scripts/validate/k3s-secret-encryption-restore-evidence.sh --check
```
`--check`는 exact evidence field, 현재 권위 bundle-id/version/backend, live
`reencrypt_finished`와 local integrity, 30일 age, `destroyed=confirmed`를 모두 요구한다.
2026-08-09 live 전환은 `Enabled/reencrypt_finished`, hash·integrity·API·node 검사,
post bundle 기록과 최신 marker 검증까지 통과했다. 그러나 격리 restore·파기 evidence가
아직 없고 lifecycle 자동화, off-host 복제와 암호화 escrow 검증도 미완료다. 따라서 실제
운영 `--check`는 아직 성공으로 기록하지 않으며, 이 gate들이 완료되기 전에는 관측성
Phase 4를 열지 않는다.
## 입력 파일 보안
외부 metadata/result는 현재 사용자 소유 regular non-symlink, mode `0600`, non-empty여야
한다. parser는 exact allowlist `KEY=VALUE` 한 줄만 받아들이며 duplicate/unknown/empty key,
control character, malformed line, trailing data, `$(`와 backtick을 거부한다. 파일을 shell로
source하거나 `eval`하지 않는다. 입력 실패는 어떤 privileged install도 수행하기 전에
종료한다.
result와 evidence는 같은 directory의 mode `0600` temporary file을 완성하고 exact field를
다시 검사한 뒤 기존 target을 덮어쓰지 않는 atomic install로 만든다.
## 실행 원장
완료된 live hardening의 비민감 정정은
[2026-08-08 복구 명령 원장](/home/donghyeon/workspace/docs/platform/runbooks/2026-08-08-k3s-local-recovery-command-log.md)의
2026-08-09 addendum과
[2026-08-09 로컬 정책 기록](/home/donghyeon/workspace/docs/platform/runbooks/2026-08-09-k3s-secret-encryption-local-policy.md)에
있다. 아래 표는 아직 수행하지 않은 격리 restore drill의 실행 원장 형식이며, 완료된
live hardening에 별도 Task 6 실행 runbook은 만들지 않았고, 이를 암시하지 않는다.
| 시각 | 사전 상태 | backend | backup 검증 | 명령 | 종료 코드 | 후속 상태 |
|---|---|---|---|---|---:|---|
backup 위치, token/hash payload, 민감 파일 hash, encryption config 내용과 escrow 위치는
`검증 완료` 또는 `미완료`로만 쓴다. bundle-id, restore drill 시각과 pass/fail은 비밀값이
아니므로 기록할 수 있다.
+118
View File
@@ -0,0 +1,118 @@
# k3s Secret 암호화 수동 운영 절차
> **현재 경계(2026-08-09):** bootstrap의
> `--recovery-policy local-separate-disk-luks`, 물리 디스크 lineage validator,
> `LOCAL_RISK_ACCEPTED`, pre/post 동적 용량 gate는 구현·fixture 검증을 마쳤다.
> 수동 LUKS header 복구 proof, recovery close·잔류 없음 검사와 closed validator를
> 통과한 뒤 live 전환도 완료했다. 현재 Secret encryption은
> `Enabled/reencrypt_finished`이며 hash·integrity·API·node 검사와 post bundle 기록,
> 최신 bundle marker 검증이 모두 통과했다. lifecycle 자동화, 격리 restore drill,
> off-host 복제와 암호화 escrow 검증은 별도 미완료 gate이므로 관측성 Phase 4를 열지 않는다.
이 문서는 단일 k3s server에서 Kubernetes Secret 저장 암호화를 fail-stop 방식으로
전환하는 수동 절차다. 아래 상태 순서의 **live 전 역사적 기준선**은
`v1.36.2+k3s1`, 단일 Ready 노드 `donghyeon-system-product-name`, SQLite, Secret
encryption Disabled, API `readyz` pass였다. 현재 live 상태는 위 banner의
`Enabled/reencrypt_finished`다. 모든 단계는 운영자가 명시적으로 실행하고 확인한다.
자동화는 상태가 불명확하거나 검증이 실패하면 다음 단계로 진행하지 않는다.
## 상태 순서
다음 순서와 각 화살표 사이의 검증을 바꾸지 않는다.
```text
disabled_no_config
-> k3s secrets-encrypt enable
-> drop-in install + restart
-> transition_start + hashes_match
-> k3s secrets-encrypt rotate-keys
-> bounded wait for reencrypt_finished
-> final restart
-> Enabled/reencrypt_finished + server_hashes_match + local_integrity_match
```
각 상태 전환 전후에는 Task 2 validator를 해당 기대 상태로 실행해 version, 단일
Ready server, datastore, API `readyz`, status와 hash/integrity를 확인한다. validator
출력이나 운영 로그에 server token, 비밀번호, encryption config 본문을 기록하지
않는다.
## 사전 판정과 소유 위치
이미 `enabled_stable`로 분류되면 Task 1 effective-source resolver를 사용하여
`ExecStart`, `Environment`/`EnvironmentFile`, systemd drop-in, default 또는
alternate config 및 그 config drop-in을 순서대로 판정한다. 여기서
`secrets-encryption`과 provider의 **유효 소유 위치만** 확인한다.
계획한 `40-secrets-encryption.yaml`이 아닌 기존 위치가 owner이면 그 위치를 그대로
보존하고 config rewrite를 하지 않는다. 서로 충돌하는 두 owner, provider 판정 불가,
또는 기존 provider가 `aescbc`가 아닌 경우에는 자동 변경하지 않는다. 이 경우에는
별도 ADR을 먼저 승인해야 한다.
`enabled_stable`의 stage가 `reencrypt_finished`이면 rotation을 건너뛴다. stage가
`start`이면 Phase 4 전에 `--rotate-existing`을 사용한 명시 승인 재암호화만 수행한다.
## Bootstrap 진입 명령
인자 없는 명령은 상태만 읽고 변경하지 않는다.
```sh
cd /home/donghyeon/workspace/platform
bash scripts/bootstrap/apply-k3s-secret-encryption.sh
```
실제 전환 명령은 recovery volume이 열린 상태에서 backup root를 직접 지정하고 로컬
정책을 명시한다. 다음 명령은 maintenance 승인 전에는 실행하지 않는다.
```sh
cd /home/donghyeon/workspace/platform
bash scripts/bootstrap/apply-k3s-secret-encryption.sh \
--execute \
--backup-root /srv/recovery/k3s \
--recovery-policy local-separate-disk-luks
```
실행 확인 순서는 `APPLY <context>` → 자동 root·lineage·용량 검증 →
`RECOVERY <context>``ENCRYPTED <context>`
`LOCAL_RISK_ACCEPTED <context>`다. 어느 검사나 확인이 실패해도 다음 mutation으로
진행하지 않는다. 비밀번호나 복구 키를 이 명령의 인자·환경변수로 전달하지 않는다.
## 실행 단계
1. `disabled_no_config`을 validator로 확인한 뒤에만 `k3s secrets-encrypt enable`
실행한다.
2. repository의 host artifact를 root 소유, mode `0644`로 설치한다.
```sh
install -o root -g root -m 0644 \
infrastructure/security/k3s/40-secrets-encryption.yaml \
/etc/rancher/k3s/config.yaml.d/40-secrets-encryption.yaml
```
3. k3s를 재시작하고, `transition_start + hashes_match`가 validator로 확인될 때까지
중단한다.
4. 확인 뒤에만 `k3s secrets-encrypt rotate-keys`를 한 번 실행한다.
5. 제한된 시간 동안 `reencrypt_finished`를 기다린다. 시간 초과, API 실패, hash
mismatch 또는 local integrity mismatch이면 중단하고 조사한다.
6. 완료 상태를 확인한 후 final restart를 하고,
`Enabled/reencrypt_finished + server_hashes_match + local_integrity_match`를
다시 확인한다.
> **금지 및 중단 조건**
>
> - `transition_start` 확인 전에는 `rotate-keys`를 실행하지 않는다.
> - 중간 stage에서는 다른 rotation 명령을 실행하지 않는다.
> - drop-in을 자동 삭제하지 않는다.
> - live datastore 자동 restore 금지: 복구 판단과 수행은 별도 승인 절차다.
> - server token 또는 encryption key material을 명령 인자, 로그, ticket, Git에
> 남기지 않는다.
post bundle의 격리 복구, 결과 반출·파기와 evidence 등록은
[k3s Secret 암호화 복구 drill](k3s-secret-encryption-restore-drill.md)을 따른다.
## 재시도와 복구 경계
상태가 `disabled_no_config`, `transition_start`, `enabled_stable` 중 하나로 명확히
판정되지 않으면 재시도나 설정 변경을 하지 않는다. 특히 hash mismatch, provider
불명확, owner 충돌, API `readyz` 실패는 자동 보정 대상이 아니다. 라이브 datastore를
되돌리거나 Secret을 변경하는 동작도 이 절차의 권한 밖이며, 별도 ADR과 명시 승인을
필요로 한다.
+50
View File
@@ -0,0 +1,50 @@
# Phase 1 Gitea 수동 부트스트랩 진입점
상세 절차와 검증·롤백 기준의 단일 원본은
[중앙 Phase 1 Gitea bootstrap runbook](/home/donghyeon/workspace/docs/platform/runbooks/2026-07-22-phase1-gitea-bootstrap.md)입니다.
이 파일은 저장소에서 실행 명령을 찾기 위한 짧은 진입점만 제공합니다.
2026-07-23 기준 Phase 1 클러스터 적용과 내부 health, Gitea restricted
securityContext 검증을 완료했습니다. 이 진입점은 **신규 클러스터의 Gitea
baseline 최초 구축 전용**입니다. baseline에는 Keycloak OIDC, `id` host alias,
Keycloak 전용 egress 및 선언형 브랜딩을 포함하지 않습니다.
기존 `Deployment/gitea` 또는 `Secret/gitea-keycloak-oidc`가 있는 클러스터에는 이
스크립트를 재실행하지 않습니다. 전용 안전장치도 둘 중 하나를 발견하면 적용을
거부합니다. Keycloak OIDC와 브랜딩을 포함한 현재 목표 상태는 public discovery
전환을 마친 뒤 `scripts/bootstrap/apply-gitea-oidc.sh --execute`로만 적용하며,
2026-07-23 실제 클러스터에는 이 전용 경로로 적용을 완료했습니다. 현재
OIDC-enabled 클러스터에서 baseline 스크립트를 복구 수단으로 재실행하지 않습니다.
## 실행 순서
저장소 루트 `/home/donghyeon/workspace/platform`에서 중앙 runbook을 확인한
뒤 다음 순서를 지킵니다.
1. 전체 렌더·정적 검증
```sh
bash scripts/validate/render-phase1.sh
```
2. 신규 클러스터 Phase 1 baseline 최초 적용
```sh
bash scripts/bootstrap/apply-phase1-gitea.sh --execute
```
`apply-phase1-gitea.sh`는 안전장치로 검증을 다시 실행합니다. 검증기가 Chart
SHA-256 확인을 마친 여섯 manifest를 제한된 `/tmp` handoff 경로로 받아
`gitea.yaml` baseline만 적용하며, 함께 검증한 OIDC 목표 상태
`gitea-oidc.yaml`은 적용하지 않습니다. 검증 뒤 Chart를 다시 내려받거나
Kustomize를 다시 렌더링하지 않으며 취소·실패·신호 종료 때 임시 파일을
정리합니다.
내부 5단계에서는 `create-phase1-secrets.sh --execute`를 호출합니다. helper는 세
Secret이 모두 없을 때에만 최초 생성하고, 모두 있으면 값이 같은 완전한 계약인지
검증한 뒤 그대로 재사용합니다. 일부만 존재하면 중단합니다. 자격 증명 회전은
별도 runbook이 마련될 때까지 지원하지 않으므로 이 helper를 갱신 용도로 단독
실행하지 않습니다.
OIDC 적용·자격 증명 회전·클러스터 검증·Nginx 전환·롤백 방법은 이 파일에
복제하지 않고 중앙 runbook을 따릅니다.
+139
View File
@@ -0,0 +1,139 @@
# Phase 2 Keycloak·MinIO AIStor 수동 배포 진입점
상세 설계는 중앙 문서 `/home/donghyeon/workspace/docs/platform`에서 관리한다.
이 파일은 선언의 의존 순서와 검증 진입점만 기록한다.
## 상태
2026-07-23 기준 Keycloak-only 경로는 DB Secret 두 사본, DatabaseRole·Database,
additive NetworkPolicy, Operator·Server `26.7.0`, Ingress, `hyeonworks` realm,
confidential `gitea` client와 OIDC Secret까지 실제 적용했다. Traefik 최소 trust도
완료했다. Host Nginx의 `id.learn.hyeonworks.com` proxy와 Gitea OIDC·브랜딩 live
적용도 완료했고 OAuth source·정책·redirect·브랜딩 자동 검증을 통과했다. 실제
realm 사용자의 브라우저 login/callback/logout, 비상 관리자 로그인과 재시작
지속성 검증은 남아 있다. MinIO AIStor는 NetworkPolicy, namespace, AIStor 전용
두 Secret, 900Gi Retain Local PV, 공식 Operator와 단일 ObjectStore까지 실제
적용했다. `Initialized/green`, PVC Bound, ClusterIP-only와 인증된 S3
put/get/delete checksum 검증을 통과했다.
## 적용 전 렌더 검증
Helm `3.19.4``PATH`에 없다면 검증된 실행 파일의 절대 경로를 지정한다.
```sh
cd /home/donghyeon/workspace/platform
PLATFORM_HELM_BIN=/absolute/path/to/helm \
bash scripts/validate/render-phase2.sh
```
이 명령은 Phase 1 검증을 먼저 수행한 뒤 Phase 2 전체를 로컬 렌더한다. 실제
클러스터의 ObjectStore CRD 설치 여부와 무관하며 어떤 리소스도 적용하지 않는다.
## Keycloak-only 적용 진입점
AIStor license와 ObjectStore 준비를 기다리지 않고 Keycloak만 먼저 배포할 때는
다음 전용 스크립트를 사용한다. Helm과 AIStor Secret은 요구하지 않는다.
```sh
cd /home/donghyeon/workspace/platform
bash scripts/bootstrap/apply-keycloak.sh --execute
```
스크립트는 Keycloak namespace, Keycloak DB Secret 두 개, 공용 PostgreSQL의
`DatabaseRole`·`Database`·`NetworkPolicy`, Keycloak Operator, Keycloak CR과
Ingress를 순서대로 렌더·검증·적용하고 각 readiness를 기다린다. 먼저 현재
Kubernetes context와 API server를 출력한 뒤 `APPLY`를 정확히 입력해야 한다.
두 DB Secret이 모두 없으면 비밀번호를 화면에 표시하지 않고 두 번 입력받은 뒤
`APPLY KEYCLOAK SECRETS`를 한 번 더 확인한다. 두 Secret이 이미 있으면 값을
바꾸지 않고 계약만 검증해 재사용한다.
자동 적용 전에 두 Secret을 안전하게 미리 생성하려면 다음 명시적 비대화형 모드를
사용할 수 있다.
```sh
kubectl apply --filename=infrastructure/namespaces/phase2/keycloak.yaml
bash scripts/bootstrap/create-keycloak-secrets.sh --generate --execute
bash scripts/bootstrap/apply-keycloak.sh --execute
```
`--generate --execute`는 OpenSSL CSPRNG로 32바이트 난수를 생성해 mode `0600`
임시 파일에만 기록한다. 생성값은 argv나 화면에 출력하지 않으며 두 플래그 자체를
자동 생성 승인으로 취급한다. 두 Secret이 이미 있으면 새 값을 만들거나 회전하지
않고 기존 계약을 검증해 그대로 재사용한다.
독립 실행하는 Secret 생성기는 `platform-data``keycloak` namespace가 먼저
존재해야 하므로 위 첫 명령을 생략하지 않는다.
위 Keycloak-only 명령은 실제 완료됐으며 재실행 시 기존 Secret을 회전하지 않고
계약과 readiness를 다시 검증한다. 실제 결과는 중앙
`runbooks/2026-07-23-keycloak-gitea-oidc-cutover.md`에 기록한다.
중간 단계가 실패해도 namespace, Secret, DB 리소스, Operator 또는 Keycloak
리소스를 자동 삭제하지 않는다. DB reclaim policy는 `Retain`으로 유지하며 원인을
해결한 뒤 같은 명령을 다시 실행하는 것이 복구 경로다. Host Nginx의 Keycloak
정적 hold 제거와 Gitea OIDC 인증 소스 등록은 이 스크립트 범위가 아니다.
## AIStor 실제 적용 및 재검증
실제 적용에는 다음 진입점을 사용했다.
```sh
cd /home/donghyeon/workspace/platform
PLATFORM_HELM_BIN=/home/donghyeon/.local/bin/helm \
bash scripts/bootstrap/apply-aistor.sh \
--license-file /home/donghyeon/.secrets/aistor/minio.license \
--root-config-file /home/donghyeon/.secrets/aistor/root.env \
--execute
bash scripts/validate/aistor-s3-smoke.sh --execute
```
첫 스크립트가 수행하고 검증한 순서는 다음과 같다.
1. Phase 1과 Keycloak-only 리소스가 정상이며 Host Nginx·Gitea OIDC 작업과 AIStor
작업이 서로 독립임을 확인한다.
2. AIStor Operator·ObjectStore용 저장소 소유 NetworkPolicy를 먼저 렌더하고
k3s CNI의 승인 흐름과 차단 흐름을 검증한다. webhook,
Kubernetes API, license 확인, DNS, ObjectStore 내부 통신과 승인된 S3 소비자를
빠뜨리지 않는다.
- `infrastructure/controllers/aistor-operator`
- `services/minio-aistor`
3. 정확히 `aistor`, `object-storage` namespace만 검토·적용한다.
4. `/srv/k3s/aistor`의 XFS mount·권한과 예상 밖 StorageClass 소비자가 없음을
확인한 뒤 `infrastructure/storage/aistor-local-pv`를 적용한다.
5. AIStor 두 Secret만 two-or-none으로 관리하는 전용 helper를 실행한다.
기존 four-or-none `create-phase2-secrets.sh`는 현재 Keycloak-only 상태에서
실행하지 않는다.
6. NetworkPolicy gate 통과 후 `infrastructure/controllers/aistor-operator`와 해당
정책을 적용하고 CRD, 두 Operator, admission webhook이 준비될 때까지 기다린다.
7. `services/minio-aistor`와 해당 정책을 적용하고 하나의 서버·하나의 900Gi 볼륨,
`ClusterIP` S3/Console, 예상 PVC·PV binding을 확인한다. 실제 workload에서도
승인·차단 흐름을 다시 시험한다.
8. S3 put/get/delete와 checksum, 관리 포트 비노출을 확인한다.
1~8의 자동 검증은 완료했다. ObjectStore Pod 재생성 뒤 기존 영구 객체가 유지되는지
보는 별도 지속성 시험은 운영 데이터와 구분된 시험 버킷·승인 창을 정한 뒤 수행한다.
Argo CD 인계와 Host Nginx 변경은 이 Phase 2 수동 적용 범위에 포함하지 않는다.
Istio와 sidecar injection도 적용하지 않는다.
## Secret 안전 조건
- Keycloak-only 경로의 두 DB Secret 중 일부만 존재하면
`create-keycloak-secrets.sh`가 생성·회전을 거부한다. 두 Secret은
`platform-data``keycloak` namespace에 동일한 `keycloak` 사용자와 동일한
비밀번호로 존재해야 한다.
- Keycloak-only 스크립트는 AIStor license와 ObjectStore root Secret을 읽거나
요구하거나 수정하지 않는다.
- Keycloak DB Secret과 AIStor 두 Secret은 모두 존재하지만 서로 독립 계약이다.
- 기존 four-or-none helper는 이 상태에서 중단하는 것이 정상이며 우회하거나
Keycloak Secret을 삭제하지 않는다.
- AIStor 전용 helper는 AIStor 두 Secret이 모두 없거나 모두 존재할 때만
진행하고 one-of-two 상태에서는 자동 삭제·덮어쓰기 없이 중단한다.
- 두 AIStor Secret이 모두 존재하면 타입·정확한 키, license file 동일성, root
configuration 형식만 검증하고 값을 바꾸지 않는다.
- license payload, root 사용자·비밀번호, DB 비밀번호는 Git, argv, 표준 출력에
기록하지 않는다.
- bootstrap helper는 credential 또는 license 회전을 수행하지 않는다.
+189
View File
@@ -0,0 +1,189 @@
# Phase 3 비공개 관리 UI 적용 절차
상세 설계와 실행 원장은 /home/donghyeon/workspace/docs/platform에서 관리합니다.
이 문서는 실제 적용 순서와 입력 문자열만 요약합니다.
## 현재 완료 경계
2026-07-24 기준 선언형 YAML, 고정 차트 검증, dry-run 스크립트는 구현·검증했습니다.
pgAdmin bootstrap password는 다음 경로에 `0600`으로 생성했습니다.
- /home/donghyeon/.secrets/pgadmin/bootstrap-password
private DNS, Gitea CoreDNS 전환, 공유기 DNS와 Tailscale split DNS 등록,
Cloudflare DNS-01 관리 인증서, Keycloak 관리 OIDC, AIStor Console OIDC
profile, pgAdmin, Host Nginx cutover와 자동 수용 시험을 실제 적용하고
검증했습니다.
현재 AIStor는 `green`, pgAdmin Deployment는 `1/1`, PVC는 2Gi SSD Local PV에
`Bound`입니다. 인증서 갱신 dry-run과 S3 bucket/object CRUD도 통과했습니다.
남은 단계는 브라우저 OIDC 수동 시험뿐입니다.
- /home/donghyeon/.secrets/certbot/cloudflare.ini
공유기 DHCP DNS와 Tailscale 관리 화면도 서버에서 자동 변경하지 않습니다.
## 1. 정적 렌더
cd /home/donghyeon/workspace/platform
PLATFORM_HELM_BIN=/home/donghyeon/.local/bin/helm \
bash scripts/validate/render-admin-services.sh
pgAdmin Chart archive와 OCI digest, AIStor ObjectStore Chart archive를 검증한 뒤
Secret 리소스가 없는 manifest만 렌더합니다.
## 2. Keycloak 외부망 시간 초과 대조
실패 단말에서 시각을 기록하고 다음을 실행합니다.
date --iso-8601=seconds
dig @1.1.1.1 A id.learn.hyeonworks.com
dig @1.1.1.1 AAAA id.learn.hyeonworks.com
curl -4 -vkI --connect-timeout 10 https://id.learn.hyeonworks.com/
curl -4 -vk --connect-timeout 10 \
https://id.learn.hyeonworks.com/realms/hyeonworks/.well-known/openid-configuration
같은 시간대 Host Nginx access log에 요청이 없으면 서버 설정이 아니라 실패
단말의 VPN·보안 필터·통신사 경로 문제로 판정합니다.
## 3. private DNS
상태: 2026-07-24 실제 적용 완료. Gitea live `hostAliases` 제거도 완료.
먼저 dry-run을 실행한 뒤 실제 적용합니다.
bash scripts/bootstrap/apply-private-dns.sh
bash scripts/bootstrap/apply-private-dns.sh --execute
확인 문자열은 APPLY default입니다. 이후 공유기 DHCP DNS를 192.168.0.107,
Tailscale의 learn.hyeonworks.com 제한 nameserver를 100.92.240.34로 등록합니다.
공개 DNS에는 두 admin 도메인을 만들지 않습니다.
서버의 공유기는 TP-Link 계열로 식별됐습니다. 정확한 TP-Link·Tailscale 메뉴와
검증 방법은 infrastructure/networking/private-dns/host/README.md를 따릅니다.
CoreDNS 전환 뒤 Gitea OIDC profile을 다시 적용하면 기존 Pod의 임시
hostAliases도 제거됩니다.
PLATFORM_HELM_BIN=/home/donghyeon/.local/bin/helm \
bash scripts/bootstrap/apply-gitea-oidc.sh --execute
## 4. 운영자 Secret 파일
Cloudflare Dashboard의 `My Profile > API Tokens > Create Token`에서
`Edit Zone DNS` 템플릿을 선택합니다. 권한은 `Zone:DNS:Edit`, Zone resource는
`Include > Specific zone > hyeonworks.com`으로 제한합니다.
공식 절차:
https://developers.cloudflare.com/fundamentals/api/get-started/create-token/
파일 내용은 `dns_cloudflare_api_token = ...` 한 줄이며 저장소에 만들지 않습니다.
sudo install -d -o root -g root -m 0700 /home/donghyeon/.secrets/certbot
sudoedit /home/donghyeon/.secrets/certbot/cloudflare.ini
sudo chown root:root /home/donghyeon/.secrets/certbot/cloudflare.ini
sudo chmod 0600 /home/donghyeon/.secrets/certbot/cloudflare.ini
pgAdmin 내부 비상 관리자 비밀번호는 이미 준비했습니다. 재생성이 필요한 경우에만
다음을 실행합니다. 실행하면 기존 비밀번호 파일이 교체됩니다.
install -d -m 0700 /home/donghyeon/.secrets/pgadmin
umask 077
openssl rand -base64 32 | tr -d '\n' \
> /home/donghyeon/.secrets/pgadmin/bootstrap-password
chmod 0600 /home/donghyeon/.secrets/pgadmin/bootstrap-password
파일 값은 출력하거나 runbook에 복사하지 않습니다.
## 5. admin 인증서
상태: 2026-07-24 실제 발급 완료. 만료일 2026-10-21, 자동 갱신 등록.
서비스 적용 전에 exact SAN 두 개만 발급합니다.
bash scripts/bootstrap/apply-host-nginx-admin.sh \
--execute \
--certificate-only \
--certbot-email you@example.com
확인 문자열은 APPLY입니다. 최초 실행은 공식 Cloudflare Certbot snap plugin을
설치하고 deploy hook을 등록합니다. wildcard 인증서는 만들지 않습니다.
## 6. Keycloak client와 그룹
상태: 2026-07-24 실제 적용 완료. `donghyeon.kang` 두 관리자 그룹 배정.
먼저 dry-run으로 대상을 확인합니다. donghyeon.kang은 실제 realm 사용자명으로
바꿀 수 있습니다.
bash scripts/bootstrap/configure-keycloak-admin-oidc.sh
bash scripts/bootstrap/configure-keycloak-admin-oidc.sh \
--execute \
--object-admin donghyeon.kang \
--db-admin donghyeon.kang
확인 문자열은 APPLY default입니다. 두 OIDC Secret의 값은 출력하지 않습니다.
## 7. 관리 서비스
상태: 2026-07-24 실제 적용 완료.
최종 검증 결과:
object-storage/minio-aistor: green, drivesOnline=1
platform-admin/pgadmin: 1/1
platform-admin/pgadmin PVC: Bound, pgadmin-data-local-pv
storage-admin Traefik HTTP: 200
db-admin Traefik HTTP: 302
PLATFORM_HELM_BIN=/home/donghyeon/.local/bin/helm \
bash scripts/bootstrap/apply-admin-services.sh \
--execute \
--pgadmin-password-file \
/home/donghyeon/.secrets/pgadmin/bootstrap-password
확인 문자열은 APPLY default입니다. 실패 시 AIStor spec은 복원하고 pgAdmin은
0 replica로 내리지만 pgAdmin PVC/PV와 AIStor 데이터는 삭제하지 않습니다.
적용 중 pgAdmin rollout 뒤 출력이 잠시 없으면 AIStor health를 확인하는
구간입니다. 현재 스크립트는 15초마다 상태와 경과 시간을 출력합니다. 300초 뒤
실패하면 자동 rollback하므로 강제로 다시 적용하기 전에 중앙 runbook의
`pgAdmin 완료 뒤 AIStor health 대기` 항목을 확인합니다.
## 8. Nginx 최종 전환
상태: 2026-07-24 실제 적용 완료.
활성 설정 SHA-256:
f07d558d7ebbe09bdd61f3422fe8dcdaaaa6d1160d0872c06538d2bee193908f
bash scripts/bootstrap/apply-host-nginx-admin.sh --execute
확인 문자열은 APPLY입니다. 설정을 timestamp backup한 뒤 LAN 요청의 정상 응답,
허용 목록 밖 loopback 요청의 403, unknown SNI 거부와 인증서 갱신 dry-run을
검사합니다.
## 9. 수용 시험
상태: 2026-07-24 자동 시험 완료.
READ-ONLY CHECK PASS
AISTOR_S3_SMOKE_PASS
ADMIN UI SMOKE PASS
bash scripts/validate/admin-ui-smoke.sh --execute --run-s3
확인 문자열은 APPLY이며 S3 하위 검사에서도 APPLY를 한 번 더 입력합니다.
재검증이 필요할 때만 다시 실행합니다.
마지막으로 LAN 또는 Tailscale 연결 상태에서 브라우저로 다음을 시험합니다.
- https://storage-admin.learn.hyeonworks.com
- `/platform-object-admins` 사용자의 Keycloak OIDC 로그인 성공
- 해당 group이 없는 사용자의 관리 기능 접근 거부
- https://db-admin.learn.hyeonworks.com
- `/platform-db-admins` 사용자의 Keycloak OIDC 로그인 성공
- 해당 group이 없는 사용자의 로그인 거부
pgAdmin 내부 비상 관리자 로그인은 OIDC 장애 대응용으로만 유지합니다.
@@ -0,0 +1,701 @@
# Phase 4 관측성 접근·알림 전환 절차
상세 설계와 실행 원장은 `/home/donghyeon/workspace/docs/platform`에서 관리합니다.
이 문서는 현재 live substrate를 보존하면서 rules·alerts와 Host Nginx를 마지막에
전환하는 권위 실행 순서를 요약합니다. Secret 값, token, Cookie, 사용자 ID와
webhook 원문은 출력하거나 문서에 복사하지 않습니다.
## 현재 완료 경계
2026-08-15 기준 Blackbox substrate, metric target과 rules-alerts는 live입니다.
- Prometheus active target `30`, healthy `30`, unhealthy `0`
- Grafana와 Blackbox Exporter Deployment 각각 `1/1` Ready
- Host Nginx는 Grafana deny-only guard 상태
- Grafana OIDC workload와 `grafana-keycloak-oidc` Secret 참조는 live
- Slack Secret과 risk deployment evidence는 live이며 off-host Slack DR은 deferred
- platform dashboard ConfigMap `5`, platform PrometheusRule `4`,
`AlertmanagerConfig/platform-alertmanager` `1`이 rollback ID `20260814T145009Z`에서 accepted
- generated receiver는
`observability/platform-alertmanager/platform-slack` exact singleton
- full Nginx cutover와 browser OIDC·Slack firing/resolved·external-client acceptance는 미실행
rules-alerts 실행 때 다음 두 선행 조건은 모두 충족됐습니다. 이후 재실행이나 복구에서도
같은 gate를 생략하지 않습니다.
1. 아래 runbook URL이 HTTPS `200`으로 도달하고 모든 alert의 `runbook_url`과 일치한다.
2. 운영자가 만든 Slack webhook 입력 파일과 recovery evidence가 준비된다.
```text
https://git.learn.hyeonworks.com/donghyeon.kang/project-infra/src/branch/main/docs/runbooks/2026-07-31-observability-access-cutover.md
```
## 1. 공통 rollback transaction
하나의 shell에서 같은 rollback ID를 끝까지 유지합니다.
```bash
cd /home/donghyeon/workspace/platform
OBS_ROLLBACK_ID="$(date -u +%Y%m%dT%H%M%SZ)"
[[ "$OBS_ROLLBACK_ID" =~ ^[0-9]{8}T[0-9]{6}Z$ ]]
[[ "$OBS_ROLLBACK_ID" != 20260814T080303Z ]]
OBS_ROLLBACK_ROOT="/var/lib/hyeonworks/platform-rollbacks/observability-$OBS_ROLLBACK_ID"
sudo -n /usr/bin/test ! -e "$OBS_ROLLBACK_ROOT"
sudo -n /usr/bin/test ! -L "$OBS_ROLLBACK_ROOT"
sudo -n /usr/bin/mkdir --mode=0700 -- "$OBS_ROLLBACK_ROOT"
[[ "$(sudo -n /usr/bin/stat -c '%F|%u:%g|%a' -- "$OBS_ROLLBACK_ROOT")" == \
'directory|0:0|700' ]]
export PLATFORM_OBSERVABILITY_ROLLBACK_ID="$OBS_ROLLBACK_ID"
[[ "$PLATFORM_OBSERVABILITY_ROLLBACK_ID" == "$OBS_ROLLBACK_ID" ]]
```
pre-absence, 단 한 번의 `mkdir`, metadata 또는 active-ID equality가 실패하면 STOP하고 그
collision/error ID를 재사용하지 않습니다. 자동 rollback 뒤에도 생성된 root를 삭제하지 않습니다.
## 2. 기존 substrate와 deny guard 확인
```bash
bash scripts/bootstrap/apply-private-dns.sh
bash scripts/bootstrap/apply-host-nginx-observability.sh
bash scripts/validate/validate-blackbox-edge-source.sh
```
private DNS와 metrics/deny guard 자체를 다시 적용해야 할 때만 다음 mutation을
순서대로 실행합니다. 각 명령의 화면 지시와 정확히 일치하는 확인 문자열만 입력합니다.
```bash
: "${CERTBOT_EMAIL:?set the operator-managed Certbot contact email}"
bash scripts/bootstrap/apply-private-dns.sh --execute
bash scripts/bootstrap/apply-host-nginx-observability.sh \
--execute --metrics-guard-only
metrics_status="$(curl --disable --silent --show-error --output /dev/null \
--write-out '%{http_code}' \
--resolve git.learn.hyeonworks.com:443:127.0.0.1 \
https://git.learn.hyeonworks.com/metrics)"
[[ "$metrics_status" == 404 ]]
bash scripts/bootstrap/apply-host-nginx-observability.sh \
--execute --certificate-only --certbot-email "$CERTBOT_EMAIL"
bash scripts/bootstrap/apply-host-nginx-observability.sh \
--execute --grafana-deny-guard-only
```
DNS/guard를 다시 적용했는지와 무관하게, **새 rollback ID마다** Blackbox source
proof를 반드시 새로 만듭니다. Task 6 retry에서는 failed ID `20260814T080303Z`를 거부하고
controller가 발급한 fresh ID와 현재 active environment가 exact equality인지 먼저 확인합니다.
```bash
FRESH_TASK6_ROLLBACK_ID="$OBS_ROLLBACK_ID"
[[ "$FRESH_TASK6_ROLLBACK_ID" != 20260814T080303Z ]]
[[ "${PLATFORM_OBSERVABILITY_ROLLBACK_ID:?active rollback ID is required}" == \
"$FRESH_TASK6_ROLLBACK_ID" ]]
set +e
bash scripts/validate/validate-blackbox-edge-source.sh \
--execute --context default
BLACKBOX_RC=$?
set -e
printf 'BLACKBOX_RC=%d\n' "$BLACKBOX_RC"
[[ "$BLACKBOX_RC" -eq 0 ]]
[[ "$(sudo -n /usr/bin/stat -c '%F|%u:%g|%a|%h' -- \
"/var/lib/hyeonworks/platform-rollbacks/observability-${FRESH_TASK6_ROLLBACK_ID}/blackbox-source-proof.env")" == \
'regular file|0:0|600|1' ]]
```
이 proof는 같은 rollback ID, active deny hash, 24시간 이내 시각과 세 private
hostname의 exact `403`에 결속되어야 합니다. validator는 한 번만 호출하고 operator가 exact
`PROVE BLACKBOX PRIVATE EDGE default`를 입력한 뒤 `BLACKBOX PRIVATE EDGE SOURCE PASS`, immediate
RC `0`, normalized proof metadata `regular|0:0|600|1`를 모두 확인합니다. 하나라도 실패하거나
불명확하면 STOP하고 new ID를 보존하며 같은 ID로 validator나 Task 6를 재시도하지 않습니다.
proof content는 읽지 않습니다.
preflight/live residue는 absolute zero가 아니라 다음 attested preexisting name-only baseline의
unchanged 계약입니다.
```text
/tmp/platform-k3s-encryption.Mskzy3
/tmp/platform-observability-access-apply.oeNcfI
/tmp/platform-observability-access-apply.Im02dz
/tmp/platform-observability-slack-gate.LYhYbv
```
`Mskzy3`는 8/1 empty evidence, `oeNcfI`/`Im02dz`는 8/12 recorded evidence,
`LYhYbv`는 private filename 두 개만 attested된 failed-live evidence입니다. 네 root는 content를
읽거나 삭제하지 않습니다. baseline name set unchanged, matching executable process `0`, current
preflight/live newly-created matching-root delta `0`을 요구합니다. unknown/new root는 broad
delete하지 않고 STOP/identity review합니다.
## 3. Secret과 Grafana OIDC recovery evidence
먼저 K3s encryption과 restore evidence를 각각 새 process에서 검사합니다.
```bash
bash scripts/validate/k3s-secret-encryption.sh --expect-reencrypted
bash scripts/validate/k3s-secret-encryption-restore-evidence.sh --check
```
입력 파일은 현재 사용자 소유 `0600`, non-symlink, link count 1이어야 합니다.
값을 shell 변수, argv, stdout 또는 runbook에 넣지 않습니다.
```bash
bash scripts/bootstrap/create-observability-secrets.sh \
--execute --grafana-admin \
--grafana-admin-user-file /home/donghyeon/.secrets/grafana/admin-user \
--grafana-admin-password-file /home/donghyeon/.secrets/grafana/admin-password
```
Slack Secret bootstrap 직전에 별도 내장 Windows SSD의 기존 KDBX에 same-host encrypted
disaster-recovery copy를 준비합니다. 이것은 일반 K3s restart나 host reboot용 사본이 아니라
datastore·Secret·bootstrap state 손실 때를 위한 것입니다. 기본 no-argument 실행은 고정
contract만 출력하며 SSD, KDBX, webhook, sudo에 접근하지 않습니다. 지원되는 interface는
다음 두 개뿐입니다.
```bash
bash scripts/bootstrap/backup-slack-webhook-recovery.sh
bash scripts/bootstrap/backup-slack-webhook-recovery.sh \
--execute \
--slack-webhook-file /home/donghyeon/.secrets/alertmanager/slack-webhook
```
execute에서 `SLACK_KEEPASS_RECOVERY=NOOP`는 exact entry의 verified read-only no-op이고,
`SLACK_KEEPASS_RECOVERY=COMMITTED`는 durable pre-change backup을 만든 verified commit입니다.
둘 다 source-based unmount proof와 private work/socket/helper cleanup 뒤에만 성공하며
`WINDOWS_SSD_UNMOUNTED=PASS`, `OFF_HOST_RECOVERY_SATISFIED=NO`를 출력합니다. lost response,
post-commit verification failure 또는 cleanup/unmount ambiguity는 자동 재시도하지 않고
`SLACK_KEEPASS_RECOVERY=MANUAL_RECOVERY_REQUIRED`로 중단하며 main과 backup을 보존합니다.
webhook payload, KeePassXC master password, hash·encoding·size·URL component 또는 protected
KDBX output을 terminal, argv, environment, log, runbook이나 plaintext 파일에 남기지 않습니다.
This local encrypted copy does not authorize RECOVERY SLACK default when the
approved gate requires off-host escrow. Do not continue the Secret bootstrap
until that independent prerequisite is literally true.
Slack에는 서로 다른 두 경로가 있습니다. off-host disaster recovery를 완료로 판정할 때만
strict recovery evidence를 검사합니다.
```bash
bash scripts/bootstrap/create-observability-secrets.sh \
--check-slack-recovery-evidence
```
현재 사용자가 승인한 operational risk path는 off-host Slack DR이 아직 deferred인 사실을
기록하고 deployment evidence를 만듭니다. 이 경로는 DR-complete을 주장하지 않습니다.
```bash
bash scripts/bootstrap/create-observability-secrets.sh \
--execute --slack-webhook \
--slack-webhook-file /home/donghyeon/.secrets/alertmanager/slack-webhook \
--accept-no-off-host-slack-recovery
```
도구가 요구하는 정확한 확인은 `ACCEPT NO OFF-HOST SLACK RECOVERY default`입니다.
이 risk path 밖에서 kubectl로 Secret을 수동 생성하지 않습니다. off-host 복구 증거가
없을 때 거짓 `RECOVERY SLACK default` 확인을 입력하지 않습니다.
위 risk path 또는 실제 off-host recovery evidence가 준비된 경우 Slack Secret bootstrap은
이미 완료된 상태이므로, deployment gate와 다음 checker만 실행합니다.
```bash
bash scripts/bootstrap/configure-keycloak-grafana-oidc.sh --execute
bash scripts/bootstrap/create-observability-secrets.sh \
--check-grafana-recovery-evidence
bash scripts/bootstrap/create-observability-secrets.sh \
--check-slack-deployment-evidence
bash scripts/bootstrap/configure-keycloak-grafana-oidc.sh \
--check-recovery-evidence
```
기존 Secret의 payload가 다르면 자동 rotation하지 않고 중단합니다. UID drift나 API
결과 불명도 자동 삭제로 처리하지 않습니다.
Grafana admin/OIDC object가 이미 exact live state이면 도구는 credential을 회전하거나
workload를 다시 쓰지 않고 기존 payload를 재사용하며 recovery evidence만 검증·갱신합니다.
exact state가 아닌데 권위 prior와 ownership을 증명할 수 없으면 자동 수렴시키지 않습니다.
## 4. 권위 inventory로 rules-alerts handoff 생성
현재 Blackbox substrate가 이미 live이므로 `target-initial`을 지금 다시 캡처하지
않습니다. 실행 당시 보존한 두 phase만 새 `0700` output root에 복제하고 renderer가
schema, phase, mode, link count와 hash를 다시 검증하게 합니다.
먼저 공개 runbook이 실제로 게시되었는지 확인합니다. `200`이 아니면 renderer와
rules-alerts apply를 실행하지 않습니다.
```bash
RUNBOOK_URL='https://git.learn.hyeonworks.com/donghyeon.kang/project-infra/src/branch/main/docs/runbooks/2026-07-31-observability-access-cutover.md'
runbook_status="$(curl --disable --silent --show-error --location --output /dev/null \
--write-out '%{http_code}' --connect-timeout 3 --max-time 10 \
"$RUNBOOK_URL")"
[[ "$runbook_status" == 200 ]]
```
```bash
SOURCE_METRIC_ROOT=/tmp/platform-observability-metrics.VUpsZn
METRIC_ROOT="$(mktemp -d /tmp/platform-observability-metrics.XXXXXX)"
chmod 0700 "$METRIC_ROOT"
[[ "$(realpath --canonicalize-existing -- "$SOURCE_METRIC_ROOT")" == "$SOURCE_METRIC_ROOT" ]]
[[ "$(stat -c '%F|%u:%g|%a|%h' -- "$SOURCE_METRIC_ROOT")" == \
'directory|1000:1000|700|4' ]]
declare -A SOURCE_METRIC_IDENTITY=()
for phase in target-initial post-substrate; do
[[ -d "$SOURCE_METRIC_ROOT/$phase" && ! -L "$SOURCE_METRIC_ROOT/$phase" ]]
[[ "$(realpath --canonicalize-existing -- "$SOURCE_METRIC_ROOT/$phase")" == \
"$SOURCE_METRIC_ROOT/$phase" ]]
[[ "$(stat -c '%F|%u:%g|%a|%h' -- "$SOURCE_METRIC_ROOT/$phase")" == \
'directory|1000:1000|700|2' ]]
install -d -m 0700 -- "$METRIC_ROOT/$phase"
for file in inventory.json inventory.sha256; do
source_file="$SOURCE_METRIC_ROOT/$phase/$file"
destination_file="$METRIC_ROOT/$phase/$file"
[[ -f "$source_file" && ! -L "$source_file" ]]
[[ "$(realpath --canonicalize-existing -- "$source_file")" == "$source_file" ]]
[[ "$(stat -c '%F|%u:%g|%a|%h' -- "$source_file")" == \
'regular file|1000:1000|600|1' ]]
SOURCE_METRIC_IDENTITY["$phase/$file"]="$(stat -c '%d:%i|%F|%u:%g|%a|%h|%s|%Y|%Z' -- \
"$source_file")|$(sha256sum -- "$source_file" | awk '{print $1}')"
cp --no-dereference --reflink=never -- "$source_file" "$destination_file"
chmod 0600 "$destination_file"
[[ "$(stat -c '%F|%u:%g|%a|%h' -- "$destination_file")" == \
'regular file|1000:1000|600|1' ]]
cmp -s -- "$source_file" "$destination_file"
done
done
[[ "$(find "$METRIC_ROOT" -mindepth 1 -maxdepth 2 -printf '%P\n' | LC_ALL=C sort)" == \
$'post-substrate\npost-substrate/inventory.json\npost-substrate/inventory.sha256\ntarget-initial\ntarget-initial/inventory.json\ntarget-initial/inventory.sha256' ]]
for phase in target-initial post-substrate; do
for file in inventory.json inventory.sha256; do
source_file="$SOURCE_METRIC_ROOT/$phase/$file"
[[ "$(stat -c '%d:%i|%F|%u:%g|%a|%h|%s|%Y|%Z' -- "$source_file")|$(sha256sum -- \
"$source_file" | awk '{print $1}')" == "${SOURCE_METRIC_IDENTITY["$phase/$file"]}" ]]
done
done
PLATFORM_HELM_BIN=/home/donghyeon/.local/bin/helm \
bash scripts/validate/render-observability-access.sh \
--component rules-alerts --verified-output-dir "$METRIC_ROOT"
```
failed transaction handoff `/tmp/platform-observability-metrics.LNzksC`는 read-only immutable
evidence로 보존하고 source, destination 또는 live apply input으로 재사용하지 않습니다.
기존 attested path/fingerprint identity만 보존·비교하며 inventory body나 private file content를
다시 읽지 않습니다. fresh destination은 renderer 전 exact two phase directories만 가집니다.
renderer와 live apply는 이 절에서 byte-preserving copy와 metadata/hash/count revalidation을
마친 fresh `$METRIC_ROOT`만 사용합니다.
위 exact six-entry gate는 renderer 전 destination이 두 phase directory와 네 file만 갖는지
확인합니다. 각 source/destination pair는 byte-equal이며 copy 뒤 source inode/metadata/size/hash가
copy 전 fingerprint와 같아야 합니다. 이어지는 renderer는 current production pins와 exact target
counts `21/30`, checksum/schema/semantic contract, 그리고 reviewed rendered manifest set을 다시
검증합니다. source/destination metadata, fingerprint, byte equality, pins, counts 또는 entry set
중 하나라도 다르면 fresh root를 apply input으로 사용하지 않고 STOP합니다.
권위 inventory hash는 다음과 같습니다.
```text
target-initial: 79688d017d38eec9a6f100f8d0f784a5474e79802046ef1c2c11b30d170b0b0c
post-substrate: b1c3049206a1a88165ee672ae9aceac7945673a3bb9c3cf3670b7f0d56c3f291
```
이 두 SHA는 현재 cluster freshness artifact가 아니라 변경할 수 없는 historical
metric/label provenance pair입니다. `target-initial``post-substrate`는 각각 exact
target count `21``30`을 initial gate와 confirmation 뒤 first mutation 직전
last gate 모두에서 다시 검증합니다. `captured_at_utc`는 exact UTC-second
형식과 유효한 UTC calendar로 parse되어야 하며, 현재보다 300초를 초과해
미래인 시각만 거부합니다. 이 exact pair에는 과거 방향 24시간 상한을
적용하지 않으며, timestamp나 checksum을 현재 시각에 맞게 다시 쓰거나
inventory를 재수집해서는 안 됩니다. 이 예외는 2절 Blackbox source proof의
기존 24시간 freshness 계약에는 적용되지 않습니다.
## 5. rules-alerts 적용 — 2026-08-15 terminal PASS
먼저 no-argument dry-run과 focused test를 실행합니다. 둘 중 하나라도 끝나지 않거나
실패하면 mutation을 실행하지 않습니다.
```bash
PLATFORM_HELM_BIN=/home/donghyeon/.local/bin/helm \
bash scripts/bootstrap/apply-observability-access.sh
bash scripts/validate/test-apply-observability-access.sh
```
성공한 뒤에만 다음을 실행합니다.
```bash
case $- in *e*) TASK6_APPLY_ERREXIT_WAS_SET=1 ;; *) TASK6_APPLY_ERREXIT_WAS_SET=0 ;; esac
set +e
PLATFORM_HELM_BIN=/home/donghyeon/.local/bin/helm bash scripts/bootstrap/apply-observability-access.sh --execute --rules-alerts --verified-output-dir "$METRIC_ROOT"
TASK6_APPLY_RC=$?
printf 'TASK6_APPLY_RC=%d\n' "$TASK6_APPLY_RC"
(( TASK6_APPLY_ERREXIT_WAS_SET == 0 )) || set -e
[[ "$TASK6_APPLY_RC" -eq 0 ]]
```
operator만 exact `APPLY` confirmation을 입력합니다. apply는 위 exact one-line command로 한 번만
실행하고, 바로 다음 statement가 다른 command 없이 `TASK6_APPLY_RC=$?`를 capture합니다.
immediate printed RC `0`과 exact `OBSERVABILITY_ACCESS_RULES_ALERTS=PASS`를 모두 확인한 경우에만
후속 acceptance를 진행합니다. nonzero, missing/ambiguous RC 또는 PASS, response loss, rollback
ambiguity는 STOP하고 rollback ID와 evidence를 보존하며 같은 ID로 재시도하지 않습니다.
성공 조건은 다음 전부입니다.
- Prometheus와 Alertmanager owner/controller Ready
- Prometheus API의 desired alert·record exact set과 evaluation health 정상
- Alertmanager generated config의 `observability/platform-alertmanager/platform-slack` receiver exact singleton
- Grafana sidecar의 exact dashboard 5개와 source content hash 일치
- 기존 target·Grafana·Blackbox·Probe·Ingress·PVC·Secret 보존
- acceptance marker는 모든 증거 뒤에만 root-only로 기록
apply 도구는 deployment checker를 confirmation 전과 Slack Secret-consuming mutation 직전에
두 번 호출한다. rules-alerts acceptance ledger schema는
`platform-observability-rules-alerts-v2`이며 Slack deployment gate 값은 bare `RECOVERY` 또는
`RISK_ACCEPTED`만 기록한다. 이는 operational acceptance이며 off-host Slack DR-complete을
의미하지 않는다.
실제 terminal transaction은 fresh rollback ID `20260814T145009Z`와 fresh handoff
`/tmp/platform-observability-metrics.dw5gLZ`를 사용했다. argv는 exact six-element array로
attest됐고, operator가 exact `APPLY`를 입력한 단 한 번의 실행에서 다음 safe marker를 확인했다.
```text
target-initial SHA-256 = 79688d017d38eec9a6f100f8d0f784a5474e79802046ef1c2c11b30d170b0b0c
post-substrate SHA-256 = b1c3049206a1a88165ee672ae9aceac7945673a3bb9c3cf3670b7f0d56c3f291
OBSERVABILITY_ACCESS_RULES_ALERTS=PASS
TASK6_APPLY_RC=0
```
payload-free terminal audit는 dashboard `5`, platform PrometheusRule `4`, 전체 desired rule
`23`(`22` alerts + `1` recording) healthy, runbook URL `22/22`, AlertmanagerConfig `1`, exact
NetworkPolicy, target `30/30`, Grafana·Blackbox·Prometheus·Alertmanager Ready와 qualified receiver
exact singleton을 확인했다. acceptance schema와 `RISK_ACCEPTED` gate, root-only ledger의
object/mutation line `13/13` 및 metadata contract도 통과했다. 성공 transaction에는 rollback이
호출되지 않았고 rollback root와 handoff는 Task 7 종료까지 보존한다.
failed rollback ID `20260814T080303Z`와 argument paste가 파싱 전에 중단된
`20260814T140953Z`는 immutable evidence로 보존하고 재사용하지 않는다. 후자는
`--verified-output-dir` token이 줄바꿈으로 분리돼 usage RC `2`, shell-level RC `127`로 끝났으며
ledger·acceptance·Kubernetes mutation은 생성되지 않았다.
## 6. Task 7 operator boundary와 Host Nginx first cutover
Task 7은 성공 rollback ID `20260814T145009Z`와 original handoff
`/tmp/platform-observability-metrics.dw5gLZ`를 그대로 보존합니다. active state는 exact deny-only
SHA-256 `dbef6d443bcba58b26a5351ea76f6d09f6da8c2ef07a806e22745cf26c88f518`,
desired full은 `7d2de2a92c3597a0859775da1d2ccbf5a3d72c0b2af5cac2439c82311361f801`여야
합니다. full이 이미 active이거나 third state이면 STOP합니다.
어떤 external preparation command보다 먼저 fixed PATH를 export하고 command cache를 비운 뒤
reviewed command/launcher inventory를 byte-equal로 재검증합니다. ambient PATH command로 prep를
시작하지 않습니다. exact canonical `/usr/bin/sudo``regular|0:0|4755|1`을 요구하는
유일한 owner-setuid 예외입니다. setgid·group/world write는 금지되고 다른 allowlisted
executable은 setuid/setgid를 모두 금지합니다.
```bash
TASK7_OPERATOR_PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
PATH=$TASK7_OPERATOR_PATH
export PATH
hash -r
cd /home/donghyeon/workspace/platform
TASK7_ID=20260814T145009Z
TASK7_METRIC_ROOT=/tmp/platform-observability-metrics.dw5gLZ
METRIC_ROOT=$TASK7_METRIC_ROOT
export PLATFORM_OBSERVABILITY_ROLLBACK_ID="$TASK7_ID"
[[ "$PLATFORM_OBSERVABILITY_ROLLBACK_ID" == "$TASK7_ID" ]]
[[ "$METRIC_ROOT" == "$TASK7_METRIC_ROOT" ]]
[[ "$(/usr/bin/readlink -f -- "$TASK7_METRIC_ROOT")" == "$TASK7_METRIC_ROOT" ]]
TASK7_HOST_DRY=(/usr/bin/bash)
TASK7_HOST_DRY+=(/home/donghyeon/workspace/platform/scripts/bootstrap/apply-host-nginx-observability.sh)
[[ "${#TASK7_HOST_DRY[@]}" -eq 2 ]]
case $- in *e*) TASK7_HOST_DRY_ERREXIT=1 ;; *) TASK7_HOST_DRY_ERREXIT=0 ;; esac
set +e
"${TASK7_HOST_DRY[@]}"
TASK7_HOST_DRY_RC=$?
printf 'TASK7_HOST_DRY_RC=%d\n' "$TASK7_HOST_DRY_RC"
(( TASK7_HOST_DRY_ERREXIT == 0 )) || set -e
```
no-arg는 source/hash/no-contact만 검증합니다. certificate/SAN, DNS, Kubernetes, proof
ID/age, NodePort와 network boundary는 execute 내부의 `APPLY` prompt 전 live gate입니다.
dry-run은 Host mutation `0`과 다음 exact output을 요구합니다.
```text
HOST_NGINX_ACTIVE_SHA256=dbef6d443bcba58b26a5351ea76f6d09f6da8c2ef07a806e22745cf26c88f518
HOST_NGINX_GRAFANA_DENY_GUARD_SHA256=dbef6d443bcba58b26a5351ea76f6d09f6da8c2ef07a806e22745cf26c88f518
HOST_NGINX_FULL_SHA256=7d2de2a92c3597a0859775da1d2ccbf5a3d72c0b2af5cac2439c82311361f801
HOST_NGINX_CERTIFICATE_EXPECTED_SAN=grafana.learn.hyeonworks.com
HOST_NGINX_CERTIFICATE_SAN=NOT_CHECKED_DRY_RUN
HOST_NGINX_GRAFANA_PUBLIC_DNS=NOT_CHECKED_DRY_RUN
HOST_NGINX_OBSERVABILITY_DRY_RUN=PASS
TASK7_HOST_DRY_RC=0
```
`HOST_NGINX_CERTIFICATE_EXACT_SAN=``HOST_NGINX_GRAFANA_PUBLIC_DNS=ABSENT`를 dry-run
결과로 받으면 STOP합니다. source proof는 exact ID/deny hash/status/IP/time에 bind되며
과거 24시간, 미래 300초 경계를 벗어나면 fresh rollback ID, fresh proof, complete Task 6를
다시 수행합니다. 같은 ID에서 proof만 바꾸거나 timestamp를 다시 쓰지 않습니다.
execute가 `APPLY`를 표시하기 전에 다음 네 path가 각각 `test -e`/`test -L` 모두에서
absent임을 no-follow, name-only 순서로 입증해야 합니다.
```text
/var/lib/hyeonworks/platform-rollbacks/observability-20260814T145009Z/host-nginx
/var/lib/hyeonworks/platform-rollbacks/observability-20260814T145009Z/host-nginx/stages.tsv
/var/lib/hyeonworks/platform-rollbacks/observability-20260814T145009Z/host-nginx/payloads
/var/lib/hyeonworks/platform-rollbacks/observability-20260814T145009Z/host-nginx/payloads/full-prior-0001.conf
```
일반 directory/file로 남은 ledger/payload도 reusable recovery state가 아니라 STOP residue입니다.
reviewed sudo identity를 다시 확인한 뒤에만 operator가 credential를 refresh합니다.
```bash
/usr/bin/sudo -v
/usr/bin/sudo -n /usr/bin/true
printf 'SUDO_READY\n'
TASK7_VOD=--verified
TASK7_VOD+=-output-dir
TASK7_HOST=(/usr/bin/bash)
TASK7_HOST+=(/home/donghyeon/workspace/platform/scripts/bootstrap/apply-host-nginx-observability.sh)
TASK7_HOST+=(--execute)
TASK7_HOST+=("$TASK7_VOD")
TASK7_HOST+=("$TASK7_METRIC_ROOT")
[[ "${#TASK7_HOST[@]}" -eq 5 ]]
printf 'TASK7_HOST_ARGC=%d\n' "${#TASK7_HOST[@]}"
case $- in *e*) TASK7_HOST_ERREXIT=1 ;; *) TASK7_HOST_ERREXIT=0 ;; esac
set +e
"${TASK7_HOST[@]}"
TASK7_HOST_RC=$?
printf 'TASK7_HOST_RC=%d\n' "$TASK7_HOST_RC"
(( TASK7_HOST_ERREXIT == 0 )) || set -e
```
operator만 exact `APPLY`를 입력합니다. 성공은 exact `HOST_NGINX_FULL_STAGE=PASS`
`TASK7_HOST_RC=0`이 모두 있을 때뿐입니다. `ALREADY_ACTIVE`, missing/ambiguous marker,
nonzero RC, response loss는 모두 실패이며 같은 ID로 재실행하지 않습니다.
1. prompt 전 실패: active deny unchanged, Host ledger/mutation `0`, rollback N/A.
2. prompt 뒤 `rollback_armed=true` 전 실패: active config/reload mutation `0`, root-owned
ledger/payload 또는 timestamp backup staging은 남을 수 있으며 rollback N/A. 전체 evidence를
보존합니다.
3. active install 뒤 실패: exact `HOST_NGINX_OBSERVABILITY_ROLLBACK=PASS`와 deny hash
복원을 요구합니다.
4. `ROLLBACK=FAIL`, `MANUAL_RECOVERY_REQUIRED=YES`, unknown stage/hash: 모든 후속 gate를
STOP합니다.
`stages.tsv`는 prior-payload recovery ledger이지 success marker가 아닙니다. ledger/payload/timestamp
staging 생성·검증·설치 중 하나라도 실패하면 현 ID/root를 보존하고 fresh ID,
source proof, complete Task 6를 다시 수행합니다. staged evidence를 repair/reuse하거나
failed fresh ID를 재사용하지 않습니다.
## 7. OIDC membership과 사람/external readiness
Host PASS 뒤 mutation 전에 서로 다른 admin, viewer, no-group, membership-removal test
identity, local break-glass 접근, Slack firing/resolved view, LAN/Tailscale 밖 proxy-disabled
external client를 모두 준비합니다. 하나라도 없으면 membership와 smoke를 시작하지
않습니다. username은 stdin으로만 받고 기록하지 않습니다.
```bash
read -r -p 'Grafana organization admin realm username: ' OBS_ADMIN_USER
read -r -p 'Grafana viewer realm username: ' OBS_VIEWER_USER
TASK7_OIDC=(/usr/bin/bash /home/donghyeon/workspace/platform/scripts/bootstrap/configure-keycloak-grafana-oidc.sh)
TASK7_OIDC+=(--execute)
TASK7_OIDC+=(--admin "$OBS_ADMIN_USER")
TASK7_OIDC+=(--viewer "$OBS_VIEWER_USER")
case $- in *e*) TASK7_OIDC_ERREXIT=1 ;; *) TASK7_OIDC_ERREXIT=0 ;; esac
set +e
"${TASK7_OIDC[@]}"
TASK7_OIDC_RC=$?
unset OBS_ADMIN_USER OBS_VIEWER_USER TASK7_OIDC
printf 'TASK7_OIDC_RC=%d\n' "$TASK7_OIDC_RC"
(( TASK7_OIDC_ERREXIT == 0 )) || set -e
```
operator는 exact `APPLY default``RECOVERY KEYCLOAK default`를 입력합니다.
`GRAFANA_OIDC_TRANSACTION=PASS`와 RC `0`을 모두 요구합니다.
`transaction_active=true` 전 실패는 managed Keycloak/OIDC Secret/membership mutation `0`,
rollback N/A입니다. active failure는 exact `GRAFANA_OIDC_ROLLBACK=PASS`를 요구합니다.
rollback FAIL, manual recovery 또는 unknown stage는 STOP입니다. OIDC rollback은 실행 중 private
snapshot을 사용하는 in-process rollback입니다. 성공 뒤 복원용 persistent Task 6 Keycloak
reversal ledger가 있다고 주장하거나 탐색하지 않습니다.
## 8. monolithic observability smoke exactly once
no-arg는 acceptance가 아닌 계획 확인으로 한 번만 실행합니다.
```bash
TASK7_SMOKE_DRY=(/usr/bin/bash)
TASK7_SMOKE_DRY+=(/home/donghyeon/workspace/platform/scripts/validate/observability-smoke.sh)
[[ "${#TASK7_SMOKE_DRY[@]}" -eq 2 ]]
case $- in *e*) TASK7_SMOKE_DRY_ERREXIT=1 ;; *) TASK7_SMOKE_DRY_ERREXIT=0 ;; esac
set +e
"${TASK7_SMOKE_DRY[@]}"
TASK7_SMOKE_DRY_RC=$?
printf 'TASK7_SMOKE_DRY_RC=%d\n' "$TASK7_SMOKE_DRY_RC"
(( TASK7_SMOKE_DRY_ERREXIT == 0 )) || set -e
```
RC `0`, `OBSERVABILITY_SMOKE_DRY_RUN=PASS`, `HUMAN_EXTERNAL_CLIENT=required`,
`MUTATION=NOT_REQUESTED`를 요구합니다. 그 뒤 machine, OIDC human/session, Slack
firing/resolved, true external-client attestation을 하나의 execute에서만 수행합니다.
```bash
TASK7_SMOKE=(/usr/bin/bash)
TASK7_SMOKE+=(/home/donghyeon/workspace/platform/scripts/validate/observability-smoke.sh)
TASK7_SMOKE+=(--execute)
case $- in *e*) TASK7_SMOKE_ERREXIT=1 ;; *) TASK7_SMOKE_ERREXIT=0 ;; esac
set +e
"${TASK7_SMOKE[@]}"
TASK7_SMOKE_RC=$?
printf 'TASK7_SMOKE_RC=%d\n' "$TASK7_SMOKE_RC"
(( TASK7_SMOKE_ERREXIT == 0 )) || set -e
```
operator만 requested identity와 exact dynamic confirmation을 입력합니다. 성공은 다음 전체
marker와 immediate RC를 요구합니다.
```text
OBSERVABILITY_MACHINE_ACCEPTANCE=PASS
OBSERVABILITY_OIDC_ACCEPTANCE=PASS
OBSERVABILITY_SLACK_ACCEPTANCE=PASS
OBSERVABILITY_EXTERNAL_BOUNDARY=PASS
OBSERVABILITY_SMOKE=PASS
TASK7_SMOKE_RC=0
```
RC `2` 또는 `OBSERVABILITY_EXTERNAL_BOUNDARY=PARTIAL`은 Task 7을 `부분 구현`으로 남깁니다.
external result를 server-side, LAN 또는 Tailscale probe로 대체하지 않습니다. cleanup ambiguity는
owned-object review 전 automatic rerun을 금지합니다.
## 9. fresh inventory-only renderer root와 단일 회귀 pass
fixed PATH를 다시 설치하고 `hash -r`, command inventory byte equality를 external prep 전에
확인합니다. original `dw5gLZ`의 canonical path, owner/mode/nlink, exact entry set, two
inventory hash와 three Task 6 YAML fingerprint를 보존합니다. complete publication에 original
root를 사용하지 않습니다.
```bash
TASK7_RENDER_ROOT="$(/usr/bin/mktemp -d /tmp/platform-observability-metrics.XXXXXX)"
/usr/bin/chmod 0700 "$TASK7_RENDER_ROOT"
for phase in target-initial post-substrate; do
/usr/bin/install -d -m 0700 -- "$TASK7_RENDER_ROOT/$phase"
for file in inventory.json inventory.sha256; do
source_file="$TASK7_METRIC_ROOT/$phase/$file"
destination_file="$TASK7_RENDER_ROOT/$phase/$file"
[[ -f "$source_file" && ! -L "$source_file" ]]
/usr/bin/cp --no-dereference --reflink=never -- "$source_file" "$destination_file"
/usr/bin/chmod 0600 "$destination_file"
/usr/bin/cmp -s -- "$source_file" "$destination_file"
done
done
unset source_file destination_file
```
destination은 exact six-entry topology, current owner, root/phase `0700`, file `0600`, nlink `1`,
byte equality와 known inventory hash를 요구합니다. copy 뒤 original fingerprint가 unchanged여야
합니다. 실패한 fresh root는 evidence로 보존하고 repair/reuse하지 않습니다.
core와 complete renderer, admin UI, AIStor S3, phase1, phase2, admin renderer를 다음 exact
array/envelope로 각각 한 번만 실행합니다.
```bash
TASK7_CORE=(/usr/bin/bash)
TASK7_CORE+=(/home/donghyeon/workspace/platform/scripts/validate/render-observability-core.sh)
[[ "${#TASK7_CORE[@]}" -eq 2 ]]
case $- in *e*) TASK7_CORE_ERREXIT=1 ;; *) TASK7_CORE_ERREXIT=0 ;; esac
set +e
PLATFORM_HELM_BIN=/home/donghyeon/.local/bin/helm "${TASK7_CORE[@]}"
TASK7_CORE_RC=$?
(( TASK7_CORE_ERREXIT == 0 )) || set -e
printf 'TASK7_CORE_RC=%d\n' "$TASK7_CORE_RC"
TASK7_VOD=--verified
TASK7_VOD+=-output-dir
TASK7_COMPLETE=(/usr/bin/bash)
TASK7_COMPLETE+=(/home/donghyeon/workspace/platform/scripts/validate/render-observability-access.sh)
TASK7_COMPLETE+=(--component complete)
TASK7_COMPLETE+=("$TASK7_VOD" "$TASK7_RENDER_ROOT")
[[ "${#TASK7_COMPLETE[@]}" -eq 6 ]]
case $- in *e*) TASK7_COMPLETE_ERREXIT=1 ;; *) TASK7_COMPLETE_ERREXIT=0 ;; esac
set +e
PLATFORM_HELM_BIN=/home/donghyeon/.local/bin/helm "${TASK7_COMPLETE[@]}"
TASK7_COMPLETE_RC=$?
(( TASK7_COMPLETE_ERREXIT == 0 )) || set -e
printf 'TASK7_COMPLETE_RC=%d\n' "$TASK7_COMPLETE_RC"
TASK7_ADMIN_UI=(/usr/bin/bash)
TASK7_ADMIN_UI+=(/home/donghyeon/workspace/platform/scripts/validate/admin-ui-smoke.sh)
[[ "${#TASK7_ADMIN_UI[@]}" -eq 2 ]]
case $- in *e*) TASK7_ADMIN_UI_ERREXIT=1 ;; *) TASK7_ADMIN_UI_ERREXIT=0 ;; esac
set +e
"${TASK7_ADMIN_UI[@]}"
TASK7_ADMIN_UI_RC=$?
(( TASK7_ADMIN_UI_ERREXIT == 0 )) || set -e
printf 'TASK7_ADMIN_UI_RC=%d\n' "$TASK7_ADMIN_UI_RC"
TASK7_AISTOR_S3=(/usr/bin/bash)
TASK7_AISTOR_S3+=(/home/donghyeon/workspace/platform/scripts/validate/aistor-s3-smoke.sh)
TASK7_AISTOR_S3+=(--execute)
[[ "${#TASK7_AISTOR_S3[@]}" -eq 3 ]]
case $- in *e*) TASK7_AISTOR_S3_ERREXIT=1 ;; *) TASK7_AISTOR_S3_ERREXIT=0 ;; esac
set +e
"${TASK7_AISTOR_S3[@]}"
TASK7_AISTOR_S3_RC=$?
(( TASK7_AISTOR_S3_ERREXIT == 0 )) || set -e
printf 'TASK7_AISTOR_S3_RC=%d\n' "$TASK7_AISTOR_S3_RC"
TASK7_PHASE1=(/usr/bin/bash)
TASK7_PHASE1+=(/home/donghyeon/workspace/platform/scripts/validate/render-phase1.sh)
[[ "${#TASK7_PHASE1[@]}" -eq 2 ]]
case $- in *e*) TASK7_PHASE1_ERREXIT=1 ;; *) TASK7_PHASE1_ERREXIT=0 ;; esac
set +e
PLATFORM_HELM_BIN=/home/donghyeon/.local/bin/helm "${TASK7_PHASE1[@]}"
TASK7_PHASE1_RC=$?
(( TASK7_PHASE1_ERREXIT == 0 )) || set -e
printf 'TASK7_PHASE1_RC=%d\n' "$TASK7_PHASE1_RC"
TASK7_PHASE2=(/usr/bin/bash)
TASK7_PHASE2+=(/home/donghyeon/workspace/platform/scripts/validate/render-phase2.sh)
[[ "${#TASK7_PHASE2[@]}" -eq 2 ]]
case $- in *e*) TASK7_PHASE2_ERREXIT=1 ;; *) TASK7_PHASE2_ERREXIT=0 ;; esac
set +e
PLATFORM_HELM_BIN=/home/donghyeon/.local/bin/helm "${TASK7_PHASE2[@]}"
TASK7_PHASE2_RC=$?
(( TASK7_PHASE2_ERREXIT == 0 )) || set -e
printf 'TASK7_PHASE2_RC=%d\n' "$TASK7_PHASE2_RC"
TASK7_ADMIN_RENDER=(/usr/bin/bash)
TASK7_ADMIN_RENDER+=(/home/donghyeon/workspace/platform/scripts/validate/render-admin-services.sh)
[[ "${#TASK7_ADMIN_RENDER[@]}" -eq 2 ]]
case $- in *e*) TASK7_ADMIN_RENDER_ERREXIT=1 ;; *) TASK7_ADMIN_RENDER_ERREXIT=0 ;; esac
set +e
PLATFORM_HELM_BIN=/home/donghyeon/.local/bin/helm "${TASK7_ADMIN_RENDER[@]}"
TASK7_ADMIN_RENDER_RC=$?
(( TASK7_ADMIN_RENDER_ERREXIT == 0 )) || set -e
printf 'TASK7_ADMIN_RENDER_RC=%d\n' "$TASK7_ADMIN_RENDER_RC"
```
다섯 renderer의 exact Helm assignment을 생략하지 않습니다. 특히 phase1/phase2는 fixed
PATH에 Helm이 없으므로 `command -v helm` fallback을 허용하지 않습니다. 전체 RC `0`,
expected terminal PASS, complete seven-artifact publication, original fingerprint unchanged와 new
residue `0`을 요구합니다. 존재하지 않는 core smoke를 호출하지 않고 monolithic smoke
execute를 다시 실행하지 않습니다.
## 10. rollback·STOP 경계
Host/rules recovery evidence는 성공 Task 6 root에서 각 transaction 소유 범위만 사용합니다.
OIDC는 persistent Task 6 reversal ledger가 아니라 in-process private snapshot으로만 rollback합니다.
PVC, Secret, CRD, PV, Loki/Tempo object·bucket은 자동 삭제하지 않습니다. API timeout,
response loss, UID drift, third-state, controller 비수렴, ledger mismatch, rollback ambiguity는
`MANUAL_RECOVERY_REQUIRED=YES`로 STOP하고 evidence/root를 보존합니다.
## 11. 전체 완료 판정
Task 7은 Host/OIDC/smoke/renderer의 실제 RC·marker·cleanup과 independent review가 모두 있을
때만 완료로 표시합니다. admin, viewer, no-group, membership-removal, break-glass, Slack
firing/resolved, true external client 중 하나라도 미실행/실패면 `부분 구현`을 유지합니다.
Slack off-host DR은 Task 7 PASS와 무관하게 `deferred / not complete`이며 active exception을
유지합니다. 실제 terminal evidence의 independent review 전에는 중앙 Task 7 Step 16
checkbox를 체크하지 않고 Task 8을 시작하지 않습니다.