From 80c14e792db2a841e91bd78e09c1636567798406 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 29 Jul 2026 10:23:49 +0000 Subject: [PATCH 1/3] =?UTF-8?q?ci(S15P11A705-154):=20runtime=20Secret=20?= =?UTF-8?q?=EB=B4=89=EC=9D=B8=20=EA=B3=84=EC=95=BD=EC=9D=84=20=EC=A0=95?= =?UTF-8?q?=EB=A0=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/seal-ai-secrets.yml | 223 ------------------ .github/workflows/seal-runtime-secrets.yml | 30 +++ docs/WORKLOG.md | 1 + .../2026-07-29-sealed-secret-handoff.md | 106 +++------ docs/proposals/P45-public-config-in-code.md | 2 +- tests/test_runtime_secret_contract.py | 81 +++++++ 6 files changed, 144 insertions(+), 299 deletions(-) delete mode 100644 .github/workflows/seal-ai-secrets.yml create mode 100644 .github/workflows/seal-runtime-secrets.yml create mode 100644 tests/test_runtime_secret_contract.py diff --git a/.github/workflows/seal-ai-secrets.yml b/.github/workflows/seal-ai-secrets.yml deleted file mode 100644 index a46978e..0000000 --- a/.github/workflows/seal-ai-secrets.yml +++ /dev/null @@ -1,223 +0,0 @@ -name: seal-ai-secrets - -# AI owner 소유 Secret을 SealedSecret으로 봉인해 Infra에 넘긴다. -# -# 왜 이 workflow가 필요한가: -# GitHub Actions Secret은 Kubernetes Pod에 자동으로 전달되지 않는다. 값을 클러스터까지 -# 보내려면 누군가 한 번은 평문을 만져야 하는데, Infra는 "값을 열람하지 않은 채 배포"를 -# 요구했다(ai#32). SealedSecret은 공개키로 암호화한 manifest만 넘기므로 그 요구를 만족한다. -# -# 무엇을 절대 하지 않는가: -# - 평문 Secret YAML을 파일로 쓰지 않는다. kubeseal --raw 로 값 하나씩 암호화해 -# 암호문만 조립한다(kubectl create secret --dry-run 경로는 중간에 평문 base64 YAML을 만든다). -# - cluster credential을 쓰지 않는다. 이 레포에 kubeconfig가 없고 kubectl apply 도 하지 않는다. -# - set -x 를 켜지 않는다. 켜면 셸이 확장된 값을 그대로 로그에 찍는다. -# -# 트리거는 수동뿐이다. Secret 회전은 사람이 결정하는 사건이고, push마다 돌 이유가 없다. - -on: - workflow_dispatch: - inputs: - namespace: - description: 대상 네임스페이스 (SealedSecret은 scope=strict라 이름·네임스페이스가 암호문에 묶인다) - required: true - default: pinlog-dev - name: - description: Secret 리소스 이름 - required: true - default: ai-owner-secrets - -permissions: - contents: read - -concurrency: - # 같은 Secret을 동시에 두 번 봉인하면 어느 artifact가 최신인지 알 수 없다. - group: seal-ai-secrets - cancel-in-progress: false - -env: - KUBESEAL_VERSION: "0.27.1" - CERT_PATH: deploy/sealed-secrets/pinlog-dev-cert.pem - # ai#32에서 Infra가 제공한 controller 인증서의 지문. 인증서가 교체되면 여기서 멈춘다 — - # 엉뚱한 공개키로 봉인하면 controller가 복호화하지 못하고, 그 실패는 배포 시점에야 드러난다. - CERT_FINGERPRINT: "4A:C5:18:6C:57:94:76:F6:AA:E5:1C:C4:53:9D:4F:11:BB:C1:8F:3E:DF:8B:81:B8:1F:5E:A9:68:1C:5B:C6:A2" - -jobs: - seal: - name: seal / ai-owner-secrets - runs-on: ubuntu-latest - timeout-minutes: 10 - - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - persist-credentials: false - - # 값이 비어 있으면 봉인은 성공하고 배포가 401 또는 기동 실패로 죽는다. 여기서 멈추는 편이 낫다. - # - # 봉인 대상은 셋뿐이다. PINLOG_EMBEDDING_* 넷은 비밀이 아니고 정본이 코드에 있어서 - # (app/core/config.py 기본값, P45) 이미지에 이미 들어 있다 — 주입할 것이 없다. - # 실험·롤백으로 덮어써야 하면 Infra 가 ConfigMap 으로 넣으면 되고, 그 경로에 암호화가 - # 필요 없다. Actions Secret 에 그 넷이 등록돼 있어도 이 workflow 는 읽지 않는다. - - name: Verify secrets are present - env: - GMS_BASE_URL: ${{ secrets.GMS_BASE_URL }} - GMS_API_KEY: ${{ secrets.GMS_API_KEY }} - INTERNAL_SHARED_SECRET: ${{ secrets.INTERNAL_SHARED_SECRET }} - run: | - set -euo pipefail - missing="" - for key in GMS_BASE_URL GMS_API_KEY INTERNAL_SHARED_SECRET; do - eval "value=\${$key}" - [ -n "${value}" ] || missing="${missing} ${key}" - done - if [ -n "${missing}" ]; then - echo "::error::등록되지 않은 Actions Secret:${missing}" - echo "Settings → Secrets and variables → Actions 에서 등록하라. 값은 여기 출력되지 않는다." - exit 1 - fi - - # 앱이 기동 시 fail-fast 로 거르는 것과 같은 검사를 여기서 미리 한다. 그러지 않으면 - # 배포하고 Pod 이 죽어야 알게 된다. 값 자체는 찍지 않는다. - case "${GMS_BASE_URL}" in - *"/gmsapi/"*) ;; - *) echo "::error::GMS_BASE_URL 에 /gmsapi/ 가 없다. embedding·judge 양쪽이 이 형식을 요구한다"; exit 1 ;; - esac - echo "세 값 모두 존재하고 형식이 맞다." - - - name: Verify controller certificate - run: | - set -euo pipefail - actual="$(openssl x509 -in "${CERT_PATH}" -noout -fingerprint -sha256 \ - | sed 's/^.*Fingerprint=//')" - if [ "${actual}" != "${CERT_FINGERPRINT}" ]; then - echo "::error::인증서 지문이 다르다. Infra가 controller 키를 교체했는지 확인하라" - echo "expected ${CERT_FINGERPRINT}" - echo "actual ${actual}" - exit 1 - fi - # 만료된 인증서로 봉인하면 controller 가 거부한다. - openssl x509 -in "${CERT_PATH}" -noout -checkend 0 - echo "지문 일치. 유효기간 내." - - - name: Install kubeseal - run: | - set -euo pipefail - base="https://github.com/bitnami-labs/sealed-secrets/releases/download/v${KUBESEAL_VERSION}" - # 받는 파일 이름을 체크섬 manifest 의 이름과 반드시 같게 둔다. - # `sha256sum -c` 는 manifest 줄에 적힌 이름으로 파일을 여는데, 다른 이름으로 - # 받아 두면 "No such file or directory / FAILED open or read" 로 죽는다. - # 그래서 이름을 변수 하나에 담아 다운로드·검증·해제가 같은 값을 쓰게 한다. - tarball="kubeseal-${KUBESEAL_VERSION}-linux-amd64.tar.gz" - curl -fsSL -o "${tarball}" "${base}/${tarball}" - curl -fsSL -o checksums.txt "${base}/sealed-secrets_${KUBESEAL_VERSION}_checksums.txt" - # 릴리스가 함께 배포하는 체크섬으로 검증한다. 바이너리를 그대로 믿지 않는다. - grep "${tarball}" checksums.txt | sha256sum -c - - tar xzf "${tarball}" kubeseal - chmod +x kubeseal - ./kubeseal --version - - # 핵심 단계. --raw 는 값 하나를 받아 암호문 한 줄을 돌려준다. 평문은 파이프 안에만 - # 존재하고 파일·인자·로그 어디에도 남지 않는다(변수 확장은 셸 내부에서 끝나므로 - # ps 로도 보이지 않는다). - - name: Seal - env: - GMS_BASE_URL: ${{ secrets.GMS_BASE_URL }} - GMS_API_KEY: ${{ secrets.GMS_API_KEY }} - INTERNAL_SHARED_SECRET: ${{ secrets.INTERNAL_SHARED_SECRET }} - NS: ${{ inputs.namespace }} - NAME: ${{ inputs.name }} - run: | - set -euo pipefail - seal_one() { - printf '%s' "$1" | ./kubeseal --raw \ - --scope strict --namespace "${NS}" --name "${NAME}" --cert "${CERT_PATH}" - } - - enc_base_url="$(seal_one "${GMS_BASE_URL}")" - enc_api_key="$(seal_one "${GMS_API_KEY}")" - enc_internal="$(seal_one "${INTERNAL_SHARED_SECRET}")" - - mkdir -p out - # heredoc 을 쓰지 않는다. 이 스크립트는 YAML 블록 스칼라(run: |) 안이라 들여쓰기가 - # 곧 블록의 경계다 — heredoc 본문을 왼쪽 끝에 붙이면 workflow YAML 자체가 끊기고, - # 들여쓴 채 두면 그 공백이 산출 파일에 그대로 들어가 SealedSecret 이 깨진다. - # echo 로 조립하면 들여쓰기가 문자열 안에 있어 둘 다 피한다. - { - echo "# 생성: seal-ai-secrets workflow (run ${GITHUB_RUN_ID}, commit ${GITHUB_SHA})" - echo "# scope=strict — 이 name/namespace 조합에서만 복호화된다. 옮겨 쓰려면 다시 봉인해야 한다." - echo "apiVersion: bitnami.com/v1alpha1" - echo "kind: SealedSecret" - echo "metadata:" - echo " name: ${NAME}" - echo " namespace: ${NS}" - echo "spec:" - echo " encryptedData:" - echo " GMS_BASE_URL: ${enc_base_url}" - echo " GMS_API_KEY: ${enc_api_key}" - echo " INTERNAL_SHARED_SECRET: ${enc_internal}" - echo " template:" - echo " metadata:" - echo " name: ${NAME}" - echo " namespace: ${NS}" - echo " type: Opaque" - } > out/ai-owner-secrets.sealed.yaml - - # 산출물 검증 — 두 방향으로 본다. - # - # (1) 모든 encryptedData 값이 실제 암호문인가. - # kubeseal 출력은 "Ag" 로 시작하는 긴 base64 다. 봉인이 빈 문자열이나 평문을 - # 흘렸다면 여기서 걸린다. 아래 (2)보다 이쪽이 판정력이 높다. - while IFS= read -r line; do - enc="${line##*: }" - case "${enc}" in - Ag*) ;; - *) echo "::error::암호문 형식이 아닌 항목이 있다: ${line%%:*}"; rm -f out/ai-owner-secrets.sealed.yaml; exit 1 ;; - esac - if [ "${#enc}" -lt 100 ]; then - echo "::error::암호문이 지나치게 짧다: ${line%%:*} (${#enc}자)" - rm -f out/ai-owner-secrets.sealed.yaml - exit 1 - fi - done < <(sed -n '/^ encryptedData:/,/^ template:/p' out/ai-owner-secrets.sealed.yaml | grep '^ [A-Z]') - - # (2) 평문이 그대로 새지 않았는가. - # 짧은 값은 base64 암호문에 우연히 포함될 수 있어 거짓 양성을 만든다. - # 그 길이대는 (1)이 이미 덮으므로 12자 이상만 본다. - for value in "${GMS_BASE_URL}" "${GMS_API_KEY}" "${INTERNAL_SHARED_SECRET}"; do - [ "${#value}" -ge 12 ] || continue - if grep -qF -- "${value}" out/ai-owner-secrets.sealed.yaml; then - echo "::error::산출물에 평문이 포함됐다. 업로드하지 않고 중단한다" - rm -f out/ai-owner-secrets.sealed.yaml - exit 1 - fi - done - echo "봉인 완료. 암호문 3건 형식 확인, 평문 미포함 확인." - - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: ai-owner-secrets-sealed - path: out/ai-owner-secrets.sealed.yaml - # Infra가 받아 가면 끝나는 산출물이다. 암호문이라도 오래 둘 이유가 없다. - retention-days: 7 - if-no-files-found: error - - - name: Summary - env: - NS: ${{ inputs.namespace }} - NAME: ${{ inputs.name }} - run: | - { - echo "## SealedSecret 산출 완료" - echo - echo "| | |" - echo "|---|---|" - echo "| 대상 | \`${NS}/${NAME}\` |" - echo "| scope | strict |" - echo "| 키 | \`GMS_BASE_URL\` · \`GMS_API_KEY\` · \`INTERNAL_SHARED_SECRET\` |" - echo "| artifact | \`ai-owner-secrets-sealed\` (7일) |" - echo - echo "\`PINLOG_EMBEDDING_*\` 넷은 봉인 대상이 아니다 — 정본이 코드에 있어 이미지에 이미 들어 있다(P45)." - echo - echo "artifact를 받아 GitOps 레포에 넣고 적용하면 된다. 평문은 이 실행 어디에도 없다." - } >> "${GITHUB_STEP_SUMMARY}" diff --git a/.github/workflows/seal-runtime-secrets.yml b/.github/workflows/seal-runtime-secrets.yml new file mode 100644 index 0000000..af6c94a --- /dev/null +++ b/.github/workflows/seal-runtime-secrets.yml @@ -0,0 +1,30 @@ +name: seal-runtime-secrets + +on: + workflow_dispatch: + +permissions: + contents: read + id-token: write + +jobs: + seal: + name: seal / ai-dev + runs-on: ubuntu-latest + environment: pinlog-secrets-dev + timeout-minutes: 10 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + ref: ${{ github.sha }} + - name: Seal runtime Secrets and open the Infra PR + uses: Team-PinLog/infra/.github/actions/sealedsecret-infra-pr@9ada03256858ccc5372675de85ba3ebee2b86343 + env: + GMS_API_KEY: ${{ secrets.GMS_API_KEY }} + GMS_BASE_URL: ${{ secrets.GMS_BASE_URL }} + INTERNAL_SHARED_SECRET: ${{ secrets.INTERNAL_SHARED_SECRET }} + PINLOG_INFRA_SECRET_PR_TOKEN: ${{ secrets.PINLOG_INFRA_SECRET_PR_TOKEN }} + with: + policy: ai-dev + revision: ${{ github.sha }} diff --git a/docs/WORKLOG.md b/docs/WORKLOG.md index 787d117..f5aef93 100644 --- a/docs/WORKLOG.md +++ b/docs/WORKLOG.md @@ -4,6 +4,7 @@ | 날짜 | 작업 | 관련 문서 | |---|---|---| +| 2026-07-29 | S15P11A705-154 — protected Environment 기반 3 runtime owner 값 + Infra PR credential handoff를 canonical workflow로 정렬 | [runtime Secret handoff](implements/2026-07-29-sealed-secret-handoff.md) | | 2026-07-23 | AI 공용 설계를 docs `static/05` 단일 원본으로 확립 (docs#2) | [proposals](proposals/README.md) (P16) | | 2026-07-23 | FastAPI AI 서버 구현 명세 작성 + version→deletion race 리네임 (ai#1) | [spec/](spec/) | | 2026-07-23 | Keyword Preset seed 27개 (ai#2) | [implements](implements/2026-07-23-keyword-preset-seed.md), [spec/keyword-preset.md](spec/keyword-preset.md) | diff --git a/docs/implements/2026-07-29-sealed-secret-handoff.md b/docs/implements/2026-07-29-sealed-secret-handoff.md index e6a64fc..23f0aa5 100644 --- a/docs/implements/2026-07-29-sealed-secret-handoff.md +++ b/docs/implements/2026-07-29-sealed-secret-handoff.md @@ -1,86 +1,42 @@ -# SealedSecret handoff — AI 소유 값을 값 노출 없이 클러스터로 넘긴다 +# Runtime Secret handoff — AI 값은 Environment 경계에서만 전달한다 -`S15P11A705-96` · [ai#32](https://github.com/Team-PinLog/ai/pull/32) Infra 요청 ① +`S15P11A705-154` -## 무엇을 푸는가 +## 계약 -**GitHub Actions Secret은 Kubernetes Pod에 자동으로 전달되지 않는다.** 값을 클러스터까지 보내려면 누군가 한 번은 평문을 만져야 하는데, Infra는 *"key/token 값과 앱 호환성 설계를 AI owner 소유로 두고, 값을 열람하지 않은 채 암호화 Secret과 컨테이너만 배포하겠다"*는 경계를 세웠다. +정본 workflow는 `.github/workflows/seal-runtime-secrets.yml` 하나다. 수동 +`workflow_dispatch`로만 실행하며 GitHub Environment `pinlog-secrets-dev`의 보호 규칙과 +Secret을 사용한다. 일반 `ai-ci` workflow는 이 런타임 Secret을 읽지 않는다. -SealedSecret이 그 경계를 성립시킨다. controller의 **공개키로 암호화한 manifest**만 넘기므로 Infra는 평문을 볼 수 없고, 복호화는 클러스터 안의 controller만 할 수 있다. +workflow가 Infra 공용 action에 전달하는 Environment Secret 이름은 다음 네 개로 +고정한다. -이 리포트는 그 산출 경로(`.github/workflows/seal-ai-secrets.yml`)의 구현 기록이다. +- `GMS_API_KEY` +- `GMS_BASE_URL` +- `INTERNAL_SHARED_SECRET` +- `PINLOG_INFRA_SECRET_PR_TOKEN` -## 봉인 대상 7종 +값, placeholder, base64 표현 또는 평문 파일은 레포와 로그에 남기지 않는다. 현재 Infra의 +7-key runtime placeholder를 AI 레포의 source contract로 복제하지 않으며 공개 앱 설정도 +이 workflow가 소유하지 않는다. -| 키 | 성격 | 앱에서 | -|---|---|---| -| `GMS_API_KEY` | 비밀 | `config.py` 필수 | -| `GMS_BASE_URL` | 준비밀(엔드포인트) | 필수 · `/gmsapi/` 형식 검증 | -| `INTERNAL_SHARED_SECRET` | 비밀 | 필수 · `X-Internal-Secret` 헤더 | -| `PINLOG_EMBEDDING_MODEL` | **비밀 아님** | 필수 · 기본값 없음 | -| `PINLOG_EMBEDDING_DIMENSION` | **비밀 아님** | 필수 | -| `PINLOG_EMBEDDING_DISTANCE` | **비밀 아님** | 필수 | -| `PINLOG_EMBEDDING_PROFILE` | **비밀 아님** | 필수 · 나머지 셋을 부분 문자열로 포함해야 함 | +## 공급망 경계 -**EMBEDDING 넷은 비밀이 아니다.** 모델명·차원·거리함수·프로필 식별자이고 정본은 공개 문서 [P32](../proposals/README.md)에 있다. `ai#32`에서 Infra도 이 넷을 *"non-secret ConfigMap 명시 주입"*으로 분류했다. +- checkout은 실행 commit인 `${{ github.sha }}`를 사용하고 credential을 보존하지 않는다. +- workflow 권한은 `contents: read`, `id-token: write`뿐이다. +- Infra action은 commit + `9ada03256858ccc5372675de85ba3ebee2b86343`으로 고정한다. +- action 입력은 policy `ai-dev`와 revision `${{ github.sha }}`뿐이다. +- 별도 artifact upload나 `repository_dispatch` handoff를 두지 않는다. Infra PR 생성은 + 공용 action 계약에 위임한다. -그럼에도 Secret 경로로 다루는 것은 **Infra가 주입 경로를 하나로 요구했기 때문**이다. 값이 공개라는 사실과 "어디서 주입되는가"는 별개이고, 앱이 이 넷에 기본값을 두지 않으므로(`app/core/config.py`) 누락되면 Pod이 아예 뜨지 않는다 — 경로를 나누면 그 실패가 두 곳에서 날 수 있다. +## 운영과 검증 범위 -`DATABASE_URL`은 여기 없다. Infra가 별도 `ai-db-credentials`로 관리하고 `envFrom`으로 함께 연결한다. +Environment Secret을 회전한 뒤 workflow를 수동 실행하고, 생성된 Infra Draft PR의 source +revision과 변경 대상을 검토한다. 실패 또는 rollback은 Infra PR을 병합하지 않거나 기존 +GitOps revision으로 되돌리는 방식으로 처리한다. 이 변경은 live Secret, GitHub 설정 또는 +클러스터 workload를 직접 수정하지 않는다. -## 설계 결정 - -### 평문 YAML을 만들지 않는다 — `kubeseal --raw` - -흔한 경로는 `kubectl create secret generic --dry-run=client -o yaml | kubeseal`인데, 이 파이프의 **중간 산출물이 평문 base64 Secret YAML**이다. 파이프 안에만 있다 해도 한 단계 실수(리다이렉트, `tee`, 디버깅용 `cat`)로 파일이 된다. - -`--raw`는 값 하나를 받아 암호문 한 줄을 돌려준다. 평문은 셸 변수와 파이프에만 존재하고 파일·인자·로그 어디에도 남지 않는다. 변수 확장은 셸 내부에서 끝나므로 `ps`로도 보이지 않는다. - -대신 SealedSecret manifest를 손으로 조립해야 한다. `echo`로 쌓는다 — **heredoc은 쓸 수 없다.** 이 스크립트는 workflow YAML의 블록 스칼라(`run: |`) 안이라 들여쓰기가 곧 블록의 경계다. heredoc 본문을 왼쪽 끝에 붙이면 workflow YAML 자체가 끊기고, 들여쓴 채 두면 그 공백이 산출 파일에 들어가 SealedSecret이 깨진다. 실제로 둘 다 겪고서 `echo` 조립으로 바꿨다. - -### 배포 전에 실패시킬 수 있는 것은 배포 전에 실패시킨다 - -앱이 기동 시 fail-fast로 거르는 검사를 **봉인 시점에 미리** 돌린다. 그러지 않으면 배포하고 Pod이 죽어야 알게 된다. - -``` -GMS_BASE_URL /gmsapi/ 포함 (embedding·judge 양쪽이 요구) -PINLOG_EMBEDDING_DIMENSION 정수 -PINLOG_EMBEDDING_PROFILE 나머지 셋을 부분 문자열로 포함 - (app/core/config.py 의 profile 정합 검사와 같은 규칙) -``` - -profile이 어긋난 채 배포되면 **기존 임베딩이 전부 조회 대상에서 빠진다** — 조용히 검색 결과가 비는 종류의 실패라 배포 후에 알아채기 가장 어렵다. - -### 인증서를 지문으로 고정한다 - -`deploy/sealed-secrets/pinlog-dev-cert.pem`은 Infra가 `kube-system/sealed-secrets-controller`에서 가져다 준 공개 인증서다. 공개키라 레포에 커밋해도 안전하다. - -workflow가 매번 SHA-256 지문을 대조한다(`4A:C5:…:A2`). **엉뚱한 공개키로 봉인하면 controller가 복호화하지 못하고, 그 실패는 배포 시점에야 드러나기 때문**이다. 만료 검사도 함께 한다(현재 유효기간 2026-07-20 ~ 2036-07-17). - -### 산출물을 두 방향으로 검증한다 - -1. **모든 `encryptedData` 값이 `Ag`로 시작하는 100자 이상 base64인가** — 봉인이 빈 문자열이나 평문을 흘렸다면 여기서 걸린다 -2. **평문이 그대로 새지 않았는가** — 단 **12자 이상만 본다.** `1536`·`cosine` 같은 짧은 값은 base64 암호문에 우연히 포함될 수 있어 거짓 양성을 만든다. 그 길이대는 (1)이 이미 덮는다 - -### scope는 strict - -암호문이 `pinlog-dev/ai-owner-secrets`라는 **이름·네임스페이스 조합에 묶인다.** 다른 이름으로 옮겨 쓰려면 다시 봉인해야 한다. 봉인된 값이 엉뚱한 리소스로 복사되는 것을 controller가 거부하므로, 유출된 manifest 하나가 다른 네임스페이스에서 복호화되는 경로가 없다. - -## 검증 - -| 항목 | 결과 | -|---|---| -| 인증서 지문 대조 | `openssl x509 -noout -fingerprint -sha256` — Infra 제공 값과 **일치** | -| 인증서 유효기간 | `-checkend 0` 통과 (2036-07-17까지) | -| kubeseal 릴리스·asset 이름 | `v0.27.1` 및 `kubeseal-0.27.1-linux-amd64.tar.gz` · `sealed-secrets_0.27.1_checksums.txt` **실재 확인** | -| workflow YAML 파싱 | 통과 (7 steps) | -| 셸 블록 문법 | `bash -n` 5개 전부 통과 | -| **실제 실행** | **미실행** — Actions Secret 등록 후에만 가능하다 | - -**마지막 줄이 이 리포트의 한계다.** 봉인이 실제로 controller가 복호화할 수 있는 암호문을 만드는지는 **Infra가 클러스터에 적용해 봐야** 확정된다. 지문·형식·문법까지가 여기서 확인 가능한 범위다. - -## 미결 - -- **`PINLOG_AI_INFRA_PR_TOKEN`** — Infra가 요구 목록에 넣었으나 **이 앱이 읽지 않는다**(`ai` 레포 전체 참조 0건). GitOps 레포에 PR을 여는 용도로 보이며, 그렇다면 전달 방식이 artifact가 아니라 PR이 된다. 대상 레포·권한 범위·만료·커밋 경로를 확인한 뒤 workflow를 그쪽으로 바꾼다 -- **회전 절차** — 지금은 값을 갱신하고 workflow를 다시 돌리면 새 manifest가 나온다. 클러스터의 기존 Secret을 언제 어떻게 교체할지는 Infra 몫이고 합의되지 않았다 -- **prod** — 이 인증서는 `pinlog-dev` 전용이다. prod는 별도 controller·인증서가 필요하다 +레포에서는 static contract test로 경로, trigger, environment, permission, checkout SHA, +action SHA, 입력과 exact key set을 검증한다. 실제 Secret 값과 live sealing 결과는 이 +변경에서 조회하거나 실행하지 않는다. diff --git a/docs/proposals/P45-public-config-in-code.md b/docs/proposals/P45-public-config-in-code.md index ef0e711..7d36841 100644 --- a/docs/proposals/P45-public-config-in-code.md +++ b/docs/proposals/P45-public-config-in-code.md @@ -78,7 +78,7 @@ Infra 가 주입 경로를 하나로 요구했으므로 그 요구를 따르는 | `app/core/config.py` | EMBEDDING 넷에 기본값. `alias` 는 유지되므로 환경변수 덮어쓰기 경로는 그대로 | | `.env.example` | 비밀/공개 두 절로 나누고 각 절의 규칙을 명시 | | `model-profile.md §2.1` | *"기본값을 넣지 않는다"* → *"기본값이 정본이다"*. 개정 사실과 근거를 인용문으로 남김 | -| `seal-ai-secrets.yml` | 봉인 대상 7종 → **3종**. EMBEDDING 넷은 주입할 것이 없어 뺀다 | +| `seal-runtime-secrets.yml` | 런타임 owner 값 **3종**만 봉인하고 Infra PR token은 action 인증에만 쓴다. EMBEDDING 넷은 주입하지 않는다 | | GitHub Actions Secret | 등록된 EMBEDDING 넷은 **지우지 않아도 된다** — workflow 가 읽지 않을 뿐이다 | **모델을 교체할 때**의 절차가 이렇게 바뀐다. diff --git a/tests/test_runtime_secret_contract.py b/tests/test_runtime_secret_contract.py new file mode 100644 index 0000000..bed4d2c --- /dev/null +++ b/tests/test_runtime_secret_contract.py @@ -0,0 +1,81 @@ +import re +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = ROOT / ".github" / "workflows" / "seal-runtime-secrets.yml" +LEGACY_WORKFLOW = ROOT / ".github" / "workflows" / "seal-ai-secrets.yml" +AI_CI = ROOT / ".github" / "workflows" / "ai-ci.yml" +ACTION = ( + "Team-PinLog/infra/.github/actions/sealedsecret-infra-pr@" + "9ada03256858ccc5372675de85ba3ebee2b86343" +) +SECRET_KEYS = { + "GMS_API_KEY", + "GMS_BASE_URL", + "INTERNAL_SHARED_SECRET", + "PINLOG_INFRA_SECRET_PR_TOKEN", +} + + +def load_workflow() -> tuple[dict, str]: + text = WORKFLOW.read_text(encoding="utf-8") + return yaml.load(text, Loader=yaml.BaseLoader), text + + +def test_canonical_workflow_is_manual_only_and_legacy_workflow_is_removed(): + workflow, text = load_workflow() + + assert not LEGACY_WORKFLOW.exists() + assert workflow["on"] == {"workflow_dispatch": ""} + assert "repository_dispatch" not in text + + +def test_sealing_job_has_the_minimal_environment_and_permission_contract(): + workflow, _ = load_workflow() + + assert workflow["permissions"] == {"contents": "read", "id-token": "write"} + assert len(workflow["jobs"]) == 1 + job = next(iter(workflow["jobs"].values())) + assert job["environment"] == "pinlog-secrets-dev" + + +def test_checkout_and_shared_action_are_immutable_and_revision_bound(): + workflow, text = load_workflow() + job = next(iter(workflow["jobs"].values())) + checkout = next( + step + for step in job["steps"] + if step.get("uses", "").startswith("actions/checkout@") + ) + action = next(step for step in job["steps"] if step.get("uses") == ACTION) + + assert re.fullmatch(r"actions/checkout@[0-9a-f]{40}", checkout["uses"]) + assert checkout["with"] == { + "persist-credentials": "false", + "ref": "${{ github.sha }}", + } + assert action["with"] == { + "policy": "ai-dev", + "revision": "${{ github.sha }}", + } + assert "Team-PinLog/infra/.github/actions/sealedsecret-infra-pr@" in text + + +def test_shared_action_receives_only_the_exact_environment_secret_keys(): + workflow, text = load_workflow() + job = next(iter(workflow["jobs"].values())) + action = next(step for step in job["steps"] if step.get("uses") == ACTION) + + assert set(action["env"]) == SECRET_KEYS + assert action["env"] == {key: f"${{{{ secrets.{key} }}}}" for key in SECRET_KEYS} + assert "placeholder" not in text.lower() + assert "base64" not in text.lower() + + +def test_general_ci_does_not_read_runtime_or_handoff_secrets(): + text = AI_CI.read_text(encoding="utf-8") + + for key in SECRET_KEYS: + assert f"secrets.{key}" not in text From 7bb6bbf7cde28f3182ab7bafe3461ee09964cf42 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 29 Jul 2026 10:25:48 +0000 Subject: [PATCH 2/3] =?UTF-8?q?test(S15P11A705-154):=20=EC=A4=91=EB=B3=B5?= =?UTF-8?q?=20Secret=20workflow=EB=A5=BC=20=EC=B0=A8=EB=8B=A8=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_runtime_secret_contract.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_runtime_secret_contract.py b/tests/test_runtime_secret_contract.py index bed4d2c..e2cb95d 100644 --- a/tests/test_runtime_secret_contract.py +++ b/tests/test_runtime_secret_contract.py @@ -28,6 +28,7 @@ def test_canonical_workflow_is_manual_only_and_legacy_workflow_is_removed(): workflow, text = load_workflow() assert not LEGACY_WORKFLOW.exists() + assert list(WORKFLOW.parent.glob("*seal*secret*.yml")) == [WORKFLOW] assert workflow["on"] == {"workflow_dispatch": ""} assert "repository_dispatch" not in text @@ -70,6 +71,13 @@ def test_shared_action_receives_only_the_exact_environment_secret_keys(): assert set(action["env"]) == SECRET_KEYS assert action["env"] == {key: f"${{{{ secrets.{key} }}}}" for key in SECRET_KEYS} + for key in SECRET_KEYS: + assert text.count(f"${{{{ secrets.{key} }}}}") == 1 + assert all( + "${{ secrets." not in str(step) + for step in job["steps"] + if step is not action + ) assert "placeholder" not in text.lower() assert "base64" not in text.lower() From bc02c9c799bdce7ff0bd2642e5817487280ad1e6 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 29 Jul 2026 10:35:55 +0000 Subject: [PATCH 3/3] =?UTF-8?q?ci(S15P11A705-154):=20=EC=B5=9C=EC=A2=85=20?= =?UTF-8?q?Infra=20action=EC=9D=84=20=EA=B3=A0=EC=A0=95=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/seal-runtime-secrets.yml | 2 +- docs/implements/2026-07-29-sealed-secret-handoff.md | 2 +- tests/test_runtime_secret_contract.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/seal-runtime-secrets.yml b/.github/workflows/seal-runtime-secrets.yml index af6c94a..2b45367 100644 --- a/.github/workflows/seal-runtime-secrets.yml +++ b/.github/workflows/seal-runtime-secrets.yml @@ -19,7 +19,7 @@ jobs: persist-credentials: false ref: ${{ github.sha }} - name: Seal runtime Secrets and open the Infra PR - uses: Team-PinLog/infra/.github/actions/sealedsecret-infra-pr@9ada03256858ccc5372675de85ba3ebee2b86343 + uses: Team-PinLog/infra/.github/actions/sealedsecret-infra-pr@84458bf35e341b79e91ce21a3667e9d3f7454068 env: GMS_API_KEY: ${{ secrets.GMS_API_KEY }} GMS_BASE_URL: ${{ secrets.GMS_BASE_URL }} diff --git a/docs/implements/2026-07-29-sealed-secret-handoff.md b/docs/implements/2026-07-29-sealed-secret-handoff.md index 23f0aa5..903ee2a 100644 --- a/docs/implements/2026-07-29-sealed-secret-handoff.md +++ b/docs/implements/2026-07-29-sealed-secret-handoff.md @@ -25,7 +25,7 @@ workflow가 Infra 공용 action에 전달하는 Environment Secret 이름은 다 - checkout은 실행 commit인 `${{ github.sha }}`를 사용하고 credential을 보존하지 않는다. - workflow 권한은 `contents: read`, `id-token: write`뿐이다. - Infra action은 commit - `9ada03256858ccc5372675de85ba3ebee2b86343`으로 고정한다. + `84458bf35e341b79e91ce21a3667e9d3f7454068`으로 고정한다. - action 입력은 policy `ai-dev`와 revision `${{ github.sha }}`뿐이다. - 별도 artifact upload나 `repository_dispatch` handoff를 두지 않는다. Infra PR 생성은 공용 action 계약에 위임한다. diff --git a/tests/test_runtime_secret_contract.py b/tests/test_runtime_secret_contract.py index e2cb95d..96b05ad 100644 --- a/tests/test_runtime_secret_contract.py +++ b/tests/test_runtime_secret_contract.py @@ -9,7 +9,7 @@ AI_CI = ROOT / ".github" / "workflows" / "ai-ci.yml" ACTION = ( "Team-PinLog/infra/.github/actions/sealedsecret-infra-pr@" - "9ada03256858ccc5372675de85ba3ebee2b86343" + "84458bf35e341b79e91ce21a3667e9d3f7454068" ) SECRET_KEYS = { "GMS_API_KEY",