feat(agent-ops): 환경별 토큰 발급 스킬을 프로젝트로 승격한다

dev와 dev-corp 요청을 명시적 환경 프로필로 라우팅하고, private override 없이 단일 실행기에서 Edge 검증과 환경별 metadata 정책을 처리한다.
This commit is contained in:
toki 2026-08-03 10:21:35 +09:00
parent 8fff0d5f95
commit b003394568
6 changed files with 2098 additions and 144 deletions

View file

@ -104,7 +104,7 @@
- `설정으로 빼`는 현재 문맥이 UI 노출 제외이고 대상 설정이 식별될 때만 위 라우팅을 사용한다. config 추출·스키마 삭제와 구분되지 않으면 자동 기록하지 않고 확인 대상으로 남긴다.
- UI 없는 사용자 CRUD, OpenAI-compatible 사용자/principal 추가·조회·수정·비활성화·삭제, principal token 운영 CRUD: `agent-ops/skills/project/iop-user-crud-ops/SKILL.md`
- Confluence 문서 작성, 컨플 문서 생성·갱신·검토, Lab2 문서 작성, lgucorp 위키 업데이트: `agent-ops/skills/project/lgucorp-confluence-docs/SKILL.md`
- OpenAI-compatible 사용자 token 발급·추가, dev-corp 사용자 추가와 token 발급, principal_ref token 등록, principal alias 매핑, raw IOP token 1회 전달: `agent-ops/skills/project/openai-usage-token-issue/SKILL.md`
- dev 또는 dev-corp OpenAI-compatible 사용자 token 발급·추가, `dev에 <사용자> 사용자 토큰 만들어줘`, `dev-corp에 <사용자> 사용자 토큰 만들어줘`, principal_ref token 등록, principal alias 매핑, raw IOP token 1회 전달: `agent-ops/skills/project/openai-usage-token-issue/SKILL.md`
- dev-corp 배포, dev-corp runtime 배포, 회사망 mac-mini Edge/Node dev-corp 환경 배포, dev-corp provider pool 배포, dev-corp OpenAI-compatible capacity smoke 검증: `agent-ops/skills/project/dev-corp-runtime-deploy/SKILL.md`
- dev 배포, dev-runtime 배포, Edge/Node dev 환경 배포, provider pool 배포, OpenAI-compatible capacity smoke 검증: `agent-ops/skills/project/dev-runtime-deploy/SKILL.md`
- 사용자 실행 파이프라인 검증, repo 내부 edge-node 진단, 메시지 2회 왕복, edge command 응답, 보조 E2E smoke, full-cycle 실제 구동, `scripts/dev/edge.sh`/`scripts/dev/node.sh` 진단 테스트: `agent-ops/skills/project/e2e-smoke/SKILL.md`

View file

