feat(agent-ops): 환경별 토큰 발급 스킬을 프로젝트로 승격한다
dev와 dev-corp 요청을 명시적 환경 프로필로 라우팅하고, private override 없이 단일 실행기에서 Edge 검증과 환경별 metadata 정책을 처리한다.
This commit is contained in:
parent
8fff0d5f95
commit
b003394568
6 changed files with 2098 additions and 144 deletions
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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을 최종 보고에 포함하지 않는다.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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
|
||||
Loading…
Reference in a new issue