← 샘플

클라우드 리눅스에서 파이썬 돌리기

Station 과 linux 가상 기기를 만들고, running 을 기다려 스크립트를 쓰고 실행해요. 오래 걸리는 명령은 202 와 롱폴로 받아요.

무엇을 하나

가상 기기(type: virtual)를 만들면 OS 가 VM·Storage·에이전트를 함께 띄워요. 에이전트가 붙어 compute_status 가 running 이 되면 그 기기로 명령을 보내 파일을 쓰고 프로그램을 실행해요.

가상 기기 만들기와 명령은 크레딧을 써요. 계정 잔액이 0 이하면 402 INSUFFICIENT_CREDITS 로 막혀요 — 샘플은 잔액과 함께 알려 주고 멈춰요.

linux 에이전트는 셸 없이 허용된 프로그램(git·node·python3 …)만 격리 안에서 실행해요. 목록 밖이면 capability_denied 예요.

부르는 API

프롬프트

Claude Code 같은 AI 코딩 에이전트에 그대로 붙여 넣으세요. 아래 코드가 이 프롬프트로 만든 결과예요.

endue OS 공개 REST API 로 "클라우드 리눅스에서 파이썬 스크립트 실행" 을 하는 Node.js 스크립트를 만들어 줘.
의존성 없이 Node 22 의 fetch 만 쓰고, 파일 하나(linux-python-runner.mjs)로.