@ -1,173 +1,123 @@
---
name: openai-usage-token-issue
version: 1.0.6
description: OpenAI-compatible 사용자 추가와 usage metering용 IOP token을 발급하고 private Edge mapping, local secret store, 사용자가 승인한 Confluence metadata table을 동기화하는 운영 절차
description: dev 또는 dev-corp OpenAI-compatible 사용자 토큰 생성 요청을 환경별 단일 실행으로 처리한다. 사용자가 "dev에 사용자 토큰 만들어줘", "dev-corp 사용자 추가하고 토큰 발급해줘", principal_ref 등록, raw IOP token 1회 전달을 요청할 때 사용한다.
---
# openai-usage-token-issue
## 목적
OpenAI-compatible 사용량 metering에 쓸 IOP bearer token을 발급하고, raw token 없이 `token_ref`, token hash, `principal_ref`, 내부 alias 매핑만 운영 기록에 남긴다.
IOP는 사용자/테넌트 source of truth를 소유하지 않고, 외부 principal id 또는 내부 운영 id를 참조값으로만 다룬다.
이 스킬은 dev-corp에서 사용자 추가와 token 발급을 함께 요청받았을 때의 단일 진입점이다.
자연어 사용자 토큰 생성 요청 한 건을 선택한 환경에서 단일 실행기로 처리한다. 안전 절차를 수동 명령으로 펼치지 않는다.
## 언제 호출할지
단일 실행기는 다음을 순서대로 묶는다.
- OpenAI-compatible 호출 사용량을 특정 `principal_ref` 또는 내부 alias로 귀속할 IOP token을 새로 발급할 때
- dev/dev-corp 운영자가 Grafana 사용량 label에 노출될 `token_ref`, `principal_alias`를 준비할 때
- raw bearer token을 tracked 파일이나 최종 보고에 남기지 않고 1회 전달해야 할 때
- dev-corp 사용자 추가와 함께 해당 사용자의 IOP token 발급·private Edge mapping 반영을 요청받았을 때
1. 환경 프로필, local secret store, active Edge 사전점검
2. `principal_ref``token_ref` 중복 확인 및 idempotent 재개
3. raw token 생성과 source-format-preserving Edge 후보 생성
4. config check, restart-required 판정, backup/cutover/restart/자동 rollback
5. 환경별 OpenAI-compatible API smoke
6. dev-corp Confluence metadata 동기화
7. tracked leak 확인과 macOS clipboard 1회 전달
## 환경 선택
사용자 요청에서 환경을 명시적으로 선택한다. 환경이 없으면 추론하거나 기본값을 적용하지 않고 `environment_not_supported`로 중단한다.
| 자연어 요청 | `env` | API 검증 | Confluence |
|---|---|---|---|
| `dev에 <사용자> 사용자 토큰 만들어줘` | `dev` | SSH runner 내부 `127.0.0.1` | `not-applicable` |
| `dev-corp에 <사용자> 사용자 토큰 만들어줘` | `dev-corp` | 승인된 public HTTPS | 필수 metadata 동기화 |
`dev` public endpoint는 HTTP이므로 raw token을 public URL로 보내지 않는다. 실행기는 SSH stdin으로 runner에 전달하고 loopback API에서만 smoke한다.
## 입력
- `operation`: 사용자 추가와 함께 발급할 때는 `create`를 사용한다. token만 추가하거나 회전할 때는 작업 의도를 명시한다. (기본: `create`)
- `env`: 대상 환경. 사용자 추가 발급 기본값은 `dev-corp`이다. (기본: `dev-corp`)
- `principal_ref`: 외부 사용자/테넌트 프로젝트 또는 운영 시스템의 principal 참조값 (필수)
- `principal_alias`: Grafana에 노출할 내부 alias. 없으면 `principal_ref`에서 secret이 아닌 짧은 별칭을 정한다. (선택)
- `token_ref`: metric label과 설정에 쓸 안정 token 참조값. 한 `principal_ref`가 여러 앱/통합을 운영하면 앱/통합/용도별로 서로 다른 `token_ref`를 발급한다. 없으면 token hash prefix로 만든다. (선택)
- `output_path`: raw token을 보관해야 한다면 이 프로젝트 repo root의 gitignored `token/.dev-corp-iop-token`을 사용한다. 이 파일 외의 운영 기록은 raw token 없이 남긴다. (선택)
실제 식별자는 command argument, 환경변수, tracked 파일에 넣지 않고 실행기의 stdin JSON으로만 전달한다.
## 이 프로젝트의 local secret store
```json
{
"operation": "create",
"env": "dev",
"principal_ref": "<operator-private-principal>",
"principal_alias": "<optional-ascii-alias>",
"token_ref": "<optional-stable-ref>"
}
```
- `token/.dev-corp-iop-token`은 repo root의 operator-local 파일이며, 한 줄에 `<private-user-key>: <raw-token>`을 기록한다. 기본 `private-user-key`는 요청자가 지정한 식별자다.
- 발급 전에 `.gitignore`에 정확한 `/token/` 항목이 있는지 확인하고, 없으면 token directory 경로만 추가한다. raw token이나 사용자 값 자체를 `.gitignore`에 쓰지 않는다.
- 파일은 `umask 077`으로 만들거나 유지해 mode `0600`이어야 한다. 기존 기록을 보존하기 위해 `token/` 안의 후보 파일을 만든 뒤 검증하고 원자적으로 교체한다.
- 요청자가 이메일을 private-user-key 또는 dev-corp `principal_ref`로 명시하면, 그 이메일은 이 ignored local store와 private Edge 설정에만 둔다. tracked 파일, 스킬 예시, 최종 보고에는 실제 이메일을 쓰지 않는다.
- `operation`: `create`만 지원한다.
- `env`: `dev` 또는 `dev-corp`를 반드시 전달한다.
- `principal_ref`: 요청자가 지정한 외부 principal 참조값이다.
- `principal_alias`: 없으면 principal local-part를 ASCII kebab-case로 정규화한다.
- `token_ref`: 없으면 `iop-<env>-<principal_alias>`를 사용한다.
## 승인된 Confluence metadata table 동기화
## 단일 실행
- 사용자가 사용자별 발급 현황을 특정 Confluence path에 표로 동기화하라고 명시했을 때만 target URL을 ignored operator-local 파일 `token/.dev-corp-iop-confluence-target`에 한 줄로 보관한다. 이 파일도 mode `0600`, ignored, untracked여야 하며, tracked 스킬·문서·최종 보고에는 draft share URL 또는 식별자를 쓰지 않는다.
- Confluence 갱신 전에는 local secret store의 private-user-key 집합과 active `openai.principal_tokens[]``principal_ref` 집합을 1:1로 대조한다. local store에 없는 service/smoke principal은 표에서 제외하며, 불일치 또는 중복이면 표를 쓰지 않고 불일치를 보고한다.
- 표의 source of truth는 local secret store의 private-user-key와 active Edge의 `principal_ref`, `principal_alias`, `token_ref`, 활성 상태다. 표는 `사용자`, `principal alias`, `token ref`, `상태`, `동기화 시각` 열만 사용하고 행은 `principal_ref`로 upsert한다. raw token, token hash, Authorization 값, provider credential은 storage body와 API payload에 넣지 않는다.
- 실제 갱신은 `lgucorp-confluence-docs` 스킬의 `sync-token-metadata-table` 절차를 따른다. HTTP Basic 인증과 table JSON payload는 stdin 또는 동등한 비노출 stream으로 전달하며 raw token을 shell 인자 또는 payload에 넣지 않는다.
- target draft/page는 id, title, status, version, parent, storage body를 먼저 읽는다. body가 비어 있으면 관리 섹션과 표를 쓰고, 비어 있지 않은 body는 `IOP 사용자 토큰 발급 현황` 관리 섹션만 교체한다.
- Confluence write 뒤에는 같은 content id를 다시 읽어 title·status·version·parent와 metadata 행 수·사용자/alias/ref/status 값을 active mapping과 내부 비교한다. 인증·권한·version conflict·재조회 실패 시 raw token store나 Edge mapping을 변경하거나 write를 자동 재시도하지 않는다.
## 사용자 추가(create) 기준
- 요청자가 이메일을 명시하면 그 값은 private-user-key와 dev-corp `principal_ref`로만 사용한다. tracked 문서·스킬 예시·최종 보고에는 실제 값을 쓰지 않는다.
- `principal_alias`는 요청자 식별에 쓸 짧은 ASCII alias로 정하고, `token_ref``iop-dev-corp-<alias>`처럼 환경과 용도를 포함한 안정값으로 정한다.
- active private Edge config에서 같은 `principal_ref` 또는 `token_ref`를 먼저 찾는다. 하나라도 있으면 새 raw token을 발급하지 않고 기존 매핑의 활성 상태를 확인한다.
- `operation=create``iop-user-crud-ops`의 create 책임과 함께 적용하되, raw token 생성·local secret store·principal token 매핑 절차는 이 스킬이 소유한다.
## 먼저 확인할 것
- [ ] `principal_ref`가 secret, provider token, provider identity가 아니라 외부 시스템 참조값인지 확인한다. 운영자가 private dev-corp 매핑의 이메일을 명시한 경우에는 ignored local store와 private Edge 설정으로 범위를 제한한다.
- [ ] raw token을 tracked `docs/`, `agent-roadmap/`, `agent-spec/`, `configs/`, git diff, shell history, 최종 보고에 남기지 않을 전달 경로를 정한다.
- [ ] raw token 보관이 필요한 경우 `token/.dev-corp-iop-token`의 ignore 상태와 mode `0600`을 확인한다.
- [ ] Confluence 동기화를 요청받은 경우 `token/.dev-corp-iop-confluence-target`의 mode `0600`, ignored, untracked 상태와 대상 page 접근을 확인한다.
- [ ] `token_ref``principal_alias`가 낮은 cardinality label로 안전한 값인지 확인한다.
- [ ] 같은 `principal_ref`에 여러 앱/통합용 token이 필요한 경우 각 token의 앱/통합/용도 구분이 `token_ref`에 반영되는지 확인한다.
- [ ] 기존 token을 회전하는 경우 기존 `token_ref`를 재사용할지 새 `token_ref`를 만들지 운영 정책을 확인한다.
## 실행 절차
1. **입력 정규화**
- `principal_ref` 앞뒤 공백을 제거한다.
- `principal_alias`는 공백을 `-`로 바꾸고, 운영자가 식별할 수 있는 짧은 ASCII alias로 둔다.
- `token_ref`를 직접 받지 않았으면 생성할 token hash의 앞 16자를 사용해 `ioptok_<hash-prefix>` 형식으로 만든다.
- `operation=create`이면 raw token 생성 전에 active private Edge config의 `principal_ref``token_ref` 중복을 확인한다. 기존 매핑이 있으면 발급을 중단하고 활성 상태를 검증한다.
2. **raw token 생성**
- 현재 shell에서 `set +x`를 확인한다.
- 아래 형태의 고엔트로피 token을 생성한다. 실제 출력은 operator에게 1회만 전달한다.
repo root에서 아래 고정 command를 시작하고 request JSON은 process stdin으로 보낸다. 실제 식별자를 shell command 문자열에 보간하지 않는다.
```bash
set +x
umask 077
raw_token="iop_$(openssl rand -base64 36 | tr '+/' '-_' | tr -d '=')"
token_hash="$(printf '%s' "$raw_token" | sha256sum | awk '{print $1}')"
token_ref="${token_ref:-ioptok_${token_hash:0:16}}"
python3 agent-ops/skills/project/openai-usage-token-issue/scripts/issue_token.py
```
3. **local secret store 갱신**
- raw token을 보관해야 할 때만 repo root의 `token/.dev-corp-iop-token``<private-user-key>: <raw-token>` 한 줄을 추가한다.
- 새 파일 또는 후보 파일은 `umask 077`으로 만들고, 기존 매핑을 보존한 뒤 mode `0600` 및 ignore 상태를 검증한다.
- raw token을 shell 인자, trace, 화면 출력, tracked diff에 노출하지 않는다.
Codex는 자연어 요청을 stdin JSON으로 변환해 실행기에 한 번 전달한다. redacted JSON 결과만 해석하며 내부 단계를 수동 명령으로 재구성하지 않는다.
4. **사용자 추가와 private Edge 후보 반영**
- `operation=create`이면 active private Edge config의 `openai.principal_tokens[]`에서 `principal_ref``token_ref` 중복을 다시 확인한다.
- local secret store는 `token/` 안의 후보 파일에 기존 레코드와 새 `<private-user-key>: <raw-token>`을 함께 기록하고, mode `0600`·ignore 상태를 확인한 뒤 원자적으로 교체한다.
- raw token은 SSH 표준 입력처럼 비노출 stream으로만 private Edge config 후보 생성기에 전달한다. 후보 config에는 `token_ref`, SHA-256 hash, `principal_ref`, `principal_alias`만 기록한다.
- `openai.principal_tokens[]` 변경은 restart-required다. 환경 배포 스킬의 config check·refresh dry-run·backup/mv cutover·Edge restart 절차를 따른다. live apply 완료로 처리하지 않는다.
5. **raw-token-free 운영 기록 작성**
- tracked 문서나 공유 운영 기록에는 아래 필드만 남긴다.
```yaml
token_ref: "<token_ref>"
principal_ref: "<principal_ref>"
principal_alias: "<principal_alias>"
token_hash_sha256: "<token_hash>"
status: active
```
6. **승인된 Confluence metadata table 동기화**
- 동기화 요청이 있을 때만 local secret store의 private-user-key와 active Edge `principal_tokens[]``principal_ref` 기준으로 1:1 대조한다. 불일치 또는 중복이면 표를 쓰지 않고 `blocked`로 보고한다.
- target URL은 `token/.dev-corp-iop-confluence-target`에서만 읽고, `lgucorp-confluence-docs``sync-token-metadata-table` 인증·대상 재조회·version 기반 update 절차를 사용한다.
- 표에는 `사용자`, `principal alias`, `token ref`, `상태: active`, `동기화 시각`만 넣는다. raw token, token hash, Authorization 값, provider credential, draft share URL은 넣지 않는다.
- target body가 비어 있으면 관리 섹션과 표를 쓰고, 비어 있지 않으면 `IOP 사용자 토큰 발급 현황` 관리 섹션만 대체한다. 다른 섹션, title, parent, 권한은 변경하지 않는다.
- 갱신 후 page 재조회로 table의 행 수·사용자/alias/ref/status metadata를 active mapping과 내부 검증한다. 실패하면 결과를 `blocked`로 보고하고, 앞선 token/Edge 상태를 되돌리거나 새 token을 발급하지 않는다.
7. **raw token 1회 전달**
- raw token은 operator-only 채널로 한 번만 전달한다.
- 채팅 최종 보고, git diff, tracked 문서, 검증 출력에는 raw token을 쓰지 않는다.
8. **누출 확인**
- 저장소 안에 raw token이 남지 않았는지 조용한 검색으로 확인한다. 실패 시 출력에 raw token이 찍히지 않게 한다.
- 검사 대상에서 의도된 ignored `token/` directory는 제외하되, 그 안의 secret store가 추적되지 않았음을 별도로 확인한다.
읽기 전용 환경 준비 상태 확인도 같은 stdin request를 사용한다.
```bash
if rg -q -F "$raw_token" agent-ops agent-roadmap agent-spec agent-contract docs configs apps packages proto; then
echo "raw token leak detected in tracked workspace paths"
exit 1
fi
echo "raw token not found in tracked workspace paths"
python3 agent-ops/skills/project/openai-usage-token-issue/scripts/issue_token.py --preflight
```
9. **결과 보고**
- `token_ref`, `principal_ref`, `principal_alias`, 매핑 기록 위치, Confluence metadata table 동기화 상태, raw token 전달 여부만 보고한다.
- raw token과 전체 token hash는 보고하지 않는다.
로컬 결정론적 테스트:
## 실행 결과 검증
- [ ] `operation=create`의 active private Edge config에 대상 `principal_ref``token_ref`가 각각 한 번만 존재하는가
- [ ] raw token이 operator에게 1회만 전달되었는가
- [ ] tracked 파일에는 raw token이 없고, `token_ref`, `principal_ref`, `principal_alias`, hash만 남았는가
- [ ] `token/.dev-corp-iop-token`이 mode `0600`이고 `git check-ignore -q token/.dev-corp-iop-token`을 통과하며 `git ls-files --error-unmatch token/.dev-corp-iop-token`이 실패하는가
- [ ] Confluence 동기화를 요청한 경우 `token/.dev-corp-iop-confluence-target`이 mode `0600`, ignored, untracked이고, local private-user-key 집합과 active Edge `principal_ref` 집합이 일치하는가
- [ ] Confluence table은 local secret store의 각 사용자당 정확히 한 행이고 `사용자`, alias, ref, status metadata가 active mapping과 일치하며 raw token·token hash를 포함하지 않는가
- [ ] Confluence 갱신 뒤 같은 draft/page의 title, status, parent가 유지되고 version·table 행 수가 기대값으로 재조회되는가
- [ ] Edge restart 후 새 token으로 `/v1/models``/v1/chat/completions` 인증이 성공하고, 가능한 경우 usage metric의 principal/token label이 관측되는가
- [ ] `rg -q -F "$raw_token" ...` 누출 확인이 실패하지 않았는가
- [ ] 최종 보고에 raw token, provider token, provider identity, raw prompt/response가 포함되지 않았는가
- 검증 실패 시: raw token을 폐기하고 새 token을 발급한다. 누출된 tracked 파일은 수정한 뒤 다시 누출 확인을 실행하고, local secret store는 새 후보 파일 검증 뒤에만 교체한다.
## 출력 형식
```text
OpenAI usage token issue
- operation: <create|rotate-token|other>
- token_ref: <token_ref>
- principal_ref: <principal_ref>
- principal_alias: <principal_alias>
- secret_store: token/.dev-corp-iop-token
- edge_mapping: <candidate-validated-and-restarted|existing-verified|not-applied>
- confluence_metadata_sync: <updated|not-requested|blocked>
- mapping_record: <path or operator-private store>
- raw_token_delivered_once: <yes|no>
- leak_check: <pass|fail>
- notes: raw token omitted from report
```bash
python3 agent-ops/skills/project/openai-usage-token-issue/scripts/issue_token.py --self-test
```
## 금지 사항
## 환경 프로필과 private 경계
- raw token을 tracked 파일, 최종 보고, 로그, metric label, Grafana dashboard, shell trace에 남기지 않는다.
- raw token 또는 실제 요청자 식별자를 `.gitignore`, 스킬 예시, tracked 문서, shell 인자에 쓰지 않는다. `.gitignore`에는 `/token/` directory 경로만 둔다.
- raw token, token hash, Authorization 값, provider credential, draft share URL을 Confluence metadata table 또는 Confluence API payload에 넣지 않는다.
- local secret store와 active Edge mapping이 1:1로 대조되지 않은 상태에서 Confluence 표를 갱신하거나, 관리 섹션 밖의 본문·title·parent를 변경하지 않는다.
- active config에 같은 `principal_ref` 또는 `token_ref`가 있는데 새 raw token을 발급하거나 중복 entry를 추가하지 않는다.
- `metadata.user`, provider token, provider identity를 사용자 식별 source로 쓰지 않는다.
- `request_id`, `session_id`, raw token, raw prompt, raw response 같은 high-cardinality 또는 secret 값을 metric label 후보로 만들지 않는다.
- 사용자 CRUD, tenant/org source of truth, token 제한 enforcement를 이 스킬 책임으로 확장하지 않는다.
- non-secret 환경 설정은 `profiles/dev.json`, `profiles/dev-corp.json`만 사용한다. request가 host, path, URL, transport를 덮어쓰게 하지 않는다.
- remote helper도 `environment` allowlist로 exact runtime path, listener, process command를 선택한다.
- raw token store는 `dev=token/.dev-iop-token`, `dev-corp=token/.dev-corp-iop-token`이다.
- dev-corp Confluence credential은 `token/.lgu-atlassian-user`, `token/.lgu-atlassian-token`에서만 읽는다.
- 위 operator-local 파일은 모두 `0600`, ignored, untracked여야 한다. tracked 스킬과 프로필에 개인 credential을 기록하지 않는다.
## 완료 기준
공통 완료 필드:
- `result=completed`
- `edge_mapping=activated|existing-verified`
- `api_smoke=passed`
- `raw_token_delivered_once=true`
- `delivery_channel=local_clipboard`
- `leak_check=passed`
환경별 metadata 필드:
- `dev`: `confluence_metadata_sync=not-applicable`
- `dev-corp`: `confluence_metadata_sync=updated`
`result=blocked`이면 완료로 보고하지 않는다. 동일 입력 재실행은 중복 발급하지 않고 active mapping과 local raw token hash를 대조해 재개한다.
## 안전 규칙
- raw token은 선택 환경의 local secret store에만 저장하고 최종 응답, 로그, tracked diff에 쓰지 않는다.
- raw token은 remote process argument에 넣지 않고 SSH stdin payload로만 전달한다.
- Edge config에는 `token_ref`, SHA-256 hash, `principal_ref`, `principal_alias`만 기록한다.
- `openai.principal_tokens[]` 변경은 restart-required로 처리한다. candidate check, cutover, exact listener identity 확인, restart, rollback을 생략하지 않는다.
- dev-corp Confluence 표에는 사용자, alias, token ref, 상태, 동기화 시각만 기록한다. raw token, token hash, Authorization, provider credential을 넣지 않는다.
- Confluence write는 최신 version에 한 번만 수행하고 409를 포함한 실패를 자동 재시도하지 않는다.
- Confluence 실패는 활성화된 Edge/store를 되돌리지 않고 clipboard 전달을 막아 동일 command로 재개한다.
- active config에 같은 `principal_ref` 또는 `token_ref`가 있으면 새 raw token을 발급하지 않는다.
## 수동 확인이 필요한 safe code
다음 code는 자동 반복하지 않고 운영 상태를 먼저 확인한다.
- `operation_not_supported_by_single_entry`
- `environment_not_supported`, `environment_profile_invalid`
- `existing_mapping_ambiguous`, `store_edge_mapping_mismatch`
- `confluence_create_would_remove_user`, `confluence_target_ambiguous`
- `confluence_managed_heading_invalid`
- `edge_listener_unexpected`, `edge_automatic_rollback_failed`
그 외 network/readiness 실패는 동일 입력으로 한 번 재실행할 수 있다. raw token 또는 실제 principal을 최종 보고에 포함하지 않는다.

View file

@ -0,0 +1,12 @@
{
"environment": "dev-corp",
"edge_ssh": "toki@iop.ai.kr",
"secret_store": "token/.dev-corp-iop-token",
"openai_smoke_transport": "public-https",
"openai_base_url": "https://digitalplatform.iop.ai.kr/v1",
"smoke_model": "ornith:35b",
"metrics_transport": "public-http",
"metrics_url": "http://iop.ai.kr:19105/metrics",
"confluence_enabled": true,
"confluence_user_store": "token/.lgu-atlassian-user"
}

View file

@ -0,0 +1,11 @@
{
"environment": "dev",
"edge_ssh": "toki@toki-labs.com",
"secret_store": "token/.dev-iop-token",
"openai_smoke_transport": "ssh-loopback",
"openai_base_url": "http://127.0.0.1:18083/v1",
"smoke_model": "laguna-s:2.1",
"metrics_transport": "ssh-loopback",
"metrics_url": "http://127.0.0.1:19101/metrics",
"confluence_enabled": false
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,618 @@
# frozen_string_literal: true
require "digest"
require "fileutils"
require "json"
require "net/http"
require "open3"
require "securerandom"
require "time"
require "tmpdir"
require "yaml"
NOHUP_BIN = "/usr/bin/nohup"
PROFILES = {
"dev" => {
"environment" => "dev",
"config_path" => "/Users/toki/agent-work/iop-dev/build/dev-runtime/edge.yaml",
"config_arg" => "build/dev-runtime/edge.yaml",
"edge_bin" => "/Users/toki/agent-work/iop-dev/build/dev-runtime/bin/edge",
"runtime_root" => "/Users/toki/agent-work/iop-dev",
"admin_addr" => "127.0.0.1:19093",
"health_url" => "http://127.0.0.1:18083/healthz",
"listener_port" => 18_083,
"log_path" => "/Users/toki/agent-work/iop-dev/build/dev-runtime/logs/iop-edge-token-issue.log",
"lock_path" => "/Users/toki/agent-work/iop-dev/build/dev-runtime/.token-issue.lock",
"api_base_url_runner" => "http://127.0.0.1:18083/v1",
"metrics_url_runner" => "http://127.0.0.1:19101/metrics",
"smoke_model" => "laguna-s:2.1"
},
"dev-corp" => {
"environment" => "dev-corp",
"config_path" => "/Users/toki/agent-work/iop-dev-corp/build/dev-corp-runtime/edge.yaml",
"config_arg" => "/Users/toki/agent-work/iop-dev-corp/build/dev-corp-runtime/edge.yaml",
"edge_bin" => "/Users/toki/agent-work/iop-dev-corp/build/dev-corp-runtime/bin/iop-edge",
"runtime_root" => "/Users/toki/agent-work/iop-dev-corp",
"admin_addr" => "127.0.0.1:19094",
"health_url" => "http://127.0.0.1:18086/healthz",
"listener_port" => 18_086,
"log_path" => "/Users/toki/agent-work/iop-dev-corp/build/dev-corp-runtime/logs/iop-edge-token-issue.log",
"lock_path" => "/Users/toki/agent-work/iop-dev-corp/build/dev-corp-runtime/.token-issue.lock",
"api_base_url_runner" => "http://127.0.0.1:18086/v1",
"metrics_url_runner" => "http://127.0.0.1:19105/metrics",
"smoke_model" => "ornith:35b"
}
}.freeze
def profile
$profile || fail_safe("environment_missing")
end
def config_path
profile.fetch("config_path")
end
def config_arg
profile.fetch("config_arg")
end
def edge_bin
profile.fetch("edge_bin")
end
def runtime_root
profile.fetch("runtime_root")
end
def admin_addr
profile.fetch("admin_addr")
end
def health_uri
URI(profile.fetch("health_url"))
end
def listener_port
Integer(profile.fetch("listener_port"))
end
def log_path
profile.fetch("log_path")
end
def lock_path
profile.fetch("lock_path")
end
def expected_command
"#{edge_bin} --config #{config_arg} serve"
end
class SafeFailure < StandardError
attr_reader :code
def initialize(code)
@code = code
super(code)
end
end
def fail_safe(code)
raise SafeFailure, code
end
def with_transaction_lock
File.open(lock_path, File::RDWR | File::CREAT, 0o600) do |lock|
File.chmod(0o600, lock_path)
fail_safe("transaction_busy") unless lock.flock(File::LOCK_EX | File::LOCK_NB)
begin
yield
ensure
lock.flock(File::LOCK_UN)
end
end
end
def read_payload
payload = JSON.parse($stdin.read)
fail_safe("invalid_action") unless %w[inspect candidate-check apply rollback api-smoke metrics selftest].include?(payload["action"])
environment = payload["environment"]
fail_safe("environment_invalid") unless environment.is_a?(String) && PROFILES.key?(environment)
payload
rescue JSON::ParserError
fail_safe("invalid_json")
end
def load_config(path = config_path)
value = YAML.safe_load(File.read(path), aliases: true)
fail_safe("invalid_config_root") unless value.is_a?(Hash)
value
rescue Errno::ENOENT
fail_safe("config_missing")
rescue Psych::Exception
fail_safe("config_yaml_invalid")
end
def mappings(config)
items = config.dig("openai", "principal_tokens") || []
fail_safe("principal_tokens_invalid") unless items.is_a?(Array) && items.all? { |item| item.is_a?(Hash) }
items
end
def validate_mappings!(items)
refs = items.map { |item| item["token_ref"].to_s }
hashes = items.map { |item| item["token_hash_sha256"].to_s.downcase }
fail_safe("mapping_token_ref_invalid") if refs.any?(&:empty?) || refs.uniq.length != refs.length
fail_safe("mapping_hash_invalid") if hashes.any? { |value| !value.match?(/\A[0-9a-f]{64}\z/) } || hashes.uniq.length != hashes.length
fail_safe("mapping_principal_invalid") if items.any? { |item| item["principal_ref"].to_s.empty? }
end
def json_scalar(value)
JSON.generate(value.to_s)
end
def insert_mapping(source, entry)
lines = source.lines
openai_index = lines.index { |line| line.match?(/\Aopenai:\s*(?:#.*)?\z/) }
fail_safe("openai_section_missing") unless openai_index
openai_end = ((openai_index + 1)...lines.length).find do |index|
lines[index].match?(/\A\S/) && !lines[index].lstrip.start_with?("#")
end || lines.length
token_index = ((openai_index + 1)...openai_end).find do |index|
lines[index].match?(/\A principal_tokens:\s*(?:\[\])?\s*(?:#.*)?\z/)
end
fail_safe("principal_tokens_section_missing") unless token_index
if lines[token_index].match?(/principal_tokens:\s*\[\]/)
lines[token_index] = " principal_tokens:\n"
token_end = token_index + 1
sequence_indent = 4
else
token_end = ((token_index + 1)...openai_end).find do |index|
lines[index].match?(/\A [A-Za-z0-9_][A-Za-z0-9_-]*:/)
end || openai_end
first_item = lines[(token_index + 1)...token_end].find { |line| line.match?(/\A\s*-\s+token_ref:/) }
fail_safe("principal_tokens_style_invalid") unless first_item
sequence_indent = first_item[/\A\s*/].length
end
sequence_prefix = " " * sequence_indent
field_prefix = " " * (sequence_indent + 2)
fragment = [
"#{sequence_prefix}- token_ref: #{json_scalar(entry.fetch("token_ref"))}\n",
"#{field_prefix}token_hash_sha256: #{json_scalar(entry.fetch("token_hash_sha256"))}\n",
"#{field_prefix}principal_ref: #{json_scalar(entry.fetch("principal_ref"))}\n",
"#{field_prefix}principal_alias: #{json_scalar(entry.fetch("principal_alias"))}\n"
]
lines.insert(token_end, *fragment)
lines.join
end
def run_command(*argv)
stdout, _stderr, status = Open3.capture3(*argv)
fail_safe("command_failed") unless status.success?
stdout
end
def loopback_uri(profile_key, path = nil)
base = profile.fetch(profile_key)
uri = URI(path ? "#{base}#{path}" : base)
fail_safe("loopback_url_invalid") unless uri.scheme == "http" && %w[127.0.0.1 ::1 localhost].include?(uri.host)
fail_safe("loopback_url_invalid") if uri.user || uri.password || uri.fragment
uri
end
def loopback_request(uri, request, read_timeout:)
http = Net::HTTP.new(uri.host, uri.port, nil)
http.open_timeout = 3
http.read_timeout = read_timeout
response = http.start { |client| client.request(request) }
fail_safe("loopback_http_failed") unless response.is_a?(Net::HTTPSuccess)
response.body.to_s
rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, SystemCallError
fail_safe("loopback_network_failed")
end
def api_json(path, raw_token, payload = nil, read_timeout: 30)
uri = loopback_uri("api_base_url_runner", path)
request = payload ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{raw_token}"
request["Content-Type"] = "application/json"
request.body = JSON.generate(payload) if payload
body = loopback_request(uri, request, read_timeout: read_timeout)
parsed = JSON.parse(body)
fail_safe("openai_response_invalid") unless parsed.is_a?(Hash)
parsed
rescue JSON::ParserError
fail_safe("openai_response_invalid")
end
def api_smoke(payload)
raw_token = payload["raw_token"]
fail_safe("raw_token_invalid") unless raw_token.is_a?(String) && raw_token.start_with?("iop_") && !raw_token.match?(/[\r\n]/)
models = api_json("/models", raw_token, nil, read_timeout: 15)
fail_safe("openai_models_invalid") unless models["data"].is_a?(Array) && !models["data"].empty?
response = api_json(
"/chat/completions",
raw_token,
{
"model" => profile.fetch("smoke_model"),
"messages" => [{ "role" => "user", "content" => "Reply with the single word OK." }],
"max_tokens" => 2048,
"temperature" => 0
},
read_timeout: 120
)
choice = response["choices"].is_a?(Array) ? response["choices"].first : nil
message = choice.is_a?(Hash) ? choice["message"] : nil
content = message.is_a?(Hash) ? message["content"] : nil
fail_safe("openai_chat_invalid") unless content.is_a?(String) && !content.strip.empty? && choice["finish_reason"]
{ "status" => "passed" }
end
def metrics_observed(payload)
token_ref = payload["token_ref"]
fail_safe("token_ref_invalid") unless token_ref.is_a?(String) && token_ref.match?(/\A[a-z0-9][a-z0-9._:-]{2,79}\z/)
uri = loopback_uri("metrics_url_runner")
body = loopback_request(uri, Net::HTTP::Get.new(uri), read_timeout: 8)
{ "status" => "ok", "observed" => body.match?(/token_ref="#{Regexp.escape(token_ref)}"/) }
end
def parse_refresh_status(stdout)
parsed = begin
value = JSON.parse(stdout)
value if value.is_a?(Hash) && value.key?("status")
rescue JSON::ParserError
nil
end
unless parsed
stdout.lines.reverse_each do |line|
begin
value = JSON.parse(line)
if value.is_a?(Hash) && value.key?("status")
parsed = value
break
end
rescue JSON::ParserError
next
end
end
end
fail_safe("refresh_response_invalid") unless parsed
parsed.fetch("status").to_s
end
def refresh_status(candidate_path)
stdout = run_command(
edge_bin,
"--config", config_path,
"config", "refresh",
"--addr", admin_addr,
"--config-path", candidate_path,
"--mode", "dry-run"
)
parse_refresh_status(stdout)
end
def listener_pid
stdout, _stderr, status = Open3.capture3("lsof", "-tiTCP:#{listener_port}", "-sTCP:LISTEN")
return nil unless status.success?
values = stdout.lines.map(&:strip).reject(&:empty?).uniq
fail_safe("edge_listener_ambiguous") unless values.length == 1
value = values.first
fail_safe("edge_listener_pid_invalid") unless value.match?(/\A\d+\z/)
value.to_i
end
def expected_listener_pid
pid = listener_pid
return nil unless pid
stdout, _stderr, status = Open3.capture3("ps", "-p", pid.to_s, "-o", "command=")
fail_safe("edge_listener_identity_unavailable") unless status.success?
fail_safe("edge_listener_unexpected") unless stdout.strip == expected_command
pid
end
def healthy?
uri = health_uri
response = Net::HTTP.start(uri.host, uri.port, open_timeout: 2, read_timeout: 2) do |http|
http.get(uri.request_uri)
end
response.is_a?(Net::HTTPSuccess)
rescue StandardError
false
end
def wait_until(timeout_seconds)
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_seconds
loop do
return true if yield
return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
sleep 0.25
end
end
def stop_edge
pid = expected_listener_pid
return unless pid
Process.kill("TERM", pid)
return if wait_until(10) { expected_listener_pid != pid }
Process.kill("KILL", pid)
fail_safe("edge_stop_failed") unless wait_until(5) { expected_listener_pid != pid }
rescue Errno::ESRCH
nil
rescue Errno::EPERM
fail_safe("edge_stop_denied")
end
def start_edge
FileUtils.mkdir_p(File.dirname(log_path))
log = File.open(log_path, "a", 0o600)
File.chmod(0o600, log_path)
begin
pid = Process.spawn(
NOHUP_BIN, edge_bin, "--config", config_arg, "serve",
chdir: runtime_root,
in: File::NULL,
out: log,
err: log,
pgroup: true
)
ensure
log.close
end
Process.detach(pid)
fail_safe("edge_readiness_failed") unless wait_until(30) { expected_listener_pid == pid && healthy? }
end
def restart_edge
stop_edge
start_edge
end
def cutover_files(config_path, candidate_path, backup_path)
File.rename(config_path, backup_path)
begin
File.rename(candidate_path, config_path)
rescue StandardError
File.rename(backup_path, config_path) if File.file?(backup_path) && !File.exist?(config_path)
raise
end
end
def rollback_to(backup_path)
expanded = File.expand_path(backup_path.to_s)
allowed_prefix = File.dirname(config_path) + File::SEPARATOR
fail_safe("backup_path_invalid") unless expanded.start_with?(allowed_prefix)
fail_safe("backup_path_invalid") unless File.basename(expanded).match?(/\Aedge\.yaml\.before-token-\d{8}T\d{6}Z-[0-9a-f]{6}\.yaml\z/)
fail_safe("backup_missing") unless File.file?(expanded)
run_command(edge_bin, "--config", expanded, "config", "check")
failed_path = "#{config_path}.failed-token-#{Time.now.utc.strftime("%Y%m%dT%H%M%SZ")}-#{SecureRandom.hex(3)}.yaml"
stop_edge
File.rename(config_path, failed_path) if File.exist?(config_path)
File.rename(expanded, config_path)
start_edge
fail_safe("rollback_config_invalid") unless refresh_status(config_path) == "applied"
{ "status" => "rolled_back" }
end
def inspect_state(payload)
fail_safe("runtime_assets_invalid") unless File.executable?(edge_bin) && File.executable?(NOHUP_BIN)
fail_safe("runtime_directory_not_writable") unless File.writable?(File.dirname(config_path))
config = load_config
items = mappings(config)
validate_mappings!(items)
probe_entry = {
"token_ref" => "iop-#{profile.fetch("environment")}-preflight-#{SecureRandom.hex(6)}",
"token_hash_sha256" => Digest::SHA256.hexdigest(SecureRandom.random_bytes(32)),
"principal_ref" => "preflight-shape.invalid",
"principal_alias" => "preflight-shape"
}
probe_config = YAML.safe_load(insert_mapping(File.read(config_path), probe_entry), aliases: true)
probe_items = mappings(probe_config)
validate_mappings!(probe_items)
fail_safe("candidate_shape_invalid") unless probe_items.length == items.length + 1
current_refresh = payload["refresh_probe"] ? refresh_status(config_path) : nil
fail_safe("current_refresh_invalid") if current_refresh && current_refresh != "applied"
{
"status" => "ok",
"mapping_count" => items.length,
"mappings" => items.map do |item|
{
"token_ref" => item["token_ref"].to_s,
"token_hash_sha256" => item["token_hash_sha256"].to_s.downcase,
"principal_ref" => item["principal_ref"].to_s,
"principal_alias" => item["principal_alias"].to_s
}
end,
"candidate_shape" => true,
"refresh_status" => current_refresh,
"healthy" => healthy?,
"listener" => !expected_listener_pid.nil?
}
end
def candidate_check
active_items = mappings(load_config)
probe_entry = {
"token_ref" => "iop-#{profile.fetch("environment")}-candidate-check-#{SecureRandom.hex(6)}",
"token_hash_sha256" => Digest::SHA256.hexdigest(SecureRandom.random_bytes(32)),
"principal_ref" => "candidate-check.invalid",
"principal_alias" => "candidate-check"
}
candidate_path = File.join(File.dirname(config_path), ".edge-token-candidate-check-#{SecureRandom.hex(6)}.yaml")
begin
File.open(candidate_path, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
file.write(insert_mapping(File.read(config_path), probe_entry))
file.flush
file.fsync
end
candidate_items = mappings(load_config(candidate_path))
validate_mappings!(candidate_items)
fail_safe("candidate_count_invalid") unless candidate_items.length == active_items.length + 1
run_command(edge_bin, "--config", candidate_path, "config", "check")
fail_safe("restart_required_not_reported") unless refresh_status(candidate_path) == "restart_required"
{ "status" => "validated" }
ensure
File.delete(candidate_path) if File.exist?(candidate_path)
end
end
def apply(payload)
entry = payload.fetch("entry")
fail_safe("entry_invalid") unless entry.is_a?(Hash)
fail_safe("hash_invalid") unless entry["token_hash_sha256"].to_s.match?(/\A[0-9a-f]{64}\z/)
%w[token_ref principal_ref principal_alias].each do |key|
value = entry[key].to_s
fail_safe("entry_invalid") if value.empty? || value.include?("\n") || value.include?("\r")
end
active = load_config
active_items = mappings(active)
validate_mappings!(active_items)
fail_safe("principal_conflict") if active_items.any? { |item| item["principal_ref"].to_s == entry["principal_ref"].to_s }
fail_safe("token_ref_conflict") if active_items.any? { |item| item["token_ref"].to_s == entry["token_ref"].to_s }
fail_safe("token_hash_conflict") if active_items.any? { |item| item["token_hash_sha256"].to_s.casecmp?(entry["token_hash_sha256"].to_s) }
candidate_path = nil
candidate_items = nil
backup_path = nil
original_moved = false
begin
source = File.read(config_path)
candidate_path = File.join(
File.dirname(config_path),
".edge-token-issue-#{Time.now.utc.strftime("%Y%m%dT%H%M%SZ")}-#{SecureRandom.hex(3)}.yaml"
)
File.open(candidate_path, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
file.write(insert_mapping(source, entry))
file.flush
file.fsync
end
candidate = load_config(candidate_path)
candidate_items = mappings(candidate)
validate_mappings!(candidate_items)
fail_safe("candidate_count_invalid") unless candidate_items.length == active_items.length + 1
run_command(edge_bin, "--config", candidate_path, "config", "check")
fail_safe("restart_required_not_reported") unless refresh_status(candidate_path) == "restart_required"
backup_path = "#{config_path}.before-token-#{Time.now.utc.strftime("%Y%m%dT%H%M%SZ")}-#{SecureRandom.hex(3)}.yaml"
cutover_files(config_path, candidate_path, backup_path)
original_moved = true
candidate_path = nil
restart_edge
current = mappings(load_config)
validate_mappings!(current)
expected = current.count do |item|
item["principal_ref"].to_s == entry["principal_ref"].to_s &&
item["token_ref"].to_s == entry["token_ref"].to_s &&
item["token_hash_sha256"].to_s.casecmp?(entry["token_hash_sha256"].to_s)
end
fail_safe("active_mapping_invalid") unless expected == 1
fail_safe("post_restart_refresh_invalid") unless refresh_status(config_path) == "applied"
rescue StandardError
if original_moved && backup_path && File.file?(backup_path)
begin
rollback_to(backup_path)
rescue StandardError
fail_safe("automatic_rollback_failed")
end
end
raise
ensure
File.delete(candidate_path) if candidate_path && File.exist?(candidate_path)
end
{
"status" => "activated",
"backup_path" => backup_path,
"mapping_count" => candidate_items.length
}
end
def selftest
PROFILES.each_value do |item|
fail_safe("selftest_profile_path_failed") unless item.fetch("config_path").start_with?(item.fetch("runtime_root") + File::SEPARATOR)
fail_safe("selftest_profile_command_failed") unless item.fetch("edge_bin").start_with?(item.fetch("runtime_root") + File::SEPARATOR)
uri = URI(item.fetch("api_base_url_runner"))
fail_safe("selftest_profile_url_failed") unless uri.scheme == "http" && uri.host == "127.0.0.1"
end
sample = <<~YAML
version: 1
openai:
enabled: true
principal_tokens: []
timeout_sec: 30
YAML
entry = {
"token_ref" => "iop-dev-corp-sample",
"token_hash_sha256" => "a" * 64,
"principal_ref" => "sample.invalid",
"principal_alias" => "sample"
}
inserted = YAML.safe_load(insert_mapping(sample, entry), aliases: true)
items = mappings(inserted)
validate_mappings!(items)
fail_safe("selftest_insert_failed") unless items.length == 1
indentless = <<~YAML
version: 1
openai:
enabled: true
principal_tokens:
- token_ref: "existing"
token_hash_sha256: "#{"b" * 64}"
principal_ref: "existing.invalid"
principal_alias: "existing"
a2a:
enabled: false
YAML
indentless_inserted = insert_mapping(indentless, entry)
fail_safe("selftest_indentless_style_failed") unless indentless_inserted.include?("\n - token_ref: \"iop-dev-corp-sample\"\n")
indentless_items = mappings(YAML.safe_load(indentless_inserted, aliases: true))
validate_mappings!(indentless_items)
fail_safe("selftest_indentless_insert_failed") unless indentless_items.length == 2
applied = JSON.pretty_generate({ "status" => "applied", "restart_required_paths" => ["openai.principal_tokens"] })
fail_safe("selftest_status_failed") unless parse_refresh_status(applied) == "applied"
Dir.mktmpdir("iop-token-cutover-") do |directory|
config_path = File.join(directory, "edge.yaml")
missing_candidate = File.join(directory, "missing.yaml")
backup_path = File.join(directory, "edge.yaml.backup")
File.write(config_path, "original")
begin
cutover_files(config_path, missing_candidate, backup_path)
fail_safe("selftest_cutover_failed")
rescue Errno::ENOENT
fail_safe("selftest_cutover_restore_failed") unless File.read(config_path) == "original"
fail_safe("selftest_cutover_backup_leaked") if File.exist?(backup_path)
end
end
{ "status" => "ok" }
end
begin
payload = read_payload
$profile = PROFILES.fetch(payload.fetch("environment"))
result = case payload.fetch("action")
when "inspect" then inspect_state(payload)
when "candidate-check" then with_transaction_lock { candidate_check }
when "apply" then with_transaction_lock { apply(payload) }
when "rollback" then with_transaction_lock { rollback_to(payload.fetch("backup_path")) }
when "api-smoke" then api_smoke(payload)
when "metrics" then metrics_observed(payload)
when "selftest" then selftest
end
puts JSON.generate(result)
rescue SafeFailure => error
puts JSON.generate({ "status" => "blocked", "code" => error.code })
exit 2
rescue KeyError
puts JSON.generate({ "status" => "blocked", "code" => "missing_input" })
exit 2
rescue StandardError
puts JSON.generate({ "status" => "blocked", "code" => "unexpected_remote_failure" })
exit 2
end