Add platform infrastructure configuration
This commit is contained in:
@@ -0,0 +1,561 @@
|
||||
# Phase 1·2 검증
|
||||
|
||||
`render-phase1.sh`와 `render-phase2.sh`는 클러스터를 변경하지 않고 각 단계의
|
||||
구성을 렌더링한 뒤 핵심 불변 조건을 확인한다. Phase 2 검증은 먼저 Phase 1
|
||||
검증을 재실행하므로 두 단계의 경계도 함께 확인한다.
|
||||
|
||||
```sh
|
||||
bash scripts/validate/render-phase1.sh
|
||||
bash scripts/validate/render-phase2.sh
|
||||
```
|
||||
|
||||
`helm`이 `PATH`에 없거나 검증용 바이너리를 별도로 내려받았다면 절대 경로를
|
||||
지정할 수 있다.
|
||||
|
||||
```sh
|
||||
PLATFORM_HELM_BIN=/tmp/helm-v3.19.4/helm \
|
||||
bash scripts/validate/render-phase2.sh
|
||||
```
|
||||
|
||||
`PLATFORM_HELM_BIN`은 실행 가능한 일반 파일의 절대 경로여야 하며, 지정하지
|
||||
않으면 `PATH`에서 `helm`을 찾는다. 어느 경로를 사용하든 버전은 정확히
|
||||
`v3.19.4`여야 한다.
|
||||
|
||||
`render-phase1.sh --verified-output-dir`는 일반 사용자가 직접 호출하는 출력 옵션이
|
||||
아니라 `apply-phase1-gitea.sh`와 `apply-gitea-oidc.sh`가 함께 사용하는 내부
|
||||
handoff 전용이다. 기존의 비어 있는 `/tmp/platform-phase1-apply.*` 디렉터리만
|
||||
허용하며, 경로가 심볼릭 링크이거나 현재 사용자 소유가 아니거나 mode `0700`이
|
||||
아니면 중단한다. 검증을 모두 통과한 경우에만 다음 여섯 manifest를 mode
|
||||
`0600`으로 복사하고 원본과 `cmp`로 다시 비교한다.
|
||||
|
||||
- `namespaces.yaml`
|
||||
- `ssd-local-pv.yaml`
|
||||
- `cnpg-operator.yaml`
|
||||
- `platform-postgres.yaml`
|
||||
- `gitea.yaml`
|
||||
- `gitea-oidc.yaml`
|
||||
|
||||
`apply-phase1-gitea.sh`는 여섯 산출물의 SHA-256을 메모리에 고정하고 확인 후,
|
||||
실제 적용 대상인 `namespaces.yaml`부터 `gitea.yaml`까지 다섯 manifest를 각각
|
||||
apply하기 직전에 다시 검사한다. `gitea-oidc.yaml`은 적용하지 않는다.
|
||||
|
||||
`apply-gitea-oidc.sh`는 같은 handoff의 파일 수·권한·소유권을 확인하되,
|
||||
`gitea-oidc.yaml`의 SHA-256만 적용 대상으로 고정하고 재확인한 뒤 그 하나만
|
||||
적용한다. 두 경로 모두 검증 뒤 Chart를 다시 내려받거나 Kustomize를 다시 실행하지
|
||||
않는다. 취소·오류·`INT`·`TERM` 종료 시 각 적용 스크립트가 자신의 handoff
|
||||
디렉터리 전체를 삭제한다.
|
||||
|
||||
공통 검증 기준은 다음과 같다.
|
||||
|
||||
- 로컬 `kubectl`의 Kustomize가 Argo CD 3.4.2와 같은 `v5.8.1`인지 확인
|
||||
- Helm이 Argo CD 3.4.2와 같은 `v3.19.4`인지 확인
|
||||
- `sha256sum`과 `tar`가 설치되어 있는지 확인
|
||||
- `helm pull`로 고정 버전 패키지를 공식 저장소에서 임시 디렉터리로 받은 뒤
|
||||
SHA-256으로 바이트 단위 무결성 확인
|
||||
- CloudNativePG Chart `0.29.0`의 SHA-256이
|
||||
`668e065ff53508d58238788fd35b355a925060843629a951df0e6a9362e6d32f`인지 확인
|
||||
- Gitea Chart `12.7.0`의 SHA-256이
|
||||
`5881ef9c59400bee2d5547e77c4cd0efb925143c2f5d93fb4f38446db76b0167`인지 확인
|
||||
- Helm Chart는 반드시 `--enable-helm`과 기본 `LoadRestrictionsRootOnly`로 렌더링
|
||||
|
||||
Phase 1은 CloudNativePG CRD, Gitea용 `Cluster`·`DatabaseRole`·`Database`, Gitea
|
||||
PVC·Ingress를 확인한다. Ingress의 `git.learn.hyeonworks.com` host, 내부 TLS 부재,
|
||||
외부 HTTPS `ROOT_URL`, 애플리케이션 `NodePort`·`LoadBalancer`와 Gitea SSH Service
|
||||
부재도 검증한다. Phase 1 PostgreSQL 빌드 루트에는 Keycloak 리소스·Secret 참조·
|
||||
namespace ingress 허용이 없어야 한다.
|
||||
|
||||
Gitea는 두 프로필을 모두 렌더하되 적용 경계를 분리해 검증한다.
|
||||
|
||||
- `services/gitea` → `gitea.yaml`: 신규 설치용 baseline. OIDC Secret·ID host
|
||||
alias·Keycloak egress·브랜딩이 없어야 하고 자체 가입을 닫은 로컬 비상 로그인만
|
||||
유지한다.
|
||||
- `services/gitea/profiles/oidc` → `gitea-oidc.yaml`: 공통
|
||||
`values/baseline.yaml`에 `values/oidc.yaml`을 병합한다. 정확한 OIDC Secret 참조,
|
||||
public issuer, 외부 인증 전용 가입, CoreDNS 기반 public issuer 접근, `/32` egress와 해시된 브랜딩
|
||||
ConfigMap·read-only mount를 확인한다.
|
||||
|
||||
공유 Cluster의 `pg_hba`도 네 규칙의 순서와 값을 검증한다. `gitea` Role은 `gitea`
|
||||
DB, `keycloak` Role은 `keycloak` DB에만 SCRAM으로 접속을 허용하고, 각 Role이 다른
|
||||
DB에 접속하는 경우에는 바로 다음 규칙에서 `reject`해야 한다.
|
||||
|
||||
Phase 2는 다음을 추가로 검증한다.
|
||||
|
||||
- 공식 Keycloak `26.7.0` 원격 리소스와 Keycloak 단일 인스턴스·외부 HTTPS
|
||||
hostname·내부 HTTP·Traefik Ingress
|
||||
- Keycloak PostgreSQL 증분 루트에 `DatabaseRole`·`Database`·추가형
|
||||
NetworkPolicy만 있고 `Cluster` 사본은 없는지 확인
|
||||
- AIStor Operator Chart `5.10.0` SHA-256 `e5534f5ae4f6f12a3528a8cda5954d73280cba1a8e979e1628fbf3aac76babd1`
|
||||
- AIStor ObjectStore Chart `1.0.16` SHA-256 `50ffa6a4e014cdc48566b237593baab41d039f103cdc50c1b1ac173b8d8bf71e`
|
||||
- AIStor XFS Local PV의 `Retain`·`WaitForFirstConsumer`·`900Gi`·node affinity
|
||||
- ObjectStore 서버 1개·볼륨 1개, PVC 보호, S3·Console `ClusterIP`, 외부
|
||||
Ingress·NodePort·LoadBalancer 부재
|
||||
- Phase 2 소스와 렌더 결과의 자격 증명·라이선스 `Secret` 부재
|
||||
|
||||
Secret 검사는 관리 대상 YAML 전체에 적용하되, 렌더 과정에서 생성되고 Git에서
|
||||
무시되는 업스트림 Chart 캐시인 `**/.helm/**`과 `**/charts/**`는 제외한다.
|
||||
|
||||
스크립트는 `/tmp/platform-phase1-render.*` 아래에만 중간 산출물을 만들고 종료 시
|
||||
삭제한다. 따라서 `helm pull`로 받은 임시 `.tgz`도 종료 시 제거된다. 각 Helm
|
||||
렌더 직전에는 다이제스트를 통과한 패키지만 아래의 정확한 생성 캐시 버전
|
||||
디렉터리에 압축 해제한다.
|
||||
|
||||
- `infrastructure/controllers/cloudnative-pg/.helm/charts/cloudnative-pg-0.29.0/cloudnative-pg/Chart.yaml`
|
||||
- `services/gitea/profiles/oidc/.helm/charts/gitea-12.7.0/gitea/Chart.yaml`
|
||||
|
||||
Phase 2도 같은 방식으로 AIStor Operator와 ObjectStore의 정확한 버전 캐시만
|
||||
일시 생성한다. 스크립트는 해당 버전 디렉터리가 이미 존재하거나 캐시 상위 디렉터리가
|
||||
심볼릭 링크면 이를 임의 삭제·덮어쓰지 않고 중단한다. 패키지 다이제스트가 일치한 뒤에만
|
||||
정확한 디렉터리를 생성하며, 자신이 생성한 버전 디렉터리는 `EXIT` 종료 정리에서
|
||||
제거한다. `.helm/charts`와 `.helm`도 이번 실행에서 생성했고 비어 있을 때만
|
||||
`rmdir`로 정리한다. 기존의 다른 캐시는 삭제하지 않는다.
|
||||
|
||||
정상 종료와 처리되는 `INT`·`TERM`에서는 생성한 압축 해제 캐시와 `/tmp` 렌더
|
||||
산출물이 제거된다. 강제 종료 등으로 정확한 캐시가 남으면 다음 실행은 안전하게
|
||||
거부하므로 운영자가 경로와 내용을 확인한 뒤에만 수동 정리한다. 패키지가 없거나
|
||||
다이제스트가 다르거나 압축 해제 후 `Chart.yaml`이 없으면 렌더 전에 즉시 실패한다.
|
||||
실제 적용, namespace 생성, Secret 변경, Nginx 변경은 수행하지 않는다.
|
||||
|
||||
내부 handoff 모드를 사용해도 검증 스크립트 자신의 Chart package, generated cache와
|
||||
작업용 render 디렉터리는 동일하게 정리된다. 보존되는 것은 적용 스크립트가 만든
|
||||
제한된 임시 디렉터리의 검증 완료 manifest 여섯 개뿐이며, 적용 스크립트 종료 시
|
||||
함께 제거된다.
|
||||
|
||||
## 관측성 코어 정적 render 검증
|
||||
|
||||
`render-observability-core.sh`는 Helm `v3.19.4`, Kustomize `v5.8.1`, Kubernetes
|
||||
render target `1.36.2`와 계획에 기록된 여섯 chart archive/name/version/appVersion,
|
||||
열두 image digest를 고정한다. 기본 실행은 정적 render만 수행하며 Kubernetes
|
||||
resource를 적용하지 않는다. live 관측성 mutation gate가 닫힌 동안에는 fixture 시험만
|
||||
실행한다.
|
||||
|
||||
```sh
|
||||
bash -n scripts/validate/render-observability-core.sh
|
||||
bash -n scripts/validate/test-render-observability-core.sh
|
||||
bash scripts/validate/test-render-observability-core.sh
|
||||
```
|
||||
|
||||
fixture 시험은 chart를 다운로드하지 않고 source된 assertion 함수에 변형 YAML을
|
||||
전달한다. 성공 표식은 `OBSERVABILITY CORE RENDER ASSERTION TEST PASS`다. 전체 renderer는
|
||||
Task 2 이후 child root가 존재할 때 다음과 같이 사용한다.
|
||||
|
||||
```sh
|
||||
PLATFORM_HELM_BIN=/home/donghyeon/.local/bin/helm \
|
||||
bash scripts/validate/render-observability-core.sh
|
||||
```
|
||||
|
||||
내부 `--verified-output-dir`는 비어 있는 current-user 소유 mode `0700`의
|
||||
`/tmp/platform-observability-core-apply.*`만 허용한다. 모든 검증과 child/aggregate
|
||||
canonical resource equivalence가 끝난 뒤에만 manifest 열네 개와
|
||||
`resource-index.tsv`를 regular mode `0600` 파일로 넘긴다. Grafana와 Blackbox child
|
||||
manifest도 각각 `grafana.yaml`, `blackbox.yaml`로 포함한다. handoff directory의
|
||||
device/inode를 고정하고 쓰기 직전에 identity와 empty 상태를 다시 확인하며, 각 파일은
|
||||
완성된 mode `0600` staging inode를 exact name에 no-clobber link하는 방식으로 공개한다.
|
||||
최종 entry set은 열일곱 exact name뿐이어야 한다. 각 chart cache는 모든 render root의
|
||||
physical lineage를 먼저 확인한 뒤 directory FD 기준 exclusive mkdir로 이번 실행의 정확한
|
||||
version 경로만 만들고, 기존 경로를 덮어쓰지 않으며, 종료 시 실제로 생성·추적한 경로만
|
||||
제거한다. 실행 전부터 존재한 빈 `.helm`이나 `.helm/charts`는 제거하지 않는다.
|
||||
cache cleanup은 tracked directory를 random no-replace quarantine name으로 옮긴 뒤 identity를
|
||||
다시 확인하고, 모든 child를 directory FD 기준으로 같은 방식으로 격리해 제거한다. cleanup
|
||||
도중 원래 path에 replacement가 생겨도 이를 순회하거나 삭제하지 않으며 identity mismatch나
|
||||
unexpected entry는 성공 종료로 숨기지 않는다.
|
||||
|
||||
Alertmanager Slack source 계약은 Secret payload를 읽지 않는 focused validator로
|
||||
검사한다. 기본 모드는 source와 Kustomize/KPS wiring만 검사하며, 선택적 server dry-run도
|
||||
두 non-Secret source만 admission에 제출하고 live object를 변경하지 않는다.
|
||||
|
||||
```sh
|
||||
bash scripts/validate/test-observability-alerting.sh
|
||||
bash scripts/validate/test-observability-alerting.sh --server-dry-run
|
||||
```
|
||||
|
||||
`render-observability-access.sh`는 core renderer의 검증된 Grafana·Blackbox·targets·
|
||||
dashboard·rule·Alertmanager bytes와 private DNS를 access 단계의 일곱 manifest로
|
||||
분리한다. rules-alerts와 complete mode는 두 metric inventory phase가 있는 direct
|
||||
`/tmp/platform-observability-metrics.XXXXXX` mode `0700` 디렉터리만 허용한다. 두
|
||||
inventory directory는 mode `0700`, JSON/checksum은 owner mode `0600`, link count 1과
|
||||
exact checksum이어야 한다. 게시 전 모든 destination absence와 root inode를 고정하고,
|
||||
모든 source를 mode `0600` staging inode에 동기화한 뒤 no-clobber link한다. 한 파일이라도
|
||||
실패하면 이번 실행이 생성한 파일을 전부 제거해 부분 handoff를 남기지 않는다.
|
||||
|
||||
```sh
|
||||
bash -n scripts/validate/render-observability-access.sh
|
||||
bash scripts/validate/test-render-observability-access.sh
|
||||
bash scripts/validate/render-observability-access.sh \
|
||||
--component complete \
|
||||
--verified-output-dir /tmp/platform-observability-metrics.XXXXXX
|
||||
```
|
||||
|
||||
출력에는 Secret kind나 credential payload가 없으며, 이미 게시된 output root를 재사용하면
|
||||
no-clobber로 즉시 거부한다. live 적용은 별도 apply transaction과 Slack Secret,
|
||||
HTTPS runbook URL, source-proof/acceptance evidence가 모두 준비된 뒤에만 수행한다.
|
||||
|
||||
## Slack webhook KeePass 복구 사본
|
||||
|
||||
`backup-slack-webhook-recovery.sh`는 Alertmanager Slack webhook의 재해 복구용 암호화
|
||||
사본을 같은 host의 별도 내장 Windows SSD에 있는 기존 KDBX에 보관한다. 이 사본은 일반
|
||||
K3s 재시작이나 host reboot에 필요하지 않으며, datastore·Secret·bootstrap state를 잃었을
|
||||
때를 위한 것이다. 기본 no-argument 실행은 고정 contract만 출력하고 SSD, KDBX, webhook,
|
||||
sudo에 접근하지 않는다. 지원되는 public interface는 다음 두 형식뿐이다.
|
||||
|
||||
```sh
|
||||
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는 전용 TTY에서 Slack app name과 KeePassXC master password를 받고, write 또는
|
||||
mismatch update 전에 정확한 확인 문자열을 요구한다. webhook payload, master password,
|
||||
그 hash·encoding·size·URL component, KDBX protected output은 argv, environment, stdout,
|
||||
log 또는 plaintext 파일에 출력하거나 기록하지 않는다.
|
||||
|
||||
검증된 read-only exact match는 `SLACK_KEEPASS_RECOVERY=NOOP`, 검증된 변경은
|
||||
`SLACK_KEEPASS_RECOVERY=COMMITTED`를 출력한다. 두 성공 분기 모두 SSD source가 실제로
|
||||
unmount된 뒤 `WINDOWS_SSD_UNMOUNTED=PASS`와
|
||||
`OFF_HOST_RECOVERY_SATISFIED=NO`를 출력한다. 변경 분기는 durable non-clobbering
|
||||
pre-change backup을 만든 뒤 `KDBX_PRECHANGE_BACKUP=CREATED`도 출력한다. rename 시도 뒤
|
||||
응답 손실, post-commit 검증 실패, 또는 cleanup/unmount 불명은 재시도하지 않고
|
||||
`SLACK_KEEPASS_RECOVERY=MANUAL_RECOVERY_REQUIRED`로 중단하며 main과 backup을 보존한다.
|
||||
private tmpfs work root, socket, helper process와 mount source의 제거·부재 증명 전에는 성공
|
||||
token을 출력하지 않는다.
|
||||
|
||||
이 same-host encrypted copy는 off-host escrow가 아니며 Phase 4의 off-host recovery gate를
|
||||
충족하거나 승인하지 않는다.
|
||||
|
||||
## 관측성 Secret create-only bootstrap
|
||||
|
||||
`create-observability-secrets.sh`는 기본 실행에서 고정 contract만 출력하며 cluster, sudo,
|
||||
payload file에 접근하지 않는다. execute는 선택한 `grafana-admin`과
|
||||
`alertmanager-slack-webhook`의 exact `Opaque` key set과 기존 payload 일치를 확인하며,
|
||||
기존 payload가 다르면 rotation 없이 중단한다. 둘을 함께 선택하면 한 create-only
|
||||
transaction으로 처리한다.
|
||||
|
||||
```sh
|
||||
bash scripts/bootstrap/create-observability-secrets.sh
|
||||
bash scripts/bootstrap/create-observability-secrets.sh \
|
||||
--execute --grafana-admin \
|
||||
--grafana-admin-user-file /absolute/current-user-0600/admin-user \
|
||||
--grafana-admin-password-file /absolute/current-user-0600/admin-password
|
||||
bash scripts/bootstrap/create-observability-secrets.sh \
|
||||
--execute --slack-webhook \
|
||||
--slack-webhook-file /absolute/current-user-0600/slack-webhook
|
||||
bash scripts/bootstrap/create-observability-secrets.sh \
|
||||
--check-grafana-recovery-evidence
|
||||
bash scripts/bootstrap/create-observability-secrets.sh \
|
||||
--check-slack-recovery-evidence
|
||||
```
|
||||
|
||||
execute는 confirmation 전과 create 직전에 k3s encryption/restore validator를 각각 새
|
||||
process로 실행한다. 입력은 no-follow private snapshot으로만 create에 전달하고 값이나 hash를
|
||||
출력하지 않는다. transaction 실패 시 이번 호출이 생성하고 UID를 캡처한 Secret만 Kubernetes
|
||||
API UID precondition으로 삭제한다. create 결과 또는 ownership이 모호하면 해당 object는
|
||||
삭제하지 않고 `MANUAL_RECOVERY_REQUIRED=YES`로 중단한다. recovery marker는 root-owned
|
||||
mode `0600` 네 필드만 허용하며 standalone check는 current-user kube context와 30일 age를
|
||||
확인하고 marker stat/read에만 좁은 sudo를 사용한다.
|
||||
|
||||
fixture 회귀는 실제 production 함수와 private Unix-socket API precondition 경계를 실행하되
|
||||
live Secret이나 실제 payload에는 접근하지 않는다.
|
||||
|
||||
```sh
|
||||
bash -n scripts/bootstrap/create-observability-secrets.sh
|
||||
bash scripts/validate/test-create-observability-secrets.sh
|
||||
```
|
||||
|
||||
## Grafana–Keycloak OIDC bootstrap 검증
|
||||
|
||||
`configure-keycloak-grafana-oidc.sh`는 인자 없이 실행하면 고정 client/group/mapper/Secret
|
||||
계획만 출력하며 Kubernetes, Keycloak, sudo와 payload에 접근하지 않는다. execute는 정확히
|
||||
`default` context, `https://127.0.0.1:6443` API, Ready
|
||||
`donghyeon-system-product-name` node와 bounded authorization을 다시 고정한 뒤 loopback-only
|
||||
Keycloak Admin API와 private Unix-socket Kubernetes Secret API만 사용한다.
|
||||
|
||||
```sh
|
||||
bash scripts/bootstrap/configure-keycloak-grafana-oidc.sh
|
||||
bash scripts/bootstrap/configure-keycloak-grafana-oidc.sh --execute
|
||||
bash scripts/bootstrap/configure-keycloak-grafana-oidc.sh \
|
||||
--execute --admin OBS_ADMIN_USER --viewer OBS_VIEWER_USER
|
||||
bash scripts/bootstrap/configure-keycloak-grafana-oidc.sh \
|
||||
--check-recovery-evidence
|
||||
```
|
||||
|
||||
`--admin`과 `--viewer`는 정확히 한 명의 기존 Keycloak user만 각 고정 group에 추가한다.
|
||||
옵션이 없으면 임의 사용자를 만들거나 membership을 바꾸지 않는다. 기존 exact client,
|
||||
full-path `groups` mapper, 두 group과 matching `observability/grafana-keycloak-oidc` Secret은
|
||||
credential rotation과 client/Secret rewrite 없이 재사용한다. declarative update가 필요한
|
||||
기존 client는 현재 credential을 private file로 보존해 PUT과 rollback에 명시적으로 넣고,
|
||||
전후 client-secret 관계를 payload 출력 없이 비교한다.
|
||||
|
||||
execute는 confirmation 전과 첫 mutation 직전에 encryption/restore validator를 각각 fresh
|
||||
`/usr/bin/env -i` process로 실행한다. `APPLY default`와
|
||||
`RECOVERY KEYCLOAK default` 뒤에만 mutation을 시작한다. 실패나 `HUP`/`INT`/`TERM`은 exact
|
||||
snapshot과 transaction ledger로 이번 실행이 추가한 membership, mapper/client/group/Secret만
|
||||
역순 복구한다. 생성 Secret 삭제는 UID와 resourceVersion precondition을 모두 사용한다.
|
||||
ownership 또는 response 결과가 모호하면 대상 삭제를 시도하지 않고
|
||||
`MANUAL_RECOVERY_REQUIRED=YES`로 중단한다.
|
||||
|
||||
성공 시 `/etc/hyeonworks/platform/recovery-evidence/keycloak.env`에는 schema, context,
|
||||
resource, checked-at UTC 네 non-secret field만 `root:root 0600`으로 atomic 기록한다.
|
||||
standalone check는 current-user kube context와 exact marker schema, no-follow/link metadata와
|
||||
30일 age를 검사하고 marker 접근에만 좁은 sudo를 사용한다.
|
||||
|
||||
focused fixture는 stateful fake Keycloak Admin API와 raw Kubernetes Secret API를 통해 실제
|
||||
production state machine을 실행한다. create/update/no-op, duplicate cardinality, membership
|
||||
ownership, response loss, conflict, timeout, rollback과 signals를 검증하되 live cluster, sudo,
|
||||
실제 Secret을 읽거나 변경하지 않는다.
|
||||
|
||||
```sh
|
||||
bash -n scripts/bootstrap/configure-keycloak-grafana-oidc.sh
|
||||
bash -n scripts/validate/test-configure-keycloak-grafana-oidc.sh
|
||||
bash scripts/validate/test-configure-keycloak-grafana-oidc.sh
|
||||
```
|
||||
|
||||
## Phase 3 비공개 관리 UI 검증
|
||||
|
||||
`render-admin-services.sh`는 pgAdmin OCI Chart `9.16.0`과 AIStor ObjectStore
|
||||
Chart `1.0.16` archive SHA-256을 다시 검증한다. pgAdmin main과 두 init image가
|
||||
모두 `9.16` amd64 digest로 바뀌었는지, 렌더 결과에 Secret kind가 없는지,
|
||||
Recreate·2Gi Retain Local PV·두 admin Ingress와 namespace 간 NetworkPolicy가
|
||||
유지되는지 확인한다.
|
||||
|
||||
```sh
|
||||
PLATFORM_HELM_BIN=/home/donghyeon/.local/bin/helm \
|
||||
bash scripts/validate/render-admin-services.sh
|
||||
```
|
||||
|
||||
실제 적용 뒤 `admin-ui-smoke.sh --execute --run-s3`로 공개 admin DNS 부재,
|
||||
LAN·Tailscale·Pod DNS, 인증서 SAN, 외부 403, loopback NodePort, pgAdmin 비밀번호
|
||||
저장 차단과 AIStor S3 회귀를 확인한다. 허용·비허용 Keycloak 사용자 브라우저
|
||||
시험은 수동 수용 시험으로 남긴다.
|
||||
|
||||
임시 network smoke Pod는 BusyBox 1.37.0의 amd64 manifest digest를 고정하고
|
||||
non-root UID, RuntimeDefault seccomp, 전체 capability drop,
|
||||
allowPrivilegeEscalation=false, read-only root filesystem과 service account token
|
||||
미마운트를 적용한다. `platform-admin`의 Pod Security restricted 정책을
|
||||
우회하지 않는다.
|
||||
|
||||
## 로컬 K3s recovery 저장소 읽기 전용 검증
|
||||
|
||||
`k3s-local-recovery.sh`는 recovery SSD의 고정 hardware identity, SMART/NTFS 상태와
|
||||
mount → container → loop → LUKS2 → ext4 lineage를 읽기 전용으로 판정한다. 실행
|
||||
전에 같은 terminal에서 sudo credential을 미리 준비해야 하며, validator 자체가 암호를
|
||||
요구하거나 package를 설치하지 않는다.
|
||||
|
||||
```sh
|
||||
sudo -v
|
||||
bash scripts/validate/k3s-local-recovery.sh --expect-device-ready
|
||||
bash scripts/validate/k3s-local-recovery.sh --expect-closed
|
||||
bash scripts/validate/k3s-local-recovery.sh --expect-open
|
||||
bash scripts/validate/k3s-local-recovery.sh --expect-open --check-latest-bundle
|
||||
```
|
||||
|
||||
기대 상태는 정확히 하나만 지정한다. `--expect-device-ready`와 `--expect-closed`는 두
|
||||
platform mount, 운영·proof mapping과 container 관련 loop가 모두 없는 상태만 허용한다.
|
||||
device-ready 검사는 canonical recovery partition이 다른 mountpoint에도 source로 쓰이지
|
||||
않고 `ntfs-3g.probe --readwrite`가 성공해야 한다. `--expect-open`은 그 partition이 승인된
|
||||
outer mount 한 곳에만 연결되고, `ntfs3`의 `nodev,nosuid,noexec`와 안전한
|
||||
`umask=077` 또는 동등한 `dmask=0077,fmask=0077`을 가져야 한다. 또한 canonical parent
|
||||
chain과 container inode, loop backing inode/device, zero offset/size limit, LUKS2 mapper와
|
||||
두 mount의 major:minor가 하나의 lineage여야 하며 마지막에 같은 snapshot을 다시 확인한다.
|
||||
완전 할당된 고정 크기 container, ext4 label과 `root:root 0700` inner root도 모두
|
||||
필수다. dirty/hibernated NTFS를 고치거나 force mount하는 동작은 없다. root EUID나
|
||||
상속된 xtrace 상태에서는 workspace path/config를 읽기 전에 거부한다.
|
||||
|
||||
`--check-latest-bundle`은 open 상태에서만 쓸 수 있다. inner root의 root-owned mode
|
||||
`0600` `.latest-post-bundle.env`는 다음 세 key를 정확히 한 번씩 가져야 한다.
|
||||
|
||||
```text
|
||||
schema=k3slr-latest-post-bundle-v1
|
||||
relative_path=k3s-secrets-encryption-YYYYMMDDTHHMMSSZ/post
|
||||
directory_identity=DEVICE:INODE
|
||||
```
|
||||
|
||||
validator는 이 제한된 relative path와 directory identity를 전후로 재확인하고,
|
||||
root-owned post `bundle.env` schema와 안전한 relative-name manifest를 검사한다. manifest의
|
||||
각 parent와 leaf는 symlink가 아닌 pinned directory 내부 object여야 하며, regular-file
|
||||
device/inode를 hash 전후에 확인하면서 각 target을 개별 `sha256sum`으로 검증한다. 성공
|
||||
출력은 다음 네 분류뿐이며 stable ID, UUID, serial, WWN, loop/KDBX/bundle path와 payload를
|
||||
표시하지 않는다.
|
||||
|
||||
```text
|
||||
Recovery device: match
|
||||
Recovery state: device_ready|closed|open
|
||||
Lineage: match
|
||||
Latest bundle: verified|not_checked
|
||||
```
|
||||
|
||||
fixture 회귀는 system command 경계만 argv log를 남기는 fake로 바꾸고 실제 collector와
|
||||
parser를 호출한다. latest verifier는 임시 일반 directory의 valid, malformed, duplicate,
|
||||
symlink escape, hash mismatch bundle을 직접 검사하며 live mount나 block device를 만들지
|
||||
않는다.
|
||||
|
||||
```sh
|
||||
bash scripts/validate/test-k3s-local-recovery.sh
|
||||
```
|
||||
|
||||
### KeePassXC → cryptsetup anonymous-pipe feasibility
|
||||
|
||||
`k3s-local-recovery-feasibility.sh`는 recovery 저장소를 만들기 전에 고정 package와
|
||||
KeePassXC CLI의 synthetic KDBX 동작을 확인한다. 요구 version은 `keepassxc`
|
||||
`2.7.6+dfsg.1-1build3`, `cryptsetup-bin` `2:2.7.0-1ubuntu4.2`이며 executable도
|
||||
root-owned regular non-symlink, group/other non-writable 조건을 만족해야 한다.
|
||||
|
||||
```sh
|
||||
bash scripts/validate/k3s-local-recovery-feasibility.sh
|
||||
sudo -v
|
||||
bash scripts/validate/k3s-local-recovery-feasibility.sh --execute
|
||||
```
|
||||
|
||||
기본 실행은 package/executable prerequisite만 판정한다. `--execute`는 interactive stdin과
|
||||
같은 terminal의 cached sudo credential을 요구하며, inherited xtrace나 root EUID에서는
|
||||
workspace library를 읽기 전에 거부한다. 이 mode가 만드는 것은 current-user `0700`
|
||||
`/tmp/k3slr-feasibility.*` 아래 synthetic KDBX와 attachment round-trip fixture뿐이다.
|
||||
실제 KeePass DB, LUKS file, loop device, mapping, mount와 K3s 상태는 읽거나 변경하지 않는다.
|
||||
성공·실패·`INT`·`TERM` 모두 exact fixture directory를 정리한다.
|
||||
|
||||
실제 recovery open/format API는 고정 entry의 Password attribute를 shell 변수, command
|
||||
substitution, argv, environment, file, log 또는 `tee`에 넣지 않는다. producer stdout은
|
||||
anonymous kernel pipe FD로만 전달된다. 검증된 caller TTY FD를 producer stdin에 명시적으로
|
||||
연결하고 producer 내부에서도 TTY를 재검증한다. producer output은 streaming `od`와 bounded
|
||||
`mawk` validator가 전부 drain하며 non-shell process 안에 최대 40 byte만 유지한다. producer,
|
||||
EOF, exact 40자+LF/class 검증과 KDBX 전체 parent lineage 재검증이 모두 성공한 뒤에만 정확한
|
||||
41 byte가 parent pipe로 전달되고 다음 두 고정 consumer 중 하나가 읽기 시작한다.
|
||||
producer supervisor는 시작 직후 STOP handshake를 수행하고 caller와 다른 실제 PGID를 trusted
|
||||
`ps`로 확인한 뒤에만 pipeline을 시작한다. 이때 `/proc/PID/stat`의 start time, direct parent,
|
||||
observed PGID를 함께 고정하며, negative group signal 직전마다 동일 identity와 caller PGID를
|
||||
다시 확인한다. `INT`/`TERM` trap은 pending state만 기록하고 lifecycle checkpoint가 TTY/pipe
|
||||
FD를 닫은 뒤 bounded TERM, 필요 시 KILL, direct-child wait/reap과 group 소멸 확인을 수행한다.
|
||||
`/proc` reader는 정확히 LF 하나로 끝나는 단일 record와 EOF를 요구하고 CR, 추가 record,
|
||||
non-canonical PID/PPID/PGID/starttime을 거부한다. PID publication 직후 STOP query보다 먼저
|
||||
direct-child identity를 pin하며, 이 최초 pin 자체가 실패하면 worker를 CONT하지 않고 freshly
|
||||
published direct PID에만 positive KILL한 뒤 wait한다.
|
||||
same-caller/query-failure에서는 group signal 없이 STOP된 exact direct child만 positive
|
||||
TERM/CONT/KILL한다. wait/reap과 PID/PGID/identity clear는 pending signal이 stale group state를
|
||||
소비할 수 없는 하나의 transition으로 처리한다. coprocess PID가 PGID라고 가정하지 않는다.
|
||||
supervisor는 initial STOP 전에 child-side INT/TERM cancellation deferral을 설치한다. cleanup
|
||||
TERM을 받으면 worker를 시작하거나 종료하지 않고 ownership anchor로 남아, TERM-ignore
|
||||
descendant가 있더라도 fresh authority로 group KILL과 direct wait를 완료할 수 있게 한다.
|
||||
|
||||
```text
|
||||
/usr/bin/sudo --non-interactive -- /usr/sbin/cryptsetup luksFormat --batch-mode --type luks2 --key-file=- LOOP
|
||||
/usr/bin/sudo --non-interactive -- /usr/sbin/cryptsetup open --type luks2 --key-file=- LOOP ALLOWED_MAPPING
|
||||
```
|
||||
|
||||
production helper는 outer mount부터 exact current-user `0600` regular non-symlink KDBX까지
|
||||
모든 component가 canonical non-symlink인 것, library-owned entry, canonical loop block
|
||||
device, allowlisted mapping과 cached sudo를 요구한다. fixture suite는 partial/zero producer
|
||||
failure의 consumer dispatch 0, xtrace 선차단, non-TTY, exact KeePassXC argv, process
|
||||
argv/environment와 stdout/stderr/tmp/run 누출, real blocking-child signal reap, exclusive
|
||||
synthetic lifecycle과 cleanup failure 전파를 low command-boundary fake로 검사한다.
|
||||
signal fixture는 launch, PID publish, STOP/query, CONT 전후, wait/reap-clear, consumer 직전
|
||||
경계에서 `INT`/`TERM`을 결정적으로 주입하고 supervisor, process-substitution shell,
|
||||
KeePassXC, `od`, `mawk`와 feasibility nested roles의 종료, caller/unrelated sentinel 생존,
|
||||
consumer dispatch 0과 반복-run leak 0을 확인한다.
|
||||
synthetic KDBX의 `add`와 `attachment-import`는 atomic-save inode 교체를 허용하되, 각 mutation
|
||||
직후 exact path/owner/mode/non-symlink를 다시 확인하여 secure 새 inode를 baseline으로 삼는다.
|
||||
read-only 단계에서는 baseline과 fixture parent identity가 바뀌면 즉시 실패한다.
|
||||
고정 package가 없는 host에서는 `--execute`를 실행하거나 임의 version을 설치하지 않고
|
||||
명시적으로 SKIP한다.
|
||||
|
||||
## k3s Secret 암호화 live 읽기 전용 검증
|
||||
|
||||
`k3s-secret-encryption.sh`는 k3s 서비스나 Kubernetes 리소스를 변경하지 않고 현재
|
||||
Secret 암호화 상태, 서버 annotation 일치 여부, local config 무결성과 API readiness를
|
||||
분류한다. 일반 사용자 shell에서 실행하면 제한 시간과 `sudo --non-interactive`가 적용된
|
||||
개별 read-only 명령만 root 전용 systemd 환경, datastore 증거와 credential 파일을 읽는다.
|
||||
workspace의 validator나 library 자체를 root shell에서 실행하거나 source하지 않는다.
|
||||
`/usr/local/bin/k3s`, `/usr/bin/systemctl`, `/usr/bin/stat` 등 허용된 절대경로의
|
||||
root-owned·non-group/other-writable system binary만 검증한 뒤 실행한다. raw status, 환경 변수
|
||||
원문, annotation, hash, active key, token, password 또는 config 내용은 사용자 terminal,
|
||||
명령 인자나 handoff에 출력하지 않고 현재 사용자 프로세스의 메모리에서만 분류한다.
|
||||
|
||||
validator는 비대화형 sudo만 사용하므로 실행 직전에 **같은 terminal**에서 credential을
|
||||
먼저 갱신해야 한다. 다른 terminal에서 실행한 `sudo -v`는 이 실행의 prerequisite를
|
||||
충족한다고 가정하지 않는다.
|
||||
|
||||
```sh
|
||||
sudo -v
|
||||
```
|
||||
|
||||
credential validation이 실패하면 validator는 root-only evidence를 하나도 읽지 않고
|
||||
`sudo -v`를 같은 terminal에서 실행한 뒤 다시 시도하라는 오류로 즉시 종료한다.
|
||||
|
||||
```sh
|
||||
bash scripts/validate/k3s-secret-encryption.sh
|
||||
bash scripts/validate/k3s-secret-encryption.sh --expect-disabled
|
||||
bash scripts/validate/k3s-secret-encryption.sh --expect-transition-start
|
||||
bash scripts/validate/k3s-secret-encryption.sh --expect-enabled
|
||||
bash scripts/validate/k3s-secret-encryption.sh --expect-reencrypted
|
||||
```
|
||||
|
||||
기대 상태 옵션은 최대 하나만 지정한다. 옵션이 없으면 inventory만 수행하며 status가
|
||||
`unsafe_transition`, `hash_mismatch`, `invalid`로 분류되면 실패한다.
|
||||
모든 성공 경로는 version이 정확히 `v1.36.2+k3s1`, server가 정확히 1개, server
|
||||
node가 정확히 `donghyeon-system-product-name`이고 Ready인 것을 요구한다.
|
||||
`--expect-enabled`는 운영 진단용으로 `Enabled/start`와
|
||||
`Enabled/reencrypt_finished`를 모두 허용한다. Phase 4 완료 gate인
|
||||
`--expect-reencrypted`는 `Enabled/reencrypt_finished`이면서 local config, state,
|
||||
server annotation hash가 모두 일치할 때만 성공한다. `--expect-transition-start`도
|
||||
status의 hash match가 확인된 exact transition만 허용한다. 기대 상태 불일치와 분류할 수
|
||||
없는 결과, API readyz 실패는 non-zero로 종료한다.
|
||||
|
||||
검증된 비민감 handoff가 필요하면 caller가 먼저 제한된 임시 디렉터리를 만든다.
|
||||
|
||||
```sh
|
||||
VERIFY_DIR="$(mktemp -d /tmp/platform-k3s-encryption.XXXXXX)"
|
||||
chmod 0700 "$VERIFY_DIR"
|
||||
bash scripts/validate/k3s-secret-encryption.sh --expect-enabled \
|
||||
--verified-output-dir "$VERIFY_DIR"
|
||||
```
|
||||
|
||||
validator는 handoff 디렉터리를 만들지 않는다. slash가 없는 nonempty suffix를 가진
|
||||
`/tmp/platform-k3s-encryption.<suffix>` physical direct child 중 기존 empty,
|
||||
non-symlink, 현재 사용자 소유 mode `0700` 디렉터리만 허용한다. 검증과 write는 같은
|
||||
열린 directory FD에 묶는다.
|
||||
inventory와 기대 상태 검증이 성공한 뒤에만 noclobber와 `umask 077`로 정확히
|
||||
`inventory.env`, `status.sha256` 두 파일을 만들며, 두 파일은 현재 사용자 소유 regular
|
||||
file mode `0600`이어야 한다. status 원문과 active key 이름은 어느 handoff에도 쓰지
|
||||
않는다. `status.sha256`은 status stdout의 공백이나 trailing newline이 아니라 `jq -cS`로
|
||||
검증·정렬·압축한 single JSON value의 UTF-8 bytes(끝 newline 없음)에 대한 SHA-256이다.
|
||||
기존 파일, 추가 파일, nested/symlink parent, 다른 owner 또는 group/other 권한이 있으면
|
||||
실패한다.
|
||||
|
||||
## k3s Secret 복구 증거 검증
|
||||
|
||||
`k3s-secret-encryption-restore-evidence.sh`는 다음 세 mode를 제공한다.
|
||||
|
||||
```sh
|
||||
bash scripts/validate/k3s-secret-encryption-restore-evidence.sh \
|
||||
--emit-result --bundle-metadata BUNDLE_METADATA_FILE --output RESULT_FILE
|
||||
bash scripts/validate/k3s-secret-encryption-restore-evidence.sh \
|
||||
--record --bundle-metadata BUNDLE_METADATA_FILE --result-file RESULT_FILE
|
||||
bash scripts/validate/k3s-secret-encryption-restore-evidence.sh --check
|
||||
```
|
||||
|
||||
`--emit-result`는 격리 복구 host에서 current-user kube context와 live API를 확인하고
|
||||
`Enabled/reencrypt_finished`, server hash, local integrity, node Ready, version/backend와
|
||||
복구 전후 Secret object count가 모두 맞을 때만 non-sensitive result를 만든다. operator가
|
||||
default route, upstream DNS, 운영 API/datastore route와 모든 egress 차단 시험까지 완료했다고
|
||||
현재 context로 확인해야 한다. 결과에는 `destroyed`가 없다.
|
||||
|
||||
`--record`는 외부 post metadata/result, 24시간 age와 운영 host의 권위 post metadata
|
||||
일곱 field 전체를 비교한다. 일회용 복구 환경과 bundle 복사본 파기 후 정확한 확인 입력을
|
||||
받아야 하며 기존 evidence를 덮어쓰지 않는다. `--check`는 현재 권위 bundle-id,
|
||||
version/backend, 30일 age, `destroyed=confirmed`, live re-encryption과 local integrity를
|
||||
읽기 전용으로 다시 검사한다. 2026-08-09 live 전환은 `Enabled/reencrypt_finished`,
|
||||
hash·integrity·API·node 검사, post bundle 기록과 최신 marker 검증까지 통과했다. 다만
|
||||
격리 restore·파기 evidence는 아직 없으므로 `--check` 성공을 기록하거나 관측성 Phase 4
|
||||
gate를 열지 않는다.
|
||||
|
||||
외부 입력은 현재 사용자 소유 regular non-symlink, mode `0600`, non-empty여야 한다. exact
|
||||
allowlist parser가 duplicate/unknown/empty key, control character, malformed/trailing data,
|
||||
`$(`와 backtick을 sudo 전에 거부한다. 전체 script를 sudo로 실행하지 않고 root 전용 파일의
|
||||
stat/read/install만 좁게 승격한다. result/evidence target은 같은 directory의 `0600`
|
||||
temporary file을 검증한 뒤 기존 파일을 덮어쓰지 않는 atomic install로 만든다.
|
||||
|
||||
전체 수동 절차와 격리 checklist는
|
||||
[k3s Secret 복구 drill](../../bootstrap/manual/k3s-secret-encryption-restore-drill.md)을 따른다.
|
||||
parser와 세 mode의 fixture 회귀는 live cluster에 연결하지 않고 다음 명령에 포함된다.
|
||||
|
||||
```sh
|
||||
bash scripts/validate/test-k3s-secret-encryption-status.sh
|
||||
```
|
||||
Executable
+168
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
set +x
|
||||
|
||||
readonly ROOT="$(cd -- "$(dirname -- "$BASH_SOURCE")/../.." && pwd -P)"
|
||||
readonly LAN_IP=192.168.0.107
|
||||
readonly TAIL_IP=100.92.240.34
|
||||
readonly STORAGE_HOST=storage-admin.learn.hyeonworks.com
|
||||
readonly DB_HOST=db-admin.learn.hyeonworks.com
|
||||
readonly SMOKE_IMAGE="busybox@sha256:7a3ebe5bfd1a4a19797d20b0c0bb39d44393e9a03fd852c0865b0f540d868df0"
|
||||
execute=false
|
||||
run_s3=false
|
||||
|
||||
fail() {
|
||||
printf 'ERROR: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
사용법:
|
||||
bash scripts/validate/admin-ui-smoke.sh
|
||||
bash scripts/validate/admin-ui-smoke.sh --execute [--run-s3]
|
||||
|
||||
기본 실행은 공개 DNS, 인증서, 리소스, Secret key 이름만 읽습니다.
|
||||
--execute는 LAN/Tailscale/Pod DNS와 실제 HTTPS·NetworkPolicy를 검사하는
|
||||
임시 Pod를 만들었다가 삭제합니다. --run-s3는 기존 인증 S3 회귀 검사도
|
||||
연결하며 그 스크립트의 APPLY 확인을 한 번 더 요구합니다.
|
||||
USAGE
|
||||
}
|
||||
|
||||
while (( $# > 0 )); do
|
||||
case "$1" in
|
||||
--execute) execute=true; shift ;;
|
||||
--run-s3) run_s3=true; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) usage >&2; fail "지원하지 않는 인자: $1" ;;
|
||||
esac
|
||||
done
|
||||
[[ "$run_s3" == false || "$execute" == true ]] || fail "--run-s3에는 --execute가 필요합니다"
|
||||
[[ "$(pwd -P)" == "$ROOT" ]] || fail "$ROOT에서 실행하세요"
|
||||
for cmd in curl dig jq kubectl nc openssl rg sort tr; do
|
||||
command -v "$cmd" >/dev/null 2>&1 || fail "$cmd 명령이 필요합니다"
|
||||
done
|
||||
|
||||
for host in "$STORAGE_HOST" "$DB_HOST"; do
|
||||
[[ -z "$(dig +short @1.1.1.1 A "$host" | tr -d '[:space:]')" ]] || fail "$host 공개 A가 있습니다"
|
||||
[[ -z "$(dig +short @1.1.1.1 AAAA "$host" | tr -d '[:space:]')" ]] || fail "$host 공개 AAAA가 있습니다"
|
||||
done
|
||||
|
||||
kubectl -n object-storage get objectstore minio-aistor -o json | \
|
||||
jq -e '.status.healthStatus == "green"' >/dev/null || fail "AIStor가 green이 아닙니다"
|
||||
kubectl -n platform-admin get deployment pgadmin -o json | \
|
||||
jq -e '.status.availableReplicas == 1 and .spec.strategy.type == "Recreate"' >/dev/null || \
|
||||
fail "pgAdmin Deployment가 available/Recreate 상태가 아닙니다"
|
||||
kubectl -n platform-admin get pvc pgadmin -o json | \
|
||||
jq -e '.status.phase == "Bound" and .spec.resources.requests.storage == "2Gi"' >/dev/null || \
|
||||
fail "pgAdmin PVC가 2Gi Bound가 아닙니다"
|
||||
kubectl get pv pgadmin-data-local-pv -o json | \
|
||||
jq -e '.spec.persistentVolumeReclaimPolicy == "Retain"' >/dev/null || fail "pgAdmin PV가 Retain이 아닙니다"
|
||||
|
||||
for contract in "object-storage aistor-keycloak-oidc" "platform-admin pgadmin-keycloak-oidc"; do
|
||||
set -- $contract
|
||||
keys="$(kubectl -n "$1" get "secret/$2" \
|
||||
-o go-template='{{range $key, $_ := .data}}{{$key}}{{"\n"}}{{end}}' | LC_ALL=C sort)"
|
||||
[[ "$keys" == $'client-id\nclient-secret' ]] || fail "$1/$2 Secret 계약이 다릅니다"
|
||||
done
|
||||
keys="$(kubectl -n platform-admin get secret pgadmin-bootstrap \
|
||||
-o go-template='{{range $key, $_ := .data}}{{$key}}{{"\n"}}{{end}}')"
|
||||
[[ "$keys" == password ]] || fail "pgAdmin bootstrap Secret 계약이 다릅니다"
|
||||
|
||||
config="$(kubectl -n platform-admin get configmap pgadmin-config -o jsonpath='{.data.config_local\.py}')"
|
||||
printf '%s\n' "$config" | rg -q -F 'ALLOW_SAVE_PASSWORD = False' || fail "비밀번호 저장 차단이 없습니다"
|
||||
printf '%s\n' "$config" | rg -q -F "'groups': ['/platform-db-admins']" || fail "pgAdmin 그룹 gate가 없습니다"
|
||||
kubectl -n platform-data exec platform-postgres-1 -c postgres -- \
|
||||
psql -Atqc "SELECT count(*) FROM pg_roles WHERE rolname IN ('pgadmin','platform-dba') OR (rolsuper AND rolname NOT IN ('postgres','streaming_replica'));" \
|
||||
| rg -q '^0$' || fail "예상하지 못한 pgAdmin/DBA/superuser 역할이 있습니다"
|
||||
|
||||
cert="$(openssl s_client -connect 127.0.0.1:443 -servername "$STORAGE_HOST" </dev/null 2>/dev/null | \
|
||||
openssl x509 -noout -text)"
|
||||
printf '%s\n' "$cert" | rg -q "DNS:$STORAGE_HOST" || fail "admin 인증서 SAN이 없습니다"
|
||||
printf '%s\n' "$cert" | rg -q "DNS:$DB_HOST" || fail "admin 인증서 SAN이 없습니다"
|
||||
|
||||
printf 'READ-ONLY CHECK PASS: 공개 admin DNS 없음, 리소스·Secret 계약·인증서 정상\n'
|
||||
if [[ "$execute" == false ]]; then
|
||||
printf '%s\n' '--execute를 지정하지 않아 임시 Pod와 네트워크 요청은 만들지 않았습니다.'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
[[ -t 0 ]] || fail "--execute는 대화형 터미널이 필요합니다"
|
||||
printf 'Type APPLY to run transient DNS and network smoke tests: '
|
||||
read -r answer
|
||||
[[ "$answer" == APPLY ]] || fail "취소했습니다"
|
||||
|
||||
for pair in "$LAN_IP $STORAGE_HOST" "$LAN_IP $DB_HOST" "$TAIL_IP $STORAGE_HOST" "$TAIL_IP $DB_HOST"; do
|
||||
set -- $pair
|
||||
[[ "$(dig +short "@$1" A "$2" | tail -n1)" == "$1" ]] || fail "$2 private DNS 실패"
|
||||
done
|
||||
|
||||
for host in "$STORAGE_HOST" "$DB_HOST"; do
|
||||
code="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \
|
||||
--noproxy '*' --interface "$LAN_IP" --resolve "$host:443:$LAN_IP" "https://$host/")"
|
||||
[[ "$code" == 200 || "$code" == 302 || "$code" == 303 ]] || fail "$host LAN HTTPS 실패: $code"
|
||||
denied="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \
|
||||
--noproxy '*' --resolve "$host:443:127.0.0.1" "https://$host/")"
|
||||
[[ "$denied" == 403 ]] || fail "$host 허용 목록 밖 요청이 403이 아닙니다"
|
||||
done
|
||||
|
||||
if nc -z -w 2 "$LAN_IP" 30080 || nc -z -w 2 "$LAN_IP" 30443; then
|
||||
fail "Traefik NodePort가 LAN 주소에 노출됐습니다"
|
||||
fi
|
||||
nc -z -w 2 127.0.0.1 30080 || fail "Traefik HTTP NodePort loopback이 닫혔습니다"
|
||||
|
||||
pod="admin-ui-smoke-$(date +%H%M%S)"
|
||||
cleanup_pod() {
|
||||
kubectl -n platform-admin delete "pod/$pod" --ignore-not-found --wait=false >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup_pod EXIT INT TERM
|
||||
kubectl apply -f - <<EOF
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: $pod
|
||||
namespace: platform-admin
|
||||
labels:
|
||||
app.kubernetes.io/name: pgadmin4
|
||||
app.kubernetes.io/instance: pgadmin
|
||||
app.kubernetes.io/component: network-smoke
|
||||
app.kubernetes.io/part-of: platform
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
restartPolicy: Never
|
||||
terminationGracePeriodSeconds: 1
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65532
|
||||
runAsGroup: 65532
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: network-smoke
|
||||
image: $SMOKE_IMAGE
|
||||
imagePullPolicy: IfNotPresent
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
readOnlyRootFilesystem: true
|
||||
command:
|
||||
- sh
|
||||
- -ec
|
||||
- |
|
||||
test "\$(nslookup id.learn.hyeonworks.com | awk '/^Address: /{print \$2}' | tail -n1)" = 192.168.0.107
|
||||
test "\$(nslookup storage-admin.learn.hyeonworks.com | awk '/^Address: /{print \$2}' | tail -n1)" = 192.168.0.107
|
||||
nc -z -w 5 platform-postgres-rw.platform-data.svc.cluster.local 5432
|
||||
EOF
|
||||
kubectl -n platform-admin wait --for=jsonpath='{.status.phase}'=Succeeded "pod/$pod" --timeout=90s
|
||||
cleanup_pod
|
||||
trap - EXIT INT TERM
|
||||
|
||||
if [[ "$run_s3" == true ]]; then
|
||||
bash "$ROOT/scripts/validate/aistor-s3-smoke.sh" --execute
|
||||
fi
|
||||
|
||||
printf 'ADMIN UI SMOKE PASS\n'
|
||||
printf '남은 수동 검증: 허용/비허용 Keycloak 그룹 계정으로 두 UI 로그인\n'
|
||||
Executable
+193
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
# Secret이 셸 추적에 노출되지 않도록 호출자가 bash -x를 사용해도 끈다.
|
||||
set +x
|
||||
|
||||
readonly NAMESPACE="object-storage"
|
||||
readonly OBJECTSTORE="minio-aistor"
|
||||
readonly ROOT_SECRET="aistor-root-configuration"
|
||||
readonly S3_ENDPOINT="http://minio.object-storage.svc.cluster.local"
|
||||
readonly MC_IMAGE="quay.io/minio/aistor/mc@sha256:c5ec777c080fd6292b7529309a0f311c58c3a92e268c9c57c0901da86881949b"
|
||||
readonly PASS_LINE="AISTOR_S3_SMOKE_PASS bucket-create object-write object-read object-delete bucket-delete"
|
||||
|
||||
execute_requested=false
|
||||
pod_name=""
|
||||
|
||||
fail() {
|
||||
printf 'ERROR: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "$pod_name" ]]; then
|
||||
kubectl --namespace "$NAMESPACE" delete pod "$pod_name" \
|
||||
--ignore-not-found --wait=true --timeout=30s >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage:
|
||||
bash scripts/validate/aistor-s3-smoke.sh --execute
|
||||
|
||||
Creates one restricted, short-lived AIStor Client Pod. It authenticates with
|
||||
object-storage/aistor-root-configuration and performs:
|
||||
bucket create -> object write -> SHA-256 read check
|
||||
-> object delete -> bucket delete
|
||||
|
||||
The script never prints credentials. It removes the temporary bucket and Pod.
|
||||
Type APPLY only when prompted because this performs temporary S3 writes.
|
||||
USAGE
|
||||
}
|
||||
|
||||
while (( $# > 0 )); do
|
||||
case "$1" in
|
||||
--execute)
|
||||
execute_requested=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage >&2
|
||||
fail "unsupported argument: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ "$execute_requested" == true ]] || {
|
||||
usage
|
||||
fail "--execute is required"
|
||||
}
|
||||
|
||||
for command_name in kubectl grep jq; do
|
||||
command -v "$command_name" >/dev/null 2>&1 || \
|
||||
fail "${command_name} is required"
|
||||
done
|
||||
|
||||
[[ "$(
|
||||
kubectl --namespace "$NAMESPACE" get objectstore "$OBJECTSTORE" \
|
||||
--output=jsonpath='{.status.currentState}'
|
||||
)" == "Initialized" ]] || fail "ObjectStore is not Initialized"
|
||||
[[ "$(
|
||||
kubectl --namespace "$NAMESPACE" get objectstore "$OBJECTSTORE" \
|
||||
--output=jsonpath='{.status.healthStatus}'
|
||||
)" == "green" ]] || fail "ObjectStore health is not green"
|
||||
kubectl --namespace "$NAMESPACE" get secret "$ROOT_SECRET" >/dev/null
|
||||
|
||||
printf '%s\n' \
|
||||
"Target: ${NAMESPACE}/${OBJECTSTORE}" \
|
||||
"Endpoint: ${S3_ENDPOINT}" \
|
||||
'Temporary resources: one bucket, one object, and one restricted client Pod' \
|
||||
'No credential value will be printed.'
|
||||
printf 'Type APPLY to run the authenticated S3 smoke test: '
|
||||
IFS= read -r confirmation
|
||||
[[ "$confirmation" == "APPLY" ]] || fail "confirmation did not match APPLY"
|
||||
|
||||
pod_name="aistor-s3-smoke-$(date +%H%M%S)-$$"
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
smoke_command='set -eu
|
||||
. /run/aistor-root/config.env
|
||||
bucket="platform-smoke-$(date +%s)"
|
||||
cleanup_bucket() { mc rb --force "local/${bucket}" >/dev/null 2>&1 || true; }
|
||||
trap cleanup_bucket EXIT
|
||||
mc alias set local "'"$S3_ENDPOINT"'" "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" --api S3v4 >/dev/null
|
||||
payload="aistor-authenticated-smoke-$(date +%s)"
|
||||
expected="$(printf "%s" "$payload" | sha256sum | cut -d " " -f 1)"
|
||||
mc mb "local/${bucket}" >/dev/null
|
||||
printf "%s" "$payload" | mc pipe "local/${bucket}/probe.txt" >/dev/null
|
||||
actual="$(mc cat "local/${bucket}/probe.txt" | sha256sum | cut -d " " -f 1)"
|
||||
[ "$expected" = "$actual" ]
|
||||
mc stat "local/${bucket}/probe.txt" >/dev/null
|
||||
mc rm "local/${bucket}/probe.txt" >/dev/null
|
||||
mc rb "local/${bucket}" >/dev/null
|
||||
trap - EXIT
|
||||
printf "%s\n" "'"$PASS_LINE"'"'
|
||||
|
||||
overrides="$(
|
||||
jq -nc --arg command "$smoke_command" --arg image "$MC_IMAGE" '{
|
||||
spec: {
|
||||
automountServiceAccountToken: false,
|
||||
securityContext: {
|
||||
runAsNonRoot: true,
|
||||
runAsUser: 1000,
|
||||
runAsGroup: 1000,
|
||||
fsGroup: 1000,
|
||||
seccompProfile: {type: "RuntimeDefault"}
|
||||
},
|
||||
containers: [{
|
||||
name: "aistor-s3-smoke",
|
||||
image: $image,
|
||||
imagePullPolicy: "IfNotPresent",
|
||||
command: ["/bin/sh", "-ec"],
|
||||
args: [$command],
|
||||
securityContext: {
|
||||
allowPrivilegeEscalation: false,
|
||||
capabilities: {drop: ["ALL"]},
|
||||
readOnlyRootFilesystem: true
|
||||
},
|
||||
env: [{name: "MC_CONFIG_DIR", value: "/tmp/mc"}],
|
||||
volumeMounts: [
|
||||
{name: "root-config", mountPath: "/run/aistor-root", readOnly: true},
|
||||
{name: "tmp", mountPath: "/tmp"}
|
||||
]
|
||||
}],
|
||||
volumes: [
|
||||
{
|
||||
name: "root-config",
|
||||
secret: {secretName: "'"$ROOT_SECRET"'", defaultMode: 256}
|
||||
},
|
||||
{name: "tmp", emptyDir: {}}
|
||||
]
|
||||
}
|
||||
}'
|
||||
)"
|
||||
|
||||
kubectl --namespace "$NAMESPACE" run "$pod_name" \
|
||||
--image="$MC_IMAGE" \
|
||||
--restart=Never \
|
||||
--labels='platform.hyeonworks.com/aistor-client=true,app.kubernetes.io/name=aistor-s3-smoke' \
|
||||
--overrides="$overrides"
|
||||
|
||||
succeeded=false
|
||||
for _ in $(seq 1 90); do
|
||||
phase="$(
|
||||
kubectl --namespace "$NAMESPACE" get pod "$pod_name" \
|
||||
--output=jsonpath='{.status.phase}'
|
||||
)"
|
||||
if [[ "$phase" == "Succeeded" ]]; then
|
||||
succeeded=true
|
||||
break
|
||||
fi
|
||||
[[ "$phase" != "Failed" ]] || break
|
||||
|
||||
waiting_reason="$(
|
||||
kubectl --namespace "$NAMESPACE" get pod "$pod_name" \
|
||||
--output=jsonpath='{.status.containerStatuses[0].state.waiting.reason}' \
|
||||
2>/dev/null || true
|
||||
)"
|
||||
case "$waiting_reason" in
|
||||
CreateContainerConfigError|CrashLoopBackOff|ErrImagePull|ImagePullBackOff)
|
||||
break
|
||||
;;
|
||||
esac
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [[ "$succeeded" != true ]]; then
|
||||
kubectl --namespace "$NAMESPACE" logs "$pod_name" || true
|
||||
kubectl --namespace "$NAMESPACE" describe pod "$pod_name" | tail -n 60
|
||||
fail "authenticated S3 smoke test failed"
|
||||
fi
|
||||
|
||||
log_output="$(kubectl --namespace "$NAMESPACE" logs "$pod_name")"
|
||||
grep --fixed-strings --line-regexp --quiet "$PASS_LINE" <<<"$log_output" || \
|
||||
fail "smoke Pod did not emit the exact success contract"
|
||||
printf '%s\n' "$PASS_LINE"
|
||||
printf '%s\n' \
|
||||
"Client image: ${MC_IMAGE}" \
|
||||
'Temporary bucket, object, and client Pod cleanup: complete'
|
||||
@@ -0,0 +1,812 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Repository-owned, read-only Prometheus metric inventory capture. This file is
|
||||
# sourceable so its parsers can be exercised without contacting a cluster.
|
||||
|
||||
readonly INVENTORY_REJECTION_STATUS=23
|
||||
readonly INVENTORY_SCHEMA='platform-observability-metric-inventory/v1'
|
||||
readonly INVENTORY_CONTEXT='default'
|
||||
readonly INVENTORY_API_SERVER='https://127.0.0.1:6443'
|
||||
readonly INVENTORY_PROMETHEUS_NAMESPACE='observability'
|
||||
readonly INVENTORY_PROMETHEUS_NAME='observability-core-kube-pr-prometheus'
|
||||
readonly INVENTORY_PROMETHEUS_SERVICE='observability-core-kube-pr-prometheus'
|
||||
readonly INVENTORY_MAX_RESPONSE_BYTES=8388608
|
||||
|
||||
inventory_reject() {
|
||||
printf 'REJECT: target-initial inventory precondition failed\n' >&2
|
||||
return "$INVENTORY_REJECTION_STATUS"
|
||||
}
|
||||
|
||||
inventory_expected_pools() {
|
||||
local phase="${1-target-initial}"
|
||||
[[ "$phase" == target-initial || "$phase" == post-substrate ]] || {
|
||||
inventory_reject
|
||||
return
|
||||
}
|
||||
cat <<'POOLS'
|
||||
podMonitor/platform-data/platform-postgres/0
|
||||
serviceMonitor/gitea/gitea/0
|
||||
serviceMonitor/keycloak/keycloak/0
|
||||
serviceMonitor/kube-system/traefik/0
|
||||
serviceMonitor/object-storage/aistor-bucket-usage/0
|
||||
serviceMonitor/observability-agent/alloy/0
|
||||
serviceMonitor/observability-agent/node-exporter/0
|
||||
serviceMonitor/observability/grafana/0
|
||||
serviceMonitor/observability/loki/0
|
||||
serviceMonitor/observability/observability-core-kube-pr-alertmanager/0
|
||||
serviceMonitor/observability/observability-core-kube-pr-alertmanager/1
|
||||
serviceMonitor/observability/observability-core-kube-pr-apiserver/0
|
||||
serviceMonitor/observability/observability-core-kube-pr-coredns/0
|
||||
serviceMonitor/observability/observability-core-kube-pr-kubelet/0
|
||||
serviceMonitor/observability/observability-core-kube-pr-kubelet/1
|
||||
serviceMonitor/observability/observability-core-kube-pr-kubelet/2
|
||||
serviceMonitor/observability/observability-core-kube-pr-operator/0
|
||||
serviceMonitor/observability/observability-core-kube-pr-prometheus/0
|
||||
serviceMonitor/observability/observability-core-kube-pr-prometheus/1
|
||||
serviceMonitor/observability/observability-core-kube-state-metrics/0
|
||||
serviceMonitor/observability/tempo/0
|
||||
POOLS
|
||||
if [[ "$phase" == post-substrate ]]; then
|
||||
cat <<'POOLS'
|
||||
probe/observability/platform-private-edge
|
||||
probe/observability/platform-private-edge
|
||||
probe/observability/platform-private-edge
|
||||
probe/observability/platform-private-internal
|
||||
probe/observability/platform-private-internal
|
||||
probe/observability/platform-private-internal
|
||||
probe/observability/platform-public-edge
|
||||
probe/observability/platform-public-edge
|
||||
serviceMonitor/observability/blackbox-exporter/0
|
||||
POOLS
|
||||
fi
|
||||
}
|
||||
|
||||
post_substrate_expected_probe_targets() {
|
||||
cat <<'JSON'
|
||||
[
|
||||
{"scrapePool":"probe/observability/platform-private-edge","scrapeUrl":"http://blackbox-exporter.observability.svc.cluster.local:9115/probe?module=http_private_edge_403&target=https%3A%2F%2Fdb-admin.learn.hyeonworks.com%2F","labels":{"instance":"https://db-admin.learn.hyeonworks.com/","job":"blackbox-private-edge","namespace":"observability","observability.hyeonworks.com/probe-group":"private-edge"}},
|
||||
{"scrapePool":"probe/observability/platform-private-edge","scrapeUrl":"http://blackbox-exporter.observability.svc.cluster.local:9115/probe?module=http_private_edge_403&target=https%3A%2F%2Fgrafana.learn.hyeonworks.com%2F","labels":{"instance":"https://grafana.learn.hyeonworks.com/","job":"blackbox-private-edge","namespace":"observability","observability.hyeonworks.com/probe-group":"private-edge"}},
|
||||
{"scrapePool":"probe/observability/platform-private-edge","scrapeUrl":"http://blackbox-exporter.observability.svc.cluster.local:9115/probe?module=http_private_edge_403&target=https%3A%2F%2Fstorage-admin.learn.hyeonworks.com%2F","labels":{"instance":"https://storage-admin.learn.hyeonworks.com/","job":"blackbox-private-edge","namespace":"observability","observability.hyeonworks.com/probe-group":"private-edge"}},
|
||||
{"scrapePool":"probe/observability/platform-private-internal","scrapeUrl":"http://blackbox-exporter.observability.svc.cluster.local:9115/probe?hostname=db-admin.learn.hyeonworks.com&module=http_private_internal_200&target=http%3A%2F%2Fpgadmin.platform-admin.svc.cluster.local%2Fmisc%2Fping","labels":{"instance":"http://pgadmin.platform-admin.svc.cluster.local/misc/ping","job":"blackbox-private-internal","namespace":"observability","observability.hyeonworks.com/probe-group":"private-internal"}},
|
||||
{"scrapePool":"probe/observability/platform-private-internal","scrapeUrl":"http://blackbox-exporter.observability.svc.cluster.local:9115/probe?hostname=grafana.learn.hyeonworks.com&module=http_private_internal_200&target=http%3A%2F%2Fgrafana.observability.svc.cluster.local%2Fapi%2Fhealth","labels":{"instance":"http://grafana.observability.svc.cluster.local/api/health","job":"blackbox-private-internal","namespace":"observability","observability.hyeonworks.com/probe-group":"private-internal"}},
|
||||
{"scrapePool":"probe/observability/platform-private-internal","scrapeUrl":"http://blackbox-exporter.observability.svc.cluster.local:9115/probe?hostname=storage-admin.learn.hyeonworks.com&module=http_private_internal_200&target=http%3A%2F%2Fminio-aistor-console.object-storage.svc.cluster.local%3A9090%2F","labels":{"instance":"http://minio-aistor-console.object-storage.svc.cluster.local:9090/","job":"blackbox-private-internal","namespace":"observability","observability.hyeonworks.com/probe-group":"private-internal"}},
|
||||
{"scrapePool":"probe/observability/platform-public-edge","scrapeUrl":"http://blackbox-exporter.observability.svc.cluster.local:9115/probe?module=http_2xx&target=https%3A%2F%2Fgit.learn.hyeonworks.com%2Fapi%2Fhealthz","labels":{"instance":"https://git.learn.hyeonworks.com/api/healthz","job":"blackbox-public-edge","namespace":"observability","observability.hyeonworks.com/probe-group":"public-edge"}},
|
||||
{"scrapePool":"probe/observability/platform-public-edge","scrapeUrl":"http://blackbox-exporter.observability.svc.cluster.local:9115/probe?module=http_2xx&target=https%3A%2F%2Fid.learn.hyeonworks.com%2Frealms%2Fhyeonworks%2F.well-known%2Fopenid-configuration","labels":{"instance":"https://id.learn.hyeonworks.com/realms/hyeonworks/.well-known/openid-configuration","job":"blackbox-public-edge","namespace":"observability","observability.hyeonworks.com/probe-group":"public-edge"}}
|
||||
]
|
||||
JSON
|
||||
}
|
||||
|
||||
sanitize_scrape_url() {
|
||||
local url="${1-}" mode="${2-strict}" remainder authority
|
||||
if [[ -z "$url" || "$url" == *[$' \t\r\n']* || "$url" == *'#'* ]]; then
|
||||
inventory_reject
|
||||
return
|
||||
fi
|
||||
if [[ "$mode" == strip-query ]]; then
|
||||
[[ "$url" == *'?'* ]] || { inventory_reject; return; }
|
||||
url="${url%%\?*}"
|
||||
elif [[ "$mode" != strict || "$url" == *'?'* ]]; then
|
||||
inventory_reject
|
||||
return
|
||||
fi
|
||||
case "$url" in
|
||||
http://*|https://*) ;;
|
||||
*) inventory_reject; return ;;
|
||||
esac
|
||||
remainder="${url#*://}"
|
||||
authority="${remainder%%/*}"
|
||||
if [[ -z "$authority" || "$authority" == *'@'* ]]; then
|
||||
inventory_reject
|
||||
return
|
||||
fi
|
||||
printf '%s\n' "$url"
|
||||
}
|
||||
|
||||
validate_targets_json() {
|
||||
local path="${1--}" phase="${2-target-initial}" expected_json post_probes='[]'
|
||||
expected_json="$(inventory_expected_pools "$phase" | jq -Rsc 'split("\n") | map(select(length > 0)) | sort')" || {
|
||||
inventory_reject
|
||||
return
|
||||
}
|
||||
if [[ "$phase" == post-substrate ]]; then
|
||||
post_probes="$(post_substrate_expected_probe_targets | jq -cS 'sort_by(.scrapePool,.scrapeUrl)')" || {
|
||||
inventory_reject
|
||||
return
|
||||
}
|
||||
fi
|
||||
if ! jq -e --arg phase "$phase" --argjson expected "$expected_json" --argjson postProbes "$post_probes" '
|
||||
type == "object" and .status == "success" and
|
||||
(.data | type == "object") and
|
||||
(.data.activeTargets | type == "array") and
|
||||
(.data.droppedTargets | type == "array") and
|
||||
((.data.activeTargets | map(.scrapePool) | sort) == $expected) and
|
||||
all(.data.activeTargets[];
|
||||
type == "object" and (.scrapePool | type == "string") and
|
||||
(.scrapeUrl | type == "string") and (.labels | type == "object") and
|
||||
(.scrapeUrl | test("^https?://[^/?#@[:space:]]+(?:/[^#[:space:]]*)?$")) and
|
||||
($phase == "post-substrate" or
|
||||
(.scrapePool | test("(?:blackbox|(?:^|/)probe(?:/|$))"; "i") | not)) and
|
||||
(.health == "up") and (.lastError == "")) and
|
||||
all(.data.droppedTargets[]?;
|
||||
(.scrapePool | type == "string") and
|
||||
($phase == "post-substrate" or
|
||||
(.scrapePool | test("(?:blackbox|(?:^|/)probe(?:/|$))"; "i") | not))) and
|
||||
([.data.activeTargets[] |
|
||||
select(.scrapePool == "serviceMonitor/object-storage/aistor-bucket-usage/0") |
|
||||
select(.labels.job == "minio-aistor" and
|
||||
.labels.namespace == "object-storage" and
|
||||
.labels.service == "minio" and
|
||||
.labels.endpoint == "http-minio" and
|
||||
.labels.container == "minio")] | length == 1) and
|
||||
($phase == "target-initial" or (
|
||||
([.data.activeTargets[] |
|
||||
select(.scrapePool | startswith("probe/observability/")) |
|
||||
{scrapePool,scrapeUrl,labels}] | sort_by(.scrapePool,.scrapeUrl)) == $postProbes and
|
||||
([.data.activeTargets[] |
|
||||
select(.scrapePool == "serviceMonitor/observability/blackbox-exporter/0") |
|
||||
select(.labels.job == "blackbox-exporter" and
|
||||
.labels.namespace == "observability" and
|
||||
.labels.service == "blackbox-exporter" and
|
||||
.labels.endpoint == "http" and
|
||||
.labels.container == "blackbox-exporter" and
|
||||
(.labels.pod | test("^blackbox-exporter-[a-z0-9]+-[a-z0-9]+$")) and
|
||||
(.labels.instance | test("^[0-9.]+:9115$")) and
|
||||
(.scrapeUrl | test("^http://[0-9.]+:9115/metrics$")))] | length == 1)
|
||||
))
|
||||
' "$path" >/dev/null 2>&1; then
|
||||
inventory_reject
|
||||
return
|
||||
fi
|
||||
}
|
||||
|
||||
validate_dropped_targets_json() {
|
||||
local path="${1--}" phase="${2-target-initial}"
|
||||
if ! jq -e --arg phase "$phase" '
|
||||
type == "object" and .status == "success" and
|
||||
(.data | type == "object") and
|
||||
(.data.activeTargets | type == "array") and
|
||||
(.data.droppedTargets | type == "array") and
|
||||
all(.data.droppedTargets[]?;
|
||||
(.scrapePool | type == "string") and
|
||||
(if $phase == "target-initial"
|
||||
then (.scrapePool | test("(?:blackbox|(?:^|/)probe(?:/|$))"; "i") | not)
|
||||
else (.scrapePool | test("(?:^|/)probe(?:/|$)"; "i") | not)
|
||||
end))
|
||||
' "$path" >/dev/null 2>&1; then
|
||||
inventory_reject
|
||||
return
|
||||
fi
|
||||
}
|
||||
|
||||
validate_probe_series_phase() {
|
||||
local path="${1--}" phase="${2-target-initial}"
|
||||
if ! jq -e --arg phase "$phase" '
|
||||
type == "object" and .status == "success" and (.data | type == "array") and
|
||||
(if $phase == "target-initial" then (.data | length) == 0
|
||||
elif $phase == "post-substrate" then
|
||||
(.data | length) > 0 and
|
||||
all(.data[];
|
||||
type == "object" and (."__name__" | type == "string") and
|
||||
(."__name__" | startswith("probe_")) and
|
||||
.namespace == "observability")
|
||||
else false end)
|
||||
' "$path" >/dev/null 2>&1; then
|
||||
inventory_reject
|
||||
return
|
||||
fi
|
||||
}
|
||||
|
||||
extract_metric_names() {
|
||||
local path="${1--}" names
|
||||
if ! names="$(jq -er '
|
||||
if type == "object" and .status == "success" then
|
||||
if (.data | type == "object") and (.data | length > 0) and
|
||||
all(.data | keys[]; test("^[a-zA-Z_:][a-zA-Z0-9_:]*$"))
|
||||
then .data | keys | sort | .[]
|
||||
elif (.data | type == "array") and (.data | length > 0) and
|
||||
all(.data[];
|
||||
type == "object" and (.target | type == "object") and
|
||||
(.metric | type == "string") and
|
||||
(.metric | test("^[a-zA-Z_:][a-zA-Z0-9_:]*$")) and
|
||||
(.type | type == "string") and (.help | type == "string") and
|
||||
(.unit | type == "string"))
|
||||
then [.data[].metric] | unique | sort | .[]
|
||||
else error("invalid metadata") end
|
||||
else error("invalid metadata") end
|
||||
' "$path" 2>/dev/null)"; then
|
||||
inventory_reject
|
||||
return
|
||||
fi
|
||||
printf '%s\n' "$names"
|
||||
}
|
||||
|
||||
extract_recording_rule_names() {
|
||||
local path="${1--}" names
|
||||
if ! names="$(jq -er '
|
||||
if type == "object" and .status == "success" and
|
||||
(.data | type == "object") and (.data.groups | type == "array") and
|
||||
all(.data.groups[]; (.rules | type == "array"))
|
||||
then [.data.groups[].rules[] | select(.type == "recording") | .name] |
|
||||
if all(.[]; type == "string" and test("^[a-zA-Z_:][a-zA-Z0-9_:]*$"))
|
||||
then unique | sort | join("\n") else error("invalid recording rule") end
|
||||
else error("invalid rules") end
|
||||
' "$path" 2>/dev/null)"; then
|
||||
inventory_reject
|
||||
return
|
||||
fi
|
||||
[[ -z "$names" ]] || printf '%s\n' "$names"
|
||||
}
|
||||
|
||||
reject_recording_rule_collisions() {
|
||||
local rules_path="${1-}" metadata_path="${2-}" rules metrics name
|
||||
if ! rules="$(extract_recording_rule_names "$rules_path")" ||
|
||||
! metrics="$(extract_metric_names "$metadata_path")"; then
|
||||
inventory_reject
|
||||
return
|
||||
fi
|
||||
reject_metric_name_collisions "$rules" "$metrics"
|
||||
}
|
||||
|
||||
reject_metric_name_collisions() {
|
||||
local rules="${1-}" metrics="${2-}" name
|
||||
while IFS= read -r name; do
|
||||
[[ -z "$name" ]] && continue
|
||||
if grep -Fqx -- "$name" <<<"$rules"; then
|
||||
inventory_reject
|
||||
return
|
||||
fi
|
||||
done <<<"$metrics"
|
||||
}
|
||||
|
||||
validate_metric_names_for_pool() {
|
||||
local metrics="${1-}" pool="${2-}" phase="${3-target-initial}"
|
||||
if [[ "$phase" == post-substrate && "$pool" == probe/observability/* ]]; then
|
||||
grep -Fqx probe_success <<<"$metrics" || { inventory_reject; return; }
|
||||
elif grep -Eq '^probe_' <<<"$metrics"; then
|
||||
inventory_reject
|
||||
return
|
||||
fi
|
||||
}
|
||||
|
||||
extract_series_label_names() {
|
||||
local path="${1--}" result
|
||||
if ! result="$(jq -ceS '
|
||||
if type == "object" and .status == "success" and
|
||||
(.data | type == "array") and (.data | length > 0) and
|
||||
all(.data[]; type == "object") and
|
||||
all([.data[] | keys[]][]; test("^[a-zA-Z_][a-zA-Z0-9_./-]*$"))
|
||||
then [.data[] | keys[]] | unique | sort
|
||||
else error("invalid series") end
|
||||
' "$path" 2>/dev/null)"; then
|
||||
inventory_reject
|
||||
return
|
||||
fi
|
||||
printf '%s\n' "$result"
|
||||
}
|
||||
|
||||
prometheus_selector_for_labels() {
|
||||
local labels_json="${1-}"
|
||||
jq -cer '
|
||||
if type == "object" and length > 0 and
|
||||
all(keys[]; test("^[a-zA-Z_][a-zA-Z0-9_./-]*$")) and
|
||||
all(.[]; type == "string" and (explode | all(. >= 32 and . != 127)))
|
||||
then "{" + (to_entries | sort_by(.key) |
|
||||
map((if (.key | test("^[a-zA-Z_][a-zA-Z0-9_]*$"))
|
||||
then .key else (.key | @json) end) + "=" + (.value | @json)) | join(",")) + "}"
|
||||
else error("invalid target labels") end
|
||||
' <<<"$labels_json" 2>/dev/null || inventory_reject
|
||||
}
|
||||
|
||||
inventory_provenance() {
|
||||
case "${1-}" in
|
||||
podMonitor/platform-data/platform-postgres/0|\
|
||||
serviceMonitor/gitea/gitea/0|\
|
||||
serviceMonitor/keycloak/keycloak/0|\
|
||||
serviceMonitor/kube-system/traefik/0|\
|
||||
serviceMonitor/object-storage/aistor-bucket-usage/0)
|
||||
printf '%s\n' 'task6-owned'
|
||||
;;
|
||||
serviceMonitor/observability/grafana/0)
|
||||
printf '%s\n' 'preexisting-verify-only'
|
||||
;;
|
||||
serviceMonitor/observability/blackbox-exporter/0|probe/observability/platform-*)
|
||||
printf '%s\n' 'substrate-owned'
|
||||
;;
|
||||
*)
|
||||
printf '%s\n' 'core'
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
validate_output_directory() {
|
||||
local directory="${1-}" canonical uid mode current component
|
||||
if [[ -z "$directory" || "$directory" != /* || "$directory" == *[$'\t\r\n']* ||
|
||||
! -d "$directory" || -L "$directory" ]]; then
|
||||
inventory_reject
|
||||
return
|
||||
fi
|
||||
if ! canonical="$(realpath -e -- "$directory" 2>/dev/null)" || [[ "$canonical" != "$directory" ]]; then
|
||||
inventory_reject
|
||||
return
|
||||
fi
|
||||
current=''
|
||||
IFS='/' read -r -a components <<<"${directory#/}"
|
||||
for component in "${components[@]}"; do
|
||||
[[ -n "$component" && "$component" != '.' && "$component" != '..' ]] || {
|
||||
inventory_reject
|
||||
return
|
||||
}
|
||||
current="$current/$component"
|
||||
if [[ -L "$current" ]]; then
|
||||
inventory_reject
|
||||
return
|
||||
fi
|
||||
done
|
||||
if ! uid="$(stat -c %u -- "$directory" 2>/dev/null)" ||
|
||||
! mode="$(stat -c %a -- "$directory" 2>/dev/null)" ||
|
||||
[[ "$uid" != "$(id -u)" || "$mode" != 700 ]]; then
|
||||
inventory_reject
|
||||
return
|
||||
fi
|
||||
if [[ -n "$(find "$directory" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]]; then
|
||||
inventory_reject
|
||||
return
|
||||
fi
|
||||
}
|
||||
|
||||
inventory_kubectl() {
|
||||
case "${1-}" in
|
||||
config)
|
||||
case "${2-}" in current-context|view) ;; *) inventory_reject; return ;; esac
|
||||
;;
|
||||
get) ;;
|
||||
auth)
|
||||
[[ "${2-}" == can-i ]] || { inventory_reject; return; }
|
||||
;;
|
||||
*)
|
||||
inventory_reject
|
||||
return
|
||||
;;
|
||||
esac
|
||||
timeout --signal=TERM --kill-after=2s 15s \
|
||||
kubectl --request-timeout=10s "$@" 2>/dev/null
|
||||
}
|
||||
|
||||
inventory_process_snapshot() {
|
||||
local pid="${1-}" stat_line remainder state start_time
|
||||
[[ "$pid" =~ ^[1-9][0-9]*$ && -r "/proc/$pid/stat" ]] || return 1
|
||||
IFS= read -r stat_line <"/proc/$pid/stat" || return 1
|
||||
remainder="${stat_line##*) }"
|
||||
state="${remainder%% *}"
|
||||
start_time="$(awk '{print $20}' <<<"$remainder")" || return 1
|
||||
[[ "$state" =~ ^[A-Z]$ && "$start_time" =~ ^[0-9]+$ ]] || return 1
|
||||
printf '%s:%s\n' "$state" "$start_time"
|
||||
}
|
||||
|
||||
inventory_port_forward_is_owned() {
|
||||
local pid="${INVENTORY_PORT_FORWARD_PID:-}" expected="${INVENTORY_PORT_FORWARD_START:-}" snapshot
|
||||
[[ "$pid" =~ ^[1-9][0-9]*$ && "$expected" =~ ^[0-9]+$ ]] || return 1
|
||||
snapshot="$(inventory_process_snapshot "$pid")" || return 1
|
||||
[[ "${snapshot#*:}" == "$expected" ]]
|
||||
}
|
||||
|
||||
inventory_stop_port_forward() {
|
||||
local pid="${INVENTORY_PORT_FORWARD_PID:-}" snapshot state attempt
|
||||
[[ -n "$pid" ]] || return 0
|
||||
if ! inventory_port_forward_is_owned; then
|
||||
INVENTORY_PORT_FORWARD_PID=''
|
||||
INVENTORY_PORT_FORWARD_START=''
|
||||
return 0
|
||||
fi
|
||||
|
||||
kill -TERM "$pid" 2>/dev/null || true
|
||||
for ((attempt = 0; attempt < 40; attempt++)); do
|
||||
if ! inventory_port_forward_is_owned; then break; fi
|
||||
snapshot="$(inventory_process_snapshot "$pid")" || break
|
||||
state="${snapshot%%:*}"
|
||||
[[ "$state" == Z || "$state" == X ]] && break
|
||||
sleep 0.025
|
||||
done
|
||||
|
||||
if inventory_port_forward_is_owned; then
|
||||
snapshot="$(inventory_process_snapshot "$pid")" || snapshot=''
|
||||
state="${snapshot%%:*}"
|
||||
if [[ "$state" != Z && "$state" != X ]]; then
|
||||
kill -KILL "$pid" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
for ((attempt = 0; attempt < 40; attempt++)); do
|
||||
if ! inventory_port_forward_is_owned; then break; fi
|
||||
snapshot="$(inventory_process_snapshot "$pid")" || break
|
||||
state="${snapshot%%:*}"
|
||||
[[ "$state" == Z || "$state" == X ]] && break
|
||||
sleep 0.025
|
||||
done
|
||||
if inventory_port_forward_is_owned; then
|
||||
snapshot="$(inventory_process_snapshot "$pid")" || snapshot=''
|
||||
state="${snapshot%%:*}"
|
||||
if [[ "$state" != Z && "$state" != X ]]; then return 1; fi
|
||||
fi
|
||||
INVENTORY_PORT_FORWARD_PID=''
|
||||
INVENTORY_PORT_FORWARD_START=''
|
||||
}
|
||||
|
||||
build_inventory_json() {
|
||||
local target_rows="${1-}" phase="${2-}" timestamp="${3-}"
|
||||
[[ -n "$target_rows" && -f "$target_rows" && ! -L "$target_rows" &&
|
||||
-n "$phase" && -n "$timestamp" ]] || { inventory_reject; return; }
|
||||
jq -cnS --slurpfile targets "$target_rows" \
|
||||
--arg schema "$INVENTORY_SCHEMA" --arg phase "$phase" --arg captured_at_utc "$timestamp" \
|
||||
--arg context "$INVENTORY_CONTEXT" --arg api_server "$INVENTORY_API_SERVER" \
|
||||
--arg prometheus "$INVENTORY_PROMETHEUS_NAMESPACE/$INVENTORY_PROMETHEUS_NAME" '
|
||||
{schema:$schema,phase:$phase,captured_at_utc:$captured_at_utc,context:$context,
|
||||
api_server:$api_server,prometheus:$prometheus,preexisting_verify_only:["grafana"],
|
||||
targets:($targets | sort_by(.scrape_pool))}
|
||||
'
|
||||
}
|
||||
|
||||
validate_post_substrate_query_body() {
|
||||
local path="${1--}" kind="${2-}" expected expected_tls now
|
||||
expected="$(post_substrate_expected_probe_targets | jq -cS '[.[].labels] | sort_by(.job,.instance)')" || {
|
||||
inventory_reject
|
||||
return
|
||||
}
|
||||
expected_tls="$(post_substrate_expected_probe_targets | jq -cS '[.[].labels | select(.instance | startswith("https://"))] | sort_by(.job,.instance)')" || {
|
||||
inventory_reject
|
||||
return
|
||||
}
|
||||
now="$(date -u +%s)" || { inventory_reject; return; }
|
||||
if ! jq -e --arg kind "$kind" --argjson expected "$expected" \
|
||||
--argjson expectedTls "$expected_tls" --argjson now "$now" '
|
||||
type == "object" and .status == "success" and .data.resultType == "vector" and
|
||||
(.data.result | type == "array") and
|
||||
(if $kind == "tls" then
|
||||
([.data.result[].metric | del(.__name__)] | sort_by(.job,.instance)) == $expectedTls and
|
||||
all(.data.result[]; (.value[1] | tonumber) > $now)
|
||||
else
|
||||
([.data.result[].metric | del(.__name__)] | sort_by(.job,.instance)) == $expected and
|
||||
(if $kind == "success" then all(.data.result[]; .value[1] == "1")
|
||||
elif $kind == "duration" then all(.data.result[]; (.value[1] | tonumber) >= 0)
|
||||
elif $kind == "status" then
|
||||
all(.data.result[];
|
||||
if .metric.job == "blackbox-private-edge" then .value[1] == "403"
|
||||
else .value[1] == "200" end)
|
||||
else false end)
|
||||
end)
|
||||
' "$path" >/dev/null 2>&1; then
|
||||
inventory_reject
|
||||
return
|
||||
fi
|
||||
}
|
||||
|
||||
inventory_validate_post_substrate_metrics() {
|
||||
local body spec
|
||||
for spec in \
|
||||
'success:probe_success{namespace="observability"}' \
|
||||
'status:probe_http_status_code{namespace="observability"}' \
|
||||
'duration:probe_duration_seconds{namespace="observability"}' \
|
||||
'tls:probe_ssl_earliest_cert_expiry{namespace="observability"}'; do
|
||||
if ! inventory_prometheus_json body '/api/v1/query' --get \
|
||||
--data-urlencode "query=${spec#*:}" ||
|
||||
! printf '%s' "$body" | validate_post_substrate_query_body - "${spec%%:*}"; then
|
||||
inventory_reject
|
||||
return
|
||||
fi
|
||||
body=''
|
||||
done
|
||||
}
|
||||
|
||||
inventory_capture_main() {
|
||||
local output_dir phase context api prometheus_json pods_json permission
|
||||
local port='' attempt start_attempt forward_ready=0 timestamp snapshot
|
||||
local targets_raw='' dropped_raw='' rules_raw='' probe_raw='' metadata_raw='' series_raw='' recording_names=''
|
||||
local row pool scrape_url labels selector metric_names metric
|
||||
local metric_labels series_selector series_count metrics_path metrics_json provenance
|
||||
local target_rows counter=0 inventory hash actual_files
|
||||
|
||||
if (($# != 5)) || [[ "$1" != --execute || "$2" != --phase ||
|
||||
("$3" != target-initial && "$3" != post-substrate) ||
|
||||
"$4" != --output-dir || -z "$5" ]]; then
|
||||
inventory_reject
|
||||
return
|
||||
fi
|
||||
phase="$3"
|
||||
output_dir="$5"
|
||||
|
||||
for permission in jq kubectl curl timeout realpath stat find mktemp sha256sum sync grep head; do
|
||||
command -v "$permission" >/dev/null 2>&1 || { inventory_reject; return; }
|
||||
done
|
||||
validate_output_directory "$output_dir" || return
|
||||
|
||||
INVENTORY_WORK_DIR="$(mktemp -d /tmp/platform-observability-inventory-capture.XXXXXX)" || {
|
||||
inventory_reject
|
||||
return
|
||||
}
|
||||
chmod 0700 "$INVENTORY_WORK_DIR" || { inventory_reject; return; }
|
||||
INVENTORY_STAGE_JSON=''
|
||||
INVENTORY_STAGE_SHA=''
|
||||
INVENTORY_OUTPUT_DIR="$output_dir"
|
||||
INVENTORY_CREATED_JSON=0
|
||||
INVENTORY_CREATED_SHA=0
|
||||
INVENTORY_JSON_ID=''
|
||||
INVENTORY_SHA_ID=''
|
||||
INVENTORY_SUCCESS=0
|
||||
INVENTORY_PORT_FORWARD_PID=''
|
||||
INVENTORY_PORT_FORWARD_START=''
|
||||
|
||||
trap inventory_capture_cleanup EXIT
|
||||
trap 'exit 129' HUP
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
if ! context="$(inventory_kubectl config current-context)" || [[ "$context" != "$INVENTORY_CONTEXT" ]]; then
|
||||
inventory_reject
|
||||
return
|
||||
fi
|
||||
if ! api="$(inventory_kubectl config view --minify -o json | jq -er '.clusters | if length == 1 then .[0].cluster.server else error("cluster count") end' 2>/dev/null)" ||
|
||||
[[ "$api" != "$INVENTORY_API_SERVER" ]]; then
|
||||
inventory_reject
|
||||
return
|
||||
fi
|
||||
if ! inventory_kubectl get --raw=/readyz >/dev/null; then inventory_reject; return; fi
|
||||
|
||||
for permission in \
|
||||
'get pods' \
|
||||
'get services' \
|
||||
'get prometheuses.monitoring.coreos.com' \
|
||||
'create pods/portforward'; do
|
||||
if ! inventory_kubectl auth can-i $permission --namespace "$INVENTORY_PROMETHEUS_NAMESPACE" | grep -Fxq yes; then
|
||||
inventory_reject
|
||||
return
|
||||
fi
|
||||
done
|
||||
|
||||
if ! prometheus_json="$(inventory_kubectl get prometheus "$INVENTORY_PROMETHEUS_NAME" \
|
||||
--namespace "$INVENTORY_PROMETHEUS_NAMESPACE" -o json)" ||
|
||||
! jq -e --arg namespace "$INVENTORY_PROMETHEUS_NAMESPACE" --arg name "$INVENTORY_PROMETHEUS_NAME" '
|
||||
.kind == "Prometheus" and .metadata.namespace == $namespace and .metadata.name == $name
|
||||
' <<<"$prometheus_json" >/dev/null 2>&1; then
|
||||
inventory_reject
|
||||
return
|
||||
fi
|
||||
if ! pods_json="$(inventory_kubectl get pods --namespace "$INVENTORY_PROMETHEUS_NAMESPACE" \
|
||||
-l "prometheus=$INVENTORY_PROMETHEUS_NAME" -o json)" ||
|
||||
! jq -e '
|
||||
(.items | length == 1) and .items[0].status.phase == "Running" and
|
||||
any(.items[0].status.conditions[]; .type == "Ready" and .status == "True") and
|
||||
any(.items[0].status.containerStatuses[]; .name == "prometheus" and .ready == true)
|
||||
' <<<"$pods_json" >/dev/null 2>&1; then
|
||||
inventory_reject
|
||||
return
|
||||
fi
|
||||
|
||||
: >"$INVENTORY_WORK_DIR/port-forward.log"
|
||||
for ((start_attempt = 0; start_attempt < 10; start_attempt++)); do
|
||||
port=$((20000 + ((RANDOM + $$ + start_attempt * 7919) % 40000)))
|
||||
kubectl --request-timeout=10s -n "$INVENTORY_PROMETHEUS_NAMESPACE" \
|
||||
port-forward --address 127.0.0.1 \
|
||||
"service/$INVENTORY_PROMETHEUS_SERVICE" "$port:9090" \
|
||||
>/dev/null 2>"$INVENTORY_WORK_DIR/port-forward.log" &
|
||||
INVENTORY_PORT_FORWARD_PID=$!
|
||||
snapshot="$(inventory_process_snapshot "$INVENTORY_PORT_FORWARD_PID")" || {
|
||||
inventory_reject
|
||||
return
|
||||
}
|
||||
INVENTORY_PORT_FORWARD_START="${snapshot#*:}"
|
||||
INVENTORY_PROMETHEUS_BASE="http://127.0.0.1:$port"
|
||||
for ((attempt = 0; attempt < 40; attempt++)); do
|
||||
if ! kill -0 "$INVENTORY_PORT_FORWARD_PID" 2>/dev/null; then break; fi
|
||||
if inventory_prometheus_ready; then
|
||||
forward_ready=1
|
||||
break 2
|
||||
fi
|
||||
sleep 0.05
|
||||
done
|
||||
inventory_stop_port_forward || { inventory_reject; return; }
|
||||
done
|
||||
if [[ "$forward_ready" != 1 || -z "$INVENTORY_PORT_FORWARD_PID" ]]; then
|
||||
inventory_reject
|
||||
return
|
||||
fi
|
||||
|
||||
if ! inventory_prometheus_json targets_raw '/api/v1/targets' --get --data 'state=active' ||
|
||||
! printf '%s' "$targets_raw" | validate_targets_json - "$phase" ||
|
||||
! inventory_prometheus_json dropped_raw '/api/v1/targets' --get --data 'state=dropped' ||
|
||||
! printf '%s' "$dropped_raw" | validate_dropped_targets_json - "$phase" ||
|
||||
! inventory_prometheus_json rules_raw '/api/v1/rules' ||
|
||||
! recording_names="$(printf '%s' "$rules_raw" | extract_recording_rule_names -)" ||
|
||||
! inventory_prometheus_json probe_raw '/api/v1/series' --get \
|
||||
--data-urlencode 'match[]={__name__=~"probe_.+"}' ||
|
||||
! printf '%s' "$probe_raw" | validate_probe_series_phase - "$phase"; then
|
||||
inventory_reject
|
||||
return
|
||||
fi
|
||||
if [[ "$phase" == post-substrate ]] && ! inventory_validate_post_substrate_metrics; then
|
||||
inventory_reject
|
||||
return
|
||||
fi
|
||||
dropped_raw=''
|
||||
rules_raw=''
|
||||
probe_raw=''
|
||||
|
||||
target_rows="$INVENTORY_WORK_DIR/target-rows.jsonl"
|
||||
: >"$target_rows"
|
||||
while IFS= read -r row; do
|
||||
pool="$(jq -er '.scrapePool' <<<"$row" 2>/dev/null)" || { inventory_reject; return; }
|
||||
scrape_url="$(jq -er '.scrapeUrl' <<<"$row" 2>/dev/null)" || { inventory_reject; return; }
|
||||
if [[ "$phase" == post-substrate && "$pool" == probe/observability/* ]]; then
|
||||
scrape_url="$(sanitize_scrape_url "$scrape_url" strip-query)" || return
|
||||
else
|
||||
scrape_url="$(sanitize_scrape_url "$scrape_url")" || return
|
||||
fi
|
||||
labels="$(jq -ceS '.labels' <<<"$row" 2>/dev/null)" || { inventory_reject; return; }
|
||||
selector="$(prometheus_selector_for_labels "$labels")" || return
|
||||
counter=$((counter + 1))
|
||||
if ! inventory_prometheus_json metadata_raw '/api/v1/targets/metadata' --get \
|
||||
--data-urlencode "match_target=$selector" --data 'limit=10000'; then
|
||||
inventory_reject
|
||||
return
|
||||
fi
|
||||
metric_names="$(printf '%s' "$metadata_raw" | extract_metric_names -)" || return
|
||||
metadata_raw=''
|
||||
reject_metric_name_collisions "$recording_names" "$metric_names" || return
|
||||
validate_metric_names_for_pool "$metric_names" "$pool" "$phase" || return
|
||||
|
||||
metrics_path="$INVENTORY_WORK_DIR/metrics-$counter.jsonl"
|
||||
: >"$metrics_path"
|
||||
while IFS= read -r metric; do
|
||||
series_selector="$(prometheus_selector_for_labels "$(
|
||||
jq -ce --arg metric "$metric" '. + {"__name__":$metric}' <<<"$labels" 2>/dev/null
|
||||
)")" || return
|
||||
if ! inventory_prometheus_json series_raw '/api/v1/series' --get \
|
||||
--data-urlencode "match[]=$series_selector"; then
|
||||
inventory_reject
|
||||
return
|
||||
fi
|
||||
series_count="$(printf '%s' "$series_raw" | jq -er '
|
||||
if type == "object" and .status == "success" and
|
||||
(.data | type == "array") and all(.data[]; type == "object")
|
||||
then .data | length else error("invalid series") end
|
||||
' 2>/dev/null)" || { inventory_reject; return; }
|
||||
if (( series_count == 0 )); then
|
||||
series_raw=''
|
||||
continue
|
||||
fi
|
||||
metric_labels="$(printf '%s' "$series_raw" | extract_series_label_names -)" || return
|
||||
series_raw=''
|
||||
jq -cnS --arg name "$metric" --argjson label_names "$metric_labels" \
|
||||
'{name:$name,label_names:$label_names}' >>"$metrics_path" || { inventory_reject; return; }
|
||||
done <<<"$metric_names"
|
||||
[[ -s "$metrics_path" ]] || { inventory_reject; return; }
|
||||
metrics_json="$(jq -csS 'sort_by(.name)' "$metrics_path" 2>/dev/null)" || { inventory_reject; return; }
|
||||
provenance="$(inventory_provenance "$pool")"
|
||||
jq -cnS --arg scrape_pool "$pool" --arg scrape_url "$scrape_url" \
|
||||
--arg provenance "$provenance" --argjson metrics "$metrics_json" \
|
||||
'{scrape_pool:$scrape_pool,scrape_url:$scrape_url,health:"up",last_error:"",
|
||||
provenance:$provenance,metrics:$metrics}' >>"$target_rows" || { inventory_reject; return; }
|
||||
done < <(printf '%s' "$targets_raw" | jq -c '.data.activeTargets | sort_by(.scrapePool)[]')
|
||||
targets_raw=''
|
||||
|
||||
timestamp="$(date -u +%Y-%m-%dT%H:%M:%SZ)" || { inventory_reject; return; }
|
||||
inventory="$(build_inventory_json "$target_rows" "$phase" "$timestamp")" || return
|
||||
|
||||
validate_output_directory "$output_dir" || return
|
||||
INVENTORY_STAGE_JSON="$(mktemp "$output_dir/.inventory.json.tmp.XXXXXX")" || { inventory_reject; return; }
|
||||
chmod 0600 "$INVENTORY_STAGE_JSON" || { inventory_reject; return; }
|
||||
printf '%s\n' "$inventory" >"$INVENTORY_STAGE_JSON" || { inventory_reject; return; }
|
||||
sync -f "$INVENTORY_STAGE_JSON" >/dev/null 2>&1 || { inventory_reject; return; }
|
||||
INVENTORY_JSON_ID="$(stat -c %d:%i -- "$INVENTORY_STAGE_JSON")" || { inventory_reject; return; }
|
||||
hash="$(sha256sum -- "$INVENTORY_STAGE_JSON" | awk '{print $1}')" || { inventory_reject; return; }
|
||||
INVENTORY_STAGE_SHA="$(mktemp "$output_dir/.inventory.sha256.tmp.XXXXXX")" || { inventory_reject; return; }
|
||||
chmod 0600 "$INVENTORY_STAGE_SHA" || { inventory_reject; return; }
|
||||
printf '%s inventory.json\n' "$hash" >"$INVENTORY_STAGE_SHA" || { inventory_reject; return; }
|
||||
sync -f "$INVENTORY_STAGE_SHA" >/dev/null 2>&1 || { inventory_reject; return; }
|
||||
INVENTORY_SHA_ID="$(stat -c %d:%i -- "$INVENTORY_STAGE_SHA")" || { inventory_reject; return; }
|
||||
|
||||
if ! ln -- "$INVENTORY_STAGE_JSON" "$output_dir/inventory.json" 2>/dev/null; then inventory_reject; return; fi
|
||||
INVENTORY_CREATED_JSON=1
|
||||
rm -- "$INVENTORY_STAGE_JSON" || { inventory_reject; return; }
|
||||
INVENTORY_STAGE_JSON=''
|
||||
if ! ln -- "$INVENTORY_STAGE_SHA" "$output_dir/inventory.sha256" 2>/dev/null; then inventory_reject; return; fi
|
||||
INVENTORY_CREATED_SHA=1
|
||||
rm -- "$INVENTORY_STAGE_SHA" || { inventory_reject; return; }
|
||||
INVENTORY_STAGE_SHA=''
|
||||
sync -f "$output_dir/inventory.json" >/dev/null 2>&1 || { inventory_reject; return; }
|
||||
sync -f "$output_dir/inventory.sha256" >/dev/null 2>&1 || { inventory_reject; return; }
|
||||
sync -f "$output_dir" >/dev/null 2>&1 || { inventory_reject; return; }
|
||||
|
||||
actual_files="$(find "$output_dir" -mindepth 1 -maxdepth 1 -printf '%f\n' | sort)"
|
||||
if [[ "$actual_files" != $'inventory.json\ninventory.sha256' ]] ||
|
||||
[[ ! -f "$output_dir/inventory.json" || -L "$output_dir/inventory.json" ]] ||
|
||||
[[ ! -f "$output_dir/inventory.sha256" || -L "$output_dir/inventory.sha256" ]] ||
|
||||
[[ "$(stat -c %a -- "$output_dir/inventory.json")" != 600 ]] ||
|
||||
[[ "$(stat -c %a -- "$output_dir/inventory.sha256")" != 600 ]]; then
|
||||
inventory_reject
|
||||
return
|
||||
fi
|
||||
INVENTORY_SUCCESS=1
|
||||
printf 'OK: %s inventory captured\n' "$phase"
|
||||
}
|
||||
|
||||
inventory_prometheus_json() {
|
||||
local destination_name="$1" api_path="$2" response
|
||||
local LC_ALL=C
|
||||
local -n destination="$destination_name"
|
||||
shift 2
|
||||
if ! response="$(
|
||||
set -o pipefail
|
||||
curl --disable --silent --show-error --fail --connect-timeout 3 --max-time 10 \
|
||||
--max-filesize "$INVENTORY_MAX_RESPONSE_BYTES" --output - \
|
||||
"$@" "$INVENTORY_PROMETHEUS_BASE$api_path" 2>/dev/null |
|
||||
LC_ALL=C head -c "$((INVENTORY_MAX_RESPONSE_BYTES + 1))"
|
||||
pipeline_status=$?
|
||||
printf '\034'
|
||||
exit "$pipeline_status"
|
||||
)"; then
|
||||
response=''
|
||||
inventory_reject
|
||||
return
|
||||
fi
|
||||
[[ "$response" == *$'\034' ]] || { response=''; inventory_reject; return; }
|
||||
response="${response%?}"
|
||||
if (( ${#response} > INVENTORY_MAX_RESPONSE_BYTES )); then
|
||||
response=''
|
||||
inventory_reject
|
||||
return
|
||||
fi
|
||||
destination="$response"
|
||||
}
|
||||
|
||||
inventory_prometheus_ready() {
|
||||
curl --disable --silent --show-error --fail --connect-timeout 1 --max-time 1 \
|
||||
--max-filesize 1024 --output /dev/null "$INVENTORY_PROMETHEUS_BASE/-/ready" 2>/dev/null
|
||||
}
|
||||
|
||||
inventory_capture_cleanup() {
|
||||
local rc=$?
|
||||
trap - EXIT HUP INT TERM
|
||||
if [[ -n "${INVENTORY_PORT_FORWARD_PID:-}" ]]; then
|
||||
inventory_stop_port_forward || true
|
||||
fi
|
||||
if [[ -n "${INVENTORY_STAGE_JSON:-}" && -f "$INVENTORY_STAGE_JSON" && ! -L "$INVENTORY_STAGE_JSON" ]]; then
|
||||
rm -- "$INVENTORY_STAGE_JSON"
|
||||
fi
|
||||
if [[ -n "${INVENTORY_STAGE_SHA:-}" && -f "$INVENTORY_STAGE_SHA" && ! -L "$INVENTORY_STAGE_SHA" ]]; then
|
||||
rm -- "$INVENTORY_STAGE_SHA"
|
||||
fi
|
||||
if [[ "${INVENTORY_SUCCESS:-0}" != 1 ]]; then
|
||||
if [[ "${INVENTORY_CREATED_SHA:-0}" == 1 ]]; then
|
||||
inventory_remove_created_file "${INVENTORY_OUTPUT_DIR:-}" inventory.sha256 "${INVENTORY_SHA_ID:-}"
|
||||
fi
|
||||
if [[ "${INVENTORY_CREATED_JSON:-0}" == 1 ]]; then
|
||||
inventory_remove_created_file "${INVENTORY_OUTPUT_DIR:-}" inventory.json "${INVENTORY_JSON_ID:-}"
|
||||
fi
|
||||
if [[ -n "${INVENTORY_OUTPUT_DIR:-}" && -d "$INVENTORY_OUTPUT_DIR" && ! -L "$INVENTORY_OUTPUT_DIR" ]]; then
|
||||
sync -f "$INVENTORY_OUTPUT_DIR" >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
if [[ -n "${INVENTORY_WORK_DIR:-}" && -d "$INVENTORY_WORK_DIR" && ! -L "$INVENTORY_WORK_DIR" ]]; then
|
||||
case "$INVENTORY_WORK_DIR" in
|
||||
/tmp/platform-observability-inventory-capture.*) rm -rf -- "$INVENTORY_WORK_DIR" ;;
|
||||
esac
|
||||
fi
|
||||
exit "$rc"
|
||||
}
|
||||
|
||||
inventory_remove_created_file() {
|
||||
local directory="${1-}" basename="${2-}" expected_id="${3-}" path actual_id canonical
|
||||
[[ -n "$directory" && -n "$basename" && -n "$expected_id" && -d "$directory" && ! -L "$directory" ]] || return 0
|
||||
canonical="$(realpath -e -- "$directory" 2>/dev/null)" || return 0
|
||||
[[ "$canonical" == "$directory" ]] || return 0
|
||||
path="$directory/$basename"
|
||||
[[ -f "$path" && ! -L "$path" ]] || return 0
|
||||
actual_id="$(stat -c %d:%i -- "$path" 2>/dev/null)" || return 0
|
||||
[[ "$actual_id" == "$expected_id" ]] || return 0
|
||||
rm -- "$path"
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||
set -Eeuo pipefail
|
||||
umask 077
|
||||
inventory_capture_main "$@"
|
||||
fi
|
||||
@@ -0,0 +1,431 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
PATH=/usr/sbin:/usr/bin:/sbin:/bin
|
||||
export PATH
|
||||
LC_ALL=C
|
||||
export LC_ALL
|
||||
umask 077
|
||||
set -Eeuo pipefail
|
||||
|
||||
_k3slrf_initial_guard() {
|
||||
local candidate_euid="${1-}" shell_flags="${2-}"
|
||||
[[ "$candidate_euid" =~ ^[0-9]+$ ]] || return 1
|
||||
(( candidate_euid != 0 )) || return 1
|
||||
[[ "$shell_flags" != *x* ]]
|
||||
}
|
||||
|
||||
if ! _k3slrf_initial_guard "$EUID" "$-"; then
|
||||
printf 'Local recovery feasibility refused\n' >&2
|
||||
if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then return 1; else exit 1; fi
|
||||
fi
|
||||
|
||||
readonly K3SLRF_KEEPASSXC='/usr/bin/keepassxc-cli'
|
||||
readonly K3SLRF_CRYPTSETUP='/usr/sbin/cryptsetup'
|
||||
readonly K3SLRF_KEEPASS_PACKAGE='keepassxc'
|
||||
readonly K3SLRF_KEEPASS_VERSION='2.7.6+dfsg.1-1build3'
|
||||
readonly K3SLRF_CRYPTSETUP_PACKAGE='cryptsetup-bin'
|
||||
readonly K3SLRF_CRYPTSETUP_VERSION='2:2.7.0-1ubuntu4.2'
|
||||
readonly K3SLRF_SCRIPT_DIR="$(cd -P -- "${BASH_SOURCE[0]%/*}" && pwd -P)"
|
||||
readonly K3SLRF_REPOSITORY_ROOT="$(cd -P -- "${K3SLRF_SCRIPT_DIR}/../.." && pwd -P)"
|
||||
readonly K3SLRF_LIBRARY_PATH="${K3SLRF_REPOSITORY_ROOT}/scripts/lib/k3s-local-recovery.sh"
|
||||
# shellcheck source=../lib/k3s-local-recovery.sh
|
||||
builtin source -- "$K3SLRF_LIBRARY_PATH" || {
|
||||
printf 'Local recovery feasibility refused\n' >&2
|
||||
if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then return 1; else exit 1; fi
|
||||
}
|
||||
|
||||
_k3slrf_usage() {
|
||||
printf 'Usage: bash scripts/validate/k3s-local-recovery-feasibility.sh [--execute]\n'
|
||||
}
|
||||
|
||||
_k3slrf_fail() {
|
||||
printf 'Local recovery feasibility failed\n' >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
_k3slrf_require_interactive_stdin() {
|
||||
[[ -t 0 ]]
|
||||
}
|
||||
|
||||
_k3slrf_require_cached_sudo() {
|
||||
_k3slr_cached_sudo_is_available
|
||||
}
|
||||
|
||||
_k3slrf_package_version_is_exact() {
|
||||
local package="$1" expected="$2" output
|
||||
output="$(
|
||||
_k3slr_command /usr/bin/dpkg-query --show --showformat='${db:Status-Abbrev}|${Version}\n' -- "$package" 2>/dev/null
|
||||
printf '\034%s' "$?"
|
||||
)" || return 1
|
||||
[[ "$output" == "ii |${expected}"$'\n'$'\0340' ]]
|
||||
}
|
||||
|
||||
_k3slrf_verify_prerequisites() {
|
||||
_k3slr_trusted_root_executable /usr/bin/dpkg-query || return 1
|
||||
_k3slr_trusted_root_executable /usr/bin/mktemp || return 1
|
||||
_k3slr_trusted_root_executable /usr/bin/rm || return 1
|
||||
_k3slr_trusted_root_executable /usr/bin/cmp || return 1
|
||||
_k3slr_trusted_root_executable /usr/bin/mawk || return 1
|
||||
_k3slr_trusted_root_executable /usr/bin/od || return 1
|
||||
_k3slr_trusted_root_executable /usr/bin/ps || return 1
|
||||
_k3slr_trusted_root_executable /usr/bin/sleep || return 1
|
||||
_k3slr_trusted_root_executable /usr/bin/sha256sum || return 1
|
||||
_k3slr_trusted_root_executable "$K3SLRF_KEEPASSXC" || return 1
|
||||
_k3slr_trusted_root_executable "$K3SLRF_CRYPTSETUP" || return 1
|
||||
_k3slrf_package_version_is_exact "$K3SLRF_KEEPASS_PACKAGE" "$K3SLRF_KEEPASS_VERSION" || return 1
|
||||
_k3slrf_package_version_is_exact "$K3SLRF_CRYPTSETUP_PACKAGE" "$K3SLRF_CRYPTSETUP_VERSION"
|
||||
}
|
||||
|
||||
_k3slrf_create_fixture_dir() {
|
||||
local destination_name="$1" identity_name="$2" owned_name="$3" created physical metadata uid
|
||||
created="$(_k3slr_command /usr/bin/mktemp -d /tmp/k3slr-feasibility.XXXXXX)" || return 1
|
||||
printf -v "$destination_name" '%s' "$created"
|
||||
printf -v "$owned_name" '%s' true
|
||||
[[ "$created" =~ ^/tmp/k3slr-feasibility\.[A-Za-z0-9]+$ ]] || return 1
|
||||
_k3slr_command /usr/bin/test -d "$created" || return 1
|
||||
_k3slr_command /usr/bin/test ! -L "$created" || return 1
|
||||
physical="$(cd -P -- "$created" && pwd -P)" || return 1
|
||||
[[ "$physical" == "$created" ]] || return 1
|
||||
_k3slr_capture_one_line uid /usr/bin/id -u || return 1
|
||||
_k3slr_capture_one_line metadata /usr/bin/stat --format='%u|%a|%F|%d:%i' -- "$created" || return 1
|
||||
[[ "$metadata" == "${uid}|700|directory|"* ]] || return 1
|
||||
printf -v "$identity_name" '%s' "${metadata##*|}"
|
||||
}
|
||||
|
||||
_k3slrf_cleanup_fixture_dir() {
|
||||
local fixture_dir="$1" expected_identity="$2" metadata uid
|
||||
[[ "$fixture_dir" =~ ^/tmp/k3slr-feasibility\.[A-Za-z0-9]+$ ]] || return 1
|
||||
_k3slr_command /usr/bin/test -d "$fixture_dir" || return 1
|
||||
_k3slr_command /usr/bin/test ! -L "$fixture_dir" || return 1
|
||||
_k3slr_capture_one_line uid /usr/bin/id -u || return 1
|
||||
_k3slr_capture_one_line metadata /usr/bin/stat --format='%u|%a|%F|%d:%i' -- "$fixture_dir" 2>/dev/null || return 1
|
||||
[[ "$metadata" == "${uid}|700|directory|"* ]] || return 1
|
||||
[[ -z "$expected_identity" || "${metadata##*|}" == "$expected_identity" ]] || return 1
|
||||
_k3slr_command /usr/bin/rm --recursive --one-file-system -- "$fixture_dir"
|
||||
}
|
||||
|
||||
_k3slrf_synthetic_master_once_stdout() {
|
||||
printf '%s' 'k3slr-package-feasibility-master-v1' |
|
||||
_k3slr_command /usr/bin/sha256sum |
|
||||
_k3slr_command /usr/bin/mawk '{ print $1 }'
|
||||
}
|
||||
|
||||
_k3slrf_synthetic_master_twice_stdout() {
|
||||
_k3slrf_synthetic_master_once_stdout
|
||||
_k3slrf_synthetic_master_once_stdout
|
||||
}
|
||||
|
||||
_k3slrf_validate_generated_password_stdin() {
|
||||
_k3slr_command /usr/bin/od -An -v -tx1 |
|
||||
_k3slr_command /usr/bin/mawk '
|
||||
{
|
||||
for (i=1; i<=NF; i++) {
|
||||
n++; byte=$i
|
||||
if (n == 41) { if (byte != "0a") bad=1 }
|
||||
else if (byte ~ /^3[0-9]$/) digit=1
|
||||
else if (byte ~ /^(4[1-9a-f]|5[0-9a])$/) upper=1
|
||||
else if (byte ~ /^(6[1-9a-f]|7[0-9a])$/) lower=1
|
||||
else bad=1
|
||||
}
|
||||
}
|
||||
END { exit !(n == 41 && !bad && lower && upper && digit) }
|
||||
'
|
||||
}
|
||||
|
||||
_k3slrf_file_identity() {
|
||||
local destination_name="$1" path="$2" metadata uid
|
||||
_k3slr_command /usr/bin/test -f "$path" || return 1
|
||||
_k3slr_command /usr/bin/test ! -L "$path" || return 1
|
||||
_k3slr_capture_one_line uid /usr/bin/id -u || return 1
|
||||
_k3slr_capture_one_line metadata /usr/bin/stat --format='%u|%a|%F|%d:%i' -- "$path" || return 1
|
||||
[[ "$metadata" == "${uid}|600|regular file|"* ||
|
||||
"$metadata" == "${uid}|600|regular empty file|"* ]] || return 1
|
||||
printf -v "$destination_name" '%s' "${metadata##*|}"
|
||||
}
|
||||
|
||||
_k3slrf_file_identity_matches() {
|
||||
local current=''
|
||||
_k3slrf_file_identity current "$1" || return 1
|
||||
[[ "$current" == "$2" ]]
|
||||
}
|
||||
|
||||
_k3slrf_directory_identity() {
|
||||
local destination_name="$1" path="$2" physical metadata uid
|
||||
[[ "$path" =~ ^/tmp/k3slr-feasibility\.[A-Za-z0-9]+$ ]] || return 1
|
||||
_k3slr_command /usr/bin/test -d "$path" || return 1
|
||||
_k3slr_command /usr/bin/test ! -L "$path" || return 1
|
||||
physical="$(cd -P -- "$path" && pwd -P)" || return 1
|
||||
[[ "$physical" == "$path" ]] || return 1
|
||||
_k3slr_capture_one_line uid /usr/bin/id -u || return 1
|
||||
_k3slr_capture_one_line metadata /usr/bin/stat --format='%u|%a|%F|%d:%i' -- "$path" || return 1
|
||||
[[ "$metadata" == "${uid}|700|directory|"* ]] || return 1
|
||||
printf -v "$destination_name" '%s' "${metadata##*|}"
|
||||
}
|
||||
|
||||
_k3slrf_directory_identity_matches() {
|
||||
local current=''
|
||||
_k3slrf_directory_identity current "$1" || return 1
|
||||
[[ "$current" == "$2" ]]
|
||||
}
|
||||
|
||||
_k3slrf_run_tracked() {
|
||||
local rc caller_group='' current_identity='' stopped_identity=''
|
||||
_k3slr_process_group_for_pid caller_group "$BASHPID" || return 1
|
||||
K3SLRF_ACTIVE_CHILD_STATE='launching'
|
||||
K3SLRF_LAST_CLEANUP_OK=false
|
||||
_k3slr_lifecycle_boundary feasibility-launch-before
|
||||
_k3slrf_pending_checkpoint || return $?
|
||||
set -m
|
||||
(
|
||||
tracked_cancel=''
|
||||
trap '[[ "$tracked_cancel" == TERM ]] || tracked_cancel=INT' INT
|
||||
trap 'tracked_cancel=TERM' TERM
|
||||
kill -STOP "$BASHPID"
|
||||
tracked_worker_rc=143
|
||||
if [[ -z "$tracked_cancel" ]]; then
|
||||
if "$@"; then tracked_worker_rc=0; else tracked_worker_rc=$?; fi
|
||||
fi
|
||||
while [[ -n "$tracked_cancel" ]]; do
|
||||
_k3slr_command /usr/bin/sleep 0.01 || :
|
||||
done
|
||||
exit "$tracked_worker_rc"
|
||||
) &
|
||||
K3SLRF_ACTIVE_CHILD_PID=$!
|
||||
K3SLRF_ACTIVE_CHILD_STATE='published'
|
||||
_k3slr_lifecycle_boundary feasibility-pid-published "$K3SLRF_ACTIVE_CHILD_PID"
|
||||
if ! _k3slr_process_identity K3SLRF_ACTIVE_CHILD_IDENTITY "$K3SLRF_ACTIVE_CHILD_PID"; then
|
||||
set +m
|
||||
_k3slrf_cleanup_active_child || return 1
|
||||
if [[ -n "$K3SLRF_PENDING_SIGNAL" ]]; then return "$K3SLRF_PENDING_STATUS"; fi
|
||||
return 1
|
||||
fi
|
||||
if ! _k3slr_wait_for_stopped_group K3SLRF_ACTIVE_CHILD_PGID stopped_identity "$K3SLRF_ACTIVE_CHILD_PID" ||
|
||||
[[ "$stopped_identity" != "$K3SLRF_ACTIVE_CHILD_IDENTITY" ]]; then
|
||||
set +m
|
||||
_k3slrf_cleanup_active_child || return 1
|
||||
if [[ -n "$K3SLRF_PENDING_SIGNAL" ]]; then return "$K3SLRF_PENDING_STATUS"; fi
|
||||
return 1
|
||||
fi
|
||||
K3SLRF_ACTIVE_CHILD_STATE='stopped_pinned'
|
||||
_k3slr_lifecycle_boundary feasibility-stop-query-complete
|
||||
_k3slrf_pending_checkpoint || { rc=$?; set +m; return "$rc"; }
|
||||
if [[ "$K3SLRF_ACTIVE_CHILD_PGID" == "$caller_group" ]]; then
|
||||
set +m
|
||||
_k3slrf_cleanup_active_child || return 1
|
||||
return 1
|
||||
fi
|
||||
set +m
|
||||
_k3slr_lifecycle_boundary feasibility-cont-before
|
||||
_k3slrf_pending_checkpoint || return $?
|
||||
_k3slr_continue_owned_child "$K3SLRF_ACTIVE_CHILD_PID" "$K3SLRF_ACTIVE_CHILD_PGID" \
|
||||
"$K3SLRF_ACTIVE_CHILD_IDENTITY" || return 1
|
||||
K3SLRF_ACTIVE_CHILD_STATE='continued'
|
||||
_k3slr_lifecycle_boundary feasibility-cont-after
|
||||
_k3slrf_pending_checkpoint || return $?
|
||||
K3SLRF_ACTIVE_CHILD_STATE='waiting'
|
||||
if _k3slr_wait_child "$K3SLRF_ACTIVE_CHILD_PID"; then rc=0; else rc=$?; fi
|
||||
_k3slr_lifecycle_boundary feasibility-wait-reaped-before-clear
|
||||
if _k3slr_process_identity current_identity "$K3SLRF_ACTIVE_CHILD_PID" 2>/dev/null &&
|
||||
[[ "$current_identity" == "$K3SLRF_ACTIVE_CHILD_IDENTITY" ]]; then
|
||||
_k3slrf_cleanup_active_child || return 1
|
||||
fi
|
||||
K3SLRF_ACTIVE_CHILD_STATE='reaped' K3SLRF_ACTIVE_CHILD_PID='' \
|
||||
K3SLRF_ACTIVE_CHILD_PGID='' K3SLRF_ACTIVE_CHILD_IDENTITY=''
|
||||
K3SLRF_LAST_CLEANUP_OK=true
|
||||
_k3slr_lifecycle_boundary feasibility-state-cleared
|
||||
if [[ -n "$K3SLRF_PENDING_SIGNAL" ]]; then
|
||||
rc="$K3SLRF_PENDING_STATUS"
|
||||
fi
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
_k3slrf_db_create() {
|
||||
_k3slrf_synthetic_master_twice_stdout |
|
||||
_k3slr_command "$K3SLRF_KEEPASSXC" db-create --quiet --set-password "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
_k3slrf_entry_add() {
|
||||
_k3slrf_synthetic_master_once_stdout |
|
||||
_k3slr_command "$K3SLRF_KEEPASSXC" add --quiet --generate --length 40 \
|
||||
--lower --upper --numeric --every-group "$1" "$K3SLR_KEEPASS_ENTRY" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
_k3slrf_password_check() {
|
||||
_k3slrf_synthetic_master_once_stdout |
|
||||
_k3slr_command "$K3SLRF_KEEPASSXC" show --show-protected \
|
||||
--attributes Password "$1" "$K3SLR_KEEPASS_ENTRY" 2>/dev/null |
|
||||
_k3slrf_validate_generated_password_stdin
|
||||
}
|
||||
|
||||
_k3slrf_attachment_listing_stdout() {
|
||||
(( $# == 1 )) || return 1
|
||||
_k3slrf_synthetic_master_once_stdout |
|
||||
_k3slr_command "$K3SLRF_KEEPASSXC" show --quiet --attributes Title \
|
||||
--show-attachments "$1" "$K3SLR_KEEPASS_ENTRY"
|
||||
}
|
||||
|
||||
_k3slrf_attachment_state_is() {
|
||||
local database="${1-}" fixture_dir="${2-}" expected_state="${3-}"
|
||||
local capture_base='' record='' state=''
|
||||
(( $# == 3 )) || return 1
|
||||
[[ "$expected_state" == absent || "$expected_state" == present ]] || return 1
|
||||
capture_base="${fixture_dir}/keepass-listing.${BASHPID}"
|
||||
_k3slr_capture_keepass_attachment_listing record \
|
||||
"${capture_base}.stdout" "${capture_base}.stderr" \
|
||||
_k3slrf_attachment_listing_stdout "$database" || return 1
|
||||
_k3slr_parse_keepass_attachment_listing "$record" fixture.bin state || return 1
|
||||
[[ "$state" == "$expected_state" ]]
|
||||
}
|
||||
|
||||
_k3slrf_attachment_import() {
|
||||
_k3slrf_synthetic_master_once_stdout |
|
||||
_k3slr_command "$K3SLRF_KEEPASSXC" attachment-import --quiet "$1" "$K3SLR_KEEPASS_ENTRY" \
|
||||
fixture.bin "$2" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
_k3slrf_attachment_export() {
|
||||
_k3slrf_synthetic_master_once_stdout |
|
||||
_k3slr_command "$K3SLRF_KEEPASSXC" attachment-export --quiet "$1" "$K3SLR_KEEPASS_ENTRY" \
|
||||
fixture.bin "$2" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
_k3slrf_run_synthetic() {
|
||||
local fixture_dir="$1" database input_file output_file database_identity input_identity output_identity fixture_identity export_fd
|
||||
database="${fixture_dir}/synthetic.kdbx"
|
||||
input_file="${fixture_dir}/input.bin"
|
||||
output_file="${fixture_dir}/output.bin"
|
||||
[[ ! -e "$database" && ! -L "$database" && ! -e "$input_file" && ! -L "$input_file" &&
|
||||
! -e "$output_file" && ! -L "$output_file" ]] || return 1
|
||||
_k3slrf_directory_identity fixture_identity "$fixture_dir" || return 1
|
||||
( set -o noclobber; printf '%s\n' 'k3slr-attachment-roundtrip-v1' >"$input_file" ) || return 1
|
||||
_k3slrf_file_identity input_identity "$input_file" || return 1
|
||||
_k3slrf_run_tracked _k3slrf_db_create "$database" || return 1
|
||||
_k3slrf_directory_identity_matches "$fixture_dir" "$fixture_identity" || return 1
|
||||
_k3slrf_file_identity database_identity "$database" || return 1
|
||||
_k3slrf_run_tracked _k3slrf_entry_add "$database" || return 1
|
||||
_k3slrf_directory_identity_matches "$fixture_dir" "$fixture_identity" || return 1
|
||||
_k3slrf_file_identity database_identity "$database" || return 1
|
||||
_k3slrf_run_tracked _k3slrf_password_check "$database" || return 1
|
||||
_k3slrf_directory_identity_matches "$fixture_dir" "$fixture_identity" || return 1
|
||||
_k3slrf_file_identity_matches "$database" "$database_identity" || return 1
|
||||
_k3slrf_file_identity_matches "$input_file" "$input_identity" || return 1
|
||||
_k3slrf_run_tracked _k3slrf_attachment_state_is "$database" "$fixture_dir" absent || return 1
|
||||
_k3slrf_directory_identity_matches "$fixture_dir" "$fixture_identity" || return 1
|
||||
_k3slrf_file_identity_matches "$database" "$database_identity" || return 1
|
||||
_k3slrf_file_identity_matches "$input_file" "$input_identity" || return 1
|
||||
_k3slrf_run_tracked _k3slrf_attachment_import "$database" "$input_file" || return 1
|
||||
_k3slrf_directory_identity_matches "$fixture_dir" "$fixture_identity" || return 1
|
||||
_k3slrf_file_identity database_identity "$database" || return 1
|
||||
_k3slrf_run_tracked _k3slrf_attachment_state_is "$database" "$fixture_dir" present || return 1
|
||||
_k3slrf_directory_identity_matches "$fixture_dir" "$fixture_identity" || return 1
|
||||
_k3slrf_file_identity_matches "$database" "$database_identity" || return 1
|
||||
set -o noclobber
|
||||
if ! exec {export_fd}>"$output_file"; then
|
||||
set +o noclobber
|
||||
return 1
|
||||
fi
|
||||
set +o noclobber
|
||||
_k3slrf_file_identity output_identity "$output_file" || return 1
|
||||
_k3slrf_run_tracked _k3slrf_attachment_export "$database" "/proc/${BASHPID}/fd/${export_fd}" || return 1
|
||||
exec {export_fd}>&-
|
||||
_k3slrf_directory_identity_matches "$fixture_dir" "$fixture_identity" || return 1
|
||||
_k3slrf_file_identity_matches "$database" "$database_identity" || return 1
|
||||
_k3slrf_file_identity_matches "$output_file" "$output_identity" || return 1
|
||||
_k3slr_command /usr/bin/cmp --silent -- "$input_file" "$output_file"
|
||||
}
|
||||
|
||||
k3slr_local_recovery_feasibility_main() (
|
||||
local execute=false fixture_dir='' fixture_identity='' fixture_owned=false
|
||||
local K3SLRF_ACTIVE_CHILD_PID='' K3SLRF_ACTIVE_CHILD_PGID='' K3SLRF_ACTIVE_CHILD_IDENTITY=''
|
||||
local K3SLRF_ACTIVE_CHILD_STATE='empty' K3SLRF_PENDING_SIGNAL='' K3SLRF_PENDING_STATUS=''
|
||||
local K3SLRF_LAST_CLEANUP_OK=false
|
||||
case "$#" in
|
||||
0) ;;
|
||||
1) [[ "$1" == --execute ]] || { _k3slrf_usage >&2; return 2; }; execute=true ;;
|
||||
*) _k3slrf_usage >&2; return 2 ;;
|
||||
esac
|
||||
|
||||
if "$execute"; then
|
||||
_k3slrf_require_interactive_stdin || { _k3slrf_fail; return 1; }
|
||||
fi
|
||||
_k3slrf_verify_prerequisites || { _k3slrf_fail; return 1; }
|
||||
if ! "$execute"; then
|
||||
printf 'Local recovery feasibility prerequisites: pass\n'
|
||||
return 0
|
||||
fi
|
||||
_k3slrf_require_cached_sudo || { _k3slrf_fail; return 1; }
|
||||
|
||||
_k3slrf_cleanup_active_child() {
|
||||
local cleanup_rc=0
|
||||
if [[ -n "$K3SLRF_ACTIVE_CHILD_PID" ]]; then
|
||||
if [[ "$K3SLRF_ACTIVE_CHILD_STATE" == published ]]; then
|
||||
_k3slr_terminate_published_child "$K3SLRF_ACTIVE_CHILD_PID" || cleanup_rc=$?
|
||||
elif [[ -n "$K3SLRF_ACTIVE_CHILD_IDENTITY" ]]; then
|
||||
_k3slr_terminate_and_reap "$K3SLRF_ACTIVE_CHILD_PID" "$K3SLRF_ACTIVE_CHILD_PGID" \
|
||||
"$K3SLRF_ACTIVE_CHILD_IDENTITY" || cleanup_rc=$?
|
||||
else
|
||||
cleanup_rc=1
|
||||
fi
|
||||
if (( cleanup_rc == 0 )); then
|
||||
K3SLRF_ACTIVE_CHILD_PID=''
|
||||
K3SLRF_ACTIVE_CHILD_PGID=''
|
||||
K3SLRF_ACTIVE_CHILD_IDENTITY=''
|
||||
K3SLRF_ACTIVE_CHILD_STATE='reaped'
|
||||
K3SLRF_LAST_CLEANUP_OK=true
|
||||
fi
|
||||
fi
|
||||
return "$cleanup_rc"
|
||||
}
|
||||
_k3slrf_signal() {
|
||||
if [[ "$1" == TERM || "$K3SLRF_PENDING_SIGNAL" != TERM ]]; then
|
||||
K3SLRF_PENDING_SIGNAL="$1"
|
||||
K3SLRF_PENDING_STATUS="$2"
|
||||
fi
|
||||
}
|
||||
_k3slrf_pending_checkpoint() {
|
||||
local signal_status cleanup_rc=0
|
||||
[[ -n "$K3SLRF_PENDING_SIGNAL" ]] || return 0
|
||||
signal_status="$K3SLRF_PENDING_STATUS"
|
||||
if [[ -n "$K3SLRF_ACTIVE_CHILD_PID" ]]; then
|
||||
[[ -n "$K3SLRF_ACTIVE_CHILD_IDENTITY" ]] || return 1
|
||||
_k3slrf_cleanup_active_child || cleanup_rc=$?
|
||||
else
|
||||
K3SLRF_LAST_CLEANUP_OK=true
|
||||
fi
|
||||
(( cleanup_rc == 0 )) || return 1
|
||||
return "$signal_status"
|
||||
}
|
||||
_k3slrf_exit_cleanup() {
|
||||
local original_status="$1" cleanup_rc=0
|
||||
trap - EXIT
|
||||
_k3slrf_cleanup_active_child || cleanup_rc=$?
|
||||
if "$fixture_owned"; then
|
||||
_k3slrf_cleanup_fixture_dir "$fixture_dir" "$fixture_identity" >/dev/null 2>&1 || cleanup_rc=1
|
||||
fi
|
||||
(( cleanup_rc == 0 )) || exit 1
|
||||
exit "$original_status"
|
||||
}
|
||||
trap '_k3slrf_exit_cleanup "$?"' EXIT
|
||||
trap '_k3slrf_signal INT 130' INT
|
||||
trap '_k3slrf_signal TERM 143' TERM
|
||||
_k3slrf_create_fixture_dir fixture_dir fixture_identity fixture_owned || { _k3slrf_fail; return 1; }
|
||||
if ! _k3slrf_run_synthetic "$fixture_dir"; then
|
||||
if [[ -n "$K3SLRF_PENDING_STATUS" && -z "$K3SLRF_ACTIVE_CHILD_PID" &&
|
||||
"$K3SLRF_LAST_CLEANUP_OK" == true ]]; then
|
||||
_k3slrf_cleanup_fixture_dir "$fixture_dir" "$fixture_identity" || return 1
|
||||
fixture_owned=false
|
||||
trap - EXIT INT TERM
|
||||
return "$K3SLRF_PENDING_STATUS"
|
||||
fi
|
||||
_k3slrf_fail
|
||||
return 1
|
||||
fi
|
||||
_k3slrf_cleanup_fixture_dir "$fixture_dir" "$fixture_identity" || { _k3slrf_fail; return 1; }
|
||||
fixture_owned=false
|
||||
trap - EXIT INT TERM
|
||||
printf 'Local recovery feasibility: pass\n'
|
||||
)
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||
k3slr_local_recovery_feasibility_main "$@"
|
||||
fi
|
||||
@@ -0,0 +1,718 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
PATH=/usr/sbin:/usr/bin:/sbin:/bin
|
||||
export PATH
|
||||
LC_ALL=C
|
||||
export LC_ALL
|
||||
umask 077
|
||||
set -Eeuo pipefail
|
||||
shopt -s extglob
|
||||
|
||||
_k3slrv_initial_guard() {
|
||||
local candidate_euid="${1-}" shell_flags="${2-}"
|
||||
[[ "$candidate_euid" =~ ^[0-9]+$ ]] || return 1
|
||||
(( candidate_euid != 0 )) || return 1
|
||||
[[ "$shell_flags" != *x* ]]
|
||||
}
|
||||
|
||||
if ! _k3slrv_initial_guard "$EUID" "$-"; then
|
||||
printf 'Recovery validation refused\n' >&2
|
||||
if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then return 1; else exit 1; fi
|
||||
fi
|
||||
|
||||
readonly K3SLRV_SECURE_PATH='/usr/sbin:/usr/bin:/sbin:/bin'
|
||||
readonly K3SLRV_TRUSTED_BINARIES=(
|
||||
/usr/bin/findmnt /usr/bin/lsblk /usr/bin/stat /usr/bin/readlink
|
||||
/usr/sbin/blkid /usr/bin/ntfs-3g.probe /usr/sbin/smartctl
|
||||
/usr/sbin/losetup /usr/sbin/cryptsetup /usr/bin/du
|
||||
/usr/bin/sha256sum /usr/bin/test /usr/bin/sudo /usr/bin/mawk
|
||||
)
|
||||
readonly K3SLRV_SCRIPT_DIR="$(cd -P -- "${BASH_SOURCE[0]%/*}" && pwd -P)"
|
||||
readonly K3SLRV_REPOSITORY_ROOT="$(cd -P -- "${K3SLRV_SCRIPT_DIR}/../.." && pwd -P)"
|
||||
readonly K3SLRV_LIBRARY_PATH="${K3SLRV_REPOSITORY_ROOT}/scripts/lib/k3s-local-recovery.sh"
|
||||
readonly K3SLRV_CONTRACT_PATH="${K3SLRV_REPOSITORY_ROOT}/infrastructure/security/k3s/local-recovery.env"
|
||||
|
||||
_k3slrv_usage() {
|
||||
printf 'Usage: bash scripts/validate/k3s-local-recovery.sh [--expect-device-ready|--expect-closed|--expect-open] [--check-latest-bundle]\n'
|
||||
}
|
||||
|
||||
_k3slrv_fail() {
|
||||
printf 'Recovery validation failed\n' >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
_k3slrv_exec() {
|
||||
local scope="$1"
|
||||
shift
|
||||
case "$scope" in
|
||||
user) "$@" ;;
|
||||
root) /usr/bin/sudo --non-interactive -- "$@" ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Bash command substitution normally discards every trailing newline. Append a
|
||||
# non-newline sentinel inside the substitution, then remove only that sentinel
|
||||
# so record parsers can distinguish one record from a record plus blank lines.
|
||||
_k3slrv_capture() {
|
||||
local destination="$1" captured command_rc sentinel=$'\036'
|
||||
shift
|
||||
if captured="$(
|
||||
set +e
|
||||
"$@"
|
||||
command_rc=$?
|
||||
printf '\036'
|
||||
exit "$command_rc"
|
||||
)"; then
|
||||
command_rc=0
|
||||
else
|
||||
command_rc=$?
|
||||
fi
|
||||
[[ "$captured" == *"$sentinel" ]] || return 125
|
||||
captured="${captured%"$sentinel"}"
|
||||
[[ "$captured" != *"$sentinel"* ]] || return 125
|
||||
printf -v "$destination" '%s' "$captured"
|
||||
return "$command_rc"
|
||||
}
|
||||
|
||||
_k3slrv_normalize_one_record() {
|
||||
local destination="$1" value="$2"
|
||||
[[ -n "$value" && "$value" != *$'\r'* ]] || return 1
|
||||
value="${value%$'\n'}"
|
||||
[[ -n "$value" && "$value" != *$'\n'* ]] || return 1
|
||||
printf -v "$destination" '%s' "$value"
|
||||
}
|
||||
|
||||
_k3slrv_verify_binary() {
|
||||
local binary="$1" metadata uid mode kind group_digit other_digit
|
||||
[[ "$binary" == /* ]] || return 1
|
||||
_k3slrv_exec user /usr/bin/test -f "$binary" >/dev/null 2>&1 || return 1
|
||||
_k3slrv_exec user /usr/bin/test ! -L "$binary" >/dev/null 2>&1 || return 1
|
||||
_k3slrv_capture metadata _k3slrv_exec user /usr/bin/stat --format='%u|%a|%F' -- "$binary" 2>/dev/null || return 1
|
||||
_k3slrv_normalize_one_record metadata "$metadata" || return 1
|
||||
IFS='|' read -r uid mode kind <<<"$metadata"
|
||||
[[ "$uid" == 0 && "$mode" =~ ^[0-7]{3,4}$ && "$kind" == 'regular file' ]] || return 1
|
||||
group_digit="${mode: -2:1}"
|
||||
other_digit="${mode: -1}"
|
||||
(( (10#$group_digit & 2) == 0 && (10#$other_digit & 2) == 0 ))
|
||||
}
|
||||
|
||||
_k3slrv_verify_trusted_binaries() {
|
||||
local binary
|
||||
[[ "$PATH" == "$K3SLRV_SECURE_PATH" ]] || return 1
|
||||
for binary in "${K3SLRV_TRUSTED_BINARIES[@]}"; do
|
||||
_k3slrv_verify_binary "$binary" || return 1
|
||||
done
|
||||
}
|
||||
|
||||
_k3slrv_require_cached_sudo() {
|
||||
_k3slrv_exec user /usr/bin/sudo --non-interactive --validate >/dev/null 2>&1
|
||||
}
|
||||
|
||||
_k3slrv_root() {
|
||||
_k3slrv_exec root "$@"
|
||||
}
|
||||
|
||||
_k3slrv_trim() {
|
||||
local value="$1"
|
||||
value="${value##+([[:space:]])}"
|
||||
value="${value%%+([[:space:]])}"
|
||||
printf '%s\n' "$value"
|
||||
}
|
||||
|
||||
_k3slrv_one_line() {
|
||||
local output
|
||||
_k3slrv_capture output _k3slrv_exec user "$@" 2>/dev/null || return 1
|
||||
_k3slrv_normalize_one_record output "$output" || return 1
|
||||
_k3slrv_trim "$output"
|
||||
}
|
||||
|
||||
_k3slrv_one_line_root() {
|
||||
local output
|
||||
_k3slrv_capture output _k3slrv_root "$@" 2>/dev/null || return 1
|
||||
_k3slrv_normalize_one_record output "$output" || return 1
|
||||
_k3slrv_trim "$output"
|
||||
}
|
||||
|
||||
_k3slrv_blkid() {
|
||||
_k3slrv_one_line_root /usr/sbin/blkid --output value --match-tag "$1" -- "$2"
|
||||
}
|
||||
|
||||
_k3slrv_lsblk() {
|
||||
_k3slrv_one_line /usr/bin/lsblk --noheadings --nodeps --paths --output "$1" -- "$2"
|
||||
}
|
||||
|
||||
_k3slrv_smart_evidence() {
|
||||
local disk="$1" output line id raw
|
||||
local -a fields=() lines=()
|
||||
local health='' reallocated='' pending='' uncorrectable=''
|
||||
_k3slrv_capture output _k3slrv_root /usr/sbin/smartctl -H -A -- "$disk" 2>/dev/null || return 1
|
||||
mapfile -t lines < <(printf '%s' "$output")
|
||||
(( ${#lines[@]} > 0 )) || return 1
|
||||
for line in "${lines[@]}"; do
|
||||
[[ "$line" != *$'\r'* ]] || return 1
|
||||
if [[ "$line" =~ ^SMART[[:space:]]overall-health[[:space:]]self-assessment[[:space:]]test[[:space:]]result:[[:space:]]*PASSED[[:space:]]*$ ]]; then
|
||||
[[ -z "$health" ]] || return 1
|
||||
health=PASSED
|
||||
continue
|
||||
fi
|
||||
[[ "$line" =~ ^[[:space:]]*(5|197|198)[[:space:]] ]] || continue
|
||||
id="${BASH_REMATCH[1]}"
|
||||
read -r -a fields <<<"$line"
|
||||
(( ${#fields[@]} >= 2 )) || return 1
|
||||
raw="${fields[${#fields[@]} - 1]}"
|
||||
[[ "$raw" =~ ^[0-9]+$ ]] || return 1
|
||||
case "$id" in
|
||||
5) [[ -z "$reallocated" ]] || return 1; reallocated="$raw" ;;
|
||||
197) [[ -z "$pending" ]] || return 1; pending="$raw" ;;
|
||||
198) [[ -z "$uncorrectable" ]] || return 1; uncorrectable="$raw" ;;
|
||||
esac
|
||||
done
|
||||
[[ -n "$health" && -n "$reallocated" && -n "$pending" && -n "$uncorrectable" ]] || return 1
|
||||
printf 'smart_health=%s\nsmart_reallocated=%s\nsmart_pending=%s\nsmart_uncorrectable=%s\n' \
|
||||
"$health" "$reallocated" "$pending" "$uncorrectable"
|
||||
}
|
||||
|
||||
_k3slrv_findmnt_line() {
|
||||
local output
|
||||
_k3slrv_capture output _k3slrv_root /usr/bin/findmnt --noheadings --raw --output SOURCE,FSTYPE,OPTIONS,ID,MAJ:MIN --mountpoint "$1" 2>/dev/null || return 1
|
||||
_k3slrv_normalize_one_record output "$output" || return 1
|
||||
output="$(_k3slrv_trim "$output")"
|
||||
[[ -n "$output" ]] || return 1
|
||||
printf '%s\n' "$output"
|
||||
}
|
||||
|
||||
_k3slrv_findmnt_optional() {
|
||||
local output rc=0
|
||||
_k3slrv_capture output _k3slrv_root /usr/bin/findmnt --noheadings --raw --output SOURCE,FSTYPE,OPTIONS,ID,MAJ:MIN --mountpoint "$1" 2>/dev/null || rc=$?
|
||||
case "$rc" in
|
||||
0)
|
||||
_k3slrv_normalize_one_record output "$output" || return 1
|
||||
output="$(_k3slrv_trim "$output")"
|
||||
[[ -n "$output" ]] || return 1
|
||||
printf '%s\n' "$output"
|
||||
;;
|
||||
1) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
_k3slrv_findmnt_source_optional() {
|
||||
local output rc=0
|
||||
_k3slrv_capture output _k3slrv_root /usr/bin/findmnt --noheadings --raw --output TARGET --source "$1" 2>/dev/null || rc=$?
|
||||
case "$rc" in
|
||||
0)
|
||||
_k3slrv_normalize_one_record output "$output" || return 1
|
||||
output="$(_k3slrv_trim "$output")"
|
||||
[[ -n "$output" ]] || return 1
|
||||
printf '%s\n' "$output"
|
||||
;;
|
||||
1) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
_k3slrv_collect_device_evidence() {
|
||||
local mode="$1" recovery_partition recovery_disk recovery_parent k3s_partition k3s_disk k3s_parent
|
||||
local recovery_model recovery_serial recovery_wwn recovery_mm k3s_model k3s_serial k3s_wwn k3s_mm
|
||||
local recovery_uuid recovery_partuuid recovery_type k3s_uuid k3s_partuuid k3s_type outer_mount source_mounts
|
||||
recovery_partition="$(_k3slrv_one_line /usr/bin/readlink -f -- "$K3SLR_RECOVERY_PARTITION_BY_ID")" || return 1
|
||||
recovery_disk="$(_k3slrv_one_line /usr/bin/readlink -f -- "$K3SLR_RECOVERY_DISK_BY_ID")" || return 1
|
||||
recovery_parent="$(_k3slrv_lsblk PKNAME "$recovery_partition")" || return 1
|
||||
k3s_partition="$(_k3slrv_one_line /usr/bin/readlink -f -- "$K3SLR_K3S_PARTITION_BY_ID")" || return 1
|
||||
k3s_disk="$(_k3slrv_one_line /usr/bin/readlink -f -- "$K3SLR_K3S_DISK_BY_ID")" || return 1
|
||||
k3s_parent="$(_k3slrv_lsblk PKNAME "$k3s_partition")" || return 1
|
||||
recovery_model="$(_k3slrv_lsblk MODEL "$recovery_disk")" || return 1
|
||||
recovery_serial="$(_k3slrv_lsblk SERIAL "$recovery_disk")" || return 1
|
||||
recovery_wwn="$(_k3slrv_lsblk WWN "$recovery_disk")" || return 1
|
||||
recovery_mm="$(_k3slrv_lsblk MAJ:MIN "$recovery_disk")" || return 1
|
||||
k3s_model="$(_k3slrv_lsblk MODEL "$k3s_disk")" || return 1
|
||||
k3s_serial="$(_k3slrv_lsblk SERIAL "$k3s_disk")" || return 1
|
||||
k3s_wwn="$(_k3slrv_lsblk WWN "$k3s_disk")" || return 1
|
||||
k3s_mm="$(_k3slrv_lsblk MAJ:MIN "$k3s_disk")" || return 1
|
||||
recovery_uuid="$(_k3slrv_blkid UUID "$recovery_partition")" || return 1
|
||||
recovery_partuuid="$(_k3slrv_blkid PARTUUID "$recovery_partition")" || return 1
|
||||
recovery_type="$(_k3slrv_blkid TYPE "$recovery_partition")" || return 1
|
||||
k3s_uuid="$(_k3slrv_blkid UUID "$k3s_partition")" || return 1
|
||||
k3s_partuuid="$(_k3slrv_blkid PARTUUID "$k3s_partition")" || return 1
|
||||
k3s_type="$(_k3slrv_blkid TYPE "$k3s_partition")" || return 1
|
||||
printf 'recovery_partition=%s\nrecovery_disk=%s\nrecovery_partition_parent=%s\n' \
|
||||
"$recovery_partition" "$recovery_disk" "$recovery_parent"
|
||||
printf 'recovery_fs_uuid=%s\nrecovery_partuuid=%s\nrecovery_type=%s\n' \
|
||||
"$recovery_uuid" "$recovery_partuuid" "$recovery_type"
|
||||
printf 'recovery_model=%s\nrecovery_serial=%s\nrecovery_wwn=%s\nrecovery_major_minor=%s\n' \
|
||||
"$recovery_model" "$recovery_serial" "$recovery_wwn" "$recovery_mm"
|
||||
printf 'k3s_partition=%s\nk3s_disk=%s\nk3s_partition_parent=%s\n' \
|
||||
"$k3s_partition" "$k3s_disk" "$k3s_parent"
|
||||
printf 'k3s_fs_uuid=%s\nk3s_partuuid=%s\nk3s_type=%s\n' \
|
||||
"$k3s_uuid" "$k3s_partuuid" "$k3s_type"
|
||||
printf 'k3s_model=%s\nk3s_serial=%s\nk3s_wwn=%s\nk3s_major_minor=%s\n' \
|
||||
"$k3s_model" "$k3s_serial" "$k3s_wwn" "$k3s_mm"
|
||||
_k3slrv_smart_evidence "$recovery_disk" || return 1
|
||||
if [[ "$mode" == open ]]; then
|
||||
printf 'ntfs_probe=not_applicable\n'
|
||||
else
|
||||
outer_mount="$(_k3slrv_findmnt_optional "$K3SLR_OUTER_MOUNT")" || return 1
|
||||
[[ -z "$outer_mount" ]] || return 1
|
||||
source_mounts="$(_k3slrv_findmnt_source_optional "$recovery_partition")" || return 1
|
||||
[[ -z "$source_mounts" ]] || return 1
|
||||
if _k3slrv_root /usr/bin/ntfs-3g.probe --readwrite "$recovery_partition" >/dev/null 2>&1; then
|
||||
printf 'ntfs_probe=pass\n'
|
||||
else
|
||||
printf 'ntfs_probe=fail\n'
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
_k3slrv_mapping_evidence() {
|
||||
local name="$1" prefix="$2" output type='' device='' rc=0
|
||||
local line
|
||||
local -a lines=()
|
||||
_k3slrv_capture output _k3slrv_root /usr/sbin/cryptsetup status -- "$name" 2>/dev/null || rc=$?
|
||||
if (( rc == 4 )); then
|
||||
printf '%s_present=0\n' "$prefix"
|
||||
return 0
|
||||
fi
|
||||
(( rc == 0 )) || return 1
|
||||
mapfile -t lines < <(printf '%s' "$output")
|
||||
(( ${#lines[@]} > 0 )) || return 1
|
||||
for line in "${lines[@]}"; do
|
||||
[[ "$line" != *$'\r'* ]] || return 1
|
||||
line="$(_k3slrv_trim "$line")"
|
||||
case "$line" in
|
||||
type:*) [[ -z "$type" ]] || return 1; type="$(_k3slrv_trim "${line#type:}")" ;;
|
||||
device:*) [[ -z "$device" ]] || return 1; device="$(_k3slrv_trim "${line#device:}")" ;;
|
||||
esac
|
||||
done
|
||||
[[ -n "$type" && -n "$device" ]] || return 1
|
||||
printf '%s_present=1\n%s_type=%s\n%s_device=%s\n' "$prefix" "$prefix" "$type" "$prefix" "$device"
|
||||
}
|
||||
|
||||
_k3slrv_pin_container_chain() {
|
||||
local container="$1" current="$K3SLR_OUTER_MOUNT" canonical metadata component index=0 snapshot=''
|
||||
local -a components=()
|
||||
IFS='/' read -r -a components <<<"$K3SLR_CONTAINER_RELATIVE"
|
||||
for component in '' "${components[@]}"; do
|
||||
if (( index > 0 )); then current="${current}/${component}"; fi
|
||||
_k3slrv_root /usr/bin/test ! -L "$current" >/dev/null 2>&1 || return 1
|
||||
canonical="$(_k3slrv_one_line_root /usr/bin/readlink -e -- "$current")" || return 1
|
||||
[[ "$canonical" == "$current" ]] || return 1
|
||||
metadata="$(_k3slrv_one_line_root /usr/bin/stat --format='%d:%i|%F' -- "$current")" || return 1
|
||||
if (( index < ${#components[@]} )); then
|
||||
[[ "$metadata" == *'|directory' ]] || return 1
|
||||
else
|
||||
[[ "$current" == "$container" && "$metadata" == *'|regular file' ]] || return 1
|
||||
fi
|
||||
snapshot+="${metadata%%|*},"
|
||||
index=$((index + 1))
|
||||
done
|
||||
printf '%s\n' "${snapshot%,}"
|
||||
}
|
||||
|
||||
_k3slrv_mapping_value() {
|
||||
local evidence="$1" key="$2" line found=''
|
||||
while IFS= read -r line; do
|
||||
[[ "$line" == "$key="* ]] || continue
|
||||
[[ -z "$found" ]] || return 1
|
||||
found="${line#*=}"
|
||||
done <<<"$evidence"
|
||||
[[ -n "$found" ]] || return 1
|
||||
printf '%s\n' "$found"
|
||||
}
|
||||
|
||||
_k3slrv_collect_state_evidence() {
|
||||
local mode="$1" outer='' inner='' loops='' outer_source outer_fstype outer_options outer_mount_id outer_major_minor
|
||||
local inner_source inner_fstype inner_options inner_mount_id inner_major_minor container metadata kind uid gid file_mode size blocks allocated inode container_device
|
||||
local inner_type inner_label recovery_partition source_mounts chain_snapshot loop_device loop_back_inode loop_back_major_minor
|
||||
local loop_major_minor loop_offset loop_sizelimit mapping_snapshot proof_snapshot mapping_device mapping_loop_major_minor mapping_major_minor
|
||||
local outer_after inner_after source_after chain_after loops_after mapping_after proof_after mapping_mm_after
|
||||
local container_metadata_after inner_type_after inner_label_after inner_root_metadata inner_root_after
|
||||
local mapping_device_after mapping_loop_mm_after
|
||||
local -a loop_records=()
|
||||
outer="$(_k3slrv_findmnt_optional "$K3SLR_OUTER_MOUNT")" || return 1
|
||||
inner="$(_k3slrv_findmnt_optional "$K3SLR_INNER_MOUNT")" || return 1
|
||||
container="${K3SLR_OUTER_MOUNT}/${K3SLR_CONTAINER_RELATIVE}"
|
||||
if [[ "$mode" != open ]]; then
|
||||
_k3slrv_capture loops _k3slrv_root /usr/sbin/losetup --associated "$container" --noheadings --output NAME 2>/dev/null || return 1
|
||||
printf 'outer_mounted=%s\ninner_mounted=%s\n' "$([[ -n "$outer" ]] && printf 1 || printf 0)" "$([[ -n "$inner" ]] && printf 1 || printf 0)"
|
||||
if [[ -z "$loops" ]]; then
|
||||
printf 'loop_count=0\n'
|
||||
else
|
||||
mapfile -t loop_records < <(printf '%s' "$loops")
|
||||
if (( ${#loop_records[@]} == 1 )) && [[ -n "${loop_records[0]}" && "${loop_records[0]}" != *$'\r'* ]]; then
|
||||
printf 'loop_count=1\n'
|
||||
else
|
||||
printf 'loop_count=2\n'
|
||||
fi
|
||||
fi
|
||||
_k3slrv_mapping_evidence "$K3SLR_MAPPING_NAME" mapping || return 1
|
||||
_k3slrv_mapping_evidence "$K3SLR_PROOF_MAPPING_NAME" proof_mapping || return 1
|
||||
return 0
|
||||
fi
|
||||
_k3slrv_capture loops _k3slrv_root /usr/sbin/losetup --list --associated "$container" --noheadings --raw \
|
||||
--output NAME,BACK-INO,BACK-MAJ:MIN,MAJ:MIN,OFFSET,SIZELIMIT 2>/dev/null || return 1
|
||||
_k3slrv_normalize_one_record loops "$loops" || return 1
|
||||
[[ -n "$outer" && -n "$inner" ]] || return 1
|
||||
read -r outer_source outer_fstype outer_options outer_mount_id outer_major_minor <<<"$outer"
|
||||
read -r inner_source inner_fstype inner_options inner_mount_id inner_major_minor <<<"$inner"
|
||||
read -r loop_device loop_back_inode loop_back_major_minor loop_major_minor loop_offset loop_sizelimit <<<"$loops"
|
||||
[[ -n "$outer_mount_id" && -n "$outer_major_minor" && -n "$inner_mount_id" && -n "$inner_major_minor" &&
|
||||
-n "$loop_device" && -n "$loop_back_inode" && -n "$loop_back_major_minor" && -n "$loop_major_minor" &&
|
||||
-n "$loop_offset" && -n "$loop_sizelimit" ]] || return 1
|
||||
outer_source="$(_k3slrv_one_line /usr/bin/readlink -f -- "$outer_source")" || return 1
|
||||
recovery_partition="$(_k3slrv_one_line /usr/bin/readlink -f -- "$K3SLR_RECOVERY_PARTITION_BY_ID")" || return 1
|
||||
source_mounts="$(_k3slrv_findmnt_source_optional "$recovery_partition")" || return 1
|
||||
[[ "$source_mounts" == "$K3SLR_OUTER_MOUNT" ]] || return 1
|
||||
chain_snapshot="$(_k3slrv_pin_container_chain "$container")" || return 1
|
||||
metadata="$(_k3slrv_one_line_root /usr/bin/stat --format='%F|%u|%g|%a|%s|%b|%i|%Hd:%Ld' -- "$container")" || return 1
|
||||
IFS='|' read -r kind uid gid file_mode size blocks inode container_device <<<"$metadata"
|
||||
[[ "$container_device" =~ ^[0-9]+:[0-9]+$ ]] || return 1
|
||||
[[ "$blocks" =~ ^[0-9]+$ && "$blocks" -le 18014398509481983 ]] || return 1
|
||||
allocated=$((blocks * 512))
|
||||
printf 'outer_mounted=1\nouter_source=%s\nouter_fstype=%s\nouter_options=%s\n' "$outer_source" "$outer_fstype" "$outer_options"
|
||||
printf 'inner_mounted=1\ninner_source=%s\ninner_fstype=%s\ninner_options=%s\n' "$inner_source" "$inner_fstype" "$inner_options"
|
||||
printf 'container_kind=%s\ncontainer_symlink=%s\ncontainer_uid=%s\ncontainer_gid=%s\ncontainer_mode=%s\n' \
|
||||
"$([[ "$kind" == 'regular file' ]] && printf regular || printf other)" \
|
||||
"$(_k3slrv_root /usr/bin/test -L "$container" >/dev/null 2>&1 && printf 1 || printf 0)" "$uid" "$gid" "$file_mode"
|
||||
printf 'container_size=%s\ncontainer_allocated=%s\nloop_count=1\nloop_device=%s\n' "$size" "$allocated" "$loop_device"
|
||||
_k3slrv_capture mapping_snapshot _k3slrv_mapping_evidence "$K3SLR_MAPPING_NAME" mapping || return 1
|
||||
_k3slrv_capture proof_snapshot _k3slrv_mapping_evidence "$K3SLR_PROOF_MAPPING_NAME" proof_mapping || return 1
|
||||
printf '%s%s' "$mapping_snapshot" "$proof_snapshot"
|
||||
mapping_device="$(_k3slrv_mapping_value "$mapping_snapshot" mapping_device)" || return 1
|
||||
mapping_loop_major_minor="$(_k3slrv_lsblk MAJ:MIN "$mapping_device")" || return 1
|
||||
mapping_major_minor="$(_k3slrv_lsblk MAJ:MIN "/dev/mapper/${K3SLR_MAPPING_NAME}")" || return 1
|
||||
inner_type="$(_k3slrv_blkid TYPE "/dev/mapper/${K3SLR_MAPPING_NAME}")" || return 1
|
||||
inner_label="$(_k3slrv_blkid LABEL "/dev/mapper/${K3SLR_MAPPING_NAME}")" || return 1
|
||||
printf 'inner_type=%s\ninner_label=%s\n' "$inner_type" "$inner_label"
|
||||
inner_root_metadata="$(_k3slrv_one_line_root /usr/bin/stat --format='%u|%g|%a|%F' -- "$K3SLR_INNER_MOUNT")" || return 1
|
||||
IFS='|' read -r uid gid file_mode kind <<<"$inner_root_metadata"
|
||||
printf 'inner_root_uid=%s\ninner_root_gid=%s\ninner_root_mode=%s\ninner_root_kind=%s\n' \
|
||||
"$uid" "$gid" "$file_mode" "$kind"
|
||||
outer_after="$(_k3slrv_findmnt_optional "$K3SLR_OUTER_MOUNT")" || return 1
|
||||
inner_after="$(_k3slrv_findmnt_optional "$K3SLR_INNER_MOUNT")" || return 1
|
||||
source_after="$(_k3slrv_findmnt_source_optional "$recovery_partition")" || return 1
|
||||
chain_after="$(_k3slrv_pin_container_chain "$container")" || return 1
|
||||
_k3slrv_capture loops_after _k3slrv_root /usr/sbin/losetup --list --associated "$container" --noheadings --raw \
|
||||
--output NAME,BACK-INO,BACK-MAJ:MIN,MAJ:MIN,OFFSET,SIZELIMIT 2>/dev/null || return 1
|
||||
_k3slrv_normalize_one_record loops_after "$loops_after" || return 1
|
||||
_k3slrv_capture mapping_after _k3slrv_mapping_evidence "$K3SLR_MAPPING_NAME" mapping || return 1
|
||||
_k3slrv_capture proof_after _k3slrv_mapping_evidence "$K3SLR_PROOF_MAPPING_NAME" proof_mapping || return 1
|
||||
container_metadata_after="$(_k3slrv_one_line_root /usr/bin/stat --format='%F|%u|%g|%a|%s|%b|%i|%Hd:%Ld' -- "$container")" || return 1
|
||||
inner_type_after="$(_k3slrv_blkid TYPE "/dev/mapper/${K3SLR_MAPPING_NAME}")" || return 1
|
||||
inner_label_after="$(_k3slrv_blkid LABEL "/dev/mapper/${K3SLR_MAPPING_NAME}")" || return 1
|
||||
inner_root_after="$(_k3slrv_one_line_root /usr/bin/stat --format='%u|%g|%a|%F' -- "$K3SLR_INNER_MOUNT")" || return 1
|
||||
mapping_device_after="$(_k3slrv_mapping_value "$mapping_after" mapping_device)" || return 1
|
||||
mapping_loop_mm_after="$(_k3slrv_lsblk MAJ:MIN "$mapping_device_after")" || return 1
|
||||
mapping_mm_after="$(_k3slrv_lsblk MAJ:MIN "/dev/mapper/${K3SLR_MAPPING_NAME}")" || return 1
|
||||
[[ "$outer_after" == "$outer" && "$inner_after" == "$inner" && "$source_after" == "$source_mounts" &&
|
||||
"$chain_after" == "$chain_snapshot" && "$loops_after" == "$loops" && "$mapping_after" == "$mapping_snapshot" &&
|
||||
"$proof_after" == "$proof_snapshot" && "$container_metadata_after" == "$metadata" &&
|
||||
"$inner_type_after" == "$inner_type" && "$inner_label_after" == "$inner_label" &&
|
||||
"$inner_root_after" == "$inner_root_metadata" && "$mapping_loop_mm_after" == "$mapping_loop_major_minor" &&
|
||||
"$mapping_mm_after" == "$mapping_major_minor" ]] || return 1
|
||||
printf '%s\n' \
|
||||
'container_chain_pinned=1' \
|
||||
"container_inode=${inode}" \
|
||||
"container_device=${container_device}" \
|
||||
"loop_back_inode=${loop_back_inode}" \
|
||||
"loop_back_major_minor=${loop_back_major_minor}" \
|
||||
"outer_major_minor=${outer_major_minor}" \
|
||||
"loop_major_minor=${loop_major_minor}" \
|
||||
"mapping_loop_major_minor=${mapping_loop_major_minor}" \
|
||||
"mapping_major_minor=${mapping_major_minor}" \
|
||||
"inner_major_minor=${inner_major_minor}" \
|
||||
"loop_offset=${loop_offset}" \
|
||||
"loop_sizelimit=${loop_sizelimit}" \
|
||||
'snapshot_stable=1'
|
||||
}
|
||||
|
||||
_k3slrv_parse_evidence() {
|
||||
local input="$1" destination_name="$2"
|
||||
shift 2
|
||||
local -n destination="$destination_name"
|
||||
local line key value expected
|
||||
local -A allowed=() seen=()
|
||||
local -a lines=()
|
||||
destination=()
|
||||
for expected in "$@"; do allowed["$expected"]=1; done
|
||||
mapfile -t lines < <(printf '%s' "$input")
|
||||
(( ${#lines[@]} > 0 )) || return 1
|
||||
for line in "${lines[@]}"; do
|
||||
[[ "$line" =~ ^([a-z][a-z0-9_]*)=([A-Za-z0-9._:/,+=-]*)$ ]] || return 1
|
||||
key="${BASH_REMATCH[1]}"; value="${BASH_REMATCH[2]}"
|
||||
[[ "${allowed[$key]+yes}" == yes && "${seen[$key]+yes}" != yes ]] || return 1
|
||||
seen["$key"]=1; destination["$key"]="$value"
|
||||
done
|
||||
for expected in "${!allowed[@]}"; do [[ "${seen[$expected]+yes}" == yes ]] || return 1; done
|
||||
}
|
||||
|
||||
_k3slrv_validate_device() {
|
||||
local mode="$1" input="$2"
|
||||
local -A e=()
|
||||
local keys=(recovery_partition recovery_disk recovery_partition_parent recovery_fs_uuid recovery_partuuid recovery_type
|
||||
recovery_model recovery_serial recovery_wwn recovery_major_minor k3s_partition k3s_disk k3s_partition_parent
|
||||
k3s_fs_uuid k3s_partuuid k3s_type k3s_model k3s_serial k3s_wwn k3s_major_minor smart_health
|
||||
smart_reallocated smart_pending smart_uncorrectable ntfs_probe)
|
||||
_k3slrv_parse_evidence "$input" e "${keys[@]}" || return 1
|
||||
[[ "${e[recovery_partition_parent]}" == "${e[recovery_disk]}" && "${e[k3s_partition_parent]}" == "${e[k3s_disk]}" ]] || return 1
|
||||
[[ "${e[recovery_fs_uuid]}" == "$K3SLR_RECOVERY_FS_UUID" && "${e[recovery_partuuid]}" == "$K3SLR_RECOVERY_PARTUUID" && "${e[recovery_type]}" == ntfs ]] || return 1
|
||||
[[ "${e[recovery_model]}" == "$K3SLR_RECOVERY_MODEL" && "${e[recovery_serial]}" == "$K3SLR_RECOVERY_SERIAL" && "${e[recovery_wwn]}" == "$K3SLR_RECOVERY_WWN" ]] || return 1
|
||||
[[ "${e[k3s_fs_uuid]}" == "$K3SLR_K3S_FS_UUID" && "${e[k3s_partuuid]}" == "$K3SLR_K3S_PARTUUID" && "${e[k3s_type]}" == ext4 ]] || return 1
|
||||
[[ "${e[k3s_model]}" == "$K3SLR_K3S_MODEL" && "${e[k3s_serial]}" == "$K3SLR_K3S_SERIAL" && "${e[k3s_wwn]}" == "$K3SLR_K3S_WWN" ]] || return 1
|
||||
[[ "${e[recovery_major_minor]}" != "${e[k3s_major_minor]}" && "${e[smart_health]}" == PASSED ]] || return 1
|
||||
[[ "${e[smart_reallocated]}" == 0 && "${e[smart_pending]}" == 0 && "${e[smart_uncorrectable]}" == 0 ]] || return 1
|
||||
if [[ "$mode" == open ]]; then [[ "${e[ntfs_probe]}" == not_applicable || "${e[ntfs_probe]}" == pass ]];
|
||||
else [[ "${e[ntfs_probe]}" == pass ]]; fi
|
||||
}
|
||||
|
||||
_k3slrv_option_present() {
|
||||
[[ ",${1}," == *",${2},"* ]]
|
||||
}
|
||||
|
||||
_k3slrv_outer_masks_secure() {
|
||||
local options="$1" option umask_count=0 dmask_count=0 fmask_count=0 invalid=false
|
||||
local -a option_list=()
|
||||
IFS=',' read -r -a option_list <<<"$options"
|
||||
for option in "${option_list[@]}"; do
|
||||
case "$option" in
|
||||
umask=077|umask=0077) umask_count=$((umask_count + 1)) ;;
|
||||
dmask=077|dmask=0077) dmask_count=$((dmask_count + 1)) ;;
|
||||
fmask=077|fmask=0077|fmask=177|fmask=0177) fmask_count=$((fmask_count + 1)) ;;
|
||||
umask=*|dmask=*|fmask=*) invalid=true ;;
|
||||
esac
|
||||
done
|
||||
"$invalid" && return 1
|
||||
if (( umask_count == 1 && dmask_count == 0 && fmask_count == 0 )); then return 0; fi
|
||||
(( umask_count == 0 && dmask_count == 1 && fmask_count == 1 ))
|
||||
}
|
||||
|
||||
_k3slrv_validate_open_snapshot() {
|
||||
local input="$1"
|
||||
local -A snapshot=()
|
||||
_k3slrv_parse_evidence "$input" snapshot \
|
||||
container_chain_pinned container_inode container_device loop_back_inode loop_back_major_minor outer_major_minor \
|
||||
loop_major_minor mapping_loop_major_minor mapping_major_minor inner_major_minor \
|
||||
loop_offset loop_sizelimit snapshot_stable || return 1
|
||||
[[ "${snapshot[container_chain_pinned]}" == 1 && "${snapshot[snapshot_stable]}" == 1 ]] || return 1
|
||||
[[ "${snapshot[container_inode]}" =~ ^[0-9]+$ &&
|
||||
"${snapshot[loop_back_inode]}" == "${snapshot[container_inode]}" ]] || return 1
|
||||
[[ "${snapshot[container_device]}" =~ ^[0-9]+:[0-9]+$ &&
|
||||
"${snapshot[loop_back_major_minor]}" == "${snapshot[container_device]}" &&
|
||||
"${snapshot[container_device]}" == "${snapshot[outer_major_minor]}" ]] || return 1
|
||||
[[ "${snapshot[mapping_loop_major_minor]}" == "${snapshot[loop_major_minor]}" ]] || return 1
|
||||
[[ "${snapshot[mapping_major_minor]}" == "${snapshot[inner_major_minor]}" ]] || return 1
|
||||
[[ "${snapshot[loop_offset]}" == 0 && "${snapshot[loop_sizelimit]}" == 0 ]]
|
||||
}
|
||||
|
||||
_k3slrv_validate_state() {
|
||||
local mode="$1" input="$2" device_input="$3" approved_partition='' line snapshot_raw
|
||||
local -A e=()
|
||||
if [[ "$mode" != open ]]; then
|
||||
_k3slrv_parse_evidence "$input" e outer_mounted inner_mounted loop_count mapping_present proof_mapping_present || return 1
|
||||
[[ "${e[outer_mounted]}" == 0 && "${e[inner_mounted]}" == 0 && "${e[loop_count]}" == 0 &&
|
||||
"${e[mapping_present]}" == 0 && "${e[proof_mapping_present]}" == 0 ]]
|
||||
return
|
||||
fi
|
||||
local keys=(outer_mounted outer_source outer_fstype outer_options inner_mounted inner_source inner_fstype inner_options
|
||||
container_kind container_symlink container_uid container_gid container_mode container_size container_allocated
|
||||
loop_count loop_device mapping_present mapping_type mapping_device proof_mapping_present inner_type inner_label
|
||||
inner_root_uid inner_root_gid inner_root_mode inner_root_kind container_chain_pinned container_inode
|
||||
container_device loop_back_inode loop_back_major_minor outer_major_minor loop_major_minor mapping_loop_major_minor
|
||||
mapping_major_minor inner_major_minor loop_offset loop_sizelimit snapshot_stable)
|
||||
_k3slrv_parse_evidence "$input" e "${keys[@]}" || return 1
|
||||
while IFS= read -r line; do
|
||||
[[ "$line" == recovery_partition=* ]] || continue
|
||||
[[ -z "$approved_partition" ]] || return 1
|
||||
approved_partition="${line#recovery_partition=}"
|
||||
done <<<"$device_input"
|
||||
[[ -n "$approved_partition" && "${e[outer_mounted]}" == 1 &&
|
||||
"${e[outer_source]}" == "$approved_partition" && "${e[outer_fstype]}" == ntfs3 ]] || return 1
|
||||
for option in rw nodev nosuid noexec "uid=${K3SLR_OWNER_UID}" "gid=${K3SLR_OWNER_GID}"; do
|
||||
_k3slrv_option_present "${e[outer_options]}" "$option" || return 1
|
||||
done
|
||||
_k3slrv_outer_masks_secure "${e[outer_options]}" || return 1
|
||||
[[ "${e[inner_mounted]}" == 1 && "${e[inner_source]}" == "/dev/mapper/${K3SLR_MAPPING_NAME}" && "${e[inner_fstype]}" == ext4 ]] || return 1
|
||||
for option in rw nodev nosuid noexec; do _k3slrv_option_present "${e[inner_options]}" "$option" || return 1; done
|
||||
[[ "${e[container_kind]}" == regular && "${e[container_symlink]}" == 0 &&
|
||||
"${e[container_uid]}" == "$K3SLR_OWNER_UID" && "${e[container_gid]}" == "$K3SLR_OWNER_GID" ]] || return 1
|
||||
[[ "${e[container_mode]}" =~ ^[0-7]{3,4}$ ]] || return 1
|
||||
(( (10#${e[container_mode]: -2:1} & 7) == 0 && (10#${e[container_mode]: -1:1} & 7) == 0 )) || return 1
|
||||
[[ "${e[container_size]}" == "$K3SLR_CONTAINER_SIZE_BYTES" && "${e[container_allocated]}" =~ ^[0-9]+$ ]] || return 1
|
||||
(( e[container_allocated] >= K3SLR_CONTAINER_SIZE_BYTES )) || return 1
|
||||
[[ "${e[loop_count]}" == 1 && "${e[mapping_present]}" == 1 && "${e[mapping_type]}" == LUKS2 &&
|
||||
"${e[mapping_device]}" == "${e[loop_device]}" && "${e[proof_mapping_present]}" == 0 ]] || return 1
|
||||
[[ "${e[inner_type]}" == ext4 && "${e[inner_label]}" == "$K3SLR_INNER_LABEL" ]] || return 1
|
||||
[[ "${e[inner_root_uid]}" == 0 && "${e[inner_root_gid]}" == 0 && "${e[inner_root_mode]}" == 700 && "${e[inner_root_kind]}" == directory ]] || return 1
|
||||
printf -v snapshot_raw '%s\n' \
|
||||
"container_chain_pinned=${e[container_chain_pinned]}" \
|
||||
"container_inode=${e[container_inode]}" \
|
||||
"container_device=${e[container_device]}" \
|
||||
"loop_back_inode=${e[loop_back_inode]}" \
|
||||
"loop_back_major_minor=${e[loop_back_major_minor]}" \
|
||||
"outer_major_minor=${e[outer_major_minor]}" \
|
||||
"loop_major_minor=${e[loop_major_minor]}" \
|
||||
"mapping_loop_major_minor=${e[mapping_loop_major_minor]}" \
|
||||
"mapping_major_minor=${e[mapping_major_minor]}" \
|
||||
"inner_major_minor=${e[inner_major_minor]}" \
|
||||
"loop_offset=${e[loop_offset]}" \
|
||||
"loop_sizelimit=${e[loop_sizelimit]}" \
|
||||
"snapshot_stable=${e[snapshot_stable]}"
|
||||
_k3slrv_validate_open_snapshot "${snapshot_raw%$'\n'}"
|
||||
}
|
||||
|
||||
_k3slrv_verify_manifest_targets() {
|
||||
local bundle="$1" manifest="$2" records record expected_hash relative target component current canonical
|
||||
local before after hash_line actual_hash count=0
|
||||
local -a components=() manifest_records=()
|
||||
_k3slrv_capture records _k3slrv_root /usr/bin/mawk '
|
||||
NF!=2 || length($1)!=64 || $1 !~ /^[0-9a-f]+$/ || $2 !~ /^\.\/[A-Za-z0-9._\/-]+$/ ||
|
||||
$2 ~ /\.\./ || $2 ~ /\/\// || $2 ~ /\/\.\// || $2 ~ /\/\.$/ || $2 ~ /\/$/ { bad=1; exit }
|
||||
{ print $1 "|" $2 }
|
||||
END { exit (bad || NR==0) }
|
||||
' "$manifest" 2>/dev/null || return 1
|
||||
mapfile -t manifest_records < <(printf '%s' "$records")
|
||||
(( ${#manifest_records[@]} > 0 )) || return 1
|
||||
for record in "${manifest_records[@]}"; do
|
||||
[[ "$record" == *'|'* ]] || return 1
|
||||
expected_hash="${record%%|*}"
|
||||
relative="${record#*|}"
|
||||
[[ "$expected_hash" =~ ^[0-9a-f]{64}$ && "$relative" =~ ^\./[A-Za-z0-9._/-]+$ ]] || return 1
|
||||
target="${bundle}/${relative#./}"
|
||||
current="$bundle"
|
||||
IFS='/' read -r -a components <<<"${relative#./}"
|
||||
for component in "${components[@]}"; do
|
||||
[[ -n "$component" && "$component" != . && "$component" != .. ]] || return 1
|
||||
current="${current}/${component}"
|
||||
_k3slrv_root /usr/bin/test ! -L "$current" >/dev/null 2>&1 || return 1
|
||||
canonical="$(_k3slrv_one_line_root /usr/bin/readlink -e -- "$current")" || return 1
|
||||
[[ "$canonical" == "$current" ]] || return 1
|
||||
done
|
||||
_k3slrv_root /usr/bin/test -f "$target" >/dev/null 2>&1 || return 1
|
||||
before="$(_k3slrv_one_line_root /usr/bin/stat --format='%d:%i|%F' -- "$target")" || return 1
|
||||
[[ "$before" == *'|regular file' ]] || return 1
|
||||
hash_line="$(_k3slrv_one_line_root /usr/bin/sha256sum -- "$target")" || return 1
|
||||
actual_hash="${hash_line%% *}"
|
||||
[[ "$actual_hash" == "$expected_hash" ]] || return 1
|
||||
after="$(_k3slrv_one_line_root /usr/bin/stat --format='%d:%i|%F' -- "$target")" || return 1
|
||||
[[ "$after" == "$before" ]] || return 1
|
||||
count=$((count + 1))
|
||||
done
|
||||
(( count > 0 ))
|
||||
}
|
||||
|
||||
_k3slrv_collect_latest_bundle_evidence() {
|
||||
local metadata="${K3SLR_INNER_MOUNT}/.latest-post-bundle.env" parsed relative expected_identity bundle canonical_bundle actual_identity after_identity
|
||||
local bundle_metadata manifest stat_value
|
||||
_k3slrv_root /usr/bin/test -f "$metadata" >/dev/null 2>&1 || return 1
|
||||
_k3slrv_root /usr/bin/test ! -L "$metadata" >/dev/null 2>&1 || return 1
|
||||
stat_value="$(_k3slrv_one_line_root /usr/bin/stat --format='%u:%g:%a:%F' -- "$metadata")" || return 1
|
||||
[[ "$stat_value" == '0:0:600:regular file' ]] || return 1
|
||||
_k3slrv_capture parsed _k3slrv_root /usr/bin/mawk -F= '
|
||||
BEGIN { ok=1 }
|
||||
!/^[a-z_]+=[A-Za-z0-9._:\/-]+$/ { ok=0; next }
|
||||
$1=="schema" && !s++ && $2=="k3slr-latest-post-bundle-v1" { schema=$2; next }
|
||||
$1=="relative_path" && !p++ && $2 ~ /^k3s-secrets-encryption-[0-9]{8}T[0-9]{6}Z\/post$/ { path=$2; next }
|
||||
$1=="directory_identity" && !i++ && $2 ~ /^[0-9]+:[0-9]+$/ { identity=$2; next }
|
||||
{ ok=0 }
|
||||
END { if (ok && NR==3 && s==1 && p==1 && i==1) print path "|" identity; else exit 1 }
|
||||
' "$metadata" 2>/dev/null || return 1
|
||||
_k3slrv_normalize_one_record parsed "$parsed" || return 1
|
||||
[[ "$parsed" == *'|'* ]] || return 1
|
||||
relative="${parsed%%|*}"; expected_identity="${parsed#*|}"
|
||||
[[ "$relative" =~ ^k3s-secrets-encryption-[0-9]{8}T[0-9]{6}Z/post$ && "$relative" != *..* ]] || return 1
|
||||
bundle="${K3SLR_INNER_MOUNT}/${relative}"
|
||||
canonical_bundle="$(_k3slrv_one_line_root /usr/bin/readlink -f -- "$bundle")" || return 1
|
||||
[[ "$canonical_bundle" == "$bundle" ]] || return 1
|
||||
actual_identity="$(_k3slrv_one_line_root /usr/bin/stat --dereference --format='%d:%i' -- "$bundle")" || return 1
|
||||
[[ "$actual_identity" == "$expected_identity" ]] || return 1
|
||||
stat_value="$(_k3slrv_one_line_root /usr/bin/stat --dereference --format='%u:%g:%a:%F' -- "$bundle")" || return 1
|
||||
[[ "$stat_value" == '0:0:700:directory' ]] || return 1
|
||||
bundle_metadata="${bundle}/bundle.env"; manifest="${bundle}/verification.manifest"
|
||||
for file in "$bundle_metadata" "$manifest"; do
|
||||
_k3slrv_root /usr/bin/test -f "$file" >/dev/null 2>&1 || return 1
|
||||
_k3slrv_root /usr/bin/test ! -L "$file" >/dev/null 2>&1 || return 1
|
||||
stat_value="$(_k3slrv_one_line_root /usr/bin/stat --format='%u:%g:%a:%F' -- "$file")" || return 1
|
||||
[[ "$stat_value" == '0:0:600:regular file' ]] || return 1
|
||||
done
|
||||
_k3slrv_root /usr/bin/mawk -F= '
|
||||
BEGIN { ok=1 }
|
||||
!/^[a-z][a-z0-9_]*=[A-Za-z0-9:+._-]+$/ { ok=0; next }
|
||||
$1=="schema" && !a++ && $2=="platform-k3s-bundle-v1" { next }
|
||||
$1=="bundle_id" && !b++ && $2 ~ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ { next }
|
||||
$1=="phase" && !c++ && $2=="post" { next }
|
||||
$1=="k3s_version" && !d++ && $2=="v1.36.2+k3s1" { next }
|
||||
$1=="datastore" && !e++ && ($2=="sqlite" || $2=="embedded-etcd") { next }
|
||||
$1=="created_at_utc" && !f++ && $2 ~ /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$/ { next }
|
||||
$1=="secret_count" && !g++ && $2 ~ /^[0-9]+$/ { next }
|
||||
{ ok=0 }
|
||||
END { exit !(ok && NR==7 && a==1 && b==1 && c==1 && d==1 && e==1 && f==1 && g==1) }
|
||||
' "$bundle_metadata" >/dev/null 2>&1 || return 1
|
||||
_k3slrv_verify_manifest_targets "$bundle" "$manifest" || return 1
|
||||
after_identity="$(_k3slrv_one_line_root /usr/bin/stat --dereference --format='%d:%i' -- "$bundle")" || return 1
|
||||
[[ "$after_identity" == "$expected_identity" ]] || return 1
|
||||
printf '%s\n' \
|
||||
'metadata_schema=k3slr-latest-post-bundle-v1' \
|
||||
'relative_path_valid=1' \
|
||||
'directory_identity_match=1' \
|
||||
'directory_secure=1' \
|
||||
'bundle_schema=platform-k3s-bundle-v1' \
|
||||
'bundle_phase=post' \
|
||||
'manifest_safe=1' \
|
||||
'manifest_verified=1' \
|
||||
'directory_identity_stable=1'
|
||||
}
|
||||
|
||||
_k3slrv_verify_latest_bundle() {
|
||||
local raw
|
||||
local -A evidence=()
|
||||
_k3slrv_capture raw _k3slrv_collect_latest_bundle_evidence || return 1
|
||||
_k3slrv_parse_evidence "$raw" evidence \
|
||||
metadata_schema relative_path_valid directory_identity_match directory_secure \
|
||||
bundle_schema bundle_phase manifest_safe manifest_verified directory_identity_stable || return 1
|
||||
[[ "${evidence[metadata_schema]}" == k3slr-latest-post-bundle-v1 &&
|
||||
"${evidence[relative_path_valid]}" == 1 &&
|
||||
"${evidence[directory_identity_match]}" == 1 &&
|
||||
"${evidence[directory_secure]}" == 1 &&
|
||||
"${evidence[bundle_schema]}" == platform-k3s-bundle-v1 &&
|
||||
"${evidence[bundle_phase]}" == post &&
|
||||
"${evidence[manifest_safe]}" == 1 &&
|
||||
"${evidence[manifest_verified]}" == 1 &&
|
||||
"${evidence[directory_identity_stable]}" == 1 ]]
|
||||
}
|
||||
|
||||
k3slr_local_recovery_main() {
|
||||
local mode='' check_latest=false argument device_raw state_raw latest=not_checked
|
||||
for argument in "$@"; do
|
||||
case "$argument" in
|
||||
--expect-device-ready) [[ -z "$mode" ]] || { _k3slrv_usage >&2; return 2; }; mode=device_ready ;;
|
||||
--expect-closed) [[ -z "$mode" ]] || { _k3slrv_usage >&2; return 2; }; mode=closed ;;
|
||||
--expect-open) [[ -z "$mode" ]] || { _k3slrv_usage >&2; return 2; }; mode=open ;;
|
||||
--check-latest-bundle) "$check_latest" && { _k3slrv_usage >&2; return 2; }; check_latest=true ;;
|
||||
--help|-h) _k3slrv_usage; return 0 ;;
|
||||
*) _k3slrv_usage >&2; return 2 ;;
|
||||
esac
|
||||
done
|
||||
[[ -n "$mode" ]] || { _k3slrv_usage >&2; return 2; }
|
||||
if "$check_latest" && [[ "$mode" != open ]]; then _k3slrv_usage >&2; return 2; fi
|
||||
PATH="$K3SLRV_SECURE_PATH"; export PATH
|
||||
_k3slrv_verify_trusted_binaries || { _k3slrv_fail; return 1; }
|
||||
# Direct execution always replaces any inherited/predefined parser with the
|
||||
# repository library in this unprivileged process.
|
||||
# shellcheck source=/dev/null
|
||||
builtin source -- "$K3SLRV_LIBRARY_PATH" || { _k3slrv_fail; return 1; }
|
||||
_k3slr_load_contract "$K3SLRV_CONTRACT_PATH" || { _k3slrv_fail; return 1; }
|
||||
_k3slrv_require_cached_sudo || { _k3slrv_fail; return 1; }
|
||||
_k3slrv_capture device_raw _k3slrv_collect_device_evidence "$mode" || { _k3slrv_fail; return 1; }
|
||||
_k3slrv_validate_device "$mode" "$device_raw" || { _k3slrv_fail; return 1; }
|
||||
_k3slrv_capture state_raw _k3slrv_collect_state_evidence "$mode" || { _k3slrv_fail; return 1; }
|
||||
_k3slrv_validate_state "$mode" "$state_raw" "$device_raw" || { _k3slrv_fail; return 1; }
|
||||
if "$check_latest"; then
|
||||
_k3slrv_verify_latest_bundle || { _k3slrv_fail; return 1; }
|
||||
latest=verified
|
||||
fi
|
||||
printf 'Recovery device: match\nRecovery state: %s\nLineage: match\nLatest bundle: %s\n' "$mode" "$latest"
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||
k3slr_local_recovery_main "$@"
|
||||
fi
|
||||
+636
@@ -0,0 +1,636 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# 격리 복구 결과를 만들고 운영 host의 복구 증거를 등록·검사한다. source-safe
|
||||
# 함수 경계는 fixture가 live/root side effect만 치환할 수 있게 유지한다.
|
||||
set -Eeuo pipefail
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == */* ]]; then
|
||||
readonly KSRE_SCRIPT_DIR="${BASH_SOURCE[0]%/*}"
|
||||
else
|
||||
readonly KSRE_SCRIPT_DIR='.'
|
||||
fi
|
||||
readonly KSRE_ROOT="$(cd -- "${KSRE_SCRIPT_DIR}/../.." && pwd -P)"
|
||||
readonly KSRE_STATUS_VALIDATOR="${KSRE_ROOT}/scripts/validate/k3s-secret-encryption.sh"
|
||||
readonly KSRE_POST_BUNDLE='/etc/rancher/k3s/platform-post-bundle.env'
|
||||
readonly KSRE_EVIDENCE='/etc/rancher/k3s/platform-restore-evidence.env'
|
||||
readonly KSRE_VERSION='v1.36.2+k3s1'
|
||||
readonly KSRE_KUBECTL='/usr/local/bin/kubectl'
|
||||
readonly KSRE_LOCAL_API_SERVER='https://127.0.0.1:6443'
|
||||
readonly KSRE_SECURE_PATH='/usr/sbin:/usr/bin:/sbin:/bin'
|
||||
readonly KSRE_BROKER_RUNTIME_TIMEOUT='2s'
|
||||
readonly KSRE_BROKER_KILL_AFTER='0.5s'
|
||||
readonly KSRE_BROKER_READY_TIMEOUT='3'
|
||||
readonly KSRE_BROKER_ACK_TIMEOUT='1.0'
|
||||
readonly KSRE_BROKER_REAP_POLLS='300'
|
||||
readonly KSRE_BROKER_SIGNAL_POLLS='50'
|
||||
readonly KSRE_BROKER_POLL_INTERVAL='0.01'
|
||||
|
||||
_ksre_fail() {
|
||||
printf 'ERROR: 복구 증거 검증에 실패했습니다. 수동 절차를 확인하세요.\n' >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
_ksre_usage() {
|
||||
cat <<'USAGE'
|
||||
사용법:
|
||||
bash scripts/validate/k3s-secret-encryption-restore-evidence.sh \
|
||||
--emit-result --bundle-metadata FILE --output FILE
|
||||
bash scripts/validate/k3s-secret-encryption-restore-evidence.sh \
|
||||
--record --bundle-metadata FILE --result-file FILE
|
||||
bash scripts/validate/k3s-secret-encryption-restore-evidence.sh --check
|
||||
|
||||
--emit-result는 격리 복구 host의 live 상태를 검사한 뒤 결과 파일을 만듭니다.
|
||||
--record는 복구 환경과 bundle 복사본 파기 확인 뒤 운영 host에 증거를 등록합니다.
|
||||
--check는 권위 post bundle, live 암호화 상태와 등록된 증거를 읽기 전용 검사합니다.
|
||||
USAGE
|
||||
}
|
||||
|
||||
_ksre_allowed_key() {
|
||||
local kind="$1" key="$2"
|
||||
case "$kind:$key" in
|
||||
bundle:schema|bundle:bundle_id|bundle:phase|bundle:k3s_version|bundle:datastore|bundle:created_at_utc|bundle:secret_count)
|
||||
return 0
|
||||
;;
|
||||
result:schema|result:bundle_id|result:k3s_version|result:datastore|result:status_class|result:rotation_stage|result:hashes|result:local_integrity|result:api_ready|result:node_ready|result:isolation|result:secret_count_match|result:tested_at_utc)
|
||||
return 0
|
||||
;;
|
||||
evidence:schema|evidence:bundle_id|evidence:k3s_version|evidence:datastore|evidence:rotation_stage|evidence:local_integrity|evidence:node_ready|evidence:isolation|evidence:tested_at_utc|evidence:recorded_at_utc|evidence:destroyed)
|
||||
return 0
|
||||
;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
_ksre_expected_field_count() {
|
||||
case "$1" in bundle) printf '7\n' ;; result) printf '13\n' ;; evidence) printf '11\n' ;; *) return 1 ;; esac
|
||||
}
|
||||
|
||||
_ksre_values_valid() {
|
||||
local kind="$1" destination="$2"
|
||||
local -n values="$destination"
|
||||
[[ "${values[bundle_id]:-}" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ ]] || return 1
|
||||
[[ "${values[k3s_version]:-}" == "$KSRE_VERSION" ]] || return 1
|
||||
[[ "${values[datastore]:-}" == sqlite || "${values[datastore]:-}" == embedded-etcd ]] || return 1
|
||||
case "$kind" in
|
||||
bundle)
|
||||
[[ "${values[schema]:-}" == platform-k3s-bundle-v1 &&
|
||||
( "${values[phase]:-}" == pre || "${values[phase]:-}" == post ) &&
|
||||
"${values[created_at_utc]:-}" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ &&
|
||||
"${values[secret_count]:-}" =~ ^[0-9]+$ ]]
|
||||
;;
|
||||
result)
|
||||
[[ "${values[schema]:-}" == platform-k3s-restore-result-v1 &&
|
||||
"${values[status_class]:-}" == enabled_stable &&
|
||||
"${values[rotation_stage]:-}" == reencrypt_finished &&
|
||||
"${values[hashes]:-}" == match && "${values[local_integrity]:-}" == match &&
|
||||
"${values[api_ready]:-}" == pass && "${values[node_ready]:-}" == pass &&
|
||||
"${values[isolation]:-}" == pass && "${values[secret_count_match]:-}" == pass &&
|
||||
"${values[tested_at_utc]:-}" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]]
|
||||
;;
|
||||
evidence)
|
||||
[[ "${values[schema]:-}" == platform-k3s-restore-evidence-v1 &&
|
||||
"${values[rotation_stage]:-}" == reencrypt_finished &&
|
||||
"${values[local_integrity]:-}" == match && "${values[node_ready]:-}" == pass &&
|
||||
"${values[isolation]:-}" == pass && "${values[destroyed]:-}" == confirmed &&
|
||||
"${values[tested_at_utc]:-}" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ &&
|
||||
"${values[recorded_at_utc]:-}" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]]
|
||||
;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
_ksre_parse_stream() {
|
||||
local kind="$1" destination="$2" line key value count=0 expected
|
||||
local -n output_fields="$destination"
|
||||
output_fields=()
|
||||
expected="$(_ksre_expected_field_count "$kind")" || return 1
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
[[ "$line" =~ ^([a-z0-9_]+)=([^=[:cntrl:]]+)$ ]] || return 1
|
||||
key="${BASH_REMATCH[1]}"; value="${BASH_REMATCH[2]}"
|
||||
_ksre_allowed_key "$kind" "$key" || return 1
|
||||
[[ -z "${output_fields[$key]+present}" ]] || return 1
|
||||
output_fields["$key"]="$value"
|
||||
count=$((count + 1))
|
||||
done
|
||||
[[ "$count" == "$expected" ]] || return 1
|
||||
_ksre_values_valid "$kind" "$destination"
|
||||
}
|
||||
|
||||
_ksre_raw_stream_has_no_nul() {
|
||||
/usr/bin/od -An -v -t u1 -- "$1" 2>/dev/null |
|
||||
/usr/bin/awk '{ for (field = 1; field <= NF; field++) if ($field == 0) found = 1 }
|
||||
END { exit(found ? 1 : 0) }'
|
||||
}
|
||||
|
||||
_ksre_parse_external_fd() {
|
||||
local kind="$1" input_fd="$2" destination="$3" handle metadata
|
||||
[[ "$input_fd" =~ ^[0-9]+$ ]] || return 1
|
||||
handle="/proc/self/fd/${input_fd}"
|
||||
metadata="$(/usr/bin/stat --dereference --format='%u:%a:%F' -- "$handle" 2>/dev/null)" || return 1
|
||||
[[ "$metadata" == "${EUID}:600:regular file" && -s "$handle" ]] || return 1
|
||||
_ksre_raw_stream_has_no_nul "$handle" || return 1
|
||||
_ksre_parse_stream "$kind" "$destination" <"$handle"
|
||||
}
|
||||
|
||||
_ksre_broker_job_running() {
|
||||
local expected_pid="$1" active_pid active_jobs
|
||||
active_jobs="$(jobs -pr)"
|
||||
while IFS= read -r active_pid; do
|
||||
[[ "$active_pid" == "$expected_pid" ]] && return 0
|
||||
done <<<"$active_jobs"
|
||||
return 1
|
||||
}
|
||||
|
||||
_ksre_reap_broker_bounded() {
|
||||
local broker_pid="$1" attempt
|
||||
for ((attempt = 0; attempt < KSRE_BROKER_REAP_POLLS; attempt++)); do
|
||||
if ! _ksre_broker_job_running "$broker_pid"; then
|
||||
if wait "$broker_pid"; then return 0; else return $?; fi
|
||||
fi
|
||||
/usr/bin/sleep "$KSRE_BROKER_POLL_INTERVAL"
|
||||
done
|
||||
/bin/kill -TERM "$broker_pid" 2>/dev/null || :
|
||||
for ((attempt = 0; attempt < KSRE_BROKER_SIGNAL_POLLS; attempt++)); do
|
||||
if ! _ksre_broker_job_running "$broker_pid"; then
|
||||
wait "$broker_pid" 2>/dev/null || :
|
||||
return 1
|
||||
fi
|
||||
/usr/bin/sleep "$KSRE_BROKER_POLL_INTERVAL"
|
||||
done
|
||||
/bin/kill -KILL "$broker_pid" 2>/dev/null || :
|
||||
for ((attempt = 0; attempt < KSRE_BROKER_SIGNAL_POLLS; attempt++)); do
|
||||
if ! _ksre_broker_job_running "$broker_pid"; then
|
||||
wait "$broker_pid" 2>/dev/null || :
|
||||
return 1
|
||||
fi
|
||||
/usr/bin/sleep "$KSRE_BROKER_POLL_INTERVAL"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
_ksre_open_external_nofollow() {
|
||||
local path="$1" destination="$2" broker_pid broker_read broker_write
|
||||
local ready opener_pid opener_fd opened_descriptor='' rc=0
|
||||
coproc KSRE_OPEN_BROKER {
|
||||
exec /usr/bin/timeout --signal=TERM --kill-after="$KSRE_BROKER_KILL_AFTER" \
|
||||
"$KSRE_BROKER_RUNTIME_TIMEOUT" /usr/bin/python3 -I -S -c '
|
||||
import os
|
||||
import select
|
||||
import stat
|
||||
import sys
|
||||
|
||||
required_flags = ("O_PATH", "O_NOFOLLOW", "O_CLOEXEC", "O_NONBLOCK")
|
||||
if any(not hasattr(os, flag) for flag in required_flags):
|
||||
sys.exit(1)
|
||||
try:
|
||||
path_descriptor = os.open(
|
||||
sys.argv[1], os.O_PATH | os.O_NOFOLLOW | os.O_CLOEXEC
|
||||
)
|
||||
except OSError:
|
||||
sys.exit(1)
|
||||
try:
|
||||
path_status = os.fstat(path_descriptor)
|
||||
if not stat.S_ISREG(path_status.st_mode):
|
||||
sys.exit(1)
|
||||
try:
|
||||
descriptor = os.open(
|
||||
f"/proc/self/fd/{path_descriptor}",
|
||||
os.O_RDONLY | os.O_CLOEXEC | os.O_NONBLOCK,
|
||||
)
|
||||
except OSError:
|
||||
sys.exit(1)
|
||||
try:
|
||||
content_status = os.fstat(descriptor)
|
||||
if (
|
||||
not stat.S_ISREG(content_status.st_mode)
|
||||
or content_status.st_dev != path_status.st_dev
|
||||
or content_status.st_ino != path_status.st_ino
|
||||
):
|
||||
sys.exit(1)
|
||||
print(f"{os.getpid()}:{descriptor}", flush=True)
|
||||
readable, _, _ = select.select(
|
||||
[sys.stdin.buffer], [], [], float(sys.argv[2])
|
||||
)
|
||||
if not readable or sys.stdin.buffer.read(1) != b"x":
|
||||
sys.exit(1)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
finally:
|
||||
os.close(path_descriptor)
|
||||
' "$path" "$KSRE_BROKER_ACK_TIMEOUT"
|
||||
}
|
||||
broker_pid="$KSRE_OPEN_BROKER_PID"
|
||||
broker_read="${KSRE_OPEN_BROKER[0]}"
|
||||
broker_write="${KSRE_OPEN_BROKER[1]}"
|
||||
if IFS= read -r -t "$KSRE_BROKER_READY_TIMEOUT" ready <&"$broker_read"; then
|
||||
if [[ "$ready" =~ ^([0-9]+):([0-9]+)$ ]]; then
|
||||
opener_pid="${BASH_REMATCH[1]}"
|
||||
opener_fd="${BASH_REMATCH[2]}"
|
||||
exec {opened_descriptor}<"/proc/${opener_pid}/fd/${opener_fd}" || rc=1
|
||||
else
|
||||
rc=1
|
||||
fi
|
||||
else
|
||||
rc=1
|
||||
fi
|
||||
if (( rc == 0 )); then
|
||||
printf 'x' >&"$broker_write" || rc=1
|
||||
fi
|
||||
exec {broker_write}>&-
|
||||
exec {broker_read}<&-
|
||||
if (( rc != 0 )); then /bin/kill -TERM "$broker_pid" 2>/dev/null || :; fi
|
||||
if ! _ksre_reap_broker_bounded "$broker_pid"; then rc=1; fi
|
||||
if (( rc != 0 )); then
|
||||
[[ -z "$opened_descriptor" ]] || exec {opened_descriptor}<&-
|
||||
return 1
|
||||
fi
|
||||
printf -v "$destination" '%s' "$opened_descriptor"
|
||||
}
|
||||
|
||||
_ksre_parse_external_file() {
|
||||
local kind="$1" path="$2" destination="$3" input_fd rc=0
|
||||
[[ -n "$path" ]] || return 1
|
||||
_ksre_open_external_nofollow "$path" input_fd || return 1
|
||||
if (( rc == 0 )); then _ksre_parse_external_fd "$kind" "$input_fd" "$destination" || rc=$?; fi
|
||||
exec {input_fd}<&-
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
_ksre_parse_trusted_content() {
|
||||
local kind="$1" content="$2" destination="$3"
|
||||
_ksre_parse_stream "$kind" "$destination" <<<"$content"
|
||||
}
|
||||
|
||||
_ksre_current_context() {
|
||||
/usr/bin/timeout --signal=TERM --kill-after=1s 9s \
|
||||
"$KSRE_KUBECTL" config current-context 2>/dev/null
|
||||
}
|
||||
|
||||
_ksre_require_same_context() {
|
||||
local expected="$1" current
|
||||
current="$(_ksre_current_context)" || return 1
|
||||
[[ -n "$current" && "$current" == "$expected" && "$current" != *$'\n'* ]]
|
||||
}
|
||||
|
||||
_ksre_validate_current_connection() {
|
||||
[[ "$1" == "${KSRE_LOCAL_API_SERVER}|" ]]
|
||||
}
|
||||
|
||||
_ksre_current_connection() {
|
||||
local context="$1"
|
||||
/usr/bin/timeout --signal=TERM --kill-after=1s 9s \
|
||||
"$KSRE_KUBECTL" --context="$context" config view --minify \
|
||||
-o 'jsonpath={.clusters[0].cluster.server}{"|"}{.clusters[0].cluster.proxy-url}' \
|
||||
2>/dev/null
|
||||
}
|
||||
|
||||
_ksre_require_same_connection() {
|
||||
local context="$1" expected="$2" current
|
||||
_ksre_validate_current_connection "$expected" || return 1
|
||||
current="$(_ksre_current_connection "$context")" || return 1
|
||||
[[ "$current" == "$expected" ]]
|
||||
}
|
||||
|
||||
_ksre_now_rfc3339() { /usr/bin/date -u +%Y-%m-%dT%H:%M:%SZ; }
|
||||
_ksre_now_epoch() { /usr/bin/date -u +%s; }
|
||||
|
||||
_ksre_timestamp_epoch() {
|
||||
local timestamp="$1" normalized
|
||||
normalized="$(/usr/bin/date -u -d "$timestamp" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)" || return 1
|
||||
[[ "$normalized" == "$timestamp" ]] || return 1
|
||||
/usr/bin/date -u -d "$timestamp" +%s
|
||||
}
|
||||
|
||||
_ksre_age_within() {
|
||||
local timestamp="$1" maximum="$2" now epoch age
|
||||
now="$(_ksre_now_epoch)" || return 1
|
||||
epoch="$(_ksre_timestamp_epoch "$timestamp")" || return 1
|
||||
[[ "$now" =~ ^[0-9]+$ && "$epoch" =~ ^[0-9]+$ && "$maximum" =~ ^[0-9]+$ ]] || return 1
|
||||
age=$((now - epoch))
|
||||
(( age >= 0 && age <= maximum ))
|
||||
}
|
||||
|
||||
_ksre_bundle_equal() {
|
||||
local left_name="$1" right_name="$2" key
|
||||
local -n left="$left_name" right="$right_name"
|
||||
for key in schema bundle_id phase k3s_version datastore created_at_utc secret_count; do
|
||||
[[ "${left[$key]}" == "${right[$key]}" ]] || return 1
|
||||
done
|
||||
}
|
||||
|
||||
_ksre_read_root_file() {
|
||||
local path="$1" kind="$2" destination="$3" metadata temporary rc=0
|
||||
/usr/bin/sudo --non-interactive -- /usr/bin/test ! -L "$path" || return 1
|
||||
/usr/bin/sudo --non-interactive -- /usr/bin/test -f "$path" || return 1
|
||||
/usr/bin/sudo --non-interactive -- /usr/bin/test -s "$path" || return 1
|
||||
metadata="$(/usr/bin/sudo --non-interactive -- /usr/bin/stat \
|
||||
--format='%u:%g:%a:%F' -- "$path" 2>/dev/null)" || return 1
|
||||
[[ "$metadata" == '0:0:600:regular file' ]] || return 1
|
||||
temporary="$(/usr/bin/mktemp /tmp/platform-k3s-restore-read.XXXXXX)" || return 1
|
||||
/usr/bin/chmod 0600 "$temporary" || { /bin/rm -f -- "$temporary"; return 1; }
|
||||
/usr/bin/sudo --non-interactive -- /bin/cat -- "$path" >"$temporary" || rc=$?
|
||||
if (( rc == 0 )); then _ksre_parse_external_file "$kind" "$temporary" "$destination" || rc=$?; fi
|
||||
/bin/rm -f -- "$temporary"
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
_ksre_read_authoritative_bundle() { _ksre_read_root_file "$KSRE_POST_BUNDLE" bundle "$1"; }
|
||||
_ksre_read_installed_evidence() { _ksre_read_root_file "$KSRE_EVIDENCE" evidence "$1"; }
|
||||
|
||||
_ksre_inventory_value() {
|
||||
local file="$1" key="$2" line found=''
|
||||
while IFS= read -r line; do
|
||||
[[ "$line" =~ ^([a-z_]+)=([A-Za-z0-9_.+-]+)$ ]] || return 1
|
||||
if [[ "${BASH_REMATCH[1]}" == "$key" ]]; then
|
||||
[[ -z "$found" ]] || return 1
|
||||
found="${BASH_REMATCH[2]}"
|
||||
fi
|
||||
done <"$file"
|
||||
[[ -n "$found" ]] || return 1
|
||||
printf '%s\n' "$found"
|
||||
}
|
||||
|
||||
_ksre_secret_count_from_stream() {
|
||||
/usr/bin/jq -er '.items | length' 2>/dev/null
|
||||
}
|
||||
|
||||
_ksre_require_same_ready_node() {
|
||||
local local_node="$1" local_node_uid="$2" nodes_json="$3"
|
||||
[[ "$local_node" =~ ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$ ]] || return 1
|
||||
[[ "$local_node_uid" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ ]] || return 1
|
||||
/usr/bin/jq -e --arg local_node "$local_node" --arg local_node_uid "$local_node_uid" '
|
||||
[.items[] |
|
||||
select(any(.status.conditions[]?; .type == "Ready" and .status == "True"))] |
|
||||
length == 1 and
|
||||
.[0].metadata.name == $local_node and
|
||||
.[0].metadata.uid == $local_node_uid
|
||||
' >/dev/null 2>&1 <<<"$nodes_json"
|
||||
}
|
||||
|
||||
_ksre_run_restore_status_validator() {
|
||||
/usr/bin/bash "$KSRE_STATUS_VALIDATOR" --expect-reencrypted-restore \
|
||||
--verified-output-dir "$1" >/dev/null
|
||||
}
|
||||
|
||||
_ksre_current_user_api_ready() {
|
||||
local context="$1" server="$2"
|
||||
/usr/bin/timeout --signal=TERM --kill-after=1s 9s \
|
||||
/usr/bin/env -u HTTP_PROXY -u HTTPS_PROXY -u ALL_PROXY -u NO_PROXY \
|
||||
-u http_proxy -u https_proxy -u all_proxy -u no_proxy \
|
||||
"$KSRE_KUBECTL" --context="$context" --server="$server" \
|
||||
get --raw=/readyz >/dev/null 2>&1
|
||||
}
|
||||
|
||||
_ksre_current_user_nodes_json() {
|
||||
local context="$1" server="$2"
|
||||
/usr/bin/timeout --signal=TERM --kill-after=1s 15s \
|
||||
/usr/bin/env -u HTTP_PROXY -u HTTPS_PROXY -u ALL_PROXY -u NO_PROXY \
|
||||
-u http_proxy -u https_proxy -u all_proxy -u no_proxy \
|
||||
"$KSRE_KUBECTL" --context="$context" --server="$server" \
|
||||
get nodes -o json 2>/dev/null
|
||||
}
|
||||
|
||||
_ksre_current_user_secret_count() {
|
||||
local context="$1" server="$2"
|
||||
/usr/bin/timeout --signal=TERM --kill-after=1s 30s \
|
||||
/usr/bin/env -u HTTP_PROXY -u HTTPS_PROXY -u ALL_PROXY -u NO_PROXY \
|
||||
-u http_proxy -u https_proxy -u all_proxy -u no_proxy \
|
||||
"$KSRE_KUBECTL" --context="$context" --server="$server" \
|
||||
get secrets --all-namespaces -o json 2>/dev/null |
|
||||
_ksre_secret_count_from_stream
|
||||
}
|
||||
|
||||
_ksre_live_restore_inventory() {
|
||||
local context="$1" connection="$2" server
|
||||
local handoff inventory nodes version datastore encryption rotation hashes integrity
|
||||
local local_node local_node_uid local_node_ready
|
||||
local api_ready node_ready secret_count rc=0
|
||||
_ksre_validate_current_connection "$connection" || return 1
|
||||
server="${connection%|}"
|
||||
handoff="$(/usr/bin/mktemp -d /tmp/platform-k3s-encryption.XXXXXX)" || return 1
|
||||
/usr/bin/chmod 0700 "$handoff" || { /bin/rm -rf -- "$handoff"; return 1; }
|
||||
if ! _ksre_run_restore_status_validator "$handoff"; then
|
||||
/bin/rm -rf -- "$handoff"; return 1
|
||||
fi
|
||||
inventory="$handoff/inventory.env"
|
||||
version="$(_ksre_inventory_value "$inventory" version)" || rc=1
|
||||
datastore="$(_ksre_inventory_value "$inventory" datastore)" || rc=1
|
||||
encryption="$(_ksre_inventory_value "$inventory" encryption)" || rc=1
|
||||
rotation="$(_ksre_inventory_value "$inventory" rotation)" || rc=1
|
||||
hashes="$(_ksre_inventory_value "$inventory" server_hashes)" || rc=1
|
||||
integrity="$(_ksre_inventory_value "$inventory" integrity)" || rc=1
|
||||
local_node="$(_ksre_inventory_value "$inventory" node_name)" || rc=1
|
||||
local_node_uid="$(_ksre_inventory_value "$inventory" node_uid)" || rc=1
|
||||
local_node_ready="$(_ksre_inventory_value "$inventory" node_ready)" || rc=1
|
||||
/bin/rm -rf -- "$handoff"
|
||||
(( rc == 0 )) || return 1
|
||||
[[ "$encryption" == Enabled && "$local_node_ready" == Ready ]] || return 1
|
||||
if _ksre_current_user_api_ready "$context" "$server"; then api_ready=pass; else return 1; fi
|
||||
nodes="$(_ksre_current_user_nodes_json "$context" "$server")" || return 1
|
||||
_ksre_require_same_ready_node "$local_node" "$local_node_uid" "$nodes" || return 1
|
||||
node_ready=pass
|
||||
secret_count="$(_ksre_current_user_secret_count "$context" "$server")" || return 1
|
||||
[[ "$secret_count" =~ ^[0-9]+$ ]] || return 1
|
||||
_ksre_require_same_connection "$context" "$connection" || return 1
|
||||
printf '%s|%s|enabled_stable|%s|%s|%s|%s|%s|%s\n' \
|
||||
"$version" "$datastore" "$rotation" "$hashes" "$integrity" \
|
||||
"$api_ready" "$node_ready" "$secret_count"
|
||||
}
|
||||
|
||||
_ksre_isolation_attested() {
|
||||
local context="$1" answer
|
||||
[[ -t 0 ]] || return 1
|
||||
printf '격리·egress 차단 시험 완료 후 Type ISOLATED %s: ' "$context" >&2
|
||||
IFS= read -r answer
|
||||
[[ "$answer" == "ISOLATED $context" ]]
|
||||
}
|
||||
|
||||
_ksre_output_target_absent() {
|
||||
local output="$1" parent base
|
||||
[[ -n "$output" && ! -e "$output" && ! -L "$output" ]] || return 1
|
||||
if [[ "$output" == */* ]]; then parent="${output%/*}"; base="${output##*/}"
|
||||
else parent='.'; base="$output"; fi
|
||||
[[ -n "$parent" && -n "$base" && "$base" != . && "$base" != .. ]] || return 1
|
||||
[[ -d "$parent" && ! -L "$parent" ]] || return 1
|
||||
}
|
||||
|
||||
_ksre_write_output() {
|
||||
local output="$1" content="$2" kind="$3" parent base temporary
|
||||
_ksre_output_target_absent "$output" || return 1
|
||||
if [[ "$output" == */* ]]; then parent="${output%/*}"; base="${output##*/}"
|
||||
else parent='.'; base="$output"; fi
|
||||
temporary="$(/usr/bin/mktemp "${parent}/.${base}.tmp.XXXXXX")" || return 1
|
||||
/usr/bin/chmod 0600 "$temporary" || { /bin/rm -f -- "$temporary"; return 1; }
|
||||
printf '%s\n' "$content" >"$temporary" || { /bin/rm -f -- "$temporary"; return 1; }
|
||||
declare -A verification=()
|
||||
_ksre_parse_external_file "$kind" "$temporary" verification || { /bin/rm -f -- "$temporary"; return 1; }
|
||||
/bin/ln -- "$temporary" "$output" 2>/dev/null || { /bin/rm -f -- "$temporary"; return 1; }
|
||||
/bin/rm -f -- "$temporary"
|
||||
}
|
||||
|
||||
_ksre_evidence_target_absent() {
|
||||
/usr/bin/sudo --non-interactive -- /usr/bin/test ! -e "$KSRE_EVIDENCE" &&
|
||||
/usr/bin/sudo --non-interactive -- /usr/bin/test ! -L "$KSRE_EVIDENCE"
|
||||
}
|
||||
|
||||
_ksre_prompt_destroyed() {
|
||||
local answer
|
||||
[[ -t 0 ]] || return 1
|
||||
printf '일회용 환경과 bundle 복사본 파기 후 Type DESTROYED default: ' >&2
|
||||
IFS= read -r answer
|
||||
[[ "$answer" == 'DESTROYED default' ]]
|
||||
}
|
||||
|
||||
_ksre_install_evidence() {
|
||||
local content="$1" local_file root_temporary
|
||||
local_file="$(/usr/bin/mktemp /tmp/platform-k3s-restore-evidence.XXXXXX)" || return 1
|
||||
/usr/bin/chmod 0600 "$local_file" || { /bin/rm -f -- "$local_file"; return 1; }
|
||||
printf '%s\n' "$content" >"$local_file" || { /bin/rm -f -- "$local_file"; return 1; }
|
||||
declare -A verification=()
|
||||
_ksre_parse_external_file evidence "$local_file" verification || { /bin/rm -f -- "$local_file"; return 1; }
|
||||
root_temporary="${KSRE_EVIDENCE}.new.$$"
|
||||
/usr/bin/sudo --non-interactive -- /usr/bin/test ! -e "$KSRE_EVIDENCE" || { /bin/rm -f -- "$local_file"; return 1; }
|
||||
/usr/bin/sudo --non-interactive -- /usr/bin/install -o root -g root -m 0600 -- \
|
||||
"$local_file" "$root_temporary" || { /bin/rm -f -- "$local_file"; return 1; }
|
||||
/usr/bin/sudo --non-interactive -- /usr/bin/cmp --silent -- "$local_file" "$root_temporary" || {
|
||||
/usr/bin/sudo --non-interactive -- /bin/rm -f -- "$root_temporary" >/dev/null 2>&1 || :
|
||||
/bin/rm -f -- "$local_file"; return 1
|
||||
}
|
||||
if ! /usr/bin/sudo --non-interactive -- /bin/ln -- "$root_temporary" "$KSRE_EVIDENCE" 2>/dev/null; then
|
||||
/usr/bin/sudo --non-interactive -- /bin/rm -f -- "$root_temporary" >/dev/null 2>&1 || :
|
||||
/bin/rm -f -- "$local_file"; return 1
|
||||
fi
|
||||
/usr/bin/sudo --non-interactive -- /bin/rm -f -- "$root_temporary" || return 1
|
||||
/bin/rm -f -- "$local_file"
|
||||
}
|
||||
|
||||
_ksre_render_result() {
|
||||
local -n bundle_fields="$1"
|
||||
local tested_at="$2"
|
||||
printf 'schema=platform-k3s-restore-result-v1\nbundle_id=%s\nk3s_version=%s\ndatastore=%s\nstatus_class=enabled_stable\nrotation_stage=reencrypt_finished\nhashes=match\nlocal_integrity=match\napi_ready=pass\nnode_ready=pass\nisolation=pass\nsecret_count_match=pass\ntested_at_utc=%s\n' \
|
||||
"${bundle_fields[bundle_id]}" "${bundle_fields[k3s_version]}" "${bundle_fields[datastore]}" "$tested_at"
|
||||
}
|
||||
|
||||
_ksre_render_evidence() {
|
||||
local -n bundle_fields="$1" result_fields="$2"
|
||||
local recorded_at="$3"
|
||||
printf 'schema=platform-k3s-restore-evidence-v1\nbundle_id=%s\nk3s_version=%s\ndatastore=%s\nrotation_stage=reencrypt_finished\nlocal_integrity=match\nnode_ready=pass\nisolation=pass\ntested_at_utc=%s\nrecorded_at_utc=%s\ndestroyed=confirmed\n' \
|
||||
"${bundle_fields[bundle_id]}" "${bundle_fields[k3s_version]}" "${bundle_fields[datastore]}" \
|
||||
"${result_fields[tested_at_utc]}" "$recorded_at"
|
||||
}
|
||||
|
||||
_ksre_emit_result() {
|
||||
local metadata="$1" output="$2" context connection live tested_at content
|
||||
local version datastore status rotation hashes integrity api node count
|
||||
declare -A bundle=()
|
||||
context="$(_ksre_current_context)" || return 1
|
||||
[[ -n "$context" && "$context" != *$'\n'* ]] || return 1
|
||||
connection="$(_ksre_current_connection "$context")" || return 1
|
||||
_ksre_validate_current_connection "$connection" || return 1
|
||||
_ksre_parse_external_file bundle "$metadata" bundle || return 1
|
||||
[[ "${bundle[phase]}" == post ]] || return 1
|
||||
_ksre_output_target_absent "$output" || return 1
|
||||
live="$(_ksre_live_restore_inventory "$context" "$connection")" || return 1
|
||||
IFS='|' read -r version datastore status rotation hashes integrity api node count <<<"$live"
|
||||
[[ "$version" == "${bundle[k3s_version]}" && "$datastore" == "${bundle[datastore]}" &&
|
||||
"$status" == enabled_stable && "$rotation" == reencrypt_finished && "$hashes" == match &&
|
||||
"$integrity" == match && "$api" == pass && "$node" == pass &&
|
||||
"$count" == "${bundle[secret_count]}" ]] || return 1
|
||||
_ksre_isolation_attested "$context" || return 1
|
||||
_ksre_require_same_connection "$context" "$connection" || return 1
|
||||
_ksre_require_same_context "$context" || return 1
|
||||
tested_at="$(_ksre_now_rfc3339)" || return 1
|
||||
content="$(_ksre_render_result bundle "$tested_at")" || return 1
|
||||
_ksre_write_output "$output" "$content" result
|
||||
}
|
||||
|
||||
_ksre_record() {
|
||||
local metadata="$1" result_file="$2" context recorded_at content
|
||||
declare -A bundle=() result=() authority=() verification=()
|
||||
context="$(_ksre_current_context)" || return 1
|
||||
[[ -n "$context" && "$context" != *$'\n'* ]] || return 1
|
||||
_ksre_parse_external_file bundle "$metadata" bundle || return 1
|
||||
_ksre_parse_external_file result "$result_file" result || return 1
|
||||
[[ "${bundle[phase]}" == post && "${result[bundle_id]}" == "${bundle[bundle_id]}" &&
|
||||
"${result[k3s_version]}" == "${bundle[k3s_version]}" &&
|
||||
"${result[datastore]}" == "${bundle[datastore]}" ]] || return 1
|
||||
_ksre_age_within "${result[tested_at_utc]}" 86400 || return 1
|
||||
_ksre_read_authoritative_bundle authority || return 1
|
||||
_ksre_bundle_equal bundle authority || return 1
|
||||
_ksre_evidence_target_absent || return 1
|
||||
_ksre_prompt_destroyed || return 1
|
||||
_ksre_require_same_context "$context" || return 1
|
||||
recorded_at="$(_ksre_now_rfc3339)" || return 1
|
||||
content="$(_ksre_render_evidence bundle result "$recorded_at")" || return 1
|
||||
_ksre_parse_trusted_content evidence "$content" verification || return 1
|
||||
_ksre_install_evidence "$content"
|
||||
}
|
||||
|
||||
_ksre_check() {
|
||||
local context connection live version datastore status rotation hashes integrity api node count
|
||||
declare -A authority=() evidence=()
|
||||
context="$(_ksre_current_context)" || return 1
|
||||
[[ -n "$context" && "$context" != *$'\n'* ]] || return 1
|
||||
connection="$(_ksre_current_connection "$context")" || return 1
|
||||
_ksre_validate_current_connection "$connection" || return 1
|
||||
_ksre_read_authoritative_bundle authority || return 1
|
||||
_ksre_read_installed_evidence evidence || return 1
|
||||
[[ "${authority[phase]}" == post &&
|
||||
"${evidence[bundle_id]}" == "${authority[bundle_id]}" &&
|
||||
"${evidence[k3s_version]}" == "${authority[k3s_version]}" &&
|
||||
"${evidence[datastore]}" == "${authority[datastore]}" ]] || return 1
|
||||
_ksre_age_within "${evidence[tested_at_utc]}" 2592000 || return 1
|
||||
live="$(_ksre_live_restore_inventory "$context" "$connection")" || return 1
|
||||
IFS='|' read -r version datastore status rotation hashes integrity api node count <<<"$live"
|
||||
[[ "$version" == "${evidence[k3s_version]}" && "$datastore" == "${evidence[datastore]}" &&
|
||||
"$status" == enabled_stable && "$rotation" == reencrypt_finished &&
|
||||
"$hashes" == match && "$integrity" == match && "$api" == pass && "$node" == pass ]] || return 1
|
||||
_ksre_require_same_context "$context"
|
||||
}
|
||||
|
||||
k3s_secret_encryption_restore_evidence_main() {
|
||||
local mode='' metadata='' result_file='' output=''
|
||||
while (( $# > 0 )); do
|
||||
case "$1" in
|
||||
--emit-result|--record|--check)
|
||||
[[ -z "$mode" ]] || { _ksre_usage >&2; return 2; }
|
||||
mode="$1"; shift
|
||||
;;
|
||||
--bundle-metadata)
|
||||
[[ $# -ge 2 && -z "$metadata" ]] || { _ksre_usage >&2; return 2; }
|
||||
metadata="$2"; shift 2
|
||||
;;
|
||||
--result-file)
|
||||
[[ $# -ge 2 && -z "$result_file" ]] || { _ksre_usage >&2; return 2; }
|
||||
result_file="$2"; shift 2
|
||||
;;
|
||||
--output)
|
||||
[[ $# -ge 2 && -z "$output" ]] || { _ksre_usage >&2; return 2; }
|
||||
output="$2"; shift 2
|
||||
;;
|
||||
-h|--help) _ksre_usage; return 0 ;;
|
||||
*) _ksre_usage >&2; return 2 ;;
|
||||
esac
|
||||
done
|
||||
case "$mode" in
|
||||
--emit-result)
|
||||
[[ -n "$metadata" && -n "$output" && -z "$result_file" ]] || { _ksre_usage >&2; return 2; }
|
||||
_ksre_emit_result "$metadata" "$output" || _ksre_fail
|
||||
;;
|
||||
--record)
|
||||
[[ -n "$metadata" && -n "$result_file" && -z "$output" ]] || { _ksre_usage >&2; return 2; }
|
||||
_ksre_record "$metadata" "$result_file" || _ksre_fail
|
||||
;;
|
||||
--check)
|
||||
[[ -z "$metadata" && -z "$result_file" && -z "$output" ]] || { _ksre_usage >&2; return 2; }
|
||||
_ksre_check || _ksre_fail
|
||||
;;
|
||||
*) _ksre_usage >&2; return 2 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||
PATH="$KSRE_SECURE_PATH"
|
||||
export PATH
|
||||
k3s_secret_encryption_restore_evidence_main "$@"
|
||||
fi
|
||||
@@ -0,0 +1,344 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Read-only k3s Secret-encryption inventory. Privileged execution is limited to
|
||||
# bounded invocations of validated, root-owned system binaries; workspace code
|
||||
# is never executed or sourced by sudo.
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly KSE_SECURE_PATH='/usr/sbin:/usr/bin:/sbin:/bin'
|
||||
readonly KSE_EXPECTED_VERSION='v1.36.2+k3s1'
|
||||
readonly KSE_EXPECTED_NODE='donghyeon-system-product-name'
|
||||
readonly KSE_K3S='/usr/local/bin/k3s'
|
||||
readonly KSE_TIMEOUT='/usr/bin/timeout'
|
||||
readonly KSE_SUDO='/usr/bin/sudo'
|
||||
readonly KSE_SYSTEMCTL='/usr/bin/systemctl'
|
||||
readonly KSE_STAT='/usr/bin/stat'
|
||||
readonly KSE_SHA256SUM='/usr/bin/sha256sum'
|
||||
readonly KSE_CAT='/usr/bin/cat'
|
||||
readonly KSE_JQ='/usr/bin/jq'
|
||||
readonly KSE_FIND='/usr/bin/find'
|
||||
readonly KSE_ID='/usr/bin/id'
|
||||
readonly KSE_TEST='/usr/bin/test'
|
||||
if [[ "${BASH_SOURCE[0]}" == */* ]]; then
|
||||
readonly KSE_SCRIPT_DIR="${BASH_SOURCE[0]%/*}"
|
||||
else
|
||||
readonly KSE_SCRIPT_DIR='.'
|
||||
fi
|
||||
readonly KSE_ROOT="$(cd -- "${KSE_SCRIPT_DIR}/../.." && pwd -P)"
|
||||
readonly KSE_LIBRARY_PATH="${KSE_ROOT}/scripts/lib/k3s-secret-encryption.sh"
|
||||
readonly KSE_CONFIG_PATH='/var/lib/rancher/k3s/server/cred/encryption-config.json'
|
||||
readonly KSE_STATE_PATH='/var/lib/rancher/k3s/server/cred/encryption-state.json'
|
||||
|
||||
kse_fail() {
|
||||
printf 'ERROR: %s\n' "$*" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
kse_usage() {
|
||||
cat <<'USAGE'
|
||||
사용법:
|
||||
bash scripts/validate/k3s-secret-encryption.sh [기대 상태] [--verified-output-dir DIR]
|
||||
|
||||
기대 상태(하나만 지정):
|
||||
--expect-disabled
|
||||
--expect-transition-start
|
||||
--expect-enabled
|
||||
--expect-reencrypted
|
||||
--expect-reencrypted-restore # 격리 복구 host의 다른 node 이름 허용
|
||||
USAGE
|
||||
}
|
||||
|
||||
_kse_trusted_executable() {
|
||||
local path="$1" metadata uid mode file_type
|
||||
[[ -f "$path" && ! -L "$path" && -x "$path" ]] || return 1
|
||||
metadata="$($KSE_STAT --format='%u:%a:%F' -- "$path" 2>/dev/null)" || return 1
|
||||
IFS=: read -r uid mode file_type <<<"$metadata"
|
||||
[[ "$uid" == 0 && "$mode" =~ ^[0-7]{3,4}$ && "$file_type" == 'regular file' ]] || return 1
|
||||
(( (8#$mode & 8#022) == 0 ))
|
||||
}
|
||||
|
||||
_kse_validate_runtime_dependencies() {
|
||||
local binary
|
||||
for binary in \
|
||||
"$KSE_TIMEOUT" "$KSE_SUDO" "$KSE_K3S" "$KSE_SYSTEMCTL" \
|
||||
"$KSE_STAT" "$KSE_SHA256SUM" "$KSE_CAT" "$KSE_JQ" \
|
||||
"$KSE_FIND" "$KSE_ID" "$KSE_TEST" \
|
||||
/usr/bin/sed /usr/bin/sort; do
|
||||
_kse_trusted_executable "$binary" || return 1
|
||||
done
|
||||
}
|
||||
|
||||
_kse_privileged_capture() {
|
||||
local binary="$1"
|
||||
shift
|
||||
"$KSE_TIMEOUT" --signal=TERM --kill-after=1s 9s \
|
||||
"$KSE_SUDO" --non-interactive -- "$binary" "$@" 2>/dev/null
|
||||
}
|
||||
|
||||
_kse_validate_sudo_credentials() {
|
||||
"$KSE_TIMEOUT" --signal=TERM --kill-after=1s 9s \
|
||||
"$KSE_SUDO" --non-interactive --validate >/dev/null 2>&1
|
||||
}
|
||||
|
||||
_kse_privileged_quiet() {
|
||||
_kse_privileged_capture "$@" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
_kse_read_version() {
|
||||
local raw version
|
||||
raw="$(_kse_privileged_capture "$KSE_K3S" --version)" || return 1
|
||||
version="$(/usr/bin/sed -n -E 's/^k3s version (v[0-9]+\.[0-9]+\.[0-9]+\+k3s[0-9]+)([[:space:]].*)?$/\1/p' <<<"$raw")"
|
||||
[[ "$version" == "$KSE_EXPECTED_VERSION" ]] || return 1
|
||||
printf '%s\n' "$version"
|
||||
}
|
||||
|
||||
_kse_read_status() {
|
||||
_kse_privileged_capture "$KSE_K3S" secrets-encrypt status --output json
|
||||
}
|
||||
|
||||
_kse_read_nodes() {
|
||||
_kse_privileged_capture "$KSE_K3S" kubectl get nodes -o json
|
||||
}
|
||||
|
||||
_kse_read_systemctl_show() {
|
||||
_kse_privileged_capture "$KSE_SYSTEMCTL" show k3s \
|
||||
--property=ExecStart --property=Environment --property=EnvironmentFiles
|
||||
}
|
||||
|
||||
_kse_api_ready() {
|
||||
_kse_privileged_quiet "$KSE_K3S" kubectl get --raw=/readyz
|
||||
}
|
||||
|
||||
_kse_allowed_root_text_path() {
|
||||
local path="$1"
|
||||
[[ "$path" == /etc/rancher/k3s/config.yaml ||
|
||||
"$path" == /etc/systemd/system/k3s.service.env ||
|
||||
"$path" =~ ^/etc/rancher/k3s/config\.yaml\.d/[A-Za-z0-9._-]+\.yaml$ ]]
|
||||
}
|
||||
|
||||
_kse_read_root_text() {
|
||||
local path="$1" metadata uid mode
|
||||
_kse_allowed_root_text_path "$path" || return 1
|
||||
_kse_privileged_quiet "$KSE_TEST" -f "$path" || return 1
|
||||
_kse_privileged_quiet "$KSE_TEST" ! -L "$path" || return 1
|
||||
metadata="$(_kse_privileged_capture "$KSE_STAT" --format='%u:%a' -- "$path")" || return 1
|
||||
IFS=: read -r uid mode <<<"$metadata"
|
||||
[[ "$uid" == 0 && "$mode" =~ ^[0-7]{3,4}$ ]] || return 1
|
||||
(( (8#$mode & 8#022) == 0 )) || return 1
|
||||
_kse_privileged_capture "$KSE_CAT" -- "$path"
|
||||
}
|
||||
|
||||
_kse_root_path_kind() {
|
||||
local path="$1"
|
||||
if _kse_privileged_quiet "$KSE_TEST" -f "$path"; then
|
||||
printf 'file\n'
|
||||
elif _kse_privileged_quiet "$KSE_TEST" -d "$path"; then
|
||||
printf 'directory\n'
|
||||
else
|
||||
printf 'absent\n'
|
||||
fi
|
||||
}
|
||||
|
||||
_kse_detect_datastore() {
|
||||
local service_show="$1"
|
||||
_k3s_systemctl_show() { printf '%s\n' "$service_show"; }
|
||||
_k3s_systemd_dropin_lines() { return 0; }
|
||||
_k3s_read_file() { _kse_read_root_text "$1"; }
|
||||
_k3s_list_yaml_files() {
|
||||
local config="$1" file listing=''
|
||||
[[ "$config" == /etc/rancher/k3s/config.yaml ]] || return 2
|
||||
if _kse_privileged_quiet "$KSE_TEST" -f "$config"; then printf '%s\n' "$config"; fi
|
||||
if _kse_privileged_quiet "$KSE_TEST" -d "${config}.d"; then
|
||||
listing="$(_kse_privileged_capture "$KSE_FIND" "${config}.d" -maxdepth 1 -type f -name '*.yaml' -print)" || return 2
|
||||
while IFS= read -r file; do
|
||||
[[ -z "$file" ]] || _kse_allowed_root_text_path "$file" || return 2
|
||||
done <<<"$listing"
|
||||
[[ -z "$listing" ]] || printf '%s\n' "$listing" | /usr/bin/sort
|
||||
fi
|
||||
}
|
||||
_k3s_datastore_local_evidence() {
|
||||
local data_dir="$1" state_kind etcd_kind
|
||||
[[ "$data_dir" == /var/lib/rancher/k3s ]] || { printf 'ambiguous\n'; return 0; }
|
||||
state_kind="$(_kse_root_path_kind "${data_dir}/server/db/state.db")" || return 1
|
||||
etcd_kind="$(_kse_root_path_kind "${data_dir}/server/db/etcd")" || return 1
|
||||
if [[ "$state_kind" == file && "$etcd_kind" == directory ]]; then printf 'ambiguous\n';
|
||||
elif [[ "$state_kind" == file ]]; then printf 'sqlite\n';
|
||||
elif [[ "$etcd_kind" == directory ]]; then printf 'embedded-etcd\n';
|
||||
else printf 'none\n'; fi
|
||||
}
|
||||
detect_k3s_datastore
|
||||
}
|
||||
|
||||
_kse_root_metadata() {
|
||||
local path="$1" metadata
|
||||
metadata="$(_kse_privileged_capture "$KSE_STAT" --format='%u:%a:%F' -- "$path")" || return 1
|
||||
[[ "$metadata" == '0:600:regular file' ]] || return 1
|
||||
printf '%s\n' "$metadata"
|
||||
}
|
||||
|
||||
_kse_integrity_from_root_evidence() {
|
||||
local annotation="$1" stage="$2" config_metadata state_metadata
|
||||
local config_hash_line config_hash state_with_sentinel state_value result
|
||||
config_metadata="$(_kse_root_metadata "$KSE_CONFIG_PATH")" || { printf 'mismatch\n'; return 1; }
|
||||
state_metadata="$(_kse_root_metadata "$KSE_STATE_PATH")" || { printf 'mismatch\n'; return 1; }
|
||||
config_hash_line="$(_kse_privileged_capture "$KSE_SHA256SUM" -- "$KSE_CONFIG_PATH")" || { printf 'mismatch\n'; return 1; }
|
||||
config_hash="${config_hash_line%% *}"
|
||||
[[ "$config_hash_line" == "${config_hash} ${KSE_CONFIG_PATH}" && "$config_hash" =~ ^[0-9a-f]{64}$ ]] || { printf 'mismatch\n'; return 1; }
|
||||
state_with_sentinel="$(_kse_privileged_capture "$KSE_CAT" -- "$KSE_STATE_PATH"; printf .)" || { printf 'mismatch\n'; return 1; }
|
||||
state_value="${state_with_sentinel%.}"
|
||||
result="$(verify_local_encryption_config_integrity_evidence \
|
||||
"$config_metadata" "$state_metadata" "$config_hash" "$state_value" \
|
||||
"$annotation" "$stage" 2>/dev/null || true)"
|
||||
[[ "$result" == match ]] || { printf 'mismatch\n'; return 1; }
|
||||
printf 'match\n'
|
||||
}
|
||||
|
||||
_kse_validate_output_dir() {
|
||||
local output_dir="$1" physical metadata uid
|
||||
[[ "$output_dir" =~ ^/tmp/platform-k3s-encryption\.[A-Za-z0-9]+$ ]] || return 1
|
||||
[[ -d /tmp && ! -L /tmp && -d "$output_dir" && ! -L "$output_dir" ]] || return 1
|
||||
[[ "$(cd -P -- /tmp && pwd -P)" == /tmp ]] || return 1
|
||||
physical="$(cd -P -- "$output_dir" && pwd -P)" || return 1
|
||||
[[ "$physical" == "$output_dir" ]] || return 1
|
||||
uid="$($KSE_ID -u)" || return 1
|
||||
metadata="$($KSE_STAT --format='%u:%a:%F' -- "$output_dir" 2>/dev/null)" || return 1
|
||||
[[ "$metadata" == "${uid}:700:directory" ]] || return 1
|
||||
[[ -z "$($KSE_FIND "$output_dir" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]]
|
||||
}
|
||||
|
||||
_kse_write_verified_output() {
|
||||
local output_dir="$1" invoking_uid metadata physical entry
|
||||
local -a entries=()
|
||||
local directory_fd
|
||||
_kse_validate_output_dir "$output_dir" || return 1
|
||||
invoking_uid="$($KSE_ID -u)" || return 1
|
||||
exec {directory_fd}<"$output_dir" || return 1
|
||||
physical="$(cd -P -- "/proc/self/fd/${directory_fd}" && pwd -P)" || { exec {directory_fd}<&-; return 1; }
|
||||
[[ "$physical" == "$output_dir" ]] || { exec {directory_fd}<&-; return 1; }
|
||||
metadata="$($KSE_STAT -L --format='%u:%a:%F' -- "/proc/self/fd/${directory_fd}" 2>/dev/null)" || { exec {directory_fd}<&-; return 1; }
|
||||
[[ "$metadata" == "${invoking_uid}:700:directory" ]] || { exec {directory_fd}<&-; return 1; }
|
||||
(
|
||||
umask 077
|
||||
set -C
|
||||
printf 'version=%s\nserver_count=%s\nnode_name=%s\nnode_uid=%s\nnode_ready=%s\ndatastore=%s\nencryption=%s\nrotation=%s\nserver_hashes=%s\nintegrity=%s\napi_readyz=%s\n' \
|
||||
"$version" "$server_count" "$node_name" "$node_uid" "$node_ready" "$datastore" "$encryption" "$rotation" "$server_hashes" "$integrity" "$api_readyz" \
|
||||
>"/proc/self/fd/${directory_fd}/inventory.env"
|
||||
printf '%s\n' "$status_sha256" >"/proc/self/fd/${directory_fd}/status.sha256"
|
||||
) || { exec {directory_fd}<&-; return 1; }
|
||||
metadata="$($KSE_STAT --format='%u:%a:%F' -- "/proc/self/fd/${directory_fd}/inventory.env" "/proc/self/fd/${directory_fd}/status.sha256" 2>/dev/null)" || { exec {directory_fd}<&-; return 1; }
|
||||
[[ "$metadata" == "${invoking_uid}:600:regular file"$'\n'"${invoking_uid}:600:regular file" ]] || { exec {directory_fd}<&-; return 1; }
|
||||
while IFS= read -r -d '' entry; do entries+=("${entry##*/}"); done < <(
|
||||
"$KSE_FIND" -H "/proc/self/fd/${directory_fd}" -mindepth 1 -maxdepth 1 -print0 2>/dev/null
|
||||
)
|
||||
exec {directory_fd}<&-
|
||||
(( ${#entries[@]} == 2 )) || return 1
|
||||
[[ " ${entries[*]} " == *' inventory.env '* && " ${entries[*]} " == *' status.sha256 '* ]]
|
||||
}
|
||||
|
||||
k3s_secret_encryption_main() {
|
||||
local expectation='' output_dir='' output_dir_seen=false
|
||||
local raw_status canonical_status status_class stage status_hash_line
|
||||
local nodes_json service_show annotations='' annotation='' first_annotation=''
|
||||
local version server_count node_name node_uid node_ready datastore encryption rotation
|
||||
local server_hashes=\mismatch integrity=mismatch api_readyz status_sha256
|
||||
|
||||
PATH="$KSE_SECURE_PATH"
|
||||
export PATH
|
||||
unset K3S_CONFIG_DIR K3S_SYSTEMD_DROPIN_DIR
|
||||
|
||||
while (( $# > 0 )); do
|
||||
case "$1" in
|
||||
--expect-disabled|--expect-transition-start|--expect-enabled|--expect-reencrypted|--expect-reencrypted-restore)
|
||||
[[ -z "$expectation" ]] || { kse_usage >&2; return 2; }
|
||||
expectation="$1"
|
||||
;;
|
||||
--verified-output-dir)
|
||||
(( $# >= 2 )) || { kse_usage >&2; return 2; }
|
||||
! "$output_dir_seen" && [[ -n "$2" ]] || { kse_usage >&2; return 2; }
|
||||
output_dir="$2"; output_dir_seen=true; shift
|
||||
;;
|
||||
--help|-h) kse_usage; return 0 ;;
|
||||
*) kse_usage >&2; return 2 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
_kse_validate_runtime_dependencies || { kse_fail 'trusted runtime dependency validation failed'; return 1; }
|
||||
! "$output_dir_seen" || _kse_validate_output_dir "$output_dir" || { kse_fail 'verified output directory is unsafe'; return 1; }
|
||||
_kse_validate_sudo_credentials || {
|
||||
kse_fail 'noninteractive sudo authorization is unavailable; run sudo -v in the same terminal, then rerun the validator'
|
||||
return 1
|
||||
}
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
source "$KSE_LIBRARY_PATH"
|
||||
version="$(_kse_read_version)" || { kse_fail 'k3s version is not the required production version'; return 1; }
|
||||
raw_status="$(_kse_read_status)" || { kse_fail 'read-only encryption status failed'; return 1; }
|
||||
canonical_status="$($KSE_JQ -ceS . <<<"$raw_status" 2>/dev/null)" || { kse_fail 'encryption status is invalid'; return 1; }
|
||||
status_class="$(classify_encryption_status "$canonical_status")"
|
||||
[[ "$status_class" != invalid ]] || { kse_fail 'encryption status is invalid'; return 1; }
|
||||
status_hash_line="$(printf '%s' "$canonical_status" | "$KSE_SHA256SUM")" || return 1
|
||||
status_sha256="${status_hash_line%% *}"
|
||||
[[ "$status_sha256" =~ ^[0-9a-f]{64}$ ]] || return 1
|
||||
|
||||
encryption=Disabled; rotation=unsafe
|
||||
case "$status_class" in
|
||||
disabled_no_config) rotation=none ;;
|
||||
transition_start) rotation=start ;;
|
||||
enabled_stable)
|
||||
encryption=Enabled
|
||||
stage="$($KSE_JQ -er '.stage' <<<"$canonical_status")" || return 1
|
||||
[[ "$stage" == start || "$stage" == reencrypt_finished ]] || return 1
|
||||
rotation="$stage"
|
||||
;;
|
||||
unsafe_transition|hash_mismatch) encryption=Enabled ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
|
||||
nodes_json="$(_kse_read_nodes)" || { kse_fail 'read-only node inventory failed'; return 1; }
|
||||
server_count="$($KSE_JQ -er '[.items[] | select((.metadata.labels["node-role.kubernetes.io/control-plane"]? != null) or (.metadata.labels["node-role.kubernetes.io/master"]? != null))] | length' <<<"$nodes_json" 2>/dev/null)" || return 1
|
||||
[[ "$server_count" == 1 ]] || { kse_fail 'server count is not exactly one'; return 1; }
|
||||
node_name="$($KSE_JQ -er '[.items[] | select((.metadata.labels["node-role.kubernetes.io/control-plane"]? != null) or (.metadata.labels["node-role.kubernetes.io/master"]? != null))][0].metadata.name' <<<"$nodes_json" 2>/dev/null)" || return 1
|
||||
node_uid="$($KSE_JQ -er '[.items[] | select((.metadata.labels["node-role.kubernetes.io/control-plane"]? != null) or (.metadata.labels["node-role.kubernetes.io/master"]? != null))][0].metadata.uid | if type == "string" and test("^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") then . else error("invalid node uid") end' <<<"$nodes_json" 2>/dev/null)" || { kse_fail 'server node UID is invalid'; return 1; }
|
||||
if [[ "$expectation" != --expect-reencrypted-restore ]]; then
|
||||
[[ "$node_name" == "$KSE_EXPECTED_NODE" ]] || { kse_fail 'server node identity mismatch'; return 1; }
|
||||
fi
|
||||
node_ready="$($KSE_JQ -er '[.items[] | select(.metadata.name == $name)][0].status.conditions | [ .[] | select(.type == "Ready") ] | if length == 1 and .[0].status == "True" then "Ready" else error("not ready") end' --arg name "$node_name" <<<"$nodes_json" 2>/dev/null)" || { kse_fail 'server node is not Ready'; return 1; }
|
||||
|
||||
annotations="$($KSE_JQ -er '[.items[] | select((.metadata.labels["node-role.kubernetes.io/control-plane"]? != null) or (.metadata.labels["node-role.kubernetes.io/master"]? != null)) | .metadata.annotations["k3s.io/encryption-config-hash"]?] | if length > 0 and all(.[]; type == "string" and test("^[a-z_]+-[0-9a-f]{64}$")) then .[] else empty end' <<<"$nodes_json" 2>/dev/null)" || annotations=''
|
||||
if [[ -n "$annotations" ]]; then
|
||||
first_annotation="${annotations%%$'\n'*}"; annotation="$first_annotation"
|
||||
while IFS= read -r annotation; do [[ "$annotation" == "$first_annotation" ]] || first_annotation=''; done <<<"$annotations"
|
||||
[[ -z "$first_annotation" ]] || server_hashes=match
|
||||
fi
|
||||
|
||||
service_show="$(_kse_read_systemctl_show)" || { kse_fail 'read-only systemd inventory failed'; return 1; }
|
||||
datastore="$(_kse_detect_datastore "$service_show")" || datastore=ambiguous
|
||||
case "$datastore" in sqlite|embedded-etcd|external|ambiguous) ;; *) datastore=ambiguous ;; esac
|
||||
|
||||
if [[ "$server_hashes" == match && ( "$status_class" == transition_start || "$status_class" == enabled_stable ) ]]; then
|
||||
integrity="$(_kse_integrity_from_root_evidence "$first_annotation" "$rotation" 2>/dev/null || true)"
|
||||
[[ "$integrity" == match ]] || integrity=mismatch
|
||||
fi
|
||||
if _kse_api_ready; then api_readyz=pass; else api_readyz=fail; fi
|
||||
|
||||
printf 'K3s version: %s\nServer count: %s\nNode: %s %s\nDatastore: %s\nEncryption status: %s\nRotation stage: %s\nServer annotation hashes: %s\nLocal config/state/annotation integrity: %s\nAPI readyz: %s\n' \
|
||||
"$version" "$server_count" "$node_name" "$node_ready" "$datastore" "$encryption" "$rotation" "$server_hashes" "$integrity" "$api_readyz"
|
||||
|
||||
[[ "$api_readyz" == pass ]] || return 1
|
||||
case "$expectation" in
|
||||
'') [[ "$status_class" != unsafe_transition && "$status_class" != hash_mismatch ]] || return 1 ;;
|
||||
--expect-disabled) [[ "$status_class" == disabled_no_config ]] || return 1 ;;
|
||||
--expect-transition-start) [[ "$status_class" == transition_start ]] || return 1 ;;
|
||||
--expect-enabled) [[ "$status_class" == enabled_stable ]] || return 1 ;;
|
||||
--expect-reencrypted|--expect-reencrypted-restore)
|
||||
[[ "$status_class" == enabled_stable && "$rotation" == reencrypt_finished &&
|
||||
"$server_hashes" == match && "$integrity" == match ]] || return 1
|
||||
;;
|
||||
esac
|
||||
! "$output_dir_seen" || _kse_write_verified_output "$output_dir" || { kse_fail 'verified output write failed'; return 1; }
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||
k3s_secret_encryption_main "$@"
|
||||
fi
|
||||
File diff suppressed because it is too large
Load Diff
Executable
+243
@@ -0,0 +1,243 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly EXPECTED_HELM_VERSION="v3.19.4"
|
||||
readonly EXPECTED_KUSTOMIZE_VERSION="v5.8.1"
|
||||
readonly KUBE_VERSION="1.36.2"
|
||||
readonly PGADMIN_SHA="0f409becdc6a811e852f384cb53b3f11cfb652a19d77cb952a8a176071694f4c"
|
||||
readonly AISTOR_SHA="50ffa6a4e014cdc48566b237593baab41d039f103cdc50c1b1ac173b8d8bf71e"
|
||||
readonly IMAGE_SHA="sha256:66a300a7ecdcc1f325af0c430315329bca46cd4a7067227d6899802238167c6e"
|
||||
readonly ROOT="$(cd -- "$(dirname -- "$BASH_SOURCE")/../.." && pwd -P)"
|
||||
readonly PG_CACHE="$ROOT/services/pgadmin/.helm/charts/pgadmin4-helm-9.16.0"
|
||||
readonly AI_CACHE="$ROOT/services/minio-aistor/base/deployment/.helm/charts/aistor-objectstore-1.0.16"
|
||||
readonly NAMES="admin-namespace pgadmin-local-pv coredns-custom aistor-admin-oidc pgadmin"
|
||||
|
||||
fail() {
|
||||
printf 'ERROR: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
사용법:
|
||||
PLATFORM_HELM_BIN=/home/donghyeon/.local/bin/helm \
|
||||
bash scripts/validate/render-admin-services.sh
|
||||
|
||||
적용 스크립트 내부 handoff:
|
||||
bash scripts/validate/render-admin-services.sh \
|
||||
--verified-output-dir /tmp/platform-admin-apply.XXXXXX
|
||||
|
||||
고정한 공식 차트의 SHA-256과 렌더 결과를 검사합니다. 변경은 수행하지 않습니다.
|
||||
USAGE
|
||||
}
|
||||
|
||||
out=""
|
||||
if (( $# == 0 )); then
|
||||
:
|
||||
elif (( $# == 2 )) && [[ "$1" == "--verified-output-dir" ]]; then
|
||||
out="$2"
|
||||
else
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
for cmd in awk find helm install kubectl rg sed sha256sum stat tar wc tr; do
|
||||
if [[ "$cmd" == helm ]] && [[ -v PLATFORM_HELM_BIN ]]; then
|
||||
continue
|
||||
fi
|
||||
command -v "$cmd" >/dev/null 2>&1 || fail "$cmd 명령이 필요합니다"
|
||||
done
|
||||
|
||||
if [[ -v PLATFORM_HELM_BIN ]]; then
|
||||
[[ "$PLATFORM_HELM_BIN" == /* && -x "$PLATFORM_HELM_BIN" ]] || \
|
||||
fail "PLATFORM_HELM_BIN은 실행 가능한 절대 경로여야 합니다"
|
||||
readonly HELM="$PLATFORM_HELM_BIN"
|
||||
else
|
||||
readonly HELM="$(command -v helm)"
|
||||
fi
|
||||
[[ "$("$HELM" version --template '{{.Version}}')" == "$EXPECTED_HELM_VERSION" ]] || \
|
||||
fail "Helm은 정확히 $EXPECTED_HELM_VERSION이어야 합니다"
|
||||
kv="$(kubectl version --client --output=yaml | sed -n 's/^kustomizeVersion: //p')"
|
||||
[[ "$kv" == "$EXPECTED_KUSTOMIZE_VERSION" ]] || \
|
||||
fail "Kustomize는 정확히 $EXPECTED_KUSTOMIZE_VERSION이어야 합니다"
|
||||
|
||||
if [[ -n "$out" ]]; then
|
||||
[[ "$out" == /tmp/platform-admin-apply.* ]] || fail "handoff 경로 형식이 잘못됐습니다"
|
||||
[[ -d "$out" && ! -L "$out" && -O "$out" ]] || fail "handoff 경로가 안전하지 않습니다"
|
||||
[[ "$(stat --format='%a' "$out")" == 700 ]] || fail "handoff 권한은 0700이어야 합니다"
|
||||
[[ -z "$(find "$out" -mindepth 1 -maxdepth 1 -print -quit)" ]] || fail "handoff 경로가 비어 있지 않습니다"
|
||||
fi
|
||||
|
||||
umask 077
|
||||
work="$(mktemp -d /tmp/platform-admin-render.XXXXXX)"
|
||||
pg_created=false
|
||||
ai_created=false
|
||||
|
||||
cleanup() {
|
||||
rc=$?
|
||||
set +e
|
||||
[[ "$pg_created" == true ]] && rm -rf -- "$PG_CACHE"
|
||||
[[ "$ai_created" == true ]] && rm -rf -- "$AI_CACHE"
|
||||
rmdir -- "$ROOT/services/pgadmin/.helm/charts" "$ROOT/services/pgadmin/.helm" 2>/dev/null || true
|
||||
rmdir -- "$ROOT/services/minio-aistor/base/deployment/.helm/charts" \
|
||||
"$ROOT/services/minio-aistor/base/deployment/.helm" 2>/dev/null || true
|
||||
case "$work" in
|
||||
/tmp/platform-admin-render.*) rm -rf -- "$work" ;;
|
||||
esac
|
||||
exit "$rc"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
prepare() {
|
||||
label="$1"
|
||||
ref="$2"
|
||||
name="$3"
|
||||
version="$4"
|
||||
expected="$5"
|
||||
cache="$6"
|
||||
archive="$work/$name-$version.tgz"
|
||||
|
||||
[[ ! -e "$cache" && ! -L "$cache" ]] || fail "생성 캐시가 이미 있습니다: $cache"
|
||||
if [[ "$ref" == oci://* ]]; then
|
||||
"$HELM" pull "$ref" --version "$version" --destination "$work"
|
||||
else
|
||||
"$HELM" pull "$name" --repo "$ref" --version "$version" --destination "$work"
|
||||
fi
|
||||
[[ -f "$archive" ]] || fail "$label archive가 없습니다"
|
||||
actual="$(sha256sum "$archive" | awk '{print $1}')"
|
||||
[[ "$actual" == "$expected" ]] || fail "$label SHA-256 불일치: $actual"
|
||||
mkdir -p -- "$cache"
|
||||
[[ "$cache" == "$PG_CACHE" ]] && pg_created=true
|
||||
[[ "$cache" == "$AI_CACHE" ]] && ai_created=true
|
||||
tar -xzf "$archive" -C "$cache"
|
||||
[[ -f "$cache/$name/Chart.yaml" ]] || fail "$label Chart.yaml이 없습니다"
|
||||
printf '검증 %-18s SHA-256 %s\n' "$label" "$actual"
|
||||
}
|
||||
|
||||
plain() {
|
||||
kubectl kustomize "$ROOT/$2" >"$work/$1.yaml"
|
||||
[[ -s "$work/$1.yaml" ]] || fail "$1 렌더 결과가 비었습니다"
|
||||
}
|
||||
|
||||
with_helm() {
|
||||
kubectl kustomize --enable-helm --helm-command "$HELM" \
|
||||
--helm-kube-version "$KUBE_VERSION" "$ROOT/$2" >"$work/$1.yaml"
|
||||
[[ -s "$work/$1.yaml" ]] || fail "$1 렌더 결과가 비었습니다"
|
||||
}
|
||||
|
||||
cd -- "$ROOT"
|
||||
if rg -n --glob '*.yaml' --glob '*.yml' --glob '!**/.helm/**' \
|
||||
'^[[:space:]]*kind:[[:space:]]*Secret[[:space:]]*$' infrastructure services bootstrap; then
|
||||
fail "Git 관리 대상에서 Secret manifest를 발견했습니다"
|
||||
fi
|
||||
if rg -n '^[[:space:]]*hostAliases:' services/gitea/profiles/oidc; then
|
||||
fail "Gitea hostAliases가 남아 있습니다"
|
||||
fi
|
||||
|
||||
prepare pgadmin oci://docker.io/dpage/pgadmin4-helm pgadmin4-helm 9.16.0 "$PGADMIN_SHA" "$PG_CACHE"
|
||||
prepare aistor-objectstore https://helm.min.io/ aistor-objectstore 1.0.16 "$AISTOR_SHA" "$AI_CACHE"
|
||||
|
||||
plain admin-namespace infrastructure/namespaces/admin-tools
|
||||
plain pgadmin-local-pv infrastructure/storage/pgadmin-local-pv
|
||||
plain coredns-custom infrastructure/networking/private-dns/kubernetes
|
||||
with_helm aistor-admin-oidc services/minio-aistor/profiles/admin-oidc
|
||||
with_helm pgadmin services/pgadmin
|
||||
|
||||
if rg -n '^[[:space:]]*kind:[[:space:]]*Secret[[:space:]]*$' "$work"/*.yaml; then
|
||||
fail "렌더 결과에 Secret 리소스가 있습니다"
|
||||
fi
|
||||
[[ "$(rg -c -F "image: docker.io/dpage/pgadmin4@$IMAGE_SHA" "$work/pgadmin.yaml")" == 3 ]] || \
|
||||
fail "pgAdmin main/init 이미지 세 개의 digest 고정이 깨졌습니다"
|
||||
rg -q -F 'type: Recreate' "$work/pgadmin.yaml" || fail "pgAdmin Recreate 전략이 없습니다"
|
||||
rg -q -F 'storageClassName: ssd-local-pgadmin-retain' "$work/pgadmin.yaml" || fail "pgAdmin StorageClass가 다릅니다"
|
||||
[[ "$(rg -c -F 'value: db-admin.learn.hyeonworks.com' "$work/pgadmin.yaml")" == 3 ]] || \
|
||||
fail "pgAdmin 세 probe의 Host header가 고정되지 않았습니다"
|
||||
rg -q -F 'host: db-admin.learn.hyeonworks.com' "$work/pgadmin.yaml" || fail "pgAdmin Ingress가 없습니다"
|
||||
[[ "$(rg -c -F 'ingressClassName: traefik' "$work/pgadmin.yaml")" == 1 ]] || \
|
||||
fail "pgAdmin IngressClass가 traefik으로 고정되지 않았습니다"
|
||||
if rg -q -F 'kubernetes.io/ingress.class' "$work/pgadmin.yaml"; then
|
||||
fail "pgAdmin Ingress에 레거시 ingress.class annotation이 남아 있습니다"
|
||||
fi
|
||||
rg -q -F 'namespace: platform-data' "$work/pgadmin.yaml" || fail "PostgreSQL 정책 namespace가 다릅니다"
|
||||
bad_pgadmin_namespaces="$(
|
||||
awk '
|
||||
function verify() {
|
||||
if (pgadmin_instance && namespace != "platform-admin") {
|
||||
printf "%s/%s=%s\n", kind, name, namespace == "" ? "missing" : namespace
|
||||
}
|
||||
}
|
||||
function reset() {
|
||||
kind=""
|
||||
name=""
|
||||
namespace=""
|
||||
in_metadata=0
|
||||
in_labels=0
|
||||
pgadmin_instance=0
|
||||
}
|
||||
/^---$/ {
|
||||
verify()
|
||||
reset()
|
||||
next
|
||||
}
|
||||
/^kind:/ {
|
||||
kind=$2
|
||||
next
|
||||
}
|
||||
/^metadata:$/ {
|
||||
in_metadata=1
|
||||
in_labels=0
|
||||
next
|
||||
}
|
||||
in_metadata && /^ name:/ && name == "" {
|
||||
name=$2
|
||||
next
|
||||
}
|
||||
in_metadata && /^ namespace:/ && namespace == "" {
|
||||
namespace=$2
|
||||
next
|
||||
}
|
||||
in_metadata && /^ labels:$/ {
|
||||
in_labels=1
|
||||
next
|
||||
}
|
||||
in_labels && /^ app.kubernetes.io\/instance: pgadmin$/ {
|
||||
pgadmin_instance=1
|
||||
next
|
||||
}
|
||||
in_metadata && /^[^ ]/ {
|
||||
in_metadata=0
|
||||
in_labels=0
|
||||
}
|
||||
END {
|
||||
verify()
|
||||
}
|
||||
' "$work/pgadmin.yaml"
|
||||
)"
|
||||
[[ -z "$bad_pgadmin_namespaces" ]] || \
|
||||
fail "pgAdmin instance 리소스 namespace 오류: $bad_pgadmin_namespaces"
|
||||
rg -q -F 'host: storage-admin.learn.hyeonworks.com' "$work/aistor-admin-oidc.yaml" || fail "AIStor Console Ingress가 없습니다"
|
||||
rg -q -F 'name: MINIO_IDENTITY_OPENID_CLIENT_SECRET' "$work/aistor-admin-oidc.yaml" || fail "AIStor OIDC Secret 참조가 없습니다"
|
||||
if rg -q -F 'name: MINIO_IDENTITY_OPENID_VENDOR' "$work/aistor-admin-oidc.yaml"; then
|
||||
fail "claim-only OIDC profile에 Keycloak Admin API vendor 설정이 있습니다"
|
||||
fi
|
||||
if rg -q -F 'host: s3.' "$work/aistor-admin-oidc.yaml"; then
|
||||
fail "S3 API Ingress가 생성됐습니다"
|
||||
fi
|
||||
|
||||
for name in $NAMES; do
|
||||
bytes="$(wc -c <"$work/$name.yaml" | tr -d '[:space:]')"
|
||||
sha="$(sha256sum "$work/$name.yaml" | awk '{print $1}')"
|
||||
printf '렌더 %-20s %8s bytes SHA-256 %s\n' "$name" "$bytes" "$sha"
|
||||
if [[ -n "$out" ]]; then
|
||||
install -m 0600 "$work/$name.yaml" "$out/$name.yaml"
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -n "$out" ]]; then
|
||||
count="$(find "$out" -mindepth 1 -maxdepth 1 -type f | wc -l | tr -d '[:space:]')"
|
||||
[[ "$count" == 5 ]] || fail "handoff 파일 수가 다릅니다"
|
||||
fi
|
||||
printf 'ADMIN SERVICES STATIC RENDER PASS\n'
|
||||
@@ -0,0 +1,825 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Static renderer and sourceable assertion library for the observability access
|
||||
# layer. It intentionally reuses the pinned core renderer for Helm/cache and
|
||||
# image/credential boundaries instead of maintaining a second downloader.
|
||||
|
||||
set -o pipefail
|
||||
|
||||
readonly ACCESS_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd -P)"
|
||||
# shellcheck source=render-observability-core.sh
|
||||
source "$ACCESS_ROOT/scripts/validate/render-observability-core.sh"
|
||||
|
||||
access_output_names() {
|
||||
case "$1" in
|
||||
grafana) printf '%s\n' grafana.yaml ;;
|
||||
blackbox) printf '%s\n' blackbox.yaml ;;
|
||||
targets) printf '%s\n' targets.yaml ;;
|
||||
rules-alerts) printf '%s\n' dashboards.yaml rules.yaml alertmanager.yaml ;;
|
||||
complete) printf '%s\n' grafana.yaml blackbox.yaml targets.yaml dashboards.yaml rules.yaml alertmanager.yaml private-dns.yaml ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
access_component_requires_inventory() {
|
||||
[[ "$1" == rules-alerts || "$1" == complete ]]
|
||||
}
|
||||
|
||||
access_secure_render_context() {
|
||||
umask 077
|
||||
}
|
||||
|
||||
_access_assert() {
|
||||
local mode=$1
|
||||
shift
|
||||
python3 - "$mode" "$@" <<'PY'
|
||||
import ipaddress
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
import yaml
|
||||
|
||||
class AccessSafeLoader(yaml.SafeLoader):
|
||||
pass
|
||||
|
||||
|
||||
AccessSafeLoader.add_constructor(
|
||||
"tag:yaml.org,2002:value",
|
||||
lambda loader, node: loader.construct_scalar(node),
|
||||
)
|
||||
|
||||
mode = sys.argv[1]
|
||||
paths = [pathlib.Path(value) for value in sys.argv[2:]]
|
||||
|
||||
def reject(message):
|
||||
print(f"REJECT: {message}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
def load(path):
|
||||
with path.open(encoding="utf-8") as stream:
|
||||
return [item for item in yaml.load_all(stream, Loader=AccessSafeLoader) if item is not None]
|
||||
|
||||
def one(items, kind, name, namespace=None):
|
||||
matches = [
|
||||
item for item in items
|
||||
if item.get("kind") == kind
|
||||
and (item.get("metadata") or {}).get("name") == name
|
||||
and (namespace is None or (item.get("metadata") or {}).get("namespace") == namespace)
|
||||
]
|
||||
if len(matches) != 1:
|
||||
reject(f"expected one {kind}/{name}, found {len(matches)}")
|
||||
return matches[0]
|
||||
|
||||
def labels(item):
|
||||
return (item.get("metadata") or {}).get("labels") or {}
|
||||
|
||||
def exact_policy_specs(items, expected):
|
||||
policies = {
|
||||
(item.get("metadata") or {}).get("name"): item.get("spec") or {}
|
||||
for item in items if item.get("kind") == "NetworkPolicy"
|
||||
}
|
||||
for name, spec in expected.items():
|
||||
if policies.get(name) != spec:
|
||||
reject(f"NetworkPolicy spec is not exact: {name}")
|
||||
|
||||
def selector_matches(selector, pod_labels):
|
||||
selector = selector or {}
|
||||
for key, value in (selector.get("matchLabels") or {}).items():
|
||||
if pod_labels.get(key) != value:
|
||||
return False
|
||||
for expression in selector.get("matchExpressions") or []:
|
||||
key = expression.get("key")
|
||||
operator = expression.get("operator")
|
||||
values = expression.get("values") or []
|
||||
value = pod_labels.get(key)
|
||||
if operator == "In" and value not in values:
|
||||
return False
|
||||
if operator == "NotIn" and value in values:
|
||||
return False
|
||||
if operator == "Exists" and value is None:
|
||||
return False
|
||||
if operator == "DoesNotExist" and value is not None:
|
||||
return False
|
||||
if operator not in {"In", "NotIn", "Exists", "DoesNotExist"}:
|
||||
reject("NetworkPolicy selector operator is unsupported")
|
||||
return True
|
||||
|
||||
def exact_selecting_policy_names(items, namespace, pod_labels, expected):
|
||||
selected = {
|
||||
(item.get("metadata") or {}).get("name")
|
||||
for item in items
|
||||
if item.get("kind") == "NetworkPolicy"
|
||||
and (item.get("metadata") or {}).get("namespace") == namespace
|
||||
and selector_matches((item.get("spec") or {}).get("podSelector") or {}, pod_labels)
|
||||
}
|
||||
if selected != set(expected):
|
||||
reject("NetworkPolicy selecting set is not exact")
|
||||
|
||||
if mode == "grafana":
|
||||
items, policies = load(paths[0]), load(paths[1])
|
||||
service = one(items, "Service", "grafana", "observability")
|
||||
if (service.get("spec") or {}).get("type") != "ClusterIP":
|
||||
reject("Grafana Service is not ClusterIP")
|
||||
ingress = one(items, "Ingress", "grafana", "observability")
|
||||
spec = ingress.get("spec") or {}
|
||||
rules = spec.get("rules") or []
|
||||
if spec.get("ingressClassName") != "traefik" or spec.get("tls") not in (None, []):
|
||||
reject("Grafana Ingress boundary is not exact")
|
||||
if len(rules) != 1 or rules[0].get("host") != "grafana.learn.hyeonworks.com":
|
||||
reject("Grafana host is not exact")
|
||||
deployment = one(items, "Deployment", "grafana", "observability")
|
||||
pod = (((deployment.get("spec") or {}).get("template") or {}).get("spec") or {})
|
||||
if pod.get("automountServiceAccountToken") is not False:
|
||||
reject("Grafana Pod token automount is not disabled")
|
||||
containers = {container.get("name"): container for container in pod.get("containers") or []}
|
||||
if set(containers) != {"grafana", "grafana-sc-dashboard"}:
|
||||
reject("Grafana container set is not exact")
|
||||
token_volume = "dashboard-sidecar-api-access"
|
||||
main_mounts = {mount.get("name") for mount in containers["grafana"].get("volumeMounts") or []}
|
||||
side_mounts = {mount.get("name") for mount in containers["grafana-sc-dashboard"].get("volumeMounts") or []}
|
||||
if token_volume in main_mounts or token_volume not in side_mounts:
|
||||
reject("dashboard API token mount is not sidecar-only")
|
||||
volume = next((volume for volume in pod.get("volumes") or [] if volume.get("name") == token_volume), None)
|
||||
sources = (((volume or {}).get("projected") or {}).get("sources") or [])
|
||||
if len(sources) != 3 or not any("serviceAccountToken" in source for source in sources) \
|
||||
or not any("configMap" in source for source in sources) \
|
||||
or not any("downwardAPI" in source for source in sources):
|
||||
reject("sidecar projected API volume is not exact")
|
||||
one(items, "Role", "grafana-dashboard-sidecar", "observability")
|
||||
if any(item.get("kind") == "ClusterRole" for item in items):
|
||||
reject("cluster-wide Grafana RBAC is forbidden")
|
||||
config = one(items, "ConfigMap", "grafana", "observability")
|
||||
ini = ((config.get("data") or {}).get("grafana.ini") or "")
|
||||
required = (
|
||||
"allowed_groups = /platform-observability-admins /platform-observability-viewers",
|
||||
"role_attribute_strict = true",
|
||||
"allow_assign_grafana_admin = false",
|
||||
"&& 'Admin' || contains(groups[*], '/platform-observability-viewers') && 'Viewer' || null",
|
||||
)
|
||||
if not all(token in ini for token in required) or "&& 'Editor'" in ini or "|| 'Viewer'" in ini:
|
||||
reject("Grafana OIDC role mapping is not fail-closed")
|
||||
exact_policy_specs(policies, {
|
||||
"observability-default-deny": {
|
||||
"podSelector": {}, "policyTypes": ["Ingress", "Egress"],
|
||||
},
|
||||
"observability-allow-grafana-ingress": {
|
||||
"podSelector": {"matchLabels": {
|
||||
"app.kubernetes.io/instance": "grafana", "app.kubernetes.io/name": "grafana",
|
||||
}},
|
||||
"policyTypes": ["Ingress"],
|
||||
"ingress": [
|
||||
{"from": [{
|
||||
"namespaceSelector": {"matchLabels": {"kubernetes.io/metadata.name": "kube-system"}},
|
||||
"podSelector": {"matchLabels": {
|
||||
"app.kubernetes.io/instance": "traefik-kube-system",
|
||||
"app.kubernetes.io/name": "traefik",
|
||||
}},
|
||||
}], "ports": [{"protocol": "TCP", "port": 3000}]},
|
||||
{"from": [{"podSelector": {"matchLabels": {
|
||||
"app.kubernetes.io/name": "prometheus",
|
||||
}}}], "ports": [{"protocol": "TCP", "port": 3000}]},
|
||||
{"from": [{"podSelector": {"matchLabels": {
|
||||
"app.kubernetes.io/instance": "blackbox-exporter",
|
||||
"app.kubernetes.io/name": "prometheus-blackbox-exporter",
|
||||
}}}], "ports": [{"protocol": "TCP", "port": 3000}]},
|
||||
],
|
||||
},
|
||||
"observability-allow-grafana-datasources": {
|
||||
"podSelector": {"matchLabels": {
|
||||
"app.kubernetes.io/instance": "grafana", "app.kubernetes.io/name": "grafana",
|
||||
}},
|
||||
"policyTypes": ["Egress"],
|
||||
"egress": [
|
||||
{"to": [{"podSelector": {"matchLabels": {"app.kubernetes.io/name": "prometheus"}}}],
|
||||
"ports": [{"protocol": "TCP", "port": 9090}]},
|
||||
{"to": [{"podSelector": {"matchLabels": {"app.kubernetes.io/name": "loki"}}}],
|
||||
"ports": [{"protocol": "TCP", "port": 3100}]},
|
||||
{"to": [{"podSelector": {"matchLabels": {"app.kubernetes.io/name": "tempo"}}}],
|
||||
"ports": [{"protocol": "TCP", "port": 3200}]},
|
||||
{"to": [{"ipBlock": {"cidr": "192.168.0.107/32"}}],
|
||||
"ports": [{"protocol": "TCP", "port": 443}]},
|
||||
],
|
||||
},
|
||||
"observability-allow-grafana-dashboard-api": {
|
||||
"podSelector": {"matchLabels": {
|
||||
"app.kubernetes.io/instance": "grafana", "app.kubernetes.io/name": "grafana",
|
||||
}},
|
||||
"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}]},
|
||||
],
|
||||
},
|
||||
})
|
||||
exact_selecting_policy_names(policies, "observability", {
|
||||
"app.kubernetes.io/instance": "grafana", "app.kubernetes.io/name": "grafana",
|
||||
}, {
|
||||
"observability-default-deny", "observability-allow-dns",
|
||||
"observability-allow-grafana-ingress", "observability-allow-grafana-datasources",
|
||||
"observability-allow-grafana-dashboard-api",
|
||||
})
|
||||
|
||||
elif mode == "blackbox":
|
||||
items = load(paths[0])
|
||||
service = one(items, "Service", "blackbox-exporter", "observability")
|
||||
if (service.get("spec") or {}).get("type") != "ClusterIP":
|
||||
reject("Blackbox Service is public")
|
||||
deployment = one(items, "Deployment", "blackbox-exporter", "observability")
|
||||
pod = (((deployment.get("spec") or {}).get("template") or {}).get("spec") or {})
|
||||
if pod.get("automountServiceAccountToken") is not False or len(pod.get("containers") or []) != 1:
|
||||
reject("Blackbox Pod attack surface is not exact")
|
||||
expected = {
|
||||
"platform-public-edge": {
|
||||
"https://git.learn.hyeonworks.com/api/healthz",
|
||||
"https://id.learn.hyeonworks.com/realms/hyeonworks/.well-known/openid-configuration",
|
||||
},
|
||||
"platform-private-edge": {
|
||||
"https://grafana.learn.hyeonworks.com/", "https://storage-admin.learn.hyeonworks.com/",
|
||||
"https://db-admin.learn.hyeonworks.com/",
|
||||
},
|
||||
"platform-private-internal": {
|
||||
"http://grafana.observability.svc.cluster.local/api/health",
|
||||
"http://pgadmin.platform-admin.svc.cluster.local/misc/ping",
|
||||
"http://minio-aistor-console.object-storage.svc.cluster.local:9090/",
|
||||
},
|
||||
}
|
||||
for name, targets in expected.items():
|
||||
probe = one(items, "Probe", name, "observability")
|
||||
actual = set((((probe.get("spec") or {}).get("targets") or {}).get("staticConfig") or {}).get("static") or [])
|
||||
if actual != targets:
|
||||
reject(f"Blackbox target set drifted: {name}")
|
||||
exact_policy_specs(items, {
|
||||
"observability-allow-prometheus-to-blackbox": {
|
||||
"podSelector": {"matchLabels": {
|
||||
"app.kubernetes.io/instance": "blackbox-exporter",
|
||||
"app.kubernetes.io/name": "prometheus-blackbox-exporter",
|
||||
}},
|
||||
"policyTypes": ["Ingress"],
|
||||
"ingress": [{
|
||||
"from": [{"podSelector": {"matchLabels": {
|
||||
"app.kubernetes.io/instance": "observability-core-kube-pr-prometheus",
|
||||
"app.kubernetes.io/name": "prometheus",
|
||||
}}}],
|
||||
"ports": [{"protocol": "TCP", "port": 9115}],
|
||||
}],
|
||||
},
|
||||
"observability-allow-blackbox-egress": {
|
||||
"podSelector": {"matchLabels": {
|
||||
"app.kubernetes.io/instance": "blackbox-exporter",
|
||||
"app.kubernetes.io/name": "prometheus-blackbox-exporter",
|
||||
}},
|
||||
"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}]},
|
||||
{"to": [{"ipBlock": {"cidr": "192.168.0.107/32"}}],
|
||||
"ports": [{"protocol": "TCP", "port": 443}]},
|
||||
{"to": [{"podSelector": {"matchLabels": {
|
||||
"app.kubernetes.io/instance": "grafana", "app.kubernetes.io/name": "grafana",
|
||||
}}}], "ports": [{"protocol": "TCP", "port": 3000}]},
|
||||
{"to": [{
|
||||
"namespaceSelector": {"matchLabels": {"kubernetes.io/metadata.name": "platform-admin"}},
|
||||
"podSelector": {"matchLabels": {
|
||||
"app.kubernetes.io/instance": "pgadmin", "app.kubernetes.io/name": "pgadmin4",
|
||||
}},
|
||||
}], "ports": [{"protocol": "TCP", "port": 5050}]},
|
||||
{"to": [{
|
||||
"namespaceSelector": {"matchLabels": {"kubernetes.io/metadata.name": "object-storage"}},
|
||||
"podSelector": {"matchLabels": {"aistor.min.io/objectStore": "minio-aistor"}},
|
||||
}], "ports": [{"protocol": "TCP", "port": 9090}]},
|
||||
],
|
||||
},
|
||||
})
|
||||
if {
|
||||
(item.get("metadata") or {}).get("name")
|
||||
for item in items if item.get("kind") == "NetworkPolicy"
|
||||
} != {"observability-allow-prometheus-to-blackbox", "observability-allow-blackbox-egress"}:
|
||||
reject("Blackbox NetworkPolicy name set is not exact")
|
||||
|
||||
elif mode == "targets":
|
||||
items = load(paths[0])
|
||||
monitors = [(item.get("kind"), (item.get("metadata") or {}).get("namespace"), (item.get("metadata") or {}).get("name"))
|
||||
for item in items if item.get("kind") in {"ServiceMonitor", "PodMonitor"}]
|
||||
if sorted(monitors) != sorted([
|
||||
("PodMonitor", "platform-data", "platform-postgres"),
|
||||
("ServiceMonitor", "object-storage", "aistor-bucket-usage"),
|
||||
]):
|
||||
reject("manual target monitor set is not exact")
|
||||
exact_policy_specs(items, {
|
||||
"traefik-preserve-ingress-and-allow-prometheus-metrics": {
|
||||
"podSelector": {"matchLabels": {
|
||||
"app.kubernetes.io/instance": "traefik-kube-system", "app.kubernetes.io/name": "traefik",
|
||||
}},
|
||||
"policyTypes": ["Ingress"],
|
||||
"ingress": [
|
||||
{"ports": [
|
||||
{"protocol": "TCP", "port": 8000}, {"protocol": "TCP", "port": 8443},
|
||||
{"protocol": "TCP", "port": 8080},
|
||||
]},
|
||||
{"from": [{
|
||||
"namespaceSelector": {"matchLabels": {"kubernetes.io/metadata.name": "observability"}},
|
||||
"podSelector": {"matchLabels": {
|
||||
"app.kubernetes.io/instance": "observability-core-kube-pr-prometheus",
|
||||
"app.kubernetes.io/name": "prometheus",
|
||||
}},
|
||||
}], "ports": [{"protocol": "TCP", "port": 9100}]},
|
||||
],
|
||||
},
|
||||
"observability-allow-prometheus-platform-targets": {
|
||||
"podSelector": {"matchLabels": {
|
||||
"app.kubernetes.io/instance": "observability-core-kube-pr-prometheus",
|
||||
"app.kubernetes.io/name": "prometheus",
|
||||
}},
|
||||
"policyTypes": ["Egress"],
|
||||
"egress": [
|
||||
{"to": [{
|
||||
"namespaceSelector": {"matchLabels": {"kubernetes.io/metadata.name": "gitea"}},
|
||||
"podSelector": {"matchLabels": {
|
||||
"app.kubernetes.io/instance": "gitea", "app.kubernetes.io/name": "gitea",
|
||||
}},
|
||||
}], "ports": [{"protocol": "TCP", "port": 3000}]},
|
||||
{"to": [{
|
||||
"namespaceSelector": {"matchLabels": {"kubernetes.io/metadata.name": "keycloak"}},
|
||||
"podSelector": {"matchLabels": {
|
||||
"app": "keycloak", "app.kubernetes.io/instance": "keycloak",
|
||||
"app.kubernetes.io/managed-by": "keycloak-operator",
|
||||
}},
|
||||
}], "ports": [{"protocol": "TCP", "port": 9000}]},
|
||||
{"to": [{
|
||||
"namespaceSelector": {"matchLabels": {"kubernetes.io/metadata.name": "platform-data"}},
|
||||
"podSelector": {"matchLabels": {"cnpg.io/cluster": "platform-postgres"}},
|
||||
}], "ports": [{"protocol": "TCP", "port": 9187}]},
|
||||
{"to": [{
|
||||
"namespaceSelector": {"matchLabels": {"kubernetes.io/metadata.name": "object-storage"}},
|
||||
"podSelector": {"matchLabels": {"aistor.min.io/objectStore": "minio-aistor"}},
|
||||
}], "ports": [{"protocol": "TCP", "port": 9000}]},
|
||||
{"to": [{
|
||||
"namespaceSelector": {"matchLabels": {"kubernetes.io/metadata.name": "kube-system"}},
|
||||
"podSelector": {"matchLabels": {
|
||||
"app.kubernetes.io/instance": "traefik-kube-system", "app.kubernetes.io/name": "traefik",
|
||||
}},
|
||||
}], "ports": [{"protocol": "TCP", "port": 9100}]},
|
||||
],
|
||||
},
|
||||
})
|
||||
if {
|
||||
(item.get("metadata") or {}).get("name")
|
||||
for item in items if item.get("kind") == "NetworkPolicy"
|
||||
} != {
|
||||
"traefik-preserve-ingress-and-allow-prometheus-metrics",
|
||||
"observability-allow-prometheus-platform-targets",
|
||||
}:
|
||||
reject("target NetworkPolicy name set is not exact")
|
||||
|
||||
elif mode == "rules-alerts":
|
||||
dashboards, rules, alertmanager = map(load, paths)
|
||||
dashboard_names = sorted((item.get("metadata") or {}).get("name") for item in dashboards)
|
||||
expected_dashboards = sorted([
|
||||
"grafana-dashboard-kubernetes-node", "grafana-dashboard-workload-health",
|
||||
"grafana-dashboard-platform-services", "grafana-dashboard-observability-backends",
|
||||
"grafana-dashboard-https-endpoints",
|
||||
])
|
||||
if dashboard_names != expected_dashboards or any(labels(item).get("grafana_dashboard") != "1" for item in dashboards):
|
||||
reject("dashboard ConfigMap set is not exact")
|
||||
rule_names = sorted((item.get("metadata") or {}).get("name") for item in rules)
|
||||
if rule_names != sorted(["platform-aistor-storage-quota", "platform-certificate-probes", "platform-observability-core", "platform-verified-services"]):
|
||||
reject("PrometheusRule set is not exact")
|
||||
am = one(alertmanager, "Alertmanager", "observability-core-kube-pr-alertmanager", "observability")
|
||||
if (((am.get("spec") or {}).get("alertmanagerConfiguration") or {}).get("name")) != "platform-alertmanager":
|
||||
reject("global AlertmanagerConfig reference is absent")
|
||||
config = one(alertmanager, "AlertmanagerConfig", "platform-alertmanager", "observability")
|
||||
receivers = (config.get("spec") or {}).get("receivers") or []
|
||||
slack_receiver = next((receiver for receiver in receivers if receiver.get("name") == "platform-slack"), {})
|
||||
slack = ((slack_receiver.get("slackConfigs") or [{}])[0])
|
||||
api_url = slack.get("apiURL") or {}
|
||||
if api_url != {"name": "alertmanager-slack-webhook", "key": "url"} or slack.get("sendResolved") is not True:
|
||||
reject("Slack Secret selector or resolved route is not exact")
|
||||
one(alertmanager, "NetworkPolicy", "observability-allow-alertmanager-public-https", "observability")
|
||||
exact_policy_specs(alertmanager, {
|
||||
"observability-allow-alertmanager-public-https": {
|
||||
"podSelector": {"matchLabels": {
|
||||
"app.kubernetes.io/instance": "observability-core-kube-pr-alertmanager",
|
||||
"app.kubernetes.io/name": "alertmanager",
|
||||
}},
|
||||
"policyTypes": ["Egress"],
|
||||
"egress": [{
|
||||
"to": [{"ipBlock": {
|
||||
"cidr": "0.0.0.0/0",
|
||||
"except": ["10.0.0.0/8", "100.64.0.0/10", "172.16.0.0/12", "192.168.0.0/16"],
|
||||
}}],
|
||||
"ports": [{"protocol": "TCP", "port": 443}],
|
||||
}],
|
||||
},
|
||||
})
|
||||
if {
|
||||
(item.get("metadata") or {}).get("name")
|
||||
for item in alertmanager if item.get("kind") == "NetworkPolicy"
|
||||
} != {"observability-allow-alertmanager-public-https"}:
|
||||
reject("Alertmanager NetworkPolicy name set is not exact")
|
||||
|
||||
elif mode == "scope":
|
||||
items = []
|
||||
for path in paths:
|
||||
items.extend(load(path))
|
||||
forbidden = re.compile(r"spring|jvm|kafka|batch|backup", re.I)
|
||||
for item in items:
|
||||
kind = item.get("kind")
|
||||
name = (item.get("metadata") or {}).get("name") or ""
|
||||
if kind in {"Probe", "ServiceMonitor", "PodMonitor", "PrometheusRule"}:
|
||||
if labels(item).get("observability.hyeonworks.com/instance") != "home":
|
||||
reject(f"missing monitor/rule instance label: {kind}/{name}")
|
||||
semantic_names = [name]
|
||||
if kind == "PrometheusRule":
|
||||
for group in (item.get("spec") or {}).get("groups") or []:
|
||||
semantic_names.append(group.get("name") or "")
|
||||
for rule in group.get("rules") or []:
|
||||
semantic_names.append(rule.get("alert") or rule.get("record") or "")
|
||||
if any(forbidden.search(value) for value in semantic_names):
|
||||
reject(f"forbidden product scope: {kind}/{name}")
|
||||
if kind in {"ServiceMonitor", "PodMonitor"}:
|
||||
namespace = (item.get("metadata") or {}).get("namespace")
|
||||
allowed = {
|
||||
("observability", "blackbox-exporter"),
|
||||
("platform-data", "platform-postgres"),
|
||||
("object-storage", "aistor-bucket-usage"),
|
||||
}
|
||||
if (namespace, name) not in allowed:
|
||||
reject(f"out-of-scope access target: {namespace}/{name}")
|
||||
|
||||
elif mode == "private-dns":
|
||||
items = load(paths[0])
|
||||
config = one(items, "ConfigMap", "coredns-custom", "kube-system")
|
||||
content = "\n".join((config.get("data") or {}).values())
|
||||
lan = paths[1].read_text()
|
||||
tail = paths[2].read_text()
|
||||
hosts = ("git", "id", "storage-admin", "db-admin", "grafana")
|
||||
for short in hosts:
|
||||
host = f"{short}.learn.hyeonworks.com"
|
||||
if f"192.168.0.107 {host}" not in content \
|
||||
or f"address=/{host}/192.168.0.107" not in lan \
|
||||
or f"address=/{host}/100.92.240.34" not in tail:
|
||||
reject(f"private DNS mapping is not exact: {host}")
|
||||
if re.search(r"(?:^|\s)(?!192\.168\.0\.107\b)\d+\.\d+\.\d+\.\d+\s+grafana\.learn", content):
|
||||
reject("Grafana DNS assumes a non-private address")
|
||||
|
||||
elif mode == "traefik":
|
||||
items = load(paths[0])
|
||||
hcc = one(items, "HelmChartConfig", "traefik", "kube-system")
|
||||
values = yaml.safe_load((hcc.get("spec") or {}).get("valuesContent") or "") or {}
|
||||
web = ((((values.get("ports") or {}).get("web") or {}).get("forwardedHeaders") or {}))
|
||||
if web.get("trustedIPs") != ["10.42.0.1/32"] or web.get("insecure") is True:
|
||||
reject("Traefik trust overlay is not exact")
|
||||
|
||||
else:
|
||||
reject(f"unknown assertion mode: {mode}")
|
||||
PY
|
||||
}
|
||||
|
||||
assert_access_grafana_contract() { _access_assert grafana "$@"; }
|
||||
assert_access_blackbox_contract() { _access_assert blackbox "$@"; }
|
||||
assert_access_targets_contract() { _access_assert targets "$@"; }
|
||||
assert_access_rules_alerts_contract() {
|
||||
(( $# == 3 )) || return 1
|
||||
_access_assert rules-alerts "$@" || return 1
|
||||
assert_alertmanager_routing_contract "$3"
|
||||
}
|
||||
assert_access_scope_contract() { _access_assert scope "$@"; }
|
||||
assert_access_private_dns_contract() { _access_assert private-dns "$@"; }
|
||||
assert_access_traefik_trust_contract() { _access_assert traefik "$@"; }
|
||||
|
||||
validate_access_inventory_root() {
|
||||
local root=$1 phase directory expected actual filename extra entries current_uid
|
||||
current_uid="$(id -u)" || return 1
|
||||
[[ "$root" =~ ^/tmp/platform-observability-metrics\.[A-Za-z0-9]{6}$ ]] || return 1
|
||||
[[ -d /tmp && ! -L /tmp && "$(readlink -f -- /tmp)" == /tmp &&
|
||||
"$(stat -c %u:%a -- /tmp)" == 0:1777 ]] || return 1
|
||||
[[ -d "$root" && ! -L "$root" && "$(readlink -f -- "$root")" == "$root" &&
|
||||
"$(stat -c %u:%a -- "$root")" == "$current_uid:700" ]] || return 1
|
||||
entries="$(find "$root" -mindepth 1 -maxdepth 1 -printf '%f\n' | sort)" || return 1
|
||||
[[ "$entries" == $'post-substrate\ntarget-initial' ]] || return 1
|
||||
for phase in target-initial post-substrate; do
|
||||
directory="$root/$phase"
|
||||
[[ -d "$directory" && ! -L "$directory" && -f "$directory/inventory.json" &&
|
||||
-f "$directory/inventory.sha256" && ! -L "$directory/inventory.json" &&
|
||||
! -L "$directory/inventory.sha256" ]] || return 1
|
||||
[[ "$(readlink -f -- "$directory")" == "$directory" &&
|
||||
"$(stat -c %u:%a -- "$directory")" == "$current_uid:700" ]] || return 1
|
||||
entries="$(find "$directory" -mindepth 1 -maxdepth 1 -printf '%f\n' | sort)" || return 1
|
||||
[[ "$entries" == $'inventory.json\ninventory.sha256' ]] || return 1
|
||||
for filename in inventory.json inventory.sha256; do
|
||||
[[ -f "$directory/$filename" && ! -L "$directory/$filename" &&
|
||||
"$(stat -c %u:%a:%h -- "$directory/$filename")" == "$current_uid:600:1" ]] || return 1
|
||||
done
|
||||
read -r expected filename extra <"$directory/inventory.sha256"
|
||||
[[ "$expected" =~ ^[0-9a-f]{64}$ && "$filename" == inventory.json && -z "${extra:-}" ]] || return 1
|
||||
actual="$(sha256sum "$directory/inventory.json" | awk '{print $1}')"
|
||||
[[ "$actual" == "$expected" ]] || return 1
|
||||
jq -e --arg phase "$phase" '
|
||||
.schema == "platform-observability-metric-inventory/v1" and
|
||||
.phase == $phase and (.targets | type == "array" and length > 0) and
|
||||
all(.targets[]; .health == "up" and .last_error == "" and (.metrics | type == "array" and length > 0))
|
||||
' "$directory/inventory.json" >/dev/null || return 1
|
||||
done
|
||||
}
|
||||
|
||||
publish_access_outputs() {
|
||||
local root=$1 work=$2 component=$3
|
||||
local -a names=()
|
||||
mapfile -t names < <(access_output_names "$component") || return 2
|
||||
(( ${#names[@]} > 0 )) || return 2
|
||||
python3 - "$root" "$work" "${names[@]}" <<'PY'
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import sys
|
||||
|
||||
|
||||
class Rejected(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def reject(reason):
|
||||
raise Rejected(reason)
|
||||
|
||||
|
||||
def same_identity(metadata, expected):
|
||||
return (metadata.st_dev, metadata.st_ino) == expected
|
||||
|
||||
|
||||
def sha256_descriptor(descriptor):
|
||||
digest = hashlib.sha256()
|
||||
os.lseek(descriptor, 0, os.SEEK_SET)
|
||||
while True:
|
||||
chunk = os.read(descriptor, 1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
digest.update(chunk)
|
||||
os.lseek(descriptor, 0, os.SEEK_SET)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
root, work, *names = sys.argv[1:]
|
||||
root_descriptor = None
|
||||
work_descriptor = None
|
||||
source_descriptors = {}
|
||||
temporary_names = []
|
||||
created = {}
|
||||
try:
|
||||
if os.path.dirname(root) != "/tmp" or not re.fullmatch(
|
||||
r"platform-observability-metrics\.[A-Za-z0-9]{6}", os.path.basename(root)
|
||||
):
|
||||
reject("output root is not the expected direct /tmp handoff")
|
||||
tmp_metadata = os.lstat("/tmp")
|
||||
root_metadata = os.lstat(root)
|
||||
if not stat.S_ISDIR(tmp_metadata.st_mode) or stat.S_ISLNK(tmp_metadata.st_mode):
|
||||
reject("/tmp is not a physical directory")
|
||||
if tmp_metadata.st_uid != 0 or stat.S_IMODE(tmp_metadata.st_mode) != 0o1777:
|
||||
reject("/tmp owner or mode differs")
|
||||
if os.path.realpath("/tmp") != "/tmp" or os.path.realpath(root) != root:
|
||||
reject("output root lineage is not physical")
|
||||
if not stat.S_ISDIR(root_metadata.st_mode) or stat.S_ISLNK(root_metadata.st_mode):
|
||||
reject("output root is not a physical directory")
|
||||
if root_metadata.st_uid != os.getuid() or stat.S_IMODE(root_metadata.st_mode) != 0o700:
|
||||
reject("output root owner or mode differs")
|
||||
root_identity = (root_metadata.st_dev, root_metadata.st_ino)
|
||||
root_descriptor = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
||||
if not same_identity(os.fstat(root_descriptor), root_identity):
|
||||
reject("opened output root identity differs")
|
||||
|
||||
base_entries = {"target-initial", "post-substrate"}
|
||||
if set(os.listdir(root_descriptor)) != base_entries:
|
||||
reject("output root is not pristine before publication")
|
||||
for phase in base_entries:
|
||||
phase_metadata = os.stat(phase, dir_fd=root_descriptor, follow_symlinks=False)
|
||||
if not stat.S_ISDIR(phase_metadata.st_mode) or phase_metadata.st_uid != os.getuid():
|
||||
reject("inventory phase type or owner differs")
|
||||
if stat.S_IMODE(phase_metadata.st_mode) != 0o700:
|
||||
reject("inventory phase mode differs")
|
||||
phase_descriptor = os.open(
|
||||
phase, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=root_descriptor
|
||||
)
|
||||
try:
|
||||
if set(os.listdir(phase_descriptor)) != {"inventory.json", "inventory.sha256"}:
|
||||
reject("inventory phase entry set differs")
|
||||
for filename in ("inventory.json", "inventory.sha256"):
|
||||
metadata = os.stat(filename, dir_fd=phase_descriptor, follow_symlinks=False)
|
||||
if not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != os.getuid():
|
||||
reject("inventory file type or owner differs")
|
||||
if stat.S_IMODE(metadata.st_mode) != 0o600 or metadata.st_nlink != 1:
|
||||
reject("inventory file mode or link count differs")
|
||||
finally:
|
||||
os.close(phase_descriptor)
|
||||
|
||||
if len(set(names)) != len(names) or any("/" in name or name in {".", ".."} for name in names):
|
||||
reject("publication names are malformed")
|
||||
if any(name in base_entries for name in names):
|
||||
reject("publication name collides with inventory")
|
||||
for name in names:
|
||||
try:
|
||||
os.stat(name, dir_fd=root_descriptor, follow_symlinks=False)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
reject("output destination already exists")
|
||||
|
||||
work_metadata = os.lstat(work)
|
||||
if not stat.S_ISDIR(work_metadata.st_mode) or stat.S_ISLNK(work_metadata.st_mode):
|
||||
reject("verified render directory is not physical")
|
||||
if work_metadata.st_uid != os.getuid() or stat.S_IMODE(work_metadata.st_mode) != 0o700:
|
||||
reject("verified render directory owner or mode differs")
|
||||
work_identity = (work_metadata.st_dev, work_metadata.st_ino)
|
||||
work_descriptor = os.open(work, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
||||
if not same_identity(os.fstat(work_descriptor), work_identity):
|
||||
reject("opened render directory identity differs")
|
||||
source_hashes = {}
|
||||
for name in names:
|
||||
descriptor = os.open(name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=work_descriptor)
|
||||
source_descriptors[name] = descriptor
|
||||
metadata = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != os.getuid():
|
||||
reject("verified output type or owner differs")
|
||||
if stat.S_IMODE(metadata.st_mode) != 0o600 or metadata.st_nlink != 1:
|
||||
reject("verified output mode or link count differs")
|
||||
source_hashes[name] = sha256_descriptor(descriptor)
|
||||
|
||||
for position, name in enumerate(names):
|
||||
temporary = f".access-handoff-{os.getpid()}-{position}"
|
||||
target_descriptor = os.open(
|
||||
temporary,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
||||
0o600,
|
||||
dir_fd=root_descriptor,
|
||||
)
|
||||
temporary_names.append(temporary)
|
||||
try:
|
||||
source_descriptor = source_descriptors[name]
|
||||
os.lseek(source_descriptor, 0, os.SEEK_SET)
|
||||
while True:
|
||||
chunk = os.read(source_descriptor, 1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
view = memoryview(chunk)
|
||||
while view:
|
||||
view = view[os.write(target_descriptor, view):]
|
||||
os.fchmod(target_descriptor, 0o600)
|
||||
os.fsync(target_descriptor)
|
||||
staged_identity = (os.fstat(target_descriptor).st_dev, os.fstat(target_descriptor).st_ino)
|
||||
finally:
|
||||
os.close(target_descriptor)
|
||||
os.link(
|
||||
temporary,
|
||||
name,
|
||||
src_dir_fd=root_descriptor,
|
||||
dst_dir_fd=root_descriptor,
|
||||
follow_symlinks=False,
|
||||
)
|
||||
created[name] = staged_identity
|
||||
os.unlink(temporary, dir_fd=root_descriptor)
|
||||
temporary_names.remove(temporary)
|
||||
|
||||
os.fsync(root_descriptor)
|
||||
if set(os.listdir(root_descriptor)) != base_entries | set(names):
|
||||
reject("published output entry set differs")
|
||||
for name in names:
|
||||
metadata = os.stat(name, dir_fd=root_descriptor, follow_symlinks=False)
|
||||
if not same_identity(metadata, created[name]) or not stat.S_ISREG(metadata.st_mode):
|
||||
reject("published output identity or type differs")
|
||||
if metadata.st_uid != os.getuid() or stat.S_IMODE(metadata.st_mode) != 0o600:
|
||||
reject("published output owner or mode differs")
|
||||
descriptor = os.open(name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=root_descriptor)
|
||||
try:
|
||||
if sha256_descriptor(descriptor) != source_hashes[name]:
|
||||
reject("published output content differs")
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
if not same_identity(os.lstat(root), root_identity):
|
||||
reject("output root identity changed during publication")
|
||||
except BaseException as error:
|
||||
if root_descriptor is not None:
|
||||
for name, identity in reversed(tuple(created.items())):
|
||||
try:
|
||||
metadata = os.stat(name, dir_fd=root_descriptor, follow_symlinks=False)
|
||||
if same_identity(metadata, identity):
|
||||
os.unlink(name, dir_fd=root_descriptor)
|
||||
except OSError:
|
||||
pass
|
||||
for name in reversed(temporary_names):
|
||||
try:
|
||||
os.unlink(name, dir_fd=root_descriptor)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.fsync(root_descriptor)
|
||||
except OSError:
|
||||
pass
|
||||
if isinstance(error, Rejected):
|
||||
print(f"REJECT: access output publication failed: {error}", file=sys.stderr)
|
||||
else:
|
||||
print("REJECT: access output publication failed: filesystem operation failed", file=sys.stderr)
|
||||
raise SystemExit(23)
|
||||
finally:
|
||||
for descriptor in source_descriptors.values():
|
||||
try:
|
||||
os.close(descriptor)
|
||||
except OSError:
|
||||
pass
|
||||
if work_descriptor is not None:
|
||||
os.close(work_descriptor)
|
||||
if root_descriptor is not None:
|
||||
os.close(root_descriptor)
|
||||
PY
|
||||
}
|
||||
|
||||
_access_usage() {
|
||||
cat <<'USAGE'
|
||||
Usage:
|
||||
PLATFORM_HELM_BIN=/home/donghyeon/.local/bin/helm \
|
||||
bash scripts/validate/render-observability-access.sh \
|
||||
--component grafana|blackbox|targets|rules-alerts|complete \
|
||||
[--verified-output-dir /tmp/platform-observability-metrics.XXXXXX]
|
||||
|
||||
Static rendering only; no Kubernetes resource is applied.
|
||||
USAGE
|
||||
}
|
||||
|
||||
access_render_main() (
|
||||
local component=complete output_root='' argument core_handoff work
|
||||
access_secure_render_context
|
||||
while (( $# > 0 )); do
|
||||
argument=$1
|
||||
case "$argument" in
|
||||
--component) (( $# >= 2 )) || return 2; component=$2; shift 2 ;;
|
||||
--verified-output-dir) (( $# >= 2 )) || return 2; output_root=$2; shift 2 ;;
|
||||
-h|--help) _access_usage; return 0 ;;
|
||||
*) _access_usage >&2; return 2 ;;
|
||||
esac
|
||||
done
|
||||
access_output_names "$component" >/dev/null || return 2
|
||||
if access_component_requires_inventory "$component"; then
|
||||
[[ -n "$output_root" ]] || { printf 'REJECT: rules-alerts/complete requires --verified-output-dir\n' >&2; return 1; }
|
||||
validate_access_inventory_root "$output_root" || { printf 'REJECT: verified inventory root is invalid\n' >&2; return 1; }
|
||||
fi
|
||||
work="$(mktemp -d /tmp/platform-observability-access-render.XXXXXX)"
|
||||
chmod 0700 "$work"
|
||||
core_handoff="$(mktemp -d /tmp/platform-observability-core-apply.XXXXXX)"
|
||||
chmod 0700 "$core_handoff"
|
||||
trap '[[ -n ${work:-} && $work == /tmp/platform-observability-access-render.* ]] && rm -rf -- "$work"; [[ -n ${core_handoff:-} && $core_handoff == /tmp/platform-observability-core-apply.* ]] && rm -rf -- "$core_handoff"' EXIT INT TERM
|
||||
/usr/bin/bash "$ACCESS_ROOT/scripts/validate/render-observability-core.sh" \
|
||||
--verified-output-dir "$core_handoff"
|
||||
|
||||
cp -- "$core_handoff/grafana.yaml" "$work/grafana.yaml"
|
||||
cp -- "$core_handoff/blackbox.yaml" "$work/blackbox.yaml"
|
||||
cp -- "$core_handoff/targets.yaml" "$work/targets.yaml"
|
||||
cp -- "$core_handoff/dashboards.yaml" "$work/dashboards.yaml"
|
||||
cp -- "$core_handoff/core-rules.yaml" "$work/rules.yaml"
|
||||
kubectl kustomize "$ACCESS_ROOT/infrastructure/networking/private-dns/kubernetes" >"$work/private-dns.yaml"
|
||||
python3 - "$core_handoff/kps.yaml" "$core_handoff/alerting.yaml" "$work/alertmanager.yaml" <<'PY'
|
||||
import sys, yaml
|
||||
|
||||
class AccessExtractionSafeLoader(yaml.SafeLoader):
|
||||
pass
|
||||
|
||||
AccessExtractionSafeLoader.add_constructor(
|
||||
"tag:yaml.org,2002:value",
|
||||
lambda loader, node: loader.construct_scalar(node),
|
||||
)
|
||||
|
||||
items=[]
|
||||
for path in sys.argv[1:3]:
|
||||
with open(path, encoding="utf-8") as stream:
|
||||
items.extend(
|
||||
item
|
||||
for item in yaml.load_all(stream, Loader=AccessExtractionSafeLoader)
|
||||
if item is not None
|
||||
)
|
||||
selected=[item for item in items if item.get("kind") in {"Alertmanager", "AlertmanagerConfig", "NetworkPolicy"}]
|
||||
with open(sys.argv[3], "w", encoding="utf-8") as stream:
|
||||
yaml.safe_dump_all(selected, stream, explicit_start=True, sort_keys=False)
|
||||
PY
|
||||
assert_access_grafana_contract "$work/grafana.yaml" "$core_handoff/core-policies.yaml"
|
||||
assert_pinned_images "$work/grafana.yaml"
|
||||
assert_access_blackbox_contract "$work/blackbox.yaml"
|
||||
assert_pinned_images "$work/blackbox.yaml"
|
||||
assert_access_targets_contract "$work/targets.yaml"
|
||||
assert_access_rules_alerts_contract "$work/dashboards.yaml" "$work/rules.yaml" "$work/alertmanager.yaml"
|
||||
assert_access_scope_contract "$work/blackbox.yaml" "$work/targets.yaml" "$work/rules.yaml"
|
||||
assert_access_private_dns_contract "$work/private-dns.yaml" \
|
||||
"$ACCESS_ROOT/infrastructure/networking/private-dns/host/dnsmasq-lan.conf" \
|
||||
"$ACCESS_ROOT/infrastructure/networking/private-dns/host/dnsmasq-tailscale.conf"
|
||||
assert_no_credentials "$work"/*.yaml
|
||||
|
||||
if [[ -n "$output_root" ]]; then
|
||||
publish_access_outputs "$output_root" "$work" "$component"
|
||||
fi
|
||||
printf 'OBSERVABILITY ACCESS STATIC RENDER PASS\n'
|
||||
)
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||
set -Eeuo pipefail
|
||||
access_render_main "$@"
|
||||
fi
|
||||
+1650
File diff suppressed because it is too large
Load Diff
Executable
+671
@@ -0,0 +1,671 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly EXPECTED_KUSTOMIZE_VERSION="v5.8.1"
|
||||
readonly EXPECTED_HELM_VERSION="v3.19.4"
|
||||
readonly TARGET_KUBERNETES_VERSION="1.36.2"
|
||||
readonly CNPG_CHART_NAME="cloudnative-pg"
|
||||
readonly CNPG_CHART_VERSION="0.29.0"
|
||||
readonly CNPG_CHART_REPOSITORY="https://cloudnative-pg.github.io/charts"
|
||||
readonly EXPECTED_CNPG_CHART_SHA256="668e065ff53508d58238788fd35b355a925060843629a951df0e6a9362e6d32f"
|
||||
readonly GITEA_CHART_NAME="gitea"
|
||||
readonly GITEA_CHART_VERSION="12.7.0"
|
||||
readonly GITEA_CHART_REPOSITORY="https://dl.gitea.com/charts/"
|
||||
readonly EXPECTED_GITEA_CHART_SHA256="5881ef9c59400bee2d5547e77c4cd0efb925143c2f5d93fb4f38446db76b0167"
|
||||
readonly REPOSITORY_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd -P)"
|
||||
readonly -a RENDERED_MANIFEST_NAMES=(
|
||||
namespaces
|
||||
ssd-local-pv
|
||||
cnpg-operator
|
||||
platform-postgres
|
||||
gitea
|
||||
gitea-oidc
|
||||
)
|
||||
|
||||
fail() {
|
||||
printf 'ERROR: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
assert_exact_source_text() {
|
||||
local file="$1"
|
||||
local expected_text="$2"
|
||||
local description="$3"
|
||||
local count
|
||||
|
||||
count="$(rg --count-matches --fixed-strings -- "$expected_text" "$file" || true)"
|
||||
[[ "$count" == "1" ]] || \
|
||||
fail "${description} must appear exactly once in ${file#${REPOSITORY_ROOT}/}"
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage:
|
||||
bash scripts/validate/render-phase1.sh
|
||||
bash scripts/validate/render-phase1.sh \
|
||||
--verified-output-dir /tmp/platform-phase1-apply.XXXXXX
|
||||
|
||||
The output option is an internal handoff used only by the Phase 1 apply
|
||||
script. The destination must be an existing, empty, non-symlink directory
|
||||
created directly below /tmp with the platform-phase1-apply.* prefix.
|
||||
USAGE
|
||||
}
|
||||
|
||||
verified_output_dir=""
|
||||
case "$#" in
|
||||
0)
|
||||
;;
|
||||
2)
|
||||
[[ "$1" == "--verified-output-dir" ]] || {
|
||||
usage >&2
|
||||
exit 2
|
||||
}
|
||||
verified_output_dir="$2"
|
||||
;;
|
||||
*)
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ -n "$verified_output_dir" ]]; then
|
||||
[[ "$verified_output_dir" == /tmp/platform-phase1-apply.* ]] || \
|
||||
fail "verified output directory must match /tmp/platform-phase1-apply.*"
|
||||
[[ -d "$verified_output_dir" && ! -L "$verified_output_dir" ]] || \
|
||||
fail "verified output directory must be an existing non-symlink directory"
|
||||
[[ "$(cd -- "$verified_output_dir" && pwd -P)" == "$verified_output_dir" ]] || \
|
||||
fail "verified output directory must be an absolute canonical path"
|
||||
[[ -O "$verified_output_dir" ]] || \
|
||||
fail "verified output directory must be owned by the current user"
|
||||
[[ "$(stat --format='%a' -- "$verified_output_dir")" == "700" ]] || \
|
||||
fail "verified output directory must have mode 0700"
|
||||
[[ -z "$(find "$verified_output_dir" -mindepth 1 -maxdepth 1 -print -quit)" ]] || \
|
||||
fail "verified output directory must be empty"
|
||||
fi
|
||||
|
||||
command -v kubectl >/dev/null 2>&1 || fail "kubectl is required"
|
||||
command -v jq >/dev/null 2>&1 || fail "jq is required"
|
||||
command -v rg >/dev/null 2>&1 || fail "ripgrep (rg) is required"
|
||||
command -v sha256sum >/dev/null 2>&1 || fail "sha256sum is required"
|
||||
command -v tar >/dev/null 2>&1 || fail "tar is required"
|
||||
command -v cmp >/dev/null 2>&1 || fail "cmp is required"
|
||||
command -v find >/dev/null 2>&1 || fail "find is required"
|
||||
command -v install >/dev/null 2>&1 || fail "install is required"
|
||||
command -v stat >/dev/null 2>&1 || fail "stat is required"
|
||||
|
||||
if [[ -n "${PLATFORM_HELM_BIN:-}" ]]; then
|
||||
[[ "$PLATFORM_HELM_BIN" == /* ]] || \
|
||||
fail "PLATFORM_HELM_BIN must be an absolute path"
|
||||
[[ -f "$PLATFORM_HELM_BIN" && -x "$PLATFORM_HELM_BIN" ]] || \
|
||||
fail "PLATFORM_HELM_BIN is not an executable file: ${PLATFORM_HELM_BIN}"
|
||||
readonly HELM_BIN="$PLATFORM_HELM_BIN"
|
||||
else
|
||||
HELM_BIN="$(command -v helm 2>/dev/null)" || \
|
||||
fail "Helm ${EXPECTED_HELM_VERSION} is required"
|
||||
readonly HELM_BIN
|
||||
fi
|
||||
|
||||
kustomize_version="$(kubectl version --client --output=yaml | sed -n 's/^kustomizeVersion: //p')"
|
||||
helm_version="$("$HELM_BIN" version --template '{{.Version}}')"
|
||||
|
||||
[[ "$kustomize_version" == "$EXPECTED_KUSTOMIZE_VERSION" ]] || \
|
||||
fail "expected Kustomize ${EXPECTED_KUSTOMIZE_VERSION}, found ${kustomize_version:-unknown}"
|
||||
[[ "$helm_version" == "$EXPECTED_HELM_VERSION" ]] || \
|
||||
fail "expected Helm ${EXPECTED_HELM_VERSION}, found ${helm_version:-unknown}"
|
||||
|
||||
render_dir="$(mktemp -d "${TMPDIR:-/tmp}/platform-phase1-render.XXXXXX")"
|
||||
declare -a generated_chart_cache_dirs=()
|
||||
declare -a generated_chart_cache_parent_dirs=()
|
||||
cleanup() {
|
||||
local cache_dir
|
||||
local parent_dir
|
||||
|
||||
for cache_dir in "${generated_chart_cache_dirs[@]}"; do
|
||||
case "$cache_dir" in
|
||||
"${REPOSITORY_ROOT}/infrastructure/controllers/cloudnative-pg/.helm/charts/cloudnative-pg-0.29.0"|\
|
||||
"${REPOSITORY_ROOT}/services/gitea/profiles/oidc/.helm/charts/gitea-12.7.0")
|
||||
rm -rf -- "$cache_dir"
|
||||
;;
|
||||
*)
|
||||
printf 'WARNING: refusing to remove unexpected chart cache: %s\n' \
|
||||
"$cache_dir" >&2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
for parent_dir in "${generated_chart_cache_parent_dirs[@]}"; do
|
||||
case "$parent_dir" in
|
||||
"${REPOSITORY_ROOT}/infrastructure/controllers/cloudnative-pg/.helm/charts"|\
|
||||
"${REPOSITORY_ROOT}/infrastructure/controllers/cloudnative-pg/.helm"|\
|
||||
"${REPOSITORY_ROOT}/services/gitea/profiles/oidc/.helm/charts"|\
|
||||
"${REPOSITORY_ROOT}/services/gitea/profiles/oidc/.helm")
|
||||
rmdir -- "$parent_dir" 2>/dev/null || true
|
||||
;;
|
||||
*)
|
||||
printf 'WARNING: refusing to remove unexpected chart cache parent: %s\n' \
|
||||
"$parent_dir" >&2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
|
||||
case "$render_dir" in
|
||||
/tmp/platform-phase1-render.*|"${TMPDIR:-/tmp}"/platform-phase1-render.*)
|
||||
rm -rf -- "$render_dir"
|
||||
;;
|
||||
*)
|
||||
printf 'WARNING: refusing to remove unexpected render directory: %s\n' "$render_dir" >&2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
render_plain() {
|
||||
local label="$1"
|
||||
local relative_path="$2"
|
||||
local output="${render_dir}/${label}.yaml"
|
||||
|
||||
kubectl kustomize "${REPOSITORY_ROOT}/${relative_path}" >"$output"
|
||||
[[ -s "$output" ]] || fail "${label} rendered an empty manifest"
|
||||
printf 'Rendered %-20s %8s bytes\n' "$label" "$(wc -c <"$output" | tr -d '[:space:]')"
|
||||
}
|
||||
|
||||
render_helm() {
|
||||
local label="$1"
|
||||
local relative_path="$2"
|
||||
local output="${render_dir}/${label}.yaml"
|
||||
|
||||
kubectl kustomize \
|
||||
--enable-helm \
|
||||
--helm-command "$HELM_BIN" \
|
||||
--helm-kube-version "$TARGET_KUBERNETES_VERSION" \
|
||||
"${REPOSITORY_ROOT}/${relative_path}" >"$output"
|
||||
[[ -s "$output" ]] || fail "${label} rendered an empty manifest"
|
||||
printf 'Rendered %-20s %8s bytes\n' "$label" "$(wc -c <"$output" | tr -d '[:space:]')"
|
||||
}
|
||||
|
||||
extract_rendered_document() {
|
||||
local manifest="$1"
|
||||
local wanted_kind="$2"
|
||||
local wanted_name="$3"
|
||||
local output="$4"
|
||||
|
||||
awk \
|
||||
-v wanted_kind="$wanted_kind" \
|
||||
-v wanted_name="$wanted_name" \
|
||||
'
|
||||
function reset_document() {
|
||||
document = ""
|
||||
document_kind = ""
|
||||
document_name = ""
|
||||
in_metadata = 0
|
||||
}
|
||||
|
||||
function flush_document() {
|
||||
if (document_kind == wanted_kind && document_name == wanted_name) {
|
||||
matches++
|
||||
printf "%s", document
|
||||
}
|
||||
}
|
||||
|
||||
BEGIN {
|
||||
reset_document()
|
||||
}
|
||||
|
||||
/^---[[:space:]]*$/ {
|
||||
flush_document()
|
||||
reset_document()
|
||||
next
|
||||
}
|
||||
|
||||
{
|
||||
document = document $0 ORS
|
||||
|
||||
if ($0 ~ /^kind:[[:space:]]*/) {
|
||||
document_kind = $0
|
||||
sub(/^kind:[[:space:]]*/, "", document_kind)
|
||||
sub(/[[:space:]]*$/, "", document_kind)
|
||||
}
|
||||
|
||||
if ($0 == "metadata:") {
|
||||
in_metadata = 1
|
||||
next
|
||||
}
|
||||
|
||||
if (in_metadata && $0 ~ /^ name:[[:space:]]*/) {
|
||||
document_name = $0
|
||||
sub(/^ name:[[:space:]]*/, "", document_name)
|
||||
sub(/[[:space:]]*$/, "", document_name)
|
||||
in_metadata = 0
|
||||
} else if (in_metadata && $0 ~ /^[^[:space:]]/) {
|
||||
in_metadata = 0
|
||||
}
|
||||
}
|
||||
|
||||
END {
|
||||
flush_document()
|
||||
if (matches != 1) {
|
||||
exit 42
|
||||
}
|
||||
}
|
||||
' \
|
||||
"$manifest" >"$output"
|
||||
}
|
||||
|
||||
prepare_verified_chart_cache() {
|
||||
local label="$1"
|
||||
local chart_name="$2"
|
||||
local chart_repository="$3"
|
||||
local chart_version="$4"
|
||||
local build_root_relative_path="$5"
|
||||
local expected_digest="$6"
|
||||
local package_path="${render_dir}/${chart_name}-${chart_version}.tgz"
|
||||
local cache_version_dir="${REPOSITORY_ROOT}/${build_root_relative_path}/.helm/charts/${chart_name}-${chart_version}"
|
||||
local checksum_output
|
||||
local actual_digest
|
||||
local chart_cache_dir="${cache_version_dir%/*}"
|
||||
local helm_cache_dir="${chart_cache_dir%/*}"
|
||||
local cache_parent
|
||||
|
||||
if [[ ! -f "$package_path" ]]; then
|
||||
"$HELM_BIN" pull "$chart_name" \
|
||||
--repo "$chart_repository" \
|
||||
--version "$chart_version" \
|
||||
--destination "$render_dir"
|
||||
fi
|
||||
|
||||
[[ -f "$package_path" ]] || fail "${label} chart package was not downloaded: ${package_path}"
|
||||
checksum_output="$(sha256sum -- "$package_path")"
|
||||
actual_digest="${checksum_output%% *}"
|
||||
[[ "$actual_digest" == "$expected_digest" ]] || \
|
||||
fail "${label} chart package SHA-256 mismatch: expected ${expected_digest}, found ${actual_digest}"
|
||||
|
||||
case "$cache_version_dir" in
|
||||
"${REPOSITORY_ROOT}/infrastructure/controllers/cloudnative-pg/.helm/charts/cloudnative-pg-0.29.0"|\
|
||||
"${REPOSITORY_ROOT}/services/gitea/profiles/oidc/.helm/charts/gitea-12.7.0")
|
||||
;;
|
||||
*)
|
||||
fail "refusing to create unexpected chart cache directory: ${cache_version_dir}"
|
||||
;;
|
||||
esac
|
||||
for cache_parent in "$helm_cache_dir" "$chart_cache_dir"; do
|
||||
[[ ! -L "$cache_parent" ]] || \
|
||||
fail "refusing symlinked chart cache parent: ${cache_parent}"
|
||||
[[ ! -e "$cache_parent" || -d "$cache_parent" ]] || \
|
||||
fail "chart cache parent is not a directory: ${cache_parent}"
|
||||
done
|
||||
[[ ! -e "$cache_version_dir" && ! -L "$cache_version_dir" ]] || \
|
||||
fail "generated chart cache already exists; remove it only after confirming it is disposable: ${cache_version_dir}"
|
||||
[[ -e "$chart_cache_dir" ]] || generated_chart_cache_parent_dirs+=("$chart_cache_dir")
|
||||
[[ -e "$helm_cache_dir" ]] || generated_chart_cache_parent_dirs+=("$helm_cache_dir")
|
||||
|
||||
mkdir -p -- "$cache_version_dir"
|
||||
generated_chart_cache_dirs+=("$cache_version_dir")
|
||||
tar -xzf "$package_path" -C "$cache_version_dir"
|
||||
[[ -f "${cache_version_dir}/${chart_name}/Chart.yaml" ]] || \
|
||||
fail "${label} extracted chart is missing Chart.yaml"
|
||||
|
||||
printf 'Verified %-20s SHA-256 %s\n' "$label" "$actual_digest"
|
||||
}
|
||||
|
||||
cd -- "$REPOSITORY_ROOT"
|
||||
|
||||
if rg --line-number --glob '*.yaml' --glob '*.yml' \
|
||||
--glob '!**/.helm/**' --glob '!**/charts/**' \
|
||||
'^[[:space:]]*kind:[[:space:]]*Secret[[:space:]]*$' \
|
||||
infrastructure services bootstrap clusters components; then
|
||||
fail "a source-controlled Kubernetes Secret manifest was found"
|
||||
fi
|
||||
|
||||
if rg --line-number --glob 'kustomization.yaml' 'LoadRestrictionsNone|load-restrictor' .; then
|
||||
fail "the repository must keep Kustomize LoadRestrictionsRootOnly"
|
||||
fi
|
||||
|
||||
readonly GITEA_BASELINE_KUSTOMIZATION="${REPOSITORY_ROOT}/services/gitea/kustomization.yaml"
|
||||
readonly GITEA_OIDC_KUSTOMIZATION="${REPOSITORY_ROOT}/services/gitea/profiles/oidc/kustomization.yaml"
|
||||
for gitea_kustomization in \
|
||||
"$GITEA_BASELINE_KUSTOMIZATION" \
|
||||
"$GITEA_OIDC_KUSTOMIZATION"; do
|
||||
assert_exact_source_text "$gitea_kustomization" \
|
||||
'repo: https://dl.gitea.com/charts/' \
|
||||
"the pinned Gitea Chart repository"
|
||||
assert_exact_source_text "$gitea_kustomization" \
|
||||
'version: 12.7.0' \
|
||||
"the pinned Gitea Chart version"
|
||||
assert_exact_source_text "$gitea_kustomization" \
|
||||
'includeCRDs: false' \
|
||||
"the Gitea includeCRDs policy"
|
||||
assert_exact_source_text "$gitea_kustomization" \
|
||||
'skipTests: true' \
|
||||
"the Gitea Helm test policy"
|
||||
done
|
||||
assert_exact_source_text "$GITEA_BASELINE_KUSTOMIZATION" \
|
||||
'chartHome: profiles/oidc/.helm/charts' \
|
||||
"the baseline Gitea verified Chart cache"
|
||||
assert_exact_source_text "$GITEA_BASELINE_KUSTOMIZATION" \
|
||||
'valuesFile: profiles/oidc/values/baseline.yaml' \
|
||||
"the baseline Gitea values path"
|
||||
assert_exact_source_text "$GITEA_OIDC_KUSTOMIZATION" \
|
||||
'chartHome: .helm/charts' \
|
||||
"the OIDC Gitea verified Chart cache"
|
||||
assert_exact_source_text "$GITEA_OIDC_KUSTOMIZATION" \
|
||||
'valuesFile: values/baseline.yaml' \
|
||||
"the OIDC Gitea baseline values path"
|
||||
assert_exact_source_text "$GITEA_OIDC_KUSTOMIZATION" \
|
||||
'additionalValuesFiles:' \
|
||||
"the OIDC Gitea values merge"
|
||||
assert_exact_source_text "$GITEA_OIDC_KUSTOMIZATION" \
|
||||
'values/oidc.yaml' \
|
||||
"the OIDC Gitea override path"
|
||||
|
||||
render_plain namespaces infrastructure/namespaces/overlays/home
|
||||
render_plain ssd-local-pv infrastructure/storage/ssd-local-pv
|
||||
prepare_verified_chart_cache \
|
||||
cloudnative-pg-chart \
|
||||
"$CNPG_CHART_NAME" \
|
||||
"$CNPG_CHART_REPOSITORY" \
|
||||
"$CNPG_CHART_VERSION" \
|
||||
infrastructure/controllers/cloudnative-pg \
|
||||
"$EXPECTED_CNPG_CHART_SHA256"
|
||||
render_helm cnpg-operator infrastructure/controllers/cloudnative-pg
|
||||
render_plain platform-postgres services/platform-postgres
|
||||
prepare_verified_chart_cache \
|
||||
gitea-chart \
|
||||
"$GITEA_CHART_NAME" \
|
||||
"$GITEA_CHART_REPOSITORY" \
|
||||
"$GITEA_CHART_VERSION" \
|
||||
services/gitea/profiles/oidc \
|
||||
"$EXPECTED_GITEA_CHART_SHA256"
|
||||
render_helm gitea services/gitea
|
||||
|
||||
render_helm gitea-oidc services/gitea/profiles/oidc
|
||||
[[ "$(awk '$0 == "kind: Cluster" { count++ } END { print count + 0 }' "${render_dir}/platform-postgres.yaml")" == "1" ]] || \
|
||||
fail "the Phase 1 PostgreSQL root must contain exactly one Cluster"
|
||||
[[ "$(awk '$0 == "kind: DatabaseRole" { count++ } END { print count + 0 }' "${render_dir}/platform-postgres.yaml")" == "1" ]] || \
|
||||
fail "the Phase 1 PostgreSQL root must contain exactly one Gitea DatabaseRole"
|
||||
[[ "$(awk '$0 == "kind: Database" { count++ } END { print count + 0 }' "${render_dir}/platform-postgres.yaml")" == "1" ]] || \
|
||||
fail "the Phase 1 PostgreSQL root must contain exactly one Gitea Database"
|
||||
[[ "$(awk '$0 == "kind: NetworkPolicy" { count++ } END { print count + 0 }' "${render_dir}/platform-postgres.yaml")" == "1" ]] || \
|
||||
fail "the Phase 1 PostgreSQL root must contain exactly one base NetworkPolicy"
|
||||
[[ "$(rg --count -- '^[[:space:]]*name:[[:space:]]platform-postgres-gitea[[:space:]]*$' "${render_dir}/platform-postgres.yaml" || true)" == "2" ]] || \
|
||||
fail "the Phase 1 PostgreSQL root must contain the Gitea DatabaseRole and Database"
|
||||
if rg --quiet '^[[:space:]]*name:[[:space:]]platform-postgres-keycloak[[:space:]]*$' "${render_dir}/platform-postgres.yaml"; then
|
||||
fail "the Phase 1 PostgreSQL root must not contain a Keycloak DatabaseRole or Database"
|
||||
fi
|
||||
[[ "$(rg --count -- '^[[:space:]]*-[[:space:]]host gitea gitea all scram-sha-256[[:space:]]*$' "${render_dir}/platform-postgres.yaml" || true)" == "1" ]] || \
|
||||
fail "the Gitea role must authenticate only to the Gitea database"
|
||||
[[ "$(rg --count -- '^[[:space:]]*-[[:space:]]host all gitea all reject[[:space:]]*$' "${render_dir}/platform-postgres.yaml" || true)" == "1" ]] || \
|
||||
fail "the Gitea role must be rejected from every other database"
|
||||
[[ "$(rg --count -- '^[[:space:]]*-[[:space:]]host keycloak keycloak all scram-sha-256[[:space:]]*$' "${render_dir}/platform-postgres.yaml" || true)" == "1" ]] || \
|
||||
fail "the Keycloak role must authenticate only to the Keycloak database"
|
||||
[[ "$(rg --count -- '^[[:space:]]*-[[:space:]]host all keycloak all reject[[:space:]]*$' "${render_dir}/platform-postgres.yaml" || true)" == "1" ]] || \
|
||||
fail "the Keycloak role must be rejected from every other database"
|
||||
|
||||
|
||||
rg --quiet '^kind: CustomResourceDefinition$' "${render_dir}/cnpg-operator.yaml" || \
|
||||
fail "CloudNativePG CRDs are missing from the operator render"
|
||||
rg --quiet '^kind: Cluster$' "${render_dir}/platform-postgres.yaml" || \
|
||||
fail "the platform PostgreSQL Cluster is missing"
|
||||
rg --quiet '^kind: DatabaseRole$' "${render_dir}/platform-postgres.yaml" || \
|
||||
fail "the Gitea DatabaseRole is missing"
|
||||
rg --quiet '^kind: Database$' "${render_dir}/platform-postgres.yaml" || \
|
||||
fail "the Gitea Database is missing"
|
||||
for gitea_profile in gitea gitea-oidc; do
|
||||
gitea_profile_manifest="${render_dir}/${gitea_profile}.yaml"
|
||||
gitea_profile_deployment_document="${render_dir}/${gitea_profile}-deployment.yaml"
|
||||
gitea_profile_ingress_document="${render_dir}/${gitea_profile}-ingress.yaml"
|
||||
gitea_profile_servicemonitor_document="${render_dir}/${gitea_profile}-servicemonitor.yaml"
|
||||
|
||||
rg --quiet '^kind: PersistentVolumeClaim$' "$gitea_profile_manifest" || \
|
||||
fail "${gitea_profile} is missing the Gitea PVC"
|
||||
rg --quiet '^kind: Ingress$' "$gitea_profile_manifest" || \
|
||||
fail "${gitea_profile} is missing the Gitea Ingress"
|
||||
rg --quiet \
|
||||
'^[[:space:]]*-[[:space:]]*host:[[:space:]]*git\.learn\.hyeonworks\.com[[:space:]]*$' \
|
||||
"$gitea_profile_manifest" || \
|
||||
fail "${gitea_profile} Ingress host is not git.learn.hyeonworks.com"
|
||||
[[ "$(rg --count-matches \
|
||||
'^[[:space:]]*ROOT_URL=https://git\.learn\.hyeonworks\.com/[[:space:]]*$' \
|
||||
"$gitea_profile_manifest" || true)" == "1" ]] || \
|
||||
fail "${gitea_profile} must render the external HTTPS ROOT_URL exactly once"
|
||||
|
||||
extract_rendered_document \
|
||||
"$gitea_profile_manifest" \
|
||||
Deployment \
|
||||
gitea \
|
||||
"$gitea_profile_deployment_document" || \
|
||||
fail "${gitea_profile} must contain exactly one gitea Deployment"
|
||||
extract_rendered_document \
|
||||
"$gitea_profile_manifest" \
|
||||
Ingress \
|
||||
gitea-http \
|
||||
"$gitea_profile_ingress_document" || \
|
||||
fail "${gitea_profile} must contain exactly one gitea-http Ingress"
|
||||
extract_rendered_document \
|
||||
"$gitea_profile_manifest" \
|
||||
ServiceMonitor \
|
||||
gitea \
|
||||
"$gitea_profile_servicemonitor_document" || \
|
||||
fail "${gitea_profile} must contain exactly one gitea ServiceMonitor"
|
||||
if rg --quiet '^[[:space:]]{2}tls:[[:space:]]*' "$gitea_profile_ingress_document"; then
|
||||
fail "${gitea_profile} must not render an in-cluster TLS section"
|
||||
fi
|
||||
|
||||
if ! kubectl create --dry-run=client \
|
||||
-f "$gitea_profile_servicemonitor_document" \
|
||||
-o json | jq -e '
|
||||
.apiVersion == "monitoring.coreos.com/v1" and
|
||||
.kind == "ServiceMonitor" and
|
||||
.metadata.name == "gitea" and
|
||||
.metadata.namespace == "gitea" and
|
||||
.metadata.labels["observability.hyeonworks.com/instance"] == "home" and
|
||||
.spec.jobLabel == "app.kubernetes.io/name" and
|
||||
.spec.selector.matchLabels == {
|
||||
"app.kubernetes.io/instance": "gitea",
|
||||
"app.kubernetes.io/name": "gitea"
|
||||
} and
|
||||
.spec.endpoints == [{
|
||||
"interval": "30s",
|
||||
"port": "http",
|
||||
"scrapeTimeout": "10s"
|
||||
}]
|
||||
' >/dev/null; then
|
||||
fail "${gitea_profile} ServiceMonitor contract is not exact"
|
||||
fi
|
||||
|
||||
for restricted_setting in \
|
||||
'allowPrivilegeEscalation: false' \
|
||||
'runAsNonRoot: true' \
|
||||
'type: RuntimeDefault' \
|
||||
'- ALL'; do
|
||||
[[ "$(rg --count-matches --fixed-strings -- "$restricted_setting" \
|
||||
"$gitea_profile_deployment_document" || true)" == "4" ]] || \
|
||||
fail "${gitea_profile} must apply ${restricted_setting} to all four containers"
|
||||
done
|
||||
done
|
||||
|
||||
for forbidden_baseline_marker in \
|
||||
'gitea-keycloak-oidc' \
|
||||
'id.learn.hyeonworks.com' \
|
||||
'gitea-allow-host-nginx-keycloak' \
|
||||
'gitea-branding-assets' \
|
||||
'gitea-branding-templates' \
|
||||
'ALLOW_ONLY_EXTERNAL_REGISTRATION=true'; do
|
||||
if rg --quiet --fixed-strings -- "$forbidden_baseline_marker" "${render_dir}/gitea.yaml"; then
|
||||
fail "the baseline Gitea render contains OIDC-only marker ${forbidden_baseline_marker}"
|
||||
fi
|
||||
done
|
||||
for baseline_setting in \
|
||||
'DISABLE_REGISTRATION=true' \
|
||||
'ALLOW_ONLY_EXTERNAL_REGISTRATION=false' \
|
||||
'SHOW_REGISTRATION_BUTTON=false' \
|
||||
'ENABLE_PASSWORD_SIGNIN_FORM=true'; do
|
||||
[[ "$(rg --count-matches --fixed-strings -- "$baseline_setting" \
|
||||
"${render_dir}/gitea.yaml" || true)" == "1" ]] || \
|
||||
fail "the baseline Gitea render must contain exactly one ${baseline_setting} setting"
|
||||
done
|
||||
|
||||
gitea_deployment_document="${render_dir}/gitea-oidc-deployment.yaml"
|
||||
gitea_keycloak_policy_document="${render_dir}/gitea-keycloak-egress-policy.yaml"
|
||||
gitea_branding_assets_document="${render_dir}/gitea-branding-assets-configmap.yaml"
|
||||
gitea_branding_templates_document="${render_dir}/gitea-branding-templates-configmap.yaml"
|
||||
|
||||
extract_rendered_document \
|
||||
"${render_dir}/gitea-oidc.yaml" \
|
||||
Deployment \
|
||||
gitea \
|
||||
"$gitea_deployment_document" || \
|
||||
fail "the Gitea render must contain exactly one gitea Deployment"
|
||||
extract_rendered_document \
|
||||
"${render_dir}/gitea-oidc.yaml" \
|
||||
NetworkPolicy \
|
||||
gitea-allow-host-nginx-keycloak \
|
||||
"$gitea_keycloak_policy_document" || \
|
||||
fail "the Gitea render must contain exactly one dedicated Keycloak egress NetworkPolicy"
|
||||
|
||||
# Assert only references to the externally-created OIDC credential Secret.
|
||||
# No credential payload is rendered, decoded, read, or printed by these checks.
|
||||
rg --quiet --multiline \
|
||||
'(?s)- name: GITEA_OAUTH_KEY_0\n[[:space:]]+valueFrom:\n[[:space:]]+secretKeyRef:\n[[:space:]]+key: key\n[[:space:]]+name: gitea-keycloak-oidc' \
|
||||
"$gitea_deployment_document" || \
|
||||
fail "the Gitea Deployment does not reference gitea-keycloak-oidc key=key"
|
||||
rg --quiet --multiline \
|
||||
'(?s)- name: GITEA_OAUTH_SECRET_0\n[[:space:]]+valueFrom:\n[[:space:]]+secretKeyRef:\n[[:space:]]+key: secret\n[[:space:]]+name: gitea-keycloak-oidc' \
|
||||
"$gitea_deployment_document" || \
|
||||
fail "the Gitea Deployment does not reference gitea-keycloak-oidc key=secret"
|
||||
[[ "$(rg --count-matches --fixed-strings \
|
||||
'https://id.learn.hyeonworks.com/realms/hyeonworks/.well-known/openid-configuration' \
|
||||
"${render_dir}/gitea-oidc.yaml" || true)" == "2" ]] || \
|
||||
fail "the Gitea OAuth add/update script must use the exact Keycloak discovery URL"
|
||||
|
||||
# These are non-sensitive app.ini policy values rendered by the pinned Chart.
|
||||
for expected_setting in \
|
||||
'ALLOW_ONLY_EXTERNAL_REGISTRATION=true' \
|
||||
'DISABLE_REGISTRATION=false' \
|
||||
'SHOW_REGISTRATION_BUTTON=false' \
|
||||
'ENABLE_PASSWORD_SIGNIN_FORM=true' \
|
||||
'ENABLE_AUTO_REGISTRATION=true' \
|
||||
'USERNAME=preferred_username' \
|
||||
'ACCOUNT_LINKING=login' \
|
||||
'OPENID_CONNECT_SCOPES=profile email'; do
|
||||
[[ "$(rg --count-matches --fixed-strings "$expected_setting" \
|
||||
"${render_dir}/gitea-oidc.yaml" || true)" == "1" ]] || \
|
||||
fail "the Gitea render must contain exactly one ${expected_setting} setting"
|
||||
done
|
||||
|
||||
if rg --quiet '^[[:space:]]*hostAliases:' "$gitea_deployment_document"; then
|
||||
fail "the Gitea Deployment must rely on CoreDNS and must not contain hostAliases"
|
||||
fi
|
||||
|
||||
[[ "$(rg --count-matches \
|
||||
'^[[:space:]]*cidr:[[:space:]]*192\.168\.0\.107/32[[:space:]]*$' \
|
||||
"$gitea_keycloak_policy_document" || true)" == "1" ]] || \
|
||||
fail "the dedicated Keycloak egress policy must allow exactly 192.168.0.107/32"
|
||||
[[ "$(rg --count-matches \
|
||||
'^[[:space:]]*-[[:space:]]*port:[[:space:]]*443[[:space:]]*$' \
|
||||
"$gitea_keycloak_policy_document" || true)" == "1" ]] || \
|
||||
fail "the dedicated Keycloak egress policy must allow exactly TCP port 443"
|
||||
[[ "$(rg --count-matches \
|
||||
'^[[:space:]]*protocol:[[:space:]]*TCP[[:space:]]*$' \
|
||||
"$gitea_keycloak_policy_document" || true)" == "1" ]] || \
|
||||
fail "the dedicated Keycloak egress policy must use TCP"
|
||||
rg --quiet \
|
||||
'^[[:space:]]*-[[:space:]]*Egress[[:space:]]*$' \
|
||||
"$gitea_keycloak_policy_document" || \
|
||||
fail "the dedicated Keycloak NetworkPolicy must select egress traffic"
|
||||
|
||||
gitea_branding_assets_configmap_name="$(
|
||||
(rg --only-matching --no-filename \
|
||||
'gitea-branding-assets-[a-z0-9]+' \
|
||||
"${render_dir}/gitea-oidc.yaml" || true) |
|
||||
LC_ALL=C sort --unique
|
||||
)"
|
||||
gitea_branding_templates_configmap_name="$(
|
||||
(rg --only-matching --no-filename \
|
||||
'gitea-branding-templates-[a-z0-9]+' \
|
||||
"${render_dir}/gitea-oidc.yaml" || true) |
|
||||
LC_ALL=C sort --unique
|
||||
)"
|
||||
[[ "$gitea_branding_assets_configmap_name" =~ ^gitea-branding-assets-[a-z0-9]{10}$ ]] || \
|
||||
fail "the Gitea branding assets ConfigMap must have one Kustomize content hash"
|
||||
[[ "$gitea_branding_templates_configmap_name" =~ ^gitea-branding-templates-[a-z0-9]{10}$ ]] || \
|
||||
fail "the Gitea branding templates ConfigMap must have one Kustomize content hash"
|
||||
|
||||
extract_rendered_document \
|
||||
"${render_dir}/gitea-oidc.yaml" \
|
||||
ConfigMap \
|
||||
"$gitea_branding_assets_configmap_name" \
|
||||
"$gitea_branding_assets_document" || \
|
||||
fail "the Gitea render must contain exactly one branding assets ConfigMap"
|
||||
extract_rendered_document \
|
||||
"${render_dir}/gitea-oidc.yaml" \
|
||||
ConfigMap \
|
||||
"$gitea_branding_templates_configmap_name" \
|
||||
"$gitea_branding_templates_document" || \
|
||||
fail "the Gitea render must contain exactly one branding templates ConfigMap"
|
||||
|
||||
for asset_key in hyeonworks.css logo.svg favicon.svg; do
|
||||
[[ "$(rg --count-matches \
|
||||
"^[[:space:]]{2}${asset_key//./\\.}:[[:space:]]*\\|[+-]?[[:space:]]*$" \
|
||||
"$gitea_branding_assets_document" || true)" == "1" ]] || \
|
||||
fail "the branding assets ConfigMap must contain exactly one ${asset_key}"
|
||||
done
|
||||
for template_key in header.tmpl extra_links.tmpl; do
|
||||
[[ "$(rg --count-matches \
|
||||
"^[[:space:]]{2}${template_key//./\\.}:[[:space:]]*\\|[+-]?[[:space:]]*$" \
|
||||
"$gitea_branding_templates_document" || true)" == "1" ]] || \
|
||||
fail "the branding templates ConfigMap must contain exactly one ${template_key}"
|
||||
done
|
||||
|
||||
[[ "$(rg --count-matches --fixed-strings \
|
||||
"$gitea_branding_assets_configmap_name" \
|
||||
"$gitea_deployment_document" || true)" == "1" ]] || \
|
||||
fail "the Gitea Deployment must reference the hashed branding assets ConfigMap once"
|
||||
[[ "$(rg --count-matches --fixed-strings \
|
||||
"$gitea_branding_templates_configmap_name" \
|
||||
"$gitea_deployment_document" || true)" == "1" ]] || \
|
||||
fail "the Gitea Deployment must reference the hashed branding templates ConfigMap once"
|
||||
rg --quiet --multiline \
|
||||
'(?s)- mountPath: /data/gitea/public/assets\n[[:space:]]+name: branding-assets\n[[:space:]]+readOnly: true' \
|
||||
"$gitea_deployment_document" || \
|
||||
fail "the Gitea branding assets must be mounted read-only at the official custom path"
|
||||
rg --quiet --multiline \
|
||||
'(?s)- mountPath: /data/gitea/templates/custom\n[[:space:]]+name: branding-templates\n[[:space:]]+readOnly: true' \
|
||||
"$gitea_deployment_document" || \
|
||||
fail "the Gitea branding templates must be mounted read-only at the official custom path"
|
||||
|
||||
if rg --quiet '^[[:space:]]*type:[[:space:]]*(NodePort|LoadBalancer)[[:space:]]*$' \
|
||||
"${render_dir}/platform-postgres.yaml" \
|
||||
"${render_dir}/gitea.yaml" \
|
||||
"${render_dir}/gitea-oidc.yaml"; then
|
||||
fail "an application service is exposed as NodePort or LoadBalancer"
|
||||
fi
|
||||
|
||||
if rg --quiet '^[[:space:]]*name:[[:space:]]*gitea-ssh[[:space:]]*$' \
|
||||
"${render_dir}/gitea.yaml" "${render_dir}/gitea-oidc.yaml"; then
|
||||
fail "the disabled Gitea SSH Service is still rendered"
|
||||
fi
|
||||
|
||||
if [[ -n "$verified_output_dir" ]]; then
|
||||
for manifest_name in "${RENDERED_MANIFEST_NAMES[@]}"; do
|
||||
source_manifest="${render_dir}/${manifest_name}.yaml"
|
||||
output_manifest="${verified_output_dir}/${manifest_name}.yaml"
|
||||
|
||||
[[ -f "$source_manifest" && ! -L "$source_manifest" && -s "$source_manifest" ]] || \
|
||||
fail "validated manifest is missing or unsafe: ${source_manifest}"
|
||||
install -m 0600 -- "$source_manifest" "$output_manifest"
|
||||
[[ -f "$output_manifest" && ! -L "$output_manifest" && -s "$output_manifest" ]] || \
|
||||
fail "verified manifest handoff failed: ${output_manifest}"
|
||||
cmp --silent -- "$source_manifest" "$output_manifest" || \
|
||||
fail "verified manifest changed during handoff: ${manifest_name}.yaml"
|
||||
done
|
||||
verified_entry_count="$(find "$verified_output_dir" -mindepth 1 -maxdepth 1 | wc -l | tr -d '[:space:]')"
|
||||
[[ "$verified_entry_count" == "${#RENDERED_MANIFEST_NAMES[@]}" ]] || \
|
||||
fail "verified output directory does not contain exactly six manifest files"
|
||||
printf 'Preserved six verified manifests for the apply handoff.\n'
|
||||
fi
|
||||
|
||||
printf 'Phase 1 baseline and Gitea OIDC desired rendering invariants passed.\n'
|
||||
printf 'Temporary rendered manifests and generated chart caches will be removed on exit.\n'
|
||||
Executable
+439
@@ -0,0 +1,439 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly EXPECTED_KUSTOMIZE_VERSION="v5.8.1"
|
||||
readonly EXPECTED_HELM_VERSION="v3.19.4"
|
||||
readonly TARGET_KUBERNETES_VERSION="1.36.2"
|
||||
readonly AISTOR_OPERATOR_CHART_NAME="aistor-operator"
|
||||
readonly AISTOR_OPERATOR_CHART_VERSION="5.10.0"
|
||||
readonly EXPECTED_AISTOR_OPERATOR_SHA256="e5534f5ae4f6f12a3528a8cda5954d73280cba1a8e979e1628fbf3aac76babd1"
|
||||
readonly AISTOR_OBJECTSTORE_CHART_NAME="aistor-objectstore"
|
||||
readonly AISTOR_OBJECTSTORE_CHART_VERSION="1.0.16"
|
||||
readonly EXPECTED_AISTOR_OBJECTSTORE_SHA256="50ffa6a4e014cdc48566b237593baab41d039f103cdc50c1b1ac173b8d8bf71e"
|
||||
readonly AISTOR_CHART_REPOSITORY="https://helm.min.io/"
|
||||
readonly REPOSITORY_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd -P)"
|
||||
readonly -a RENDERED_MANIFEST_NAMES=(
|
||||
phase2-namespaces
|
||||
aistor-local-pv
|
||||
keycloak-operator
|
||||
platform-postgres-keycloak
|
||||
keycloak
|
||||
aistor-operator
|
||||
minio-aistor
|
||||
aistor-network-policies
|
||||
)
|
||||
|
||||
fail() {
|
||||
printf 'ERROR: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
assert_regex_count() {
|
||||
local file="$1"
|
||||
local pattern="$2"
|
||||
local expected="$3"
|
||||
local description="$4"
|
||||
local actual
|
||||
|
||||
[[ -f "$file" ]] || fail "missing validation input: ${file}"
|
||||
actual="$(rg --count --no-filename -- "$pattern" "$file" || true)"
|
||||
actual="${actual:-0}"
|
||||
[[ "$actual" == "$expected" ]] || \
|
||||
fail "${description}: expected ${expected}, found ${actual}"
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage:
|
||||
bash scripts/validate/render-phase2.sh
|
||||
bash scripts/validate/render-phase2.sh \
|
||||
--verified-output-dir /tmp/platform-phase2-apply.XXXXXX
|
||||
|
||||
The output option is an internal handoff used only by the AIStor apply script.
|
||||
The destination must be an existing, empty, non-symlink directory created
|
||||
below /tmp with the platform-phase2-apply.* prefix.
|
||||
USAGE
|
||||
}
|
||||
|
||||
verified_output_dir=""
|
||||
case "$#" in
|
||||
0)
|
||||
;;
|
||||
2)
|
||||
[[ "$1" == "--verified-output-dir" ]] || {
|
||||
usage >&2
|
||||
exit 2
|
||||
}
|
||||
verified_output_dir="$2"
|
||||
;;
|
||||
*)
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ -n "$verified_output_dir" ]]; then
|
||||
[[ "$verified_output_dir" == /tmp/platform-phase2-apply.* ]] || \
|
||||
fail "verified output directory must match /tmp/platform-phase2-apply.*"
|
||||
[[ -d "$verified_output_dir" && ! -L "$verified_output_dir" ]] || \
|
||||
fail "verified output directory must be an existing non-symlink directory"
|
||||
[[ "$(cd -- "$verified_output_dir" && pwd -P)" == "$verified_output_dir" ]] || \
|
||||
fail "verified output directory must be an absolute canonical path"
|
||||
[[ -O "$verified_output_dir" ]] || \
|
||||
fail "verified output directory must be owned by the current user"
|
||||
[[ "$(stat --format='%a' -- "$verified_output_dir")" == "700" ]] || \
|
||||
fail "verified output directory must have mode 0700"
|
||||
[[ -z "$(find "$verified_output_dir" -mindepth 1 -maxdepth 1 -print -quit)" ]] || \
|
||||
fail "verified output directory must be empty"
|
||||
fi
|
||||
|
||||
for command_name in cmp find install kubectl rg sha256sum stat tar; do
|
||||
command -v "$command_name" >/dev/null 2>&1 || \
|
||||
fail "${command_name} is required"
|
||||
done
|
||||
|
||||
if [[ -n "${PLATFORM_HELM_BIN:-}" ]]; then
|
||||
[[ "$PLATFORM_HELM_BIN" == /* ]] || \
|
||||
fail "PLATFORM_HELM_BIN must be an absolute path"
|
||||
[[ -f "$PLATFORM_HELM_BIN" && -x "$PLATFORM_HELM_BIN" ]] || \
|
||||
fail "PLATFORM_HELM_BIN is not an executable file: ${PLATFORM_HELM_BIN}"
|
||||
readonly HELM_BIN="$PLATFORM_HELM_BIN"
|
||||
else
|
||||
HELM_BIN="$(command -v helm 2>/dev/null)" || \
|
||||
fail "Helm ${EXPECTED_HELM_VERSION} is required"
|
||||
readonly HELM_BIN
|
||||
fi
|
||||
|
||||
kustomize_version="$(kubectl version --client --output=yaml | sed -n 's/^kustomizeVersion: //p')"
|
||||
helm_version="$("$HELM_BIN" version --template '{{.Version}}')"
|
||||
[[ "$kustomize_version" == "$EXPECTED_KUSTOMIZE_VERSION" ]] || \
|
||||
fail "expected Kustomize ${EXPECTED_KUSTOMIZE_VERSION}, found ${kustomize_version:-unknown}"
|
||||
[[ "$helm_version" == "$EXPECTED_HELM_VERSION" ]] || \
|
||||
fail "expected Helm ${EXPECTED_HELM_VERSION}, found ${helm_version:-unknown}"
|
||||
|
||||
printf 'Validating the Phase 1 baseline and Gitea OIDC desired profile first.\n'
|
||||
PLATFORM_HELM_BIN="$HELM_BIN" \
|
||||
bash "${REPOSITORY_ROOT}/scripts/validate/render-phase1.sh"
|
||||
|
||||
render_dir="$(mktemp -d "${TMPDIR:-/tmp}/platform-phase2-render.XXXXXX")"
|
||||
declare -a generated_chart_cache_dirs=()
|
||||
declare -a generated_chart_cache_parent_dirs=()
|
||||
|
||||
cleanup() {
|
||||
local cache_dir
|
||||
local parent_dir
|
||||
|
||||
for cache_dir in "${generated_chart_cache_dirs[@]}"; do
|
||||
case "$cache_dir" in
|
||||
"${REPOSITORY_ROOT}/infrastructure/controllers/aistor-operator/.helm/charts/aistor-operator-5.10.0"|\
|
||||
"${REPOSITORY_ROOT}/services/minio-aistor/base/deployment/.helm/charts/aistor-objectstore-1.0.16")
|
||||
rm -rf -- "$cache_dir"
|
||||
;;
|
||||
*)
|
||||
printf 'WARNING: refusing to remove unexpected chart cache: %s\n' \
|
||||
"$cache_dir" >&2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
for parent_dir in "${generated_chart_cache_parent_dirs[@]}"; do
|
||||
case "$parent_dir" in
|
||||
"${REPOSITORY_ROOT}/infrastructure/controllers/aistor-operator/.helm/charts"|\
|
||||
"${REPOSITORY_ROOT}/infrastructure/controllers/aistor-operator/.helm"|\
|
||||
"${REPOSITORY_ROOT}/services/minio-aistor/base/deployment/.helm/charts"|\
|
||||
"${REPOSITORY_ROOT}/services/minio-aistor/base/deployment/.helm")
|
||||
rmdir -- "$parent_dir" 2>/dev/null || true
|
||||
;;
|
||||
*)
|
||||
printf 'WARNING: refusing to remove unexpected chart cache parent: %s\n' \
|
||||
"$parent_dir" >&2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
|
||||
case "$render_dir" in
|
||||
/tmp/platform-phase2-render.*|"${TMPDIR:-/tmp}"/platform-phase2-render.*)
|
||||
rm -rf -- "$render_dir"
|
||||
;;
|
||||
*)
|
||||
printf 'WARNING: refusing to remove unexpected render directory: %s\n' \
|
||||
"$render_dir" >&2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
render_plain() {
|
||||
local label="$1"
|
||||
local relative_path="$2"
|
||||
local output="${render_dir}/${label}.yaml"
|
||||
|
||||
kubectl kustomize "${REPOSITORY_ROOT}/${relative_path}" >"$output"
|
||||
[[ -s "$output" ]] || fail "${label} rendered an empty manifest"
|
||||
printf 'Rendered %-24s %8s bytes\n' \
|
||||
"$label" "$(wc -c <"$output" | tr -d '[:space:]')"
|
||||
}
|
||||
|
||||
render_helm() {
|
||||
local label="$1"
|
||||
local relative_path="$2"
|
||||
local output="${render_dir}/${label}.yaml"
|
||||
|
||||
kubectl kustomize \
|
||||
--enable-helm \
|
||||
--helm-command "$HELM_BIN" \
|
||||
--helm-kube-version "$TARGET_KUBERNETES_VERSION" \
|
||||
"${REPOSITORY_ROOT}/${relative_path}" >"$output"
|
||||
[[ -s "$output" ]] || fail "${label} rendered an empty manifest"
|
||||
printf 'Rendered %-24s %8s bytes\n' \
|
||||
"$label" "$(wc -c <"$output" | tr -d '[:space:]')"
|
||||
}
|
||||
|
||||
prepare_verified_chart_cache() {
|
||||
local label="$1"
|
||||
local chart_name="$2"
|
||||
local chart_version="$3"
|
||||
local build_root_relative_path="$4"
|
||||
local expected_digest="$5"
|
||||
local package_path="${render_dir}/${chart_name}-${chart_version}.tgz"
|
||||
local cache_version_dir="${REPOSITORY_ROOT}/${build_root_relative_path}/.helm/charts/${chart_name}-${chart_version}"
|
||||
local checksum_output
|
||||
local actual_digest
|
||||
local chart_cache_dir="${cache_version_dir%/*}"
|
||||
local helm_cache_dir="${chart_cache_dir%/*}"
|
||||
local cache_parent
|
||||
|
||||
case "$cache_version_dir" in
|
||||
"${REPOSITORY_ROOT}/infrastructure/controllers/aistor-operator/.helm/charts/aistor-operator-5.10.0"|\
|
||||
"${REPOSITORY_ROOT}/services/minio-aistor/base/deployment/.helm/charts/aistor-objectstore-1.0.16")
|
||||
;;
|
||||
*)
|
||||
fail "refusing to create unexpected chart cache: ${cache_version_dir}"
|
||||
;;
|
||||
esac
|
||||
for cache_parent in "$helm_cache_dir" "$chart_cache_dir"; do
|
||||
[[ ! -L "$cache_parent" ]] || \
|
||||
fail "refusing symlinked chart cache parent: ${cache_parent}"
|
||||
[[ ! -e "$cache_parent" || -d "$cache_parent" ]] || \
|
||||
fail "chart cache parent is not a directory: ${cache_parent}"
|
||||
done
|
||||
|
||||
[[ ! -e "$cache_version_dir" && ! -L "$cache_version_dir" ]] || \
|
||||
fail "generated chart cache already exists; remove it after confirming it is disposable: ${cache_version_dir}"
|
||||
|
||||
"$HELM_BIN" pull "$chart_name" \
|
||||
--repo "$AISTOR_CHART_REPOSITORY" \
|
||||
--version "$chart_version" \
|
||||
--destination "$render_dir"
|
||||
|
||||
[[ -f "$package_path" ]] || \
|
||||
fail "${label} chart package was not downloaded: ${package_path}"
|
||||
checksum_output="$(sha256sum -- "$package_path")"
|
||||
actual_digest="${checksum_output%% *}"
|
||||
[[ "$actual_digest" == "$expected_digest" ]] || \
|
||||
fail "${label} chart SHA-256 mismatch: expected ${expected_digest}, found ${actual_digest}"
|
||||
|
||||
[[ -e "$chart_cache_dir" ]] || generated_chart_cache_parent_dirs+=("$chart_cache_dir")
|
||||
[[ -e "$helm_cache_dir" ]] || generated_chart_cache_parent_dirs+=("$helm_cache_dir")
|
||||
mkdir -p -- "$cache_version_dir"
|
||||
generated_chart_cache_dirs+=("$cache_version_dir")
|
||||
tar -xzf "$package_path" -C "$cache_version_dir"
|
||||
[[ -f "${cache_version_dir}/${chart_name}/Chart.yaml" ]] || \
|
||||
fail "${label} extracted chart is missing Chart.yaml"
|
||||
printf 'Verified %-24s SHA-256 %s\n' "$label" "$actual_digest"
|
||||
}
|
||||
|
||||
cd -- "$REPOSITORY_ROOT"
|
||||
|
||||
if rg --line-number --glob '*.yaml' --glob '*.yml' \
|
||||
--glob '!**/.helm/**' --glob '!**/charts/**' \
|
||||
'^[[:space:]]*kind:[[:space:]]*Secret[[:space:]]*$' \
|
||||
infrastructure services bootstrap clusters components; then
|
||||
fail "a source-controlled Kubernetes Secret manifest was found"
|
||||
fi
|
||||
|
||||
if rg --line-number --glob 'kustomization.yaml' \
|
||||
'LoadRestrictionsNone|load-restrictor' .; then
|
||||
fail "the repository must keep Kustomize LoadRestrictionsRootOnly"
|
||||
fi
|
||||
|
||||
keycloak_operator_kustomization="${REPOSITORY_ROOT}/infrastructure/controllers/keycloak-operator/kustomization.yaml"
|
||||
aistor_operator_kustomization="${REPOSITORY_ROOT}/infrastructure/controllers/aistor-operator/kustomization.yaml"
|
||||
aistor_objectstore_kustomization="${REPOSITORY_ROOT}/services/minio-aistor/base/deployment/kustomization.yaml"
|
||||
aistor_operator_values="${REPOSITORY_ROOT}/infrastructure/controllers/aistor-operator/values/home.yaml"
|
||||
aistor_objectstore_values="${REPOSITORY_ROOT}/services/minio-aistor/base/deployment/values/home.yaml"
|
||||
|
||||
assert_regex_count "$keycloak_operator_kustomization" '^[[:space:]]*-[[:space:]]github\.com/keycloak/keycloak-k8s-resources/kubernetes\?ref=26\.7\.0[[:space:]]*$' 1 "Keycloak Operator remote ref must be 26.7.0"
|
||||
assert_regex_count "$keycloak_operator_kustomization" 'github\.com/keycloak/keycloak-k8s-resources/kubernetes' 1 "exactly one Keycloak Operator remote resource is allowed"
|
||||
assert_regex_count "$keycloak_operator_kustomization" '\?ref=' 1 "exactly one pinned remote ref is allowed in the Keycloak Operator root"
|
||||
|
||||
assert_regex_count "$aistor_operator_kustomization" '^[[:space:]]*-[[:space:]]name:' 1 "AIStor Operator root must contain one Helm chart"
|
||||
assert_regex_count "$aistor_operator_kustomization" '^[[:space:]]*-[[:space:]]name:[[:space:]]aistor-operator[[:space:]]*$' 1 "AIStor Operator chart name is pinned"
|
||||
assert_regex_count "$aistor_operator_kustomization" '^[[:space:]]*repo:[[:space:]]https://helm\.min\.io/[[:space:]]*$' 1 "AIStor Operator chart repository is pinned"
|
||||
assert_regex_count "$aistor_operator_kustomization" '^[[:space:]]*version:[[:space:]]5\.10\.0[[:space:]]*$' 1 "AIStor Operator chart version is pinned"
|
||||
assert_regex_count "$aistor_operator_kustomization" '^[[:space:]]*valuesFile:[[:space:]]values/home\.yaml[[:space:]]*$' 1 "AIStor Operator values file is pinned"
|
||||
assert_regex_count "$aistor_operator_kustomization" '^[[:space:]]*includeCRDs:[[:space:]]true[[:space:]]*$' 1 "AIStor Operator CRDs must be included"
|
||||
assert_regex_count "$aistor_operator_kustomization" '^[[:space:]]*chartHome:[[:space:]]\.helm/charts[[:space:]]*$' 1 "AIStor Operator chartHome is pinned"
|
||||
|
||||
assert_regex_count "$aistor_objectstore_kustomization" '^[[:space:]]*-[[:space:]]name:' 1 "AIStor ObjectStore root must contain one Helm chart"
|
||||
assert_regex_count "$aistor_objectstore_kustomization" '^[[:space:]]*-[[:space:]]name:[[:space:]]aistor-objectstore[[:space:]]*$' 1 "AIStor ObjectStore chart name is pinned"
|
||||
assert_regex_count "$aistor_objectstore_kustomization" '^[[:space:]]*repo:[[:space:]]https://helm\.min\.io/[[:space:]]*$' 1 "AIStor ObjectStore chart repository is pinned"
|
||||
assert_regex_count "$aistor_objectstore_kustomization" '^[[:space:]]*version:[[:space:]]1\.0\.16[[:space:]]*$' 1 "AIStor ObjectStore chart version is pinned"
|
||||
assert_regex_count "$aistor_objectstore_kustomization" '^[[:space:]]*valuesFile:[[:space:]]values/home\.yaml[[:space:]]*$' 1 "AIStor ObjectStore values file is pinned"
|
||||
assert_regex_count "$aistor_objectstore_kustomization" '^[[:space:]]*includeCRDs:[[:space:]]false[[:space:]]*$' 1 "AIStor ObjectStore root must not duplicate CRDs"
|
||||
assert_regex_count "$aistor_objectstore_kustomization" '^[[:space:]]*chartHome:[[:space:]]\.helm/charts[[:space:]]*$' 1 "AIStor ObjectStore chartHome is pinned"
|
||||
|
||||
assert_regex_count "$aistor_operator_values" '^license:[[:space:]]*""[[:space:]]*$' 1 "AIStor license payload must not be rendered from values"
|
||||
assert_regex_count "$aistor_objectstore_values" '^[[:space:]]*existingSecret:[[:space:]]true[[:space:]]*$' 1 "AIStor ObjectStore must use an existing Secret"
|
||||
assert_regex_count "$aistor_objectstore_values" '^[[:space:]]*name:[[:space:]]aistor-root-configuration[[:space:]]*$' 2 "AIStor configuration Secret references are pinned"
|
||||
assert_regex_count "$aistor_objectstore_values" '^[[:space:]]*serviceType:[[:space:]]ClusterIP[[:space:]]*$' 2 "AIStor S3 and Console services must be ClusterIP"
|
||||
|
||||
|
||||
|
||||
render_plain phase2-namespaces infrastructure/namespaces/phase2
|
||||
render_plain aistor-local-pv infrastructure/storage/aistor-local-pv
|
||||
render_plain aistor-network-policies infrastructure/networking/aistor
|
||||
render_plain keycloak-operator infrastructure/controllers/keycloak-operator
|
||||
render_plain platform-postgres-keycloak services/platform-postgres-keycloak
|
||||
render_plain keycloak services/keycloak
|
||||
|
||||
prepare_verified_chart_cache \
|
||||
aistor-operator-chart \
|
||||
"$AISTOR_OPERATOR_CHART_NAME" \
|
||||
"$AISTOR_OPERATOR_CHART_VERSION" \
|
||||
infrastructure/controllers/aistor-operator \
|
||||
"$EXPECTED_AISTOR_OPERATOR_SHA256"
|
||||
render_helm aistor-operator infrastructure/controllers/aistor-operator
|
||||
|
||||
prepare_verified_chart_cache \
|
||||
aistor-objectstore-chart \
|
||||
"$AISTOR_OBJECTSTORE_CHART_NAME" \
|
||||
"$AISTOR_OBJECTSTORE_CHART_VERSION" \
|
||||
services/minio-aistor/base/deployment \
|
||||
"$EXPECTED_AISTOR_OBJECTSTORE_SHA256"
|
||||
render_helm minio-aistor services/minio-aistor
|
||||
|
||||
if rg --line-number '^[[:space:]]*kind:[[:space:]]*Secret[[:space:]]*$' \
|
||||
"${render_dir}/phase2-namespaces.yaml" \
|
||||
"${render_dir}/aistor-local-pv.yaml" \
|
||||
"${render_dir}/keycloak-operator.yaml" \
|
||||
"${render_dir}/platform-postgres-keycloak.yaml" \
|
||||
"${render_dir}/keycloak.yaml" \
|
||||
"${render_dir}/aistor-operator.yaml" \
|
||||
"${render_dir}/minio-aistor.yaml" \
|
||||
"${render_dir}/aistor-network-policies.yaml"; then
|
||||
fail "a Phase 2 render unexpectedly contains a Secret"
|
||||
fi
|
||||
|
||||
|
||||
assert_regex_count "${render_dir}/phase2-namespaces.yaml" '^kind: Namespace$' 3 "Phase 2 namespace count"
|
||||
assert_regex_count "${render_dir}/aistor-local-pv.yaml" '^kind: StorageClass$' 1 "AIStor StorageClass count"
|
||||
assert_regex_count "${render_dir}/aistor-local-pv.yaml" '^kind: PersistentVolume$' 1 "AIStor PersistentVolume count"
|
||||
assert_regex_count "${render_dir}/aistor-network-policies.yaml" '^kind: NetworkPolicy$' 11 "AIStor NetworkPolicy count"
|
||||
assert_regex_count "${render_dir}/platform-postgres-keycloak.yaml" '^kind: DatabaseRole$' 1 "Keycloak DatabaseRole count"
|
||||
assert_regex_count "${render_dir}/platform-postgres-keycloak.yaml" '^kind: Database$' 1 "Keycloak Database count"
|
||||
assert_regex_count "${render_dir}/platform-postgres-keycloak.yaml" '^kind: NetworkPolicy$' 1 "Keycloak PostgreSQL NetworkPolicy count"
|
||||
assert_regex_count "${render_dir}/platform-postgres-keycloak.yaml" '^kind: Cluster$' 0 "the Phase 2 PostgreSQL extension must not contain a Cluster"
|
||||
assert_regex_count "${render_dir}/keycloak.yaml" '^kind: Keycloak$' 1 "Keycloak custom resource count"
|
||||
assert_regex_count "${render_dir}/keycloak.yaml" '^kind: Ingress$' 1 "Keycloak Ingress count"
|
||||
assert_regex_count "${render_dir}/minio-aistor.yaml" '^kind: ObjectStore$' 1 "AIStor ObjectStore count"
|
||||
|
||||
assert_regex_count "${render_dir}/keycloak.yaml" '^[[:space:]]*instances:[[:space:]]1[[:space:]]*$' 1 "Keycloak instance count field"
|
||||
assert_regex_count "${render_dir}/keycloak.yaml" '^[[:space:]]*hostname:[[:space:]]https://id\.learn\.hyeonworks\.com[[:space:]]*$' 1 "Keycloak external hostname"
|
||||
assert_regex_count "${render_dir}/keycloak.yaml" '^[[:space:]]*strict:[[:space:]]true[[:space:]]*$' 1 "Keycloak strict hostname mode"
|
||||
assert_regex_count "${render_dir}/keycloak.yaml" '^[[:space:]]*httpEnabled:[[:space:]]true[[:space:]]*$' 1 "Keycloak internal HTTP mode"
|
||||
assert_regex_count "${render_dir}/keycloak.yaml" '^[[:space:]]*httpPort:[[:space:]]8080[[:space:]]*$' 1 "Keycloak HTTP port"
|
||||
assert_regex_count "${render_dir}/keycloak.yaml" '^[[:space:]]*serviceHttpPort:[[:space:]]8080[[:space:]]*$' 1 "Keycloak Service HTTP port"
|
||||
assert_regex_count "${render_dir}/keycloak.yaml" '^[[:space:]]*headers:[[:space:]]xforwarded[[:space:]]*$' 1 "Keycloak forwarded-header mode"
|
||||
assert_regex_count "${render_dir}/keycloak.yaml" '^[[:space:]]*enabled:[[:space:]]false[[:space:]]*$' 1 "Keycloak Operator-managed Ingress must be disabled"
|
||||
assert_regex_count "${render_dir}/keycloak.yaml" '^[[:space:]]*ingressClassName:[[:space:]]traefik[[:space:]]*$' 1 "Keycloak Ingress class"
|
||||
assert_regex_count "${render_dir}/keycloak.yaml" '^[[:space:]]*-[[:space:]]host:[[:space:]]id\.learn\.hyeonworks\.com[[:space:]]*$' 1 "Keycloak Ingress host"
|
||||
assert_regex_count "${render_dir}/keycloak.yaml" '^[[:space:]]*name:[[:space:]]keycloak-service[[:space:]]*$' 1 "Keycloak Ingress backend Service"
|
||||
assert_regex_count "${render_dir}/keycloak.yaml" '^[[:space:]]*number:[[:space:]]8080[[:space:]]*$' 1 "Keycloak Ingress backend port"
|
||||
assert_regex_count "${render_dir}/keycloak.yaml" '^[[:space:]]*kubernetes\.io/metadata\.name:[[:space:]]kube-system[[:space:]]*$' 2 "Keycloak Traefik namespace selectors"
|
||||
assert_regex_count "${render_dir}/keycloak.yaml" '^[[:space:]]*app\.kubernetes\.io/name:[[:space:]]traefik[[:space:]]*$' 2 "Keycloak Traefik pod selectors"
|
||||
assert_regex_count "${render_dir}/keycloak.yaml" '^[[:space:]]*kubernetes\.io/metadata\.name:[[:space:]]keycloak[[:space:]]*$' 1 "Keycloak management namespace selector"
|
||||
assert_regex_count "${render_dir}/keycloak.yaml" '^[[:space:]]*app\.kubernetes\.io/name:[[:space:]]keycloak-operator[[:space:]]*$' 1 "Keycloak management Operator selector"
|
||||
assert_regex_count "${render_dir}/keycloak.yaml" '^[[:space:]]*xaEnabled:[[:space:]]false[[:space:]]*$' 1 "Keycloak XA transaction mode"
|
||||
assert_regex_count "${render_dir}/keycloak.yaml" ':[[:space:]]*9000[[:space:]]*$' 0 "Keycloak management port must not be exposed"
|
||||
|
||||
assert_regex_count "${render_dir}/platform-postgres-keycloak.yaml" '^[[:space:]]*name:[[:space:]]platform-postgres-keycloak[[:space:]]*$' 2 "Keycloak database and role names"
|
||||
assert_regex_count "${render_dir}/platform-postgres-keycloak.yaml" '^[[:space:]]*name:[[:space:]]keycloak-db-credentials[[:space:]]*$' 1 "Keycloak DatabaseRole Secret reference"
|
||||
assert_regex_count "${render_dir}/platform-postgres-keycloak.yaml" '^[[:space:]]*kubernetes\.io/metadata\.name:[[:space:]]keycloak[[:space:]]*$' 1 "Keycloak PostgreSQL namespace selector"
|
||||
assert_regex_count "${render_dir}/platform-postgres-keycloak.yaml" '^[[:space:]]*-[[:space:]]port:[[:space:]]5432[[:space:]]*$' 1 "Keycloak PostgreSQL ingress port"
|
||||
assert_regex_count "${render_dir}/platform-postgres-keycloak.yaml" '^[[:space:]]*cnpg\.io/cluster:[[:space:]]platform-postgres[[:space:]]*$' 1 "Keycloak PostgreSQL pod selector"
|
||||
|
||||
assert_regex_count "${render_dir}/aistor-local-pv.yaml" '^provisioner:[[:space:]]kubernetes\.io/no-provisioner[[:space:]]*$' 1 "AIStor static StorageClass provisioner"
|
||||
assert_regex_count "${render_dir}/aistor-local-pv.yaml" '^reclaimPolicy:[[:space:]]Retain[[:space:]]*$' 1 "AIStor StorageClass reclaim policy"
|
||||
assert_regex_count "${render_dir}/aistor-local-pv.yaml" '^volumeBindingMode:[[:space:]]WaitForFirstConsumer[[:space:]]*$' 1 "AIStor volume binding mode"
|
||||
assert_regex_count "${render_dir}/aistor-local-pv.yaml" '^[[:space:]]*persistentVolumeReclaimPolicy:[[:space:]]Retain[[:space:]]*$' 1 "AIStor PersistentVolume reclaim policy"
|
||||
assert_regex_count "${render_dir}/aistor-local-pv.yaml" '^[[:space:]]*storage:[[:space:]]900Gi[[:space:]]*$' 1 "AIStor Local PV capacity"
|
||||
assert_regex_count "${render_dir}/aistor-local-pv.yaml" '^[[:space:]]*path:[[:space:]]/srv/k3s/aistor[[:space:]]*$' 1 "AIStor Local PV host path"
|
||||
assert_regex_count "${render_dir}/aistor-local-pv.yaml" '^[[:space:]]*-[[:space:]]donghyeon-system-product-name[[:space:]]*$' 1 "AIStor Local PV node affinity"
|
||||
assert_regex_count "${render_dir}/aistor-local-pv.yaml" '^[[:space:]]*platform\.hyeonworks\.com/filesystem:[[:space:]]xfs[[:space:]]*$' 1 "AIStor Local PV filesystem label"
|
||||
assert_regex_count "${render_dir}/aistor-local-pv.yaml" '^[[:space:]]*storageClassName:[[:space:]]aistor-local-xfs-retain[[:space:]]*$' 1 "AIStor Local PV StorageClass binding"
|
||||
assert_regex_count "${render_dir}/aistor-local-pv.yaml" '^[[:space:]]*-[[:space:]]ReadWriteOnce[[:space:]]*$' 1 "AIStor Local PV access mode"
|
||||
|
||||
assert_regex_count "${render_dir}/aistor-operator.yaml" '^kind: CustomResourceDefinition$' 3 "AIStor Operator CRD count"
|
||||
assert_regex_count "${render_dir}/aistor-operator.yaml" '^kind: Deployment$' 3 "AIStor Operator Deployment count"
|
||||
assert_regex_count "${render_dir}/aistor-operator.yaml" '^[[:space:]]*caBundle:' 0 "AIStor webhook runtime CA must not be declaratively owned"
|
||||
assert_regex_count "${render_dir}/aistor-network-policies.yaml" '^[[:space:]]*namespace:[[:space:]]aistor$' 5 "AIStor control-plane NetworkPolicy namespace count"
|
||||
assert_regex_count "${render_dir}/aistor-network-policies.yaml" '^[[:space:]]*namespace:[[:space:]]object-storage$' 6 "AIStor data-plane NetworkPolicy namespace count"
|
||||
assert_regex_count "${render_dir}/aistor-network-policies.yaml" '^[[:space:]]*-[[:space:]]port:[[:space:]]8443$' 1 "AIStor admission webhook policy port"
|
||||
assert_regex_count "${render_dir}/aistor-network-policies.yaml" '^[[:space:]]*-[[:space:]]port:[[:space:]]4221$' 2 "AIStor upgrade service policy port"
|
||||
assert_regex_count "${render_dir}/minio-aistor.yaml" '^[[:space:]]*servers:[[:space:]]1[[:space:]]*$' 1 "AIStor server count"
|
||||
assert_regex_count "${render_dir}/minio-aistor.yaml" '^[[:space:]]*volumesPerServer:[[:space:]]1[[:space:]]*$' 1 "AIStor volume count"
|
||||
assert_regex_count "${render_dir}/minio-aistor.yaml" '^[[:space:]]*storage:[[:space:]]900Gi[[:space:]]*$' 1 "AIStor claim size"
|
||||
assert_regex_count "${render_dir}/minio-aistor.yaml" '^[[:space:]]*storageClassName:[[:space:]]aistor-local-xfs-retain[[:space:]]*$' 1 "AIStor claim StorageClass"
|
||||
assert_regex_count "${render_dir}/minio-aistor.yaml" '^[[:space:]]*name:[[:space:]]aistor-root-configuration[[:space:]]*$' 1 "AIStor configuration Secret reference"
|
||||
assert_regex_count "${render_dir}/minio-aistor.yaml" '^[[:space:]]*pvcProtection:[[:space:]]true[[:space:]]*$' 1 "AIStor PVC protection"
|
||||
assert_regex_count "${render_dir}/minio-aistor.yaml" '^[[:space:]]*serviceType:[[:space:]]ClusterIP[[:space:]]*$' 2 "AIStor S3 and Console Service types"
|
||||
assert_regex_count "${render_dir}/minio-aistor.yaml" '^[[:space:]]*disableAutoCert:[[:space:]]true[[:space:]]*$' 1 "AIStor in-cluster TLS mode"
|
||||
assert_regex_count "${render_dir}/minio-aistor.yaml" '^[[:space:]]*kubernetes\.io/hostname:[[:space:]]donghyeon-system-product-name[[:space:]]*$' 1 "AIStor ObjectStore node selector"
|
||||
|
||||
if rg --quiet '^[[:space:]]*kind:[[:space:]]*Ingress[[:space:]]*$|^[[:space:]]*(serviceType|type):[[:space:]]*(NodePort|LoadBalancer)[[:space:]]*$' \
|
||||
"${render_dir}/minio-aistor.yaml"; then
|
||||
fail "AIStor must not render Ingress, NodePort, or LoadBalancer exposure"
|
||||
fi
|
||||
if rg --quiet '^[[:space:]]*tls:[[:space:]]*(\[\])?[[:space:]]*$' \
|
||||
"${render_dir}/keycloak.yaml"; then
|
||||
fail "Keycloak must not render in-cluster TLS"
|
||||
fi
|
||||
if rg --quiet '^[[:space:]]*(serviceType|type):[[:space:]]*(NodePort|LoadBalancer)[[:space:]]*$' \
|
||||
"${render_dir}/keycloak.yaml"; then
|
||||
fail "Keycloak must not render NodePort or LoadBalancer exposure"
|
||||
fi
|
||||
|
||||
if rg --quiet 'cidr:[[:space:]]*0\.0\.0\.0/0' \
|
||||
"${render_dir}/aistor-network-policies.yaml"; then
|
||||
fail "AIStor NetworkPolicies must not allow unrestricted Internet egress"
|
||||
fi
|
||||
|
||||
if [[ -n "$verified_output_dir" ]]; then
|
||||
for manifest_name in "${RENDERED_MANIFEST_NAMES[@]}"; do
|
||||
source_manifest="${render_dir}/${manifest_name}.yaml"
|
||||
output_manifest="${verified_output_dir}/${manifest_name}.yaml"
|
||||
|
||||
[[ -f "$source_manifest" && ! -L "$source_manifest" && -s "$source_manifest" ]] || \
|
||||
fail "validated manifest is missing or unsafe: ${source_manifest}"
|
||||
install -m 0600 -- "$source_manifest" "$output_manifest"
|
||||
[[ -f "$output_manifest" && ! -L "$output_manifest" && -s "$output_manifest" ]] || \
|
||||
fail "verified manifest handoff failed: ${output_manifest}"
|
||||
cmp --silent -- "$source_manifest" "$output_manifest" || \
|
||||
fail "verified manifest changed during handoff: ${manifest_name}.yaml"
|
||||
done
|
||||
verified_entry_count="$(
|
||||
find "$verified_output_dir" -mindepth 1 -maxdepth 1 |
|
||||
wc -l | tr -d '[:space:]'
|
||||
)"
|
||||
[[ "$verified_entry_count" == "${#RENDERED_MANIFEST_NAMES[@]}" ]] || \
|
||||
fail "verified output directory does not contain exactly eight manifest files"
|
||||
printf 'Preserved eight verified manifests for the AIStor apply handoff.\n'
|
||||
fi
|
||||
|
||||
printf 'Phase 2 rendering and source invariants passed.\n'
|
||||
printf 'No live ObjectStore CRD or Kubernetes cluster access was required.\n'
|
||||
printf 'Temporary Phase 2 rendered manifests and generated chart caches will be removed on exit.\n'
|
||||
File diff suppressed because it is too large
Load Diff
+1181
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+2197
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,751 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd -P)"
|
||||
readonly CAPTURE="$ROOT/scripts/validate/capture-observability-metric-inventory.sh"
|
||||
readonly REJECT_STATUS=23
|
||||
readonly ORIGINAL_PATH="$PATH"
|
||||
|
||||
fail() {
|
||||
printf 'TEST FAILURE: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
expect_pass() {
|
||||
local name="$1" output
|
||||
shift
|
||||
if ! output="$({ "$@"; } 2>&1)"; then
|
||||
fail "$name (expected success): $output"
|
||||
fi
|
||||
pass_count=$((pass_count + 1))
|
||||
}
|
||||
|
||||
expect_reject() {
|
||||
local name="$1" output rc=0
|
||||
shift
|
||||
output="$({ "$@"; } 2>&1)" || rc=$?
|
||||
[[ "$rc" == "$REJECT_STATUS" ]] || fail "$name (expected $REJECT_STATUS, got $rc): $output"
|
||||
[[ "$output" == REJECT:* ]] || fail "$name (unsanitized rejection): $output"
|
||||
[[ "$output" != *DO-NOT-LEAK-FIXTURE* ]] || fail "$name leaked a fixture value"
|
||||
pass_count=$((pass_count + 1))
|
||||
}
|
||||
|
||||
[[ -f "$CAPTURE" ]] || fail "EXPECTED RED: capture command is missing: $CAPTURE"
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
source "$CAPTURE"
|
||||
|
||||
fixture_root="$(mktemp -d /tmp/platform-observability-inventory-test.XXXXXX)"
|
||||
test_helper_pids=()
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
local helper_pid
|
||||
for helper_pid in "${test_helper_pids[@]}"; do
|
||||
if [[ "$helper_pid" =~ ^[0-9]+$ ]]; then kill -KILL "$helper_pid" 2>/dev/null || true; fi
|
||||
done
|
||||
if [[ "${KEEP_INVENTORY_TEST_FIXTURE:-0}" == 1 ]]; then
|
||||
printf 'KEPT FIXTURE: %s\n' "$fixture_root" >&2
|
||||
exit "$rc"
|
||||
fi
|
||||
case "$fixture_root" in
|
||||
/tmp/platform-observability-inventory-test.*) rm -rf -- "$fixture_root" ;;
|
||||
*) printf 'TEST FAILURE: refusing unsafe fixture cleanup\n' >&2; rc=1 ;;
|
||||
esac
|
||||
exit "$rc"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
pass_count=0
|
||||
|
||||
mapfile -t exact_pools <<'POOLS'
|
||||
podMonitor/platform-data/platform-postgres/0
|
||||
serviceMonitor/gitea/gitea/0
|
||||
serviceMonitor/keycloak/keycloak/0
|
||||
serviceMonitor/kube-system/traefik/0
|
||||
serviceMonitor/object-storage/aistor-bucket-usage/0
|
||||
serviceMonitor/observability-agent/alloy/0
|
||||
serviceMonitor/observability-agent/node-exporter/0
|
||||
serviceMonitor/observability/grafana/0
|
||||
serviceMonitor/observability/loki/0
|
||||
serviceMonitor/observability/observability-core-kube-pr-alertmanager/0
|
||||
serviceMonitor/observability/observability-core-kube-pr-alertmanager/1
|
||||
serviceMonitor/observability/observability-core-kube-pr-apiserver/0
|
||||
serviceMonitor/observability/observability-core-kube-pr-coredns/0
|
||||
serviceMonitor/observability/observability-core-kube-pr-kubelet/0
|
||||
serviceMonitor/observability/observability-core-kube-pr-kubelet/1
|
||||
serviceMonitor/observability/observability-core-kube-pr-kubelet/2
|
||||
serviceMonitor/observability/observability-core-kube-pr-operator/0
|
||||
serviceMonitor/observability/observability-core-kube-pr-prometheus/0
|
||||
serviceMonitor/observability/observability-core-kube-pr-prometheus/1
|
||||
serviceMonitor/observability/observability-core-kube-state-metrics/0
|
||||
serviceMonitor/observability/tempo/0
|
||||
POOLS
|
||||
|
||||
expected_target_selectors="$fixture_root/expected-target-selectors"
|
||||
expected_series_selectors="$fixture_root/expected-series-selectors"
|
||||
: >"$expected_target_selectors"
|
||||
: >"$expected_series_selectors"
|
||||
|
||||
make_targets() {
|
||||
local destination="$1" pool labels selector series_selector index=0
|
||||
local rows="$fixture_root/target-rows.jsonl"
|
||||
: >"$rows"
|
||||
for pool in "${exact_pools[@]}"; do
|
||||
labels="$(jq -cn --arg instance "10.0.0.$((index + 1)):9090" --arg target "target-$index" \
|
||||
'{job:"fixture-job",instance:$instance,sensitive_fixture:"DO-NOT-LEAK-FIXTURE",target_fixture:$target}')"
|
||||
selector="{instance=\"10.0.0.$((index + 1)):9090\",job=\"fixture-job\",sensitive_fixture=\"DO-NOT-LEAK-FIXTURE\",target_fixture=\"target-$index\"}"
|
||||
if [[ "$pool" == serviceMonitor/object-storage/aistor-bucket-usage/0 ]]; then
|
||||
labels="$(jq -cn --arg target "target-$index" \
|
||||
'{job:"minio-aistor",namespace:"object-storage",service:"minio",endpoint:"http-minio",
|
||||
container:"minio",instance:"10.42.7.19:9000",pod:"minio-aistor-pool-0-0",
|
||||
sensitive_fixture:"DO-NOT-LEAK-FIXTURE",target_fixture:$target}')"
|
||||
selector="{container=\"minio\",endpoint=\"http-minio\",instance=\"10.42.7.19:9000\",job=\"minio-aistor\",namespace=\"object-storage\",pod=\"minio-aistor-pool-0-0\",sensitive_fixture=\"DO-NOT-LEAK-FIXTURE\",service=\"minio\",target_fixture=\"target-$index\"}"
|
||||
fi
|
||||
printf '%s\n' "$selector" >>"$expected_target_selectors"
|
||||
series_selector="{__name__=\"fixture_metric\",${selector#\{}"
|
||||
printf '%s\n' "$series_selector" >>"$expected_series_selectors"
|
||||
jq -cn \
|
||||
--arg pool "$pool" \
|
||||
--arg url "http://10.42.0.$((index + 1)):9090/metrics" \
|
||||
--argjson labels "$labels" \
|
||||
'{discoveredLabels:$labels,labels:$labels,scrapePool:$pool,scrapeUrl:$url,
|
||||
globalUrl:$url,lastError:"",lastScrape:"2026-08-12T00:00:00Z",
|
||||
lastScrapeDuration:0.01,health:"up",scrapeInterval:"30s",scrapeTimeout:"10s"}' \
|
||||
>>"$rows"
|
||||
index=$((index + 1))
|
||||
done
|
||||
jq -cs '{status:"success",data:{activeTargets:.,droppedTargets:[]}}' "$rows" >"$destination"
|
||||
}
|
||||
|
||||
valid_targets="$fixture_root/targets.json"
|
||||
make_targets "$valid_targets"
|
||||
oversized_padding="$fixture_root/oversized-padding"
|
||||
head -c 8388609 /dev/zero | tr '\000' x >"$oversized_padding"
|
||||
oversized_targets="$fixture_root/oversized-targets.json"
|
||||
jq --rawfile padding "$oversized_padding" '. + {padding:$padding}' "$valid_targets" >"$oversized_targets"
|
||||
expect_pass 'exact 21 active pools accepted' validate_targets_json "$valid_targets"
|
||||
|
||||
post_targets="$fixture_root/post-targets.json"
|
||||
post_probe_rows="$fixture_root/post-probe-rows.json"
|
||||
post_substrate_expected_probe_targets | jq '[.[] as $target | $target + {
|
||||
discoveredLabels:$target.labels,globalUrl:$target.scrapeUrl,lastError:"",
|
||||
lastScrape:"2026-08-12T00:00:00Z",lastScrapeDuration:0.01,
|
||||
health:"up",scrapeInterval:"30s",scrapeTimeout:"15s"}]' >"$post_probe_rows"
|
||||
jq --slurpfile probes "$post_probe_rows" '
|
||||
.data.activeTargets += ($probes[0] + [{
|
||||
discoveredLabels:{},
|
||||
labels:{job:"blackbox-exporter",namespace:"observability",service:"blackbox-exporter",
|
||||
endpoint:"http",container:"blackbox-exporter",pod:"blackbox-exporter-6d8444ffdb-svsck",
|
||||
instance:"10.42.0.123:9115"},
|
||||
scrapePool:"serviceMonitor/observability/blackbox-exporter/0",
|
||||
scrapeUrl:"http://10.42.0.123:9115/metrics",globalUrl:"http://10.42.0.123:9115/metrics",
|
||||
lastError:"",lastScrape:"2026-08-12T00:00:00Z",lastScrapeDuration:0.01,
|
||||
health:"up",scrapeInterval:"30s",scrapeTimeout:"10s"
|
||||
}])
|
||||
' "$valid_targets" >"$post_targets"
|
||||
expect_pass 'exact post-substrate 30 targets and multiplicities accepted' \
|
||||
validate_targets_json "$post_targets" post-substrate
|
||||
expect_reject 'post-substrate targets rejected by target-initial phase' \
|
||||
validate_targets_json "$post_targets" target-initial
|
||||
post_missing_probe="$fixture_root/post-missing-probe.json"
|
||||
jq 'del(.data.activeTargets[] | select(.scrapePool=="probe/observability/platform-public-edge"))' \
|
||||
"$post_targets" >"$post_missing_probe"
|
||||
expect_reject 'post-substrate missing Probe target rejected' \
|
||||
validate_targets_json "$post_missing_probe" post-substrate
|
||||
expect_pass 'Probe query is removed from persisted scrape URL' \
|
||||
test "$(sanitize_scrape_url 'http://blackbox-exporter.observability.svc.cluster.local:9115/probe?module=http_2xx&target=https%3A%2F%2Fexample.test' strip-query)" = \
|
||||
'http://blackbox-exporter.observability.svc.cluster.local:9115/probe'
|
||||
expect_pass 'UTF-8 Prometheus label key is quoted in selector' \
|
||||
test "$(prometheus_selector_for_labels '{"job":"blackbox","observability.hyeonworks.com/probe-group":"public-edge"}')" = \
|
||||
'{job="blackbox","observability.hyeonworks.com/probe-group"="public-edge"}'
|
||||
|
||||
mutate_and_reject() {
|
||||
local name="$1" filter="$2" destination
|
||||
destination="$fixture_root/${name// /-}.json"
|
||||
jq "$filter" "$valid_targets" >"$destination"
|
||||
expect_reject "$name" validate_targets_json "$destination"
|
||||
}
|
||||
|
||||
mutate_and_reject 'missing pool rejected' 'del(.data.activeTargets[0])'
|
||||
mutate_and_reject 'extra pool rejected' '.data.activeTargets += [.data.activeTargets[0] | .scrapePool="serviceMonitor/extra/extra/0"]'
|
||||
mutate_and_reject 'duplicate pool rejected' '.data.activeTargets += [.data.activeTargets[0]]'
|
||||
mutate_and_reject 'down target rejected' '.data.activeTargets[0].health="down"'
|
||||
mutate_and_reject 'last error rejected' '.data.activeTargets[0].lastError="DO-NOT-LEAK-FIXTURE"'
|
||||
mutate_and_reject 'blackbox pool rejected' '.data.activeTargets[0].scrapePool="serviceMonitor/observability/blackbox-exporter/0"'
|
||||
mutate_and_reject 'Probe pool rejected' '.data.activeTargets[0].scrapePool="probe/observability/public-edge/0"'
|
||||
dropped_blackbox="$fixture_root/dropped-blackbox.json"
|
||||
jq '.data.droppedTargets = [{
|
||||
"discoveredLabels": {
|
||||
"__meta_kubernetes_namespace": "observability",
|
||||
"__meta_kubernetes_service_name": "blackbox-exporter",
|
||||
"job": "blackbox-exporter"
|
||||
},
|
||||
"labels": {"job": "blackbox-exporter"},
|
||||
"scrapePool": "serviceMonitor/observability/blackbox-exporter/0",
|
||||
"scrapeUrl": "http://blackbox-exporter.observability.svc:9115/metrics",
|
||||
"globalUrl": "http://blackbox-exporter.observability.svc:9115/metrics",
|
||||
"lastError": "",
|
||||
"health": "unknown"
|
||||
}]' "$valid_targets" >"$dropped_blackbox"
|
||||
expect_reject 'dropped blackbox identity rejected' validate_targets_json "$dropped_blackbox"
|
||||
expect_pass 'post-substrate allows blackbox self discovery drops' \
|
||||
validate_dropped_targets_json "$dropped_blackbox" post-substrate
|
||||
dropped_probe="$fixture_root/dropped-probe.json"
|
||||
jq '.data.droppedTargets = [{
|
||||
"discoveredLabels": {
|
||||
"__meta_kubernetes_namespace": "observability",
|
||||
"__meta_kubernetes_service_name": "synthetic-exporter",
|
||||
"monitor_kind": "Probe"
|
||||
},
|
||||
"labels": {"job": "synthetic-checks"},
|
||||
"scrapePool": "probe/observability/public-edge/0",
|
||||
"scrapeUrl": "http://synthetic-exporter.observability.svc:9115/metrics",
|
||||
"globalUrl": "http://synthetic-exporter.observability.svc:9115/metrics",
|
||||
"lastError": "",
|
||||
"health": "unknown"
|
||||
}]' "$valid_targets" >"$dropped_probe"
|
||||
expect_reject 'dropped Probe identity rejected' validate_targets_json "$dropped_probe"
|
||||
legitimate_probe_word="$fixture_root/legitimate-probe-word.json"
|
||||
jq '.data.droppedTargets = [{
|
||||
"discoveredLabels": {
|
||||
"__meta_kubernetes_pod_annotation_fixture": "{\"livenessProbe\":true}",
|
||||
"__metrics_path__": "/metrics/probes"
|
||||
},
|
||||
"labels": {"job": "platform-postgres"},
|
||||
"scrapePool": "podMonitor/platform-data/platform-postgres/0",
|
||||
"scrapeUrl": "http://10.42.0.1:9187/metrics/probes",
|
||||
"globalUrl": "http://10.42.0.1:9187/metrics/probes",
|
||||
"lastError": "",
|
||||
"health": "unknown"
|
||||
}]' "$valid_targets" >"$legitimate_probe_word"
|
||||
expect_pass 'legitimate dropped metadata containing probe words accepted in active response' \
|
||||
validate_targets_json "$legitimate_probe_word"
|
||||
expect_pass 'legitimate dropped metadata containing probe words accepted in dropped response' \
|
||||
validate_dropped_targets_json "$legitimate_probe_word"
|
||||
empty_dropped_targets="$fixture_root/empty-dropped-targets.json"
|
||||
printf '%s\n' '{"status":"success","data":{"activeTargets":[],"droppedTargets":[]}}' >"$empty_dropped_targets"
|
||||
mutate_and_reject 'AIStor wrong job rejected' '(.data.activeTargets[] | select(.scrapePool=="serviceMonitor/object-storage/aistor-bucket-usage/0") | .labels.job)="wrong"'
|
||||
mutate_and_reject 'AIStor wrong namespace rejected' '(.data.activeTargets[] | select(.scrapePool=="serviceMonitor/object-storage/aistor-bucket-usage/0") | .labels.namespace)="wrong"'
|
||||
mutate_and_reject 'AIStor wrong service rejected' '(.data.activeTargets[] | select(.scrapePool=="serviceMonitor/object-storage/aistor-bucket-usage/0") | .labels.service)="wrong"'
|
||||
mutate_and_reject 'AIStor wrong endpoint rejected' '(.data.activeTargets[] | select(.scrapePool=="serviceMonitor/object-storage/aistor-bucket-usage/0") | .labels.endpoint)="wrong"'
|
||||
mutate_and_reject 'AIStor wrong container rejected' '(.data.activeTargets[] | select(.scrapePool=="serviceMonitor/object-storage/aistor-bucket-usage/0") | .labels.container)="wrong"'
|
||||
|
||||
expect_pass 'safe HTTP URL accepted' test "$(sanitize_scrape_url 'http://10.42.7.19:9000/minio/metrics/v3/cluster/usage/buckets')" = 'http://10.42.7.19:9000/minio/metrics/v3/cluster/usage/buckets'
|
||||
expect_pass 'safe HTTPS URL accepted' test "$(sanitize_scrape_url 'https://metrics.example.test/metrics')" = 'https://metrics.example.test/metrics'
|
||||
for unsafe_url in \
|
||||
'http://user:DO-NOT-LEAK-FIXTURE@host/metrics' \
|
||||
'http://host/metrics?token=DO-NOT-LEAK-FIXTURE' \
|
||||
'http://host/metrics#DO-NOT-LEAK-FIXTURE' \
|
||||
'ftp://host/metrics' \
|
||||
'http://host/space here'; do
|
||||
expect_reject 'unsafe scrape URL rejected' sanitize_scrape_url "$unsafe_url"
|
||||
done
|
||||
|
||||
metadata="$fixture_root/metadata.json"
|
||||
printf '%s\n' '{"status":"success","data":{"z_metric":[{"type":"gauge","help":"DO-NOT-LEAK-FIXTURE","unit":""}],"a_metric":[{"type":"counter","help":"ignored","unit":""}]}}' >"$metadata"
|
||||
expect_pass 'metadata metric names sorted' test "$(extract_metric_names "$metadata")" = $'a_metric\nz_metric'
|
||||
metadata_array="$fixture_root/metadata-array.json"
|
||||
printf '%s\n' '{"status":"success","data":[{"target":{"job":"fixture"},"metric":"z_metric","type":"gauge","help":"DO-NOT-LEAK-FIXTURE","unit":""},{"target":{"job":"fixture"},"metric":"a_metric","type":"counter","help":"ignored","unit":""},{"target":{"job":"fixture"},"metric":"z_metric","type":"gauge","help":"duplicate","unit":""}]}' >"$metadata_array"
|
||||
expect_pass 'current Prometheus metadata array yields unique sorted metric names' \
|
||||
test "$(extract_metric_names "$metadata_array")" = $'a_metric\nz_metric'
|
||||
bad_metadata_array="$fixture_root/bad-metadata-array.json"
|
||||
printf '%s\n' '{"status":"success","data":[{"target":{},"metric":"bad metric","type":"gauge","help":"ignored","unit":""}]}' >"$bad_metadata_array"
|
||||
expect_reject 'invalid current metadata array metric name rejected' extract_metric_names "$bad_metadata_array"
|
||||
expect_pass 'post-substrate Probe pool accepts probe metrics' \
|
||||
validate_metric_names_for_pool $'probe_duration_seconds\nprobe_success' \
|
||||
probe/observability/platform-public-edge post-substrate
|
||||
expect_reject 'target-initial rejects probe metrics' \
|
||||
validate_metric_names_for_pool probe_success serviceMonitor/observability/grafana/0 target-initial
|
||||
expect_reject 'post-substrate non-Probe pool rejects probe metrics' \
|
||||
validate_metric_names_for_pool probe_success serviceMonitor/observability/blackbox-exporter/0 post-substrate
|
||||
expect_reject 'post-substrate Probe pool requires probe_success descriptor' \
|
||||
validate_metric_names_for_pool probe_duration_seconds probe/observability/platform-public-edge post-substrate
|
||||
bad_metadata="$fixture_root/bad-metadata.json"
|
||||
printf '%s\n' '{"status":"success","data":{"bad metric":[]}}' >"$bad_metadata"
|
||||
expect_reject 'invalid metadata metric name rejected' extract_metric_names "$bad_metadata"
|
||||
|
||||
rules="$fixture_root/rules.json"
|
||||
printf '%s\n' '{"status":"success","data":{"groups":[{"name":"fixture","rules":[{"type":"recording","name":"z_metric","query":"DO-NOT-LEAK-FIXTURE"},{"type":"alerting","name":"a_metric"}]}]}}' >"$rules"
|
||||
expect_reject 'recording-rule collision rejected' reject_recording_rule_collisions "$rules" "$metadata"
|
||||
printf '%s\n' '{"status":"success","data":{"groups":[{"name":"fixture","rules":[{"type":"recording","name":"recorded_metric"}]}]}}' >"$rules"
|
||||
expect_pass 'non-colliding recording rule accepted' reject_recording_rule_collisions "$rules" "$metadata"
|
||||
|
||||
series="$fixture_root/series.json"
|
||||
printf '%s\n' '{"status":"success","data":[{"z_label":"DO-NOT-LEAK-FIXTURE","__name__":"a_metric","a_label":"private-value"},{"__name__":"a_metric","middle":"another-private-value"}]}' >"$series"
|
||||
expect_pass 'series parser emits only sorted label keys' test "$(extract_series_label_names "$series")" = '["__name__","a_label","middle","z_label"]'
|
||||
[[ "$(extract_series_label_names "$series")" != *private-value* ]] || fail 'series parser persisted label values'
|
||||
dotted_series="$fixture_root/dotted-series.json"
|
||||
printf '%s\n' '{"status":"success","data":[{"__name__":"probe_success","instance":"https://example.test/","job":"blackbox-public-edge","namespace":"observability","observability.hyeonworks.com/probe-group":"public-edge"}]}' >"$dotted_series"
|
||||
expect_pass 'series parser preserves safe quoted Prometheus label names' \
|
||||
test "$(extract_series_label_names "$dotted_series")" = \
|
||||
'["__name__","instance","job","namespace","observability.hyeonworks.com/probe-group"]'
|
||||
empty_probe_series="$fixture_root/empty-probe-series.json"
|
||||
printf '%s\n' '{"status":"success","data":[]}' >"$empty_probe_series"
|
||||
expect_pass 'target-initial requires probe series absence' \
|
||||
validate_probe_series_phase "$empty_probe_series" target-initial
|
||||
expect_reject 'post-substrate rejects probe series absence' \
|
||||
validate_probe_series_phase "$empty_probe_series" post-substrate
|
||||
expect_pass 'post-substrate accepts observability probe series' \
|
||||
validate_probe_series_phase "$dotted_series" post-substrate
|
||||
expect_reject 'target-initial rejects existing probe series' \
|
||||
validate_probe_series_phase "$dotted_series" target-initial
|
||||
|
||||
post_labels="$fixture_root/post-labels.json"
|
||||
post_substrate_expected_probe_targets | jq '[.[].labels]' >"$post_labels"
|
||||
for query_kind in success status duration tls; do
|
||||
query_fixture="$fixture_root/post-query-$query_kind.json"
|
||||
jq -cn --slurpfile labels "$post_labels" --arg kind "$query_kind" '
|
||||
($labels[0] | if $kind=="tls" then map(select(.instance|startswith("https://"))) else . end) as $selected |
|
||||
{status:"success",data:{resultType:"vector",result:[$selected[] |
|
||||
{metric:(. + {__name__:(if $kind=="success" then "probe_success"
|
||||
elif $kind=="status" then "probe_http_status_code"
|
||||
elif $kind=="duration" then "probe_duration_seconds"
|
||||
else "probe_ssl_earliest_cert_expiry" end)}),
|
||||
value:[1770000000,(if $kind=="success" then "1"
|
||||
elif $kind=="status" then (if .job=="blackbox-private-edge" then "403" else "200" end)
|
||||
elif $kind=="duration" then "0.1" else "4102444800" end)]}]}}
|
||||
' >"$query_fixture"
|
||||
expect_pass "post-substrate $query_kind query contract accepted" \
|
||||
validate_post_substrate_query_body "$query_fixture" "$query_kind"
|
||||
done
|
||||
|
||||
large_target_rows="$fixture_root/large-target-rows.jsonl"
|
||||
awk 'BEGIN {
|
||||
for (i = 0; i < 20000; i++)
|
||||
printf "{\"scrape_pool\":\"pool/%d\",\"scrape_url\":\"http://10.42.0.1:9090/metrics\",\"health\":\"up\",\"last_error\":\"\",\"provenance\":\"core\",\"metrics\":[{\"name\":\"fixture_metric\",\"label_names\":[\"__name__\",\"instance\"]}]}\n", i
|
||||
}' >"$large_target_rows"
|
||||
large_inventory_contract() {
|
||||
[[ "$(stat -c %s "$large_target_rows")" -gt 2097152 ]] || return 1
|
||||
build_inventory_json "$large_target_rows" target-initial 2026-08-12T00:00:00Z |
|
||||
jq -e '.phase=="target-initial" and (.targets|length)==20000' >/dev/null
|
||||
}
|
||||
expect_pass 'inventory builder reads target rows from file beyond argv size limit' large_inventory_contract
|
||||
|
||||
fake_bin="$fixture_root/bin"
|
||||
mkdir -p "$fake_bin"
|
||||
fake_log="$fixture_root/fake-commands.log"
|
||||
pf_log="$fixture_root/port-forward-pids.log"
|
||||
: >"$fake_log"
|
||||
: >"$pf_log"
|
||||
|
||||
cat >"$fake_bin/kubectl" <<'FAKE_KUBECTL'
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
printf 'kubectl' >>"$FAKE_LOG"
|
||||
printf ' <%s>' "$@" >>"$FAKE_LOG"
|
||||
printf '\n' >>"$FAKE_LOG"
|
||||
args=" $* "
|
||||
if [[ "$args" =~ [[:space:]](apply|delete|patch|replace|exec|rollout|scale)[[:space:]] ]]; then
|
||||
exit 91
|
||||
fi
|
||||
if [[ "$args" == *' config current-context '* ]]; then printf 'default\n'; exit 0; fi
|
||||
if [[ "$args" == *' config view '* ]]; then printf '%s\n' '{"clusters":[{"cluster":{"server":"https://127.0.0.1:6443"}}]}'; exit 0; fi
|
||||
if [[ "$args" == *' get --raw=/readyz '* ]]; then printf 'ok\n'; exit 0; fi
|
||||
if [[ "$args" == *' auth can-i '* ]]; then printf 'yes\n'; exit 0; fi
|
||||
if [[ "$args" == *' get prometheus '* ]]; then printf '%s\n' '{"apiVersion":"monitoring.coreos.com/v1","kind":"Prometheus","metadata":{"name":"observability-core-kube-pr-prometheus","namespace":"observability"}}'; exit 0; fi
|
||||
if [[ "$args" == *' get pods '* ]]; then printf '%s\n' '{"items":[{"metadata":{"name":"prometheus-observability-core-kube-pr-prometheus-0","namespace":"observability"},"status":{"phase":"Running","conditions":[{"type":"Ready","status":"True"}],"containerStatuses":[{"name":"prometheus","ready":true}]}}]}'; exit 0; fi
|
||||
if [[ "$args" == *' port-forward '* ]]; then
|
||||
printf '%s\n' "$$" >>"$PF_LOG"
|
||||
pf_stderr_path="$(readlink -- "/proc/$$/fd/2")"
|
||||
printf '%s\n' "${pf_stderr_path%/*}" >>"$PF_WORKDIR_LOG"
|
||||
if [[ "${PF_IGNORE_TERM:-0}" == 1 ]]; then
|
||||
trap 'printf "ignored %s TERM\n" "$$" >>"$PF_LOG"' TERM
|
||||
else
|
||||
trap 'printf "stopped %s TERM\n" "$$" >>"$PF_LOG"; exit 0' TERM
|
||||
fi
|
||||
trap 'printf "stopped %s INT\n" "$$" >>"$PF_LOG"; exit 0' INT
|
||||
trap 'printf "stopped %s HUP\n" "$$" >>"$PF_LOG"; exit 0' HUP
|
||||
if [[ "${PF_NO_ANNOUNCEMENT:-0}" != 1 ]]; then
|
||||
printf 'Forwarding from 127.0.0.1:43123 -> 9090\n' >&2
|
||||
fi
|
||||
while :; do /usr/bin/sleep 0.05; done
|
||||
fi
|
||||
exit 1
|
||||
FAKE_KUBECTL
|
||||
|
||||
cat >"$fake_bin/curl" <<'FAKE_CURL'
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
[[ "${1-}" == --disable ]] || exit 92
|
||||
printf 'curl' >>"$FAKE_LOG"
|
||||
printf ' <%s>' "$@" >>"$FAKE_LOG"
|
||||
printf '\n' >>"$FAKE_LOG"
|
||||
destination=''
|
||||
url=''
|
||||
max_filesize=''
|
||||
all_arguments=" $* "
|
||||
declare -a data_values=()
|
||||
declare -a urlencode_values=()
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
--output) destination="$2"; shift 2 ;;
|
||||
--max-filesize) max_filesize="$2"; shift 2 ;;
|
||||
--data) data_values+=("$2"); shift 2 ;;
|
||||
--data-urlencode) urlencode_values+=("$2"); shift 2 ;;
|
||||
http://*) url="$1"; shift ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
[[ -n "$destination" && -n "$url" ]] || exit 2
|
||||
emit_text() {
|
||||
if [[ "$destination" == - ]]; then printf '%s\n' "$1"; else printf '%s\n' "$1" >"$destination"; fi
|
||||
}
|
||||
emit_file() {
|
||||
if [[ "$destination" == - ]]; then cat -- "$1"; else cp -- "$1" "$destination"; fi
|
||||
}
|
||||
case "$url" in
|
||||
*/-/ready) emit_text 'ready' ;;
|
||||
*/api/v1/targets)
|
||||
[[ "${#data_values[@]}" == 1 ]] || exit 80
|
||||
case "${data_values[0]}" in
|
||||
state=dropped)
|
||||
if [[ "${FAKE_CURL_MODE:-success}" == dropped_blackbox ]]; then
|
||||
emit_file "$DROPPED_BLACKBOX_FIXTURE"
|
||||
else
|
||||
emit_file "$DROPPED_TARGETS_FIXTURE"
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
state=active) ;;
|
||||
*) exit 81 ;;
|
||||
esac
|
||||
if [[ "${FAKE_CURL_MODE:-success}" == hang ]]; then
|
||||
trap 'exit 128' INT TERM HUP
|
||||
while :; do /usr/bin/sleep 0.05; done
|
||||
fi
|
||||
if [[ "${FAKE_CURL_MODE:-success}" == error ]]; then exit 22; fi
|
||||
if [[ "${FAKE_CURL_MODE:-success}" == oversized_targets ]]; then
|
||||
[[ "$max_filesize" =~ ^[0-9]+$ ]] || exit 88
|
||||
if (( $(stat -c %s "$OVERSIZED_TARGETS_FIXTURE") > max_filesize )); then exit 63; fi
|
||||
emit_file "$OVERSIZED_TARGETS_FIXTURE"
|
||||
else
|
||||
emit_file "$TARGETS_FIXTURE"
|
||||
fi
|
||||
;;
|
||||
*/api/v1/rules) emit_text '{"status":"success","data":{"groups":[]}}' ;;
|
||||
*/api/v1/targets/metadata)
|
||||
[[ "${#urlencode_values[@]}" == 1 && "${urlencode_values[0]}" == match_target=* ]] || exit 84
|
||||
[[ "${#data_values[@]}" == 1 && "${data_values[0]}" == 'limit=10000' ]] || exit 85
|
||||
target_selector="${urlencode_values[0]#match_target=}"
|
||||
grep -Fqx -- "$target_selector" "$EXPECTED_TARGET_SELECTORS" || exit 86
|
||||
printf '%s\n' "$target_selector" >>"$METADATA_SELECTOR_LOG"
|
||||
emit_text '{"status":"success","data":{"descriptor_only_metric":[{"type":"gauge","help":"registered without a current series","unit":""}],"fixture_metric":[{"type":"gauge","help":"DO-NOT-LEAK-FIXTURE","unit":""}]}}'
|
||||
;;
|
||||
*/api/v1/series)
|
||||
if [[ "$all_arguments" == *'probe_.+'* ]]; then
|
||||
[[ "${#urlencode_values[@]}" == 1 && "${urlencode_values[0]}" == 'match[]={__name__=~"probe_.+"}' ]] || exit 87
|
||||
emit_text '{"status":"success","data":[]}'
|
||||
else
|
||||
[[ "${#urlencode_values[@]}" == 1 && "${urlencode_values[0]}" == match\[\]=* ]] || exit 89
|
||||
series_selector="${urlencode_values[0]#match[]=}"
|
||||
if [[ "$series_selector" == *'__name__="descriptor_only_metric"'* ]]; then
|
||||
fixture_selector="${series_selector/descriptor_only_metric/fixture_metric}"
|
||||
grep -Fqx -- "$fixture_selector" "$EXPECTED_SERIES_SELECTORS" || exit 90
|
||||
emit_text '{"status":"success","data":[]}'
|
||||
exit 0
|
||||
fi
|
||||
grep -Fqx -- "$series_selector" "$EXPECTED_SERIES_SELECTORS" || exit 90
|
||||
if [[ "${FAKE_CURL_MODE:-success}" == all_series_empty ]]; then
|
||||
emit_text '{"status":"success","data":[]}'
|
||||
exit 0
|
||||
fi
|
||||
printf '%s\n' "$series_selector" >>"$SERIES_SELECTOR_LOG"
|
||||
if [[ "${FAKE_CURL_MODE:-success}" == hang_series ]]; then
|
||||
printf '%s\n' "$$" >>"$CURL_PID_LOG"
|
||||
: >"$CURL_HANG_MARKER"
|
||||
trap 'exit 128' INT TERM HUP
|
||||
while :; do /usr/bin/sleep 0.05; done
|
||||
fi
|
||||
emit_text '{"status":"success","data":[{"__name__":"fixture_metric","instance":"10.42.0.1:9090","secret_label":"DO-NOT-LEAK-FIXTURE"}]}'
|
||||
fi
|
||||
;;
|
||||
*) exit 3 ;;
|
||||
esac
|
||||
FAKE_CURL
|
||||
cat >"$fake_bin/ln" <<'FAKE_LN'
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
if [[ "${FAIL_SHA_LINK:-0}" == 1 && "${*: -1}" == */inventory.sha256 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
exec /usr/bin/ln "$@"
|
||||
FAKE_LN
|
||||
chmod 0700 "$fake_bin/kubectl" "$fake_bin/curl" "$fake_bin/ln"
|
||||
|
||||
export PATH="$fake_bin:$ORIGINAL_PATH"
|
||||
pf_workdir_log="$fixture_root/port-forward-workdirs.log"
|
||||
curl_pid_log="$fixture_root/curl-pids.log"
|
||||
metadata_selector_log="$fixture_root/metadata-selectors.log"
|
||||
series_selector_log="$fixture_root/series-selectors.log"
|
||||
: >"$pf_workdir_log"
|
||||
: >"$curl_pid_log"
|
||||
: >"$metadata_selector_log"
|
||||
: >"$series_selector_log"
|
||||
export FAKE_LOG="$fake_log" PF_LOG="$pf_log" PF_WORKDIR_LOG="$pf_workdir_log"
|
||||
export CURL_PID_LOG="$curl_pid_log" TARGETS_FIXTURE="$valid_targets" OVERSIZED_TARGETS_FIXTURE="$oversized_targets"
|
||||
export DROPPED_TARGETS_FIXTURE="$empty_dropped_targets" DROPPED_BLACKBOX_FIXTURE="$dropped_blackbox"
|
||||
export EXPECTED_TARGET_SELECTORS="$expected_target_selectors" EXPECTED_SERIES_SELECTORS="$expected_series_selectors"
|
||||
export METADATA_SELECTOR_LOG="$metadata_selector_log" SERIES_SELECTOR_LOG="$series_selector_log"
|
||||
|
||||
run_capture() {
|
||||
local output_dir="$1"
|
||||
shift
|
||||
env PATH="$PATH" FAKE_LOG="$FAKE_LOG" PF_LOG="$PF_LOG" TARGETS_FIXTURE="$TARGETS_FIXTURE" \
|
||||
PF_WORKDIR_LOG="$PF_WORKDIR_LOG" CURL_PID_LOG="$CURL_PID_LOG" \
|
||||
EXPECTED_TARGET_SELECTORS="$EXPECTED_TARGET_SELECTORS" EXPECTED_SERIES_SELECTORS="$EXPECTED_SERIES_SELECTORS" \
|
||||
METADATA_SELECTOR_LOG="$METADATA_SELECTOR_LOG" SERIES_SELECTOR_LOG="$SERIES_SELECTOR_LOG" \
|
||||
DROPPED_TARGETS_FIXTURE="$DROPPED_TARGETS_FIXTURE" DROPPED_BLACKBOX_FIXTURE="$DROPPED_BLACKBOX_FIXTURE" \
|
||||
OVERSIZED_TARGETS_FIXTURE="$OVERSIZED_TARGETS_FIXTURE" \
|
||||
FAKE_CURL_MODE="${FAKE_CURL_MODE:-success}" \
|
||||
bash "$CAPTURE" --execute --phase target-initial --output-dir "$output_dir" "$@"
|
||||
}
|
||||
|
||||
inventory_kubectl_alias() { inventory_kubectl "$@"; }
|
||||
mutation_log_lines="$(wc -l <"$fake_log")"
|
||||
for wrapper_name in inventory_kubectl inventory_kubectl_alias; do
|
||||
for mutation_verb in apply create delete patch replace exec rollout scale; do
|
||||
expect_reject "$wrapper_name default-denies $mutation_verb" \
|
||||
"$wrapper_name" "$mutation_verb" fixture
|
||||
done
|
||||
done
|
||||
[[ "$(wc -l <"$fake_log")" == "$mutation_log_lines" ]] || fail 'denied wrapper mutation reached kubectl'
|
||||
|
||||
ownership_signal_log="$fixture_root/ownership-signals.log"
|
||||
ownership_ready="$fixture_root/ownership-ready"
|
||||
bash -c '
|
||||
trap '\''printf "TERM\n" >>"$1"; exit 0'\'' TERM
|
||||
: >"$2"
|
||||
while :; do /usr/bin/sleep 0.05; done
|
||||
' bash "$ownership_signal_log" "$ownership_ready" &
|
||||
ownership_pid=$!
|
||||
test_helper_pids+=("$ownership_pid")
|
||||
for ((attempt=0; attempt<40; attempt++)); do [[ -e "$ownership_ready" ]] && break; sleep 0.025; done
|
||||
[[ -e "$ownership_ready" ]] || fail 'ownership fixture did not start'
|
||||
ownership_snapshot="$(inventory_process_snapshot "$ownership_pid")" || fail 'ownership fixture snapshot failed'
|
||||
INVENTORY_PORT_FORWARD_PID="$ownership_pid"
|
||||
INVENTORY_PORT_FORWARD_START="$((10#${ownership_snapshot#*:} + 1))"
|
||||
expect_pass 'wrong start-time PID is not signaled' inventory_stop_port_forward
|
||||
[[ ! -s "$ownership_signal_log" ]] || fail 'wrong start-time process received TERM'
|
||||
kill -0 "$ownership_pid" 2>/dev/null || fail 'wrong start-time process was killed'
|
||||
kill -TERM "$ownership_pid" 2>/dev/null || true
|
||||
for ((attempt=0; attempt<40; attempt++)); do kill -0 "$ownership_pid" 2>/dev/null || break; sleep 0.025; done
|
||||
|
||||
cli_dir="$fixture_root/cli"
|
||||
mkdir -m 0700 "$cli_dir"
|
||||
expect_reject 'missing CLI arguments rejected' bash "$CAPTURE"
|
||||
expect_reject 'missing execute rejected' bash "$CAPTURE" --phase target-initial --output-dir "$cli_dir"
|
||||
expect_reject 'duplicate argument rejected' bash "$CAPTURE" --execute --phase target-initial --phase target-initial --output-dir "$cli_dir"
|
||||
expect_reject 'unknown phase rejected' bash "$CAPTURE" --execute --phase future-phase --output-dir "$cli_dir"
|
||||
expect_reject 'unknown argument rejected' bash "$CAPTURE" --execute --phase target-initial --output-dir "$cli_dir" --unknown
|
||||
|
||||
relative_dir="${cli_dir#/}"
|
||||
expect_reject 'relative output path rejected' bash "$CAPTURE" --execute --phase target-initial --output-dir "$relative_dir"
|
||||
noncanonical="$fixture_root/../$(basename "$fixture_root")/cli"
|
||||
expect_reject 'noncanonical output path rejected' bash "$CAPTURE" --execute --phase target-initial --output-dir "$noncanonical"
|
||||
chmod 0755 "$cli_dir"
|
||||
expect_reject 'non-0700 output directory rejected' run_capture "$cli_dir"
|
||||
chmod 0700 "$cli_dir"
|
||||
ln -s "$cli_dir" "$fixture_root/cli-link"
|
||||
expect_reject 'symlink output directory rejected' run_capture "$fixture_root/cli-link"
|
||||
mkdir -m 0700 "$fixture_root/unsafe-parent"
|
||||
mkdir -m 0700 "$fixture_root/real-child"
|
||||
ln -s "$fixture_root/unsafe-parent" "$fixture_root/parent-link"
|
||||
mkdir -m 0700 "$fixture_root/unsafe-parent/child"
|
||||
expect_reject 'symlink path component rejected' run_capture "$fixture_root/parent-link/child"
|
||||
printf 'preserve-me\n' >"$cli_dir/preexisting"
|
||||
expect_reject 'nonempty output directory rejected without clobber' run_capture "$cli_dir"
|
||||
[[ "$(cat "$cli_dir/preexisting")" == preserve-me ]] || fail 'preexisting file was modified'
|
||||
rm "$cli_dir/preexisting"
|
||||
|
||||
empty_series_dir="$fixture_root/empty-series"
|
||||
mkdir -m 0700 "$empty_series_dir"
|
||||
FAKE_CURL_MODE=all_series_empty
|
||||
export FAKE_CURL_MODE
|
||||
expect_reject 'target with metadata descriptors but no actual series rejected' run_capture "$empty_series_dir"
|
||||
unset FAKE_CURL_MODE
|
||||
[[ -z "$(find "$empty_series_dir" -mindepth 1 -maxdepth 1 -print -quit)" ]] || fail 'empty-series response produced accepted output'
|
||||
: >"$metadata_selector_log"
|
||||
: >"$series_selector_log"
|
||||
|
||||
success_dir="$fixture_root/success"
|
||||
mkdir -m 0700 "$success_dir"
|
||||
expect_pass 'full capture succeeds with fixture APIs' run_capture "$success_dir"
|
||||
cmp -s <(sort "$expected_target_selectors") <(sort "$metadata_selector_log") || fail 'metadata calls did not cover every exact full-label selector once'
|
||||
cmp -s <(sort "$expected_series_selectors") <(sort "$series_selector_log") || fail 'series calls did not cover every exact metric selector once'
|
||||
mapfile -t output_names < <(find "$success_dir" -mindepth 1 -maxdepth 1 -printf '%f\n' | sort)
|
||||
[[ "${output_names[*]}" == 'inventory.json inventory.sha256' ]] || fail 'capture did not create exactly two files'
|
||||
for output_name in "${output_names[@]}"; do
|
||||
[[ -f "$success_dir/$output_name" && ! -L "$success_dir/$output_name" ]] || fail "$output_name is not a regular non-symlink"
|
||||
[[ "$(stat -c %a "$success_dir/$output_name")" == 600 ]] || fail "$output_name mode is not 0600"
|
||||
done
|
||||
[[ "$(jq -cS . "$success_dir/inventory.json")"$'\n' == "$(cat "$success_dir/inventory.json")"$'\n' ]] || fail 'inventory is not canonical jq -cS JSON'
|
||||
(cd "$success_dir" && sha256sum --check inventory.sha256 >/dev/null) || fail 'inventory hash does not bind JSON bytes'
|
||||
jq -e '
|
||||
.schema == "platform-observability-metric-inventory/v1" and
|
||||
.phase == "target-initial" and .context == "default" and
|
||||
.api_server == "https://127.0.0.1:6443" and
|
||||
.prometheus == "observability/observability-core-kube-pr-prometheus" and
|
||||
.preexisting_verify_only == ["grafana"] and
|
||||
(.targets | length == 21) and
|
||||
([.targets[].scrape_pool] == ([.targets[].scrape_pool] | sort)) and
|
||||
(all(.targets[]; .health == "up" and .last_error == "" and
|
||||
.metrics == [{"label_names":["__name__","instance","secret_label"],"name":"fixture_metric"}])) and
|
||||
([.targets[] | select(.scrape_pool=="serviceMonitor/observability/grafana/0") | .provenance] == ["preexisting-verify-only"]) and
|
||||
([.targets[] | select(.scrape_pool=="serviceMonitor/object-storage/aistor-bucket-usage/0") | .provenance] == ["task6-owned"])
|
||||
' "$success_dir/inventory.json" >/dev/null || fail 'inventory schema/provenance/content mismatch'
|
||||
if rg -q 'DO-NOT-LEAK-FIXTURE|private-value|help|query|fragment|exemplar' "$success_dir/inventory.json"; then
|
||||
fail 'inventory persisted a prohibited value or field'
|
||||
fi
|
||||
success_pf_pid="$(head -n 1 "$pf_log")"
|
||||
if kill -0 "$success_pf_pid" 2>/dev/null; then fail 'port-forward survived success'; fi
|
||||
|
||||
oversized_dir="$fixture_root/oversized-response"
|
||||
mkdir -m 0700 "$oversized_dir"
|
||||
FAKE_CURL_MODE=oversized_targets
|
||||
export FAKE_CURL_MODE
|
||||
expect_reject 'Prometheus response above the byte ceiling rejected' run_capture "$oversized_dir"
|
||||
unset FAKE_CURL_MODE
|
||||
[[ -z "$(find "$oversized_dir" -mindepth 1 -maxdepth 1 -print -quit)" ]] || fail 'oversized response produced accepted output'
|
||||
|
||||
dropped_live_dir="$fixture_root/dropped-live-response"
|
||||
mkdir -m 0700 "$dropped_live_dir"
|
||||
FAKE_CURL_MODE=dropped_blackbox
|
||||
export FAKE_CURL_MODE
|
||||
expect_reject 'live dropped blackbox response rejected with exact active set' run_capture "$dropped_live_dir"
|
||||
unset FAKE_CURL_MODE
|
||||
[[ -z "$(find "$dropped_live_dir" -mindepth 1 -maxdepth 1 -print -quit)" ]] || fail 'dropped blackbox response produced accepted output'
|
||||
|
||||
sigkill_dir="$fixture_root/sigkill"
|
||||
mkdir -m 0700 "$sigkill_dir"
|
||||
sigkill_marker="$fixture_root/sigkill-series-started"
|
||||
sigkill_pf_lines="$(wc -l <"$pf_log")"
|
||||
sigkill_workdir_lines="$(wc -l <"$pf_workdir_log")"
|
||||
sigkill_curl_lines="$(wc -l <"$curl_pid_log")"
|
||||
env PATH="$PATH" FAKE_LOG="$FAKE_LOG" PF_LOG="$PF_LOG" PF_WORKDIR_LOG="$PF_WORKDIR_LOG" \
|
||||
CURL_PID_LOG="$CURL_PID_LOG" CURL_HANG_MARKER="$sigkill_marker" \
|
||||
TARGETS_FIXTURE="$TARGETS_FIXTURE" OVERSIZED_TARGETS_FIXTURE="$OVERSIZED_TARGETS_FIXTURE" \
|
||||
FAKE_CURL_MODE=hang_series bash "$CAPTURE" --execute --phase target-initial --output-dir "$sigkill_dir" \
|
||||
>/dev/null 2>&1 &
|
||||
sigkill_capture_pid=$!
|
||||
for ((attempt=0; attempt<200; attempt++)); do
|
||||
[[ -e "$sigkill_marker" ]] && break
|
||||
kill -0 "$sigkill_capture_pid" 2>/dev/null || break
|
||||
sleep 0.025
|
||||
done
|
||||
[[ -e "$sigkill_marker" ]] || fail 'SIGKILL residue fixture did not reach the series request'
|
||||
sigkill_workdir="$(sed -n "$((sigkill_workdir_lines + 1))p" "$pf_workdir_log")"
|
||||
sigkill_pf_pid="$(sed -n "$((sigkill_pf_lines + 1))p" "$pf_log")"
|
||||
sigkill_curl_pid="$(sed -n "$((sigkill_curl_lines + 1))p" "$curl_pid_log")"
|
||||
[[ "$sigkill_workdir" == /tmp/platform-observability-inventory-capture.* && -d "$sigkill_workdir" && ! -L "$sigkill_workdir" ]] || fail 'SIGKILL fixture work directory was not identified safely'
|
||||
kill -KILL "$sigkill_capture_pid" 2>/dev/null || true
|
||||
wait "$sigkill_capture_pid" 2>/dev/null || true
|
||||
for owned_pid in "$sigkill_curl_pid" "$sigkill_pf_pid"; do
|
||||
[[ "$owned_pid" =~ ^[0-9]+$ ]] || fail 'SIGKILL fixture lost an owned child PID'
|
||||
kill -KILL "$owned_pid" 2>/dev/null || true
|
||||
for ((attempt=0; attempt<40; attempt++)); do
|
||||
kill -0 "$owned_pid" 2>/dev/null || break
|
||||
sleep 0.025
|
||||
done
|
||||
done
|
||||
raw_residue="$(find "$sigkill_workdir" -type f \( -name 'targets*.json' -o -name 'rules*.json' -o -name 'metadata*.json' -o -name 'series*.json' \) -print)"
|
||||
secret_residue=0
|
||||
if rg -q 'DO-NOT-LEAK-FIXTURE' "$sigkill_workdir"; then secret_residue=1; fi
|
||||
find "$sigkill_workdir" -depth -delete
|
||||
[[ -z "$raw_residue" ]] || fail "SIGKILL left raw Prometheus response files: $raw_residue"
|
||||
[[ "$secret_residue" == 0 ]] || fail 'SIGKILL residue persisted a raw label/help value'
|
||||
pass_count=$((pass_count + 1))
|
||||
|
||||
stubborn_pf_dir="$fixture_root/stubborn-port-forward"
|
||||
mkdir -m 0700 "$stubborn_pf_dir"
|
||||
stubborn_lines="$(wc -l <"$pf_log")"
|
||||
set +e
|
||||
timeout --preserve-status --signal=TERM --kill-after=0.5s 5s \
|
||||
env PATH="$PATH" FAKE_LOG="$FAKE_LOG" PF_LOG="$PF_LOG" TARGETS_FIXTURE="$TARGETS_FIXTURE" PF_IGNORE_TERM=1 \
|
||||
bash "$CAPTURE" --execute --phase target-initial --output-dir "$stubborn_pf_dir" >/dev/null 2>&1
|
||||
stubborn_rc=$?
|
||||
set -e
|
||||
stubborn_pf_pid="$(sed -n "$((stubborn_lines + 1))p" "$pf_log")"
|
||||
[[ "$stubborn_pf_pid" =~ ^[0-9]+$ ]] || fail 'TERM-ignoring fixture did not start a port-forward'
|
||||
stubborn_survived_production_cleanup=0
|
||||
if kill -0 "$stubborn_pf_pid" 2>/dev/null; then
|
||||
stubborn_survived_production_cleanup=1
|
||||
kill -KILL "$stubborn_pf_pid" 2>/dev/null || true
|
||||
for ((attempt=0; attempt<40; attempt++)); do
|
||||
kill -0 "$stubborn_pf_pid" 2>/dev/null || break
|
||||
sleep 0.025
|
||||
done
|
||||
fi
|
||||
[[ "$stubborn_rc" == 0 ]] || fail "TERM-ignoring port-forward cleanup exceeded its bound (status $stubborn_rc)"
|
||||
grep -Fqx "ignored $stubborn_pf_pid TERM" "$pf_log" || fail 'TERM-ignoring fixture did not receive TERM before escalation'
|
||||
[[ "$stubborn_survived_production_cleanup" == 0 ]] || fail 'TERM-ignoring port-forward survived production cleanup'
|
||||
pass_count=$((pass_count + 1))
|
||||
|
||||
silent_pf_dir="$fixture_root/silent-port-forward"
|
||||
mkdir -m 0700 "$silent_pf_dir"
|
||||
PF_NO_ANNOUNCEMENT=1
|
||||
export PF_NO_ANNOUNCEMENT
|
||||
expect_pass 'readiness does not depend on port-forward status text' run_capture "$silent_pf_dir"
|
||||
unset PF_NO_ANNOUNCEMENT
|
||||
|
||||
publish_failure_dir="$fixture_root/publish-failure"
|
||||
mkdir -m 0700 "$publish_failure_dir"
|
||||
FAIL_SHA_LINK=1
|
||||
export FAIL_SHA_LINK
|
||||
expect_reject 'second-file publication failure removes only run-owned output' run_capture "$publish_failure_dir"
|
||||
unset FAIL_SHA_LINK
|
||||
[[ -z "$(find "$publish_failure_dir" -mindepth 1 -maxdepth 1 -print -quit)" ]] || fail 'publication failure left a partial inventory'
|
||||
|
||||
error_dir="$fixture_root/error"
|
||||
mkdir -m 0700 "$error_dir"
|
||||
error_pf_lines="$(wc -l <"$pf_log")"
|
||||
FAKE_CURL_MODE=error
|
||||
export FAKE_CURL_MODE
|
||||
expect_reject 'ordinary API error is sanitized' run_capture "$error_dir"
|
||||
[[ -z "$(find "$error_dir" -mindepth 1 -maxdepth 1 -print -quit)" ]] || fail 'ordinary error left partial output'
|
||||
error_pf_pid="$(sed -n "$((error_pf_lines + 1))p" "$pf_log")"
|
||||
[[ "$error_pf_pid" =~ ^[0-9]+$ ]] || fail 'ordinary error did not start a port-forward'
|
||||
if kill -0 "$error_pf_pid" 2>/dev/null; then fail 'port-forward survived ordinary error'; fi
|
||||
unset FAKE_CURL_MODE
|
||||
|
||||
for signal in INT TERM HUP; do
|
||||
signal_dir="$fixture_root/signal-${signal,,}"
|
||||
mkdir -m 0700 "$signal_dir"
|
||||
before_lines="$(wc -l <"$pf_log")"
|
||||
set +e
|
||||
timeout --preserve-status --signal="$signal" --kill-after=2s 0.3s \
|
||||
env PATH="$PATH" FAKE_LOG="$FAKE_LOG" PF_LOG="$PF_LOG" TARGETS_FIXTURE="$TARGETS_FIXTURE" FAKE_CURL_MODE=hang \
|
||||
bash "$CAPTURE" --execute --phase target-initial --output-dir "$signal_dir" >/dev/null 2>&1
|
||||
signal_rc=$?
|
||||
set -e
|
||||
[[ "$signal_rc" != 0 ]] || fail "$signal unexpectedly succeeded"
|
||||
[[ -z "$(find "$signal_dir" -mindepth 1 -maxdepth 1 -print -quit)" ]] || fail "$signal left partial output"
|
||||
signal_pf_pid="$(sed -n "$((before_lines + 1))p" "$pf_log")"
|
||||
[[ "$signal_pf_pid" =~ ^[0-9]+$ ]] || fail "$signal did not start a port-forward"
|
||||
if kill -0 "$signal_pf_pid" 2>/dev/null; then fail "port-forward survived $signal"; fi
|
||||
pass_count=$((pass_count + 1))
|
||||
done
|
||||
|
||||
if awk '
|
||||
/kubectl/ {
|
||||
line=$0
|
||||
if (line ~ / <(apply|delete|patch|replace|exec|rollout|scale)>/) exit 1
|
||||
if (line ~ / <create>/ && line !~ / <auth> <can-i> <create> <pods\/portforward>/) exit 1
|
||||
}
|
||||
' "$fake_log"; then :; else fail 'dynamic command trace contains a mutation verb'; fi
|
||||
|
||||
# Whole-source proof complements the runtime trace. Removing line boundaries
|
||||
# prevents a backslash-newline, command prefix, or wrapper alias from hiding a
|
||||
# mutation verb. The exact RBAC question is the sole create exception.
|
||||
[[ "$(rg -Fxc " 'create pods/portforward'; do" "$CAPTURE")" == 1 ]] || fail 'port-forward RBAC exception is not pinned exactly once'
|
||||
mutation_free_source="$(sed "s/'create pods\\/portforward'/'RBAC_PORTFORWARD'/" "$CAPTURE")"
|
||||
if rg -q '(^|[^[:alnum:]_])(apply|create|delete|patch|replace|exec|rollout|scale)($|[^[:alnum:]_])' <<<"$mutation_free_source"; then
|
||||
fail 'whole-source proof found a Kubernetes mutation verb'
|
||||
fi
|
||||
[[ "$(rg -c '^[[:space:]]*kubectl --request-timeout=' "$CAPTURE")" == 2 ]] || fail 'kubectl invocation count escaped the wrapper/port-forward allowlist'
|
||||
if rg -q '^[[:space:]]*(alias[[:space:]]+[^=]+=.*kubectl|[A-Za-z_][A-Za-z0-9_]*=.*kubectl)' "$CAPTURE"; then
|
||||
fail 'kubectl executable alias bypasses the default-deny wrapper'
|
||||
fi
|
||||
pass_count=$((pass_count + 1))
|
||||
|
||||
printf 'PASS: %d target-initial inventory assertions\n' "$pass_count"
|
||||
+651
@@ -0,0 +1,651 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd -P)"
|
||||
readonly SCRIPT="$ROOT/scripts/bootstrap/configure-keycloak-grafana-oidc.sh"
|
||||
readonly NOW_UTC='2026-08-12T07:00:00Z'
|
||||
readonly CLIENT_SECRET='GrafanaFixtureClientSecret-123456789'
|
||||
WORK=''
|
||||
ASSERTIONS=0
|
||||
|
||||
fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; }
|
||||
pass() { ASSERTIONS=$((ASSERTIONS + 1)); printf 'PASS: %s\n' "$1"; }
|
||||
cleanup() {
|
||||
trap - EXIT HUP INT TERM
|
||||
case "$WORK" in /tmp/platform-grafana-oidc-test.??????) rm -rf -- "$WORK" ;; esac
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 129' HUP
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
write_executable() {
|
||||
local path=$1
|
||||
shift
|
||||
printf '%s\n' "$@" >"$path"
|
||||
chmod 0755 "$path"
|
||||
}
|
||||
|
||||
make_fakes() {
|
||||
local fixture=$1
|
||||
mkdir -p "$fixture/bin" "$fixture/state" "$fixture/evidence-parent"
|
||||
chmod 0700 "$fixture" "$fixture/state" "$fixture/evidence-parent"
|
||||
chmod 0755 "$fixture/bin"
|
||||
: >"$fixture/commands.log"
|
||||
|
||||
write_executable "$fixture/bin/encryption" \
|
||||
'#!/usr/bin/env bash' \
|
||||
'set -Eeuo pipefail' \
|
||||
'printf '\''encryption'\'' >>"$PLATFORM_TEST_COMMAND_LOG"; printf '\'' %q'\'' "$@" >>"$PLATFORM_TEST_COMMAND_LOG"; printf '\''\n'\'' >>"$PLATFORM_TEST_COMMAND_LOG"' \
|
||||
'if [[ -n "${PLATFORM_TEST_VALIDATOR_ENV_LOG:-}" ]]; then /usr/bin/env | LC_ALL=C /usr/bin/sort >>"$PLATFORM_TEST_VALIDATOR_ENV_LOG"; printf '\''--\n'\'' >>"$PLATFORM_TEST_VALIDATOR_ENV_LOG"; fi' \
|
||||
'n=0; [[ ! -f "$PLATFORM_TEST_ENCRYPTION_COUNT" ]] || read -r n <"$PLATFORM_TEST_ENCRYPTION_COUNT"' \
|
||||
'n=$((n + 1)); printf '\''%s\n'\'' "$n" >"$PLATFORM_TEST_ENCRYPTION_COUNT"' \
|
||||
'[[ "$n" != "${PLATFORM_TEST_ENCRYPTION_FAIL_AT:-0}" ]]'
|
||||
write_executable "$fixture/bin/restore" \
|
||||
'#!/usr/bin/env bash' \
|
||||
'set -Eeuo pipefail' \
|
||||
'printf '\''restore'\'' >>"$PLATFORM_TEST_COMMAND_LOG"; printf '\'' %q'\'' "$@" >>"$PLATFORM_TEST_COMMAND_LOG"; printf '\''\n'\'' >>"$PLATFORM_TEST_COMMAND_LOG"' \
|
||||
'if [[ -n "${PLATFORM_TEST_VALIDATOR_ENV_LOG:-}" ]]; then /usr/bin/env | LC_ALL=C /usr/bin/sort >>"$PLATFORM_TEST_VALIDATOR_ENV_LOG"; printf '\''--\n'\'' >>"$PLATFORM_TEST_VALIDATOR_ENV_LOG"; fi' \
|
||||
'n=0; [[ ! -f "$PLATFORM_TEST_RESTORE_COUNT" ]] || read -r n <"$PLATFORM_TEST_RESTORE_COUNT"' \
|
||||
'n=$((n + 1)); printf '\''%s\n'\'' "$n" >"$PLATFORM_TEST_RESTORE_COUNT"' \
|
||||
'if [[ "$n" == 1 && -f "$PLATFORM_TEST_STATE/remove-evidence-parent" ]]; then /usr/bin/mv -- "${PLATFORM_TEST_STATE%/state}/evidence-parent" "${PLATFORM_TEST_STATE%/state}/evidence-parent-missing"; fi' \
|
||||
'[[ "$n" != "${PLATFORM_TEST_RESTORE_FAIL_AT:-0}" ]]'
|
||||
write_executable "$fixture/bin/sudo" \
|
||||
'#!/usr/bin/env bash' \
|
||||
'set -Eeuo pipefail' \
|
||||
'printf '\''sudo'\'' >>"$PLATFORM_TEST_COMMAND_LOG"; printf '\'' %q'\'' "$@" >>"$PLATFORM_TEST_COMMAND_LOG"; printf '\''\n'\'' >>"$PLATFORM_TEST_COMMAND_LOG"' \
|
||||
'if [[ "${1:-}" == -v ]]; then : >"$PLATFORM_TEST_SUDO_REFRESHED"; exit 0; fi' \
|
||||
'[[ -f "$PLATFORM_TEST_SUDO_REFRESHED" ]] || exit 92' \
|
||||
'[[ "${1:-}" != --non-interactive && "${1:-}" != -n ]] || shift' \
|
||||
'exec "$@"'
|
||||
|
||||
cat >"$fixture/bin/keycloak-api" <<'PY'
|
||||
#!/usr/bin/env python3
|
||||
import http.server, json, os, pathlib, signal, sys, time, urllib.parse
|
||||
|
||||
port = int(sys.argv[1]); root = pathlib.Path(os.environ["PLATFORM_TEST_STATE"])
|
||||
state_path = root / "keycloak.json"; log = pathlib.Path(os.environ["PLATFORM_TEST_COMMAND_LOG"])
|
||||
|
||||
def load(): return json.loads(state_path.read_text())
|
||||
def save(value): state_path.write_text(json.dumps(value, sort_keys=True))
|
||||
def public_client(item): return {k:v for k,v in item.items() if k != "_secret"}
|
||||
def maybe_fault(label, handler):
|
||||
fault = os.environ.get("PLATFORM_TEST_FAULT", "")
|
||||
marker = root / ("fault-fired-" + label)
|
||||
if marker.exists():
|
||||
return False
|
||||
if fault == "timeout-" + label:
|
||||
marker.write_text("1")
|
||||
time.sleep(5)
|
||||
return True
|
||||
if fault == "loss-" + label:
|
||||
marker.write_text("1")
|
||||
handler.close_connection = True
|
||||
return True
|
||||
if fault == "http500-" + label:
|
||||
marker.write_text("1")
|
||||
handler.send_response(500); handler.end_headers(); return True
|
||||
if fault == "drift-secret-" + label:
|
||||
marker.write_text("1")
|
||||
secret_path=root/"secret.json"
|
||||
item=json.loads(secret_path.read_text())
|
||||
item["metadata"]["uid"]="secret-concurrent-replacement"
|
||||
item["metadata"]["resourceVersion"]="99"
|
||||
secret_path.write_text(json.dumps(item,sort_keys=True))
|
||||
handler.send_response(500); handler.end_headers(); return True
|
||||
if fault.startswith("signal-") and fault.endswith("-" + label):
|
||||
marker.write_text("1")
|
||||
sig = fault.split("-", 2)[1]
|
||||
os.kill(int(os.environ["PLATFORM_TEST_TARGET_PID"]), getattr(signal, "SIG" + sig))
|
||||
handler.close_connection = True
|
||||
return True
|
||||
return False
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def log_message(self, *_): return
|
||||
def body(self):
|
||||
length = int(self.headers.get("Content-Length", "0")); data = self.rfile.read(length)
|
||||
return json.loads(data) if data else None
|
||||
def send_json(self, code, value):
|
||||
payload = json.dumps(value, separators=(",", ":")).encode()
|
||||
self.send_response(code); self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(payload))); self.end_headers(); self.wfile.write(payload)
|
||||
def empty(self, code): self.send_response(code); self.end_headers()
|
||||
def parts(self): return urllib.parse.urlparse(self.path), urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
|
||||
def do_GET(self):
|
||||
parsed, query = self.parts(); path = parsed.path; state = load()
|
||||
if path.endswith("/.well-known/openid-configuration"):
|
||||
return self.send_json(200, {"issuer":"https://id.learn.hyeonworks.com/realms/hyeonworks"})
|
||||
if path.endswith("/clients"):
|
||||
matches=[public_client(x) for x in state["clients"] if x.get("clientId") == query.get("clientId",[""])[0]]
|
||||
return self.send_json(200, matches[:int(query.get("max",["2"])[0])])
|
||||
if "/clients/" in path and path.endswith("/client-secret"):
|
||||
cid=path.split("/clients/",1)[1].split("/",1)[0]
|
||||
matches=[x for x in state["clients"] if x["id"] == cid]
|
||||
return self.send_json(200,{"type":"secret","value":matches[0]["_secret"]}) if len(matches)==1 else self.empty(404)
|
||||
if "/clients/" in path and path.endswith("/protocol-mappers/models"):
|
||||
cid=path.split("/clients/",1)[1].split("/",1)[0]
|
||||
return self.send_json(200,state["mappers"].get(cid,[]))
|
||||
if "/clients/" in path:
|
||||
cid=path.split("/clients/",1)[1].split("/",1)[0]; matches=[public_client(x) for x in state["clients"] if x["id"]==cid]
|
||||
return self.send_json(200,matches[0]) if len(matches)==1 else self.empty(404)
|
||||
if path.endswith("/groups") and "/users/" not in path:
|
||||
search=query.get("search",[""])[0]
|
||||
matches=[x for x in state["groups"] if x.get("name")==search or x.get("path")=="/"+search]
|
||||
return self.send_json(200,matches[:int(query.get("max",["2"])[0])])
|
||||
if "/groups/" in path:
|
||||
gid=path.split("/groups/",1)[1].split("/",1)[0]; matches=[x for x in state["groups"] if x["id"]==gid]
|
||||
return self.send_json(200,matches[0]) if len(matches)==1 else self.empty(404)
|
||||
if path.endswith("/users"):
|
||||
username=query.get("username",[""])[0]; matches=[{"id":x["id"],"username":x["username"]} for x in state["users"] if x["username"]==username]
|
||||
return self.send_json(200,matches[:int(query.get("max",["2"])[0])])
|
||||
if "/users/" in path and path.endswith("/groups"):
|
||||
uid=path.split("/users/",1)[1].split("/",1)[0]; users=[x for x in state["users"] if x["id"]==uid]
|
||||
groups=[x for x in state["groups"] if x["id"] in users[0]["groups"]] if len(users)==1 else []
|
||||
return self.send_json(200,groups[:int(query.get("max",["101"])[0])])
|
||||
self.empty(404)
|
||||
def do_POST(self):
|
||||
with log.open("a",encoding="utf-8") as stream: stream.write("keycloak POST "+self.path+"\n")
|
||||
parsed,_=self.parts(); path=parsed.path
|
||||
if path.endswith("/protocol/openid-connect/token"):
|
||||
self.rfile.read(int(self.headers.get("Content-Length", "0")))
|
||||
return self.send_json(200,{"access_token":"fixture-admin-token","token_type":"Bearer"})
|
||||
body=self.body(); state=load()
|
||||
if path.endswith("/clients"):
|
||||
item=body; item["id"]="client-created"; item["_secret"]=os.environ["PLATFORM_TEST_CLIENT_SECRET"]
|
||||
state["clients"].append(item); state["mappers"][item["id"]]=[]; save(state)
|
||||
if maybe_fault("client-create",self): return
|
||||
self.send_response(201); self.send_header("Location",self.path+"/"+item["id"]); self.end_headers(); return
|
||||
if path.endswith("/groups"):
|
||||
item=body; item["id"]="group-created-"+str(len(state["groups"])+1); item["path"]="/"+item["name"]
|
||||
state["groups"].append(item); save(state)
|
||||
if maybe_fault("group-create",self): return
|
||||
self.send_response(201); self.send_header("Location",self.path+"/"+item["id"]); self.end_headers(); return
|
||||
if path.endswith("/protocol-mappers/models"):
|
||||
cid=path.split("/clients/",1)[1].split("/",1)[0]; item=body; item["id"]="mapper-created"
|
||||
state["mappers"].setdefault(cid,[]).append(item); save(state)
|
||||
if maybe_fault("mapper-create",self): return
|
||||
self.send_response(201); self.send_header("Location",self.path+"/"+item["id"]); self.end_headers(); return
|
||||
self.empty(404)
|
||||
def do_PUT(self):
|
||||
with log.open("a",encoding="utf-8") as stream: stream.write("keycloak PUT "+self.path+"\n")
|
||||
parsed,_=self.parts(); path=parsed.path; body=self.body(); state=load()
|
||||
if "/protocol-mappers/models/" in path:
|
||||
cid=path.split("/clients/",1)[1].split("/",1)[0]; mid=path.rsplit("/",1)[1]
|
||||
for i,item in enumerate(state["mappers"].get(cid,[])):
|
||||
if item["id"]==mid: body["id"]=mid; state["mappers"][cid][i]=body; save(state); break
|
||||
if maybe_fault("mapper-put",self): return
|
||||
return self.empty(204)
|
||||
if "/clients/" in path:
|
||||
cid=path.split("/clients/",1)[1].split("/",1)[0]
|
||||
for i,item in enumerate(state["clients"]):
|
||||
if item["id"]==cid:
|
||||
secret=body.pop("secret",item["_secret"]); body["id"]=cid; body["_secret"]=secret; state["clients"][i]=body; save(state); break
|
||||
if maybe_fault("client-put",self): return
|
||||
return self.empty(204)
|
||||
if "/groups/" in path and "/users/" not in path:
|
||||
gid=path.split("/groups/",1)[1].split("/",1)[0]
|
||||
for i,item in enumerate(state["groups"]):
|
||||
if item["id"]==gid:
|
||||
body["id"]=gid; body["path"]="/"+body["name"]; state["groups"][i]=body; save(state); break
|
||||
if maybe_fault("group-put",self): return
|
||||
return self.empty(204)
|
||||
if "/users/" in path and "/groups/" in path:
|
||||
uid=path.split("/users/",1)[1].split("/",1)[0]; gid=path.rsplit("/",1)[1]
|
||||
for user in state["users"]:
|
||||
if user["id"]==uid and gid not in user["groups"]: user["groups"].append(gid)
|
||||
save(state)
|
||||
label="admin-member-put" if gid.startswith("group-admin") else "viewer-member-put"
|
||||
if maybe_fault(label,self): return
|
||||
return self.empty(204)
|
||||
self.empty(404)
|
||||
def do_DELETE(self):
|
||||
with log.open("a",encoding="utf-8") as stream: stream.write("keycloak DELETE "+self.path+"\n")
|
||||
parsed,_=self.parts(); path=parsed.path; state=load()
|
||||
if "/users/" in path and "/groups/" in path:
|
||||
uid=path.split("/users/",1)[1].split("/",1)[0]; gid=path.rsplit("/",1)[1]
|
||||
for user in state["users"]:
|
||||
if user["id"]==uid and gid in user["groups"]: user["groups"].remove(gid)
|
||||
save(state); return self.empty(204)
|
||||
if "/protocol-mappers/models/" in path:
|
||||
cid=path.split("/clients/",1)[1].split("/",1)[0]; mid=path.rsplit("/",1)[1]
|
||||
if os.environ.get("PLATFORM_TEST_ROLLBACK_FAULT","")=="noop-mapper-delete":
|
||||
(root/"rollback-fault-fired-noop-mapper-delete").write_text("1")
|
||||
return self.empty(204)
|
||||
state["mappers"][cid]=[x for x in state["mappers"].get(cid,[]) if x["id"]!=mid]; save(state); return self.empty(204)
|
||||
if "/clients/" in path:
|
||||
cid=path.split("/clients/",1)[1].split("/",1)[0]; state["clients"]=[x for x in state["clients"] if x["id"]!=cid]; state["mappers"].pop(cid,None); save(state); return self.empty(204)
|
||||
if "/groups/" in path:
|
||||
gid=path.rsplit("/",1)[1]
|
||||
if os.environ.get("PLATFORM_TEST_ROLLBACK_FAULT","")=="noop-group-delete":
|
||||
(root/"rollback-fault-fired-noop-group-delete").write_text("1")
|
||||
return self.empty(204)
|
||||
state["groups"]=[x for x in state["groups"] if x["id"]!=gid]
|
||||
for user in state["users"]: user["groups"]=[x for x in user["groups"] if x!=gid]
|
||||
save(state); return self.empty(204)
|
||||
self.empty(404)
|
||||
|
||||
try:
|
||||
server=http.server.ThreadingHTTPServer(("127.0.0.1",port),Handler)
|
||||
server.serve_forever()
|
||||
except BaseException as error:
|
||||
(root/"keycloak-api-error").write_text(repr(error))
|
||||
raise
|
||||
PY
|
||||
chmod 0755 "$fixture/bin/keycloak-api"
|
||||
/usr/bin/python3 -m py_compile "$fixture/bin/keycloak-api"
|
||||
|
||||
cat >"$fixture/bin/kube-api" <<'PY'
|
||||
#!/usr/bin/env python3
|
||||
import base64, http.server, json, os, pathlib, socketserver, sys
|
||||
sock=sys.argv[1]; root=pathlib.Path(os.environ["PLATFORM_TEST_STATE"]); path=root/"secret.json"
|
||||
def load(): return json.loads(path.read_text()) if path.exists() else None
|
||||
class Server(socketserver.UnixStreamServer): pass
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def log_message(self,*_): return
|
||||
def payload(self):
|
||||
n=int(self.headers.get("Content-Length","0")); raw=self.rfile.read(n); return json.loads(raw) if raw else None
|
||||
def send_json(self,code,item):
|
||||
raw=json.dumps(item,separators=(",",":")).encode(); self.send_response(code); self.send_header("Content-Type","application/json"); self.send_header("Content-Length",str(len(raw))); self.end_headers(); self.wfile.write(raw)
|
||||
def do_GET(self):
|
||||
item=load()
|
||||
if self.path.endswith("/grafana-keycloak-oidc"):
|
||||
return self.send_json(200,item) if item else self.send_json(404,{"kind":"Status","reason":"NotFound"})
|
||||
self.send_json(405,{"kind":"Status"})
|
||||
def do_POST(self):
|
||||
if path.exists(): return self.send_json(409,{"kind":"Status","reason":"AlreadyExists"})
|
||||
item=self.payload(); item["metadata"]["uid"]="secret-created-uid"; item["metadata"]["resourceVersion"]="1"; path.write_text(json.dumps(item,sort_keys=True))
|
||||
fault=os.environ.get("PLATFORM_TEST_FAULT","")
|
||||
if fault=="loss-secret-create": self.close_connection=True; return
|
||||
if fault=="conflict-secret-create": path.unlink(); return self.send_json(409,{"kind":"Status","reason":"Conflict"})
|
||||
self.send_json(201,item)
|
||||
def do_DELETE(self):
|
||||
item=load(); options=self.payload() or {}; pre=(options.get("preconditions") or {})
|
||||
if not item: return self.send_json(404,{"kind":"Status","reason":"NotFound"})
|
||||
if pre.get("uid")!=item["metadata"]["uid"] or pre.get("resourceVersion")!=item["metadata"]["resourceVersion"]:
|
||||
return self.send_json(409,{"kind":"Status","reason":"Conflict"})
|
||||
path.unlink(); self.send_json(200,{"kind":"Status","status":"Success"})
|
||||
with Server(sock,Handler) as server: server.serve_forever()
|
||||
PY
|
||||
chmod 0755 "$fixture/bin/kube-api"
|
||||
/usr/bin/python3 -m py_compile "$fixture/bin/kube-api"
|
||||
|
||||
cat >"$fixture/bin/kubectl" <<'PY'
|
||||
#!/usr/bin/env python3
|
||||
import base64,json,os,pathlib,sys
|
||||
args=sys.argv[1:]; log=pathlib.Path(os.environ["PLATFORM_TEST_COMMAND_LOG"])
|
||||
with log.open("a") as f: f.write("kubectl "+" ".join(args)+"\n")
|
||||
if "--request-timeout=5s" not in args: raise SystemExit(84)
|
||||
args=[x for x in args if x!="--request-timeout=5s"]
|
||||
def opt(name):
|
||||
for i,x in enumerate(args):
|
||||
if x==name and i+1<len(args): return args[i+1]
|
||||
if x.startswith(name+"="): return x.split("=",1)[1]
|
||||
return None
|
||||
if args==["config","current-context"]: print(os.environ.get("PLATFORM_TEST_CONTEXT","default")); raise SystemExit()
|
||||
if args[:3]==["config","view","--minify"]: print("https://127.0.0.1:6443",end=""); raise SystemExit()
|
||||
if args[:3]==["get","node","donghyeon-system-product-name"]:
|
||||
print(json.dumps({"apiVersion":"v1","kind":"Node","metadata":{"name":"donghyeon-system-product-name","uid":"node-uid"},"status":{"conditions":[{"type":"Ready","status":"True"}]}})); raise SystemExit()
|
||||
if args[:2]==["auth","can-i"]: print("yes"); raise SystemExit()
|
||||
if args[:3]==["get","namespace","keycloak"] or args[:3]==["get","namespace","observability"]: print("namespace/"+args[2]); raise SystemExit()
|
||||
if "get" in args and "keycloak.k8s.keycloak.org/keycloak" in args:
|
||||
print(json.dumps({"apiVersion":"k8s.keycloak.org/v2beta1","kind":"Keycloak","metadata":{"name":"keycloak","namespace":"keycloak","uid":"kc-uid"},"status":{"conditions":[{"type":"Ready","status":"True"}]}})); raise SystemExit()
|
||||
if "get" in args and "service/keycloak-service" in args:
|
||||
print(json.dumps({"apiVersion":"v1","kind":"Service","metadata":{"name":"keycloak-service","namespace":"keycloak","uid":"svc-uid"},"spec":{"ports":[{"port":8080,"targetPort":8080}]}})); raise SystemExit()
|
||||
if "get" in args and "secret/keycloak-initial-admin" in args:
|
||||
item={"apiVersion":"v1","kind":"Secret","type":"kubernetes.io/basic-auth","metadata":{"name":"keycloak-initial-admin","namespace":"keycloak","uid":"admin-uid","resourceVersion":"1"},"data":{"username":base64.b64encode(b"temp-admin").decode(),"password":base64.b64encode(b"TempAdminPassword-123").decode()}}
|
||||
print(json.dumps(item)); raise SystemExit()
|
||||
if any(x.startswith("--accept-methods") for x in args): raise SystemExit(86)
|
||||
if args and args[0]=="port-forward":
|
||||
mapping=next(x for x in args if x.endswith(":8080")); port=mapping.split(":",1)[0]
|
||||
print("Forwarding from 127.0.0.1:"+port+" -> 8080",flush=True)
|
||||
os.execv(os.environ["PLATFORM_TEST_KEYCLOAK_API"],[os.environ["PLATFORM_TEST_KEYCLOAK_API"],port])
|
||||
if args and args[0]=="proxy":
|
||||
sock=opt("--unix-socket"); os.execv(os.environ["PLATFORM_TEST_KUBE_API"],[os.environ["PLATFORM_TEST_KUBE_API"],sock])
|
||||
raise SystemExit(83)
|
||||
PY
|
||||
chmod 0755 "$fixture/bin/kubectl"
|
||||
}
|
||||
|
||||
seed_state() {
|
||||
local fixture=$1 profile=${2:-absent}
|
||||
python3 -I -S - "$fixture/state/keycloak.json" "$profile" "$CLIENT_SECRET" <<'PY'
|
||||
import json,pathlib,sys
|
||||
path,profile,secret=sys.argv[1:]
|
||||
client={"id":"client-existing","clientId":"grafana","name":"Drifted Grafana","enabled":False,"protocol":"openid-connect","publicClient":True,"standardFlowEnabled":False,"implicitFlowEnabled":True,"directAccessGrantsEnabled":True,"serviceAccountsEnabled":True,"authorizationServicesEnabled":True,"fullScopeAllowed":True,"rootUrl":"https://wrong.invalid","baseUrl":"https://wrong.invalid","redirectUris":["https://wrong.invalid/cb"],"webOrigins":["+"],"attributes":{},"_secret":secret}
|
||||
groups=[{"id":"group-admin-existing","name":"platform-observability-admins","path":"/platform-observability-admins"},{"id":"group-viewer-existing","name":"platform-observability-viewers","path":"/platform-observability-viewers"}]
|
||||
mapper={"id":"mapper-existing","name":"grafana-groups","protocol":"openid-connect","protocolMapper":"oidc-hardcoded-claim-mapper","consentRequired":True,"config":{"claim.name":"wrong"}}
|
||||
state={"clients":[],"groups":[],"mappers":{},"users":[{"id":"user-admin","username":"admin-user","groups":[]},{"id":"user-viewer","username":"viewer-user","groups":[]}]}
|
||||
if profile in {"existing","exact-secret","preexisting-admin","duplicate-client","duplicate-user","duplicate-group","mapper-duplicate"}:
|
||||
state["clients"]=[client]; state["groups"]=groups; state["mappers"]={"client-existing":[mapper]}
|
||||
if profile=="exact-secret":
|
||||
state["clients"][0].update({"name":"Grafana","description":"Grafana confidential OIDC client managed by the platform bootstrap","enabled":True,"protocol":"openid-connect","clientAuthenticatorType":"client-secret","publicClient":False,"standardFlowEnabled":True,"implicitFlowEnabled":False,"directAccessGrantsEnabled":False,"serviceAccountsEnabled":False,"authorizationServicesEnabled":False,"consentRequired":False,"fullScopeAllowed":False,"rootUrl":"https://grafana.learn.hyeonworks.com","baseUrl":"https://grafana.learn.hyeonworks.com","redirectUris":["https://grafana.learn.hyeonworks.com/login/generic_oauth"],"webOrigins":["https://grafana.learn.hyeonworks.com"],"attributes":{"post.logout.redirect.uris":"https://grafana.learn.hyeonworks.com/*","oauth2.device.authorization.grant.enabled":"false","oidc.ciba.grant.enabled":"false"}})
|
||||
state["mappers"]["client-existing"]=[{"id":"mapper-existing","name":"grafana-groups","protocol":"openid-connect","protocolMapper":"oidc-group-membership-mapper","consentRequired":False,"config":{"claim.name":"groups","full.path":"true","id.token.claim":"true","access.token.claim":"true","userinfo.token.claim":"true"}}]
|
||||
if profile=="preexisting-admin": state["users"][0]["groups"]=["group-admin-existing"]
|
||||
if profile=="duplicate-client": state["clients"].append(dict(client,id="client-second"))
|
||||
if profile=="duplicate-user": state["users"].append({"id":"user-admin-second","username":"admin-user","groups":[]})
|
||||
if profile=="duplicate-group": state["groups"].append({"id":"group-admin-second","name":"platform-observability-admins","path":"/platform-observability-admins"})
|
||||
if profile=="mapper-duplicate": state["mappers"]["client-existing"].append(dict(mapper,id="mapper-second",name="other",config={"claim.name":"groups"}))
|
||||
pathlib.Path(path).write_text(json.dumps(state,sort_keys=True))
|
||||
PY
|
||||
}
|
||||
|
||||
seed_secret() {
|
||||
local fixture=$1 secret=${2:-$CLIENT_SECRET}
|
||||
python3 -I -S - "$fixture/state/secret.json" "$secret" <<'PY'
|
||||
import base64,json,pathlib,sys
|
||||
path,secret=sys.argv[1:]
|
||||
item={"apiVersion":"v1","kind":"Secret","type":"Opaque","metadata":{"namespace":"observability","name":"grafana-keycloak-oidc","uid":"secret-existing-uid","resourceVersion":"7"},"data":{"client-id":base64.b64encode(b"grafana").decode(),"client-secret":base64.b64encode(secret.encode()).decode()}}
|
||||
pathlib.Path(path).write_text(json.dumps(item,sort_keys=True))
|
||||
PY
|
||||
}
|
||||
|
||||
new_fixture() {
|
||||
local name=$1 profile=${2:-absent} fixture
|
||||
fixture="$WORK/$name"
|
||||
mkdir -p "$fixture"; make_fakes "$fixture"; seed_state "$fixture" "$profile"; printf '%s\n' "$fixture"
|
||||
}
|
||||
|
||||
run_script() {
|
||||
local fixture=$1; shift
|
||||
env \
|
||||
PATH="$fixture/bin:$PATH" \
|
||||
PLATFORM_GRAFANA_OIDC_CONFIRMATIONS="${PLATFORM_TEST_CONFIRMATIONS:-}" \
|
||||
PLATFORM_GRAFANA_OIDC_NOW_UTC="$NOW_UTC" \
|
||||
PLATFORM_TEST_COMMAND_LOG="$fixture/commands.log" \
|
||||
PLATFORM_TEST_STATE="$fixture/state" \
|
||||
PLATFORM_TEST_SUDO_REFRESHED="$fixture/sudo-refreshed" \
|
||||
PLATFORM_TEST_ENCRYPTION_COUNT="$fixture/encryption-count" \
|
||||
PLATFORM_TEST_RESTORE_COUNT="$fixture/restore-count" \
|
||||
PLATFORM_TEST_ENCRYPTION_FAIL_AT="${PLATFORM_TEST_ENCRYPTION_FAIL_AT:-0}" \
|
||||
PLATFORM_TEST_RESTORE_FAIL_AT="${PLATFORM_TEST_RESTORE_FAIL_AT:-0}" \
|
||||
PLATFORM_TEST_VALIDATOR_ENV_LOG="${PLATFORM_TEST_VALIDATOR_ENV_LOG:-}" \
|
||||
PLATFORM_TEST_CONTEXT="${PLATFORM_TEST_CONTEXT:-default}" \
|
||||
PLATFORM_TEST_FAULT="${PLATFORM_TEST_FAULT:-}" \
|
||||
PLATFORM_TEST_ROLLBACK_FAULT="${PLATFORM_TEST_ROLLBACK_FAULT:-}" \
|
||||
PLATFORM_TEST_CLIENT_SECRET="$CLIENT_SECRET" \
|
||||
PLATFORM_TEST_KEYCLOAK_API="$fixture/bin/keycloak-api" \
|
||||
PLATFORM_TEST_KUBE_API="$fixture/bin/kube-api" \
|
||||
bash -c 'source "$1"; shift; platform_grafana_oidc_fixture_main "$@"' \
|
||||
platform-grafana-oidc-test "$SCRIPT" "$fixture" "$@"
|
||||
}
|
||||
|
||||
assert_exact_contract() {
|
||||
local fixture=$1
|
||||
python3 -I -S - "$fixture/state/keycloak.json" "$fixture/state/secret.json" "$CLIENT_SECRET" <<'PY' || return 1
|
||||
import base64,json,pathlib,sys
|
||||
kc=json.loads(pathlib.Path(sys.argv[1]).read_text()); secret=json.loads(pathlib.Path(sys.argv[2]).read_text()); expected=sys.argv[3]
|
||||
assert len(kc["clients"])==1
|
||||
c=kc["clients"][0]
|
||||
assert c["clientId"]=="grafana" and c["publicClient"] is False and c["standardFlowEnabled"] is True
|
||||
assert all(c[x] is False for x in ("implicitFlowEnabled","directAccessGrantsEnabled","serviceAccountsEnabled","authorizationServicesEnabled"))
|
||||
assert c["rootUrl"]==c["baseUrl"]=="https://grafana.learn.hyeonworks.com"
|
||||
assert c["redirectUris"]==["https://grafana.learn.hyeonworks.com/login/generic_oauth"]
|
||||
assert c["webOrigins"]==["https://grafana.learn.hyeonworks.com"] and c["fullScopeAllowed"] is False
|
||||
assert c["attributes"]["post.logout.redirect.uris"]=="https://grafana.learn.hyeonworks.com/*"
|
||||
assert c["attributes"]["oauth2.device.authorization.grant.enabled"]=="false" and c["attributes"]["oidc.ciba.grant.enabled"]=="false"
|
||||
assert c["_secret"]==expected
|
||||
assert sorted(x["path"] for x in kc["groups"])==["/platform-observability-admins","/platform-observability-viewers"]
|
||||
m=kc["mappers"][c["id"]]; assert len(m)==1
|
||||
assert m[0]["name"]=="grafana-groups" and m[0]["protocolMapper"]=="oidc-group-membership-mapper"
|
||||
assert m[0]["config"]=={"access.token.claim":"true","claim.name":"groups","full.path":"true","id.token.claim":"true","userinfo.token.claim":"true"}
|
||||
assert secret["type"]=="Opaque" and sorted(secret["data"])==["client-id","client-secret"]
|
||||
assert base64.b64decode(secret["data"]["client-id"])==b"grafana"
|
||||
assert base64.b64decode(secret["data"]["client-secret"]).decode()==expected
|
||||
PY
|
||||
}
|
||||
|
||||
WORK="$(mktemp -d /tmp/platform-grafana-oidc-test.XXXXXX)"; chmod 0700 "$WORK"
|
||||
|
||||
[[ -f "$SCRIPT" ]] || fail 'production Grafana OIDC bootstrap is absent'
|
||||
|
||||
fixture="$(new_fixture dry-run)"
|
||||
output="$(run_script "$fixture")" || fail 'no-argument dry-run failed'
|
||||
grep -Fq 'GRAFANA_OIDC_DRY_RUN=PASS' <<<"$output" || fail 'dry-run marker absent'
|
||||
[[ ! -s "$fixture/commands.log" ]] || fail 'dry-run crossed a system boundary'
|
||||
[[ "$output" != *"$CLIENT_SECRET"* ]] || fail 'dry-run leaked a payload'
|
||||
pass 'no-argument mode is payload-free and read-only'
|
||||
|
||||
fixture="$(new_fixture boundary)"
|
||||
for argv in '--admin admin-user' '--execute --check-recovery-evidence' '--check-recovery-evidence --viewer viewer-user' '--execute --admin' '--execute --admin admin-user --admin admin-user'; do
|
||||
read -r -a args <<<"$argv"
|
||||
if run_script "$fixture" "${args[@]}" >"$fixture/out" 2>&1; then fail "unsafe CLI accepted: $argv"; fi
|
||||
[[ ! -s "$fixture/commands.log" ]] || fail "invalid CLI crossed a boundary: $argv"
|
||||
done
|
||||
if PLATFORM_GRAFANA_OIDC_TEST_MODE=1 bash "$SCRIPT" >"$fixture/direct" 2>&1; then fail 'production entrypoint accepted an environment test bypass'; fi
|
||||
pass 'CLI modes are closed and production rejects test overrides'
|
||||
|
||||
fixture="$(new_fixture last-gate)"
|
||||
if PLATFORM_TEST_CONFIRMATIONS=$'APPLY default\nRECOVERY KEYCLOAK default' PLATFORM_TEST_RESTORE_FAIL_AT=2 \
|
||||
run_script "$fixture" --execute >"$fixture/out" 2>&1; then fail 'last restore gate failure was accepted'; fi
|
||||
[[ "$(grep -c '^encryption --expect-reencrypted$' "$fixture/commands.log")" == 2 ]] || {
|
||||
sed -n '1,200p' "$fixture/out" >&2; sed -n '1,240p' "$fixture/commands.log" >&2
|
||||
[[ ! -f "$fixture/state/keycloak-api-error" ]] || cat "$fixture/state/keycloak-api-error" >&2
|
||||
fail 'encryption gate was not fresh twice'
|
||||
}
|
||||
[[ "$(grep -c '^restore --check$' "$fixture/commands.log")" == 2 ]] || {
|
||||
sed -n '1,200p' "$fixture/out" >&2; sed -n '1,240p' "$fixture/commands.log" >&2
|
||||
fail 'restore gate was not fresh twice'
|
||||
}
|
||||
python3 -I -S - "$fixture/state/keycloak.json" <<'PY' || fail 'last gate mutated Keycloak'
|
||||
import json,pathlib,sys
|
||||
x=json.loads(pathlib.Path(sys.argv[1]).read_text()); assert x["clients"]==[] and x["groups"]==[]
|
||||
PY
|
||||
[[ ! -e "$fixture/state/secret.json" ]] || fail 'last gate mutated Kubernetes'
|
||||
pass 'both validator pairs run in fresh processes and last-gate failure is zero-mutation'
|
||||
|
||||
fixture="$(new_fixture missing-evidence-parent)"
|
||||
: >"$fixture/state/remove-evidence-parent"
|
||||
if PLATFORM_TEST_CONFIRMATIONS=$'APPLY default\nRECOVERY KEYCLOAK default' \
|
||||
run_script "$fixture" --execute >"$fixture/out" 2>&1; then
|
||||
fail 'missing recovery-evidence parent was accepted'
|
||||
fi
|
||||
! grep -Fq 'Type APPLY default:' "$fixture/out" ||
|
||||
fail 'missing recovery-evidence parent reached confirmation'
|
||||
! grep -Eq '^keycloak (POST|PUT|DELETE) /admin/realms/hyeonworks/' "$fixture/commands.log" ||
|
||||
fail 'missing recovery-evidence parent reached Keycloak mutation'
|
||||
[[ ! -e "$fixture/state/secret.json" ]] ||
|
||||
fail 'missing recovery-evidence parent reached Kubernetes Secret mutation'
|
||||
pass 'missing recovery-evidence parent fails closed before object mutation'
|
||||
|
||||
fixture="$(new_fixture create)"
|
||||
PLATFORM_TEST_CONFIRMATIONS=$'APPLY default\nRECOVERY KEYCLOAK default' run_script "$fixture" --execute >"$fixture/out" 2>&1 || { sed -n '1,220p' "$fixture/out" >&2; fail 'create path failed'; }
|
||||
assert_exact_contract "$fixture" || fail 'created contract differs'
|
||||
[[ "$(<"$fixture/out")" != *"$CLIENT_SECRET"* ]] || fail 'success output leaked client secret'
|
||||
! grep -Fq "$CLIENT_SECRET" "$fixture/commands.log" || fail 'client secret reached argv log'
|
||||
grep -Fq 'GRAFANA_OIDC_TRANSACTION=PASS' "$fixture/out" || fail 'success marker absent'
|
||||
marker="$fixture/evidence-parent/recovery-evidence/keycloak.env"
|
||||
[[ -f "$marker" && ! -L "$marker" && "$(stat -c '%u:%a:%h' "$marker")" == "$(id -u):600:1" ]] || fail 'marker metadata differs'
|
||||
[[ "$(wc -l <"$marker" | tr -d '[:space:]')" == 4 ]] || fail 'marker key set differs'
|
||||
grep -Fqx 'schema=platform-observability-recovery-evidence-v1' "$marker" || fail 'marker schema differs'
|
||||
grep -Fqx 'context=default' "$marker" || fail 'marker context differs'
|
||||
grep -Fqx 'resource=keycloak/hyeonworks/client/grafana' "$marker" || fail 'marker resource differs'
|
||||
grep -Fqx "checked_at_utc=$NOW_UTC" "$marker" || fail 'marker timestamp differs'
|
||||
pass 'create path establishes the exact client, groups, mapper, Secret, and evidence marker'
|
||||
|
||||
fixture="$(new_fixture hostile-curlrc)"
|
||||
mkdir -m 0700 -- "$fixture/curl-home"
|
||||
printf 'trace-ascii = "%s"\n' "$fixture/curl-trace" >"$fixture/curl-home/.curlrc"
|
||||
chmod 0600 -- "$fixture/curl-home/.curlrc"
|
||||
CURL_HOME="$fixture/curl-home" \
|
||||
PLATFORM_TEST_CONFIRMATIONS=$'APPLY default\nRECOVERY KEYCLOAK default' \
|
||||
run_script "$fixture" --execute >"$fixture/out" 2>&1 || {
|
||||
sed -n '1,220p' "$fixture/out" >&2
|
||||
fail 'hostile curl default-config fixture did not complete'
|
||||
}
|
||||
[[ ! -e "$fixture/curl-trace" ]] || fail 'curl loaded a user default config and wrote a credential trace'
|
||||
pass 'curl ignores user default configuration across credential-bearing requests'
|
||||
|
||||
marker="$fixture/evidence-parent/recovery-evidence/keycloak.env"
|
||||
: >"$fixture/commands.log"
|
||||
run_script "$fixture" --check-recovery-evidence >"$fixture/check" 2>&1 || fail 'fresh evidence rejected'
|
||||
grep -Fq 'KEYCLOAK_RECOVERY_EVIDENCE=PASS' "$fixture/check" || fail 'check marker absent'
|
||||
! grep -q '^encryption\|^restore' "$fixture/commands.log" || fail 'check mode ran mutation gates'
|
||||
grep -q '^sudo ' "$fixture/commands.log" || fail 'check did not use narrow sudo'
|
||||
if PLATFORM_TEST_CONTEXT=other run_script "$fixture" --check-recovery-evidence >"$fixture/wrong" 2>&1; then fail 'wrong context accepted evidence'; fi
|
||||
ln -- "$marker" "$marker.second"
|
||||
if run_script "$fixture" --check-recovery-evidence >"$fixture/link" 2>&1; then fail 'multiply linked marker accepted'; fi
|
||||
unlink -- "$marker.second"
|
||||
cp -- "$marker" "$fixture/marker-valid"
|
||||
sed -i 's/checked_at_utc=.*/checked_at_utc=2026-06-01T00:00:00Z/' "$marker"
|
||||
if run_script "$fixture" --check-recovery-evidence >"$fixture/stale" 2>&1; then fail 'stale recovery evidence accepted'; fi
|
||||
cp -- "$fixture/marker-valid" "$marker"
|
||||
printf 'extra=forbidden\n' >>"$marker"
|
||||
if run_script "$fixture" --check-recovery-evidence >"$fixture/extra" 2>&1; then fail 'extra recovery evidence field accepted'; fi
|
||||
cp -- "$fixture/marker-valid" "$marker"
|
||||
mv -- "$fixture/evidence-parent/recovery-evidence" "$fixture/evidence-parent/recovery-evidence-real"
|
||||
ln -s -- recovery-evidence-real "$fixture/evidence-parent/recovery-evidence"
|
||||
if run_script "$fixture" --check-recovery-evidence >"$fixture/symlink" 2>&1; then fail 'symlink evidence directory accepted'; fi
|
||||
unlink -- "$fixture/evidence-parent/recovery-evidence"
|
||||
mv -- "$fixture/evidence-parent/recovery-evidence-real" "$fixture/evidence-parent/recovery-evidence"
|
||||
pass 'evidence check binds context, age, schema, and no-follow metadata'
|
||||
|
||||
fixture="$(new_fixture existing existing)"; seed_secret "$fixture"
|
||||
before_secret="$(sha256sum "$fixture/state/secret.json" | awk '{print $1}')"
|
||||
PLATFORM_TEST_CONFIRMATIONS=$'APPLY default\nRECOVERY KEYCLOAK default' run_script "$fixture" --execute --admin admin-user --viewer viewer-user >"$fixture/out" 2>&1 || {
|
||||
sed -n '1,240p' "$fixture/out" >&2; sed -n '1,300p' "$fixture/commands.log" >&2
|
||||
fail 'existing update failed'
|
||||
}
|
||||
assert_exact_contract "$fixture" || fail 'existing update contract differs'
|
||||
[[ "$(sha256sum "$fixture/state/secret.json" | awk '{print $1}')" == "$before_secret" ]] || fail 'exact existing Secret was rotated or rewritten'
|
||||
python3 -I -S - "$fixture/state/keycloak.json" <<'PY' || fail 'requested memberships absent'
|
||||
import json,pathlib,sys
|
||||
x=json.loads(pathlib.Path(sys.argv[1]).read_text()); ids={g["path"]:g["id"] for g in x["groups"]}; users={u["username"]:u for u in x["users"]}
|
||||
assert ids["/platform-observability-admins"] in users["admin-user"]["groups"]
|
||||
assert ids["/platform-observability-viewers"] in users["viewer-user"]["groups"]
|
||||
PY
|
||||
pass 'existing client is declaratively updated without credential rotation and memberships are optional'
|
||||
|
||||
fixture="$(new_fixture exact-noop exact-secret)"; seed_secret "$fixture"
|
||||
before_keycloak="$(sha256sum "$fixture/state/keycloak.json" | awk '{print $1}')"
|
||||
before_exact_secret="$(sha256sum "$fixture/state/secret.json" | awk '{print $1}')"
|
||||
PLATFORM_TEST_CONFIRMATIONS=$'APPLY default\nRECOVERY KEYCLOAK default' run_script "$fixture" --execute >"$fixture/out" 2>&1 || {
|
||||
sed -n '1,240p' "$fixture/out" >&2; fail 'exact-existing no-op failed'
|
||||
}
|
||||
[[ "$(sha256sum "$fixture/state/keycloak.json" | awk '{print $1}')" == "$before_keycloak" ]] || fail 'exact-existing Keycloak state was rewritten'
|
||||
[[ "$(sha256sum "$fixture/state/secret.json" | awk '{print $1}')" == "$before_exact_secret" ]] || fail 'exact-existing Secret was rewritten'
|
||||
! grep -q '^keycloak PUT ' "$fixture/commands.log" || fail 'exact-existing Keycloak state was PUT'
|
||||
[[ -f "$fixture/evidence-parent/recovery-evidence/keycloak.env" ]] || fail 'exact-existing no-op omitted recovery evidence'
|
||||
pass 'exact existing live-style state is a no-op except recovery evidence validation'
|
||||
|
||||
for profile in duplicate-client duplicate-group duplicate-user mapper-duplicate; do
|
||||
fixture="$(new_fixture "$profile" "$profile")"; seed_secret "$fixture"
|
||||
args=(--execute); [[ "$profile" != duplicate-user ]] || args+=(--admin admin-user)
|
||||
if PLATFORM_TEST_CONFIRMATIONS=$'APPLY default\nRECOVERY KEYCLOAK default' run_script "$fixture" "${args[@]}" >"$fixture/out" 2>&1; then fail "$profile ambiguity accepted"; fi
|
||||
! grep -q 'request=\(POST\|PUT\|DELETE\)' "$fixture/commands.log" || fail "$profile reached mutation"
|
||||
done
|
||||
pass 'duplicate client, group, mapper, and user states fail before mutation'
|
||||
|
||||
fixture="$(new_fixture mismatch existing)"; seed_secret "$fixture" 'DifferentClientSecret-123456789'
|
||||
if PLATFORM_TEST_CONFIRMATIONS=$'APPLY default\nRECOVERY KEYCLOAK default' run_script "$fixture" --execute >"$fixture/out" 2>&1; then fail 'mismatched existing Secret accepted'; fi
|
||||
python3 -I -S - "$fixture/state/keycloak.json" <<'PY' || fail 'mismatch mutated client'
|
||||
import json,pathlib,sys
|
||||
x=json.loads(pathlib.Path(sys.argv[1]).read_text()); assert x["clients"][0]["name"]=="Drifted Grafana"
|
||||
PY
|
||||
pass 'existing Secret/client mismatch refuses rotation with zero mutation'
|
||||
|
||||
for malformed_client_id in trailing-newline trailing-nul; do
|
||||
fixture="$(new_fixture "client-id-$malformed_client_id" existing)"; seed_secret "$fixture"
|
||||
python3 -I -S - "$fixture/state/secret.json" "$malformed_client_id" <<'PY'
|
||||
import base64,json,pathlib,sys
|
||||
path=pathlib.Path(sys.argv[1]); variant=sys.argv[2]
|
||||
item=json.loads(path.read_text())
|
||||
payload={"trailing-newline":b"grafana\n","trailing-nul":b"grafana\0"}[variant]
|
||||
item["data"]["client-id"]=base64.b64encode(payload).decode()
|
||||
path.write_text(json.dumps(item,sort_keys=True))
|
||||
PY
|
||||
cp -- "$fixture/state/keycloak.json" "$fixture/before.json"
|
||||
if PLATFORM_TEST_CONFIRMATIONS=$'APPLY default\nRECOVERY KEYCLOAK default' \
|
||||
run_script "$fixture" --execute >"$fixture/out" 2>&1; then
|
||||
fail "$malformed_client_id OIDC Secret client-id was accepted"
|
||||
fi
|
||||
cmp --silent -- "$fixture/before.json" "$fixture/state/keycloak.json" ||
|
||||
fail "$malformed_client_id OIDC Secret reached Keycloak mutation"
|
||||
done
|
||||
pass 'OIDC Secret client-id must decode to the exact grafana byte sequence'
|
||||
|
||||
fixture="$(new_fixture rollback preexisting-admin)"; seed_secret "$fixture"
|
||||
cp -- "$fixture/state/keycloak.json" "$fixture/before.json"
|
||||
if PLATFORM_TEST_CONFIRMATIONS=$'APPLY default\nRECOVERY KEYCLOAK default' PLATFORM_TEST_FAULT=http500-viewer-member-put \
|
||||
run_script "$fixture" --execute --admin admin-user --viewer viewer-user >"$fixture/out" 2>&1; then fail 'membership failure accepted'; fi
|
||||
cmp --silent "$fixture/before.json" "$fixture/state/keycloak.json" || fail 'rollback did not restore exact Keycloak prestate'
|
||||
[[ "$(sha256sum "$fixture/state/secret.json" | awk '{print $1}')" == "$before_secret" ]] || fail 'rollback rewrote prior Secret'
|
||||
grep -Fq 'GRAFANA_OIDC_ROLLBACK=PASS' "$fixture/out" || fail 'rollback marker absent'
|
||||
pass 'rollback preserves preexisting membership and removes only transaction-added membership'
|
||||
|
||||
for rollback_fault in noop-group-delete noop-mapper-delete; do
|
||||
fixture="$(new_fixture "$rollback_fault")"
|
||||
if PLATFORM_TEST_CONFIRMATIONS=$'APPLY default\nRECOVERY KEYCLOAK default' \
|
||||
PLATFORM_TEST_FAULT=http500-viewer-member-put \
|
||||
PLATFORM_TEST_ROLLBACK_FAULT="$rollback_fault" \
|
||||
run_script "$fixture" --execute --viewer viewer-user >"$fixture/out" 2>&1; then
|
||||
fail "$rollback_fault trigger was accepted"
|
||||
fi
|
||||
[[ -f "$fixture/state/rollback-fault-fired-$rollback_fault" ]] ||
|
||||
fail "$rollback_fault fixture did not intercept the rollback DELETE"
|
||||
! grep -Fq 'GRAFANA_OIDC_ROLLBACK=PASS' "$fixture/out" ||
|
||||
fail "$rollback_fault produced a false rollback PASS"
|
||||
grep -Fq 'GRAFANA_OIDC_ROLLBACK=FAIL' "$fixture/out" ||
|
||||
fail "$rollback_fault omitted rollback failure evidence"
|
||||
grep -Fq 'MANUAL_RECOVERY_REQUIRED=YES' "$fixture/out" ||
|
||||
fail "$rollback_fault omitted manual recovery evidence"
|
||||
done
|
||||
pass 'created Keycloak object rollback requires exact post-delete absence'
|
||||
|
||||
for fault in loss-group-put loss-client-create loss-client-put loss-mapper-put loss-secret-create conflict-secret-create timeout-client-create; do
|
||||
fixture="$(new_fixture "fault-$fault")"
|
||||
if PLATFORM_TEST_CONFIRMATIONS=$'APPLY default\nRECOVERY KEYCLOAK default' PLATFORM_TEST_FAULT="$fault" run_script "$fixture" --execute >"$fixture/out" 2>&1; then fail "$fault accepted"; fi
|
||||
python3 -I -S - "$fixture/state/keycloak.json" <<'PY' || fail "$fault left Keycloak objects"
|
||||
import json,pathlib,sys
|
||||
x=json.loads(pathlib.Path(sys.argv[1]).read_text()); assert x["clients"]==[] and x["groups"]==[]
|
||||
PY
|
||||
[[ ! -e "$fixture/state/secret.json" ]] || fail "$fault left transaction Secret"
|
||||
done
|
||||
pass 'response loss and Kubernetes conflict classify ownership and reverse exact transaction state'
|
||||
|
||||
fixture="$(new_fixture update-response-loss existing)"; seed_secret "$fixture"; cp -- "$fixture/state/keycloak.json" "$fixture/before.json"
|
||||
if PLATFORM_TEST_CONFIRMATIONS=$'APPLY default\nRECOVERY KEYCLOAK default' PLATFORM_TEST_FAULT=loss-client-put \
|
||||
run_script "$fixture" --execute >"$fixture/out" 2>&1; then fail 'lost client update response was accepted'; fi
|
||||
cmp --silent -- "$fixture/before.json" "$fixture/state/keycloak.json" || fail 'lost update response did not restore exact client prestate'
|
||||
grep -Fq 'GRAFANA_OIDC_ROLLBACK=PASS' "$fixture/out" || {
|
||||
sed -n '1,240p' "$fixture/out" >&2; fail 'lost update response omitted rollback proof'
|
||||
}
|
||||
pass 'lost Keycloak update response is classified and exact prior client state is restored'
|
||||
|
||||
for sig in HUP INT TERM; do
|
||||
fixture="$(new_fixture "signal-${sig,,}" existing)"; seed_secret "$fixture"; cp -- "$fixture/state/keycloak.json" "$fixture/before.json"
|
||||
if PLATFORM_TEST_CONFIRMATIONS=$'APPLY default\nRECOVERY KEYCLOAK default' PLATFORM_TEST_FAULT="signal-$sig-viewer-member-put" \
|
||||
run_script "$fixture" --execute --admin admin-user --viewer viewer-user >"$fixture/out" 2>&1; then fail "$sig was accepted"; fi
|
||||
cmp --silent -- "$fixture/before.json" "$fixture/state/keycloak.json" || fail "$sig did not restore exact Keycloak prestate"
|
||||
grep -Fq 'GRAFANA_OIDC_ROLLBACK=PASS' "$fixture/out" || fail "$sig did not complete the rollback path"
|
||||
! grep -Fq 'MANUAL_RECOVERY_REQUIRED=YES' "$fixture/out" || fail "$sig exact ownership was misclassified as ambiguous"
|
||||
done
|
||||
pass 'HUP, INT, and TERM classify exact ownership and restore the transaction prestate'
|
||||
|
||||
fixture="$(new_fixture exit-fallback existing)"; seed_secret "$fixture"; cp -- "$fixture/state/keycloak.json" "$fixture/before.json"
|
||||
if PLATFORM_TEST_CONFIRMATIONS=$'APPLY default\nRECOVERY KEYCLOAK default' PLATFORM_TEST_FAULT=signal-USR1-viewer-member-put \
|
||||
run_script "$fixture" --execute --admin admin-user --viewer viewer-user >"$fixture/out" 2>&1; then fail 'unhandled fatal signal was accepted'; fi
|
||||
cmp --silent -- "$fixture/before.json" "$fixture/state/keycloak.json" || fail 'EXIT fallback did not restore exact Keycloak prestate'
|
||||
grep -Fq 'GRAFANA_OIDC_ROLLBACK=PASS' "$fixture/out" || fail 'EXIT fallback omitted rollback proof'
|
||||
pass 'EXIT fallback classifies pending ownership and restores exact prestate'
|
||||
|
||||
fixture="$(new_fixture secret-uid-conflict)"
|
||||
if PLATFORM_TEST_CONFIRMATIONS=$'APPLY default\nRECOVERY KEYCLOAK default' PLATFORM_TEST_FAULT=drift-secret-viewer-member-put \
|
||||
run_script "$fixture" --execute --viewer viewer-user >"$fixture/out" 2>&1; then fail 'concurrent Secret replacement was accepted'; fi
|
||||
[[ -f "$fixture/state/secret.json" ]] || fail 'concurrent Secret replacement was deleted'
|
||||
python3 -I -S - "$fixture/state/secret.json" <<'PY' || fail 'concurrent Secret replacement identity was not preserved'
|
||||
import json,pathlib,sys
|
||||
item=json.loads(pathlib.Path(sys.argv[1]).read_text())
|
||||
assert item["metadata"]["uid"]=="secret-concurrent-replacement" and item["metadata"]["resourceVersion"]=="99"
|
||||
PY
|
||||
grep -Fq 'MANUAL_RECOVERY_REQUIRED=YES' "$fixture/out" || fail 'Secret UID/RV conflict omitted manual recovery marker'
|
||||
pass 'Secret UID and resourceVersion preconditions preserve a concurrent replacement'
|
||||
|
||||
for line in $(grep '^kubectl ' "$fixture/commands.log"); do :; done
|
||||
grep '^kubectl ' "$fixture/commands.log" | grep -vq -- '--request-timeout=5s' && fail 'an unbounded kubectl invocation was observed'
|
||||
[[ "$(<"$fixture/out")" != *"$CLIENT_SECRET"* ]] || fail 'fault output leaked payload'
|
||||
! grep -Fq "$CLIENT_SECRET" "$fixture/commands.log" || fail 'fault argv leaked payload'
|
||||
pass 'kubectl and curl boundaries are bounded and payloads stay out of output and argv'
|
||||
|
||||
printf 'KEYCLOAK GRAFANA OIDC TEST PASS (%s assertions)\n' "$ASSERTIONS"
|
||||
+1728
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly ROOT="$(cd -- "$(dirname -- "$BASH_SOURCE")/../.." && pwd -P)"
|
||||
readonly DENY="$ROOT/infrastructure/networking/host-nginx/learn-services-grafana-deny-guard.conf"
|
||||
readonly FULL="$ROOT/infrastructure/networking/host-nginx/learn-services-observability.conf"
|
||||
|
||||
fail() {
|
||||
printf 'FAIL: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ -f "$DENY" && ! -L "$DENY" ]] || fail 'deny-guard source is missing'
|
||||
[[ -f "$FULL" && ! -L "$FULL" ]] || fail 'full observability source is missing'
|
||||
|
||||
python3 - "$DENY" "$FULL" <<'PY'
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
deny_path, full_path = map(pathlib.Path, sys.argv[1:])
|
||||
deny = deny_path.read_text()
|
||||
full = full_path.read_text()
|
||||
old = " proxy_pass http://127.0.0.1:1;"
|
||||
new = " proxy_pass http://127.0.0.1:30080;"
|
||||
|
||||
if deny.count(old) != 1:
|
||||
raise SystemExit("FAIL: deny guard does not have one closed Grafana upstream")
|
||||
if deny.count(new) != 4:
|
||||
raise SystemExit("FAIL: deny guard existing service upstream set changed")
|
||||
expected = deny.replace(old, new, 1)
|
||||
if full != expected:
|
||||
raise SystemExit("FAIL: full candidate differs from deny guard outside the Grafana upstream")
|
||||
if full.count(old) != 0 or full.count(new) != 5:
|
||||
raise SystemExit("FAIL: full candidate Grafana upstream is not exact")
|
||||
|
||||
required = (
|
||||
"ssl_reject_handshake on;",
|
||||
"server_name git.learn.hyeonworks.com;",
|
||||
"server_name id.learn.hyeonworks.com;",
|
||||
"server_name storage-admin.learn.hyeonworks.com;",
|
||||
"server_name db-admin.learn.hyeonworks.com;",
|
||||
"server_name grafana.learn.hyeonworks.com;",
|
||||
"location = /metrics {",
|
||||
"allow 192.168.0.0/24;",
|
||||
"allow 100.64.0.0/10;",
|
||||
"deny all;",
|
||||
)
|
||||
for token in required:
|
||||
if token not in full:
|
||||
raise SystemExit(f"FAIL: full candidate lacks {token}")
|
||||
if full.count("location = /metrics {") != 2:
|
||||
raise SystemExit("FAIL: Gitea and Grafana metrics guards are not exact")
|
||||
PY
|
||||
|
||||
printf 'HOST NGINX OBSERVABILITY SOURCE CONTRACT PASS\n'
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Regression test for the NTFS mask used by the recovery volume. The 0177
|
||||
# file mask removes owner execute plus every group/other permission, producing
|
||||
# mode 0600 for the KeePass database and LUKS container.
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly REPOSITORY_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd -P)"
|
||||
readonly VALIDATOR_PATH="${REPOSITORY_ROOT}/scripts/validate/k3s-local-recovery.sh"
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
source "$VALIDATOR_PATH"
|
||||
|
||||
readonly LIVE_SAFE_OPTIONS='rw,nosuid,nodev,noexec,relatime,uid=1000,gid=1000,dmask=0077,fmask=0177,iocharset=utf8'
|
||||
|
||||
if ! _k3slrv_outer_masks_secure "$LIVE_SAFE_OPTIONS"; then
|
||||
printf '%s\n' \
|
||||
'TEST FAILURE: recovery validator rejected secure fmask=0177' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '%s\n' 'PASS: recovery validator accepts secure fmask=0177'
|
||||
@@ -0,0 +1,797 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Source-safe TDD coverage for the standalone K3s recovery LUKS header proof.
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly K3SLRH_TEST_DIRECTORY="${BASH_SOURCE[0]%/*}"
|
||||
readonly K3SLRH_REPOSITORY_ROOT="$(cd -- "${K3SLRH_TEST_DIRECTORY}/../.." && pwd -P)"
|
||||
readonly K3SLRH_SHARED_LIBRARY="${K3SLRH_REPOSITORY_ROOT}/scripts/lib/k3s-local-recovery.sh"
|
||||
readonly K3SLRH_A1_LIBRARY="${K3SLRH_REPOSITORY_ROOT}/scripts/lib/k3s-local-recovery-a1.sh"
|
||||
readonly K3SLRH_A1_HELPER="${K3SLRH_REPOSITORY_ROOT}/scripts/libexec/k3s-local-recovery-a1-check.sh"
|
||||
readonly K3SLRH_PROOF_LIBRARY="${K3SLRH_REPOSITORY_ROOT}/scripts/lib/k3s-local-recovery-header-proof.sh"
|
||||
readonly K3SLRH_WRAPPER="${K3SLRH_REPOSITORY_ROOT}/scripts/bootstrap/prove-k3s-local-recovery-header.sh"
|
||||
readonly K3SLRH_CONTRACT="${K3SLRH_REPOSITORY_ROOT}/infrastructure/security/k3s/local-recovery.env"
|
||||
readonly K3SLRH_VALIDATOR="${K3SLRH_REPOSITORY_ROOT}/scripts/validate/k3s-local-recovery.sh"
|
||||
readonly K3SLRH_AUTHORITY_DESIGN='/home/donghyeon/workspace/docs/platform/plans/2026-08-09-k3s-luks-header-proof-design.md'
|
||||
readonly K3SLRH_AUTHORITY_PLAN='/home/donghyeon/workspace/docs/platform/plans/2026-08-09-k3s-luks-header-proof-implementation.md'
|
||||
|
||||
readonly K3SLRH_AUTHORITY_DESIGN_SHA256='0d1546795da59490956ba2e260bf12df210083514ae55c0551de632d54e278ec'
|
||||
readonly K3SLRH_AUTHORITY_PLAN_SHA256='126f7fa43fb857dd9ff753aac4ce6528f7a227d7151d688f8cce2b6e0ff8fbc9'
|
||||
readonly K3SLRH_EXPECTED_SHARED_SHA256='a206d81539e6c37fa085457ba01cff9de57de50f7235e3a4085989780e99585d'
|
||||
readonly K3SLRH_EXPECTED_A1_SHA256='8b39fc46229ebd4c77ff20040f942c4ef7b756d564d344a8ecdd11a7428e2968'
|
||||
readonly K3SLRH_EXPECTED_A1_HELPER_SHA256='06831fa35943a328ecd183d3c6f721b24c4607b2f349abf6201a12e690d08ebe'
|
||||
readonly K3SLRH_EXPECTED_CONTRACT_SHA256='c045f42c2dc3eda09c37a123bcc05d88d94f275e61504a2d5816948237e516df'
|
||||
readonly K3SLRH_EXPECTED_VALIDATOR_SHA256='6b869032fc325759ee25b219fad4ea6190a3389a561c496f0664a51b802f73e6'
|
||||
|
||||
k3slrh_test_fail() {
|
||||
printf 'TEST FAILURE: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# This RED gate intentionally precedes authority hashing, source scanning,
|
||||
# sourcing, mktemp, command doubles, and every capability or live path.
|
||||
[[ -f "$K3SLRH_PROOF_LIBRARY" && ! -L "$K3SLRH_PROOF_LIBRARY" &&
|
||||
-r "$K3SLRH_PROOF_LIBRARY" ]] || k3slrh_test_fail 'header-proof library missing'
|
||||
[[ -f "$K3SLRH_WRAPPER" && ! -L "$K3SLRH_WRAPPER" &&
|
||||
-r "$K3SLRH_WRAPPER" ]] || k3slrh_test_fail 'header-proof wrapper missing'
|
||||
[[ "${K3SLRH_FOCUS:-entrypoint}" == entrypoint ]] ||
|
||||
k3slrh_test_fail 'unsupported header-proof focus'
|
||||
|
||||
k3slrh_sha_is_exact() {
|
||||
local path="${1-}" expected="${2-}" record
|
||||
(( $# == 2 )) || return 97
|
||||
record="$(/usr/bin/sha256sum -- "$path")" || return 1
|
||||
[[ "$record" == "${expected} ${path}" ]]
|
||||
}
|
||||
|
||||
for k3slrh_checkpoint in \
|
||||
"$K3SLRH_AUTHORITY_DESIGN:$K3SLRH_AUTHORITY_DESIGN_SHA256" \
|
||||
"$K3SLRH_AUTHORITY_PLAN:$K3SLRH_AUTHORITY_PLAN_SHA256" \
|
||||
"$K3SLRH_SHARED_LIBRARY:$K3SLRH_EXPECTED_SHARED_SHA256" \
|
||||
"$K3SLRH_A1_LIBRARY:$K3SLRH_EXPECTED_A1_SHA256" \
|
||||
"$K3SLRH_A1_HELPER:$K3SLRH_EXPECTED_A1_HELPER_SHA256" \
|
||||
"$K3SLRH_CONTRACT:$K3SLRH_EXPECTED_CONTRACT_SHA256" \
|
||||
"$K3SLRH_VALIDATOR:$K3SLRH_EXPECTED_VALIDATOR_SHA256"; do
|
||||
k3slrh_checkpoint_path="${k3slrh_checkpoint%:*}"
|
||||
k3slrh_checkpoint_hash="${k3slrh_checkpoint##*:}"
|
||||
k3slrh_sha_is_exact "$k3slrh_checkpoint_path" "$k3slrh_checkpoint_hash" ||
|
||||
k3slrh_test_fail 'authority or frozen dependency drift'
|
||||
done
|
||||
unset k3slrh_checkpoint k3slrh_checkpoint_path k3slrh_checkpoint_hash
|
||||
|
||||
k3slrh_library_static_is_safe() {
|
||||
local source_path="${1-}"
|
||||
(( $# == 1 )) || return 97
|
||||
[[ -f "$source_path" && ! -L "$source_path" ]] || return 1
|
||||
/usr/bin/awk '
|
||||
function trim(value) {
|
||||
sub(/^[[:space:]]+/, "", value)
|
||||
sub(/[[:space:]]+$/, "", value)
|
||||
return value
|
||||
}
|
||||
function external_path(value) {
|
||||
return value ~ /\/(usr\/)?(s?bin)\/[A-Za-z0-9._+:-]+/
|
||||
}
|
||||
function sensitive_bare(value) {
|
||||
return value ~ /(^|(&&|\|\||;)[[:space:]]*)(sudo|mount|umount|losetup|cryptsetup|keepassxc-cli|sqlite3|systemctl|timeout|k3s|findmnt|lsblk|blockdev|dmsetup)([[:space:]]|$)/
|
||||
}
|
||||
{
|
||||
raw = $0
|
||||
value = trim(raw)
|
||||
if (value == "" || value ~ /^#/) next
|
||||
if (value ~ /<Task-[0-9]+-[^>]+>/) bad = 1
|
||||
if (value ~ /_k3slr_collect_prepackage_snapshot|_k3slr_lifecycle_dispatch|_k3slr_prove_header_restore/) bad = 1
|
||||
if (in_function) {
|
||||
if (raw == "}") { in_function = 0; function_name = ""; next }
|
||||
if (value ~ /`/ || value ~ /(^|[;[:space:]])(eval|command|exec)[[:space:]]/) bad = 1
|
||||
if (sensitive_bare(value) && value !~ /_k3slrh_command[[:space:]]/) bad = 1
|
||||
if (external_path(value) && value !~ /_k3slrh_command[[:space:]]/ &&
|
||||
value !~ /_k3slr[a-z0-9_]+[[:space:]]/) bad = 1
|
||||
next
|
||||
}
|
||||
if (value ~ /^[A-Za-z_][A-Za-z0-9_]*\(\)[[:space:]]*\{$/) {
|
||||
in_function = 1
|
||||
function_name = value
|
||||
sub(/\(\)[[:space:]]*\{$/, "", function_name)
|
||||
next
|
||||
}
|
||||
if (value ~ /^readonly[[:space:]]+[A-Za-z_][A-Za-z0-9_]*=(\047[^\047]*\047|[A-Za-z0-9_.:+\/-]+)$/) next
|
||||
bad = 1
|
||||
}
|
||||
END { if (in_function || bad) exit 1 }
|
||||
' "$source_path"
|
||||
}
|
||||
|
||||
k3slrh_wrapper_static_is_safe() {
|
||||
local source_path="${1-}"
|
||||
(( $# == 1 )) || return 97
|
||||
[[ -f "$source_path" && ! -L "$source_path" ]] || return 1
|
||||
/usr/bin/awk '
|
||||
{
|
||||
if ($0 ~ /_k3slr_collect_prepackage_snapshot|_k3slr_lifecycle_dispatch|_k3slr_prove_header_restore/) bad = 1
|
||||
if ($0 ~ /(^|[[:space:]])(sudo|mount|umount|losetup|cryptsetup|keepassxc-cli|sqlite3)([[:space:]]|$)/) bad = 1
|
||||
if ($0 ~ /scripts\/lib\/k3s-local-recovery\.sh/) shared = NR
|
||||
if ($0 ~ /scripts\/lib\/k3s-local-recovery-a1\.sh/) a1 = NR
|
||||
if ($0 ~ /scripts\/lib\/k3s-local-recovery-header-proof\.sh/) proof = NR
|
||||
if ($0 ~ /^if \[\[ "\$\{BASH_SOURCE\[0\]\}" == "\$0" \]\]; then$/) direct_guard++
|
||||
if ($0 ~ /_k3slrh_header_proof_main "\$@"/) direct_main++
|
||||
if ($0 ~ /_k3slrh_wrapper_initial_guard/) initial_guard++
|
||||
if ($0 ~ /unset -f[[:space:]]+_k3slrh_/) bad = 1
|
||||
if ($0 ~ /_k3slrh_wrapper_environment_is_clean/) inherited_guard = NR
|
||||
}
|
||||
END {
|
||||
if (bad || shared == 0 || a1 == 0 || proof == 0 ||
|
||||
!(shared < a1 && a1 < proof) || direct_guard != 1 ||
|
||||
direct_main != 1 || initial_guard < 2 || inherited_guard == 0 ||
|
||||
inherited_guard >= shared) exit 1
|
||||
}
|
||||
' "$source_path"
|
||||
}
|
||||
|
||||
# No production source is evaluated until both conservative scans pass.
|
||||
k3slrh_library_static_is_safe "$K3SLRH_PROOF_LIBRARY" ||
|
||||
k3slrh_test_fail 'header-proof library static preflight failed'
|
||||
k3slrh_wrapper_static_is_safe "$K3SLRH_WRAPPER" ||
|
||||
k3slrh_test_fail 'header-proof wrapper static preflight failed'
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
source "$K3SLRH_SHARED_LIBRARY" || k3slrh_test_fail 'shared library source failed'
|
||||
# shellcheck source=/dev/null
|
||||
source "$K3SLRH_A1_LIBRARY" || k3slrh_test_fail 'A1 library source failed'
|
||||
# shellcheck source=/dev/null
|
||||
source "$K3SLRH_PROOF_LIBRARY" || k3slrh_test_fail 'header-proof library source failed'
|
||||
_k3slr_load_contract "$K3SLRH_CONTRACT" || k3slrh_test_fail 'strict contract load failed'
|
||||
|
||||
k3slrh_require_function() {
|
||||
local name="${1-}"
|
||||
(( $# == 1 )) || return 97
|
||||
declare -F "$name" >/dev/null || k3slrh_test_fail "header-proof API missing: ${name}"
|
||||
}
|
||||
|
||||
for k3slrh_api in \
|
||||
_k3slrh_parse_cli \
|
||||
_k3slrh_derive_targets \
|
||||
_k3slrh_run_closed_validator \
|
||||
_k3slrh_k3s_health \
|
||||
_k3slrh_pre_mount_capability \
|
||||
_k3slrh_header_proof_main \
|
||||
_k3slrh_target_state_is_consistent; do
|
||||
k3slrh_require_function "$k3slrh_api"
|
||||
done
|
||||
unset k3slrh_api
|
||||
|
||||
k3slrh_assert_readonly_literal() {
|
||||
local name="${1-}" expected="${2-}" declaration
|
||||
(( $# == 2 )) || return 97
|
||||
declaration="$(declare -p "$name" 2>/dev/null)" || return 1
|
||||
[[ "$declaration" == 'declare -r '* || "$declaration" == 'declare -rx '* ]] || return 1
|
||||
[[ "${!name}" == "$expected" ]]
|
||||
}
|
||||
|
||||
k3slrh_assert_readonly_literal K3SLRH_PROOF_RELATIVE \
|
||||
'HyeonworksRecovery/containers/k3s-recovery.header-proof.luks' ||
|
||||
k3slrh_test_fail 'proof relative name is not immutable'
|
||||
k3slrh_assert_readonly_literal K3SLRH_PRE_KDBX_PREFIX \
|
||||
'hyeonworks-recovery.header-proof-pre.' ||
|
||||
k3slrh_test_fail 'pre KDBX prefix is not immutable'
|
||||
k3slrh_assert_readonly_literal K3SLRH_POST_KDBX_PREFIX \
|
||||
'hyeonworks-recovery.header-proof-post.' ||
|
||||
k3slrh_test_fail 'post KDBX prefix is not immutable'
|
||||
k3slrh_assert_readonly_literal K3SLRH_HEADER_BACKUP_NAME 'header-original.bin' ||
|
||||
k3slrh_test_fail 'header backup name is not immutable'
|
||||
k3slrh_assert_readonly_literal K3SLRH_HEADER_EXPORT_NAME 'header-exported.bin' ||
|
||||
k3slrh_test_fail 'header export name is not immutable'
|
||||
k3slrh_assert_readonly_literal K3SLRH_SQLITE_PACKAGE_VERSION '3.45.1-1ubuntu2.7' ||
|
||||
k3slrh_test_fail 'sqlite package version is not immutable'
|
||||
k3slrh_assert_readonly_literal K3SLRH_SHARED_LIBRARY_SHA256 "$K3SLRH_EXPECTED_SHARED_SHA256" ||
|
||||
k3slrh_test_fail 'shared source checkpoint placeholder or drift'
|
||||
k3slrh_assert_readonly_literal K3SLRH_A1_LIBRARY_SHA256 "$K3SLRH_EXPECTED_A1_SHA256" ||
|
||||
k3slrh_test_fail 'A1 source checkpoint placeholder or drift'
|
||||
k3slrh_assert_readonly_literal K3SLRH_A1_HELPER_SHA256 "$K3SLRH_EXPECTED_A1_HELPER_SHA256" ||
|
||||
k3slrh_test_fail 'A1 helper checkpoint placeholder or drift'
|
||||
k3slrh_assert_readonly_literal K3SLRH_CONTRACT_SHA256 "$K3SLRH_EXPECTED_CONTRACT_SHA256" ||
|
||||
k3slrh_test_fail 'contract checkpoint placeholder or drift'
|
||||
k3slrh_assert_readonly_literal K3SLRH_VALIDATOR_SHA256 "$K3SLRH_EXPECTED_VALIDATOR_SHA256" ||
|
||||
k3slrh_test_fail 'validator checkpoint placeholder or drift'
|
||||
|
||||
readonly K3SLRH_EXPECTED_UTC='20260809T120304Z'
|
||||
readonly K3SLRH_RAW_IDENTIFIER_SENTINEL='RAW_IDENTIFIER_SENTINEL_DO_NOT_PRINT'
|
||||
readonly K3SLRH_EXPECTED_CLOSED_RECORD=$'Recovery device: match\nRecovery state: closed\nLineage: match\nLatest bundle: not_checked\n'
|
||||
readonly K3SLRH_EXPECTED_DRY_RUN=$'Header proof state: closed\nHeader proof capability: pass\nExecute-time checks: pending\nDRY RUN PASS: no changes\n'
|
||||
|
||||
k3slrh_fixture_root="$(/usr/bin/mktemp -d /tmp/k3slrh-entrypoint-test.XXXXXXXX)" ||
|
||||
k3slrh_test_fail 'cannot allocate fixture root'
|
||||
readonly k3slrh_fixture_root
|
||||
readonly k3slrh_stdout="${k3slrh_fixture_root}/stdout"
|
||||
readonly k3slrh_stderr="${k3slrh_fixture_root}/stderr"
|
||||
readonly k3slrh_command_log="${k3slrh_fixture_root}/commands"
|
||||
readonly k3slrh_mutation_log="${k3slrh_fixture_root}/mutations"
|
||||
readonly k3slrh_validator_call_log="${k3slrh_fixture_root}/validator-calls"
|
||||
readonly k3slrh_validator_hash_log="${k3slrh_fixture_root}/validator-hashes"
|
||||
readonly k3slrh_sudo_validate_log="${k3slrh_fixture_root}/sudo-validates"
|
||||
readonly k3slrh_readyz_log="${k3slrh_fixture_root}/readyz-calls"
|
||||
readonly k3slrh_validator_file_log="${k3slrh_fixture_root}/validator-file-checks"
|
||||
readonly k3slrh_validator_symlink_log="${k3slrh_fixture_root}/validator-symlink-checks"
|
||||
readonly k3slrh_validator_stat_log="${k3slrh_fixture_root}/validator-stat-checks"
|
||||
readonly k3slrh_binary_stat_log="${k3slrh_fixture_root}/binary-stat-checks"
|
||||
|
||||
k3slrh_cleanup() {
|
||||
case "$k3slrh_fixture_root" in
|
||||
/tmp/k3slrh-entrypoint-test.[A-Za-z0-9]*) /usr/bin/rm -rf -- "$k3slrh_fixture_root" ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
trap k3slrh_cleanup EXIT
|
||||
|
||||
k3slrh_assert_eq() {
|
||||
local expected="${1-}" actual="${2-}" label="${3-}"
|
||||
(( $# == 3 )) || k3slrh_test_fail 'invalid assert_eq invocation'
|
||||
[[ "$actual" == "$expected" ]] || k3slrh_test_fail "$label"
|
||||
}
|
||||
|
||||
k3slrh_assert_rc() {
|
||||
local expected="${1-}" label="${2-}" rc=0
|
||||
shift 2 || k3slrh_test_fail 'invalid assert_rc invocation'
|
||||
if "$@"; then rc=0; else rc=$?; fi
|
||||
[[ "$rc" == "$expected" ]] || k3slrh_test_fail "${label}: rc=${rc}, expected=${expected}"
|
||||
}
|
||||
|
||||
k3slrh_assert_succeeds() {
|
||||
local label="${1-}"
|
||||
shift || k3slrh_test_fail 'invalid assert_succeeds invocation'
|
||||
"$@" || k3slrh_test_fail "$label"
|
||||
}
|
||||
|
||||
k3slrh_wrapper_clean_composition() {
|
||||
local rc=0
|
||||
: >"$k3slrh_stdout"
|
||||
: >"$k3slrh_stderr"
|
||||
if /usr/bin/env -i \
|
||||
PATH=/usr/sbin:/usr/bin:/sbin:/bin LC_ALL=C \
|
||||
/usr/bin/bash --noprofile --norc -c '
|
||||
set -Eeuo pipefail
|
||||
source "$1"
|
||||
declare -F _k3slrh_header_proof_main >/dev/null
|
||||
declare -F _k3slrh_wrapper_initial_guard >/dev/null
|
||||
if _k3slrh_wrapper_initial_guard 0 hB; then exit 41; fi
|
||||
if _k3slrh_wrapper_initial_guard 1000 hxB; then exit 42; fi
|
||||
' k3slrh-wrapper-probe "$K3SLRH_WRAPPER" \
|
||||
>"$k3slrh_stdout" 2>"$k3slrh_stderr"; then
|
||||
rc=0
|
||||
else
|
||||
rc=$?
|
||||
fi
|
||||
[[ "$rc" == 0 && ! -s "$k3slrh_stdout" && ! -s "$k3slrh_stderr" ]]
|
||||
}
|
||||
|
||||
k3slrh_wrapper_rejects_inherited_route() {
|
||||
local rc=0 marker="${k3slrh_fixture_root}/inherited-route-marker"
|
||||
: >"$k3slrh_stdout"
|
||||
: >"$k3slrh_stderr"
|
||||
if /usr/bin/env -i \
|
||||
PATH=/usr/sbin:/usr/bin:/sbin:/bin LC_ALL=C \
|
||||
/usr/bin/bash --noprofile --norc -c '
|
||||
set -Eeuo pipefail
|
||||
K3SLRH_PROOF_RELATIVE=attacker-controlled
|
||||
test_marker=$2
|
||||
_k3slrh_header_proof_main() { : >"$test_marker"; }
|
||||
set +e
|
||||
source "$1" >/dev/null 2>&1
|
||||
wrapper_rc=$?
|
||||
set -e
|
||||
[[ "$wrapper_rc" == 1 && ! -e "$test_marker" ]]
|
||||
' k3slrh-wrapper-probe "$K3SLRH_WRAPPER" "$marker" \
|
||||
>"$k3slrh_stdout" 2>"$k3slrh_stderr"; then
|
||||
rc=0
|
||||
else
|
||||
rc=$?
|
||||
fi
|
||||
[[ "$rc" == 0 && ! -e "$marker" && ! -s "$k3slrh_stdout" && ! -s "$k3slrh_stderr" ]]
|
||||
}
|
||||
|
||||
k3slrh_run_parse() {
|
||||
local parsed='' rc=0
|
||||
if _k3slrh_parse_cli parsed "$@"; then rc=0; else rc=$?; fi
|
||||
(( rc == 0 )) || return "$rc"
|
||||
printf '%s' "$parsed"
|
||||
}
|
||||
|
||||
k3slrh_run_timestamp() (
|
||||
_k3slrh_derive_targets "$@"
|
||||
)
|
||||
|
||||
k3slrh_targets_are_exact() (
|
||||
_k3slrh_derive_targets "$K3SLRH_EXPECTED_UTC" || return 1
|
||||
[[ "${K3SLRH_RUN_UTC:-}" == "$K3SLRH_EXPECTED_UTC" &&
|
||||
"${K3SLRH_PRE_KDBX_RELATIVE:-}" == \
|
||||
"HyeonworksRecovery/vault/backups/${K3SLRH_PRE_KDBX_PREFIX}${K3SLRH_EXPECTED_UTC}.kdbx" &&
|
||||
"${K3SLRH_POST_KDBX_RELATIVE:-}" == \
|
||||
"HyeonworksRecovery/vault/backups/${K3SLRH_POST_KDBX_PREFIX}${K3SLRH_EXPECTED_UTC}.kdbx" ]] || return 1
|
||||
_k3slrh_target_state_is_consistent \
|
||||
"$K3SLRH_RUN_UTC" "$K3SLRH_PRE_KDBX_RELATIVE" "$K3SLRH_POST_KDBX_RELATIVE"
|
||||
)
|
||||
|
||||
k3slrh_run_targets_same_second_collision() (
|
||||
_k3slrh_derive_targets "$K3SLRH_EXPECTED_UTC" || return 1
|
||||
_k3slrh_derive_targets "$K3SLRH_EXPECTED_UTC"
|
||||
)
|
||||
|
||||
k3slrh_run_targets_pre_post_timestamp_drift() (
|
||||
local drifted_post
|
||||
_k3slrh_derive_targets "$K3SLRH_EXPECTED_UTC" || return 1
|
||||
drifted_post="HyeonworksRecovery/vault/backups/${K3SLRH_POST_KDBX_PREFIX}20260809T120305Z.kdbx"
|
||||
_k3slrh_target_state_is_consistent \
|
||||
"$K3SLRH_RUN_UTC" "$K3SLRH_PRE_KDBX_RELATIVE" "$drifted_post"
|
||||
)
|
||||
|
||||
k3slrh_model_case=valid
|
||||
k3slrh_validator_count=0
|
||||
k3slrh_mutation_count=0
|
||||
|
||||
k3slrh_log_command() {
|
||||
local argument
|
||||
for argument in "$@"; do printf '%s\0' "$argument" >>"$k3slrh_command_log"; done
|
||||
printf '\0' >>"$k3slrh_command_log"
|
||||
}
|
||||
|
||||
k3slrh_path_is_known_binary() {
|
||||
local candidate
|
||||
for candidate in \
|
||||
/usr/bin/awk /usr/bin/bash /usr/bin/chmod /usr/bin/chown /usr/bin/cmp \
|
||||
/usr/bin/date /usr/bin/dd /usr/bin/dpkg-query /usr/bin/env /usr/bin/find \
|
||||
/usr/bin/findmnt /usr/bin/grep /usr/bin/keepassxc-cli /usr/bin/lsblk \
|
||||
/usr/bin/mkdir /usr/bin/mktemp /usr/bin/mount /usr/bin/ntfsinfo \
|
||||
/usr/bin/od /usr/bin/readlink /usr/bin/rm /usr/bin/rmdir /usr/bin/sha256sum \
|
||||
/usr/bin/sqlite3 /usr/bin/stat /usr/bin/sudo /usr/bin/sync /usr/bin/systemctl \
|
||||
/usr/bin/test /usr/bin/timeout /usr/bin/umount /usr/bin/unlink /usr/bin/wc \
|
||||
/usr/local/bin/k3s /usr/sbin/blockdev /usr/sbin/cryptsetup /usr/sbin/dmsetup \
|
||||
/usr/sbin/losetup; do
|
||||
[[ "${1-}" != "$candidate" ]] || return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
_k3slrh_command() {
|
||||
local package='' path='' expected_backing='' hash_count=0
|
||||
k3slrh_log_command "$@"
|
||||
case "${1-}" in
|
||||
/usr/bin/sudo)
|
||||
if (( $# == 3 )) && [[ "$2" == --non-interactive && "$3" == --validate ]]; then
|
||||
printf x >>"$k3slrh_sudo_validate_log"
|
||||
if [[ "$k3slrh_model_case" == uncached_sudo ]]; then
|
||||
printf 'SUDO_PROMPT_SENTINEL\n' >&2
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
if (( $# == 15 )) && [[ "$2" == --non-interactive && "$3" == -- &&
|
||||
"$4" == /usr/bin/env && "$5" == -i &&
|
||||
"$6" == 'PATH=/usr/sbin:/usr/bin:/sbin:/bin' && "$7" == LC_ALL=C &&
|
||||
"$8" == /usr/bin/timeout && "$9" == --signal=TERM &&
|
||||
"${10}" == --kill-after=1s && "${11}" == 30s &&
|
||||
"${12}" == /usr/local/bin/k3s && "${13}" == kubectl &&
|
||||
"${14}" == get && "${15}" == --raw=/readyz ]]; then
|
||||
printf x >>"$k3slrh_readyz_log"
|
||||
case "$k3slrh_model_case" in
|
||||
readyz_extra) printf 'ok\nextra\n' ;;
|
||||
readyz_missing_lf) printf 'ok' ;;
|
||||
readyz_cr) printf 'ok\r\n' ;;
|
||||
readyz_nul) printf 'ok\0\n' ;;
|
||||
readyz_empty) : ;;
|
||||
readyz_rc1) return 1 ;;
|
||||
readyz_rc124) return 124 ;;
|
||||
readyz_rc137) return 137 ;;
|
||||
readyz_stderr) printf 'READYZ_STDERR_SENTINEL\n' >&2; printf 'ok\n' ;;
|
||||
*) printf 'ok\n' ;;
|
||||
esac
|
||||
return 0
|
||||
fi
|
||||
printf 'mutation:sudo\n' >>"$k3slrh_mutation_log"
|
||||
return 97
|
||||
;;
|
||||
/usr/bin/systemctl)
|
||||
[[ "$#" == 4 && "$2" == is-active && "$3" == --quiet && "$4" == k3s ]] || return 97
|
||||
[[ "$k3slrh_model_case" != k3s_inactive ]] || return 3
|
||||
;;
|
||||
/usr/bin/timeout)
|
||||
# Direct non-root readyz is forbidden by the authority contract.
|
||||
return 97
|
||||
;;
|
||||
/usr/bin/env)
|
||||
[[ "$#" == 10 && "$2" == -i && "$3" == 'PATH=/usr/sbin:/usr/bin:/sbin:/bin' &&
|
||||
"$4" == LC_ALL=C && "$5" == /usr/bin/bash && "$6" == --noprofile &&
|
||||
"$7" == --norc && "$8" == -- && "$9" == "$K3SLRH_VALIDATOR" &&
|
||||
"${10}" == --expect-closed ]] || return 97
|
||||
k3slrh_validator_count=$((k3slrh_validator_count + 1))
|
||||
printf x >>"$k3slrh_validator_call_log"
|
||||
case "$k3slrh_model_case" in
|
||||
validator_cr) printf '%s\r\n' 'Recovery device: match' 'Recovery state: closed' 'Lineage: match' 'Latest bundle: not_checked' ;;
|
||||
validator_nul) printf 'Recovery device: match\0Recovery state: closed\nLineage: match\nLatest bundle: not_checked\n' ;;
|
||||
validator_missing_lf) printf '%s' "${K3SLRH_EXPECTED_CLOSED_RECORD%$'\n'}" ;;
|
||||
validator_extra) printf '%sextra\n' "$K3SLRH_EXPECTED_CLOSED_RECORD" ;;
|
||||
validator_raw_id) printf '%s%s\n' "$K3SLRH_EXPECTED_CLOSED_RECORD" "$K3SLRH_RAW_IDENTIFIER_SENTINEL" ;;
|
||||
validator_rc1) return 1 ;;
|
||||
validator_rc124) return 124 ;;
|
||||
validator_rc137) return 137 ;;
|
||||
validator_stderr) printf 'x' >&2; printf '%s' "$K3SLRH_EXPECTED_CLOSED_RECORD" ;;
|
||||
*) printf '%s' "$K3SLRH_EXPECTED_CLOSED_RECORD" ;;
|
||||
esac
|
||||
;;
|
||||
/usr/bin/dpkg-query)
|
||||
[[ "$#" == 4 && "$2" == --show &&
|
||||
"$3" == '--showformat=${Status}|${Version}\n' ]] || return 97
|
||||
package="${!#}"
|
||||
case "$package" in
|
||||
keepassxc) path='2.7.6+dfsg.1-1build3'; [[ "$k3slrh_model_case" != keepass_package_drift ]] || path='0.invalid' ;;
|
||||
cryptsetup-bin) path='2:2.7.0-1ubuntu4.2'; [[ "$k3slrh_model_case" != cryptsetup_package_drift ]] || path='0.invalid' ;;
|
||||
sqlite3) path='3.45.1-1ubuntu2.7'; [[ "$k3slrh_model_case" != sqlite_package_drift ]] || path='0.invalid' ;;
|
||||
*) return 97 ;;
|
||||
esac
|
||||
printf 'install ok installed|%s\n' "$path"
|
||||
;;
|
||||
/usr/bin/date)
|
||||
[[ "$#" == 3 && "$2" == --utc && "$3" == +%Y%m%dT%H%M%SZ ]] || return 97
|
||||
printf '%s\n' "$K3SLRH_EXPECTED_UTC"
|
||||
;;
|
||||
/usr/bin/findmnt)
|
||||
[[ "$#" == 6 && "$2" == -rn && "$3" == -M && "$5" == -o && "$6" == TARGET ]] || return 97
|
||||
case "$4|$k3slrh_model_case" in
|
||||
"$K3SLR_OUTER_MOUNT|outer_mount_collision"|"$K3SLR_INNER_MOUNT|inner_mount_collision")
|
||||
printf '%s\n' "$4"
|
||||
return 0
|
||||
;;
|
||||
"$K3SLR_OUTER_MOUNT|"*|"$K3SLR_INNER_MOUNT|"*) return 1 ;;
|
||||
*) return 97 ;;
|
||||
esac
|
||||
;;
|
||||
/usr/sbin/losetup)
|
||||
[[ "$#" == 5 && "$2" == --list && "$3" == --noheadings &&
|
||||
"$4" == --output && "$5" == NAME,BACK-FILE ]] || {
|
||||
printf 'mutation:losetup\n' >>"$k3slrh_mutation_log"
|
||||
return 97
|
||||
}
|
||||
case "$k3slrh_model_case" in
|
||||
main_loop_residue)
|
||||
expected_backing="${K3SLR_OUTER_MOUNT}/${K3SLR_CONTAINER_RELATIVE}"
|
||||
printf '/dev/loop91 %s\n' "$expected_backing"
|
||||
;;
|
||||
proof_loop_residue)
|
||||
expected_backing="${K3SLR_OUTER_MOUNT}/${K3SLRH_PROOF_RELATIVE}"
|
||||
printf '/dev/loop92 %s\n' "$expected_backing"
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
/usr/sbin/dmsetup)
|
||||
return 97
|
||||
;;
|
||||
/usr/bin/find)
|
||||
if (( $# == 8 )) && [[ "$2" == "$K3SLR_OUTER_MOUNT" &&
|
||||
"$3" == -mindepth && "$4" == 1 && "$5" == -maxdepth &&
|
||||
"$6" == 1 && "$7" == -print && "$8" == -quit ]]; then
|
||||
[[ "$k3slrh_model_case" != outer_mountpoint_not_empty ]] ||
|
||||
printf '%s/unexpected\n' "$K3SLR_OUTER_MOUNT"
|
||||
return 0
|
||||
fi
|
||||
if (( $# == 10 )) && [[ "$2" == /run && "$3" == -mindepth &&
|
||||
"$4" == 1 && "$5" == -maxdepth && "$6" == 1 && "$7" == -name &&
|
||||
"$8" == 'hyeonworks-k3s-recovery.*' && "$9" == -print &&
|
||||
"${10}" == -quit ]]; then
|
||||
[[ "$k3slrh_model_case" != tmpfs_residue ]] ||
|
||||
printf '/run/hyeonworks-k3s-recovery.ABCDEFGH\n'
|
||||
return 0
|
||||
fi
|
||||
return 97
|
||||
;;
|
||||
/usr/bin/test)
|
||||
if (( $# == 3 )) && [[ "$2" == ! && "$3" == -L ]]; then return 97; fi
|
||||
if (( $# == 4 )) && [[ "$2" == ! && "$3" == -e ]]; then
|
||||
case "$4" in
|
||||
"/dev/mapper/${K3SLR_MAPPING_NAME}")
|
||||
[[ "$k3slrh_model_case" != main_mapping_residue ]]
|
||||
return
|
||||
;;
|
||||
"/dev/mapper/${K3SLR_PROOF_MAPPING_NAME}")
|
||||
[[ "$k3slrh_model_case" != proof_mapping_residue ]]
|
||||
return
|
||||
;;
|
||||
*) return 97 ;;
|
||||
esac
|
||||
fi
|
||||
if (( $# == 3 )); then
|
||||
case "$2" in
|
||||
-d) [[ "$3" == "$K3SLR_OUTER_MOUNT" ]] || return 97 ;;
|
||||
-f|-x|-r)
|
||||
case "$3" in
|
||||
"$K3SLRH_SHARED_LIBRARY"|"$K3SLRH_A1_LIBRARY"|"$K3SLRH_A1_HELPER"|"$K3SLRH_CONTRACT"|"$K3SLRH_VALIDATOR") ;;
|
||||
*) k3slrh_path_is_known_binary "$3" || return 97 ;;
|
||||
esac
|
||||
if [[ "$2" == -f && "$3" == "$K3SLRH_VALIDATOR" ]]; then
|
||||
printf x >>"$k3slrh_validator_file_log"
|
||||
fi
|
||||
;;
|
||||
*) return 97 ;;
|
||||
esac
|
||||
return 0
|
||||
fi
|
||||
if (( $# == 4 )) && [[ "$2" == ! && "$3" == -L ]]; then
|
||||
case "$4" in
|
||||
"/dev/mapper/${K3SLR_MAPPING_NAME}")
|
||||
[[ "$k3slrh_model_case" != main_mapping_dangling ]]
|
||||
return
|
||||
;;
|
||||
"/dev/mapper/${K3SLR_PROOF_MAPPING_NAME}")
|
||||
[[ "$k3slrh_model_case" != proof_mapping_dangling ]]
|
||||
return
|
||||
;;
|
||||
"$K3SLRH_VALIDATOR") printf x >>"$k3slrh_validator_symlink_log"; return 0 ;;
|
||||
"$K3SLR_OUTER_MOUNT"|"$K3SLRH_SHARED_LIBRARY"|"$K3SLRH_A1_LIBRARY"|"$K3SLRH_A1_HELPER"|"$K3SLRH_CONTRACT") return 0 ;;
|
||||
*) k3slrh_path_is_known_binary "$4" || return 97; return 0 ;;
|
||||
esac
|
||||
fi
|
||||
return 97
|
||||
;;
|
||||
/usr/bin/stat)
|
||||
path="${!#}"
|
||||
if (( $# == 4 )) && [[ "$2" == "--format=%u|%a|%F" && "$3" == -- ]]; then
|
||||
k3slrh_path_is_known_binary "$path" || return 97
|
||||
printf '%s\n' "$path" >>"$k3slrh_binary_stat_log"
|
||||
if [[ "$k3slrh_model_case" == binary_untrusted && "$path" == /usr/bin/keepassxc-cli ]]; then
|
||||
printf '0|775|regular file\n'
|
||||
else
|
||||
printf '0|755|regular file\n'
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
if (( $# == 4 )) && [[ "$2" == "--format=%F|%u|%g|%a" && "$3" == -- ]]; then
|
||||
case "$path" in
|
||||
"$K3SLR_OUTER_MOUNT") printf 'directory|0|0|755\n' ;;
|
||||
"$K3SLRH_SHARED_LIBRARY"|"$K3SLRH_A1_LIBRARY"|"$K3SLRH_A1_HELPER"|"$K3SLRH_CONTRACT"|"$K3SLRH_VALIDATOR")
|
||||
[[ "$path" != "$K3SLRH_VALIDATOR" ]] || printf x >>"$k3slrh_validator_stat_log"
|
||||
printf 'regular file|1000|1000|664\n'
|
||||
;;
|
||||
*) return 97 ;;
|
||||
esac
|
||||
return 0
|
||||
fi
|
||||
return 97
|
||||
;;
|
||||
/usr/bin/readlink)
|
||||
[[ "$#" == 4 && "$2" == -e && "$3" == -- ]] || return 97
|
||||
path="$4"
|
||||
case "$path" in
|
||||
"$K3SLR_OUTER_MOUNT"|"$K3SLRH_SHARED_LIBRARY"|"$K3SLRH_A1_LIBRARY"|"$K3SLRH_A1_HELPER"|"$K3SLRH_CONTRACT"|"$K3SLRH_VALIDATOR") printf '%s\n' "$path" ;;
|
||||
*) return 97 ;;
|
||||
esac
|
||||
;;
|
||||
/usr/bin/sha256sum)
|
||||
[[ "$#" == 3 && "$2" == -- ]] || return 97
|
||||
path="${!#}"
|
||||
case "$path" in
|
||||
"$K3SLRH_SHARED_LIBRARY") printf '%s %s\n' "$K3SLRH_EXPECTED_SHARED_SHA256" "$path" ;;
|
||||
"$K3SLRH_A1_LIBRARY") printf '%s %s\n' "$K3SLRH_EXPECTED_A1_SHA256" "$path" ;;
|
||||
"$K3SLRH_A1_HELPER") printf '%s %s\n' "$K3SLRH_EXPECTED_A1_HELPER_SHA256" "$path" ;;
|
||||
"$K3SLRH_CONTRACT") printf '%s %s\n' "$K3SLRH_EXPECTED_CONTRACT_SHA256" "$path" ;;
|
||||
"$K3SLRH_VALIDATOR")
|
||||
printf x >>"$k3slrh_validator_hash_log"
|
||||
hash_count="$(/usr/bin/wc -c <"$k3slrh_validator_hash_log")" || return 97
|
||||
if [[ "$k3slrh_model_case" == validator_hash_pre_drift ||
|
||||
( "$k3slrh_model_case" == validator_hash_post_drift && "$hash_count" -ge 2 ) ]]; then
|
||||
printf '%064d %s\n' 0 "$path"
|
||||
else
|
||||
printf '%s %s\n' "$K3SLRH_EXPECTED_VALIDATOR_SHA256" "$path"
|
||||
fi
|
||||
;;
|
||||
*) return 97 ;;
|
||||
esac
|
||||
;;
|
||||
/usr/bin/mount|/usr/bin/umount|/usr/sbin/cryptsetup)
|
||||
k3slrh_mutation_count=$((k3slrh_mutation_count + 1))
|
||||
printf 'mutation\n' >>"$k3slrh_mutation_log"
|
||||
return 97
|
||||
;;
|
||||
*) return 97 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
_k3slr_command() { _k3slrh_command "$@"; }
|
||||
|
||||
_k3slr_collect_prepackage_snapshot() { printf 'legacy-collector\n' >>"$k3slrh_mutation_log"; return 97; }
|
||||
_k3slr_prove_header_restore() { printf 'legacy-marker\n' >>"$k3slrh_mutation_log"; return 97; }
|
||||
_k3slr_lifecycle_dispatch() { printf 'prepare-dispatch\n' >>"$k3slrh_mutation_log"; return 97; }
|
||||
|
||||
k3slrh_reset_model_logs() {
|
||||
: >"$k3slrh_command_log"
|
||||
: >"$k3slrh_mutation_log"
|
||||
: >"$k3slrh_validator_call_log"
|
||||
: >"$k3slrh_validator_hash_log"
|
||||
: >"$k3slrh_sudo_validate_log"
|
||||
: >"$k3slrh_readyz_log"
|
||||
: >"$k3slrh_validator_file_log"
|
||||
: >"$k3slrh_validator_symlink_log"
|
||||
: >"$k3slrh_validator_stat_log"
|
||||
: >"$k3slrh_binary_stat_log"
|
||||
}
|
||||
|
||||
k3slrh_capture_call() {
|
||||
: >"$k3slrh_stdout"
|
||||
: >"$k3slrh_stderr"
|
||||
"$@" >"$k3slrh_stdout" 2>"$k3slrh_stderr"
|
||||
}
|
||||
|
||||
k3slrh_validator_case() {
|
||||
local case_name="${1-}" expected_rc="${2-}" expected_calls="${3-}"
|
||||
local expected_hashes="${4-}" rc=0 expected_physical=2
|
||||
(( $# == 4 )) || return 97
|
||||
k3slrh_reset_model_logs
|
||||
k3slrh_model_case="$case_name"
|
||||
[[ "$case_name" != validator_hash_pre_drift ]] || expected_physical=1
|
||||
if k3slrh_capture_call _k3slrh_run_closed_validator; then rc=0; else rc=$?; fi
|
||||
[[ "$rc" == "$expected_rc" &&
|
||||
"$(/usr/bin/wc -c <"$k3slrh_validator_call_log")" == "$expected_calls" &&
|
||||
"$(/usr/bin/wc -c <"$k3slrh_validator_hash_log")" == "$expected_hashes" &&
|
||||
"$(/usr/bin/wc -c <"$k3slrh_validator_file_log")" == "$expected_physical" &&
|
||||
"$(/usr/bin/wc -c <"$k3slrh_validator_symlink_log")" == "$expected_physical" &&
|
||||
"$(/usr/bin/wc -c <"$k3slrh_validator_stat_log")" == "$expected_physical" ]] || return 1
|
||||
[[ ! -s "$k3slrh_stdout" && ! -s "$k3slrh_stderr" ]] || return 1
|
||||
! /usr/bin/grep -aFq -- "$K3SLRH_RAW_IDENTIFIER_SENTINEL" "$k3slrh_stdout" "$k3slrh_stderr"
|
||||
}
|
||||
|
||||
k3slrh_health_case() {
|
||||
local case_name="${1-}" expected_rc="${2-}" expected_readyz="${3-}" rc=0
|
||||
(( $# == 3 )) || return 97
|
||||
k3slrh_reset_model_logs
|
||||
k3slrh_model_case="$case_name"
|
||||
if k3slrh_capture_call _k3slrh_k3s_health; then rc=0; else rc=$?; fi
|
||||
[[ "$rc" == "$expected_rc" && ! -s "$k3slrh_stdout" && ! -s "$k3slrh_stderr" &&
|
||||
"$(/usr/bin/wc -c <"$k3slrh_readyz_log")" == "$expected_readyz" ]]
|
||||
}
|
||||
|
||||
k3slrh_capability_case() {
|
||||
local case_name="${1-}" expected_rc="${2-}" rc=0
|
||||
k3slrh_reset_model_logs
|
||||
k3slrh_model_case="$case_name"
|
||||
if k3slrh_capture_call _k3slrh_pre_mount_capability; then rc=0; else rc=$?; fi
|
||||
[[ "$rc" == "$expected_rc" ]] || return 1
|
||||
if [[ "$expected_rc" == 0 ]]; then
|
||||
[[ "$(<"$k3slrh_stdout")" == 'Header proof capability: pass' && ! -s "$k3slrh_stderr" ]] || return 1
|
||||
else
|
||||
[[ ! -s "$k3slrh_stdout" && ! -s "$k3slrh_stderr" ]] || return 1
|
||||
fi
|
||||
[[ ! -s "$k3slrh_mutation_log" ]] || return 1
|
||||
if [[ "$case_name" == uncached_sudo ]]; then
|
||||
[[ "$(/usr/bin/wc -c <"$k3slrh_sudo_validate_log")" == 1 &&
|
||||
! -s "$k3slrh_readyz_log" && ! -s "$k3slrh_validator_call_log" &&
|
||||
! -s "$k3slrh_validator_hash_log" ]]
|
||||
fi
|
||||
}
|
||||
|
||||
k3slrh_command_model_is_closed() {
|
||||
local rc=0 k3slrh_bad_command
|
||||
local -a k3slrh_bad_argv=()
|
||||
k3slrh_reset_model_logs
|
||||
for k3slrh_bad_command in \
|
||||
'/usr/bin/timeout --signal=TERM 30s /usr/local/bin/k3s kubectl get --raw=/readyz' \
|
||||
'/usr/bin/findmnt --target /fixture' \
|
||||
'/usr/sbin/losetup --find --show /fixture' \
|
||||
'/usr/bin/stat --format=%F -- /dev/loop0' \
|
||||
'/usr/bin/test -e /dev/mapper/k3s-recovery'; do
|
||||
read -r -a k3slrh_bad_argv <<<"$k3slrh_bad_command"
|
||||
if _k3slrh_command "${k3slrh_bad_argv[@]}" >/dev/null 2>&1; then rc=0; else rc=$?; fi
|
||||
[[ "$rc" == 97 ]] || return 1
|
||||
done
|
||||
[[ -s "$k3slrh_mutation_log" ]]
|
||||
}
|
||||
|
||||
k3slrh_invalid_main() {
|
||||
local rc=0
|
||||
: >"$k3slrh_stdout"
|
||||
: >"$k3slrh_stderr"
|
||||
if _k3slrh_header_proof_main "$@" >"$k3slrh_stdout" 2>"$k3slrh_stderr"; then
|
||||
rc=0
|
||||
else
|
||||
rc=$?
|
||||
fi
|
||||
! /usr/bin/grep -aFq -- "$K3SLRH_RAW_IDENTIFIER_SENTINEL" \
|
||||
"$k3slrh_stdout" "$k3slrh_stderr" || return 97
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
k3slrh_execute_without_tty() (
|
||||
local rc=0
|
||||
_k3slrh_tty_capability() { return 1; }
|
||||
_k3slr_require_execute_tty() { return 1; }
|
||||
: >"$k3slrh_stdout"
|
||||
: >"$k3slrh_stderr"
|
||||
if _k3slrh_header_proof_main --execute >"$k3slrh_stdout" 2>"$k3slrh_stderr"; then
|
||||
rc=0
|
||||
else
|
||||
rc=$?
|
||||
fi
|
||||
[[ ! -s "$k3slrh_stdout" && ! -s "$k3slrh_stderr" ]] || return 97
|
||||
return "$rc"
|
||||
)
|
||||
|
||||
k3slrh_dry_run_closed() {
|
||||
local rc=0 validator_calls validator_hashes sudo_validates readyz_calls
|
||||
local validator_files validator_symlinks validator_stats binary_count k3slrh_required_binary
|
||||
k3slrh_model_case=valid
|
||||
k3slrh_validator_count=0
|
||||
k3slrh_mutation_count=0
|
||||
k3slrh_reset_model_logs
|
||||
if k3slrh_capture_call _k3slrh_header_proof_main; then rc=0; else rc=$?; fi
|
||||
validator_calls="$(/usr/bin/wc -c <"$k3slrh_validator_call_log")" || return 1
|
||||
validator_hashes="$(/usr/bin/wc -c <"$k3slrh_validator_hash_log")" || return 1
|
||||
sudo_validates="$(/usr/bin/wc -c <"$k3slrh_sudo_validate_log")" || return 1
|
||||
readyz_calls="$(/usr/bin/wc -c <"$k3slrh_readyz_log")" || return 1
|
||||
validator_files="$(/usr/bin/wc -c <"$k3slrh_validator_file_log")" || return 1
|
||||
validator_symlinks="$(/usr/bin/wc -c <"$k3slrh_validator_symlink_log")" || return 1
|
||||
validator_stats="$(/usr/bin/wc -c <"$k3slrh_validator_stat_log")" || return 1
|
||||
for k3slrh_required_binary in /usr/bin/keepassxc-cli /usr/sbin/cryptsetup /usr/bin/sqlite3; do
|
||||
binary_count="$(/usr/bin/grep -Fxc -- "$k3slrh_required_binary" "$k3slrh_binary_stat_log")" || return 1
|
||||
[[ "$binary_count" == 1 ]] || return 1
|
||||
done
|
||||
[[ "$rc" == 0 && "$(<"$k3slrh_stdout")"$'\n' == "$K3SLRH_EXPECTED_DRY_RUN" &&
|
||||
! -s "$k3slrh_stderr" && ! -s "$k3slrh_mutation_log" &&
|
||||
"$validator_calls" == 1 && "$validator_hashes" == 2 &&
|
||||
"$validator_files" == 2 && "$validator_symlinks" == 2 && "$validator_stats" == 2 &&
|
||||
"$sudo_validates" == 1 && "$readyz_calls" == 1 &&
|
||||
"$k3slrh_mutation_count" == 0 ]]
|
||||
}
|
||||
|
||||
k3slrh_assert_eq dry-run "$(k3slrh_run_parse)" 'no argument must select dry-run'
|
||||
k3slrh_assert_eq execute "$(k3slrh_run_parse --execute)" 'one execute must be accepted'
|
||||
k3slrh_assert_rc 1 'duplicate parser execute must fail safely' k3slrh_run_parse --execute --execute
|
||||
k3slrh_assert_rc 1 'caller parser path must fail safely' k3slrh_run_parse --container /fixture
|
||||
k3slrh_assert_rc 2 'duplicate main execute must be usage rc2' k3slrh_invalid_main --execute --execute
|
||||
k3slrh_assert_rc 2 'caller main path must be usage rc2' k3slrh_invalid_main --container /fixture
|
||||
k3slrh_assert_succeeds 'actual clean wrapper composition and root/xtrace guards must pass' \
|
||||
k3slrh_wrapper_clean_composition
|
||||
k3slrh_assert_succeeds 'actual wrapper must reject inherited route and constants' \
|
||||
k3slrh_wrapper_rejects_inherited_route
|
||||
k3slrh_assert_rc 1 'execute without TTY must be safety rc1' k3slrh_execute_without_tty
|
||||
k3slrh_assert_rc 1 'malformed timestamp must be safety rc1' k3slrh_run_timestamp malformed
|
||||
k3slrh_assert_succeeds 'valid target names must derive exactly once' k3slrh_targets_are_exact
|
||||
k3slrh_assert_rc 1 'same-second target reservation must be safety rc1' \
|
||||
k3slrh_run_targets_same_second_collision
|
||||
k3slrh_assert_rc 1 'pre/post timestamp drift must be safety rc1' \
|
||||
k3slrh_run_targets_pre_post_timestamp_drift
|
||||
k3slrh_assert_succeeds 'closed external command model must reject unknown argv' \
|
||||
k3slrh_command_model_is_closed
|
||||
|
||||
for k3slrh_bad_health in k3s_inactive readyz_extra readyz_missing_lf readyz_cr \
|
||||
readyz_nul readyz_empty readyz_rc1 readyz_rc124 readyz_rc137 readyz_stderr; do
|
||||
if [[ "$k3slrh_bad_health" == k3s_inactive ]]; then k3slrh_expected_readyz=0; else k3slrh_expected_readyz=1; fi
|
||||
k3slrh_assert_succeeds "K3s health must reject ${k3slrh_bad_health}" \
|
||||
k3slrh_health_case "$k3slrh_bad_health" 1 "$k3slrh_expected_readyz"
|
||||
done
|
||||
unset k3slrh_bad_health k3slrh_expected_readyz
|
||||
k3slrh_assert_succeeds 'exact cached-sudo readyz must pass silently' \
|
||||
k3slrh_health_case valid 0 1
|
||||
|
||||
for k3slrh_bad_validator in validator_cr validator_nul validator_missing_lf \
|
||||
validator_extra validator_raw_id validator_rc1 validator_rc124 validator_rc137 \
|
||||
validator_stderr; do
|
||||
k3slrh_assert_succeeds "closed validator must reject ${k3slrh_bad_validator}" \
|
||||
k3slrh_validator_case "$k3slrh_bad_validator" 1 1 2
|
||||
done
|
||||
unset k3slrh_bad_validator
|
||||
k3slrh_assert_succeeds 'validator pre-call hash drift must stop before dispatch' \
|
||||
k3slrh_validator_case validator_hash_pre_drift 1 0 1
|
||||
k3slrh_assert_succeeds 'validator post-call hash drift must fail closed' \
|
||||
k3slrh_validator_case validator_hash_post_drift 1 1 2
|
||||
k3slrh_assert_succeeds 'exact pinned closed validator record must pass' \
|
||||
k3slrh_validator_case valid 0 1 2
|
||||
|
||||
for k3slrh_bad_capability in uncached_sudo k3s_inactive readyz_extra \
|
||||
keepass_package_drift cryptsetup_package_drift sqlite_package_drift \
|
||||
binary_untrusted \
|
||||
outer_mount_collision inner_mount_collision outer_mountpoint_not_empty tmpfs_residue \
|
||||
main_mapping_residue proof_mapping_residue main_mapping_dangling proof_mapping_dangling \
|
||||
main_loop_residue proof_loop_residue; do
|
||||
k3slrh_assert_succeeds "capability must reject ${k3slrh_bad_capability}" \
|
||||
k3slrh_capability_case "$k3slrh_bad_capability" 1
|
||||
done
|
||||
unset k3slrh_bad_capability
|
||||
k3slrh_assert_succeeds 'closed dry-run must pass without mutation' k3slrh_dry_run_closed
|
||||
|
||||
printf 'PASS: header-proof entrypoint and closed capability fixtures\n'
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Regression test for whole-disk lsblk queries. Without --nodeps, lsblk emits
|
||||
# the disk and all child partitions, while the validator requires one record.
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly REPOSITORY_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd -P)"
|
||||
readonly VALIDATOR_PATH="${REPOSITORY_ROOT}/scripts/validate/k3s-local-recovery.sh"
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
source "$VALIDATOR_PATH"
|
||||
|
||||
_k3slrv_exec() {
|
||||
local scope="${1-}"
|
||||
shift || return 1
|
||||
[[ "$scope" == user ]] || return 1
|
||||
|
||||
if (( $# == 8 )) &&
|
||||
[[ "$1" == /usr/bin/lsblk && "$2" == --noheadings &&
|
||||
"$3" == --nodeps && "$4" == --paths && "$5" == --output &&
|
||||
"$6" == MODEL && "$7" == -- && "$8" == /dev/fixture-disk ]]; then
|
||||
printf '%s\n' FIXTURE_DISK_MODEL
|
||||
else
|
||||
# This mirrors lsblk against a whole disk when dependency traversal is
|
||||
# not disabled: the disk row is followed by a child-partition row.
|
||||
printf '%s\n%s\n' FIXTURE_DISK_MODEL FIXTURE_CHILD_ROW
|
||||
fi
|
||||
}
|
||||
|
||||
actual=''
|
||||
if ! actual="$(_k3slrv_lsblk MODEL /dev/fixture-disk)"; then
|
||||
printf '%s\n' \
|
||||
'TEST FAILURE: whole-disk lsblk query did not produce one record' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$actual" != FIXTURE_DISK_MODEL ]]; then
|
||||
printf '%s\n' \
|
||||
'TEST FAILURE: whole-disk lsblk query returned an unexpected record' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '%s\n' 'PASS: recovery validator excludes lsblk child rows'
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,705 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# k3s Secret 복구 증거의 parser, 세 mode와 권한 경계를 검증한다.
|
||||
# 이 파일은 source-safe 함수만 호출하며 live host나 Kubernetes API를 건드리지 않는다.
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly TEST_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd -P)"
|
||||
readonly BOOTSTRAP_PATH="${TEST_ROOT}/scripts/bootstrap/apply-k3s-secret-encryption.sh"
|
||||
readonly RESTORE_VALIDATOR_PATH="${TEST_ROOT}/scripts/validate/k3s-secret-encryption-restore-evidence.sh"
|
||||
|
||||
fail() {
|
||||
printf 'RESTORE EVIDENCE TEST FAILURE: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
assert_eq() {
|
||||
local expected="$1" actual="$2" description="$3"
|
||||
[[ "$actual" == "$expected" ]] || fail "$description"
|
||||
}
|
||||
|
||||
# 잘못된 legacy field를 쓰는 producer 변경을 잡는다. 기대값은 Task 5 계약에서
|
||||
# 손으로 적었으며 production parser나 formatter로 만들지 않는다.
|
||||
probe_canonical_bundle_metadata_producer() (
|
||||
# shellcheck source=/dev/null
|
||||
source "$BOOTSTRAP_PATH"
|
||||
declare -F _kseb_render_bundle_metadata >/dev/null || return 1
|
||||
local actual
|
||||
actual="$(_kseb_render_bundle_metadata \
|
||||
'01234567-89ab-4cde-8fab-0123456789ab' post embedded-etcd \
|
||||
'2026-08-01T00:00:00Z' 7 'v1.36.2+k3s1')" || return 1
|
||||
assert_eq $'schema=platform-k3s-bundle-v1\nbundle_id=01234567-89ab-4cde-8fab-0123456789ab\nphase=post\nk3s_version=v1.36.2+k3s1\ndatastore=embedded-etcd\ncreated_at_utc=2026-08-01T00:00:00Z\nsecret_count=7' \
|
||||
"$actual" 'canonical bundle metadata fields/order'
|
||||
)
|
||||
|
||||
probe_canonical_bundle_metadata_producer || fail 'canonical bundle metadata producer'
|
||||
|
||||
# 아래 production 변경이 깨질 때 잡는 결함은 allowlist parser 우회, mode 간
|
||||
# 검증 누락, sudo 전 입력 검증 누락과 기존 evidence overwrite다.
|
||||
[[ -f "$RESTORE_VALIDATOR_PATH" ]] || fail 'restore evidence validator is absent'
|
||||
# shellcheck source=/dev/null
|
||||
source "$RESTORE_VALIDATOR_PATH"
|
||||
|
||||
fixture_root="$(mktemp -d "${TMPDIR:-/tmp}/k3s-restore-evidence-test.XXXXXX")"
|
||||
cleanup() {
|
||||
case "$fixture_root" in
|
||||
/tmp/k3s-restore-evidence-test.*|"${TMPDIR:-/tmp}"/k3s-restore-evidence-test.*)
|
||||
rm -rf -- "$fixture_root"
|
||||
;;
|
||||
*) fail 'unexpected fixture root' ;;
|
||||
esac
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
readonly FIXTURE_BUNDLE_ID='01234567-89ab-4cde-8fab-0123456789ab'
|
||||
readonly FIXTURE_OTHER_ID='11234567-89ab-4cde-8fab-0123456789ab'
|
||||
readonly FIXTURE_LOCAL_NODE_UID='aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'
|
||||
readonly FIXTURE_OTHER_NODE_UID='11111111-2222-4333-8444-555555555555'
|
||||
readonly FIXTURE_TESTED_AT='2026-08-01T00:00:00Z'
|
||||
readonly FIXTURE_RECORDED_AT='2026-08-01T01:00:00Z'
|
||||
|
||||
# Fix round 1 / finding 4: invalid compact timestamp는 production transaction의
|
||||
# directory/backup/marker mutation 전에 거부해야 한다.
|
||||
probe_invalid_timestamp_precedes_transaction_mutation() (
|
||||
local parent="${fixture_root}/invalid-stamp" root data fake_k3s command_log rc=0
|
||||
parent="$(mktemp -d "${fixture_root}/invalid-stamp.XXXXXX")"
|
||||
root="$parent/recovery"; data="$parent/data"; fake_k3s="$parent/k3s"
|
||||
command_log="$parent/commands.log"
|
||||
mkdir -p "$root" "$data/server/cred"
|
||||
chmod 0700 "$root"
|
||||
printf 'fixture\n' >"$data/server/token"
|
||||
printf '{}\n' >"$data/server/cred/encryption-config.json"
|
||||
: >"$command_log"
|
||||
printf '%s\n' '#!/usr/bin/env bash' \
|
||||
'printf "called\n" >>"${KSEB_INVALID_STAMP_LOG:?}"' \
|
||||
'exit 1' >"$fake_k3s"
|
||||
chmod 0700 "$fake_k3s"
|
||||
# shellcheck source=/dev/null
|
||||
source "$BOOTSTRAP_PATH"
|
||||
KSEB_BACKUP_ROOT_IDENTITY="$(stat --format='%d:%i' -- "$root")"
|
||||
_kseb_run_privileged_shell() { /usr/bin/bash "$@"; }
|
||||
_kseb_transaction_data_dir() { printf '%s\n' "$data"; }
|
||||
_kseb_transaction_config_dir() { printf '%s\n' "$parent/no-config"; }
|
||||
_kseb_transaction_systemd_paths() { :; }
|
||||
_kseb_transaction_k3s_binary() { printf '%s\n' "$fake_k3s"; }
|
||||
_kseb_transaction_post_marker() { printf '%s\n' "$parent/post-marker"; }
|
||||
_kseb_transaction_restore_evidence() { printf '%s\n' "$parent/restore-evidence"; }
|
||||
export KSEB_INVALID_STAMP_LOG="$command_log"
|
||||
_kseb_pinned_phase_transaction "$root" bundle/pre embedded-etcd \
|
||||
"$FIXTURE_BUNDLE_ID" pre 20261301T000000Z 7 v1.36.2+k3s1 \
|
||||
>/dev/null 2>&1 || rc=$?
|
||||
[[ "$rc" -ne 0 && ! -e "$root/bundle" && ! -s "$command_log" ]]
|
||||
)
|
||||
|
||||
write_metadata() {
|
||||
local path="$1" phase="${2:-post}" bundle_id="${3:-$FIXTURE_BUNDLE_ID}"
|
||||
local version="${4:-v1.36.2+k3s1}" datastore="${5:-embedded-etcd}"
|
||||
local created="${6:-2026-07-31T23:00:00Z}" count="${7:-7}"
|
||||
printf 'schema=platform-k3s-bundle-v1\nbundle_id=%s\nphase=%s\nk3s_version=%s\ndatastore=%s\ncreated_at_utc=%s\nsecret_count=%s\n' \
|
||||
"$bundle_id" "$phase" "$version" "$datastore" "$created" "$count" >"$path"
|
||||
chmod 0600 "$path"
|
||||
}
|
||||
|
||||
write_result() {
|
||||
local path="$1" bundle_id="${2:-$FIXTURE_BUNDLE_ID}" version="${3:-v1.36.2+k3s1}"
|
||||
local datastore="${4:-embedded-etcd}" node="${5:-pass}" isolation="${6:-pass}"
|
||||
local integrity="${7:-match}" count="${8:-pass}" tested="${9:-$FIXTURE_TESTED_AT}"
|
||||
printf 'schema=platform-k3s-restore-result-v1\nbundle_id=%s\nk3s_version=%s\ndatastore=%s\nstatus_class=enabled_stable\nrotation_stage=reencrypt_finished\nhashes=match\nlocal_integrity=%s\napi_ready=pass\nnode_ready=%s\nisolation=%s\nsecret_count_match=%s\ntested_at_utc=%s\n' \
|
||||
"$bundle_id" "$version" "$datastore" "$integrity" "$node" "$isolation" "$count" "$tested" >"$path"
|
||||
chmod 0600 "$path"
|
||||
}
|
||||
|
||||
write_evidence() {
|
||||
local path="$1" bundle_id="${2:-$FIXTURE_BUNDLE_ID}" version="${3:-v1.36.2+k3s1}"
|
||||
local datastore="${4:-embedded-etcd}" integrity="${5:-match}" destroyed="${6:-confirmed}"
|
||||
local tested="${7:-$FIXTURE_TESTED_AT}" recorded="${8:-$FIXTURE_RECORDED_AT}"
|
||||
printf 'schema=platform-k3s-restore-evidence-v1\nbundle_id=%s\nk3s_version=%s\ndatastore=%s\nrotation_stage=reencrypt_finished\nlocal_integrity=%s\nnode_ready=pass\nisolation=pass\ntested_at_utc=%s\nrecorded_at_utc=%s\ndestroyed=%s\n' \
|
||||
"$bundle_id" "$version" "$datastore" "$integrity" "$tested" "$recorded" "$destroyed" >"$path"
|
||||
chmod 0600 "$path"
|
||||
}
|
||||
|
||||
assert_fails() {
|
||||
if "$@" >/dev/null 2>&1; then fail "expected failure: $*"; fi
|
||||
}
|
||||
|
||||
assert_succeeds() {
|
||||
"$@" || fail "expected success: $*"
|
||||
}
|
||||
|
||||
assert_succeeds probe_invalid_timestamp_precedes_transaction_mutation
|
||||
|
||||
metadata="${fixture_root}/bundle.env"
|
||||
result="${fixture_root}/result.env"
|
||||
evidence_fixture="${fixture_root}/evidence.env"
|
||||
write_metadata "$metadata"
|
||||
write_result "$result"
|
||||
write_evidence "$evidence_fixture"
|
||||
|
||||
probe_parse() (
|
||||
local kind="$1" path="$2"
|
||||
declare -A parsed=()
|
||||
_ksre_parse_external_file "$kind" "$path" parsed
|
||||
)
|
||||
|
||||
assert_succeeds probe_parse bundle "$metadata"
|
||||
assert_succeeds probe_parse result "$result"
|
||||
assert_succeeds probe_parse evidence "$evidence_fixture"
|
||||
|
||||
# Fix round 1 / finding 1: local restore validator와 current-user API가
|
||||
# 서로 다른 cluster를 보면 production identity gate가 실패해야 한다.
|
||||
matching_nodes='{"items":[{"metadata":{"name":"isolated-ready","uid":"aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"},"status":{"conditions":[{"type":"Ready","status":"True"}]}}]}'
|
||||
mismatched_nodes='{"items":[{"metadata":{"name":"other-ready","uid":"aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"},"status":{"conditions":[{"type":"Ready","status":"True"}]}}]}'
|
||||
multiple_ready_nodes='{"items":[{"metadata":{"name":"isolated-ready","uid":"aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"},"status":{"conditions":[{"type":"Ready","status":"True"}]}},{"metadata":{"name":"other-ready","uid":"11111111-2222-4333-8444-555555555555"},"status":{"conditions":[{"type":"Ready","status":"True"}]}}]}'
|
||||
assert_succeeds _ksre_require_same_ready_node isolated-ready "$FIXTURE_LOCAL_NODE_UID" "$matching_nodes"
|
||||
assert_fails _ksre_require_same_ready_node isolated-ready "$FIXTURE_LOCAL_NODE_UID" "$mismatched_nodes"
|
||||
assert_fails _ksre_require_same_ready_node isolated-ready "$FIXTURE_LOCAL_NODE_UID" "$multiple_ready_nodes"
|
||||
|
||||
# Fix round 2 / finding 1: 같은 UID가 복제된 다른 API도 허용하지 않도록
|
||||
# current-user context의 effective endpoint는 exact loopback이며 proxy가 없어야 한다.
|
||||
assert_succeeds _ksre_validate_current_connection 'https://127.0.0.1:6443|'
|
||||
assert_fails _ksre_validate_current_connection 'https://192.0.2.10:6443|'
|
||||
assert_fails _ksre_validate_current_connection 'https://localhost:6443|'
|
||||
assert_fails _ksre_validate_current_connection 'https://127.0.0.1:6443/|'
|
||||
assert_fails _ksre_validate_current_connection 'https://127.0.0.1:6443|http://127.0.0.1:8080'
|
||||
|
||||
probe_live_inventory_cluster_binding() (
|
||||
local api_node="$1" api_node_uid="$2" output
|
||||
declare -F _ksre_current_connection >/dev/null || return 1
|
||||
declare -F _ksre_require_same_connection >/dev/null || return 1
|
||||
declare -F _ksre_run_restore_status_validator >/dev/null || return 1
|
||||
declare -F _ksre_current_user_api_ready >/dev/null || return 1
|
||||
declare -F _ksre_current_user_nodes_json >/dev/null || return 1
|
||||
declare -F _ksre_current_user_secret_count >/dev/null || return 1
|
||||
_ksre_run_restore_status_validator() {
|
||||
local handoff="$1"
|
||||
printf 'version=v1.36.2+k3s1\nserver_count=1\nnode_name=isolated-ready\nnode_uid=%s\nnode_ready=Ready\ndatastore=embedded-etcd\nencryption=Enabled\nrotation=reencrypt_finished\nserver_hashes=match\nintegrity=match\napi_readyz=pass\n' \
|
||||
"$FIXTURE_LOCAL_NODE_UID" \
|
||||
>"$handoff/inventory.env"
|
||||
chmod 0600 "$handoff/inventory.env"
|
||||
}
|
||||
_ksre_current_connection() {
|
||||
[[ "${1:-}" == fixture-context ]] || return 1
|
||||
printf 'https://127.0.0.1:6443|\n'
|
||||
}
|
||||
_ksre_current_user_api_ready() {
|
||||
[[ "${1:-}" == fixture-context && "${2:-}" == https://127.0.0.1:6443 ]]
|
||||
}
|
||||
_ksre_current_user_nodes_json() {
|
||||
[[ "${1:-}" == fixture-context && "${2:-}" == https://127.0.0.1:6443 ]] || return 1
|
||||
printf '{"items":[{"metadata":{"name":"%s","uid":"%s"},"status":{"conditions":[{"type":"Ready","status":"True"}]}}]}\n' \
|
||||
"$api_node" "$api_node_uid"
|
||||
}
|
||||
_ksre_current_user_secret_count() {
|
||||
[[ "${1:-}" == fixture-context && "${2:-}" == https://127.0.0.1:6443 ]] || return 1
|
||||
printf '7\n'
|
||||
}
|
||||
output="$(_ksre_live_restore_inventory fixture-context 'https://127.0.0.1:6443|')" || return 1
|
||||
[[ "$output" == 'v1.36.2+k3s1|embedded-etcd|enabled_stable|reencrypt_finished|match|match|pass|pass|7' ]]
|
||||
)
|
||||
|
||||
assert_succeeds probe_live_inventory_cluster_binding isolated-ready "$FIXTURE_LOCAL_NODE_UID"
|
||||
assert_fails probe_live_inventory_cluster_binding isolated-ready "$FIXTURE_OTHER_NODE_UID"
|
||||
assert_fails probe_live_inventory_cluster_binding other-ready "$FIXTURE_LOCAL_NODE_UID"
|
||||
|
||||
# Fix round 1 / finding 2: Bash read가 버리는 NUL도 raw byte 단계에서 거부한다.
|
||||
nul_metadata="${fixture_root}/metadata-nul.env"
|
||||
{
|
||||
printf 'schema=platform-k3s-bundle-v1\n'
|
||||
printf 'bundle_id=%s\n' "$FIXTURE_BUNDLE_ID"
|
||||
printf 'phase=po\0st\n'
|
||||
printf 'k3s_version=v1.36.2+k3s1\n'
|
||||
printf 'datastore=embedded-etcd\n'
|
||||
printf 'created_at_utc=2026-07-31T23:00:00Z\n'
|
||||
printf 'secret_count=7\n'
|
||||
} >"$nul_metadata"
|
||||
chmod 0600 "$nul_metadata"
|
||||
assert_fails probe_parse bundle "$nul_metadata"
|
||||
|
||||
# Fix round 1 / finding 3: pathname이 아니라 실제로 열린 FD identity를 검사한다.
|
||||
probe_pinned_external_fd_survives_path_swap() (
|
||||
local input="${fixture_root}/toctou-valid.env" moved="${fixture_root}/toctou-valid-opened.env"
|
||||
local input_fd
|
||||
write_metadata "$input"
|
||||
exec {input_fd}<"$input"
|
||||
/bin/mv -- "$input" "$moved"
|
||||
printf 'unsafe replacement\n' >"$input"
|
||||
chmod 0644 "$input"
|
||||
declare -A parsed=()
|
||||
_ksre_parse_external_fd bundle "$input_fd" parsed || return 1
|
||||
exec {input_fd}<&-
|
||||
[[ "${parsed[bundle_id]}" == "$FIXTURE_BUNDLE_ID" ]]
|
||||
)
|
||||
|
||||
probe_unsafe_opened_fd_cannot_borrow_safe_path() (
|
||||
local input="${fixture_root}/toctou-unsafe.env" moved="${fixture_root}/toctou-unsafe-opened.env"
|
||||
local input_fd
|
||||
write_metadata "$input"
|
||||
chmod 0644 "$input"
|
||||
exec {input_fd}<"$input"
|
||||
/bin/mv -- "$input" "$moved"
|
||||
write_metadata "$input"
|
||||
declare -A parsed=()
|
||||
if _ksre_parse_external_fd bundle "$input_fd" parsed; then return 1; fi
|
||||
exec {input_fd}<&-
|
||||
)
|
||||
|
||||
assert_succeeds probe_pinned_external_fd_survives_path_swap
|
||||
assert_succeeds probe_unsafe_opened_fd_cannot_borrow_safe_path
|
||||
|
||||
# Fix round 2 / finding 2: production final-component open의 정확한 순간을
|
||||
# test-only interposer로 멈춘다. precheck 뒤 symlink로 바꾸고 open 뒤 같은
|
||||
# target inode의 hard link로 되돌려도 parser가 반드시 실패해야 한다.
|
||||
race_interposer_source="${fixture_root}/nofollow-race.c"
|
||||
race_interposer="${fixture_root}/nofollow-race.so"
|
||||
cat >"$race_interposer_source" <<'RACE_INTERPOSER'
|
||||
#define _GNU_SOURCE
|
||||
#include <dlfcn.h>
|
||||
#include <fcntl.h>
|
||||
#include <limits.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
static int triggered;
|
||||
static int (*real_open_fn)(const char *, int, ...);
|
||||
static int (*real_open64_fn)(const char *, int, ...);
|
||||
static int (*real_openat_fn)(int, const char *, int, ...);
|
||||
static int (*real_openat64_fn)(int, const char *, int, ...);
|
||||
|
||||
static void resolve_symbols(void) {
|
||||
if (!real_open_fn) real_open_fn = dlsym(RTLD_NEXT, "open");
|
||||
if (!real_open64_fn) real_open64_fn = dlsym(RTLD_NEXT, "open64");
|
||||
if (!real_openat_fn) real_openat_fn = dlsym(RTLD_NEXT, "openat");
|
||||
if (!real_openat64_fn) real_openat64_fn = dlsym(RTLD_NEXT, "openat64");
|
||||
}
|
||||
|
||||
static void marker_path(char *buffer, size_t size, const char *name) {
|
||||
const char *directory = getenv("KSRE_RACE_SYNC");
|
||||
if (!directory || snprintf(buffer, size, "%s/%s", directory, name) >= (int)size) _exit(125);
|
||||
}
|
||||
|
||||
static void touch_marker(const char *name) {
|
||||
char path[PATH_MAX];
|
||||
int fd;
|
||||
marker_path(path, sizeof(path), name);
|
||||
resolve_symbols();
|
||||
fd = real_open_fn(path, O_WRONLY | O_CREAT | O_TRUNC, 0600);
|
||||
if (fd < 0) _exit(125);
|
||||
dprintf(fd, "%ld\n", (long)getpid());
|
||||
close(fd);
|
||||
}
|
||||
|
||||
static void wait_for_marker(const char *name) {
|
||||
char path[PATH_MAX];
|
||||
marker_path(path, sizeof(path), name);
|
||||
while (access(path, F_OK) != 0) usleep(1000);
|
||||
}
|
||||
|
||||
static int should_race(const char *path) {
|
||||
const char *target = getenv("KSRE_RACE_PATH");
|
||||
if (!triggered && target && path && strcmp(path, target) == 0) {
|
||||
triggered = 1;
|
||||
touch_marker("entered-open");
|
||||
wait_for_marker("continue-open");
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void after_open(void) {
|
||||
touch_marker("opened");
|
||||
wait_for_marker("continue-postcheck");
|
||||
}
|
||||
|
||||
int open(const char *path, int flags, ...) {
|
||||
mode_t mode = 0;
|
||||
int raced, result;
|
||||
va_list arguments;
|
||||
resolve_symbols();
|
||||
if ((flags & O_CREAT) || ((flags & O_TMPFILE) == O_TMPFILE)) {
|
||||
va_start(arguments, flags); mode = va_arg(arguments, mode_t); va_end(arguments);
|
||||
}
|
||||
raced = should_race(path);
|
||||
result = ((flags & O_CREAT) || ((flags & O_TMPFILE) == O_TMPFILE))
|
||||
? real_open_fn(path, flags, mode) : real_open_fn(path, flags);
|
||||
if (raced) after_open();
|
||||
return result;
|
||||
}
|
||||
|
||||
int open64(const char *path, int flags, ...) {
|
||||
mode_t mode = 0;
|
||||
int raced, result;
|
||||
va_list arguments;
|
||||
resolve_symbols();
|
||||
if ((flags & O_CREAT) || ((flags & O_TMPFILE) == O_TMPFILE)) {
|
||||
va_start(arguments, flags); mode = va_arg(arguments, mode_t); va_end(arguments);
|
||||
}
|
||||
raced = should_race(path);
|
||||
if (!real_open64_fn) real_open64_fn = real_open_fn;
|
||||
result = ((flags & O_CREAT) || ((flags & O_TMPFILE) == O_TMPFILE))
|
||||
? real_open64_fn(path, flags, mode) : real_open64_fn(path, flags);
|
||||
if (raced) after_open();
|
||||
return result;
|
||||
}
|
||||
|
||||
int openat(int directory_fd, const char *path, int flags, ...) {
|
||||
mode_t mode = 0;
|
||||
int raced, result;
|
||||
va_list arguments;
|
||||
resolve_symbols();
|
||||
if ((flags & O_CREAT) || ((flags & O_TMPFILE) == O_TMPFILE)) {
|
||||
va_start(arguments, flags); mode = va_arg(arguments, mode_t); va_end(arguments);
|
||||
}
|
||||
raced = should_race(path);
|
||||
result = ((flags & O_CREAT) || ((flags & O_TMPFILE) == O_TMPFILE))
|
||||
? real_openat_fn(directory_fd, path, flags, mode) : real_openat_fn(directory_fd, path, flags);
|
||||
if (raced) after_open();
|
||||
return result;
|
||||
}
|
||||
|
||||
int openat64(int directory_fd, const char *path, int flags, ...) {
|
||||
mode_t mode = 0;
|
||||
int raced, result;
|
||||
va_list arguments;
|
||||
resolve_symbols();
|
||||
if ((flags & O_CREAT) || ((flags & O_TMPFILE) == O_TMPFILE)) {
|
||||
va_start(arguments, flags); mode = va_arg(arguments, mode_t); va_end(arguments);
|
||||
}
|
||||
raced = should_race(path);
|
||||
if (!real_openat64_fn) real_openat64_fn = real_openat_fn;
|
||||
result = ((flags & O_CREAT) || ((flags & O_TMPFILE) == O_TMPFILE))
|
||||
? real_openat64_fn(directory_fd, path, flags, mode) : real_openat64_fn(directory_fd, path, flags);
|
||||
if (raced) after_open();
|
||||
return result;
|
||||
}
|
||||
RACE_INTERPOSER
|
||||
/usr/bin/cc -shared -fPIC -O2 -o "$race_interposer" "$race_interposer_source" -ldl ||
|
||||
fail 'compile no-follow race interposer'
|
||||
|
||||
wait_for_race_marker() {
|
||||
local marker="$1" process_id="$2" attempt
|
||||
for ((attempt = 0; attempt < 5000; attempt++)); do
|
||||
[[ -e "$marker" ]] && return 0
|
||||
/bin/kill -0 "$process_id" 2>/dev/null || return 1
|
||||
/usr/bin/sleep 0.001
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
probe_open_instant_symlink_replacement_rejected() (
|
||||
local target="${fixture_root}/race-target.env" input="${fixture_root}/race-input.env"
|
||||
local original="${fixture_root}/race-input-original.env" sync="${fixture_root}/race-sync"
|
||||
local process_id parse_rc=0
|
||||
write_metadata "$target"
|
||||
write_metadata "$input"
|
||||
mkdir "$sync"
|
||||
/usr/bin/env KSRE_RACE_PATH="$input" KSRE_RACE_SYNC="$sync" LD_PRELOAD="$race_interposer" \
|
||||
/usr/bin/timeout --signal=TERM --kill-after=1s 10s \
|
||||
/usr/bin/bash -c '
|
||||
set -Eeuo pipefail
|
||||
source "$1"
|
||||
declare -A parsed=()
|
||||
_ksre_parse_external_file bundle "$2" parsed
|
||||
' bash "$RESTORE_VALIDATOR_PATH" "$input" &
|
||||
process_id=$!
|
||||
if ! wait_for_race_marker "$sync/entered-open" "$process_id"; then
|
||||
: >"$sync/continue-open"; : >"$sync/continue-postcheck"
|
||||
wait "$process_id" 2>/dev/null || :
|
||||
return 1
|
||||
fi
|
||||
/bin/mv -- "$input" "$original"
|
||||
/bin/ln -s -- "$target" "$input"
|
||||
: >"$sync/continue-open"
|
||||
if ! wait_for_race_marker "$sync/opened" "$process_id"; then
|
||||
: >"$sync/continue-postcheck"
|
||||
wait "$process_id" 2>/dev/null || :
|
||||
return 1
|
||||
fi
|
||||
/bin/rm -- "$input"
|
||||
/bin/ln -- "$target" "$input"
|
||||
: >"$sync/continue-postcheck"
|
||||
if wait "$process_id"; then parse_rc=0; else parse_rc=$?; fi
|
||||
[[ "$parse_rc" -ne 0 ]]
|
||||
)
|
||||
|
||||
assert_succeeds probe_open_instant_symlink_replacement_rejected
|
||||
|
||||
# Fix round 3: production broker가 readiness 전에 멈춰도 parent 자체 상한 안에
|
||||
# nonzero로 끝나고 partial success output과 살아 있는 broker를 남기면 안 된다.
|
||||
probe_broker_readiness_delay_is_bounded() (
|
||||
local input="${fixture_root}/broker-delay.env" sync="${fixture_root}/broker-delay-sync"
|
||||
local partial_output="${fixture_root}/broker-delay.out" broker_process='' parser_rc=0 attempt
|
||||
write_metadata "$input"
|
||||
mkdir "$sync"
|
||||
/usr/bin/env KSRE_RACE_PATH="$input" KSRE_RACE_SYNC="$sync" LD_PRELOAD="$race_interposer" \
|
||||
/usr/bin/timeout --signal=TERM --kill-after=0.5s 4s \
|
||||
/usr/bin/bash -c '
|
||||
set -Eeuo pipefail
|
||||
source "$1"
|
||||
declare -A parsed=()
|
||||
if _ksre_parse_external_file bundle "$2" parsed; then
|
||||
printf "unexpected-success\n"
|
||||
exit 0
|
||||
fi
|
||||
exit 23
|
||||
' bash "$RESTORE_VALIDATOR_PATH" "$input" >"$partial_output" 2>/dev/null || parser_rc=$?
|
||||
[[ -s "$sync/entered-open" ]] || return 1
|
||||
broker_process="$(<"$sync/entered-open")"
|
||||
[[ "$broker_process" =~ ^[0-9]+$ ]] || return 1
|
||||
: >"$sync/continue-open"
|
||||
: >"$sync/continue-postcheck"
|
||||
for ((attempt = 0; attempt < 1000; attempt++)); do
|
||||
/bin/kill -0 "$broker_process" 2>/dev/null || break
|
||||
/usr/bin/sleep 0.001
|
||||
done
|
||||
if [[ "$parser_rc" == 124 || "$parser_rc" == 137 ]]; then
|
||||
printf 'broker readiness required the outer watchdog\n' >&2
|
||||
return 1
|
||||
fi
|
||||
[[ "$parser_rc" -ne 0 && ! -s "$partial_output" ]]
|
||||
! /bin/kill -0 "$broker_process" 2>/dev/null
|
||||
)
|
||||
|
||||
assert_succeeds probe_broker_readiness_delay_is_bounded
|
||||
|
||||
# Fix round 2 pre-review concern: writer가 없는 FIFO는 regular-file 검사 전에
|
||||
# block하지 않고 즉시 거부되어야 한다. timeout은 실패가 아니라 RED 증거다.
|
||||
probe_fifo_rejected_without_blocking() (
|
||||
local fifo="${fixture_root}/external-input.fifo" parser_rc=0
|
||||
/usr/bin/mkfifo -- "$fifo"
|
||||
/usr/bin/chmod 0600 "$fifo"
|
||||
/usr/bin/timeout --signal=TERM --kill-after=0.25s 0.25s \
|
||||
/usr/bin/bash -c '
|
||||
set -Eeuo pipefail
|
||||
source "$1"
|
||||
declare -A parsed=()
|
||||
_ksre_parse_external_file bundle "$2" parsed
|
||||
' bash "$RESTORE_VALIDATOR_PATH" "$fifo" >/dev/null 2>&1 || parser_rc=$?
|
||||
if [[ "$parser_rc" == 124 || "$parser_rc" == 137 ]]; then
|
||||
/usr/bin/timeout --signal=TERM --kill-after=0.25s 1s \
|
||||
/usr/bin/bash -c 'printf x >"$1"' bash "$fifo" >/dev/null 2>&1 || :
|
||||
printf 'FIFO parser blocked before regular-file rejection\n' >&2
|
||||
return 1
|
||||
fi
|
||||
[[ "$parser_rc" -ne 0 ]]
|
||||
)
|
||||
|
||||
assert_succeeds probe_fifo_rejected_without_blocking
|
||||
|
||||
secret_stream="${fixture_root}/secret-stream.json"
|
||||
secret_trace="${fixture_root}/secret-stream.trace"
|
||||
printf '{"items":[{"data":{"value":"fixture-sensitive-payload"}}]}\n' >"$secret_stream"
|
||||
probe_stream_secret_count() (
|
||||
exec 9>"$secret_trace"
|
||||
BASH_XTRACEFD=9
|
||||
set -x
|
||||
local count
|
||||
count="$(_ksre_secret_count_from_stream <"$secret_stream")"
|
||||
set +x
|
||||
[[ "$count" == 1 ]]
|
||||
)
|
||||
assert_succeeds probe_stream_secret_count
|
||||
[[ "$(<"$secret_trace")" != *fixture-sensitive-payload* ]] || fail 'Secret payload reached xtrace'
|
||||
|
||||
# Parser/security matrix. 각 fixture는 parser의 실제 branch를 지나며 privileged
|
||||
# helper가 한 번이라도 호출되면 별도 marker를 남기도록 mode fixture에서 확인한다.
|
||||
for attack in command_substitution backtick duplicate unknown empty control malformed trailing; do
|
||||
candidate="${fixture_root}/metadata-${attack}.env"
|
||||
write_metadata "$candidate"
|
||||
case "$attack" in
|
||||
command_substitution) /usr/bin/sed -i '$c\secret_count=$(id)' "$candidate" ;;
|
||||
backtick) /usr/bin/sed -i '$c\secret_count=`id`' "$candidate" ;;
|
||||
duplicate) printf 'phase=post\n' >>"$candidate" ;;
|
||||
unknown) printf 'credential=forbidden\n' >>"$candidate" ;;
|
||||
empty) /usr/bin/sed -i 's/^phase=.*/phase=/' "$candidate" ;;
|
||||
control) /usr/bin/sed -i 's/^phase=.*/phase=post\r/' "$candidate" ;;
|
||||
malformed) printf 'not-an-assignment\n' >>"$candidate" ;;
|
||||
trailing) printf 'trailing-data\n' >>"$candidate" ;;
|
||||
esac
|
||||
assert_fails probe_parse bundle "$candidate"
|
||||
done
|
||||
|
||||
empty_file="${fixture_root}/empty.env"; : >"$empty_file"; chmod 0600 "$empty_file"
|
||||
assert_fails probe_parse bundle "$empty_file"
|
||||
mode_file="${fixture_root}/mode.env"; write_metadata "$mode_file"; chmod 0644 "$mode_file"
|
||||
assert_fails probe_parse bundle "$mode_file"
|
||||
symlink_file="${fixture_root}/link.env"; ln -s -- "$metadata" "$symlink_file"
|
||||
assert_fails probe_parse bundle "$symlink_file"
|
||||
|
||||
probe_mode_rejects_before_privilege() (
|
||||
local path="$1" marker="${fixture_root}/unexpected-privilege"
|
||||
_ksre_current_context() { printf 'default\n'; }
|
||||
_ksre_read_authoritative_bundle() { : >"$marker"; return 1; }
|
||||
_ksre_install_evidence() { : >"$marker"; return 1; }
|
||||
_ksre_record "$path" "$result" <<<"DESTROYED default" >/dev/null 2>&1 && return 1
|
||||
[[ ! -e "$marker" ]]
|
||||
)
|
||||
for attack in command_substitution backtick duplicate unknown control malformed trailing; do
|
||||
assert_succeeds probe_mode_rejects_before_privilege "${fixture_root}/metadata-${attack}.env"
|
||||
done
|
||||
assert_succeeds probe_mode_rejects_before_privilege "$mode_file"
|
||||
assert_succeeds probe_mode_rejects_before_privilege "$symlink_file"
|
||||
|
||||
mode_events="${fixture_root}/mode-events.log"
|
||||
installed_evidence="${fixture_root}/installed-evidence.env"
|
||||
|
||||
install_mode_fakes() {
|
||||
: >"$mode_events"
|
||||
rm -f -- "$installed_evidence"
|
||||
active_evidence_file="$evidence_fixture"
|
||||
_ksre_current_context() { printf 'context\n' >>"$mode_events"; printf 'default\n'; }
|
||||
_ksre_current_connection() { printf 'connection\n' >>"$mode_events"; printf 'https://127.0.0.1:6443|\n'; }
|
||||
_ksre_require_same_context() { printf 'context-recheck\n' >>"$mode_events"; }
|
||||
_ksre_now_rfc3339() { printf '%s\n' "$FIXTURE_RECORDED_AT"; }
|
||||
_ksre_now_epoch() { printf '1785546000\n'; }
|
||||
_ksre_live_restore_inventory() {
|
||||
printf 'live\n' >>"$mode_events"
|
||||
printf 'v1.36.2+k3s1|embedded-etcd|enabled_stable|reencrypt_finished|match|match|pass|pass|7\n'
|
||||
}
|
||||
_ksre_isolation_attested() { printf 'isolation\n' >>"$mode_events"; }
|
||||
_ksre_read_authoritative_bundle() {
|
||||
local destination="$1"
|
||||
printf 'authority\n' >>"$mode_events"
|
||||
_ksre_parse_trusted_content bundle "$(<"$metadata")" "$destination"
|
||||
}
|
||||
_ksre_read_installed_evidence() {
|
||||
local destination="$1"
|
||||
printf 'evidence\n' >>"$mode_events"
|
||||
_ksre_parse_trusted_content evidence "$(<"$active_evidence_file")" "$destination"
|
||||
}
|
||||
_ksre_evidence_target_absent() { printf 'target-check\n' >>"$mode_events"; }
|
||||
_ksre_prompt_destroyed() { printf 'destroyed\n' >>"$mode_events"; }
|
||||
_ksre_install_evidence() {
|
||||
local content="$1"
|
||||
printf 'install\n' >>"$mode_events"
|
||||
printf '%s\n' "$content" >"$installed_evidence"
|
||||
chmod 0600 "$installed_evidence"
|
||||
}
|
||||
}
|
||||
|
||||
probe_emit_success() (
|
||||
install_mode_fakes
|
||||
local output="${fixture_root}/emitted.env"
|
||||
rm -f -- "$output"
|
||||
_ksre_emit_result "$metadata" "$output" >/dev/null
|
||||
declare -A parsed=()
|
||||
_ksre_parse_external_file result "$output" parsed
|
||||
[[ "${parsed[bundle_id]}" == "$FIXTURE_BUNDLE_ID" &&
|
||||
"${parsed[secret_count_match]}" == pass && -z "${parsed[destroyed]+present}" &&
|
||||
"$(<"$mode_events")" == $'context\nconnection\nlive\nisolation\nconnection\ncontext-recheck' ]]
|
||||
)
|
||||
assert_succeeds probe_emit_success
|
||||
|
||||
probe_emit_relative_output() (
|
||||
install_mode_fakes
|
||||
cd -- "$fixture_root"
|
||||
rm -f -- relative-result.env
|
||||
_ksre_emit_result "$metadata" relative-result.env >/dev/null
|
||||
[[ -f relative-result.env ]]
|
||||
)
|
||||
assert_succeeds probe_emit_relative_output
|
||||
|
||||
probe_emit_existing_output() (
|
||||
install_mode_fakes
|
||||
local output="${fixture_root}/existing-output.env"
|
||||
printf 'keep\n' >"$output"; chmod 0600 "$output"
|
||||
_ksre_emit_result "$metadata" "$output" >/dev/null 2>&1 && return 1
|
||||
[[ "$(<"$output")" == keep ]]
|
||||
)
|
||||
assert_succeeds probe_emit_existing_output
|
||||
|
||||
probe_emit_live_failure() (
|
||||
local field="$1"
|
||||
install_mode_fakes
|
||||
local output="${fixture_root}/emit-failure-${field}.env"
|
||||
_ksre_live_restore_inventory() {
|
||||
case "$field" in
|
||||
count) printf 'v1.36.2+k3s1|embedded-etcd|enabled_stable|reencrypt_finished|match|match|pass|pass|6\n' ;;
|
||||
node) printf 'v1.36.2+k3s1|embedded-etcd|enabled_stable|reencrypt_finished|match|match|pass|fail|7\n' ;;
|
||||
integrity) printf 'v1.36.2+k3s1|embedded-etcd|enabled_stable|reencrypt_finished|match|mismatch|pass|pass|7\n' ;;
|
||||
esac
|
||||
}
|
||||
if [[ "$field" == isolation ]]; then _ksre_isolation_attested() { return 1; }; fi
|
||||
_ksre_emit_result "$metadata" "$output" >/dev/null 2>&1 && return 1
|
||||
[[ ! -e "$output" ]]
|
||||
)
|
||||
for field in count node isolation integrity; do assert_succeeds probe_emit_live_failure "$field"; done
|
||||
|
||||
probe_record_success() (
|
||||
install_mode_fakes
|
||||
_ksre_record "$metadata" "$result" <<<"DESTROYED default" >/dev/null
|
||||
declare -A parsed=()
|
||||
_ksre_parse_external_file evidence "$installed_evidence" parsed
|
||||
[[ "${parsed[destroyed]}" == confirmed && "$(head -n 1 "$mode_events")" == context ]]
|
||||
)
|
||||
assert_succeeds probe_record_success
|
||||
|
||||
probe_record_failure() (
|
||||
local scenario="$1" local_metadata="${fixture_root}/record-${scenario}-metadata.env"
|
||||
local local_result="${fixture_root}/record-${scenario}-result.env"
|
||||
install_mode_fakes
|
||||
write_metadata "$local_metadata"; write_result "$local_result"
|
||||
case "$scenario" in
|
||||
metadata_id) write_metadata "$local_metadata" post "$FIXTURE_OTHER_ID" ;;
|
||||
result_id) write_result "$local_result" "$FIXTURE_OTHER_ID" ;;
|
||||
result_version) write_result "$local_result" "$FIXTURE_BUNDLE_ID" v1.36.1+k3s1 ;;
|
||||
result_backend) write_result "$local_result" "$FIXTURE_BUNDLE_ID" v1.36.2+k3s1 sqlite ;;
|
||||
pre) write_metadata "$local_metadata" pre ;;
|
||||
stale) write_result "$local_result" "$FIXTURE_BUNDLE_ID" v1.36.2+k3s1 embedded-etcd pass pass match pass 2026-07-30T00:00:00Z ;;
|
||||
secret_count) write_result "$local_result" "$FIXTURE_BUNDLE_ID" v1.36.2+k3s1 embedded-etcd pass pass match fail ;;
|
||||
node) write_result "$local_result" "$FIXTURE_BUNDLE_ID" v1.36.2+k3s1 embedded-etcd fail ;;
|
||||
isolation) write_result "$local_result" "$FIXTURE_BUNDLE_ID" v1.36.2+k3s1 embedded-etcd pass fail ;;
|
||||
integrity) write_result "$local_result" "$FIXTURE_BUNDLE_ID" v1.36.2+k3s1 embedded-etcd pass pass mismatch ;;
|
||||
authority_id)
|
||||
_ksre_read_authoritative_bundle() {
|
||||
local destination="$1" alternate="${fixture_root}/alternate-authority.env"
|
||||
write_metadata "$alternate" post "$FIXTURE_OTHER_ID"
|
||||
_ksre_parse_trusted_content bundle "$(<"$alternate")" "$destination"
|
||||
}
|
||||
;;
|
||||
destroyed) _ksre_prompt_destroyed() { return 1; } ;;
|
||||
target_exists) _ksre_evidence_target_absent() { return 1; } ;;
|
||||
esac
|
||||
_ksre_record "$local_metadata" "$local_result" <<<"DESTROYED default" >/dev/null 2>&1 && return 1
|
||||
[[ ! -e "$installed_evidence" ]]
|
||||
)
|
||||
for scenario in metadata_id result_id result_version result_backend pre stale secret_count node isolation integrity authority_id destroyed target_exists; do
|
||||
assert_succeeds probe_record_failure "$scenario"
|
||||
done
|
||||
|
||||
probe_check_success() (
|
||||
install_mode_fakes
|
||||
_ksre_check >/dev/null
|
||||
[[ "$(head -n 1 "$mode_events")" == context ]]
|
||||
)
|
||||
assert_succeeds probe_check_success
|
||||
|
||||
probe_check_failure() (
|
||||
local scenario="$1" altered="${fixture_root}/check-${scenario}.env"
|
||||
install_mode_fakes
|
||||
case "$scenario" in
|
||||
authority_id)
|
||||
_ksre_read_authoritative_bundle() {
|
||||
local destination="$1" alternate="${fixture_root}/check-authority.env"
|
||||
write_metadata "$alternate" post "$FIXTURE_OTHER_ID"
|
||||
_ksre_parse_trusted_content bundle "$(<"$alternate")" "$destination"
|
||||
}
|
||||
;;
|
||||
version) write_evidence "$altered" "$FIXTURE_BUNDLE_ID" v1.36.1+k3s1; active_evidence_file="$altered" ;;
|
||||
backend) write_evidence "$altered" "$FIXTURE_BUNDLE_ID" v1.36.2+k3s1 sqlite; active_evidence_file="$altered" ;;
|
||||
integrity) write_evidence "$altered" "$FIXTURE_BUNDLE_ID" v1.36.2+k3s1 embedded-etcd mismatch; active_evidence_file="$altered" ;;
|
||||
destroyed) write_evidence "$altered" "$FIXTURE_BUNDLE_ID" v1.36.2+k3s1 embedded-etcd match pending; active_evidence_file="$altered" ;;
|
||||
stale) write_evidence "$altered" "$FIXTURE_BUNDLE_ID" v1.36.2+k3s1 embedded-etcd match confirmed 2026-06-01T00:00:00Z 2026-06-01T01:00:00Z; active_evidence_file="$altered" ;;
|
||||
live_stage) _ksre_live_restore_inventory() { printf 'v1.36.2+k3s1|embedded-etcd|enabled_stable|start|match|match|pass|pass|7\n'; } ;;
|
||||
live_integrity) _ksre_live_restore_inventory() { printf 'v1.36.2+k3s1|embedded-etcd|enabled_stable|reencrypt_finished|match|mismatch|pass|pass|7\n'; } ;;
|
||||
esac
|
||||
_ksre_check >/dev/null 2>&1 && return 1
|
||||
return 0
|
||||
)
|
||||
for scenario in authority_id version backend integrity destroyed stale live_stage live_integrity; do
|
||||
assert_succeeds probe_check_failure "$scenario"
|
||||
done
|
||||
|
||||
printf 'K3S SECRET ENCRYPTION RESTORE EVIDENCE TEST PASS\n'
|
||||
File diff suppressed because it is too large
Load Diff
+273
@@ -0,0 +1,273 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd -P)"
|
||||
readonly ALERTING_ROOT="$ROOT/services/observability/alerting"
|
||||
readonly ALERTMANAGER_CONFIG="$ALERTING_ROOT/platform-alertmanager.yaml"
|
||||
readonly PUBLIC_HTTPS_POLICY="$ALERTING_ROOT/alertmanager-public-https-egress.yaml"
|
||||
readonly ALERTING_KUSTOMIZATION="$ALERTING_ROOT/kustomization.yaml"
|
||||
readonly OBSERVABILITY_KUSTOMIZATION="$ROOT/services/observability/kustomization.yaml"
|
||||
readonly KPS_ROOT="$ROOT/services/observability/kube-prometheus-stack"
|
||||
readonly KPS_VALUES="$ROOT/services/observability/kube-prometheus-stack/values/home.yaml"
|
||||
|
||||
usage() {
|
||||
printf 'usage: %s [--server-dry-run]\n' "${0##*/}" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
server_dry_run=false
|
||||
case $# in
|
||||
0) ;;
|
||||
1)
|
||||
[[ "$1" == --server-dry-run ]] || usage
|
||||
server_dry_run=true
|
||||
;;
|
||||
*) usage ;;
|
||||
esac
|
||||
|
||||
python3 - \
|
||||
"$ALERTMANAGER_CONFIG" \
|
||||
"$PUBLIC_HTTPS_POLICY" \
|
||||
"$ALERTING_KUSTOMIZATION" \
|
||||
"$OBSERVABILITY_KUSTOMIZATION" \
|
||||
"$KPS_VALUES" <<'PY'
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def fail(reason):
|
||||
print(f"FAIL: observability alerting contract: {reason}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def load_one(path):
|
||||
source = pathlib.Path(path)
|
||||
try:
|
||||
with source.open(encoding="utf-8") as stream:
|
||||
documents = [item for item in yaml.safe_load_all(stream) if item is not None]
|
||||
except (OSError, yaml.YAMLError):
|
||||
fail(f"{source.name} is missing or invalid YAML")
|
||||
if len(documents) != 1 or not isinstance(documents[0], dict):
|
||||
fail(f"{source.name} must contain exactly one object")
|
||||
return documents[0]
|
||||
|
||||
|
||||
alert_path, policy_path, child_kustomization_path, root_kustomization_path, values_path = sys.argv[1:]
|
||||
alert = load_one(alert_path)
|
||||
policy = load_one(policy_path)
|
||||
child = load_one(child_kustomization_path)
|
||||
root = load_one(root_kustomization_path)
|
||||
values = load_one(values_path)
|
||||
|
||||
if alert != {
|
||||
"apiVersion": "monitoring.coreos.com/v1alpha1",
|
||||
"kind": "AlertmanagerConfig",
|
||||
"metadata": {
|
||||
"name": "platform-alertmanager",
|
||||
"namespace": "observability",
|
||||
"labels": {"observability.hyeonworks.com/instance": "home"},
|
||||
},
|
||||
"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 }}\n*Alert status:* {{ .Status | toUpper }}\n*Target:* {{ $target := .Labels.Remove $.GroupLabels.Names }}{{ if $target }}{{ range $target.SortedPairs }}{{ .Name }}={{ .Value }} {{ end }}{{ else }}unknown{{ end }}\n*Summary:* {{ with .Annotations.summary }}{{ . }}{{ else }}No summary provided{{ end }}\n*Details:* {{ with .Annotations.description }}{{ . }}{{ else }}No description provided{{ end }}\n*Started:* {{ .StartsAt.Format \"2006-01-02T15:04:05Z07:00\" }}\n{{ if eq .Status \"resolved\" }}*Ended:* {{ .EndsAt.Format \"2006-01-02T15:04:05Z07:00\" }}{{ end }}\n{{ with .Annotations.runbook_url }}*Runbook:* <{{ . }}|대응 절차 열기>{{ end }}\n\n{{ end }}\n<https://grafana.learn.hyeonworks.com/|Grafana> · <https://grafana.learn.hyeonworks.com/explore|Explore>",
|
||||
"footer": "hyeonworks observability · Alertmanager",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
}:
|
||||
fail("platform-alertmanager must match the exact global Slack contract")
|
||||
|
||||
if policy != {
|
||||
"apiVersion": "networking.k8s.io/v1",
|
||||
"kind": "NetworkPolicy",
|
||||
"metadata": {
|
||||
"name": "observability-allow-alertmanager-public-https",
|
||||
"namespace": "observability",
|
||||
},
|
||||
"spec": {
|
||||
"podSelector": {
|
||||
"matchLabels": {
|
||||
"app.kubernetes.io/name": "alertmanager",
|
||||
"app.kubernetes.io/instance": "observability-core-kube-pr-alertmanager",
|
||||
}
|
||||
},
|
||||
"policyTypes": ["Egress"],
|
||||
"egress": [
|
||||
{
|
||||
"to": [
|
||||
{
|
||||
"ipBlock": {
|
||||
"cidr": "0.0.0.0/0",
|
||||
"except": [
|
||||
"10.0.0.0/8",
|
||||
"100.64.0.0/10",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
],
|
||||
}
|
||||
}
|
||||
],
|
||||
"ports": [{"protocol": "TCP", "port": 443}],
|
||||
}
|
||||
],
|
||||
},
|
||||
}:
|
||||
fail("Alertmanager egress must allow only public IPv4 TCP/443")
|
||||
|
||||
if child.get("apiVersion") != "kustomize.config.k8s.io/v1beta1" or child.get("kind") != "Kustomization":
|
||||
fail("alerting kustomization identity is invalid")
|
||||
if child.get("namespace") != "observability":
|
||||
fail("alerting kustomization namespace must be observability")
|
||||
if child.get("resources") != [
|
||||
"platform-alertmanager.yaml",
|
||||
"alertmanager-public-https-egress.yaml",
|
||||
]:
|
||||
fail("alerting kustomization resources are not exact")
|
||||
if set(child) != {"apiVersion", "kind", "namespace", "resources"}:
|
||||
fail("alerting kustomization contains unapproved behavior")
|
||||
|
||||
root_resources = root.get("resources")
|
||||
if not isinstance(root_resources, list) or len(root_resources) != len(set(root_resources)):
|
||||
fail("observability root resources must be a unique list")
|
||||
for required in ("targets", "rules/platform", "alerting", "dashboards/platform"):
|
||||
if root_resources.count(required) != 1:
|
||||
fail(f"observability root must include {required} exactly once")
|
||||
|
||||
try:
|
||||
alertmanager_spec = values["alertmanager"]["alertmanagerSpec"]
|
||||
except (KeyError, TypeError):
|
||||
fail("kube-prometheus-stack alertmanagerSpec is missing")
|
||||
if alertmanager_spec.get("alertmanagerConfiguration") != {"name": "platform-alertmanager"}:
|
||||
fail("global alertmanagerConfiguration reference is not exact")
|
||||
for forbidden in ("alertmanagerConfigSelector", "alertmanagerConfigNamespaceSelector"):
|
||||
if forbidden in alertmanager_spec:
|
||||
fail(f"{forbidden} must remain absent when using the global reference")
|
||||
|
||||
for document, label in ((alert, "AlertmanagerConfig"), (policy, "NetworkPolicy")):
|
||||
if document.get("kind") == "Secret":
|
||||
fail(f"{label} source must not contain a Secret")
|
||||
|
||||
print("OBSERVABILITY ALERTING STATIC CONTRACT PASS")
|
||||
PY
|
||||
|
||||
render_dir="$(mktemp -d /tmp/platform-observability-alerting-test.XXXXXX)"
|
||||
cleanup() {
|
||||
case "$render_dir" in
|
||||
/tmp/platform-observability-alerting-test.*) rm -rf -- "$render_dir" ;;
|
||||
esac
|
||||
}
|
||||
trap cleanup EXIT
|
||||
chmod 0700 "$render_dir"
|
||||
kubectl kustomize --enable-helm "$KPS_ROOT" >"$render_dir/kps.yaml"
|
||||
chmod 0600 "$render_dir/kps.yaml"
|
||||
python3 - "$render_dir/kps.yaml" <<'PY'
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8") as stream:
|
||||
items = [item for item in yaml.safe_load_all(stream) if item is not None]
|
||||
alertmanagers = [item for item in items if item.get("kind") == "Alertmanager"]
|
||||
if len(alertmanagers) != 1:
|
||||
raise SystemExit("FAIL: rendered KPS must contain exactly one Alertmanager")
|
||||
spec = alertmanagers[0].get("spec") or {}
|
||||
if spec.get("alertmanagerConfiguration") != {"name": "platform-alertmanager"}:
|
||||
raise SystemExit("FAIL: rendered Alertmanager global reference is not exact")
|
||||
for forbidden in ("alertmanagerConfigSelector", "alertmanagerConfigNamespaceSelector"):
|
||||
if forbidden in spec:
|
||||
raise SystemExit(f"FAIL: rendered Alertmanager retains forbidden {forbidden}")
|
||||
print("OBSERVABILITY ALERTMANAGER GLOBAL RENDER CONTRACT PASS")
|
||||
PY
|
||||
|
||||
if [[ "$server_dry_run" == true ]]; then
|
||||
readonly KUBECTL_BIN="${PLATFORM_KUBECTL_BIN:-/usr/local/bin/kubectl}"
|
||||
[[ "$KUBECTL_BIN" == /* && -f "$KUBECTL_BIN" && -x "$KUBECTL_BIN" && ! -L "$KUBECTL_BIN" ]] || {
|
||||
printf 'FAIL: PLATFORM_KUBECTL_BIN must be an executable absolute regular file\n' >&2
|
||||
exit 1
|
||||
}
|
||||
"$KUBECTL_BIN" --request-timeout=10s apply --dry-run=server -f "$ALERTMANAGER_CONFIG" >/dev/null
|
||||
"$KUBECTL_BIN" --request-timeout=10s apply --dry-run=server -f "$PUBLIC_HTTPS_POLICY" >/dev/null
|
||||
printf 'OBSERVABILITY ALERTING SERVER DRY RUN PASS\n'
|
||||
fi
|
||||
+612
@@ -0,0 +1,612 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)
|
||||
DASHBOARD_ROOT="$ROOT/services/observability/dashboards/platform"
|
||||
METRIC_ROOT=${PLATFORM_OBSERVABILITY_METRIC_ROOT:-/tmp/platform-observability-metrics.VUpsZn}
|
||||
TARGET_INVENTORY="$METRIC_ROOT/target-initial/inventory.json"
|
||||
POST_INVENTORY="$METRIC_ROOT/post-substrate/inventory.json"
|
||||
PROMETHEUS_IMAGE='quay.io/prometheus/prometheus@sha256:ce95cfa77eff5aad28bd7a65aff19868cf78d9e17e4c254da7dfe22ade78318b'
|
||||
WORK_DIR=''
|
||||
PROMETHEUS_BASE_URL=''
|
||||
PORT_FORWARD_PID=''
|
||||
|
||||
fail() {
|
||||
printf 'FAIL: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
pass() {
|
||||
printf 'PASS: %s\n' "$*"
|
||||
}
|
||||
|
||||
require_command() {
|
||||
command -v "$1" >/dev/null 2>&1 || fail "required command is unavailable: $1"
|
||||
}
|
||||
|
||||
usage() {
|
||||
printf 'Usage: %s [--prometheus-base-url http://127.0.0.1:PORT]\n' "${0##*/}"
|
||||
}
|
||||
|
||||
parse_args() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--prometheus-base-url)
|
||||
[[ $# -ge 2 ]] || fail '--prometheus-base-url requires a value'
|
||||
[[ -z "$PROMETHEUS_BASE_URL" ]] || fail '--prometheus-base-url may be specified only once'
|
||||
PROMETHEUS_BASE_URL=$2
|
||||
shift 2
|
||||
;;
|
||||
--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
fail "unknown argument: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -n "$PROMETHEUS_BASE_URL" ]]; then
|
||||
[[ "$PROMETHEUS_BASE_URL" =~ ^http://(127\.0\.0\.1|localhost):([1-9][0-9]{0,4})$ ]] \
|
||||
|| fail '--prometheus-base-url must be an exact loopback HTTP origin without a path'
|
||||
(( BASH_REMATCH[2] <= 65535 )) \
|
||||
|| fail '--prometheus-base-url port exceeds 65535'
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
trap - EXIT
|
||||
if [[ -n "$PORT_FORWARD_PID" ]] && kill -0 "$PORT_FORWARD_PID" 2>/dev/null; then
|
||||
kill "$PORT_FORWARD_PID" 2>/dev/null || true
|
||||
wait "$PORT_FORWARD_PID" 2>/dev/null || true
|
||||
fi
|
||||
if [[ -n "$WORK_DIR" && -d "$WORK_DIR" ]]; then
|
||||
rm -rf -- "$WORK_DIR"
|
||||
fi
|
||||
exit "$rc"
|
||||
}
|
||||
|
||||
verify_inventory() {
|
||||
local phase=$1
|
||||
local inventory="$METRIC_ROOT/$phase/inventory.json"
|
||||
local checksum="$METRIC_ROOT/$phase/inventory.sha256"
|
||||
local expected actual
|
||||
|
||||
[[ -f "$inventory" ]] || fail "$phase inventory is absent"
|
||||
[[ -f "$checksum" ]] || fail "$phase inventory checksum is absent"
|
||||
jq -e --arg phase "$phase" '
|
||||
.schema == "platform-observability-metric-inventory/v1"
|
||||
and .phase == $phase
|
||||
and (.targets | type == "array" and length > 0)
|
||||
and all(.targets[]; .health == "up" and (.metrics | type == "array" and length > 0))
|
||||
' "$inventory" >/dev/null || fail "$phase inventory contract is invalid"
|
||||
|
||||
read -r expected checksum_name < "$checksum"
|
||||
[[ "$checksum_name" == 'inventory.json' ]] || fail "$phase checksum names an unexpected file"
|
||||
actual=$(sha256sum "$inventory" | awk '{print $1}')
|
||||
[[ "$actual" == "$expected" ]] || fail "$phase inventory checksum mismatch"
|
||||
pass "$phase inventory contract and checksum"
|
||||
}
|
||||
|
||||
inventory_has_metric() {
|
||||
local inventory=$1
|
||||
local metric=$2
|
||||
jq -e --arg metric "$metric" 'any(.targets[].metrics[]; .name == $metric)' "$inventory" >/dev/null
|
||||
}
|
||||
|
||||
inventory_metric_has_label() {
|
||||
local inventory=$1
|
||||
local metric=$2
|
||||
local label=$3
|
||||
jq -e --arg metric "$metric" --arg label "$label" '
|
||||
any(.targets[].metrics[]; .name == $metric and (.label_names | index($label) != null))
|
||||
' "$inventory" >/dev/null
|
||||
}
|
||||
|
||||
validate_inventory_references() {
|
||||
local dashboard=$1
|
||||
local inventory=$2
|
||||
local expression selector metric matchers matcher label token
|
||||
local inventory_metrics referenced_metrics found
|
||||
|
||||
inventory_metrics=$(jq -r '[.targets[].metrics[].name] | unique[]' "$inventory")
|
||||
|
||||
while IFS= read -r expression; do
|
||||
[[ -n "$expression" ]] || fail "empty PromQL expression in $dashboard"
|
||||
referenced_metrics=''
|
||||
|
||||
while IFS= read -r selector; do
|
||||
[[ -n "$selector" ]] || continue
|
||||
metric=${selector%%\{*}
|
||||
grep -Fxq "$metric" <<<"$inventory_metrics" \
|
||||
|| fail "$dashboard references absent metric $metric"
|
||||
if ! grep -Fxq "$metric" <<<"$referenced_metrics"; then
|
||||
referenced_metrics+="${metric}"$'\n'
|
||||
fi
|
||||
matchers=${selector#*\{}
|
||||
matchers=${matchers%\}}
|
||||
while IFS= read -r matcher; do
|
||||
matcher=${matcher#"${matcher%%[![:space:]]*}"}
|
||||
[[ -n "$matcher" ]] || continue
|
||||
label=$(sed -E 's/^([A-Za-z_][A-Za-z0-9_]*).*/\1/' <<<"$matcher")
|
||||
[[ "$label" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] \
|
||||
|| fail "$dashboard has an unauditable label matcher in $expression"
|
||||
inventory_metric_has_label "$inventory" "$metric" "$label" \
|
||||
|| fail "$dashboard references absent label $metric.$label"
|
||||
done < <(tr ',' '\n' <<<"$matchers")
|
||||
done < <(grep -oE '[A-Za-z_:][A-Za-z0-9_:]*\{[^}]*\}' <<<"$expression" || true)
|
||||
|
||||
[[ -n "$referenced_metrics" ]] || fail "$dashboard expression has no inventory metric: $expression"
|
||||
|
||||
while IFS= read -r token; do
|
||||
[[ -n "$token" ]] || continue
|
||||
if grep -Fxq "$token" <<<"$inventory_metrics"; then
|
||||
grep -Fxq "$token" <<<"$referenced_metrics" \
|
||||
|| fail "$dashboard uses inventory metric $token without an auditable selector"
|
||||
fi
|
||||
done < <(grep -oE '[A-Za-z_:][A-Za-z0-9_:]*' <<<"$expression" | sort -u)
|
||||
|
||||
while IFS= read -r label; do
|
||||
[[ -n "$label" ]] || continue
|
||||
found=0
|
||||
while IFS= read -r metric; do
|
||||
[[ -n "$metric" ]] || continue
|
||||
if inventory_metric_has_label "$inventory" "$metric" "$label"; then
|
||||
found=1
|
||||
break
|
||||
fi
|
||||
done <<<"$referenced_metrics"
|
||||
[[ "$found" -eq 1 ]] || fail "$dashboard groups by absent inventory label $label"
|
||||
done < <(
|
||||
grep -oE '(by|without)[[:space:]]*\([^)]*\)' <<<"$expression" \
|
||||
| sed -E 's/^[^(]*\(([^)]*)\)$/\1/' \
|
||||
| tr ',' '\n' \
|
||||
| sed -E 's/^[[:space:]]+|[[:space:]]+$//g' \
|
||||
|| true
|
||||
)
|
||||
done < <(jq -r '.panels[].targets[].expr' "$dashboard")
|
||||
}
|
||||
|
||||
validate_dashboard() {
|
||||
local file=$1
|
||||
local expected_uid=$2
|
||||
local inventory=$3
|
||||
local path="$DASHBOARD_ROOT/$file"
|
||||
|
||||
[[ -f "$path" ]] || fail "dashboard source is absent: $file"
|
||||
jq -e --arg uid "$expected_uid" '
|
||||
type == "object"
|
||||
and .uid == $uid
|
||||
and (.title | type == "string" and length > 0)
|
||||
and .schemaVersion == 42
|
||||
and .editable == false
|
||||
and .refresh == "30s"
|
||||
and (.templating.list == [])
|
||||
and (.panels | type == "array" and length > 0)
|
||||
and ([.panels[].id] | length == (unique | length))
|
||||
and all(.panels[];
|
||||
.type != "row"
|
||||
and (.title | type == "string" and length > 0)
|
||||
and (.gridPos.w > 0 and .gridPos.h > 0)
|
||||
and .datasource.type == "prometheus"
|
||||
and .datasource.uid == "prometheus"
|
||||
and (.targets | type == "array" and length > 0)
|
||||
and all(.targets[];
|
||||
.datasource.type == "prometheus"
|
||||
and .datasource.uid == "prometheus"
|
||||
and (.expr | type == "string" and length > 0)
|
||||
and (.refId | type == "string" and length > 0)
|
||||
)
|
||||
)
|
||||
' "$path" >/dev/null || fail "dashboard structure is invalid: $file"
|
||||
|
||||
if jq -r '.title, .tags[], .panels[].title' "$path" \
|
||||
| grep -Eiq '(^|[^[:alnum:]_])(spring|jvm|kafka|batch|backup)([^[:alnum:]_]|$)'; then
|
||||
fail "dashboard contains a forbidden product area: $file"
|
||||
fi
|
||||
if jq -r '.panels[].targets[].expr' "$path" \
|
||||
| grep -Eiq '(^|[^[:alnum:]_])(spring_|jvm_|kafka_|batch_|backup_)'; then
|
||||
fail "dashboard contains a forbidden product query: $file"
|
||||
fi
|
||||
if jq -r '.. | strings' "$path" \
|
||||
| grep -Eiq '(request[_ -]?id|trace[_ -]?id|username|raw[_ -]?url)'; then
|
||||
fail "dashboard contains forbidden or high-cardinality content: $file"
|
||||
fi
|
||||
|
||||
validate_inventory_references "$path" "$inventory"
|
||||
pass "$file structure and inventory references"
|
||||
}
|
||||
|
||||
validate_rendered_configmaps() {
|
||||
local rendered=$1
|
||||
local file name block
|
||||
local -a files=(
|
||||
kubernetes-node
|
||||
workload-health
|
||||
platform-services
|
||||
observability-backends
|
||||
https-endpoints
|
||||
)
|
||||
|
||||
kubectl kustomize "$DASHBOARD_ROOT" > "$rendered"
|
||||
[[ $(grep -c '^kind: ConfigMap$' "$rendered") -eq 5 ]] \
|
||||
|| fail 'kustomization must render exactly five ConfigMaps'
|
||||
|
||||
for file in "${files[@]}"; do
|
||||
name="grafana-dashboard-$file"
|
||||
block=$(awk -v name="$name" '
|
||||
BEGIN { RS="---" }
|
||||
$0 ~ "name: " name "([[:space:]]|$)" { print }
|
||||
' "$rendered")
|
||||
[[ -n "$block" ]] || fail "rendered ConfigMap is absent: $name"
|
||||
grep -Eq 'grafana_dashboard: ("1"|1)$' <<<"$block" \
|
||||
|| fail "$name lacks grafana_dashboard=1"
|
||||
grep -q 'observability.hyeonworks.com/instance: home' <<<"$block" \
|
||||
|| fail "$name lacks the observability instance label"
|
||||
grep -q 'observability.hyeonworks.com/owner: platform-observability' <<<"$block" \
|
||||
|| fail "$name lacks the observability owner label"
|
||||
grep -q " $file.json:" <<<"$block" \
|
||||
|| fail "$name does not embed $file.json"
|
||||
done
|
||||
pass 'kustomization renders five stable labeled dashboard ConfigMaps'
|
||||
}
|
||||
|
||||
validate_inventory_driven_omissions() {
|
||||
local workload="$DASHBOARD_ROOT/workload-health.json"
|
||||
local services="$DASHBOARD_ROOT/platform-services.json"
|
||||
local endpoints="$DASHBOARD_ROOT/https-endpoints.json"
|
||||
|
||||
if inventory_has_metric "$TARGET_INVENTORY" kube_pod_container_status_last_terminated_reason; then
|
||||
fail 'the OOM omission contract no longer matches target-initial inventory'
|
||||
fi
|
||||
if jq -r '.panels[].targets[].expr' "$workload" \
|
||||
| grep -Fq 'kube_pod_container_status_last_terminated_reason'; then
|
||||
fail 'workload dashboard guessed an OOM metric absent from target-initial inventory'
|
||||
fi
|
||||
pass 'OOM panel omitted: kube_pod_container_status_last_terminated_reason is absent from target-initial inventory'
|
||||
|
||||
jq -e '
|
||||
([.panels[].targets[].expr] | index("sum(kube_persistentvolumeclaim_status_phase{phase=\"Pending\"})") != null)
|
||||
and
|
||||
([.panels[].targets[].expr] | index("sum(kube_persistentvolumeclaim_status_phase{phase=\"Lost\"})") != null)
|
||||
' "$workload" >/dev/null \
|
||||
|| fail 'PVC health panel must retain zero-valued Pending and Lost series instead of filtering healthy state away'
|
||||
pass 'PVC health panel keeps visible zero-valued Pending and Lost aggregates'
|
||||
|
||||
if jq -e '
|
||||
any(.targets[].metrics[];
|
||||
(.name | startswith("traefik_"))
|
||||
and (.name | endswith("_bucket"))
|
||||
and (.name | test("duration|request"))
|
||||
)
|
||||
' "$TARGET_INVENTORY" >/dev/null; then
|
||||
fail 'the Traefik latency omission contract no longer matches target-initial inventory'
|
||||
fi
|
||||
if jq -r '.panels[].targets[].expr' "$services" \
|
||||
| grep -Eq 'histogram_quantile|traefik_.*_bucket'; then
|
||||
fail 'service dashboard guessed a Traefik latency histogram absent from target-initial inventory'
|
||||
fi
|
||||
pass 'Traefik p95/p99 omitted: no request duration histogram bucket exists in target-initial inventory'
|
||||
|
||||
inventory_has_metric "$POST_INVENTORY" probe_success \
|
||||
|| fail 'post-substrate inventory lacks probe_success for the platform boundary summary'
|
||||
jq -e '
|
||||
any(.panels[].targets[];
|
||||
.expr == "min by (job) (probe_success{})"
|
||||
)
|
||||
' "$services" >/dev/null \
|
||||
|| fail 'platform service dashboard lacks the inventory-backed boundary health summary'
|
||||
pass 'platform service boundary summary uses post-substrate probe_success'
|
||||
|
||||
jq -e '
|
||||
any(.panels[].targets[];
|
||||
.expr == "max by (instance) (probe_http_status_code{job=\"blackbox-private-edge\"})"
|
||||
)
|
||||
' "$endpoints" >/dev/null \
|
||||
|| fail 'HTTPS dashboard does not isolate the observed private-edge 403 boundary'
|
||||
pass 'HTTPS dashboard isolates the observed blackbox-private-edge status boundary'
|
||||
}
|
||||
|
||||
validate_promql_syntax() {
|
||||
local rules=$1
|
||||
local file expression index=0
|
||||
local -a files=(
|
||||
kubernetes-node.json
|
||||
workload-health.json
|
||||
platform-services.json
|
||||
observability-backends.json
|
||||
https-endpoints.json
|
||||
)
|
||||
|
||||
{
|
||||
printf 'groups:\n'
|
||||
printf ' - name: dashboard.promql.syntax\n'
|
||||
printf ' rules:\n'
|
||||
for file in "${files[@]}"; do
|
||||
while IFS= read -r expression; do
|
||||
index=$((index + 1))
|
||||
printf ' - record: dashboard_syntax_%d\n' "$index"
|
||||
printf ' expr: %s\n' "$(jq -Rn --arg expr "$expression" '$expr')"
|
||||
done < <(jq -r '.panels[].targets[].expr' "$DASHBOARD_ROOT/$file")
|
||||
done
|
||||
} > "$rules"
|
||||
|
||||
if [[ -n ${PROMTOOL_BIN:-} ]]; then
|
||||
"$PROMTOOL_BIN" check rules "$rules" >/dev/null
|
||||
else
|
||||
require_command docker
|
||||
docker run --rm \
|
||||
--entrypoint=/bin/promtool \
|
||||
-v "$rules:/tmp/dashboard-rules.yaml:ro" \
|
||||
"$PROMETHEUS_IMAGE" check rules /tmp/dashboard-rules.yaml >/dev/null
|
||||
fi
|
||||
pass "promtool parsed $index dashboard expressions"
|
||||
}
|
||||
|
||||
validate_traefik_low_traffic_ratio() {
|
||||
local rules="$WORK_DIR/traefik-ratio-rules.yaml"
|
||||
local tests="$WORK_DIR/traefik-ratio-tests.yaml"
|
||||
local expression
|
||||
|
||||
expression=$(jq -er '
|
||||
.panels[]
|
||||
| select(.title == "Traefik 5xx Ratio")
|
||||
| .targets[]
|
||||
| select(.refId == "A")
|
||||
| .expr
|
||||
' "$DASHBOARD_ROOT/platform-services.json") \
|
||||
|| fail 'Traefik 5xx ratio expression is absent'
|
||||
|
||||
{
|
||||
printf 'groups:\n'
|
||||
printf ' - name: dashboard.traefik.ratio\n'
|
||||
printf ' interval: 1m\n'
|
||||
printf ' rules:\n'
|
||||
printf ' - record: dashboard_traefik_5xx_ratio_percent\n'
|
||||
printf ' expr: %s\n' "$(jq -Rn --arg expr "$expression" '$expr')"
|
||||
} > "$rules"
|
||||
|
||||
{
|
||||
printf 'rule_files:\n'
|
||||
printf ' - traefik-ratio-rules.yaml\n'
|
||||
printf 'evaluation_interval: 1m\n'
|
||||
printf 'tests:\n'
|
||||
printf ' - interval: 1m\n'
|
||||
printf ' input_series:\n'
|
||||
printf ' - series: '\''traefik_entrypoint_requests_total{code="200",entrypoint="websecure"}'\''\n'
|
||||
printf ' values: '\''0+1x10'\''\n'
|
||||
printf ' - series: '\''traefik_entrypoint_requests_total{code="500",entrypoint="websecure"}'\''\n'
|
||||
printf ' values: '\''0+1x10'\''\n'
|
||||
printf ' promql_expr_test:\n'
|
||||
printf ' - expr: dashboard_traefik_5xx_ratio_percent\n'
|
||||
printf ' eval_time: 10m\n'
|
||||
printf ' exp_samples:\n'
|
||||
printf ' - labels: '\''dashboard_traefik_5xx_ratio_percent{}'\''\n'
|
||||
printf ' value: 50\n'
|
||||
} > "$tests"
|
||||
|
||||
if [[ -n ${PROMTOOL_BIN:-} ]]; then
|
||||
(cd "$WORK_DIR" && "$PROMTOOL_BIN" test rules traefik-ratio-tests.yaml) >/dev/null
|
||||
else
|
||||
require_command docker
|
||||
docker run --rm \
|
||||
--entrypoint=/bin/promtool \
|
||||
-v "$WORK_DIR:/tmp/dashboard-validation:ro" \
|
||||
-w /tmp/dashboard-validation \
|
||||
"$PROMETHEUS_IMAGE" test rules traefik-ratio-tests.yaml >/dev/null
|
||||
fi
|
||||
pass 'Traefik 5xx ratio preserves 50 percent at low request rates'
|
||||
}
|
||||
|
||||
start_prometheus_port_forward() {
|
||||
local log="$WORK_DIR/prometheus-port-forward.log"
|
||||
local port=''
|
||||
local attempt
|
||||
|
||||
kubectl -n observability port-forward \
|
||||
--address=127.0.0.1 \
|
||||
service/observability-core-kube-pr-prometheus \
|
||||
:9090 > "$log" 2>&1 &
|
||||
PORT_FORWARD_PID=$!
|
||||
|
||||
for ((attempt = 1; attempt <= 100; attempt++)); do
|
||||
if grep -Eq '^Forwarding from 127\.0\.0\.1:[0-9]+ -> 9090$' "$log"; then
|
||||
port=$(sed -nE 's/^Forwarding from 127\.0\.0\.1:([0-9]+) -> 9090$/\1/p' "$log" | head -n 1)
|
||||
break
|
||||
fi
|
||||
if ! kill -0 "$PORT_FORWARD_PID" 2>/dev/null; then
|
||||
fail "Prometheus port-forward exited before readiness: $(tr '\n' ' ' < "$log")"
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
[[ "$port" =~ ^[1-9][0-9]*$ ]] \
|
||||
|| fail 'Prometheus port-forward did not bind a loopback port within 10 seconds'
|
||||
PROMETHEUS_BASE_URL="http://127.0.0.1:$port"
|
||||
}
|
||||
|
||||
prometheus_get() {
|
||||
local endpoint=$1
|
||||
shift
|
||||
curl \
|
||||
--fail \
|
||||
--silent \
|
||||
--show-error \
|
||||
--connect-timeout 2 \
|
||||
--max-time 10 \
|
||||
--get \
|
||||
"$PROMETHEUS_BASE_URL$endpoint" \
|
||||
"$@"
|
||||
}
|
||||
|
||||
wait_for_prometheus() {
|
||||
local attempt
|
||||
for ((attempt = 1; attempt <= 50; attempt++)); do
|
||||
if prometheus_get '/-/ready' >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
fail 'Prometheus did not become ready within 5 seconds'
|
||||
}
|
||||
|
||||
live_query_nonempty() {
|
||||
local expression=$1
|
||||
local context=$2
|
||||
local expected_scalar=${3:-}
|
||||
local quiet=${4:-0}
|
||||
local response="$WORK_DIR/live-query-response.json"
|
||||
|
||||
if ! prometheus_get '/api/v1/query' \
|
||||
--data-urlencode "query=$expression" > "$response"; then
|
||||
fail "live Prometheus query request failed: $context"
|
||||
fi
|
||||
jq -e '
|
||||
.status == "success"
|
||||
and (.data.resultType == "vector" or .data.resultType == "scalar")
|
||||
and (.data.result | type == "array" and length > 0)
|
||||
' "$response" >/dev/null \
|
||||
|| fail "live Prometheus query returned no vector/scalar result: $context"
|
||||
|
||||
if [[ -n "$expected_scalar" ]] && ! jq -e --arg expected "$expected_scalar" '
|
||||
.data.resultType == "scalar"
|
||||
and .data.result[1] == $expected
|
||||
' "$response" >/dev/null; then
|
||||
if [[ "$quiet" -eq 1 ]]; then
|
||||
return 1
|
||||
fi
|
||||
fail "live Prometheus scalar result was not exact $expected_scalar: $context"
|
||||
fi
|
||||
}
|
||||
|
||||
validate_scalar_one_gate_regression() {
|
||||
if (live_query_nonempty 'scalar(vector(0))' 'scalar-zero regression fixture' 1 1); then
|
||||
fail 'scalar-zero comparison fixture passed the success gate'
|
||||
fi
|
||||
live_query_nonempty 'scalar(vector(1))' 'scalar-one regression fixture' 1
|
||||
pass 'success comparison gate rejects scalar 0 and accepts exact scalar 1'
|
||||
}
|
||||
|
||||
validate_live_dashboard_queries() {
|
||||
local dashboard uid panel ref expression
|
||||
local count=0
|
||||
|
||||
for dashboard in "$DASHBOARD_ROOT"/*.json; do
|
||||
while IFS=$'\t' read -r uid panel ref expression; do
|
||||
live_query_nonempty "$expression" "$uid / $panel / $ref"
|
||||
count=$((count + 1))
|
||||
done < <(
|
||||
jq -r '
|
||||
.uid as $uid
|
||||
| .panels[]
|
||||
| .title as $panel
|
||||
| .targets[]
|
||||
| [$uid, $panel, .refId, .expr]
|
||||
| @tsv
|
||||
' "$dashboard"
|
||||
)
|
||||
done
|
||||
|
||||
[[ "$count" -eq 37 ]] || fail "expected 37 live dashboard queries, got $count"
|
||||
pass 'live Prometheus accepted 37 dashboard queries with nonempty vector/scalar results'
|
||||
}
|
||||
|
||||
validate_live_matcher_selectors() {
|
||||
local dashboard expression selector
|
||||
local selectors="$WORK_DIR/dashboard-matchers.txt"
|
||||
local count=0
|
||||
|
||||
: > "$selectors"
|
||||
for dashboard in "$DASHBOARD_ROOT"/*.json; do
|
||||
while IFS= read -r expression; do
|
||||
grep -oE '[A-Za-z_:][A-Za-z0-9_:]*\{[^}]+\}' <<<"$expression" >> "$selectors" || true
|
||||
done < <(jq -r '.panels[].targets[].expr' "$dashboard")
|
||||
done
|
||||
sort -u -o "$selectors" "$selectors"
|
||||
|
||||
while IFS= read -r selector; do
|
||||
[[ -n "$selector" ]] || continue
|
||||
live_query_nonempty "$selector" "live matcher selector $selector"
|
||||
count=$((count + 1))
|
||||
done < "$selectors"
|
||||
|
||||
[[ "$count" -gt 0 ]] || fail 'no dashboard matcher selectors were discovered'
|
||||
pass "live Prometheus found series for $count dashboard matcher selectors"
|
||||
}
|
||||
|
||||
validate_live_matcher_values() {
|
||||
local -a presence_contracts=(
|
||||
'node_cpu_seconds_total{mode="idle"}'
|
||||
'node_filesystem_avail_bytes{mountpoint="/"}'
|
||||
'node_filesystem_size_bytes{mountpoint="/"}'
|
||||
'node_filesystem_files_free{mountpoint="/"}'
|
||||
'node_filesystem_files{mountpoint="/"}'
|
||||
'kube_persistentvolumeclaim_status_phase{phase="Pending"}'
|
||||
'kube_persistentvolumeclaim_status_phase{phase="Lost"}'
|
||||
)
|
||||
local -a success_contracts=(
|
||||
'scalar(count(count by (bucket) (minio_cluster_usage_buckets_total_bytes{bucket=~"loki|tempo"}))) == bool 2'
|
||||
'scalar(count(count by (bucket) (minio_cluster_usage_buckets_quota_total_bytes{bucket=~"loki|tempo"}))) == bool 2'
|
||||
'scalar(min(probe_success{job="blackbox-private-edge"})) == bool 1'
|
||||
'scalar(count(probe_http_status_code{job="blackbox-private-edge"})) == bool scalar(count(probe_http_status_code{job="blackbox-private-edge"} == 403))'
|
||||
)
|
||||
local contract
|
||||
|
||||
for contract in "${presence_contracts[@]}"; do
|
||||
live_query_nonempty "$contract" "live matcher-value contract $contract"
|
||||
done
|
||||
for contract in "${success_contracts[@]}"; do
|
||||
live_query_nonempty "$contract" "live matcher-value contract $contract" 1
|
||||
done
|
||||
pass 'live matcher values cover idle CPU, root filesystems, PVC phases, both buckets, and private-edge 403'
|
||||
}
|
||||
|
||||
validate_live_prometheus() {
|
||||
require_command curl
|
||||
if [[ -z "$PROMETHEUS_BASE_URL" ]]; then
|
||||
start_prometheus_port_forward
|
||||
fi
|
||||
wait_for_prometheus
|
||||
validate_scalar_one_gate_regression
|
||||
validate_live_dashboard_queries
|
||||
validate_live_matcher_selectors
|
||||
validate_live_matcher_values
|
||||
}
|
||||
|
||||
main() {
|
||||
local work rendered rules
|
||||
parse_args "$@"
|
||||
require_command jq
|
||||
require_command sha256sum
|
||||
require_command kubectl
|
||||
|
||||
verify_inventory target-initial
|
||||
verify_inventory post-substrate
|
||||
[[ -d "$DASHBOARD_ROOT" ]] || fail 'dashboard source directory is absent'
|
||||
|
||||
validate_dashboard kubernetes-node.json platform-kubernetes-node "$TARGET_INVENTORY"
|
||||
validate_dashboard workload-health.json platform-workload-health "$TARGET_INVENTORY"
|
||||
validate_dashboard platform-services.json platform-services "$POST_INVENTORY"
|
||||
validate_dashboard observability-backends.json platform-observability-backends "$TARGET_INVENTORY"
|
||||
validate_dashboard https-endpoints.json platform-https-endpoints "$POST_INVENTORY"
|
||||
validate_inventory_driven_omissions
|
||||
|
||||
work=$(mktemp -d)
|
||||
WORK_DIR=$work
|
||||
chmod 0755 "$WORK_DIR"
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
rendered="$work/rendered.yaml"
|
||||
rules="$work/dashboard-rules.yaml"
|
||||
validate_rendered_configmaps "$rendered"
|
||||
validate_promql_syntax "$rules"
|
||||
validate_traefik_low_traffic_ratio
|
||||
validate_live_prometheus
|
||||
|
||||
pass 'observability dashboard contract'
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# This closed policy check intentionally reports only labels and token counts.
|
||||
readonly ROOT='/home/donghyeon/workspace/platform'
|
||||
readonly CENTRAL_DOCS='/home/donghyeon/workspace/docs/platform'
|
||||
readonly MASTER="$CENTRAL_DOCS/plans/2026-07-31-observability-phase4.md"
|
||||
readonly ACCESS="$CENTRAL_DOCS/plans/2026-07-31-observability-access-alerting.md"
|
||||
readonly MANUAL="$ROOT/bootstrap/manual/phase4-observability-access.md"
|
||||
readonly WORKLOG="$CENTRAL_DOCS/runbooks/2026-08-13-observability-phase4-resume-worklog.md"
|
||||
readonly HISTORICAL_PLAN="$CENTRAL_DOCS/plans/2026-08-11-gitea-metrics-checkpoint.md"
|
||||
readonly HISTORICAL_DESIGN="$CENTRAL_DOCS/plans/2026-08-11-gitea-metrics-checkpoint-design.md"
|
||||
|
||||
failures=0
|
||||
|
||||
fail() {
|
||||
printf 'FAIL: %s\n' "$1" >&2
|
||||
failures=$((failures + 1))
|
||||
}
|
||||
|
||||
require_file() {
|
||||
[[ -f "$1" && ! -L "$1" ]] || fail "$2 is not a regular file"
|
||||
}
|
||||
|
||||
count_token() {
|
||||
local file=$1 token=$2
|
||||
grep -Foc -- "$token" "$file" || true
|
||||
}
|
||||
|
||||
require_count() {
|
||||
local file=$1 label=$2 token=$3 expected=$4 actual
|
||||
actual="$(count_token "$file" "$token")"
|
||||
[[ "$actual" == "$expected" ]] || fail "$label token_count=$actual expected=$expected"
|
||||
}
|
||||
|
||||
require_hash() {
|
||||
local file=$1 label=$2 expected=$3 actual
|
||||
actual="$(sha256sum -- "$file" | awk '{print $1}')"
|
||||
[[ "$actual" == "$expected" ]] || fail "$label sha256=$actual expected=$expected"
|
||||
}
|
||||
|
||||
require_execution_relationships() {
|
||||
python3 - "$ACCESS" "$MANUAL" <<'PY'
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
access = pathlib.Path(sys.argv[1]).read_text(encoding='utf-8')
|
||||
manual = pathlib.Path(sys.argv[2]).read_text(encoding='utf-8')
|
||||
|
||||
def commands(text):
|
||||
result = []
|
||||
lines = text.splitlines()
|
||||
index = 0
|
||||
while index < len(lines):
|
||||
line = lines[index]
|
||||
if line.lstrip().startswith('bash scripts/bootstrap/create-observability-secrets.sh'):
|
||||
start = index
|
||||
parts = [line.strip()]
|
||||
while parts[-1].rstrip().endswith('\\') and index + 1 < len(lines):
|
||||
index += 1
|
||||
parts.append(lines[index].strip())
|
||||
result.append((start, ' '.join(parts)))
|
||||
index += 1
|
||||
return result
|
||||
|
||||
def slack_execute(items):
|
||||
return [item for item in items if '--execute' in item[1] and '--slack-webhook' in item[1]]
|
||||
|
||||
def checker_after_count(text, command_index):
|
||||
return sum(
|
||||
index > command_index and '--check-slack-deployment-evidence' in line
|
||||
for index, line in enumerate(text.splitlines())
|
||||
)
|
||||
|
||||
failures = 0
|
||||
def require(label, actual, expected):
|
||||
global failures
|
||||
if actual != expected:
|
||||
print(f'FAIL: {label} count={actual} expected={expected}')
|
||||
failures += 1
|
||||
|
||||
hostile = '''padding padding padding padding padding padding padding padding padding padding padding padding
|
||||
--check-slack-deployment-evidence
|
||||
bash scripts/bootstrap/create-observability-secrets.sh --execute --slack-webhook --accept-no-off-host-slack-recovery
|
||||
'''
|
||||
hostile_slack = slack_execute(commands(hostile))
|
||||
require('hostile checker-before-Slack ordering', checker_after_count(hostile, hostile_slack[0][0]), 0)
|
||||
|
||||
step = '- [ ] **Step 3: 실행 순서를 manual 문서에 고정한다**'
|
||||
start = access.find(step)
|
||||
if start < 0:
|
||||
require('authoritative access cutover block', 0, 1)
|
||||
block = ''
|
||||
else:
|
||||
fence_start = access.find(' ```bash', start)
|
||||
fence_end = access.find(' ```', fence_start + len(' ```bash'))
|
||||
block = access[fence_start:fence_end] if fence_start >= 0 and fence_end >= 0 else ''
|
||||
require('authoritative access cutover block', int(bool(block)), 1)
|
||||
|
||||
access_slack = slack_execute(commands(block))
|
||||
require('authoritative access Slack execute command', len(access_slack), 1)
|
||||
if len(access_slack) == 1:
|
||||
require('authoritative access Slack risk flag', int('--accept-no-off-host-slack-recovery' in access_slack[0][1]), 1)
|
||||
|
||||
manual_slack = slack_execute(commands(manual))
|
||||
require('manual Slack execute command', len(manual_slack), 1)
|
||||
if len(manual_slack) == 1:
|
||||
command_index, command = manual_slack[0]
|
||||
require('manual Slack risk flag', int('--accept-no-off-host-slack-recovery' in command), 1)
|
||||
require('manual deployment checker after Slack bootstrap', checker_after_count(manual, command_index), 1)
|
||||
|
||||
sys.exit(1 if failures else 0)
|
||||
PY
|
||||
}
|
||||
|
||||
require_file "$MASTER" master
|
||||
require_file "$ACCESS" access
|
||||
require_file "$MANUAL" manual
|
||||
require_file "$WORKLOG" worklog
|
||||
require_file "$HISTORICAL_PLAN" historical_plan
|
||||
require_file "$HISTORICAL_DESIGN" historical_design
|
||||
|
||||
if (( failures == 0 )); then
|
||||
require_count "$MASTER" 'master deployment gate' '--check-slack-deployment-evidence' 1
|
||||
require_count "$ACCESS" 'access deployment gate' '--check-slack-deployment-evidence' 2
|
||||
require_count "$ACCESS" 'access strict recovery gate' '--check-slack-recovery-evidence' 1
|
||||
require_count "$MANUAL" 'manual deployment gate' '--check-slack-deployment-evidence' 1
|
||||
require_count "$MANUAL" 'manual strict recovery gate' '--check-slack-recovery-evidence' 1
|
||||
require_count "$MASTER" 'master strict DR-only wording' 'off-host Slack DR-complete 판정에만 사용' 1
|
||||
require_count "$ACCESS" 'access strict DR-only wording' 'operational apply를 허용하지 않는다' 1
|
||||
require_count "$MANUAL" 'manual risk-path DR-deferred wording' '이 경로는 DR-complete을 주장하지 않습니다' 1
|
||||
require_count "$MANUAL" 'manual risk flag' '--accept-no-off-host-slack-recovery' 1
|
||||
require_count "$MANUAL" 'manual exact risk confirmation' 'ACCEPT NO OFF-HOST SLACK RECOVERY default' 1
|
||||
require_count "$MANUAL" 'manual kubectl prohibition' 'kubectl로 Secret을 수동 생성하지 않습니다' 1
|
||||
require_count "$MANUAL" 'manual false recovery prohibition' '거짓 `RECOVERY SLACK default` 확인을 입력하지 않습니다' 1
|
||||
require_count "$MANUAL" 'manual two deployment-gate calls' '두 번 호출' 1
|
||||
require_count "$MANUAL" 'manual v2 acceptance schema' 'platform-observability-rules-alerts-v2' 1
|
||||
require_count "$MANUAL" 'manual bare risk ledger value' '`RISK_ACCEPTED`' 1
|
||||
require_count "$MANUAL" 'manual bare recovery ledger value' '`RECOVERY`' 1
|
||||
require_count "$ACCESS" 'access risk flag order' '--accept-no-off-host-slack-recovery' 3
|
||||
require_count "$MASTER" 'master operational acceptance status' 'Phase 4 operational acceptance: complete with active Slack DR exception' 1
|
||||
require_count "$MASTER" 'master deferred DR status' 'Slack off-host disaster recovery: deferred / not complete' 1
|
||||
require_count "$ACCESS" 'access operational acceptance status' 'Phase 4 operational acceptance: complete with active Slack DR exception' 1
|
||||
require_count "$ACCESS" 'access deferred DR status' 'Slack off-host disaster recovery: deferred / not complete' 1
|
||||
require_count "$WORKLOG" 'worklog user decision' 'Slack risk acceptance decision' 1
|
||||
require_count "$WORKLOG" 'worklog KeePass deferred' 'KeePass work remains deferred' 1
|
||||
require_count "$WORKLOG" 'worklog absent Slack Secret' 'alertmanager-slack-webhook Secret remains absent' 1
|
||||
require_hash "$HISTORICAL_PLAN" 'historical plan byte preservation' '10ccc38835928304bd631a60680ecc8e6c1d565d51cbe7047169eb609aa38ce5'
|
||||
require_hash "$HISTORICAL_DESIGN" 'historical design byte preservation' 'a9fd69e0241e5b56c5328076648a8ecf4a6f71d416aae7d53453ce7ff969249d'
|
||||
if ! require_execution_relationships; then
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -d "$ROOT/docs" ]]; then
|
||||
fail 'platform docs boundary token_count=1 expected=0'
|
||||
fi
|
||||
|
||||
if (( failures != 0 )); then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'PASS: observability Slack risk policy token_counts=exact\n'
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly ROOT="$(cd -- "$(dirname -- "$BASH_SOURCE")/../.." && pwd -P)"
|
||||
readonly APPLY="$ROOT/scripts/bootstrap/apply-private-dns.sh"
|
||||
readonly LAN="$ROOT/infrastructure/networking/private-dns/host/dnsmasq-lan.conf"
|
||||
readonly TAIL="$ROOT/infrastructure/networking/private-dns/host/dnsmasq-tailscale.conf"
|
||||
readonly CORE="$ROOT/infrastructure/networking/private-dns/kubernetes/coredns-custom.yaml"
|
||||
readonly BUSYBOX='docker.io/library/busybox:1.37.0@sha256:7a3ebe5bfd1a4a19797d20b0c0bb39d44393e9a03fd852c0865b0f540d868df0'
|
||||
readonly -a HOSTS=(
|
||||
git.learn.hyeonworks.com
|
||||
id.learn.hyeonworks.com
|
||||
storage-admin.learn.hyeonworks.com
|
||||
db-admin.learn.hyeonworks.com
|
||||
grafana.learn.hyeonworks.com
|
||||
)
|
||||
|
||||
fail() {
|
||||
printf 'FAIL: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
pass() {
|
||||
printf 'PASS: %s\n' "$*"
|
||||
}
|
||||
|
||||
for file in "$APPLY" "$LAN" "$TAIL" "$CORE"; do
|
||||
[[ -f "$file" && ! -L "$file" ]] || fail "missing regular source: $file"
|
||||
done
|
||||
bash -n "$APPLY"
|
||||
|
||||
for host in "${HOSTS[@]}"; do
|
||||
grep -Fqx "address=/$host/192.168.0.107" "$LAN" || fail "LAN source lacks $host"
|
||||
grep -Fqx "address=/$host/100.92.240.34" "$TAIL" || fail "Tail source lacks $host"
|
||||
grep -Fq "192.168.0.107 $host" "$CORE" || fail "CoreDNS source lacks $host"
|
||||
done
|
||||
pass 'all five private names are source-bound for LAN, Tail, and Pods'
|
||||
|
||||
grep -Fq 'PUBLIC_PRIVATE_HOSTS=(storage-admin.learn.hyeonworks.com db-admin.learn.hyeonworks.com grafana.learn.hyeonworks.com)' "$APPLY" ||
|
||||
fail 'public A/AAAA absence set is not exact'
|
||||
grep -Fq 'PRIVATE_HOSTS=(git.learn.hyeonworks.com id.learn.hyeonworks.com storage-admin.learn.hyeonworks.com db-admin.learn.hyeonworks.com grafana.learn.hyeonworks.com)' "$APPLY" ||
|
||||
fail 'private resolver verification set is not exact'
|
||||
grep -Fq "readonly BUSYBOX_IMAGE=\"$BUSYBOX\"" "$APPLY" || fail 'BusyBox pin constant is not exact'
|
||||
grep -Fq -- '--image="$BUSYBOX_IMAGE"' "$APPLY" || fail 'Pod smoke does not use the pinned BusyBox constant'
|
||||
for host in "${HOSTS[@]}"; do
|
||||
grep -Fq "nslookup $host" "$APPLY" || fail "Pod smoke omits $host"
|
||||
done
|
||||
pass 'apply source checks public absence and all five resolver paths with pinned BusyBox'
|
||||
|
||||
grep -Fq 'managed_listeners_are_exact' "$APPLY" || fail 'repeat execution has no exact managed-listener classifier'
|
||||
grep -Fq 'lan_was_active=' "$APPLY" || fail 'rollback does not snapshot LAN active state'
|
||||
grep -Fq 'tail_was_active=' "$APPLY" || fail 'rollback does not snapshot Tail active state'
|
||||
grep -Fq 'lan_was_enabled=' "$APPLY" || fail 'rollback does not snapshot LAN enabled state'
|
||||
grep -Fq 'tail_was_enabled=' "$APPLY" || fail 'rollback does not snapshot Tail enabled state'
|
||||
grep -Fq 'restore_service_state' "$APPLY" || fail 'rollback does not restore managed service state'
|
||||
pass 'repeat execution and rollback service-state contracts are present'
|
||||
|
||||
printf 'PRIVATE DNS OBSERVABILITY CONTRACT PASS\n'
|
||||
+1066
File diff suppressed because it is too large
Load Diff
+1093
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,562 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Production-boundary test for the temporary blackbox source proof. Every
|
||||
# external side effect is replaced only below the Kubernetes/root boundaries;
|
||||
# the validator CLI and its production control flow remain real.
|
||||
set -Eeuo pipefail
|
||||
set +x
|
||||
umask 077
|
||||
|
||||
readonly ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd -P)"
|
||||
readonly VALIDATOR="$ROOT/scripts/validate/validate-blackbox-edge-source.sh"
|
||||
|
||||
fail() { printf 'BLACKBOX EDGE SOURCE TEST FAILURE: %s\n' "$*" >&2; exit 1; }
|
||||
assert_eq() { [[ "$1" == "$2" ]] || fail "$3: expected=$1 actual=$2"; }
|
||||
assert_contains() { [[ "$1" == *"$2"* ]] || fail "$3"; }
|
||||
assert_not_contains() { [[ "$1" != *"$2"* ]] || fail "$3"; }
|
||||
assert_no_object_create() {
|
||||
[[ "$1" != *' create --dry-run=server '* && "$1" != *' create -f '* ]] || fail "$2"
|
||||
}
|
||||
assert_no_name_delete() {
|
||||
[[ "$1" != *' delete pod '* && "$1" != *' delete networkpolicy '* ]] || fail "$2"
|
||||
}
|
||||
|
||||
[[ -f "$VALIDATOR" && ! -L "$VALIDATOR" ]] || fail 'production validator is missing (RED: create validate-blackbox-edge-source.sh)'
|
||||
|
||||
# RED 8: production uses / as its rollback anchor. A canonical absolute
|
||||
# descendant of that anchor must remain valid without invoking sudo or any
|
||||
# Kubernetes boundary; non-root fixture-anchor checks are exercised below.
|
||||
production_root_anchor_rc=0
|
||||
PLATFORM_BLACKBOX_EDGE_TEST_MODE=0 bash -c '
|
||||
source "$1"
|
||||
root_run() { "$@"; }
|
||||
validate_root_chain /var/lib /
|
||||
' bash "$VALIDATOR" || production_root_anchor_rc=$?
|
||||
assert_eq 0 "$production_root_anchor_rc" 'production root anchor must accept a canonical descendant'
|
||||
|
||||
fixture_root="$(mktemp -d /tmp/platform-blackbox-edge-test.XXXXXX)"
|
||||
chmod 0700 "$fixture_root"
|
||||
trap 'rm -rf -- "$fixture_root"' EXIT
|
||||
mkdir -p "$fixture_root/bin" "$fixture_root/state" "$fixture_root/active" \
|
||||
"$fixture_root/rollbacks/observability-20260812T000000Z"
|
||||
chmod 0700 "$fixture_root/bin" "$fixture_root/state" "$fixture_root/active" \
|
||||
"$fixture_root/rollbacks" "$fixture_root/rollbacks/observability-20260812T000000Z"
|
||||
cp -- "$ROOT/infrastructure/networking/host-nginx/learn-services-grafana-deny-guard.conf" \
|
||||
"$fixture_root/active/learn-services"
|
||||
chmod 0600 "$fixture_root/active/learn-services"
|
||||
|
||||
cat >"$fixture_root/bin/kubectl-proxy.py" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import socket
|
||||
import sys
|
||||
|
||||
sock_path = pathlib.Path(sys.argv[1])
|
||||
state = pathlib.Path(sys.argv[2])
|
||||
if sock_path.exists() or sock_path.is_symlink():
|
||||
raise SystemExit(91)
|
||||
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
server.bind(str(sock_path))
|
||||
os.chmod(sock_path, 0o600)
|
||||
server.listen(1)
|
||||
connection, _ = server.accept()
|
||||
request = b""
|
||||
while b"\r\n\r\n" not in request:
|
||||
piece = connection.recv(4096)
|
||||
if not piece:
|
||||
break
|
||||
request += piece
|
||||
head, _, body = request.partition(b"\r\n\r\n")
|
||||
lines = head.decode("ascii", "replace").split("\r\n")
|
||||
method, path, _ = lines[0].split(" ", 2)
|
||||
length = 0
|
||||
for line in lines[1:]:
|
||||
if line.lower().startswith("content-length:"):
|
||||
length = int(line.split(":", 1)[1].strip())
|
||||
while len(body) < length:
|
||||
piece = connection.recv(4096)
|
||||
if not piece:
|
||||
break
|
||||
body += piece
|
||||
try:
|
||||
expected = json.loads(body.decode("utf-8"))["preconditions"]["uid"]
|
||||
except Exception:
|
||||
connection.sendall(b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n")
|
||||
connection.close(); server.close(); raise SystemExit(0)
|
||||
kind = "pod" if "/pods/" in path else "networkpolicy" if "/networkpolicies/" in path else ""
|
||||
item_path = state / f"{kind}.json"
|
||||
log = pathlib.Path(os.environ["BB_TEST_PROXY_LOG"])
|
||||
with log.open("a", encoding="utf-8") as stream:
|
||||
stream.write(f"{method} {path} uid={expected}\n")
|
||||
if not kind or not item_path.exists():
|
||||
connection.sendall(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n")
|
||||
elif os.environ.get("BB_TEST_DELETE_REPLACE_KIND") == kind or os.environ.get("BB_TEST_FOREIGN_UID") == "1":
|
||||
item = json.loads(item_path.read_text(encoding="utf-8"))
|
||||
item["metadata"]["uid"] = "uid-external-replacement"
|
||||
item["metadata"]["labels"]["platform.hyeonworks.com/source-proof-run"] = "foreign-run"
|
||||
item_path.write_text(json.dumps(item), encoding="utf-8")
|
||||
connection.sendall(b"HTTP/1.1 409 Conflict\r\nContent-Length: 0\r\n\r\n")
|
||||
elif json.loads(item_path.read_text(encoding="utf-8"))["metadata"]["uid"] != expected:
|
||||
connection.sendall(b"HTTP/1.1 409 Conflict\r\nContent-Length: 0\r\n\r\n")
|
||||
elif os.environ.get("BB_TEST_DELETE_AMBIGUOUS_KIND") == kind:
|
||||
item_path.unlink()
|
||||
connection.close(); server.close(); raise SystemExit(0)
|
||||
elif os.environ.get("BB_TEST_DELETE_READ_AMBIGUOUS_KIND") == kind:
|
||||
(state / f"{kind}.delete-read-ambiguous").touch()
|
||||
connection.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 20\r\nContent-Type: application/json\r\n\r\n{\"status\":\"Success\"}")
|
||||
elif os.environ.get("BB_TEST_DELETE_NEVER_DISAPPEARS_KIND") == kind:
|
||||
connection.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 20\r\nContent-Type: application/json\r\n\r\n{\"status\":\"Success\"}")
|
||||
elif os.environ.get("BB_TEST_DELETE_TERMINATING_KIND") == kind:
|
||||
pending_polls = int(os.environ.get("BB_TEST_DELETE_PENDING_POLLS", "1"))
|
||||
if pending_polls < 1:
|
||||
raise SystemExit(92)
|
||||
(state / f"{kind}.delete-pending").write_text(str(pending_polls), encoding="utf-8")
|
||||
connection.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 20\r\nContent-Type: application/json\r\n\r\n{\"status\":\"Success\"}")
|
||||
else:
|
||||
item_path.unlink()
|
||||
connection.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 20\r\nContent-Type: application/json\r\n\r\n{\"status\":\"Success\"}")
|
||||
connection.close()
|
||||
server.close()
|
||||
PY
|
||||
|
||||
cat >"$fixture_root/bin/kubectl" <<'KUBECTL'
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
log_command() { printf '%q ' "$@" >>"${BB_TEST_LOG:?}"; printf '\n' >>"${BB_TEST_LOG:?}"; }
|
||||
state_file() { printf '%s/%s.json\n' "${BB_TEST_STATE:?}" "$1"; }
|
||||
|
||||
render_object() {
|
||||
local kind=$1 name=$2 run=$3 uid=$4 bad=${5:-} live_defaults=${6:-0} mutate=${7:-} policy_mutate=${8:-}
|
||||
/usr/bin/python3 - "$kind" "$name" "$run" "$uid" "$bad" "$live_defaults" "$mutate" "$policy_mutate" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
kind, name, run, uid, bad, live_defaults, mutate, policy_mutate = sys.argv[1:]
|
||||
labels = {
|
||||
"app.kubernetes.io/managed-by": "platform-blackbox-edge-source",
|
||||
"platform.hyeonworks.com/source-proof-run": run,
|
||||
}
|
||||
metadata = {"namespace": "observability", "name": name, "uid": uid, "labels": labels}
|
||||
if kind == "networkpolicy":
|
||||
spec = {
|
||||
"podSelector": {"matchLabels": {"platform.hyeonworks.com/source-proof-run": run}},
|
||||
"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}]},
|
||||
{"to": [{"ipBlock": {"cidr": "192.168.0.107/32"}}], "ports": [{"protocol": "TCP", "port": 443}]},
|
||||
],
|
||||
}
|
||||
item = {"apiVersion": "networking.k8s.io/v1", "kind": "NetworkPolicy", "metadata": metadata, "spec": spec}
|
||||
else:
|
||||
spec = {
|
||||
"automountServiceAccountToken": False,
|
||||
"restartPolicy": "Never",
|
||||
"securityContext": {"runAsNonRoot": True, "runAsUser": 65534, "runAsGroup": 65534, "seccompProfile": {"type": "RuntimeDefault"}},
|
||||
"containers": [{"name": "probe", "image": "docker.io/library/busybox:1.37.0@sha256:7a3ebe5bfd1a4a19797d20b0c0bb39d44393e9a03fd852c0865b0f540d868df0", "imagePullPolicy": "IfNotPresent", "command": ["sh", "-c", "sleep 120"], "securityContext": {"allowPrivilegeEscalation": False, "readOnlyRootFilesystem": True, "capabilities": {"drop": ["ALL"]}}}],
|
||||
}
|
||||
item = {"apiVersion": "v1", "kind": "Pod", "metadata": metadata, "spec": spec}
|
||||
if live_defaults == "1":
|
||||
spec.update({
|
||||
"dnsPolicy": "ClusterFirst",
|
||||
"enableServiceLinks": True,
|
||||
"preemptionPolicy": "PreemptLowerPriority",
|
||||
"priority": 0,
|
||||
"schedulerName": "default-scheduler",
|
||||
"serviceAccount": "default",
|
||||
"serviceAccountName": "default",
|
||||
"terminationGracePeriodSeconds": 30,
|
||||
"tolerations": [
|
||||
{"effect": "NoExecute", "key": "node.kubernetes.io/not-ready", "operator": "Exists", "tolerationSeconds": 300},
|
||||
{"effect": "NoExecute", "key": "node.kubernetes.io/unreachable", "operator": "Exists", "tolerationSeconds": 300},
|
||||
],
|
||||
})
|
||||
spec["containers"][0].update({
|
||||
"resources": {},
|
||||
"terminationMessagePath": "/dev/termination-log",
|
||||
"terminationMessagePolicy": "File",
|
||||
})
|
||||
metadata.update({"creationTimestamp": "2026-08-12T17:20:51Z", "generation": 1})
|
||||
item["status"] = {"phase": "Pending", "qosClass": "BestEffort"}
|
||||
if mutate == "security":
|
||||
spec["containers"][0]["securityContext"]["privileged"] = True
|
||||
elif mutate == "command":
|
||||
spec["containers"][0]["command"] = ["sh", "-c", "sleep 120; id"]
|
||||
elif mutate == "image":
|
||||
spec["containers"][0]["image"] = "docker.io/library/busybox:latest"
|
||||
if (kind == "pod" and mutate == "label") or (kind == "networkpolicy" and policy_mutate == "label"):
|
||||
labels["platform.hyeonworks.com/unowned"] = "unexpected"
|
||||
if bad == kind:
|
||||
item["metadata"]["labels"]["platform.hyeonworks.com/source-proof-run"] = "foreign-run"
|
||||
print(json.dumps(item))
|
||||
PY
|
||||
}
|
||||
|
||||
read_manifest() {
|
||||
local body kind name run
|
||||
body="$(/bin/cat)"
|
||||
kind="$(printf '%s\n' "$body" | /usr/bin/awk '/^kind: / { print $2; exit }')"
|
||||
name="$(printf '%s\n' "$body" | /usr/bin/awk '/^ name: / { print $2; exit }')"
|
||||
run="$(printf '%s\n' "$body" | /usr/bin/awk '/source-proof-run:/ { gsub(/"/, "", $2); print $2; exit }')"
|
||||
case "$kind" in NetworkPolicy) printf 'networkpolicy|%s|%s\n' "$name" "$run" ;; Pod) printf 'pod|%s|%s\n' "$name" "$run" ;; *) exit 95 ;; esac
|
||||
}
|
||||
|
||||
log_command "$@"
|
||||
args=" $* "
|
||||
if [[ "$args" == *' config current-context '* ]]; then printf 'fixture-context\n'; exit 0; fi
|
||||
if [[ "$args" == *' get --raw=/readyz '* ]]; then printf 'ok\n'; exit 0; fi
|
||||
if [[ "$args" == *' auth can-i '* ]]; then printf '%s\n' "${BB_TEST_AUTH:-yes}"; exit 0; fi
|
||||
|
||||
if [[ "$args" == *' create '* ]]; then
|
||||
IFS='|' read -r kind name run <<<"$(read_manifest)"
|
||||
mode=create
|
||||
[[ "$args" == *' --dry-run=server '* ]] && mode=dry-run
|
||||
printf '%s %s\n' "$kind" "$mode" >>"${BB_TEST_CREATE_LOG:?}"
|
||||
uid="uid-${kind}-${run}"
|
||||
bad="${BB_TEST_BAD_SPEC_KIND:-}"
|
||||
if [[ "$mode" == dry-run ]]; then render_object "$kind" "$name" "$run" dry-run "$bad" "${BB_TEST_LIVE_DEFAULTED_POD_DRY_RUN:-0}" "${BB_TEST_MUTATE_POD_FIELD:-}" "${BB_TEST_MUTATE_NETWORKPOLICY_FIELD:-}"; exit 0; fi
|
||||
path="$(state_file "$kind")"
|
||||
[[ ! -e "$path" && ! -L "$path" ]] || exit 1
|
||||
render_object "$kind" "$name" "$run" "$uid" "$bad" >"$path"
|
||||
if [[ "${BB_TEST_CREATE_AMBIGUOUS_KIND:-}" == "$kind" || "${BB_TEST_CREATE_FAILURE_KIND:-}" == "$kind" ]]; then exit 1; fi
|
||||
if [[ "${BB_TEST_BAD_RESPONSE_KIND:-}" == "$kind" ]]; then
|
||||
render_object "$kind" "$name" "$run" "$uid" "$kind"
|
||||
exit 0
|
||||
fi
|
||||
/bin/cat -- "$path"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$args" == *' get pod '* || "$args" == *' get networkpolicy '* ]]; then
|
||||
if [[ "$args" =~ get\ (pod|networkpolicy)\ ([^[:space:]]+) ]]; then kind=${BASH_REMATCH[1]}; name=${BASH_REMATCH[2]}; else exit 96; fi
|
||||
path="$(state_file "$kind")"
|
||||
[[ ! -e "${BB_TEST_STATE:?}/${kind}.delete-read-ambiguous" ]] || exit 1
|
||||
if [[ -e "$path" && ! -L "$path" ]]; then
|
||||
if [[ "${BB_TEST_FOREIGN_UID:-0}" == 1 ]]; then
|
||||
/usr/bin/python3 - "$path" <<'PY'
|
||||
import json, pathlib, sys
|
||||
p = pathlib.Path(sys.argv[1]); item = json.loads(p.read_text()); item["metadata"]["uid"] = "foreign-uid"; item["metadata"]["labels"]["platform.hyeonworks.com/source-proof-run"] = "foreign-run"; p.write_text(json.dumps(item))
|
||||
PY
|
||||
fi
|
||||
if [[ -e "${BB_TEST_STATE:?}/${kind}.delete-pending" ]]; then
|
||||
remaining="$(<"${BB_TEST_STATE:?}/${kind}.delete-pending")"
|
||||
[[ "$remaining" =~ ^[1-9][0-9]*$ ]] || exit 99
|
||||
remaining=$((remaining - 1))
|
||||
/bin/cat -- "$path"
|
||||
if (( remaining == 0 )); then
|
||||
/bin/rm -f -- "${BB_TEST_STATE:?}/${kind}.delete-pending" "$path"
|
||||
else
|
||||
printf '%s\n' "$remaining" >"${BB_TEST_STATE:?}/${kind}.delete-pending"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
/bin/cat -- "$path"; exit 0
|
||||
fi
|
||||
[[ "$args" == *'--ignore-not-found'* ]] && exit 0
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$args" == *' wait --for=condition=Ready '* && "$args" == *' pod/'* ]]; then
|
||||
if [[ -n "${BB_TEST_SCHEDULED_POD_NODE:-}" && -e "$(state_file pod)" ]]; then
|
||||
/usr/bin/python3 - "$(state_file pod)" "${BB_TEST_SCHEDULED_POD_NODE}" <<'PY'
|
||||
import json, pathlib, sys
|
||||
p = pathlib.Path(sys.argv[1]); item = json.loads(p.read_text()); item["spec"]["nodeName"] = sys.argv[2]; p.write_text(json.dumps(item))
|
||||
PY
|
||||
fi
|
||||
[[ -e "$(state_file pod)" ]]; exit $?
|
||||
fi
|
||||
if [[ "$args" == *' exec '* ]]; then
|
||||
[[ "${BB_TEST_EXEC_SLEEP:-0}" == 1 ]] && /usr/bin/sleep 20
|
||||
case "${BB_TEST_CLIENT_STATUS:-403}" in
|
||||
403) printf 'HTTP/1.1 403 Forbidden\n' >&2 ;;
|
||||
200) printf 'HTTP/1.1 200 OK\n' >&2 ;;
|
||||
302) printf 'HTTP/1.1 302 Found\n' >&2 ;;
|
||||
*) printf 'HTTP/1.1 500 Error\n' >&2 ;;
|
||||
esac
|
||||
exit "${BB_TEST_CLIENT_RC:-1}"
|
||||
fi
|
||||
if [[ "$args" == *' proxy '* ]]; then
|
||||
socket=''
|
||||
for argument in "$@"; do [[ "$argument" == --unix-socket=* ]] && socket=${argument#--unix-socket=}; done
|
||||
[[ "$socket" == /tmp/platform-blackbox-edge-source.*/*.sock ]] || exit 97
|
||||
exec /usr/bin/python3 "${BB_TEST_PROXY_HELPER:?}" "$socket" "${BB_TEST_STATE:?}"
|
||||
fi
|
||||
if [[ "$args" == *' delete '* || "$args" == *' apply '* || "$args" == *' replace '* || "$args" == *' patch '* ]]; then exit 98; fi
|
||||
exit 0
|
||||
KUBECTL
|
||||
|
||||
cat >"$fixture_root/bin/sudo" <<'SUDO'
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
while [[ "${1:-}" == -n || "${1:-}" == -- ]]; do shift; done
|
||||
command_path=${1:-}
|
||||
[[ "$command_path" == /* ]] || { printf 'non-absolute sudo child: %s\n' "$command_path" >&2; exit 97; }
|
||||
printf '%q ' "$@" >>"${BB_TEST_ROOT_LOG:?}"; printf '\n' >>"${BB_TEST_ROOT_LOG:?}"
|
||||
if [[ "$command_path" == /usr/bin/awk ]]; then
|
||||
printf '%q ' "$@" >>"${BB_TEST_SUDO_LOG:?}"; printf '\n' >>"${BB_TEST_SUDO_LOG:?}"
|
||||
case "${BB_TEST_LOG_MATCHES:-1}" in
|
||||
0) exit 0 ;;
|
||||
1) printf '10.42.0.55 403\n10.42.0.55 403\n10.42.0.55 403\n' ;;
|
||||
2) printf '10.42.0.55 403\n10.42.0.55 403\n10.42.0.55 403\n10.42.0.55 403\n' ;;
|
||||
*) exit 98 ;;
|
||||
esac
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$command_path" == /usr/bin/install ]]; then
|
||||
shift
|
||||
filtered=()
|
||||
while (( $# )); do case "$1" in -o|-g) shift 2 ;; *) filtered+=("$1"); shift ;; esac; done
|
||||
exec /usr/bin/install "${filtered[@]}"
|
||||
fi
|
||||
if [[ "$command_path" == /bin/ln && "${BB_TEST_PUBLISH_RACE:-0}" == 1 ]]; then
|
||||
/bin/mkdir -p -- "${BB_TEST_PROOF:?}.race-dir"
|
||||
/bin/ln -s -- "${BB_TEST_PROOF}.race-dir" "${BB_TEST_PROOF:?}"
|
||||
fi
|
||||
exec "$@"
|
||||
SUDO
|
||||
|
||||
cat >"$fixture_root/bin/date" <<'DATE'
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
if [[ ( "${BB_TEST_POST_PROMPT_ACTIVE_DRIFT:-0}" == 1 || "${BB_TEST_POST_PROMPT_PROOF_APPEARS:-0}" == 1 ) && ! -e "${BB_TEST_POST_PROMPT_MARKER:?}" ]]; then
|
||||
: >"${BB_TEST_POST_PROMPT_MARKER:?}"
|
||||
[[ "${BB_TEST_POST_PROMPT_ACTIVE_DRIFT:-0}" == 1 ]] && printf 'active drift\n' >"${BB_TEST_ACTIVE:?}"
|
||||
[[ "${BB_TEST_POST_PROMPT_PROOF_APPEARS:-0}" == 1 ]] && printf 'unsafe existing proof\n' >"${BB_TEST_PROOF:?}"
|
||||
fi
|
||||
if [[ "$*" == *'+%Y-%m-%dT%H:%M:%SZ'* ]]; then printf '2026-08-12T00:00:00Z\n'; else /usr/bin/date "$@"; fi
|
||||
DATE
|
||||
|
||||
cat >"$fixture_root/bin/sleep" <<'SLEEP'
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
[[ "$#" == 1 && "$1" == 0.05 ]] || exit 97
|
||||
exec /usr/bin/sleep 0.001
|
||||
SLEEP
|
||||
|
||||
chmod 0700 "$fixture_root/bin"/*
|
||||
|
||||
proof="$fixture_root/rollbacks/observability-20260812T000000Z/blackbox-source-proof.env"
|
||||
|
||||
run_validator() {
|
||||
local output_file="$fixture_root/out" rc=0
|
||||
/bin/rm -f -- "$fixture_root/state"/* "$fixture_root/commands.log" "$fixture_root/creates.log" \
|
||||
"$fixture_root/deletes.log" "$fixture_root/sudo.log" "$fixture_root/root.log" \
|
||||
"$fixture_root/proxy.log" "$fixture_root/post-prompt"
|
||||
: >"$fixture_root/commands.log"; : >"$fixture_root/creates.log"; : >"$fixture_root/deletes.log"
|
||||
: >"$fixture_root/sudo.log"; : >"$fixture_root/root.log"; : >"$fixture_root/proxy.log"
|
||||
PLATFORM_BLACKBOX_EDGE_TEST_MODE=1 \
|
||||
PLATFORM_BLACKBOX_EDGE_KUBECTL="$fixture_root/bin/kubectl" \
|
||||
PLATFORM_BLACKBOX_EDGE_SUDO="$fixture_root/bin/sudo" \
|
||||
PLATFORM_BLACKBOX_EDGE_DATE="$fixture_root/bin/date" \
|
||||
PLATFORM_BLACKBOX_EDGE_SLEEP="$fixture_root/bin/sleep" \
|
||||
PLATFORM_BLACKBOX_EDGE_ACTIVE="$fixture_root/active/learn-services" \
|
||||
PLATFORM_BLACKBOX_EDGE_ROLLBACK_BASE="$fixture_root/rollbacks" \
|
||||
PLATFORM_BLACKBOX_EDGE_CONFIRMATION='PROVE BLACKBOX PRIVATE EDGE fixture-context' \
|
||||
PLATFORM_OBSERVABILITY_ROLLBACK_ID=20260812T000000Z \
|
||||
BB_TEST_LOG="$fixture_root/commands.log" BB_TEST_CREATE_LOG="$fixture_root/creates.log" \
|
||||
BB_TEST_DELETE_LOG="$fixture_root/deletes.log" BB_TEST_SUDO_LOG="$fixture_root/sudo.log" \
|
||||
BB_TEST_ROOT_LOG="$fixture_root/root.log" BB_TEST_PROXY_LOG="$fixture_root/proxy.log" \
|
||||
BB_TEST_PROXY_HELPER="$fixture_root/bin/kubectl-proxy.py" BB_TEST_STATE="$fixture_root/state" \
|
||||
BB_TEST_TMP="$fixture_root/state" BB_TEST_ACTIVE="$fixture_root/active/learn-services" \
|
||||
BB_TEST_PROOF="$proof" BB_TEST_POST_PROMPT_MARKER="$fixture_root/post-prompt" \
|
||||
BB_TEST_SCHEDULED_POD_NODE="${BB_TEST_SCHEDULED_POD_NODE:-}" \
|
||||
BB_TEST_DELETE_TERMINATING_KIND="${BB_TEST_DELETE_TERMINATING_KIND:-}" \
|
||||
BB_TEST_DELETE_PENDING_POLLS="${BB_TEST_DELETE_PENDING_POLLS:-1}" \
|
||||
BB_TEST_DELETE_READ_AMBIGUOUS_KIND="${BB_TEST_DELETE_READ_AMBIGUOUS_KIND:-}" \
|
||||
BB_TEST_DELETE_NEVER_DISAPPEARS_KIND="${BB_TEST_DELETE_NEVER_DISAPPEARS_KIND:-}" \
|
||||
bash "$VALIDATOR" --execute --context fixture-context >"$output_file" 2>&1 || rc=$?
|
||||
RUN_OUTPUT="$(<"$output_file")"; RUN_RC=$rc
|
||||
}
|
||||
|
||||
# Production must reject escape hatches before a dry-run can contact a fake.
|
||||
override_rc=0
|
||||
PLATFORM_BLACKBOX_EDGE_ACTIVE=/tmp/override bash "$VALIDATOR" >"$fixture_root/override.out" 2>&1 || override_rc=$?
|
||||
assert_eq 1 "$override_rc" 'production override boundary'
|
||||
|
||||
# Dry-run is read-only and contains neither token nor log payload.
|
||||
: >"$fixture_root/commands.log"
|
||||
dry_output="$(PLATFORM_BLACKBOX_EDGE_TEST_MODE=1 PLATFORM_BLACKBOX_EDGE_KUBECTL="$fixture_root/bin/kubectl" PLATFORM_BLACKBOX_EDGE_SUDO="$fixture_root/bin/sudo" PLATFORM_BLACKBOX_EDGE_DATE="$fixture_root/bin/date" PLATFORM_BLACKBOX_EDGE_SLEEP="$fixture_root/bin/sleep" PLATFORM_BLACKBOX_EDGE_ACTIVE="$fixture_root/active/learn-services" PLATFORM_BLACKBOX_EDGE_ROLLBACK_BASE="$fixture_root/rollbacks" BB_TEST_LOG="$fixture_root/commands.log" bash "$VALIDATOR")"
|
||||
assert_contains "$dry_output" 'BLACKBOX_PRIVATE_EDGE_SOURCE_DRY_RUN=PASS' 'dry-run result label'
|
||||
[[ ! -s "$fixture_root/commands.log" ]] || fail 'dry-run contacted Kubernetes'
|
||||
assert_not_contains "$dry_output" 'hyeonworks_probe=' 'dry-run disclosed a token'
|
||||
|
||||
# Bad active SHA and authorization fail before any create.
|
||||
printf 'wrong active source\n' >"$fixture_root/active/learn-services"
|
||||
run_validator
|
||||
assert_eq 1 "$RUN_RC" 'wrong active guard must use fixed failure status'
|
||||
assert_no_object_create "$(<"$fixture_root/commands.log")" 'wrong active guard created an object'
|
||||
cp -- "$ROOT/infrastructure/networking/host-nginx/learn-services-grafana-deny-guard.conf" "$fixture_root/active/learn-services"
|
||||
BB_TEST_AUTH=no run_validator
|
||||
assert_eq 1 "$RUN_RC" 'authorization denial must use fixed failure status'
|
||||
assert_no_object_create "$(<"$fixture_root/commands.log")" 'authorization denial created an object'
|
||||
|
||||
# RED 1: BusyBox wget returns 1 for HTTP 403. The pinned client must pass only
|
||||
# for exactly that result and an exact final parsed 403—not GNU wget's rc 8.
|
||||
# RED 1a: Kubernetes server-side Pod dry-run adds its default fields. They must
|
||||
# not make the validator reject an otherwise exact restricted probe Pod.
|
||||
BB_TEST_LIVE_DEFAULTED_POD_DRY_RUN=1 BB_TEST_CLIENT_STATUS=403 BB_TEST_CLIENT_RC=1 run_validator
|
||||
assert_eq 0 "$RUN_RC" 'live-defaulted Pod server dry-run must pass'
|
||||
/bin/rm -f -- "$proof"
|
||||
BB_TEST_MUTATE_NETWORKPOLICY_FIELD=label run_validator
|
||||
assert_eq 1 "$RUN_RC" 'NetworkPolicy extra metadata label must fail closed'
|
||||
unset BB_TEST_MUTATE_NETWORKPOLICY_FIELD
|
||||
for mutated_field in label security command image; do
|
||||
BB_TEST_LIVE_DEFAULTED_POD_DRY_RUN=1 BB_TEST_MUTATE_POD_FIELD="$mutated_field" run_validator
|
||||
assert_eq 1 "$RUN_RC" "live-defaulted Pod $mutated_field mutation must fail closed"
|
||||
done
|
||||
unset BB_TEST_LIVE_DEFAULTED_POD_DRY_RUN BB_TEST_MUTATE_POD_FIELD
|
||||
BB_TEST_CLIENT_STATUS=403 BB_TEST_CLIENT_RC=1 run_validator
|
||||
assert_eq 0 "$RUN_RC" 'BusyBox 403 exit 1 with final parsed 403 must pass'
|
||||
assert_contains "$RUN_OUTPUT" 'BLACKBOX PRIVATE EDGE SOURCE PASS' 'BusyBox success label'
|
||||
assert_not_contains "$RUN_OUTPUT" 'hyeonworks_probe=' 'BusyBox success disclosed token'
|
||||
assert_eq 1 "$(wc -l <"$fixture_root/sudo.log")" 'exactly one sudo awk invocation'
|
||||
assert_eq $'networkpolicy dry-run\nnetworkpolicy create\npod dry-run\npod create' "$(<"$fixture_root/creates.log")" 'create-only safe order'
|
||||
assert_contains "$(<"$fixture_root/commands.log")" ' proxy ' 'cleanup must use bounded raw-delete proxy'
|
||||
assert_no_name_delete "$(<"$fixture_root/commands.log")" 'cleanup must never name-delete'
|
||||
assert_contains "$(<"$fixture_root/root.log")" '/usr/bin/mktemp' 'proof must stage under root mktemp'
|
||||
assert_contains "$(<"$fixture_root/root.log")" "--tmpdir=$fixture_root/rollbacks/observability-20260812T000000Z" 'proof staging directory'
|
||||
assert_contains "$(<"$fixture_root/root.log")" '/bin/ln -nT --' 'proof publication must not dereference a raced destination'
|
||||
assert_eq $'schema=platform-blackbox-source-v1\nrollback_id=20260812T000000Z\nnginx_sha256='"$(sha256sum "$fixture_root/active/learn-services" | awk '{print $1}')"$'\ntested_at_utc=2026-08-12T00:00:00Z\ngrafana_remote_addr=10.42.0.55\ngrafana_status=403\nstorage_admin_remote_addr=10.42.0.55\nstorage_admin_status=403\ndb_admin_remote_addr=10.42.0.55\ndb_admin_status=403' "$(<"$proof")" 'exact proof schema and order'
|
||||
[[ "$(stat -c %a "$proof")" == 600 && "$(stat -c %h "$proof")" == 1 ]] || fail 'proof must be unlinked 0600 evidence'
|
||||
/bin/rm -f -- "$proof"
|
||||
|
||||
BB_TEST_CLIENT_STATUS=403 BB_TEST_CLIENT_RC=0 run_validator
|
||||
assert_eq 1 "$RUN_RC" '403 with non-BusyBox success rc must fail'
|
||||
BB_TEST_CLIENT_STATUS=403 BB_TEST_CLIENT_RC=8 run_validator
|
||||
assert_eq 1 "$RUN_RC" '403 with GNU-wget rc must fail'
|
||||
BB_TEST_CLIENT_STATUS=200 BB_TEST_CLIENT_RC=1 run_validator
|
||||
assert_eq 1 "$RUN_RC" '200 source masquerade must fail'
|
||||
assert_not_contains "$RUN_OUTPUT" 'hyeonworks_probe=' 'masquerade failure disclosed token'
|
||||
|
||||
# RED 2: values that drift after confirmation but before the first create are
|
||||
# rejected by a repeated commit gate.
|
||||
BB_TEST_POST_PROMPT_ACTIVE_DRIFT=1 run_validator
|
||||
assert_eq 1 "$RUN_RC" 'post-prompt active SHA drift must fail'
|
||||
assert_no_object_create "$(<"$fixture_root/commands.log")" 'active drift reached create'
|
||||
cp -- "$ROOT/infrastructure/networking/host-nginx/learn-services-grafana-deny-guard.conf" "$fixture_root/active/learn-services"
|
||||
/bin/rm -f -- "$proof"
|
||||
BB_TEST_POST_PROMPT_PROOF_APPEARS=1 run_validator
|
||||
assert_eq 1 "$RUN_RC" 'post-prompt proof appearance must fail'
|
||||
assert_no_object_create "$(<"$fixture_root/commands.log")" 'proof appearance reached create'
|
||||
/bin/rm -f -- "$proof"
|
||||
|
||||
# RED 3/4: a nonzero create may have committed. Exact owned state is cleaned
|
||||
# through UID-preconditioned raw DELETE; never apply/update or name-delete.
|
||||
BB_TEST_CREATE_AMBIGUOUS_KIND=networkpolicy run_validator
|
||||
assert_eq 1 "$RUN_RC" 'ambiguous NetworkPolicy create must fail closed'
|
||||
[[ ! -e "$fixture_root/state/networkpolicy.json" && ! -e "$fixture_root/state/pod.json" ]] || fail 'owned ambiguous NetworkPolicy was not cleaned'
|
||||
assert_contains "$(<"$fixture_root/proxy.log")" '/networkpolicies/' 'NetworkPolicy raw UID delete'
|
||||
BB_TEST_CREATE_AMBIGUOUS_KIND=pod run_validator
|
||||
assert_eq 1 "$RUN_RC" 'ambiguous Pod create must fail closed'
|
||||
[[ ! -e "$fixture_root/state/networkpolicy.json" && ! -e "$fixture_root/state/pod.json" ]] || fail 'owned partial create was not cleaned'
|
||||
assert_contains "$(<"$fixture_root/proxy.log")" '/networkpolicies/' 'partial cleanup NetworkPolicy raw UID delete'
|
||||
assert_contains "$(<"$fixture_root/proxy.log")" '/pods/' 'partial cleanup Pod raw UID delete'
|
||||
BB_TEST_BAD_RESPONSE_KIND=pod run_validator
|
||||
assert_eq 1 "$RUN_RC" 'malformed successful Pod create response must reclassify and fail closed'
|
||||
[[ ! -e "$fixture_root/state/networkpolicy.json" && ! -e "$fixture_root/state/pod.json" ]] || fail 'owned malformed-response objects were not cleaned'
|
||||
|
||||
# RED 4a: catch a cleanup wait that gives up while a normally terminating,
|
||||
# exact-owned scheduled Pod remains present beyond 640 cleanup observations,
|
||||
# then disappears while still requiring two consecutive absence reads.
|
||||
export BB_TEST_SCHEDULED_POD_NODE=donghyeon-system-product-name \
|
||||
BB_TEST_DELETE_TERMINATING_KIND=pod BB_TEST_DELETE_PENDING_POLLS=700
|
||||
run_validator
|
||||
assert_eq 0 "$RUN_RC" 'expected scheduled Pod node during terminating cleanup must pass'
|
||||
/bin/rm -f -- "$proof"
|
||||
export BB_TEST_SCHEDULED_POD_NODE=foreign-node BB_TEST_DELETE_TERMINATING_KIND=pod \
|
||||
BB_TEST_DELETE_PENDING_POLLS=2
|
||||
run_validator
|
||||
assert_eq 1 "$RUN_RC" 'foreign scheduled Pod node must fail closed'
|
||||
assert_not_contains "$RUN_OUTPUT" 'BLACKBOX PRIVATE EDGE SOURCE PASS' 'foreign scheduled Pod node became proof-command PASS'
|
||||
[[ -e "$fixture_root/state/pod.json" ]] || fail 'foreign scheduled Pod disappeared'
|
||||
unset BB_TEST_SCHEDULED_POD_NODE BB_TEST_DELETE_TERMINATING_KIND BB_TEST_DELETE_PENDING_POLLS
|
||||
/bin/rm -f -- "$proof"
|
||||
|
||||
# Cleanup GET ambiguity must fail closed with the exact-owned object retained.
|
||||
BB_TEST_DELETE_READ_AMBIGUOUS_KIND=pod run_validator
|
||||
assert_eq 1 "$RUN_RC" 'cleanup API/read ambiguity must fail closed'
|
||||
assert_not_contains "$RUN_OUTPUT" 'BLACKBOX PRIVATE EDGE SOURCE PASS' 'cleanup API/read ambiguity became proof-command PASS'
|
||||
[[ -e "$fixture_root/state/pod.json" ]] || fail 'read-ambiguous exact-owned Pod disappeared'
|
||||
/bin/rm -f -- "$proof"
|
||||
|
||||
# An exact-owned object that never disappears must exhaust the bounded wait,
|
||||
# fail cleanup, and never become a proof-command PASS.
|
||||
BB_TEST_DELETE_NEVER_DISAPPEARS_KIND=pod run_validator
|
||||
assert_eq 1 "$RUN_RC" 'never-disappearing exact-owned Pod must fail bounded cleanup'
|
||||
assert_not_contains "$RUN_OUTPUT" 'BLACKBOX PRIVATE EDGE SOURCE PASS' 'never-disappearing exact-owned Pod became proof-command PASS'
|
||||
[[ -e "$fixture_root/state/pod.json" ]] || fail 'never-disappearing exact-owned Pod disappeared'
|
||||
/bin/rm -f -- "$proof"
|
||||
|
||||
# A transport-ambiguous raw delete is accepted only after bounded stable
|
||||
# absence; a UID/label replacement remains untouched and fails safely.
|
||||
BB_TEST_DELETE_AMBIGUOUS_KIND=pod run_validator
|
||||
assert_eq 0 "$RUN_RC" 'ambiguous raw delete with stable absence must pass'
|
||||
/bin/rm -f -- "$proof"
|
||||
BB_TEST_DELETE_REPLACE_KIND=pod run_validator
|
||||
assert_eq 1 "$RUN_RC" 'replacement during raw delete must fail safely'
|
||||
[[ -e "$fixture_root/state/pod.json" ]] || fail 'replacement was deleted'
|
||||
/bin/rm -f -- "$proof" "$fixture_root/state/pod.json" "$fixture_root/state/networkpolicy.json"
|
||||
|
||||
# RED 5: a proof target that becomes a directory symlink only at publication
|
||||
# must fail without allowing ln to create a hard link below that foreign path.
|
||||
BB_TEST_PUBLISH_RACE=1 run_validator
|
||||
assert_eq 1 "$RUN_RC" 'publication symlink race must fail safely'
|
||||
[[ -L "$proof" ]] || fail 'publication race did not preserve the unsafe target'
|
||||
[[ ! -e "$proof.race-dir"/* && ! -L "$proof.race-dir"/* ]] || fail 'publication followed a raced destination symlink'
|
||||
/bin/rm -f -- "$proof"; /bin/rm -rf -- "$proof.race-dir"
|
||||
|
||||
# Existing proof symlink/hardlink and any rollback-root symlink ancestor are
|
||||
# unsafe. The validator must leave all temporary objects uncreated.
|
||||
ln -s /dev/null "$proof"
|
||||
run_validator
|
||||
assert_eq 1 "$RUN_RC" 'symlink proof target must fail safely'
|
||||
/bin/rm -f -- "$proof"; printf 'existing\n' >"$proof"; ln "$proof" "$proof.link"
|
||||
run_validator
|
||||
assert_eq 1 "$RUN_RC" 'hard-linked proof target must fail safely'
|
||||
/bin/rm -f -- "$proof" "$proof.link"
|
||||
/bin/mv -- "$fixture_root/rollbacks" "$fixture_root/rollbacks-real"
|
||||
/bin/ln -s -- "$fixture_root/rollbacks-real" "$fixture_root/rollbacks"
|
||||
run_validator
|
||||
assert_eq 1 "$RUN_RC" 'rollback root symlink ancestor must fail safely'
|
||||
assert_no_object_create "$(<"$fixture_root/commands.log")" 'symlink ancestor reached create'
|
||||
/bin/rm -f -- "$fixture_root/rollbacks"; /bin/mv -- "$fixture_root/rollbacks-real" "$fixture_root/rollbacks"
|
||||
|
||||
# A foreign object is never deleted, including on a signal path.
|
||||
BB_TEST_FOREIGN_UID=1 run_validator
|
||||
assert_eq 1 "$RUN_RC" 'foreign UID must fail safely'
|
||||
assert_not_contains "$RUN_OUTPUT" 'BLACKBOX PRIVATE EDGE SOURCE PASS' 'foreign UID became proof-command PASS'
|
||||
[[ -e "$fixture_root/state/networkpolicy.json" || -e "$fixture_root/state/pod.json" ]] || fail 'foreign object disappeared'
|
||||
/bin/rm -f -- "$fixture_root/state"/*.json "$proof"
|
||||
|
||||
: >"$fixture_root/commands.log"; : >"$fixture_root/creates.log"; : >"$fixture_root/root.log"; : >"$fixture_root/proxy.log"
|
||||
setsid env \
|
||||
PLATFORM_BLACKBOX_EDGE_TEST_MODE=1 \
|
||||
PLATFORM_BLACKBOX_EDGE_KUBECTL="$fixture_root/bin/kubectl" \
|
||||
PLATFORM_BLACKBOX_EDGE_SUDO="$fixture_root/bin/sudo" \
|
||||
PLATFORM_BLACKBOX_EDGE_DATE="$fixture_root/bin/date" \
|
||||
PLATFORM_BLACKBOX_EDGE_SLEEP="$fixture_root/bin/sleep" \
|
||||
PLATFORM_BLACKBOX_EDGE_ACTIVE="$fixture_root/active/learn-services" \
|
||||
PLATFORM_BLACKBOX_EDGE_ROLLBACK_BASE="$fixture_root/rollbacks" \
|
||||
PLATFORM_BLACKBOX_EDGE_CONFIRMATION='PROVE BLACKBOX PRIVATE EDGE fixture-context' \
|
||||
PLATFORM_OBSERVABILITY_ROLLBACK_ID=20260812T000000Z \
|
||||
BB_TEST_LOG="$fixture_root/commands.log" BB_TEST_CREATE_LOG="$fixture_root/creates.log" \
|
||||
BB_TEST_DELETE_LOG="$fixture_root/deletes.log" BB_TEST_SUDO_LOG="$fixture_root/sudo.log" \
|
||||
BB_TEST_ROOT_LOG="$fixture_root/root.log" BB_TEST_PROXY_LOG="$fixture_root/proxy.log" \
|
||||
BB_TEST_PROXY_HELPER="$fixture_root/bin/kubectl-proxy.py" BB_TEST_STATE="$fixture_root/state" \
|
||||
BB_TEST_TMP="$fixture_root/state" BB_TEST_ACTIVE="$fixture_root/active/learn-services" \
|
||||
BB_TEST_PROOF="$proof" BB_TEST_POST_PROMPT_MARKER="$fixture_root/post-prompt" \
|
||||
BB_TEST_CLIENT_STATUS=403 BB_TEST_CLIENT_RC=1 BB_TEST_EXEC_SLEEP=1 \
|
||||
bash "$VALIDATOR" --execute --context fixture-context >"$fixture_root/signal.out" 2>&1 &
|
||||
signal_pid=$!
|
||||
for ((attempt = 0; attempt < 100; attempt++)); do
|
||||
[[ "$(<"$fixture_root/commands.log")" == *' exec '* ]] && break
|
||||
/usr/bin/sleep 0.02
|
||||
done
|
||||
[[ "$(<"$fixture_root/commands.log")" == *' exec '* ]] || fail 'signal fixture did not reach client request'
|
||||
/bin/kill -TERM -- "-$signal_pid"
|
||||
signal_rc=0; wait "$signal_pid" || signal_rc=$?
|
||||
assert_eq 143 "$signal_rc" 'SIGTERM must retain fixed status'
|
||||
assert_contains "$(<"$fixture_root/proxy.log")" '/pods/' 'signal raw Pod cleanup'
|
||||
assert_contains "$(<"$fixture_root/proxy.log")" '/networkpolicies/' 'signal raw NetworkPolicy cleanup'
|
||||
|
||||
printf 'BLACKBOX EDGE SOURCE PRODUCTION-BOUNDARY TEST PASS\n'
|
||||
@@ -0,0 +1,728 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Prove that a restricted in-cluster source is denied by the active Grafana
|
||||
# Nginx guard. This is deliberately a short-lived prerequisite transaction,
|
||||
# not a reusable HTTP probing tool.
|
||||
set -Eeuo pipefail
|
||||
set +x
|
||||
umask 077
|
||||
|
||||
readonly ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd -P)"
|
||||
readonly DENY_GUARD="$ROOT/infrastructure/networking/host-nginx/learn-services-grafana-deny-guard.conf"
|
||||
readonly NAMESPACE=observability
|
||||
readonly ROLLBACK_BASE_DEFAULT=/var/lib/hyeonworks/platform-rollbacks
|
||||
readonly ACTIVE_DEFAULT=/etc/nginx/sites-available/learn-services
|
||||
readonly KUBECTL_DEFAULT=/usr/local/bin/kubectl
|
||||
readonly SUDO_DEFAULT=/usr/bin/sudo
|
||||
readonly DATE_DEFAULT=/usr/bin/date
|
||||
readonly SLEEP_DEFAULT=/usr/bin/sleep
|
||||
readonly AWK_BIN=/usr/bin/awk
|
||||
readonly CHMOD_BIN=/usr/bin/chmod
|
||||
readonly CMP_BIN=/usr/bin/cmp
|
||||
readonly CURL_BIN=/usr/bin/curl
|
||||
readonly ID_BIN=/usr/bin/id
|
||||
readonly INSTALL_BIN=/usr/bin/install
|
||||
readonly JQ_BIN=/usr/bin/jq
|
||||
readonly KILL_BIN=/bin/kill
|
||||
readonly LN_BIN=/bin/ln
|
||||
readonly MKTEMP_BIN=/usr/bin/mktemp
|
||||
readonly REALPATH_BIN=/usr/bin/realpath
|
||||
readonly RM_BIN=/bin/rm
|
||||
readonly SHA256SUM_BIN=/usr/bin/sha256sum
|
||||
readonly STAT_BIN=/usr/bin/stat
|
||||
readonly TEST_BIN=/usr/bin/test
|
||||
readonly TRUE_BIN=/usr/bin/true
|
||||
readonly UNLINK_BIN=/usr/bin/unlink
|
||||
readonly PROOF_SCHEMA=platform-blackbox-source-v1
|
||||
readonly BUSYBOX_IMAGE='docker.io/library/busybox:1.37.0@sha256:7a3ebe5bfd1a4a19797d20b0c0bb39d44393e9a03fd852c0865b0f540d868df0'
|
||||
readonly EXPECTED_NODE='donghyeon-system-product-name'
|
||||
readonly REQUEST_TIMEOUT=10s
|
||||
readonly READY_TIMEOUT=30s
|
||||
readonly DELETE_WAIT_POLLS=1200
|
||||
readonly PROXY_WAIT_POLLS=50
|
||||
readonly GRAFANA_HOST=grafana.learn.hyeonworks.com
|
||||
readonly STORAGE_HOST=storage-admin.learn.hyeonworks.com
|
||||
readonly DB_HOST=db-admin.learn.hyeonworks.com
|
||||
|
||||
readonly TEST_MODE="${PLATFORM_BLACKBOX_EDGE_TEST_MODE:-0}"
|
||||
readonly KUBECTL="${PLATFORM_BLACKBOX_EDGE_KUBECTL:-$KUBECTL_DEFAULT}"
|
||||
readonly SUDO="${PLATFORM_BLACKBOX_EDGE_SUDO:-$SUDO_DEFAULT}"
|
||||
readonly DATE_BIN="${PLATFORM_BLACKBOX_EDGE_DATE:-$DATE_DEFAULT}"
|
||||
readonly SLEEP_BIN="${PLATFORM_BLACKBOX_EDGE_SLEEP:-$SLEEP_DEFAULT}"
|
||||
readonly ACTIVE="${PLATFORM_BLACKBOX_EDGE_ACTIVE:-$ACTIVE_DEFAULT}"
|
||||
readonly ROLLBACK_BASE="${PLATFORM_BLACKBOX_EDGE_ROLLBACK_BASE:-$ROLLBACK_BASE_DEFAULT}"
|
||||
|
||||
execute=false
|
||||
context=''
|
||||
fixture_root=''
|
||||
rollback_anchor=''
|
||||
rollback_root=''
|
||||
proof_path=''
|
||||
run_temp=''
|
||||
run_label=''
|
||||
pod_name=''
|
||||
policy_name=''
|
||||
pod_uid=''
|
||||
policy_uid=''
|
||||
cleanup_started=false
|
||||
declare -a LOG_RESULTS=()
|
||||
|
||||
fail() { printf 'ERROR: %s\n' "$*" >&2; return 1; }
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage:
|
||||
bash scripts/validate/validate-blackbox-edge-source.sh
|
||||
bash scripts/validate/validate-blackbox-edge-source.sh --execute --context CONTEXT
|
||||
|
||||
The default is read-only. Execute requires an exact context-qualified
|
||||
confirmation, creates only a temporary restricted NetworkPolicy and Pod, and
|
||||
never prints request tokens or Nginx log lines.
|
||||
USAGE
|
||||
}
|
||||
|
||||
sha_of() { "$SHA256SUM_BIN" -- "$1" | "$AWK_BIN" '{print $1}'; }
|
||||
is_sha256() { [[ "$1" =~ ^[0-9a-f]{64}$ ]]; }
|
||||
require_regular_source() { [[ -f "$1" && ! -L "$1" ]] || fail "unsafe or missing source: $1"; }
|
||||
|
||||
safe_fixture_command() {
|
||||
local path=$1 fixture_bin=$2 resolved metadata type owner mode mode_value
|
||||
[[ "$path" != /usr/bin/sudo ]] || return 1
|
||||
resolved="$("$REALPATH_BIN" --canonicalize-existing -- "$path" 2>/dev/null)" || return 1
|
||||
[[ "$resolved" == "$path" && "${path%/*}" == "$fixture_bin" && -f "$path" && ! -L "$path" && -x "$path" ]] || return 1
|
||||
metadata="$("$STAT_BIN" -c '%F|%u|%a' -- "$path")" || return 1
|
||||
IFS='|' read -r type owner mode <<<"$metadata"
|
||||
mode_value=$((8#$mode))
|
||||
[[ "$type" == 'regular file' && "$owner" == "$EUID" && $((mode_value & 0022)) == 0 && $((mode_value & 07000)) == 0 ]]
|
||||
}
|
||||
|
||||
validate_test_mode() {
|
||||
local active_parent resolved key
|
||||
[[ "$TEST_MODE" == 0 || "$TEST_MODE" == 1 ]] || { fail 'invalid test-mode flag'; return 1; }
|
||||
if [[ "$TEST_MODE" == 0 ]]; then
|
||||
for key in ${!PLATFORM_BLACKBOX_EDGE_@}; do
|
||||
fail "production rejects Blackbox edge override: $key"
|
||||
return 1
|
||||
done
|
||||
[[ "$KUBECTL" == "$KUBECTL_DEFAULT" && "$SUDO" == "$SUDO_DEFAULT" && "$DATE_BIN" == "$DATE_DEFAULT" && "$SLEEP_BIN" == "$SLEEP_DEFAULT" && "$ACTIVE" == "$ACTIVE_DEFAULT" && "$ROLLBACK_BASE" == "$ROLLBACK_BASE_DEFAULT" ]] || {
|
||||
fail 'production Blackbox edge constants are not exact'; return 1;
|
||||
}
|
||||
rollback_anchor=/
|
||||
return 0
|
||||
fi
|
||||
active_parent=${ACTIVE%/*}
|
||||
resolved="$("$REALPATH_BIN" --canonicalize-existing -- "$active_parent/.." 2>/dev/null)" || {
|
||||
fail 'test fixture root does not resolve'; return 1;
|
||||
}
|
||||
fixture_root=$resolved
|
||||
[[ "$fixture_root" == /tmp/platform-blackbox-edge-test.* && "$ACTIVE" == "$fixture_root/active/learn-services" && "$ROLLBACK_BASE" == "$fixture_root/rollbacks" && -d "$fixture_root/bin" && ! -L "$fixture_root/bin" ]] || {
|
||||
fail 'test mode is restricted to one isolated fixture tree'; return 1;
|
||||
}
|
||||
safe_fixture_command "$KUBECTL" "$fixture_root/bin" &&
|
||||
safe_fixture_command "$SUDO" "$fixture_root/bin" &&
|
||||
safe_fixture_command "$DATE_BIN" "$fixture_root/bin" &&
|
||||
safe_fixture_command "$SLEEP_BIN" "$fixture_root/bin" || {
|
||||
fail 'test command boundary is unsafe'; return 1;
|
||||
}
|
||||
rollback_anchor=$fixture_root
|
||||
}
|
||||
|
||||
parse_args() {
|
||||
local execute_count=0 context_count=0
|
||||
while (( $# > 0 )); do
|
||||
case "$1" in
|
||||
--execute) execute=true; execute_count=$((execute_count + 1)); shift ;;
|
||||
--context)
|
||||
(( $# >= 2 )) || { fail '--context requires a value'; return 1; }
|
||||
context=$2; context_count=$((context_count + 1)); shift 2
|
||||
;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) usage >&2; fail "unsupported argument: $1"; return 1 ;;
|
||||
esac
|
||||
done
|
||||
(( execute_count <= 1 && context_count <= 1 )) || { fail 'duplicate execute argument'; return 1; }
|
||||
if [[ "$execute" == true ]]; then
|
||||
[[ "$context" =~ ^[A-Za-z0-9._:-]+$ ]] || { fail '--execute requires an exact --context'; return 1; }
|
||||
else
|
||||
[[ -z "$context" ]] || { fail '--context is execute-only'; return 1; }
|
||||
fi
|
||||
}
|
||||
|
||||
root_run() { "$SUDO" -n "$@"; }
|
||||
root_metadata() { root_run "$STAT_BIN" -c '%F|%u:%g|%a|%h' -- "$1"; }
|
||||
root_owner_expected() {
|
||||
if [[ "$TEST_MODE" == 0 ]]; then [[ "$1" == 0:0 ]]; else [[ "$1" == "$EUID:$($ID_BIN -g)" ]]; fi
|
||||
}
|
||||
|
||||
root_safe_ancestor_dir() {
|
||||
local path=$1 metadata type owner mode links mode_value
|
||||
root_run "$TEST_BIN" ! -L "$path" || return 1
|
||||
root_run "$TEST_BIN" -d "$path" || return 1
|
||||
metadata="$(root_metadata "$path")" || return 1
|
||||
IFS='|' read -r type owner mode links <<<"$metadata"
|
||||
mode_value=$((8#$mode))
|
||||
[[ "$type" == directory && "$links" =~ ^[1-9][0-9]*$ ]] || return 1
|
||||
root_owner_expected "$owner" || return 1
|
||||
(( (mode_value & 0022) == 0 ))
|
||||
}
|
||||
|
||||
root_safe_dir() {
|
||||
local path=$1 expected_mode=$2 metadata type owner mode links
|
||||
root_safe_ancestor_dir "$path" || return 1
|
||||
metadata="$(root_metadata "$path")" || return 1
|
||||
IFS='|' read -r type owner mode links <<<"$metadata"
|
||||
[[ "$mode" == "$expected_mode" && "$links" =~ ^[1-9][0-9]*$ ]]
|
||||
}
|
||||
|
||||
root_safe_file() {
|
||||
local path=$1 expected_mode=$2 metadata type owner mode links
|
||||
root_run "$TEST_BIN" ! -L "$path" || return 1
|
||||
root_run "$TEST_BIN" -f "$path" || return 1
|
||||
metadata="$(root_metadata "$path")" || return 1
|
||||
IFS='|' read -r type owner mode links <<<"$metadata"
|
||||
[[ ( "$type" == 'regular file' || "$type" == 'regular empty file' ) && "$mode" == "$expected_mode" && "$links" == 1 ]] || return 1
|
||||
root_owner_expected "$owner"
|
||||
}
|
||||
|
||||
validate_root_chain() {
|
||||
local target=$1 anchor=$2 canonical relative current component
|
||||
[[ "$target" == /* && "$anchor" == /* && "$target" != *'//' && "$target" != */./* && "$target" != */../* ]] || return 1
|
||||
canonical="$(root_run "$REALPATH_BIN" --canonicalize-existing -- "$target")" || return 1
|
||||
[[ "$canonical" == "$target" && "$canonical" != *$'\n'* ]] || return 1
|
||||
canonical="$(root_run "$REALPATH_BIN" --canonicalize-existing -- "$anchor")" || return 1
|
||||
[[ "$canonical" == "$anchor" && "$canonical" != *$'\n'* ]] || return 1
|
||||
if [[ "$anchor" == / ]]; then
|
||||
# Root has no separator to append: "$anchor/" would become "//" and
|
||||
# reject every normal absolute descendant after canonicalization.
|
||||
[[ "$target" == /* ]] || return 1
|
||||
else
|
||||
[[ "$target" == "$anchor" || "$target" == "$anchor/"* ]] || return 1
|
||||
fi
|
||||
current=$anchor
|
||||
root_safe_ancestor_dir "$current" || return 1
|
||||
relative=${target#"$anchor"}
|
||||
relative=${relative#/}
|
||||
[[ -z "$relative" ]] && return 0
|
||||
IFS='/' read -r -a _blackbox_path_parts <<<"$relative"
|
||||
for component in "${_blackbox_path_parts[@]}"; do
|
||||
[[ -n "$component" && "$component" != . && "$component" != .. ]] || return 1
|
||||
if [[ "$current" == / ]]; then current="/$component"; else current="$current/$component"; fi
|
||||
root_safe_ancestor_dir "$current" || return 1
|
||||
done
|
||||
}
|
||||
|
||||
validate_rollback_root() {
|
||||
local id=${PLATFORM_OBSERVABILITY_ROLLBACK_ID:-}
|
||||
[[ "$id" =~ ^[0-9]{8}T[0-9]{6}Z$ ]] || { fail 'PLATFORM_OBSERVABILITY_ROLLBACK_ID format is invalid'; return 1; }
|
||||
if [[ "$TEST_MODE" == 0 ]]; then [[ "$ROLLBACK_BASE" == "$ROLLBACK_BASE_DEFAULT" ]] || { fail 'production rollback base is not exact'; return 1; }; fi
|
||||
rollback_root="$ROLLBACK_BASE/observability-$id"
|
||||
proof_path="$rollback_root/blackbox-source-proof.env"
|
||||
validate_root_chain "$ROLLBACK_BASE" "$rollback_anchor" || { fail 'rollback base lineage is unsafe'; return 1; }
|
||||
validate_root_chain "$rollback_root" "$rollback_anchor" || { fail 'rollback root lineage is unsafe'; return 1; }
|
||||
root_safe_dir "$rollback_root" 700 || { fail 'rollback root metadata is unsafe'; return 1; }
|
||||
}
|
||||
|
||||
validate_proof_target_absent() {
|
||||
validate_rollback_root || return 1
|
||||
[[ "$proof_path" == "$rollback_root/blackbox-source-proof.env" ]] || { fail 'source-proof path is not exact'; return 1; }
|
||||
root_run "$TEST_BIN" ! -e "$proof_path" && root_run "$TEST_BIN" ! -L "$proof_path" || {
|
||||
fail 'source-proof evidence already exists or is unsafe'; return 1;
|
||||
}
|
||||
}
|
||||
|
||||
validate_active_guard_expected() {
|
||||
local expected=$1 deny_sha metadata type owner mode links active_sha
|
||||
require_regular_source "$DENY_GUARD" || return 1
|
||||
is_sha256 "$expected" || { fail 'expected deny guard hash is invalid'; return 1; }
|
||||
deny_sha="$(sha_of "$DENY_GUARD")" || { fail 'cannot hash deny guard source'; return 1; }
|
||||
[[ "$deny_sha" == "$expected" ]] || { fail 'deny guard source changed during proof'; return 1; }
|
||||
root_run "$TEST_BIN" ! -L "$ACTIVE" && root_run "$TEST_BIN" -f "$ACTIVE" || {
|
||||
fail 'active Nginx config is missing or symlinked'; return 1;
|
||||
}
|
||||
metadata="$(root_metadata "$ACTIVE")" || { fail 'cannot stat active Nginx config'; return 1; }
|
||||
IFS='|' read -r type owner mode links <<<"$metadata"
|
||||
[[ "$type" == 'regular file' && "$links" == 1 ]] || { fail 'active Nginx config metadata is unsafe'; return 1; }
|
||||
root_owner_expected "$owner" || { fail 'active Nginx config owner is unsafe'; return 1; }
|
||||
if [[ "$TEST_MODE" == 0 ]]; then [[ "$mode" == 644 ]] || { fail 'active Nginx config mode is unsafe'; return 1; }; fi
|
||||
active_sha="$(root_run "$SHA256SUM_BIN" -- "$ACTIVE" | "$AWK_BIN" '{print $1}')" || {
|
||||
fail 'cannot hash active Nginx config'; return 1;
|
||||
}
|
||||
[[ "$active_sha" == "$expected" ]] || { fail 'active Nginx config is not the exact Grafana deny guard'; return 1; }
|
||||
}
|
||||
|
||||
validate_active_guard() {
|
||||
local deny_sha
|
||||
require_regular_source "$DENY_GUARD" || return 1
|
||||
deny_sha="$(sha_of "$DENY_GUARD")" || return 1
|
||||
is_sha256 "$deny_sha" || { fail 'deny guard hash is invalid'; return 1; }
|
||||
validate_active_guard_expected "$deny_sha" || return 1
|
||||
printf '%s\n' "$deny_sha"
|
||||
}
|
||||
|
||||
confirm() {
|
||||
local expected="PROVE BLACKBOX PRIVATE EDGE $context" answer
|
||||
printf 'Type %s: ' "$expected" >&2
|
||||
if [[ "$TEST_MODE" == 1 ]]; then
|
||||
answer=${PLATFORM_BLACKBOX_EDGE_CONFIRMATION:-}
|
||||
printf '%s\n' "$answer" >&2
|
||||
else
|
||||
[[ -t 0 ]] || { fail '--execute requires an interactive terminal'; return 1; }
|
||||
IFS= read -r answer
|
||||
fi
|
||||
[[ "$answer" == "$expected" ]] || { fail 'cancelled'; return 1; }
|
||||
}
|
||||
|
||||
require_context_api_auth() {
|
||||
local current answer
|
||||
current="$("$KUBECTL" --request-timeout="$REQUEST_TIMEOUT" config current-context)" || { fail 'cannot read Kubernetes context'; return 1; }
|
||||
[[ "$current" == "$context" && "$current" != *$'\n'* ]] || { fail 'current Kubernetes context does not match --context'; return 1; }
|
||||
"$KUBECTL" --context "$context" --request-timeout="$REQUEST_TIMEOUT" get --raw=/readyz >/dev/null || { fail 'Kubernetes API is not ready'; return 1; }
|
||||
for answer in \
|
||||
"$("$KUBECTL" --context "$context" --request-timeout="$REQUEST_TIMEOUT" auth can-i create pods -n "$NAMESPACE")" \
|
||||
"$("$KUBECTL" --context "$context" --request-timeout="$REQUEST_TIMEOUT" auth can-i create networkpolicies.networking.k8s.io -n "$NAMESPACE")" \
|
||||
"$("$KUBECTL" --context "$context" --request-timeout="$REQUEST_TIMEOUT" auth can-i delete pods -n "$NAMESPACE")" \
|
||||
"$("$KUBECTL" --context "$context" --request-timeout="$REQUEST_TIMEOUT" auth can-i delete networkpolicies.networking.k8s.io -n "$NAMESPACE")"; do
|
||||
[[ "$answer" == yes ]] || { fail 'Kubernetes authorization is insufficient'; return 1; }
|
||||
done
|
||||
root_run "$TRUE_BIN" || { fail 'sudo non-interactive authentication failed'; return 1; }
|
||||
}
|
||||
|
||||
prepare_local_temp() {
|
||||
run_temp="$("$MKTEMP_BIN" -d /tmp/platform-blackbox-edge-source.XXXXXXXX)" || return 1
|
||||
[[ "$run_temp" == /tmp/platform-blackbox-edge-source.???????? && -d "$run_temp" && ! -L "$run_temp" ]] || return 1
|
||||
"$CHMOD_BIN" 0700 -- "$run_temp"
|
||||
}
|
||||
|
||||
cleanup_local_temp() {
|
||||
[[ -n "$run_temp" && "$run_temp" == /tmp/platform-blackbox-edge-source.???????? && -d "$run_temp" && ! -L "$run_temp" ]] || return 0
|
||||
"$RM_BIN" -rf -- "$run_temp"
|
||||
run_temp=''
|
||||
}
|
||||
|
||||
render_network_policy() {
|
||||
/bin/cat <<EOF
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: $policy_name
|
||||
namespace: $NAMESPACE
|
||||
labels:
|
||||
app.kubernetes.io/managed-by: platform-blackbox-edge-source
|
||||
platform.hyeonworks.com/source-proof-run: "$run_label"
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
platform.hyeonworks.com/source-proof-run: "$run_label"
|
||||
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}
|
||||
- to:
|
||||
- ipBlock: {cidr: 192.168.0.107/32}
|
||||
ports: [{protocol: TCP, port: 443}]
|
||||
EOF
|
||||
}
|
||||
|
||||
render_pod() {
|
||||
/bin/cat <<EOF
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: $pod_name
|
||||
namespace: $NAMESPACE
|
||||
labels:
|
||||
app.kubernetes.io/managed-by: platform-blackbox-edge-source
|
||||
platform.hyeonworks.com/source-proof-run: "$run_label"
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
restartPolicy: Never
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65534
|
||||
runAsGroup: 65534
|
||||
seccompProfile: {type: RuntimeDefault}
|
||||
containers:
|
||||
- name: probe
|
||||
image: $BUSYBOX_IMAGE
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: ["sh", "-c", "sleep 120"]
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities: {drop: ["ALL"]}
|
||||
EOF
|
||||
}
|
||||
|
||||
validate_object_json() {
|
||||
local kind=$1 name=$2 run=$3 source=$4 require_uid=$5 allow_scheduled_node=${6:-false} uid_test
|
||||
uid_test='true'
|
||||
[[ "$require_uid" == true ]] && uid_test='(.metadata.uid | type == "string" and test("^[A-Za-z0-9][A-Za-z0-9._-]*$"))'
|
||||
case "$kind" in
|
||||
networkpolicy)
|
||||
"$JQ_BIN" -e --arg namespace "$NAMESPACE" --arg name "$name" --arg run "$run" --argjson uid_required "$([[ "$require_uid" == true ]] && printf true || printf false)" '
|
||||
.apiVersion == "networking.k8s.io/v1" and .kind == "NetworkPolicy" and
|
||||
.metadata.namespace == $namespace and .metadata.name == $name and
|
||||
.metadata.labels == {
|
||||
"app.kubernetes.io/managed-by":"platform-blackbox-edge-source",
|
||||
"platform.hyeonworks.com/source-proof-run":$run
|
||||
} and
|
||||
(($uid_required | not) or (.metadata.uid | type == "string" and test("^[A-Za-z0-9][A-Za-z0-9._-]*$"))) and
|
||||
.spec.podSelector == {"matchLabels":{"platform.hyeonworks.com/source-proof-run":$run}} and
|
||||
.spec.policyTypes == ["Egress"] and
|
||||
.spec.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}]},
|
||||
{"to":[{"ipBlock":{"cidr":"192.168.0.107/32"}}],"ports":[{"protocol":"TCP","port":443}]}
|
||||
]
|
||||
' "$source" >/dev/null
|
||||
;;
|
||||
pod)
|
||||
"$JQ_BIN" -e --arg namespace "$NAMESPACE" --arg name "$name" --arg run "$run" --arg image "$BUSYBOX_IMAGE" --arg node "$EXPECTED_NODE" --argjson uid_required "$([[ "$require_uid" == true ]] && printf true || printf false)" --argjson scheduled_node_allowed "$([[ "$allow_scheduled_node" == true ]] && printf true || printf false)" '
|
||||
def optional_default($key; $value):
|
||||
(has($key) | not) or .[$key] == $value;
|
||||
.apiVersion == "v1" and .kind == "Pod" and
|
||||
.metadata.namespace == $namespace and .metadata.name == $name and
|
||||
.metadata.labels == {
|
||||
"app.kubernetes.io/managed-by":"platform-blackbox-edge-source",
|
||||
"platform.hyeonworks.com/source-proof-run":$run
|
||||
} and
|
||||
(($uid_required | not) or (.metadata.uid | type == "string" and test("^[A-Za-z0-9][A-Za-z0-9._-]*$"))) and
|
||||
(.spec |
|
||||
type == "object" and
|
||||
optional_default("dnsPolicy"; "ClusterFirst") and
|
||||
optional_default("enableServiceLinks"; true) and
|
||||
optional_default("preemptionPolicy"; "PreemptLowerPriority") and
|
||||
optional_default("priority"; 0) and
|
||||
optional_default("schedulerName"; "default-scheduler") and
|
||||
optional_default("serviceAccount"; "default") and
|
||||
optional_default("serviceAccountName"; "default") and
|
||||
optional_default("terminationGracePeriodSeconds"; 30) and
|
||||
((has("nodeName") | not) or ($scheduled_node_allowed and .nodeName == $node)) and
|
||||
optional_default("tolerations"; [
|
||||
{"effect":"NoExecute","key":"node.kubernetes.io/not-ready","operator":"Exists","tolerationSeconds":300},
|
||||
{"effect":"NoExecute","key":"node.kubernetes.io/unreachable","operator":"Exists","tolerationSeconds":300}
|
||||
]) and
|
||||
(.containers | type == "array" and length == 1) and
|
||||
(.containers[0] |
|
||||
optional_default("resources"; {}) and
|
||||
optional_default("terminationMessagePath"; "/dev/termination-log") and
|
||||
optional_default("terminationMessagePolicy"; "File")) and
|
||||
(del(
|
||||
.dnsPolicy, .enableServiceLinks, .preemptionPolicy, .priority,
|
||||
.schedulerName, .serviceAccount, .serviceAccountName,
|
||||
.terminationGracePeriodSeconds, .tolerations, .nodeName,
|
||||
.containers[0].resources, .containers[0].terminationMessagePath,
|
||||
.containers[0].terminationMessagePolicy
|
||||
) == {
|
||||
"automountServiceAccountToken":false,
|
||||
"restartPolicy":"Never",
|
||||
"securityContext":{"runAsNonRoot":true,"runAsUser":65534,"runAsGroup":65534,"seccompProfile":{"type":"RuntimeDefault"}},
|
||||
"containers":[{"name":"probe","image":$image,"imagePullPolicy":"IfNotPresent","command":["sh","-c","sleep 120"],"securityContext":{"allowPrivilegeEscalation":false,"readOnlyRootFilesystem":true,"capabilities":{"drop":["ALL"]}}}]
|
||||
})
|
||||
)
|
||||
' "$source" >/dev/null
|
||||
;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
response_uid() {
|
||||
local source=$1 uid
|
||||
uid="$("$JQ_BIN" -er '.metadata.uid' "$source")" || return 1
|
||||
[[ "$uid" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || return 1
|
||||
printf '%s\n' "$uid"
|
||||
}
|
||||
|
||||
local_response_file() {
|
||||
"$MKTEMP_BIN" "$run_temp/${1}.response.XXXXXXXX"
|
||||
}
|
||||
|
||||
render_resource() {
|
||||
case "$1" in networkpolicy) render_network_policy ;; pod) render_pod ;; *) return 1 ;; esac
|
||||
}
|
||||
resource_name() { case "$1" in networkpolicy) printf '%s\n' "$policy_name" ;; pod) printf '%s\n' "$pod_name" ;; *) return 1 ;; esac; }
|
||||
|
||||
server_dry_run_resource() {
|
||||
local kind=$1 name response
|
||||
name="$(resource_name "$kind")" || return 1
|
||||
response="$(local_response_file "${kind}.dry")" || return 1
|
||||
"$CHMOD_BIN" 0600 -- "$response"
|
||||
if ! render_resource "$kind" | "$KUBECTL" --context "$context" -n "$NAMESPACE" --request-timeout="$REQUEST_TIMEOUT" create --dry-run=server -f - -o json >"$response"; then
|
||||
"$RM_BIN" -f -- "$response"; return 1
|
||||
fi
|
||||
validate_object_json "$kind" "$name" "$run_label" "$response" false || { "$RM_BIN" -f -- "$response"; return 1; }
|
||||
"$RM_BIN" -f -- "$response"
|
||||
}
|
||||
|
||||
inspect_owned_object() {
|
||||
local kind=$1 name=$2 destination=$3 response uid rc=0
|
||||
response="$(local_response_file "${kind}.inspect")" || return 3
|
||||
"$CHMOD_BIN" 0600 -- "$response"
|
||||
"$KUBECTL" --context "$context" -n "$NAMESPACE" --request-timeout="$REQUEST_TIMEOUT" get "$kind" "$name" --ignore-not-found -o json >"$response" || rc=$?
|
||||
if (( rc != 0 )); then "$RM_BIN" -f -- "$response"; return 3; fi
|
||||
if [[ ! -s "$response" ]]; then "$RM_BIN" -f -- "$response"; return 1; fi
|
||||
validate_object_json "$kind" "$name" "$run_label" "$response" true || { "$RM_BIN" -f -- "$response"; return 2; }
|
||||
uid="$(response_uid "$response")" || { "$RM_BIN" -f -- "$response"; return 2; }
|
||||
"$RM_BIN" -f -- "$response"
|
||||
printf -v "$destination" '%s' "$uid"
|
||||
}
|
||||
|
||||
create_resource() {
|
||||
local kind=$1 destination=$2 name response uid rc=0 classification
|
||||
name="$(resource_name "$kind")" || return 1
|
||||
response="$(local_response_file "${kind}.create")" || return 1
|
||||
"$CHMOD_BIN" 0600 -- "$response"
|
||||
if render_resource "$kind" | "$KUBECTL" --context "$context" -n "$NAMESPACE" --request-timeout="$REQUEST_TIMEOUT" create -f - -o json >"$response"; then
|
||||
if validate_object_json "$kind" "$name" "$run_label" "$response" true && uid="$(response_uid "$response")"; then
|
||||
"$RM_BIN" -f -- "$response"
|
||||
printf -v "$destination" '%s' "$uid"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
"$RM_BIN" -f -- "$response"
|
||||
# A syntactically bad success response is still ambiguous: the API may have
|
||||
# committed the exact object, so classify the live state before cleanup.
|
||||
if inspect_owned_object "$kind" "$name" "$destination"; then
|
||||
return 2
|
||||
else
|
||||
classification=$?
|
||||
[[ "$classification" == 1 ]] && return 1
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
create_temporary_objects() {
|
||||
local result
|
||||
server_dry_run_resource networkpolicy || { fail 'temporary NetworkPolicy server dry-run failed'; return 1; }
|
||||
if create_resource networkpolicy policy_uid; then :; else
|
||||
result=$?
|
||||
[[ "$result" == 2 ]] && fail 'temporary NetworkPolicy create outcome was ambiguous but owned'
|
||||
[[ "$result" != 2 ]] && fail 'temporary NetworkPolicy create failed'
|
||||
return 1
|
||||
fi
|
||||
server_dry_run_resource pod || { fail 'temporary Pod server dry-run failed'; return 1; }
|
||||
if create_resource pod pod_uid; then :; else
|
||||
result=$?
|
||||
[[ "$result" == 2 ]] && fail 'temporary Pod create outcome was ambiguous but owned'
|
||||
[[ "$result" != 2 ]] && fail 'temporary Pod create failed'
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
stop_proxy() {
|
||||
local pid=$1 attempt
|
||||
"$KILL_BIN" -TERM "$pid" 2>/dev/null || true
|
||||
for ((attempt=0; attempt<20; attempt++)); do
|
||||
if ! "$KILL_BIN" -0 "$pid" 2>/dev/null; then wait "$pid" 2>/dev/null || true; return 0; fi
|
||||
"$SLEEP_BIN" 0.05
|
||||
done
|
||||
"$KILL_BIN" -KILL "$pid" 2>/dev/null || true
|
||||
wait "$pid" 2>/dev/null || true
|
||||
}
|
||||
|
||||
api_path_for() {
|
||||
case "$1" in
|
||||
pod) printf '/api/v1/namespaces/%s/pods/%s\n' "$NAMESPACE" "$2" ;;
|
||||
networkpolicy) printf '/apis/networking.k8s.io/v1/namespaces/%s/networkpolicies/%s\n' "$NAMESPACE" "$2" ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
object_state() {
|
||||
local kind=$1 name=$2 expected_uid=$3 response uid rc=0
|
||||
response="$(local_response_file "${kind}.state")" || return 4
|
||||
"$CHMOD_BIN" 0600 -- "$response"
|
||||
"$KUBECTL" --context "$context" -n "$NAMESPACE" --request-timeout="$REQUEST_TIMEOUT" get "$kind" "$name" --ignore-not-found -o json >"$response" || rc=$?
|
||||
if (( rc != 0 )); then "$RM_BIN" -f -- "$response"; return 4; fi
|
||||
if [[ ! -s "$response" ]]; then "$RM_BIN" -f -- "$response"; return 1; fi
|
||||
uid="$(response_uid "$response")" || { "$RM_BIN" -f -- "$response"; return 4; }
|
||||
[[ "$uid" == "$expected_uid" ]] || { "$RM_BIN" -f -- "$response"; return 3; }
|
||||
validate_object_json "$kind" "$name" "$run_label" "$response" true true || { "$RM_BIN" -f -- "$response"; return 4; }
|
||||
"$RM_BIN" -f -- "$response"
|
||||
return 0
|
||||
}
|
||||
|
||||
wait_for_stable_absence() {
|
||||
local kind=$1 name=$2 uid=$3 attempt absent=0 state
|
||||
for ((attempt=0; attempt<DELETE_WAIT_POLLS; attempt++)); do
|
||||
if object_state "$kind" "$name" "$uid"; then
|
||||
absent=0
|
||||
else
|
||||
state=$?
|
||||
if [[ "$state" == 1 ]]; then
|
||||
absent=$((absent + 1))
|
||||
(( absent >= 2 )) && return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
"$SLEEP_BIN" 0.05
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
delete_with_uid_precondition() {
|
||||
local kind=$1 name=$2 uid=$3 api_path socket options response proxy_log proxy_pid='' attempt http_code='' curl_rc=0
|
||||
[[ "$name" =~ ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ && "$uid" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || return 1
|
||||
api_path="$(api_path_for "$kind" "$name")" || return 1
|
||||
socket="$run_temp/proxy-${kind}.sock"
|
||||
options="$run_temp/delete-${kind}.json"
|
||||
response="$run_temp/delete-${kind}.response"
|
||||
proxy_log="$run_temp/proxy-${kind}.log"
|
||||
[[ ! -e "$socket" && ! -L "$socket" ]] || return 1
|
||||
printf '{"apiVersion":"meta.k8s.io/v1","kind":"DeleteOptions","propagationPolicy":"Background","preconditions":{"uid":"%s"}}\n' "$uid" >"$options"
|
||||
"$CHMOD_BIN" 0600 -- "$options"
|
||||
: >"$response"; "$CHMOD_BIN" 0600 -- "$response"
|
||||
"$KUBECTL" --context "$context" --request-timeout="$REQUEST_TIMEOUT" proxy \
|
||||
--unix-socket="$socket" --api-prefix=/ --accept-paths="^${api_path}$" \
|
||||
--reject-methods='^(GET|POST|PUT|PATCH)$' >"$proxy_log" 2>&1 &
|
||||
proxy_pid=$!
|
||||
for ((attempt=0; attempt<PROXY_WAIT_POLLS; attempt++)); do
|
||||
[[ -S "$socket" ]] && break
|
||||
"$KILL_BIN" -0 "$proxy_pid" 2>/dev/null || { wait "$proxy_pid" 2>/dev/null || true; return 1; }
|
||||
"$SLEEP_BIN" 0.05
|
||||
done
|
||||
[[ -S "$socket" ]] || { stop_proxy "$proxy_pid"; return 1; }
|
||||
http_code="$("$CURL_BIN" --silent --show-error --max-time 5 --unix-socket "$socket" --output "$response" --write-out '%{http_code}' \
|
||||
--request DELETE --header 'Content-Type: application/json' --data-binary "@$options" "http://localhost${api_path}")" || curl_rc=$?
|
||||
stop_proxy "$proxy_pid"
|
||||
# A transport/status ambiguity is not treated as success on its own. Only
|
||||
# two consecutive exact absence reads reclassify it as a completed delete.
|
||||
wait_for_stable_absence "$kind" "$name" "$uid"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
local rc=0
|
||||
[[ "$cleanup_started" == false ]] || return 0
|
||||
cleanup_started=true
|
||||
if [[ -n "$pod_uid" ]]; then delete_with_uid_precondition pod "$pod_name" "$pod_uid" || rc=1; fi
|
||||
if [[ -n "$policy_uid" ]]; then delete_with_uid_precondition networkpolicy "$policy_name" "$policy_uid" || rc=1; fi
|
||||
pod_uid=''; policy_uid=''
|
||||
(( rc == 0 )) || printf 'ERROR: temporary Blackbox proof object identity is unknown; manual review required\n' >&2
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
on_exit() {
|
||||
local rc=$?
|
||||
trap - EXIT HUP INT TERM
|
||||
if [[ -n "$pod_uid" || -n "$policy_uid" ]]; then
|
||||
cleanup || { [[ "$rc" == 0 ]] && rc=1; }
|
||||
fi
|
||||
cleanup_local_temp || { [[ "$rc" == 0 ]] && rc=1; }
|
||||
exit "$rc"
|
||||
}
|
||||
|
||||
run_client_request() {
|
||||
local host=$1 token=$2 output status rc=0
|
||||
output="$("$KUBECTL" --context "$context" -n "$NAMESPACE" --request-timeout="$REQUEST_TIMEOUT" exec "$pod_name" -- sh -ec \
|
||||
"wget --no-check-certificate --server-response --spider 'https://$host/?hyeonworks_probe=$token'" 2>&1)" || rc=$?
|
||||
status="$("$AWK_BIN" '/^ HTTP\// { code=$2 } /^HTTP\// { code=$2 } END { print code }' <<<"$output")"
|
||||
[[ "$rc" == 1 && "$status" == 403 ]]
|
||||
}
|
||||
|
||||
collect_log_results() {
|
||||
local one=$1 two=$2 three=$3 output pair addr status
|
||||
output="$(root_run "$AWK_BIN" -v one="$one" -v two="$two" -v three="$three" '
|
||||
$0 ~ ("hyeonworks_probe=" one "([& ]| HTTP)") { if (++count[1] == 1) value[1]=$1 " " $9 }
|
||||
$0 ~ ("hyeonworks_probe=" two "([& ]| HTTP)") { if (++count[2] == 1) value[2]=$1 " " $9 }
|
||||
$0 ~ ("hyeonworks_probe=" three "([& ]| HTTP)") { if (++count[3] == 1) value[3]=$1 " " $9 }
|
||||
END { if (count[1] != 1 || count[2] != 1 || count[3] != 1) exit 42; print value[1]; print value[2]; print value[3] }
|
||||
' /var/log/nginx/access.log)" || return 1
|
||||
mapfile -t LOG_RESULTS <<<"$output"
|
||||
(( ${#LOG_RESULTS[@]} == 3 )) || return 1
|
||||
for pair in "${LOG_RESULTS[@]}"; do
|
||||
read -r addr status <<<"$pair"
|
||||
[[ "$addr" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ && "$status" == 403 ]] || return 1
|
||||
done
|
||||
}
|
||||
|
||||
write_proof() {
|
||||
local nginx_sha=$1 tested=$2 local_file root_temporary canonical suffix
|
||||
local_file="$("$MKTEMP_BIN" "$run_temp/proof.XXXXXXXX")" || return 1
|
||||
"$CHMOD_BIN" 0600 -- "$local_file"
|
||||
printf 'schema=%s\nrollback_id=%s\nnginx_sha256=%s\ntested_at_utc=%s\ngrafana_remote_addr=%s\ngrafana_status=403\nstorage_admin_remote_addr=%s\nstorage_admin_status=403\ndb_admin_remote_addr=%s\ndb_admin_status=403\n' \
|
||||
"$PROOF_SCHEMA" "${PLATFORM_OBSERVABILITY_ROLLBACK_ID:?}" "$nginx_sha" "$tested" \
|
||||
"${LOG_RESULTS[0]%% *}" "${LOG_RESULTS[1]%% *}" "${LOG_RESULTS[2]%% *}" >"$local_file"
|
||||
root_temporary="$(root_run "$MKTEMP_BIN" --tmpdir="$rollback_root" '.blackbox-source-proof.env.new.XXXXXXXX')" || { "$RM_BIN" -f -- "$local_file"; return 1; }
|
||||
suffix=${root_temporary#"$rollback_root/.blackbox-source-proof.env.new."}
|
||||
[[ "$root_temporary" == "$rollback_root/.blackbox-source-proof.env.new."* && "$suffix" =~ ^[A-Za-z0-9]{8}$ && "$root_temporary" != *$'\n'* ]] || {
|
||||
"$RM_BIN" -f -- "$local_file"; return 1;
|
||||
}
|
||||
canonical="$(root_run "$REALPATH_BIN" --canonicalize-existing -- "$root_temporary")" || { "$RM_BIN" -f -- "$local_file"; return 1; }
|
||||
[[ "$canonical" == "$root_temporary" ]] && root_safe_file "$root_temporary" 600 || {
|
||||
"$RM_BIN" -f -- "$local_file"; return 1;
|
||||
}
|
||||
if ! root_run "$INSTALL_BIN" -o root -g root -m 0600 -- "$local_file" "$root_temporary" ||
|
||||
! root_safe_file "$root_temporary" 600 ||
|
||||
! root_run "$CMP_BIN" --silent -- "$local_file" "$root_temporary" ||
|
||||
! root_run "$LN_BIN" -nT -- "$root_temporary" "$proof_path"; then
|
||||
root_safe_file "$root_temporary" 600 && root_run "$UNLINK_BIN" -- "$root_temporary" >/dev/null 2>&1 || true
|
||||
"$RM_BIN" -f -- "$local_file"
|
||||
return 1
|
||||
fi
|
||||
root_run "$UNLINK_BIN" -- "$root_temporary" || { "$RM_BIN" -f -- "$local_file"; return 1; }
|
||||
root_safe_file "$proof_path" 600 && root_run "$CMP_BIN" --silent -- "$local_file" "$proof_path" || {
|
||||
"$RM_BIN" -f -- "$local_file"; return 1;
|
||||
}
|
||||
"$RM_BIN" -f -- "$local_file"
|
||||
}
|
||||
|
||||
execute_proof() {
|
||||
local nginx_sha stamp random token_one token_two token_three tested
|
||||
nginx_sha="$(validate_active_guard)" || return 1
|
||||
validate_rollback_root || return 1
|
||||
require_context_api_auth || return 1
|
||||
confirm || return 1
|
||||
prepare_local_temp || { fail 'cannot create private validator temporary directory'; return 1; }
|
||||
stamp="$("$DATE_BIN" -u +%Y%m%dT%H%M%SZ)" || return 1
|
||||
random="${RANDOM}${RANDOM}"
|
||||
run_label="${stamp,,}-${random}"
|
||||
policy_name="blackbox-edge-source-egress-${run_label}"
|
||||
pod_name="blackbox-edge-source-${run_label}"
|
||||
token_one="${stamp}-${RANDOM}${RANDOM}"
|
||||
token_two="${stamp}-${RANDOM}${RANDOM}"
|
||||
token_three="${stamp}-${RANDOM}${RANDOM}"
|
||||
[[ "$token_one" != "$token_two" && "$token_one" != "$token_three" && "$token_two" != "$token_three" ]] || { fail 'could not create unique probe tokens'; return 1; }
|
||||
# This is the commit gate: it is immediately after confirmation and all
|
||||
# local preparation, and immediately before the first Kubernetes mutation.
|
||||
validate_active_guard_expected "$nginx_sha" || return 1
|
||||
validate_proof_target_absent || return 1
|
||||
create_temporary_objects || return 1
|
||||
"$KUBECTL" --context "$context" -n "$NAMESPACE" --request-timeout="$REQUEST_TIMEOUT" wait --for=condition=Ready --timeout="$READY_TIMEOUT" "pod/$pod_name" >/dev/null || {
|
||||
fail 'temporary Pod did not become Ready'; return 1;
|
||||
}
|
||||
run_client_request "$GRAFANA_HOST" "$token_one" || { fail 'Grafana client status is not exact BusyBox 403'; return 1; }
|
||||
run_client_request "$STORAGE_HOST" "$token_two" || { fail 'AIStor client status is not exact BusyBox 403'; return 1; }
|
||||
run_client_request "$DB_HOST" "$token_three" || { fail 'pgAdmin client status is not exact BusyBox 403'; return 1; }
|
||||
collect_log_results "$token_one" "$token_two" "$token_three" || { fail 'Nginx log did not contain exactly one 403 result per probe'; return 1; }
|
||||
tested="$("$DATE_BIN" -u +%Y-%m-%dT%H:%M:%SZ)" || return 1
|
||||
write_proof "$nginx_sha" "$tested" || { fail 'cannot atomically write source-proof evidence'; return 1; }
|
||||
cleanup || { fail 'temporary object cleanup failed'; return 1; }
|
||||
printf 'BLACKBOX PRIVATE EDGE SOURCE PASS\n'
|
||||
}
|
||||
|
||||
main() {
|
||||
parse_args "$@" || return 1
|
||||
validate_test_mode || return 1
|
||||
require_regular_source "$DENY_GUARD" || return 1
|
||||
if [[ "$execute" == false ]]; then
|
||||
printf 'BLACKBOX_EDGE_SOURCE_DENY_GUARD_SHA256=%s\nBLACKBOX_EDGE_SOURCE_BUSYBOX_IMAGE=%s\nBLACKBOX_PRIVATE_EDGE_SOURCE_DRY_RUN=PASS\n' "$(sha_of "$DENY_GUARD")" "$BUSYBOX_IMAGE"
|
||||
return 0
|
||||
fi
|
||||
for command_path in "$AWK_BIN" "$CHMOD_BIN" "$CMP_BIN" "$CURL_BIN" "$ID_BIN" "$INSTALL_BIN" "$JQ_BIN" "$KILL_BIN" "$LN_BIN" "$MKTEMP_BIN" "$REALPATH_BIN" "$RM_BIN" "$SHA256SUM_BIN" "$SLEEP_BIN" "$STAT_BIN" "$TEST_BIN" "$TRUE_BIN" "$UNLINK_BIN"; do
|
||||
[[ -x "$command_path" ]] || { fail "required command is missing: $command_path"; return 1; }
|
||||
done
|
||||
[[ -x "$KUBECTL" && -x "$SUDO" && -x "$DATE_BIN" ]] || { fail 'required command boundary is missing'; return 1; }
|
||||
trap on_exit EXIT
|
||||
trap 'exit 129' HUP
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
execute_proof
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then main "$@"; fi
|
||||
Reference in New Issue
Block a user