Metadata-Version: 2.4
Name: oceanctl
Version: 1.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 애플리케이션이므로 **격리 설치가 맞다.**

### 권장 — pipx + PyPI

```bash
sudo apt install pipx        # 또는 python3 -m pip install --user pipx
pipx install oceanctl
oceanctl --version           # PATH 에 자동 등록된다
```

버전을 고정하려면 `pipx install oceanctl==1.0.0`. 레포 체크아웃에서 개발판을 깔려면
`pipx install /path/to/ocean-all/ocean-cli`.

### 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
```

- 자격증명은 `~/.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 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 logs --job-uid U [--pod-uid U]     잡 로그(raw 스트림 — 아래 참조)
oceanctl job why --pod-uid U                   그 파드가 왜 대기 중인가
```

> ★**`instance delete`·`stop` 은 없다**(설계 D4). 정리는 웹 UI 에서 한다. **`connect`/ssh 래퍼도
> 없다**(D5) — `instance list` 가 `sshCommand` 를 그대로 보여주고 거기서 멈춘다. 감싸면
> 포트포워딩·키·에이전트 전달까지 떠안게 되고, 그건 ssh 클라이언트가 이미 하는 일이다.
> **`job delete`·`cancel` 도 같은 이유로 없다** — 끝난 잡은 할당량을 먹지 않고 60일 뒤 자동으로
> 사라지므로 반복 실험이 그대로 돈다.

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

| 옵션 | 설명 |
|---|---|
| `--json` | **모든 명령** — `{ok, data}` / `{ok, error}` 봉투(에이전트용). **exit code 는 0/1**. ★`job logs` 는 예외 — 아래 |
| `--project <id>` | 대상 프로젝트(`quota`·`job submit`·`instance create` — **생략 = 기본 프로젝트**) |
| `--limit <N>` | 가져올 개수(`instance list`·`project 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` 와 같다).

```
$ 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`).
- `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`)·루트·상위 이동(`..`)·중복은 서버가 **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` 등은
  폭 때문에 뺐다). **`--json` 에는 전부 들어 있다** — 뺀 이유는 `oceanctl/cli.py` 의 표에 적혀 있다.
- SSH 칸이 `-` 면 그 클러스터가 SSH 를 NodePort 로 노출하지 않는 것이다. 가장 흔한 원인은
  **Istio 모드**(그때는 VSCODE 칸에 게이트웨이 URL 이 온다)지만 유일하지는 않다 — 클러스터
  노출 주소(`cluster.address`)가 비어 있어도 같은 결과다. 즉 `-` 는 *"이 경로로는 못 붙는다"* 이지
  *"Istio 다"* 가 아니다.
- 메모리 단위는 **`Gi`** 다 — 백엔드가 파드 스펙에 그대로 넣는 값이다(`{memory}Gi`).
- `FREE` = `QUOTA - USED`. 제출 전에 보는 명령이라 남은 개수가 먼저다.
- `MACHINE TYPE ID` 가 곧 제출에 넣을 `machineTypeId` 다 — `machine-type list` 를 다시 칠 일이 없다.
- ★**`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.
```

- **`--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`)

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

> ★**주의**: `--json` **출력**은 서버 **응답**이라 이 파일과 모양이 다르다(제출 응답은 만들어진 잡
> 정보다). *"`--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` 은 없다(잡 전용) — 넣으면 거부한다.
- `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
```

`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` 은 **서버 응답 원형**이다. 사람용 표의 정렬·단위·`-` 채움은 봉투에 들어가지 않으므로,
사람용 출력을 손봐도 에이전트 파서가 깨지지 않는다.

- ★**필수 플래그가 빠지면 exit 1 + 봉투**다(`missing_required_flags`) — 어떤 플래그가 빠졌는지
  메시지에 이름으로 들어 있다. 예전에는 argparse 사용법이 exit **2** 로 나갔는데, `-f` 와
  양자택일이 되면서 검사를 코드로 옮겼고 그 편이 *"exit code 는 0/1"* 계약에도 맞다.
  단 **argparse 가 먼저 잡는 것은 여전히 exit 2** 다 — 모르는 플래그(`--nope`), 숫자여야 하는
  자리에 문자(`--machine-type abc`), 없는 서브커맨드. 그것들은 파서가 인자를 해석하기도 전에
  죽는 자리라 CLI 코드가 손댈 수 없다.
- **성공/실패는 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).
