diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c81d217 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +web-interface/node_modules +web-interface/dist +logs +**/__pycache__ diff --git a/.gitignore b/.gitignore index e660fd9..91c1095 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,6 @@ bin/ +/logs/ +__pycache__/ +*.py[cod] +auth-service/node_modules/ +auth-service/data/ diff --git a/README.md b/README.md index 684da20..1c502b6 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,115 @@ # Mist -Mist: UTMIST's Compute Platform +UTMIST's compute platform. The Kubernetes pilot runs submitted CPU, NVIDIA, +and Tenstorrent workloads as real Jobs. The Jobs page and CLI use the same +API for submission, status, logs, and cancellation. The local foundation adds +live hardware inventory, self-service container images, team workspaces, +scoped shared storage and a durable fair job queue. -## Runtime requirements +## Current pilot -- Go 1.25.1 (also satisfies the backend's Go 1.24.6 requirement) -- Node.js 22.16.0 or newer, with npm -- Docker Engine and the Docker Compose plugin (`docker compose`) -- Permission to run `docker ps` as the same user that runs the backend +- `utmist-z1opa08`: k3s server, CPU workloads, two NVIDIA RTX A4000 GPUs. +- `utmist-tt`: QuietBox worker, four n300 boards with two Wormhole chips each. +- Request one or two NVIDIA GPUs, or one to four Tenstorrent boards. Other + jobs can use the remaining devices; requests wait when capacity is occupied. +- Website: http://100.73.139.66:8088. API on that origin: `/api`; auth: `/auth`. -## Run locally +The department rollout includes admin/member login, team common/member folders, +explicit cross-team read/use grants, enforced storage/resource limits, durable +fair admission and revocation. The portal uses interactive compute choices, +clear feature panels and searchable, paginated job rows that expand for details. +Use **http://100.73.139.66:8088** privately through Tailscale. -Ensure `go version`, `node --version`, and `docker ps` work before starting. -Run the following from the repository root: +See [the current department operating guide](docs/department-rollout.md), +[private deployment operations](deploy/private/README.md), +[completed execution plan and evidence](docs/department-rollout-execution.md), +[portal design](docs/design-system.md), and +[release testing](docs/testing.md). The earlier foundation guides record +historical phases. + +Credits, Jupyter, chaining, browser image archives/builds and arbitrary distributed +training remain outside this release. Custom TT code/images must use compatible +TT libraries. Automatic backups, HTTPS and monitoring/cleanup products were +explicitly excluded. Hosting remains private HTTP over Tailscale. + +## Developer preview against this cluster + +**Keep production authentication/team enforcement enabled for research users.** +The standalone trusted-owner mode below is only for development. + +Use Go 1.25.1 and Node.js 22.16.0 or newer. Apply the workload prerequisites +and claim templates described in the deployment guide first. Kubernetes is +now the default executor; Redis and Docker supervisors are unnecessary for +this path. This server has both runtimes under `~/.local/share/mist-runtimes`: ```bash -docker compose up -d redis -docker build -t pytorch-cpu src/docker/pytorch-cpu +export PATH=/home/utmist/.local/share/mist-runtimes/go-1.25.1/bin:/home/utmist/.local/share/mist-runtimes/node-22.16.0/bin:$PATH ``` -Start the backend in one terminal. Run it from `src` so it finds its logging configuration: - ```bash +export KUBECONFIG=/home/utmist/.kube/config +# Standalone development defaults to a trusted local owner with auth disabled. +# For the deployed authenticated/shared API, use its existing port-forward. +export MIST_PILOT_OWNER=local-dev cd src go run . ``` -Start the frontend in another terminal: +If the deployed API's port-forward already occupies port 3000, use that API +or stop only `mist-api-forward.service` before starting a local backend. +Standalone `go run` uses the default base-image allowlist. Set +`MIST_ALLOWED_IMAGES` to the deployment's approved references when developing +with the packaged example images or other custom images. + +In another terminal: ```bash cd web-interface -npm install --package-lock=false -npm run dev -- --port 3001 +npm ci +npm run dev -- --host 127.0.0.1 --port 3001 --strictPort ``` -Open http://localhost:3001/jobs. The API uses http://localhost:3000. -The repository includes a Bun lockfile; npm installation does not use that lockfile. +The Vite proxy forwards `/api` and `/auth` to the API on port 3000. The installed user +services already run both endpoints on this machine: -The optional CLI can be built from a third terminal: +```bash +systemctl --user status mist-api-forward mist-web +``` + +## CLI + +From the repository root: ```bash -cd cli -go build -o ../bin/mist . -../bin/mist --help +go -C cli build -o ../bin/mist . +printf "print('Hello from a real Mist job')\n" > /tmp/hello.py +export MIST_API_URL=http://100.73.139.66:8088/api +bin/mist auth login --email your-member-email@example.org +bin/mist job submit /tmp/hello.py --compute CPU +bin/mist job list --all +bin/mist job status +bin/mist job logs +bin/mist job cancel ``` -The frontend and CLI currently contain mock data and placeholder actions. The -backend's job execution is also incomplete; starting these services does not yet -provide end-to-end workload execution. +Use `--compute NVIDIA --devices 1` for one GPU, or +`--compute TT --devices 1` for one two-chip board. The submitted script must +actually use the chosen accelerator. `--api-url` or `MIST_API_URL` selects an +API endpoint. See [CLI setup](cli/docs/setup.md) for resource options. + +## Tests and legacy development + +```bash +go -C src test -run 'Test(Kubernetes|Hardware|GPURequest|CustomImage|TenstorrentCustom|Shared|Member)' ./... +go -C cli test ./... +cd web-interface +npm test -- --run +npm run build +npx tsc --noEmit +``` -Stop the backend/frontend with Ctrl+C in their terminals, then run -`docker compose stop redis` from the repository root to stop Redis without deleting its data. +The older Docker/Redis executor is available with `MIST_EXECUTOR=docker`. +Its supervisor execution is incomplete. For its existing tests, start a +local Redis instance and run `go -C src test ./...`; Docker integration tests +also need Docker permissions and their test image. This path is retained for +compatibility and is separate from the validated Kubernetes executor. diff --git a/auth-service/.dockerignore b/auth-service/.dockerignore new file mode 100644 index 0000000..15de390 --- /dev/null +++ b/auth-service/.dockerignore @@ -0,0 +1,2 @@ +node_modules +data diff --git a/auth-service/Dockerfile b/auth-service/Dockerfile new file mode 100644 index 0000000..17ae551 --- /dev/null +++ b/auth-service/Dockerfile @@ -0,0 +1,13 @@ +FROM node:22.16.0-bookworm-slim AS dependencies +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ && rm -rf /var/lib/apt/lists/* +COPY package*.json ./ +RUN npm ci --omit=dev +FROM node:22.16.0-bookworm-slim +ENV NODE_ENV=production HOST=0.0.0.0 PORT=3002 MIST_AUTH_DATABASE=/data/auth.sqlite +WORKDIR /app +COPY --from=dependencies /app/node_modules ./node_modules +COPY package.json server.mjs ./ +USER 65532:65532 +EXPOSE 3002 +CMD ["node", "server.mjs"] diff --git a/auth-service/README.md b/auth-service/README.md new file mode 100644 index 0000000..9f15490 --- /dev/null +++ b/auth-service/README.md @@ -0,0 +1,25 @@ +# Mist member authentication + +Small Node service using maintained Better Auth 1.7.7 and better-sqlite3 13.0.3. +It is internal to `mist-system`; the Go gateway proxies `/auth` on the website's +origin and checks sessions before authorizing data routes. No public signup. + +`npm ci && npm start` requires Node 22.16 or newer and these environment variables: + +- `BETTER_AUTH_SECRET`: protected random secret, at least 32 characters. +- `MIST_SITE_ORIGIN`: exact website origin; HTTPS enables Secure cookies. +- `MIST_ALLOWED_ORIGINS`: comma-separated trusted origins. +- `MIST_AUTH_DATABASE`: local SQLite path (default `./data/auth.sqlite`). +- `MIST_ADMIN_EMAIL`, `MIST_ADMIN_PASSWORD`: only needed when initializing an empty + database. The first user is an administrator. Existing users are never reset. +- `HOST`, `PORT`: listener (default localhost:3002). + +Never put a real secret or bootstrap password in the image, Git, shell arguments +or logs. Kubernetes injects the protected Secret. SQLite uses WAL, a busy timeout +and startup migrations on a persistent local PVC, with one Recreate replica. +Database-backed rate limiting survives restarts. Upload/storage metadata live +in Go/NFS, not in this auth database. Member passwords use the library's password +implementation; Go does not hash passwords or mint authentication tokens. + +See [operations and recovery](../deploy/private/README.md) for online backups, +restoration, private HTTP/HTTPS configuration and deployment. diff --git a/auth-service/package-lock.json b/auth-service/package-lock.json new file mode 100644 index 0000000..28c2a92 --- /dev/null +++ b/auth-service/package-lock.json @@ -0,0 +1,396 @@ +{ + "name": "mist-auth", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mist-auth", + "dependencies": { + "better-auth": "1.7.7", + "better-sqlite3": "13.0.3" + }, + "engines": { + "node": ">=22.16.0" + } + }, + "node_modules/@better-auth/core": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.7.7.tgz", + "integrity": "sha512-srgFzHEjB2WWlokFz1i4BfG41sNhsA105Pukgqu6RrEh5A+LSkzf1mCHydVipkjt9l5qswtBMi7IL6ouHbZL4Q==", + "license": "MIT", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.41.1", + "@standard-schema/spec": "^1.1.0", + "zod": "^4.5.4" + }, + "peerDependencies": { + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.2", + "@opentelemetry/api": "^1.9.0", + "better-call": "1.4.0", + "jose": "^6.1.0", + "kysely": "^0.28.5 || ^0.29.0", + "nanostores": "^1.0.1" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@better-auth/drizzle-adapter": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.7.7.tgz", + "integrity": "sha512-qon0U94PR5FQysuyGoAlGVQpIIiNg5dtDpwjE63sN/HjoRWZadZzHPcS9mn7JsJyQZK6iTNhd7LD0AaXt2f78w==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.7.7", + "@better-auth/utils": "0.4.2", + "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0" + }, + "peerDependenciesMeta": { + "drizzle-orm": { + "optional": true + } + } + }, + "node_modules/@better-auth/kysely-adapter": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.7.7.tgz", + "integrity": "sha512-9FONOCgOcrQI9wJ5v1oDGIg9sT7pa9RjZZQ4fsCTudo8R87SrIGNUvUxDFTJhZvUblvFfGaIBR9Vb8LA+8bqLQ==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.7.7", + "@better-auth/utils": "0.4.2", + "kysely": "^0.28.17 || ^0.29.0" + }, + "peerDependenciesMeta": { + "kysely": { + "optional": true + } + } + }, + "node_modules/@better-auth/memory-adapter": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.7.7.tgz", + "integrity": "sha512-FqKFEe+b5tXXV0gRbyirca+qXgMpLOeJ8Wk19yykb/scQKy0WJ4k22WzEV3TTPctQV9DZhwCXPlDtQhnlOv5Gg==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.7.7", + "@better-auth/utils": "0.4.2" + } + }, + "node_modules/@better-auth/mongo-adapter": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.7.7.tgz", + "integrity": "sha512-ZnVDuRrXqbf0oHphrEN27oTImNH5NTO26dtWatq5cXyYAsH7goof9QIoTOmxTbBxUQahKBw9T6+9h6n+RtOE+g==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.7.7", + "@better-auth/utils": "0.4.2", + "mongodb": "^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "mongodb": { + "optional": true + } + } + }, + "node_modules/@better-auth/prisma-adapter": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.7.7.tgz", + "integrity": "sha512-4YcnrcVdvWiAZw0sZl63tWUClPXg5r3QsLn7C2sG7kKEBY06zOng0ibecyxRBixKq1zE1ceHuEUqez5Rhi+nQA==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.7.7", + "@better-auth/utils": "0.4.2", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "@prisma/client": { + "optional": true + }, + "prisma": { + "optional": true + } + } + }, + "node_modules/@better-auth/telemetry": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.7.7.tgz", + "integrity": "sha512-PaRA6i+kAioVYmza2gHPcdrxP0hAotph4KV9hft2GeNJq+AZXDiBu3FuLM9HxvcmiZeUT74Vufqd9ypmB8veTA==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.7.7", + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.2" + } + }, + "node_modules/@better-auth/utils": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.4.2.tgz", + "integrity": "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1" + } + }, + "node_modules/@better-fetch/fetch": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.3.2.tgz", + "integrity": "sha512-Gs7n99b5tqUC6cQAPbV0uED3IraHB6xQbHLQ/C3l7ZFafHScOx9pQ+DYmP5blbLFShVWLqxNUlI9wi4xU/X+ow==", + "license": "MIT" + }, + "node_modules/@noble/ciphers": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz", + "integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/better-auth": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.7.7.tgz", + "integrity": "sha512-Zhgxi/c5ylmfB/sX+nwnBbsFf/j6k0L8DvL6CJXZSrXgxc5pPMC/e7j812zNd4L4B4ELmdURCPw3X8nAOUdcjw==", + "license": "MIT", + "dependencies": { + "@better-auth/core": "1.7.7", + "@better-auth/drizzle-adapter": "1.7.7", + "@better-auth/kysely-adapter": "1.7.7", + "@better-auth/memory-adapter": "1.7.7", + "@better-auth/mongo-adapter": "1.7.7", + "@better-auth/prisma-adapter": "1.7.7", + "@better-auth/telemetry": "1.7.7", + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.2", + "@noble/ciphers": "^2.2.0", + "@noble/hashes": "^2.2.0", + "better-call": "1.4.0", + "defu": "^6.1.4", + "jose": "^6.2.3", + "kysely": "^0.28.17 || ^0.29.0", + "nanostores": "^1.3.0", + "zod": "^4.5.4" + }, + "peerDependencies": { + "@lynx-js/react": "*", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "@sveltejs/kit": "^2.0.0", + "@tanstack/react-start": "^1.0.0", + "@tanstack/solid-start": "^1.0.0", + "drizzle-kit": ">=0.31.4 || >=1.0.0-beta.1", + "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0", + "mongodb": "^6.0.0 || ^7.0.0", + "mysql2": "^3.0.0", + "next": "^14.0.0 || ^15.0.0 || ^16.0.0", + "pg": "^8.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0", + "solid-js": "^1.0.0", + "svelte": "^4.0.0 || ^5.0.0", + "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", + "vue": "^3.0.0" + }, + "peerDependenciesMeta": { + "@lynx-js/react": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@sveltejs/kit": { + "optional": true + }, + "@tanstack/react-start": { + "optional": true + }, + "@tanstack/solid-start": { + "optional": true + }, + "drizzle-kit": { + "optional": true + }, + "drizzle-orm": { + "optional": true + }, + "mongodb": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "next": { + "optional": true + }, + "pg": { + "optional": true + }, + "prisma": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "solid-js": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vitest": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/better-call": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.4.0.tgz", + "integrity": "sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA==", + "license": "MIT", + "dependencies": { + "@better-auth/utils": "^0.5.0", + "@better-fetch/fetch": "^1.3.1", + "rou3": "^0.9.1", + "set-cookie-parser": "^3.1.2" + }, + "peerDependencies": { + "zod": "^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/better-call/node_modules/@better-auth/utils": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.5.0.tgz", + "integrity": "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1" + } + }, + "node_modules/better-sqlite3": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", + "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/kysely": { + "version": "0.29.6", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.6.tgz", + "integrity": "sha512-hHaB8C/rfzDDtr/t8YZwxAuPJTT0zHyaPoVzcXwDYhYNAgH/4sIfVhi/XLLIY+bL/FqaIJnjATDbi8ObSELmxg==", + "license": "MIT", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/nanostores": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.5.5.tgz", + "integrity": "sha512-FixtE239Gl6Gz0ZGCK0WzvNwObNCIwVT+L7zQcf1QgcA6AzBxblzqe9OT88qiucNkhsjSOqiogSaF5jVbJiqSA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, + "node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/rou3": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.9.2.tgz", + "integrity": "sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==", + "license": "MIT" + }, + "node_modules/set-cookie-parser": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.3.tgz", + "integrity": "sha512-xletSdAI5efyVJjsatBbzDkXl3M/Zmad3xfnQe7ZXuBB0yNwW7C2WFsNGjdRNfZunmNHQuX79xs/ACMTLCdcew==", + "license": "MIT" + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/auth-service/package.json b/auth-service/package.json new file mode 100644 index 0000000..411117f --- /dev/null +++ b/auth-service/package.json @@ -0,0 +1,11 @@ +{ + "name": "mist-auth", + "private": true, + "type": "module", + "engines": { "node": ">=22.16.0" }, + "scripts": { "start": "node server.mjs" }, + "dependencies": { + "better-auth": "1.7.7", + "better-sqlite3": "13.0.3" + } +} diff --git a/auth-service/server.mjs b/auth-service/server.mjs new file mode 100644 index 0000000..42cb089 --- /dev/null +++ b/auth-service/server.mjs @@ -0,0 +1,73 @@ +import { createServer } from 'node:http' +import { mkdirSync } from 'node:fs' +import { dirname } from 'node:path' +import { timingSafeEqual } from 'node:crypto' +import Database from 'better-sqlite3' +import { betterAuth } from 'better-auth' +import { getMigrations } from 'better-auth/db/migration' +import { toNodeHandler } from 'better-auth/node' +import { admin } from 'better-auth/plugins/admin' +import { APIError, createAuthMiddleware } from 'better-auth/api' + +const dbPath = process.env.MIST_AUTH_DATABASE ?? './data/auth.sqlite' +const origin = process.env.MIST_SITE_ORIGIN ?? 'http://127.0.0.1:3001' +const secret = process.env.BETTER_AUTH_SECRET +if (!secret || secret.length < 32) throw new Error('BETTER_AUTH_SECRET must contain at least 32 characters') +mkdirSync(dirname(dbPath), { recursive: true, mode: 0o700 }) +const database = new Database(dbPath) +database.pragma('journal_mode = WAL') +database.pragma('busy_timeout = 5000') +const options = { + appName: 'Mist', database, secret, baseURL: origin, basePath: '/auth', + trustedOrigins: (process.env.MIST_ALLOWED_ORIGINS ?? origin).split(',').filter(Boolean), + emailAndPassword: { enabled: true, disableSignUp: true, minPasswordLength: 12, maxPasswordLength: 128 }, + session: { expiresIn: 60 * 60 * 24 * 7, updateAge: 60 * 60 * 24, cookieCache: { enabled: false } }, + advanced: { useSecureCookies: origin.startsWith('https:'), ipAddress: { ipAddressHeaders: ['x-real-ip'] } }, + rateLimit: { enabled: true, storage: 'database', window: 60, max: 120, + customRules: { '/get-session': false, '/sign-in/email': { window: 60, max: 8 } } }, + plugins: [admin()], + hooks: { before: createAuthMiddleware(async (ctx) => { + if (ctx.path === '/admin/create-user' || ctx.path === '/admin/set-user-password') { + const password = ctx.body?.password ?? ctx.body?.newPassword + if (typeof password !== 'string' || password.length < 12 || password.length > 128) + throw new APIError('BAD_REQUEST', { message: 'Password must contain 12–128 characters' }) + } + }) }, +} +await (await getMigrations(options)).runMigrations() +const auth = betterAuth(options) +if (!database.prepare('SELECT id FROM user LIMIT 1').get()) { + const email = process.env.MIST_ADMIN_EMAIL + const password = process.env.MIST_ADMIN_PASSWORD + if (!email || !password || password.length < 12) throw new Error('Initial admin credentials are required for an empty database') + // A server-only SDK call, before the network listener exists. Public signup stays disabled. + await auth.api.createUser({ body: { name: 'Mist Administrator', email, password, role: 'admin' } }) + console.log('Initial Mist administrator created; credentials are not logged') +} +const handler = toNodeHandler(auth) +const server = createServer((req, res) => { + if (req.url === '/internal/members' && req.method === 'GET') { + const configured = process.env.MIST_INTERNAL_TOKEN ?? '' + const presented = req.headers.authorization?.replace(/^Bearer /, '') ?? '' + if (configured.length < 32 || Buffer.byteLength(configured) !== Buffer.byteLength(presented) || + !timingSafeEqual(Buffer.from(configured), Buffer.from(presented))) { + res.writeHead(403); res.end(); return + } + const members = database.prepare('SELECT id, banned, role FROM user').all() + res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }) + res.end(JSON.stringify({ members: members.map(({ id, banned, role }) => ({ id, active: !banned, role })) })) + return + } + if (req.url === '/healthz') { + try { database.prepare('SELECT 1').get(); res.writeHead(200); res.end('ok') } + catch { res.writeHead(503); res.end('database unavailable') } + return + } + if (!req.url?.startsWith('/auth/')) { res.writeHead(404); res.end(); return } + // Only the same-origin Go proxy is exposed to clients. It replaces this header. + void handler(req, res) +}) +server.requestTimeout = 30000 +server.headersTimeout = 10000 +server.listen(Number(process.env.PORT ?? 3002), process.env.HOST ?? '127.0.0.1', () => console.log('Mist auth service ready')) +for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => server.close(() => { database.close(); process.exit(0) })) diff --git a/cli/cmd/api_client.go b/cli/cmd/api_client.go new file mode 100644 index 0000000..555490f --- /dev/null +++ b/cli/cmd/api_client.go @@ -0,0 +1,83 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +func (ctx *AppContext) api(method, path string, body, out interface{}) error { + if ctx == nil { + return fmt.Errorf("missing application context") + } + base := ctx.baseURL() + var data []byte + var err error + if body != nil { + data, err = json.Marshal(body) + if err != nil { + return err + } + } + req, err := http.NewRequest(method, strings.TrimRight(base, "/")+path, bytes.NewReader(data)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + if team := ctx.teamID(); team != "" { + req.Header.Set("X-Mist-Team", team) + } + if ctx.Config != nil && ctx.Config.SessionCookie != "" { + req.Header.Set("Cookie", ctx.Config.SessionCookie) + } + client := ctx.HTTPClient + if client == nil { + client = &http.Client{Timeout: 30 * time.Second} + } + response, err := client.Do(req) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + data, _ := io.ReadAll(io.LimitReader(response.Body, 8192)) + var errorBody struct { + Error string `json:"error"` + } + if json.Unmarshal(data, &errorBody) == nil && errorBody.Error != "" { + return fmt.Errorf("API %d: %s", response.StatusCode, errorBody.Error) + } + return fmt.Errorf("API %d: %s", response.StatusCode, strings.TrimSpace(string(data))) + } + if out != nil { + return json.NewDecoder(response.Body).Decode(out) + } + return nil +} + +func (ctx *AppContext) baseURL() string { + if ctx.APIBaseURL != "" { + return strings.TrimRight(ctx.APIBaseURL, "/") + } + if ctx.Config != nil && ctx.Config.APIBaseURL != "" { + return strings.TrimRight(ctx.Config.APIBaseURL, "/") + } + return "http://127.0.0.1:3000" +} + +func (ctx *AppContext) teamID() string { + if ctx.TeamID == "legacy" { + return "" + } + if ctx.TeamID != "" { + return ctx.TeamID + } + if ctx.Config != nil { + return ctx.Config.TeamID + } + return "" +} diff --git a/cli/cmd/auth.go b/cli/cmd/auth.go index 6df3e45..9da9f38 100644 --- a/cli/cmd/auth.go +++ b/cli/cmd/auth.go @@ -1,8 +1,8 @@ package cmd type AuthCmd struct { - Login LoginCmd `cmd:"" help:"Log in to your account"` - // Logout LogoutCmd `cmd:"" help:"Log out of your account"` + Login LoginCmd `cmd:"" help:"Log in to your account"` + Logout LogoutCmd `cmd:"" help:"Sign out and revoke your session"` // Status AuthStatusCmd `cmd:"" help:"Check your authentication status" default:1` } diff --git a/cli/cmd/auth_login.go b/cli/cmd/auth_login.go index 9209865..8fff8d0 100644 --- a/cli/cmd/auth_login.go +++ b/cli/cmd/auth_login.go @@ -1,109 +1,148 @@ package cmd import ( - "bufio" + "bytes" "encoding/json" "fmt" + "golang.org/x/term" + "io" + "net/http" + "net/url" "os" - "os/exec" "path/filepath" - "runtime" "strings" ) -// TODO: Update with real auth URL -const authUrl = "https://example.com/login" - type LoginCmd struct { + Email string `help:"Mist member email" required:""` + PasswordStdin bool `help:"Read password from standard input (for automation)"` } -func openUrl() error { - - var cmd *exec.Cmd - url := authUrl - switch runtime.GOOS { - case "darwin": - cmd = exec.Command("open", url) - case "windows": - cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) - default: // Linux, BSD, etc. - cmd = exec.Command("xdg-open", url) +func (l *LoginCmd) Run(ctx *AppContext) error { + var password string + if l.PasswordStdin { + data, err := io.ReadAll(io.LimitReader(os.Stdin, 130)) + if err != nil { + return err + } + password = strings.TrimSpace(string(data)) + } else { + fmt.Print("Password: ") + data, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + if err != nil { + return fmt.Errorf("password input: %w (use --password-stdin for automation)", err) + } + password = string(data) } - - return cmd.Start() -} - -func saveTokenToConfig(ctx *AppContext, token string) error { - configPath := defaultConfigPath() - if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { - return fmt.Errorf("failed to create config directory: %w", err) + base := ctx.baseURL() + u, err := url.Parse(base) + if err != nil { + return err + } + u.Path = "/auth/sign-in/email" + u.RawQuery = "" + body, _ := json.Marshal(map[string]string{"email": l.Email, "password": password}) + req, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", u.Scheme+"://"+u.Host) + response, err := ctx.HTTPClient.Do(req) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode != 200 { + return fmt.Errorf("login failed (HTTP %d)", response.StatusCode) + } + cookies := []string{} + for _, cookie := range response.Cookies() { + if strings.Contains(cookie.Name, "session_token") && cookie.Value != "" { + cookies = append(cookies, cookie.Name+"="+cookie.Value) + } + } + if len(cookies) == 0 { + return fmt.Errorf("login returned no session cookie") } - if ctx.Config == nil { ctx.Config = &Config{} } - ctx.Config.AccessToken = token - + ctx.Config.SessionCookie = strings.Join(cookies, "; ") + ctx.Config.AccessToken = "" + ctx.Config.APIBaseURL = base + if err := ctx.saveConfig(); err != nil { + return err + } + fmt.Println("Signed in. Session saved in protected CLI configuration.") + return nil +} +func (ctx *AppContext) saveConfig() error { + path := ctx.ConfigPath + if path == "" { + path = defaultConfigPath() + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } data, err := json.MarshalIndent(ctx.Config, "", " ") if err != nil { - return fmt.Errorf("failed to marshal config: %w", err) + return err } - if err := os.WriteFile(configPath, data, 0o600); err != nil { - return fmt.Errorf("failed to write config file: %w", err) + file, err := os.CreateTemp(filepath.Dir(path), ".mist-config-") + if err != nil { + return err } - fmt.Println("Token saved:", token) - return nil -} - -func getLongLivedToken(shortLivedToken string) (string, error) { - // Placeholder for actual implementation to exchange short-lived token for long-lived token - // In a real scenario, this would involve making an HTTP request to the auth server - return shortLivedToken + "_long_lived", nil + defer os.Remove(file.Name()) + if err = file.Chmod(0600); err != nil { + file.Close() + return err + } + if _, err = file.Write(data); err != nil { + file.Close() + return err + } + if err = file.Close(); err != nil { + return err + } + return os.Rename(file.Name(), path) } -func (l *LoginCmd) Run(ctx *AppContext) error { - // mist auth login - if ctx.Config != nil && ctx.Config.AccessToken != "" { +type LogoutCmd struct{} - // Already logged in, ask if they want to re-login - fmt.Println("Already logged in with token:", ctx.Config.AccessToken) - fmt.Print("Re-enter token? (y/N): ") - reader := bufio.NewReader(os.Stdin) - answer, _ := reader.ReadString('\n') - answer = strings.TrimSpace(strings.ToLower(answer)) - if answer != "y" && answer != "yes" { - fmt.Println("Aborting login.") - return nil - } +func (l *LogoutCmd) Run(ctx *AppContext) error { + if ctx.Config == nil || ctx.Config.SessionCookie == "" { + fmt.Println("Not signed in.") + return nil } - - fmt.Println("Opening browser for authentication...") - fmt.Printf("If your browser didn't open, click here: \033]8;;%s\033\\%s\033]8;;\033\\\n", authUrl, authUrl) - - err := openUrl() + base, err := url.Parse(ctx.baseURL()) if err != nil { - fmt.Println("Error opening browser:", err) return err } - fmt.Print("token: ") - - reader := bufio.NewReader(os.Stdin) - token, _ := reader.ReadString('\n') - token = strings.TrimSpace(token) - - token, err = getLongLivedToken(token) + origin := base.Scheme + "://" + base.Host + base.Path = "/auth/sign-out" + base.RawQuery = "" + req, err := http.NewRequest(http.MethodPost, base.String(), strings.NewReader("{}")) if err != nil { - fmt.Println("Error obtaining long-lived token:", err) return err } - - err = saveTokenToConfig(ctx, token) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", origin) + req.Header.Set("Cookie", ctx.Config.SessionCookie) + response, err := ctx.HTTPClient.Do(req) if err != nil { - fmt.Println("Error during token saving") return err } - - fmt.Println("Saved token to config") - + response.Body.Close() + if response.StatusCode != 200 { + return fmt.Errorf("sign out failed (HTTP %d)", response.StatusCode) + } + ctx.Config.SessionCookie = "" + ctx.Config.AccessToken = "" + if err := ctx.saveConfig(); err != nil { + return err + } + fmt.Println("Signed out.") return nil } diff --git a/cli/cmd/config_test.go b/cli/cmd/config_test.go index 60758db..d3ecb15 100644 --- a/cli/cmd/config_test.go +++ b/cli/cmd/config_test.go @@ -1,4 +1,4 @@ -package cmd +package cmd import ( "testing" @@ -6,52 +6,52 @@ import ( // "bytes" ) -// No Flag config -func TestConfigNoFlags(t *testing.T){ +// No Flag config +func TestConfigNoFlags(t *testing.T) { cmd := &ConfigCmd{} - output := CaptureOutput(func(){ - _ = cmd.Run() + output := CaptureOutput(func() { + _ = cmd.Run(&AppContext{}) }) - if want := "No config action specified. Use --help for options."; !contains(output, want){ - t.Errorf("expected output to contain %q, got %q", want, output) + if want := "No config action specified. Use --help for options."; !contains(output, want) { + t.Errorf("expected output to contain %q, got %q", want, output) } } // Set Default Cluster to tt-gpu-cluster-1 -func TestConfigDefaultCluster(t *testing.T){ - cmd := &ConfigCmd{DefaultCluster: "tt-gpu-cluster-1"} // Create config object +func TestConfigDefaultCluster(t *testing.T) { + cmd := &ConfigCmd{DefaultCluster: "tt-gpu-cluster-1"} // Create config object - output := CaptureOutput(func(){ - _ = cmd.Run() + output := CaptureOutput(func() { + _ = cmd.Run(&AppContext{}) }) // fmt.Printf("Captured the output: %s\n", output) if want := "Setting default cluster to: tt-gpu-cluster-1"; !contains(output, want) { - t.Errorf("expected output to contain %q, got %q", want, output) - } + t.Errorf("expected output to contain %q, got %q", want, output) + } } -// Show Config -func TestConfigCmd_Show(t *testing.T){ +// Show Config +func TestConfigCmd_Show(t *testing.T) { cmd := &ConfigCmd{Show: true} - output := CaptureOutput(func(){ - _ = cmd.Run() + output := CaptureOutput(func() { + _ = cmd.Run(&AppContext{}) }) - if want := "Current configuration:"; !contains(output, want){ + if want := "Current configuration:"; !contains(output, want) { t.Errorf("expected output to contain %q, got %q", want, output) } } -// Show Error message if both flags are sent -func TestConfigBothFlagError(t *testing.T){ +// Show Error message if both flags are sent +func TestConfigBothFlagError(t *testing.T) { cmd := &ConfigCmd{DefaultCluster: "tt-gpu-cluster-1", Show: true} - output := CaptureOutput(func(){ - _ = cmd.Run() + output := CaptureOutput(func() { + _ = cmd.Run(&AppContext{}) }) - if want := "Cannot use --show and --default-cluster together"; !contains(output, want){ + if want := "Cannot use --show and --default-cluster together"; !contains(output, want) { t.Errorf("Expected the error message of \"%s\", got %q", want, output) } diff --git a/cli/cmd/job.go b/cli/cmd/job.go index 6fcf5b2..94f487a 100644 --- a/cli/cmd/job.go +++ b/cli/cmd/job.go @@ -6,7 +6,8 @@ type JobCmd struct { // Delete JobDeleteCmd `cmd: "" help: "Delete an existing job"` Status JobStatusCmd `cmd:"" help:"Check the status of a job"` // Cancel CancelCmd `cmd:"" help:"Cancel a running job"` - List ListCmd `cmd:"" help:"List all jobs" default:1` + Logs JobLogsCmd `cmd:"" help:"Read workload logs"` + List ListCmd `cmd:"" help:"List all jobs" default:1` } func (j *JobCmd) Run() error { diff --git a/cli/cmd/job_cancel.go b/cli/cmd/job_cancel.go index 949a885..0cd7072 100644 --- a/cli/cmd/job_cancel.go +++ b/cli/cmd/job_cancel.go @@ -1,72 +1,19 @@ package cmd import ( - "bufio" "fmt" - "os" - "strings" - "time" + "net/url" ) type JobCancelCmd struct { - ID string `arg:"" help:"ID of job you want to cancel"` + ID string `arg:"" help:"Job ID to cancel"` } -func (c *JobCancelCmd) Run(ctx *AppContext) error { - // Same Mock data from job list. - jobs := []Job{ - { - ID: "ID:1", - Name: "docker_container_name_1", - Status: "Running", - GPUType: "AMD", - CreatedAt: time.Now(), - }, - { - ID: "ID:2", - Name: "docker_container_name_2", - Status: "Enqueued", - GPUType: "TT", - CreatedAt: time.Now().Add(-time.Hour * 24), - }, - { - ID: "ID:3", - Name: "docker_container_name_3", - Status: "Running", - GPUType: "TT", - CreatedAt: time.Now().Add(-time.Hour * 24), - }, +func (j *JobCancelCmd) Run(ctx *AppContext) error { + var job Job + if err := ctx.api("POST", "/jobs/"+url.PathEscape(j.ID)+"/cancel", nil, &job); err != nil { + return err } - - // Check if job exists - if !jobExists(jobs, c.ID) { - fmt.Printf("%s does not exist in your jobs.\n", c.ID) - fmt.Printf("Use the command \"job list\" for your list of jobs.") - return nil - } - - fmt.Printf("Are you sure you want to cancel %s? (y/n): \n", c.ID) - - reader := bufio.NewReader(os.Stdin) - input, _ := reader.ReadString('\n') - input = strings.TrimSpace(strings.ToLower(input)) - - if input == "y" || input == "yes" { - fmt.Println("Confirmed, proceeding job cancellation....") - - // Confirmed job cancellation logic - - fmt.Println("Cancelling job with ID:", c.ID) - fmt.Printf("Job cancelled successfully with ID: %s\n", c.ID) - return nil - } else if input == "n" || input == "no" { - fmt.Println("Cancelled.") - return nil - } else { - fmt.Println("Invalid response.") - return nil - } - + fmt.Printf("Job %s: %s\n", job.ID, job.Status) return nil - } diff --git a/cli/cmd/job_cancel_test.go b/cli/cmd/job_cancel_test.go index eff91c6..79da1e2 100644 --- a/cli/cmd/job_cancel_test.go +++ b/cli/cmd/job_cancel_test.go @@ -1,46 +1,25 @@ -package cmd +package cmd import ( + "net/http" + "net/http/httptest" "testing" - "fmt" ) - -// Added job, with no compute type added -func TestJobCancelJobDoesNotExist(t *testing.T){ - // This job should not exist in the dummy - cmd := &JobCancelCmd{ID: "job_12345"} - output := CaptureOutput(func(){ - _ = cmd.Run() - }) - if want := "job_12345 does not exist in your jobs.\nUse the command \"job list\" for your list of jobs."; !contains(output, want){ - t.Errorf("expected output to contain %q, got %q", want, output) - } -} - -// Added job, with compute type -func TestJobCancelValid(t *testing.T){ - // This job should not exist in the dummy - cmd := &JobCancelCmd{ID: "ID:1"} - output := CaptureOutput(func(){ - _ = cmd.Run() +func TestJobCancelCallsAPIWithoutConfirmation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/jobs/cancel-id/cancel" { + t.Errorf("wrong request: %s %s", r.Method, r.URL.Path) + } + _, _ = w.Write([]byte(`{"id":"cancel-id","job_state":"Cancelled"}`)) + })) + defer server.Close() + out := CaptureOutput(func() { + if err := (&JobCancelCmd{ID: "cancel-id"}).Run(&AppContext{APIBaseURL: server.URL}); err != nil { + t.Error(err) + } }) - if want := "Are you sure you want to cancel ID:1? (y/n):"; !contains(output, want){ - t.Errorf("expected output to contain %q, got %q", want, output) + if !contains(out, "Cancelled") || contains(out, "Are you sure") { + t.Fatal(out) } } - - -func TestJobCancelProceed(t *testing.T){ - cmd := &JobCancelCmd{ID: "ID:1"} - // Lowkey, we should refactor this into a - output := CaptureOutput(func(){ - MockInput("y\n", func() { - _ = cmd.Run() - }) - }) - if !contains(output, "Confirmed, proceeding job cancellation...."){ - t.Errorf("expected 'Confirmed, proceeding job cancellation....' but got:\n%s", output) - } - // fmt.Printf("Got the output %s\n", output) -} \ No newline at end of file diff --git a/cli/cmd/job_list.go b/cli/cmd/job_list.go index 9e4787d..0dfec2d 100644 --- a/cli/cmd/job_list.go +++ b/cli/cmd/job_list.go @@ -8,60 +8,42 @@ import ( ) type ListCmd struct { - All bool `help:"List all jobs, including completed and failed ones." short:"a"` + All bool `help:"Include completed, failed, and cancelled jobs" short:"a"` } - type Job struct { - ID string - Name string - Status string - GPUType string - CreatedAt time.Time + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"job_state"` + GPUType string `json:"accelerator"` + CreatedAt time.Time `json:"created"` + Node string `json:"node"` + DeviceCount int `json:"device_count"` + ExitCode *int `json:"exit_code"` + Message string `json:"message"` } -func (l *ListCmd) Run(ctx *AppContext) error { - // Mock data - pull from API in real implementation - jobs := []Job{ - { - ID: "ID:1", - Name: "docker_container_name_1", - Status: "Running", - GPUType: "AMD", - CreatedAt: time.Now(), - }, - { - ID: "ID:2", - Name: "docker_container_name_2", - Status: "Enqueued", - GPUType: "TT", - CreatedAt: time.Now().Add(-time.Hour * 24), - }, - { - ID: "ID:3", - Name: "docker_container_name_3", - Status: "Running", - GPUType: "TT", - CreatedAt: time.Now().Add(-time.Hour * 24), - }, - } +func printJobs(jobs []Job) { w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "Job ID\tName\tStatus\tGPU Type\tCreated At") - fmt.Fprintln(w, "--------------------------------------------------------------") - + fmt.Fprintln(w, "Job ID\tName\tStatus\tCompute\tDevices\tNode\tCreated") for _, job := range jobs { - // Maybe filter based on running? - fmt.Fprintf( - w, - "%s\t%s\t%s\t%s\t%s\n", - job.ID, - job.Name, - job.Status, - job.GPUType, - job.CreatedAt.Format(time.RFC1123), - ) + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\t%s\t%s\n", job.ID, job.Name, job.Status, job.GPUType, job.DeviceCount, job.Node, job.CreatedAt.Format(time.RFC3339)) } + _ = w.Flush() +} - w.Flush() - +func (l *ListCmd) Run(ctx *AppContext) error { + var response struct { + Jobs []Job `json:"jobs"` + } + if err := ctx.api("GET", "/jobs", nil, &response); err != nil { + return err + } + jobs := []Job{} + for _, job := range response.Jobs { + if l.All || job.Status == "Scheduled" || job.Status == "InProgress" { + jobs = append(jobs, job) + } + } + printJobs(jobs) return nil } diff --git a/cli/cmd/job_list_test.go b/cli/cmd/job_list_test.go index ad9b088..070ce98 100644 --- a/cli/cmd/job_list_test.go +++ b/cli/cmd/job_list_test.go @@ -1,18 +1,31 @@ -package cmd +package cmd import ( + "net/http" + "net/http/httptest" "testing" ) -// Will be more specific in the future! -func TestJobList(t *testing.T){ - cmd := &ListCmd{All: true} - output := CaptureOutput(func(){ - _ = cmd.Run() +func TestJobListFiltersCompleted(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"jobs":[{"id":"running-id","name":"work","job_state":"InProgress","accelerator":"nvidia"},{"id":"done-id","name":"old","job_state":"Success","accelerator":"cpu"}]}`)) + })) + defer server.Close() + ctx := &AppContext{APIBaseURL: server.URL} + out := CaptureOutput(func() { + if err := (&ListCmd{}).Run(ctx); err != nil { + t.Error(err) + } }) - // Note the time is dynamic. - want := "Job ID Name Status GPU Type Created At\n--------------------------------------------------------------\nID:1 docker_container_name_1 Running AMD" - if !contains(output, want){ - t.Errorf("expected output to contain %q, got %q", want, output) + if !contains(out, "running-id") || contains(out, "done-id") { + t.Fatal(out) } - } \ No newline at end of file + out = CaptureOutput(func() { + if err := (&ListCmd{All: true}).Run(ctx); err != nil { + t.Error(err) + } + }) + if !contains(out, "done-id") { + t.Fatal(out) + } +} diff --git a/cli/cmd/job_logs.go b/cli/cmd/job_logs.go new file mode 100644 index 0000000..b183510 --- /dev/null +++ b/cli/cmd/job_logs.go @@ -0,0 +1,21 @@ +package cmd + +import ( + "fmt" + "net/url" +) + +type JobLogsCmd struct { + ID string `arg:"" help:"Job ID"` +} + +func (j *JobLogsCmd) Run(ctx *AppContext) error { + var response struct { + Logs string `json:"logs"` + } + if err := ctx.api("GET", "/jobs/"+url.PathEscape(j.ID)+"/logs", nil, &response); err != nil { + return err + } + fmt.Print(response.Logs) + return nil +} diff --git a/cli/cmd/job_status.go b/cli/cmd/job_status.go index 20634c8..9d15a01 100644 --- a/cli/cmd/job_status.go +++ b/cli/cmd/job_status.go @@ -2,46 +2,24 @@ package cmd import ( "fmt" - "os" - "text/tabwriter" - "time" + "net/url" ) type JobStatusCmd struct { - ID string `arg:"" help:"The ID of the job to check the status for"` + ID string `arg:"" help:"Job ID"` } func (j *JobStatusCmd) Run(ctx *AppContext) error { - // Mock data - pull from API in real implementation - jobs := []Job{{ - ID: "ID:1", - Name: "docker_container_name_1", - Status: "Running", - GPUType: "AMD", - CreatedAt: time.Now(), - }} - - job, err := findJobByID(jobs, j.ID) - if err != nil { - fmt.Printf("%s does not exist in your jobs.\n", j.ID) - fmt.Printf("Use the command \"job list\" for your list of jobs.") - return nil + var job Job + if err := ctx.api("GET", "/jobs/"+url.PathEscape(j.ID), nil, &job); err != nil { + return err + } + printJobs([]Job{job}) + if job.ExitCode != nil { + fmt.Println("Exit code:", *job.ExitCode) + } + if job.Message != "" { + fmt.Println(job.Message) } - - println("Checking status for job ID:", j.ID) - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "Job ID\tName\tStatus\tGPU Type\tCreated At") - fmt.Fprintln(w, "--------------------------------------------------------------") - - fmt.Fprintf( - w, - "%s\t%s\t%s\t%s\t%s\n", - job.ID, - job.Name, - job.Status, - job.GPUType, - job.CreatedAt.Format(time.RFC1123), - ) - w.Flush() return nil } diff --git a/cli/cmd/job_status_test.go b/cli/cmd/job_status_test.go index d7d82a8..09e1f1d 100644 --- a/cli/cmd/job_status_test.go +++ b/cli/cmd/job_status_test.go @@ -1,29 +1,32 @@ package cmd import ( + "net/http" + "net/http/httptest" "testing" ) -// Added job, with no compute type added -func TestJobStatusJobDoesNotExist(t *testing.T) { - // This job should not exist in the dummy - cmd := &JobStatusCmd{ID: "job_12345"} - output := CaptureOutput(func() { - _ = cmd.Run() +func TestJobStatusPropagatesFailureAndExitCode(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"id":"failed-id","name":"failed","job_state":"Failure","exit_code":17}`)) + })) + defer server.Close() + out := CaptureOutput(func() { + if err := (&JobStatusCmd{ID: "failed-id"}).Run(&AppContext{APIBaseURL: server.URL}); err != nil { + t.Error(err) + } }) - if want := "job_12345 does not exist in your jobs.\nUse the command \"job list\" for your list of jobs."; !contains(output, want) { - t.Errorf("expected output to contain %q, got %q", want, output) + if !contains(out, "Failure") || !contains(out, "Exit code: 17") { + t.Fatal(out) } } - -// Added job, with compute type -func TestJobStatusValid(t *testing.T) { - // This job should not exist in the dummy - cmd := &JobStatusCmd{ID: "ID:1"} - output := CaptureOutput(func() { - _ = cmd.Run() - }) - if want := "docker_container_name_1"; !contains(output, want) { - t.Errorf("expected output to contain %q, got %q", want, output) +func TestJobStatusDoesNotInventSuccessForMissingJob(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(404) + _, _ = w.Write([]byte(`{"error":"job not found"}`)) + })) + defer server.Close() + if err := (&JobStatusCmd{ID: "missing"}).Run(&AppContext{APIBaseURL: server.URL}); err == nil || !contains(err.Error(), "job not found") { + t.Fatal(err) } } diff --git a/cli/cmd/job_submit.go b/cli/cmd/job_submit.go index 22b793f..a54945a 100644 --- a/cli/cmd/job_submit.go +++ b/cli/cmd/job_submit.go @@ -1,61 +1,63 @@ package cmd import ( - "bufio" "fmt" "os" + "path/filepath" "strings" ) type JobSubmitCmd struct { - Script string `arg:"" help:"Path to the job script file to submit"` - Compute string `help:"Type of compute required for the job: AMD|TT|CPU" default:"AMD"` + Scope string `help:"Save outputs in common or your member folder (default: own folder)"` + TTRuntime string `help:"Tenstorrent runtime: host or container"` + Script string `arg:"" help:"Path to a Python or shell script"` + Compute string `help:"CPU, NVIDIA, or TT" default:"CPU"` + Devices int `help:"NVIDIA GPU count or Tenstorrent board count" default:"1"` + Dataset string `help:"Owned ready dataset ID to mount at /inputs (optional)"` + Image string `help:"Tagged image from an allowed registry (optional; defaults by compute type)"` + CPU string `help:"Requested CPU quantity, e.g. 2 or 500m"` + Memory string `help:"Requested memory, e.g. 4Gi"` + Timeout int64 `help:"Maximum execution time in seconds" default:"600"` + Name string `help:"Display name for the job"` } func (j *JobSubmitCmd) Run(ctx *AppContext) error { - // mist job submit