Metadata-Version: 2.4
Name: oceanctl
Version: 3.0.0
Summary: Ocean CLI — 작업 제출·상태 확인을 터미널에서
Author: AI-Ocean
License-Expression: MIT
Keywords: ocean,gpu,cluster,cli
Classifier: Development Status :: 5 - Production/Stable
Classifier: Environment :: Console
Classifier: Intended Audience :: Science/Research
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Scientific/Engineering
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: dev
Requires-Dist: pytest>=7.4.0; extra == "dev"
Requires-Dist: ruff>=0.1.0; extra == "dev"
Dynamic: license-file

# oceanctl — Ocean CLI

터미널에서 Ocean 에 작업을 제출하고 상태를 확인한다. AI 에이전트가 쓰는 것을 상한으로 설계했다.

- 설계 = [projects/ocean-cli/design.md](../projects/ocean-cli/design.md)
- 구현 계획 = [projects/ocean-cli/implementation-plan.md](../projects/ocean-cli/implementation-plan.md)
- **런타임 의존성 0** — 표준 라이브러리만 쓴다. 어디서든 설치가 가볍다.

> 라이선스: MIT(이 디렉터리 한정) · 배포: [PyPI `oceanctl`](https://pypi.org/project/oceanctl/)

## 설치

최근 Debian/Ubuntu 는 시스템 Python 에 직접 설치하는 것을 막는다(PEP 668,
`externally-managed-environment`). CLI 애플리케이션이므로 **격리 설치가 맞다.**

### 권장 — uv

```bash
uv tool install oceanctl     # pipx 와 같은 격리 설치, 더 빠르다
oceanctl --version           # PATH 에 자동 등록된다

uvx oceanctl whoami          # 설치 없이 일회성 실행도 된다
```

★uv 는 **파이썬이 없는 머신에서도** 파이썬을 알아서 받아 깐다 — "파이썬부터 깔아야 하나" 가
없는 유일한 경로라 권장 기본이다. 버전 고정은 `uv tool install oceanctl==1.0.0`.

### pipx 도 그대로 된다

```bash
sudo apt install pipx        # 또는 python3 -m pip install --user pipx
pipx install oceanctl
```

레포 체크아웃에서 개발판을 깔려면 `pipx install /path/to/ocean-all/ocean-cli`
(uv 는 `uv tool install /path/to/ocean-all/ocean-cli`).

### 단일 파일 — `.pyz` (kubectl 식)

릴리스마다 실행 가능한 zipapp 하나가 첨부된다. 런타임 의존성이 0 이라 가능한 형태다 —
파이썬 3.10+ 만 있으면 어떤 설치 도구도 없이 돈다:

```bash
gh release download oceanctl-v1.0.0 -p oceanctl.pyz -R AI-Ocean/ocean-all
chmod +x oceanctl.pyz && ./oceanctl.pyz --version    # 또는 python3 oceanctl.pyz
```

- ★저장소가 private 인 동안 릴리스 자산도 private 다 — **레포 접근 권한이 있는 사람만** 받을 수
  있다(외부 사용자는 PyPI 경로를 쓴다). 저장소가 공개되면 `curl -LO
  https://github.com/AI-Ocean/ocean-all/releases/download/oceanctl-v<버전>/oceanctl.pyz` 가 된다.
- ★모노레포라 `releases/latest` 는 다른 컴포넌트(agent·SM) 릴리스를 가리킬 수 있다 — **태그를
  명시한 URL 만** 쓴다.

### pipx 가 없으면 — venv

```bash
python3 -m venv ~/.venvs/oceanctl
~/.venvs/oceanctl/bin/pip install /path/to/ocean-all/ocean-cli

# PATH 에 올리려면 (하나만 골라서)
ln -s ~/.venvs/oceanctl/bin/oceanctl ~/.local/bin/oceanctl
# 또는
echo 'alias oceanctl="$HOME/.venvs/oceanctl/bin/oceanctl"' >> ~/.bashrc
```

> `pip install --break-system-packages` 는 쓰지 않는다 — 시스템 Python 을 깨뜨릴 수 있고,
> 이 도구는 격리해도 잃을 게 없다(의존성이 0이다).

## 설정

토큰은 **웹에서 발급**한다 — 사이드바 **CLI 토큰** → 이름 입력 → 발급.
평문은 **그때 한 번만** 보인다(서버는 해시만 저장한다).

```bash
oceanctl configure --api-url https://api.aiocean.click
#   CLI token: ← 붙여넣기 (화면에 찍히지 않는다)

#   ✓ 저장됨   ~/.ocean/credentials (0600)
#   ✓ 확인     오션어드민 <lee@example.com> · 고려대 / korea
#                                            └ 어느 조직/클러스터에 붙었는지 그 자리에서 보인다

oceanctl configure --show
#   API URL  https://api.aiocean.click
#   토큰     ocn_3f9a7c21…  (전체는 표시하지 않음)
#   파일     ~/.ocean/credentials
```

> 비대화형(파이프)에서는 stdin 한 줄로 토큰을 받는다(`printf '%s' "$TOKEN" | oceanctl configure --json`).
> 토큰이 비어 있으면 `token_required` 봉투 + exit 1 이고(1.2.2+ — 그전엔 stderr 로만 죽었다),
> 서버 검증 실패는 `invalid_cli_token` — 어느 쪽도 파일을 만들거나 덮지 않는다.

- 자격증명은 `~/.ocean/credentials` 에 **0600** 으로 저장된다.
- ★**토큰을 환경변수나 플래그로 주지 않는다.** env 는 에이전트 프로세스에 상속돼
  `env` 출력·로그·에러 리포트로 새고, 플래그는 셸 히스토리와 `ps` 에 남는다.
  에이전트는 `oceanctl ...` 을 실행할 뿐 토큰 원문을 보지 않는다.
- ★**토큰은 조직 + 클러스터에 묶인다.** 발급할 때 클러스터를 고르고, 그 토큰은 그 클러스터에서만
  쓴다. **컨텍스트가 곧 자격증명**이라 CLI 에 `--cluster` 플래그가 없다 — 대상을 바꾸려면
  그 클러스터용 토큰으로 `oceanctl configure` 를 다시 한다(`kubectl config use-context` 대신
  토큰 교체). 여러 토큰을 등록해 두고 이름으로 전환하는 프로파일은 뒤에 붙일 수 있다(계약을
  넓히는 방향이라 안전하다).

## 명령

```
oceanctl configure [--api-url URL] [--show]   자격증명·API URL 설정 / 현재 설정 보기
oceanctl whoami                                이 토큰이 묶인 유저·조직·클러스터
oceanctl machine-type list                     고를 수 있는 머신타입
oceanctl quota                                 남은 할당량
oceanctl project list                          프로젝트 목록
oceanctl image list                            쓸 수 있는 컨테이너 이미지(create 에 넣을 이름)
oceanctl volume list                           마운트할 수 있는 볼륨(create 에 넣을 이름)
oceanctl node list --machine-type ID --for job|instance
                                               지금 쓸 수 있는 노드 후보(--node 에 넣을 이름)
oceanctl instance list                         인스턴스 목록 + 접속 정보(SSH·VSCode)
oceanctl instance create --name N --image I --machine-type ID --volume V[:PATH[:ro]]
                                               인스턴스 생성 (`-f spec.json` 도 가능)
oceanctl instance delete --pod-uid UID --yes   인스턴스 삭제(본인 것만 · 1.2.0+ — 아래 참조)
oceanctl job submit --name N --image I --machine-type ID --volume V[:PATH[:ro]] --command CMD
                                               잡 제출 (+ [--repeat N] [--project ID] [--node NAME])
oceanctl job submit -f spec.json               잡 제출 (스펙 파일 · `-f -` 는 stdin)
oceanctl job list                              잡 목록 · 상태 · uid
oceanctl job wait --job-uid UID                잡 종결 대기(성공 0 · 실패/초과 1)
oceanctl job logs --job-uid U [--pod-uid U]     잡 로그(raw 스트림 — 아래 참조)
oceanctl job why --pod-uid U                   그 파드가 왜 대기 중인가
```

> ★**`instance delete` 는 1.2.0 에서 열렸다**(D4 2026-07-31 개정 — Running 인스턴스는 할당량을
> 계속 점유하는데 정리가 웹뿐이면 에이전트 루프가 닫히지 않아서다). **본인 것만** 지워진다
> (남의 podUid 는 404). `--pod-uid` 는 `instance create --json` 요약 또는 `instance list --json`
> 의 `podUid` 그 값이다. 확인 게이트: 비대화형/`--json` 은 `--yes` 필수(없으면
> `confirmation_required` 봉투), TTY 는 `y/N`(취소 = `delete_cancelled` · exit 1 · 요청 0건).
>
> ★**`stop` 은 없고, `connect`/ssh 래퍼도 없다**(D5) — `instance list` 가 `sshCommand` 를 그대로
> 보여주고 거기서 멈춘다. 감싸면 포트포워딩·키·에이전트 전달까지 떠안게 되고, 그건 ssh
> 클라이언트가 이미 하는 일이다. **`job delete`·`cancel` 도 여전히 없다**(D4 는 잡에 그대로) —
> 끝난 잡은 할당량을 먹지 않고 60일 뒤 자동으로 사라지므로 반복 실험이 그대로 돈다.

옵션(붙는 명령은 괄호 안에):

| 옵션 | 설명 |
|---|---|
| `--json` | **모든 명령** — `{ok, data}` / `{ok, error}` 봉투(에이전트용). **exit code 는 0/1**. ★`job logs` 는 예외 — 아래 |
| `--details` | `--json` 을 **응답 원형 전체**로(목록 다섯: `job list`·`volume list` — 2.0.0 · `instance list`·`image list`·`machine-type list` — 3.0.0. 기본은 요약. **`--json` 전용** — 없이 주면 `details_requires_json`) |
| `--project <id>` | 대상 프로젝트(`quota`·`job submit`·`instance create` — **생략 = 기본 프로젝트**) |
| `--limit <N>` | 가져올 개수(`instance list`·`project list`·`volume list`·`job list` — 생략 시 **서버 기본 50**, 상한 200) |
| `--node <name>` | 이 노드에 올린다(`job submit`·`instance create` — 생략 = 스케줄러 선택) |

`organizationId`·`clusterId` 는 **주지 않는다** — 토큰이 담고 서버 미들웨어가 채운다.
토큰이 고정한 것과 다른 값을 굳이 보내면 **403** 이다(조용히 덮지 않는다).

> ★**CLI 토큰은 개인 작업용이다**(owner 2026-07-29). 목록은 **자기 자원만** 보여준다 — 조직 관리자라
> 해도 CLI 로는 조직 전체를 볼 수 없다(`showAll` 을 보내면 403 `cli_token_show_all_forbidden`).
> **웹 관리자 화면은 그대로** 조직 전체를 본다. 능력이 아니라 **자격증명의 수명** 때문이다: 웹
> 세션은 24시간 쿠키인데 이 토큰은 **만료가 없고 파일에** 있어, 유출 시 반경을 그 사람 것으로
> 묶어 두는 편이 안전하다.
>
> ★단서 하나(P6 실측, [[OD-113]]): `job logs`·`job why` 는 `showAll` 이 아니라 **조직 역할**로
> 범위가 정해진다 — 조직 관리자 토큰은 **uid 를 이미 알고 있는** 남의 잡 로그를 읽을 수 있다.
> 웹 관리자와 같은 범위이고, uid 를 얻는 CLI 경로(`job list`·`volume list`)는 전부 자기 것만
> 주므로 목록으로 훑을 수는 없다.

> ★**`cluster list` 는 없다**(2026-07-28 제거). 토큰이 클러스터를 고정하므로 목록을 봐도 CLI 로
> 할 수 있는 일이 없다 — *"지금 어느 클러스터에 붙어 있나"* 는 `whoami` 가 답한다. 부수로 그
> 라우트(`GET /api/organization/kubernetes`)가 토큰 수용 목록에서 빠졌다: 클러스터 API 주소·
> prometheus 주소·조직 구성원 전원 명단을 실어 보내는데 CLI 는 id·name·status 만 쓰고 있었다.

### 출력

목록은 **헤더가 있는 표**다. 값이 없으면 빈칸이 아니라 `-` 로 채운다.

```
$ oceanctl whoami
오션어드민 <lee@example.com>
조직      고려대 (3)
클러스터  korea (11)
토큰      p5-dev · ocn_ab12cd34
API       https://api.aiocean.click

$ oceanctl machine-type list
ID  NAME                   CPU     MEM  GPU  GPU TYPE
34  32CPU-100G-2GPU         32   100Gi    2  NVIDIA-RTX-A6000
46  default-cpu              1     4Gi    0  -

$ oceanctl quota
범위  legacy-5-3 (기본)

인스턴스
MACHINE TYPE ID  NAME     CPU   MEM  GPU  GPU TYPE          USED  QUOTA  FREE
             48  mid-gpu   16  64Gi    2  NVIDIA-RTX-A6000     1      1     0
             49  mid-cpu   16  64Gi    0  -                    0      2     2

잡
MACHINE TYPE ID  NAME     CPU   MEM  GPU  GPU TYPE          USED  QUOTA  FREE
             48  mid-gpu   16  64Gi    2  NVIDIA-RTX-A6000     0      2     2
```

```
$ oceanctl instance create --name devbox --image busybox:1.36 --machine-type 48 --volume data
✓ 생성 요청됨  devbox  (Pending)
  접속 정보(SSH·VSCode)는 `oceanctl instance list` 에서 확인하세요(파드가 Running 이 되면 접속됩니다).

$ oceanctl instance list
NAME    STATUS   MACHINE TYPE  NODE    SSH                          VSCODE
devbox  Running  mid-gpu       gpu-01  ssh ocean@10.0.0.5 -p 30022  http://10.0.0.5:30080

총 1개
```

★**목록은 조용히 잘리지 않는다.** 서버 기본 `limit` 이 50 이므로 인스턴스·프로젝트가 그보다 많으면
표 뒤에 그 사실이 뜬다:

```
★총 120개 중 50개만 표시됐다 — `--limit 120` 로 전부 본다
```

`total`(잘리기 **전** 총계)은 서버가 원래 주고 있던 값이다 — CLI 가 그걸 버려서 51번째부터
*있는데 없는 것처럼* 보이고 있었다. 상한(200)을 넘으면 안 되는 값을 권하지 않고 웹으로 안내한다.

```
$ oceanctl image list
NAME                                            TYPE     SIZE
aicoean/custom:v1                               PRIVATE  0.04Gi
myaiocean/pytorch:2.1.2-cuda12.1-cudnn8-runtime  PUBLIC  12.35Gi
```

- **`NAME` 이 곧 `instance create --image` 에 넣을 값**이다.
- `TYPE` — `PUBLIC` 은 모두가 쓰는 것, `PRIVATE` 은 이 조직이 올린 것. 남의 조직 PRIVATE 은
  서버가 애초에 안 준다.
- 크기 단위는 **`Gi`** 다 — 백엔드가 레지스트리 bytes 를 `1024³` 으로 나눠 저장하므로 실제로는
  GiB 다(웹 화면은 "GB" 로 찍는데 그건 표기가 틀린 것이다).
- ★이미지 **등록·삭제 명령은 없다**(볼륨과 같은 논리). 목록만 있는 이유는 `--image` 에 넣을
  이름을 CLI 안에서 알 수 있어야 하기 때문이다.
- 이 목록은 페이지네이션이 없어 `--limit` 이 **없다**(`machine-type list` 와 같다).
- `--json` 기본은 요약이다(3.0.0, [[OD-149]]): `{name, imageType, size}` — 표와 같은 재료를
  **서버 순서 그대로**(이름 정렬은 사람 표에만). `id`·`organizationId`·`userId`·`createdAt` 이
  필요하면 `--json --details`(원형).

```
$ oceanctl volume list
NAME       CAPACITY  STATUS  SHARED  WRITE  IN USE
workspace  100Gi     Bound   -       -           1
datasets   2Ti       Bound   yes     ro          0
```

- **`NAME` 이 곧 `instance create --volume` 에 넣을 값**이다. 백엔드는 claim 이름·표시명·라벨·
  PV 이름을 전부 별칭으로 받지만(`volumes/mount.py`), 사람이 웹에서 보는 것과 같은 값이 이것이다.
- `IN USE` = 그 볼륨을 마운트한 인스턴스 + 잡 **개수**. 서버는 객체를 통째로 주지만 표에는 개수만
  낸다(원문은 `--json --details`).
- `WRITE` 는 **공유 볼륨에만** 의미가 있다. `ro` 인 공유 볼륨을 rw 로 마운트하려 하면 422 로 거부된다.
- ★볼륨 **생성·삭제 명령은 없다**(D4 와 같은 논리). 목록만 있는 이유는 `--volume` 에 넣을 이름을
  **CLI 안에서** 알 수 있어야 하기 때문이다 — 웹을 봐야 아는 상태면 에이전트는 쓸 수 없다.
- ★**`CAPACITY` 는 할당량이지 사용량이 아니다.** 실제로 얼마나 썼는지는 `oceanctl volume usage
  NAME` 이 답한다([[OD-112]]) — 할당/실사용을 함께 보여주고, 서버가 백그라운드로 계산 중이면
  "계산 중" 이라고 말한다(잠시 후 재실행). ★현재 이 명령은 **org ADMIN 전용**이다(서버 라우트
  게이트) — 일반 사용자 개방은 owner=self 강제 설계가 필요한 별건으로 남아 있다. 같은 이름의
  볼륨이 여럿이면 `--owner USER_ID` 로 좁힌다.
- ★**`--volume` 은 필수다.** 빼면 PVC 가 하나도 안 붙은 파드가 떠서 재시작·evict·수명 만료에
  작업물이 사라진다. 웹은 그 상태를 아예 만들 수 없다(볼륨 0개면 생성 패널이 안 열린다) — 백엔드가
  빈 목록을 받아줄 뿐 **제품 계약에는 없는 상태**다. 여러 개면 반복한다: `--volume a --volume b`.
- **마운트 경로와 모드를 줄 수 있다** — `--volume NAME[:PATH[:ro]]`(도커 `-v` 관례):

  ```bash
  --volume data                      # /volume/data (서버가 정한다 — 지금까지와 같다)
  --volume data:/volume/mydata       # 경로 지정
  --volume datasets:/volume/ds:ro    # 경로 + 읽기전용
  --volume datasets:ro               # 경로는 서버 기본, 읽기전용만 지정
  ```

  - 필드는 **최대 셋**이고 **경로에 `:` 를 쓸 수 없다.** 두 번째 필드가 `/` 로 시작하면 경로,
    아니면 모드다. **모르는 모드는 거부한다**(`:readonly`·`:r` 등) — 조용히 경로로 삼으면
    *요청한 읽기전용이 사라진다.* 대소문자는 가리지 않는다(`:RO` = `:ro`).
  - 명시 `:rw` 는 **아무것도 보내지 않는다**(서버 기본값과 같다) — 서버가 기본을 바꾸면 CLI 가
    그걸 덮어쓰지 않게 하기 위해서다.

  - 경로는 **`/volume/…` 아래 또는 `/home/ocean`·`/home/linuxbrew/.linuxbrew`** 만 된다.
    예약 경로(`/root/dataset`·`/dev/shm`)·루트·상위 이동(`..`)·중복은 서버가 **400**
    (`invalid_volume_mount`)으로 막는다([[OD-143]] 실측 정정 — 읽기전용 강등 거부만 422 다).
    CLI 는 규칙을 따라 검사하지 않는다(두 곳이 어긋나면 더 나쁘다) — **서버가 진실**이다.
  - ★**읽기전용 공유 볼륨(`volume list` 의 `WRITE=ro`)은 `:ro` 를 줘야 붙는다.** 안 주면 서버가
    422 로 거부한다(조용히 읽기전용으로 낮추지 않는다). 그전에는 CLI 로 아예 못 붙였다.
- ★**생성 응답에는 접속 정보가 없다.** `POST /api/instances` 는 만들어진 k8s 파드·서비스 객체를
  주는데, `sshCommand`·`vscodeAddress` 는 **조회 시점에** 계산되는 값이다 — 그래서 `create` 는
  다음 행동을 알려주고 멈추고, 접속 정보는 `instance list` 가 답한다.
  주소 자체는 **바로** 나온다(재료인 nodePort 가 Service 에서 오고 Service 는 생성 시점에 생긴다).
  실제로 **붙는** 것은 파드가 `Running` 이 된 뒤다.
- `instance list` 는 17개 응답 필드 중 6개만 표에 넣는다(`podUid`·`image`·`limits`·`volumes` 등은
  폭 때문에 뺐다 — 뺀 이유는 `oceanctl/cli.py` 의 표에 적혀 있다). `--json` 기본도 같은 축의
  **요약 8키**다(3.0.0, [[OD-148]]): `{name, podUid, status, node, machineType, sshCommand,
  vscodeAddress, vscodePrivateAddress}` — `podUid` 는 `instance delete --pod-uid` 에 넣을 값이고
  (표에는 없어 이 요약이 유일한 출처), vscode 두 필드는 노출 모드에 따라 **한쪽만** 값이 있다
  (NodePort ↔ Istio — 표의 VSCODE 칸이 `or` 로 잇는 것과 같은 사실이라 병합값을 발명하지 않고
  둘 다 싣는다). 원형 17필드(annotations 이메일·`image`·`limits` 등)는 `--json --details`.
- SSH 칸이 `-` 면 그 클러스터가 SSH 를 NodePort 로 노출하지 않는 것이다. 가장 흔한 원인은
  **Istio 모드**(그때는 VSCODE 칸에 게이트웨이 URL 이 온다)지만 유일하지는 않다 — 클러스터
  노출 주소(`cluster.address`)가 비어 있어도 같은 결과다. 즉 `-` 는 *"이 경로로는 못 붙는다"* 이지
  *"Istio 다"* 가 아니다.
- 메모리 단위는 **`Gi`** 다 — 백엔드가 파드 스펙에 그대로 넣는 값이다(`{memory}Gi`).
- `FREE` = `QUOTA - USED`. 제출 전에 보는 명령이라 남은 개수가 먼저다.
- `MACHINE TYPE ID` 가 곧 제출에 넣을 `machineTypeId` 다 — `machine-type list` 를 다시 칠 일이 없다.
- `machine-type list --json` 기본은 요약이다(3.0.0, [[OD-149]]): `{id, name, cpus, memory, gpus,
  gpuType}` — 서버 순서·값 그대로(`id` 정렬과 `Gi` 단위는 사람 표에만). `createdAt`·`updatedAt` 은
  `--json --details`(원형).
- ★**`quota` 의 기본 범위는 "기본 프로젝트" 다** — *전체 합산이 아니다.* 제출(`instance create`·
  `job submit`)이 `projectId` 를 생략하면 서버가 기본 프로젝트로 넣으므로, **quota 가 보여주는
  숫자가 곧 그 제출을 막을 숫자**여야 한다. 두 기본값이 어긋나 있으면 프로젝트가 여러 개일 때
  quota 는 FREE 2 라고 하는데 제출은 거부되는 일이 생긴다.
  다른 프로젝트를 보려면 `--project <id>`(id 는 `oceanctl project list`).
  - 프로젝트는 있는데 **기본이 없으면** 전체 합산으로 떨어지지 않고 에러다(`default_project_not_found`)
    — 틀린 숫자보다 낫다.
  - 프로젝트가 **아예 없으면**(할당량 승인 전 신규 유저) 에러가 아니라 `범위 프로젝트 없음` 으로
    표시한다 — 프로젝트가 0개면 틀릴 숫자가 없고, 이때 죽이면 안내(`project list`)가 실행 불가다.

### 잡

제출 → 목록에서 uid 확인 → 로그. 이 셋이 한 흐름이다.

```
$ oceanctl job submit --name exp --image nvcr.io/nvidia/pytorch:24.01-py3 \
    --machine-type 48 --volume data --command "python train.py"
✓ 제출됨  exp  (잡 1개)
  상태와 uid 는 `oceanctl job list`, 안 뜨는 이유는 `oceanctl job why` 로 봅니다.

$ oceanctl job list
NAME   STATUS   NODE    JOB UID                               POD UID
exp-0  Running  gpu-01  7d0a1c9e-3f2b-4f7a-9c11-8e5a1b2c3d4e  1b9f8e7d-6c5b-4a39-8271-0f1e2d3c4b5a

총 1개 잡 그룹

$ oceanctl job logs --job-uid 7d0a1c9e-…
2026-07-29T01:00:11.123Z Epoch 1/10 loss=2.31
2026-07-29T01:00:39.884Z Epoch 2/10 loss=1.87

$ oceanctl job why --pod-uid 1b9f8e7d-…
사유  insufficient_gpu
설명  0/3 nodes are available: 3 Insufficient nvidia.com/gpu.
```

- ★**`job submit --json` 은 축약 봉투다**(2.0.0, [[OD-144]] — `instance create` 와 같은 논리):
  `{"name", "jobUids", "status"}` 셋뿐이고 서버 응답(`JobsResponse`) 원문을 싣지 않는다. 원문에는
  `command` **원문**(자격증명이 실릴 수 있는 필드)과 이메일이 들어 있어 에이전트 transcript 에
  영구 기록되기 때문이다. `jobUids` 는 `--repeat N` 이면 N 개(→ `job wait`·`job logs` 의
  `--job-uid`). **`podUid` 는 없다** — 파드는 제출 직후 비동기라 `job list` 가 답한다(`status` 가
  대개 `null` 인 것도 같은 이유 — 키는 항상 있고 없으면 `null`).
- **`--volume` 은 여기서도 필수다.** 잡은 인스턴스보다 더 아프다 — **끝나면 파드가 사라지므로**
  결과물을 PVC 에 안 썼으면 회수할 방법이 없다(웹은 볼륨 0개인 잡을 아예 만들 수 없다).
- **`--repeat` 은 기본 1** 이다(웹 생성 폼과 같은 기본값). N 을 주면 같은 잡이 N개 제출되고
  **할당량을 N개 먹는다** — `oceanctl quota` 의 `FREE` 를 먼저 보라.
- **`--project <id>` 로 프로젝트를 고른다**(생략 = 기본 프로젝트, 지금까지와 같다). id 는
  `oceanctl project list`. `quota --project` 와 **같은 범위**를 가리키므로, 제출 전에 본 숫자가 곧
  그 제출을 막을 숫자다.
- **`--node <name>` 으로 노드를 지정한다**(생략 = 스케줄러가 고른다). 후보는 `oceanctl node list`.
  ★핀은 **honored** 다 — 그 노드에 자리가 없으면 거부가 아니라 **pending** 이고(`--repeat` 은 모든
  반복이 같은 노드를 겨냥한다), 이유는 `oceanctl job why` 가 답한다.
- ★**없는 노드 이름은 서버가 막는다**(404 `node_not_found`, [[OD-117]]). 예전에는 그대로 받아
  파드가 **영원히 pending** 이었고 그동안 할당량을 먹었다 — 웹은 목록에서 고르게 해 그 실수가
  불가능했지만 CLI 는 자유 입력이라 열려 있었다. 지금은 **아무것도 만들지 않고** 거부한다.
- 노드가 **있는데** 자리가 없으면 그건 pending 이 맞다(honored 핀의 설계) — `oceanctl job why` 가
  `insufficient_gpu`·`selector_mismatch` 로 답한다.

```
$ oceanctl node list --machine-type 48 --for job
범위  머신타입 48 · job · 요청 2 GPU

NODE       FREE GPU  FITS  OWNER GROUP  SHARING    MINE
gpusystem         2  yes   물리학과      PUBLIC     -
ds02              0  no    내 랩         EXCLUSIVE  yes
```

- ★**`FREE GPU` 는 노드별**이다 — 총합이 아니라 노드별이라야 *"이 노드에 핀하면 지금 뜨는가"* 를
  안다. 여유가 **0인 노드도 숨기지 않는다**(그것도 판단 재료다).
- ★**`FITS` 가 그 판단을 대신 해 준다.** 전에는 "후보"가 *GPU 종류·컴퓨팅타입이 맞는 노드*를 뜻할 뿐
  *지금 들어갈 수 있는 노드*가 아니어서, 2 GPU 를 요청했는데 여유 1인 노드도 올라왔다. 그걸 `--node`
  로 핀하면 **영영 pending** 이다(2026-07-29 도그푸딩에서 실제로 그랬다). **전부 `no` 면** 표 아래에
  그렇게 적는다. 목록에서 지우지는 않는다 — `--node` 를 생략한 자동 배치는 여전히 가능하다.
- ★**판정은 서버가 한다**(`fits`). CLI 는 읽어서 그릴 뿐이다 — 잠깐 웹과 CLI 가 같은 규칙을 각각 갖고
  있었는데, 서버 필터가 바뀌면 둘 다 따로 어긋나는 모양이라 한 곳으로 모았다. 네 값이 각각 다르다:

  | 값 | 뜻 |
  |---|---|
  | `yes` | 서버가 **막을 이유를 못 찾았다**. "확실히 뜬다"가 아니다 — 서버는 GPU 만 판정한다 |
  | `no` | 서버가 부족을 확인했다 |
  | `-` | 이 머신타입은 GPU 를 안 쓴다 — **판정 대상이 아니라는 뜻**이지 "뜬다"가 아니다 |
  | `?` | 서버가 판정을 **안 줬다**(구버전). `no` 와 구분해서 읽어야 한다 |

- ★서버가 **CPU·메모리는 판정하지 않는다.** 사용량 지표가 Ocean namespace 파드만 세어 다른
  namespace(kube-system 등)가 잡아 둔 몫이 빠지기 때문이다 — 판정하면 거짓 `yes` 가 된다.
  그래서 `yes` 를 받고도 CPU 가 모자라 pending 일 수 있고, 그때 이유는 `oceanctl job why` 가 답한다.
- `--for` 는 **필수**다. 서버가 이 값으로 후보를 거르므로(노드마다 잡용/인스턴스용 라벨이 있다)
  생략하면 **조용히 틀린 목록**이 된다.
- `OWNER GROUP` 이 내 랩이 아니어도 **고를 수는 있다**(소유는 advisory). `SHARING` 이 그 노드의
  공유 정책이다 — `EXCLUSIVE`·`SHARED_RECLAIMABLE`·`PUBLIC`.
- ★노드 **변경 명령은 없다** — 소유·타입 지정은 ADMIN 운영 작업이다. 목록만 있는 이유는 `--node`
  에 넣을 이름을 CLI 안에서 알 수 있어야 하기 때문이다(볼륨·이미지와 같은 논리).
- **`job list` 는 파드마다 한 행**이다. `--repeat 3` 으로 만든 잡 그룹은 세 행으로 보인다 —
  로그·대기사유가 `podUid` 를 받으므로 행 단위가 파드여야 한다. 그래서 표 뒤의 총계는
  **잡 그룹 수**(서버가 세는 단위)라고 밝혀 둔다.
- **`JOB UID`·`POD UID` 를 그대로 복사해** `job logs`·`job why` 에 넣는다. 이름으로는 못 찾는다 —
  같은 이름으로 여러 번 제출할 수 있어서 CLI 가 이름→uid 규칙을 발명해야 하기 때문이다.
- ★**`job logs` 는 `--job-uid` 하나면 된다.** 잡의 파드가 하나면(오늘의 모든 잡) 서버가 그것을
  고른다. `--pod-uid` 는 **파드가 여럿일 때만** 필요하고, 그때 서버가 `pod_selection_required`
  (400)로 후보 uid 를 `details.podUids` 에 담아 알려준다. `--job-uid` 를 남긴 이유는 나중에
  멀티노드 잡이 와도 **잡 uid 는 그대로 정체성**이기 때문이다.
  (후보 uid 는 `--json` 의 `error.details.podUids` 에 온다 — 사람용 출력은 메시지와 코드만 낸다.
  `oceanctl job list` 의 `POD UID` 컬럼에서도 같은 값을 볼 수 있다.)
- ★**끝난 잡의 로그는 사라진다.** 파드가 정리되면(수명 제한 초과·GC) `job logs` 는 *"파드가 남아
  있지 않다"* 는 **에러**(`job_pod_not_found`)로 답한다 — 기다려도 안 오는 것을 "준비 중" 이라고
  하면 에이전트가 무한히 폴링한다.
- ★**`job logs` 는 `--json` 봉투를 씌우지 않는다**(설계 §5 가 정한 **명시적 예외**). 서버가
  `text/plain` **스트림**으로 주기 때문에, 봉투를 씌우려면 전부 모았다가 한 번에 내야 해서
  스트리밍이 아니게 된다. `--json` 을 줘도 **본문은 원문 그대로**이고 다음만 달라진다:

  | 언제 | 어디로 | 모양 |
  |---|---|---|
  | 로그 본문 | stdout | **원문 그대로** |
  | 스트림이 **시작되기 전** 에러(404·403·연결 실패) | `--json` 이면 stdout, 아니면 stderr | 평소 봉투 |
  | 스트림 **도중** 끊김 | **항상 stderr** + exit 1 | `✗ …` 한 줄 |

  마지막 줄이 핵심이다 — 이미 로그가 stdout 에 흐른 뒤 봉투를 stdout 에 얹으면 둘 다 못 읽는다.
- 로그는 **현재 로그(마지막 50줄)** 를 흘리고 끝난다 — `tail -f` 가 아니다(서버가 `follow=False`).
  실행 중인 잡에서도 멈추지 않으므로 에이전트가 매달릴 일이 없다.
- **파이프로 잘라 써도 된다**: `oceanctl job logs … | head -20` 처럼 읽는 쪽이 먼저 닫으면 조용히
  끝난다(**exit 0** — 원하는 만큼 받고 닫은 것이지 실패가 아니다).
- ★**`job why` 는 `null` 을 줄 수 있다** — 파드가 대기 중이 아니면(이미 스케줄됐거나 끝났으면)
  에러가 아니라 `{"ok": true, "data": null}` 이다. 에이전트는 이걸 *"기다릴 이유가 없다"* 로 읽는다.
  `category` 는 안정적인 값이라 분기 축으로 쓴다: `insufficient_gpu`·`selector_mismatch`·
  `node_unavailable`·`insufficient_cpu_mem`·`other`.
- `설명` 은 **쿠버네티스 스케줄러가 남긴 원문**이다(영문). 서버가 요약하지 않고 그대로 싣고
  (*"진실 은폐 금지"*) `category` 만 얹는다 — 분류에 안 걸리면 `other` + 원문이다.

### 스펙 파일로 제출하기 (`-f`)

> `repeat` 를 생략하면 플래그 경로와 같은 **1** 이다([[OD-127]]) — "스펙=서버 body 원형" 계약의 유일한 예외.

플래그가 늘면 한 줄이 길어진다. **`-f` 로 JSON 을 주면 된다** — 스키마는 **서버가 받는 요청 본문
그대로**다(CLI 전용 스키마가 없다).

> ★**주의**: `--json` **출력**은 이 파일과 모양이 다르다(제출 출력은 `{name, jobUids, status}`
> 축약 봉투다 — [[OD-144]]). *"`--json` 으로 나온 것을 그대로 `-f` 에 넣는다"* 는 **성립하지
> 않는다** — 처음 이 문서에 그렇게 적었다가 무맥락 리뷰가 잡았다. 재사용하려면 **보낸 스펙
> 파일을 보관**하면 된다.

```bash
$ cat job.json
{
  "name": "exp-42",
  "image": "myaiocean/pytorch:2.1.2-cuda12.1-cudnn8-runtime",
  "machineTypeId": 48,
  "command": "python train.py",
  "repeat": 1,
  "projectId": 7,
  "nodeName": "gpusystem",
  "purpose": "lr 3e-4 재현",
  "volumes": [
    {"volumeName": "test0521", "mountPath": "/volume/data"},
    {"volumeName": "datasets", "readOnly": true}
  ]
}

$ oceanctl job submit -f job.json
$ cat job.json | oceanctl job submit -f -        # stdin
```

- **`-f` 와 다른 플래그를 함께 쓸 수 없다**(v1). 병합 규칙(무엇이 이기나)을 지금 정하면 검증하지
  않은 규칙이 계약이 되기 때문이다 — 필요가 증명되면 넓힌다(넓히기는 안전하다).
- ★**모르는 필드는 CLI 가 거부한다.** 백엔드는 모르는 필드를 **조용히 무시**하므로 `"comand"` 오타가
  *"command 누락"* 422 로 둔갑하거나, 선택 필드 오타는 **아무 말 없이 사라진다.**
- **`instance create` 도 같다.** 단 스펙에 `command`·`repeat` 은 없다(잡 전용) — 넣으면 거부한다.
- 허용 필드에는 위 예시 밖의 **top-level `volumeName`(legacy 단수)** 도 있다([[OD-143]] 정체
  확인). 서버가 지금도 유효하게 받는 필드라(*"스펙 = 서버 body 원형"* 계약) 목록에 남아 있다 —
  의미는 `volumes: [{"volumeName": …}]` 한 개와 같고, **`volumes` 와 동시에 쓰면 서버가
  400 으로 거부**한다. 새 스펙은 `volumes` 를 쓰면 된다.
- `purpose` 는 **자유 텍스트 메모**(200자)다. 목록에 보이지만 **검색은 안 된다** — 실행 조건을
  적어 두는 용도이고, 플래그로는 안 받는다(파일에만 있다).
- YAML 은 지원하지 않는다 — 이 CLI 는 **런타임 의존성 0**이 계약이고 YAML 은 새 의존성이 필요하다.

## 에이전트가 쓸 때

```bash
oceanctl quota --json
# {"ok": true, "data": {"instances": [...], "jobs": [...]}}

oceanctl project list --json
# {"ok": false, "error": {"errorCode": "invalid_cli_token", "message": "..."}}   # exit 1
```

★**`job list --json` 의 기본은 평면 요약이다**(2.0.0, [[OD-145]]). 원형 전체가 기본이던 1.x 는
역사 잡 8그룹 기준 실측 **18.9KB** 로 에이전트 컨텍스트를 먹었고, 상태가 3단 깊이(`jobs[].
jobPodInfos[].status`)에 있어 첫 폴러가 스키마를 몰라 헛돌았다([[OD-126]]). 이제 사람용 표와
같은 단위(**파드마다 한 항목**)의 다섯 키가 전부이고, 이 모양이 계약이다:

```json
{"ok": true, "data": {"items": [
  {"name": "exp-0",                    // 잡 이름(그룹 아님 — repeat 이 -0·-1 을 만든다)
   "status": "Running", "node": "…",   // 제출 직후 파드가 아직 없으면 status·node·podUid = null
   "jobUid": "…",                      // ← job logs --job-uid · job wait --job-uid 에 넣는 값
   "podUid": "…"}                      // ← job why --pod-uid 에 넣는 값
], "total": 1}}                        // total = 잡 그룹 수(서버가 세는 단위 — 잘림 감지 재료)
```

★**잘림 판정은 `total` vs 보낸 `--limit`(생략 시 서버 기본 50)으로 한다 — `items` 개수와
비교하지 말 것.** `items` 는 **파드 단위**고 `total` 은 **그룹 단위**라, repeat 그룹이 섞이면
그룹이 잘렸는데도 `len(items) >= total` 이 될 수 있다(그룹 5개만 받았는데 그 안의 파드가
12개 > total 10 같은 모양). `total > limit` 이면 잘린 것이다 — `total` 이 200 이하면
`--limit <total>` 로 다시 부르고, 200 을 넘으면 상한이 200 이라(넘겨 보내면 검증 400)
CLI 로는 거기까지만 보고 나머지는 웹에서 본다(사람용 표의 안내와 같은 규칙).

`volume list --json` 도 같은 방식의 요약이다(실측 11.3KB → 다섯 키: `name`·`capacity`·`status`·
`shared`·`writeAllowed` — `--volume` 인자와 `:ro` 판단에 필요한 것들). ★**원형 전체가 필요하면
`--details` 를 붙인다** — 2.0.0 전의 기본이던 원형(잡 = 그룹→`jobs[]`→`jobPodInfos[]` 3단 ·
볼륨 = 워크로드 원문·annotations 포함)이 그대로 나온다. 서버가 원형의 원천인 것은 그대로고,
CLI 의 **기본 표현**만 요약이다(설계 §5 개정).

종결만 기다릴 거면 이 스키마도 파싱할 필요가 없다 — **`job wait` 가 폴링을 안으로 접는다**
([[OD-128]]): `oceanctl job wait --job-uid UID --json` → 성공 종결 exit 0 · 실패/시간초과 exit 1.

`whoami --json` 의 모양은 **계약이므로 여기 박아 둔다** — 바꾸면 에이전트 스크립트가 깨진다:

```json
{"ok": true, "data": {
  "user":         {"id": 5, "name": "오션어드민"},
  "email":        "lee@example.com",
  "organization": {"id": 3,  "name": "고려대"},
  "cluster":      {"id": 11, "name": "korea"},
  "token":        {"id": 7, "name": "p5-dev", "tokenPrefix": "ocn_ab12cd34",
                   "createdAt": "2026-07-28T00:00:00Z"},
  "apiUrl":       "https://api.aiocean.click"
}}
```

> ★2026-07-28 에 이 모양이 한 번 바뀌었다(`name`·`email` 이 최상위 → `user.name`·`email`).
> `whoami` 가 `GET /api/users/me` 대신 토큰 검증 라우트를 쓰게 되면서다. 사용자가 owner 뿐이라
> 그때는 무해했지만, 다음 변경은 **계약 변경**이다.

★`--json` 봉투의 기본값은 **다음 행동에 필요한 것**이다(2.0.0·3.0.0, 설계 §5 개정): 제출 응답은
축약 봉투([[OD-133]]·[[OD-144]]), 목록 다섯(`job list`·`volume list` — 2.0.0 · `instance list`·
`image list`·`machine-type list` — 3.0.0, [[OD-148]]·[[OD-149]])은 요약 projection — 원형은
`--details` 로 연다. 요약 스키마는 각 명령 절에 있고, 바깥 모양은 서버를 따른다: 페이지 봉투
라우트(`instance list`)는 `{items, total}` 그대로, 평면 리스트(`image`·`machine-type`)는 평면
그대로 — `--details` 로 갈아타도 바깥 모양은 안 바뀐다. 요약이 없는 나머지(`node list`·
`project list`·`quota`)는 받은 응답을 그대로 싣는다(owner 결정을 거친 목록만 요약한다).
사람용 표의 정렬·단위·`-` 채움은 어느 쪽 봉투에도 들어가지 않으므로, 사람용 출력을 손봐도
에이전트 파서가 깨지지 않는다.

- ★**필수 플래그가 빠지면 exit 1 + 봉투**다(`missing_required_flags`) — 어떤 플래그가 빠졌는지
  메시지에 이름으로 들어 있다. 예전에는 argparse 사용법이 exit **2** 로 나갔는데, `-f` 와
  양자택일이 되면서 검사를 코드로 옮겼고 그 편이 *"exit code 는 0/1"* 계약에도 맞다.
  ★버전 경계: create/submit 밖의 명령(`node list`·`instance delete` 등)은 **1.1.1~1.2.1 에선
  이 경우 `invalid_arguments`** 였다 — 1.2.2 부터 전 명령이 이 코드로 통일됐다
  (위치인자 누락은 계속 `invalid_arguments`).
- ★**파싱 오류도 exit 1 + 봉투**다(`invalid_arguments`, 1.1.1+) — 모르는 플래그(`--nope`),
  숫자여야 하는 자리에 문자(`--machine-type abc`), 없는 서브커맨드. 메시지가
  `oceanctl job submit: …` 처럼 **어느 명령의 인자가 틀렸는지**를 접두로 알려 준다.
  (1.1.0 까지는 이 부류만 argparse 가 exit **2** + stderr 로 죽었다 — 외부 에이전트 도그푸딩이
  잡은 세 번째 실패 모양이었고, 이제 실패 모양은 봉투 하나다. `--help`/`--version` 은 그대로
  exit 0.)
  ★**`--json` 이면 파싱 오류의 stderr 가 완전히 빈다**(2.0.0, [[OD-146]]) — 1.x 는 usage 를
  stderr 로도 찍어서, 스트림을 `2>&1` 로 합쳐 읽는 에이전트에게 JSON 앞에 usage 가 끼는
  잡음이 있었다. 사람 모드의 usage 안내는 그대로다.
- ★**`configure` 의 토큰 필요도 봉투**다(`token_required`, 1.2.2+) — 비대화형
  프로비저닝이 stdin 을 비워 보낸 경우. 서버 검증 실패(`invalid_cli_token`)와 함께 어느 쪽도
  파일을 만들거나 덮지 않는다.
- ★**파괴적 명령의 확인 게이트도 봉투**다(1.2.0+): `instance delete` 를 `--yes` 없이
  `--json`/비대화형으로 부르면 `confirmation_required`(요청은 서버에 안 나감 — `--yes` 붙여
  재시도), TTY 에서 취소하면 `delete_cancelled`(역시 요청 0건 · exit 1 — 아무것도 안 지웠는데
  0 이면 스크립트가 성공으로 읽기 때문).
- **성공/실패는 exit code 0/1** 로 먼저 갈린다. 세부 분기는 `error.errorCode` 로 한다
  (서버가 정의한 안정적인 snake_case 코드 — `cluster_id_required`·`invalid_cli_token` 등).
- `--json` 은 성공·실패 모두 **stdout 하나로** 나간다.
- CLI 는 **재시도하지 않는다.** 재시도 여부는 호출자가 `errorCode` 를 보고 판단한다
  (예: `cluster_tunnel_disconnected` 는 잠시 뒤 다시, `permission_forbidden` 은 재시도 무의미).

## 개발

```bash
python3 -m venv .venv && .venv/bin/pip install -e '.[dev]'
.venv/bin/pytest
.venv/bin/ruff check .
```

CI 게이트는 `.github/workflows/python-services.yml`(Tier 0 — ruff hard · pytest hard).