API 규칙:
- 기본 주소 ENDUE_OS_URL (없으면 http://127.0.0.1:5173), 경로는 /api/v1/...
- 인증: Authorization: Bearer <토큰>. 토큰은 ENDUE_OS_TOKEN, 없으면 로컬 가짜 IdP 에서
  POST {ENDUE_OS_URL}/idp/token?sub=<아무 이름>&ttl=3600 → { access_token }.
- 성공 응답은 { data, meta }, 오류는 { error: { code, message, details } }. 오류면 code 를 담아 던져.
- 402 INSUFFICIENT_CREDITS 는 크레딧이 부족하다는 뜻이다 — details.balance 와 함께 "충전 후 다시" 라고 알려 줘.

할 일:
1. POST /stations {name} 으로 Station 을 만든다.
2. POST /stations/{station_id}/devices {type: "virtual", kind: "linux", name, size: "small"} 로 가상 기기를 만든다
   → data.device (사용자 JWT 로만 — API 키면 403).
3. GET /devices/{device_id} 를 2초마다 불러 compute_status 가 "running" 이고 online 이 true 가 될 때까지 기다린다
   (3분 제한, compute_status "failed" 면 status_reason 과 함께 실패).
4. POST /devices/{device_id}/commands {command, input, wait_ms?} 를 보낸다.
   201 이면 data.output 이 결과, 202 면 GET /commands/{command_id}?wait_ms=25000 를 끝 상태가 될 때까지
   반복하고 GET /commands/{command_id}/output 으로 출력을 받는 run() 함수를 만든다.
5. write_file {path: "stats.py", content} 로 파이썬 스크립트를 쓰고,
   run_program {program: "python3", args: ["stats.py"]} 로 실행해 stdout 을 출력한다 (셸은 없다).
6. list_dir {path: "."} 를 wait_ms: 0 으로 보내 202 경로도 보여 준다.
7. finally 에서 KEEP=1 이 아니면 POST /devices/{id}/stop → DELETE /devices/{id} → DELETE /stations/{id}
   (Station 삭제는 기기가 사라질 때까지 409 일 수 있으니 몇 번 재시도).

결과 코드 linux-python-runner.mjs

#!/usr/bin/env node
// 클라우드 리눅스에서 파이썬 돌리기 — endue OS 샘플.
//   Station 을 만들고 → linux 가상 기기(virtual Device)를 띄우고 → running 을 기다려 → 스크립트를 써서 실행하고
//   → 오래 걸리는 명령은 202 + 롱폴로 받고 → 정리한다.
//
//   node linux-python-runner.mjs              로컬 스택 (가짜 IdP 로 토큰을 받는다)
//   ENDUE_OS_TOKEN=… ENDUE_OS_URL=… node …    운영 — endue 계정의 토큰
//   KEEP=1 node …                             끝나도 기기·Station 을 남긴다
const BASE = (process.env.ENDUE_OS_URL ?? 'http://127.0.0.1:5173').replace(/\/+$/, '');
const API = `${BASE}/api/v1`;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function getToken() {
  if (process.env.ENDUE_OS_TOKEN) return process.env.ENDUE_OS_TOKEN;
  // 로컬 스택 전용 — 가짜 IdP 가 아무 이름으로 토큰을 준다.
  const res = await fetch(`${BASE}/idp/token?sub=${process.env.ENDUE_OS_SUB ?? `sample-${Date.now().toString(36)}`}&ttl=3600`, { method: 'POST' });
  return (await res.json()).access_token;
}

const token = await getToken();

/** 공개 API 한 번. 성공이면 { status, data, headers }, 아니면 오류 코드를 담아 던진다. */
async function api(method, path, body) {
  const res = await fetch(`${API}${path}`, {
    method,
    headers: { Authorization: `Bearer ${token}`, ...(body ? { 'Content-Type': 'application/json' } : {}) },
    body: body ? JSON.stringify(body) : undefined,
  });
  const json = res.status === 204 ? null : await res.json();
  if (res.status === 402) {
    // 비용이 생기는 호출(가상 기기 만들기·시작, 명령)은 계정 잔액이 0 이하면 막힌다.
    throw new Error(`${method} ${path} → 402 ${json?.error?.code}: 크레딧이 부족해요 (잔액 ${json?.error?.details?.balance ?? '?'}). 충전한 뒤 다시 돌려 주세요.`);
  }
  if (!res.ok) throw new Error(`${method} ${path} → ${res.status} ${json?.error?.code}: ${json?.error?.message}`);
  return { status: res.status, data: json?.data, headers: res.headers };
}

/** 명령 하나. 25초 안에 끝나면 201 로 결과가 오고, 아니면 202 — 그때는 롱폴로 끝을 기다린다. */
async function run(deviceId, command, input, opts = {}) {
  const first = await api('POST', `/devices/${deviceId}/commands`, { command, input, ...opts });
  let cmd = first.data;
  if (first.status === 202) {
    console.log(`  … 202 Accepted (${cmd.command_id}) — 롱폴로 기다린다`);
    while (!['succeeded', 'failed', 'timed_out', 'cancelled', 'expired', 'unknown'].includes(cmd.status)) {
      cmd = (await api('GET', `/commands/${cmd.command_id}?wait_ms=25000`)).data;
    }
    if (cmd.status === 'succeeded') cmd.output = (await api('GET', `/commands/${cmd.command_id}/output`)).data.output;
  }
  if (cmd.status !== 'succeeded') throw new Error(`${command}: ${cmd.status} ${cmd.error_code ?? ''} ${cmd.error ?? ''}`);
  return cmd.output;
}

const station = (await api('POST', '/stations', { name: 'python-lab' })).data;
console.log(`Station ${station.station_id}`);
let device;
try {
  // 가상 기기 — VM·Storage·에이전트가 함께 생긴다. 사용자 JWT 로만 (API 키면 403).
  device = (await api('POST', `/stations/${station.station_id}/devices`, { type: 'virtual', kind: 'linux', name: 'py-box', size: 'small' })).data.device;
  console.log(`Device ${device.device_id} — ${device.compute_status}`);

  // 에이전트가 붙으면 running. 단건 조회는 전이 중인 가상 기기를 공급자에게 한 번 확인해 준다.
  const until = Date.now() + 180_000;
  while (!(device.compute_status === 'running' && device.online)) {
    if (device.compute_status === 'failed') throw new Error(`Device failed: ${device.status_reason}`);
    if (Date.now() > until) throw new Error('3분 안에 running 이 되지 않았다');
    await sleep(2000);
    device = (await api('GET', `/devices/${device.device_id}`)).data;
  }
  const rid = device.device_id;
  console.log(`running — device ${rid}`);

  const script = ['import platform, statistics', 'xs = [3, 1, 4, 1, 5, 9, 2, 6]', 'print(platform.machine(), statistics.mean(xs), sorted(xs))'].join('\n');
  await run(rid, 'write_file', { path: 'stats.py', content: script });
  const out = await run(rid, 'run_program', { program: 'python3', args: ['stats.py'] });
  console.log(`python3 stats.py → ${String(out.stdout).trim()}`);

  // wait_ms: 0 이면 바로 202 — 오래 걸리는 작업을 보내 두고 나중에 받는 모양.
  const listing = await run(rid, 'list_dir', { path: '.' }, { wait_ms: 0 });
  console.log(`작업 폴더: ${(listing.entries ?? []).map((e) => e.name).join(', ')}`);
} finally {
  if (process.env.KEEP) {
    console.log('KEEP=1 — 남겨 둔다');
  } else {
    if (device) {
      await api('POST', `/devices/${device.device_id}/stop`).catch(() => {});
      await api('DELETE', `/devices/${device.device_id}`).catch((e) => console.warn(String(e)));
    }
    for (let i = 0; i < 15; i++) {
      const ok = await api('DELETE', `/stations/${station.station_id}`).then(() => true, () => false);
      if (ok) break;
      await sleep(2000);
    }
    console.log('정리했다');
  }
}

돌려 보기

# endue-os 제품 레포에서 로컬 스택을 띄운 뒤
local/up.sh
node site/samples/linux-python-runner.mjs

# 운영 (예정) — endue 계정의 토큰으로
ENDUE_OS_URL=https://api.endueos.com ENDUE_OS_TOKEN=… node linux-python-runner.mjs