Compare commits
31
Commits
v1.0.0
...
v1.1.0-alpha.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4d88c7ffc | ||
|
|
44247bd94e | ||
|
|
7c1d0dc568 | ||
|
|
82eaec4c55 | ||
|
|
c301499bef | ||
|
|
8b7094b0cb | ||
|
|
bdbe0e3e39 | ||
|
|
c11756c3d8 | ||
|
|
46645b8730 | ||
|
|
e923627ce0 | ||
|
|
7fb1059401 | ||
|
|
41cae546ff | ||
|
|
f8ff0956b9 | ||
|
|
7da9bf8c3a | ||
|
|
69a2d59b23 | ||
|
|
45d5c2eaad | ||
|
|
10a7e8df1d | ||
|
|
76746187eb | ||
|
|
9b0b9b208a | ||
|
|
a339ac853b | ||
|
|
0494b8bc2a | ||
|
|
0f9cbcec48 | ||
|
|
a4abdb970a | ||
|
|
b4fe7aaf72 | ||
|
|
e308085fe2 | ||
|
|
988101afab | ||
|
|
215325a9e0 | ||
|
|
c06d8673ce | ||
|
|
1473bbe2a8 | ||
|
|
9883def0c2 | ||
|
|
079eca071e |
@@ -58,6 +58,8 @@ jobs:
|
||||
needs: binaries
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
@@ -72,7 +74,12 @@ jobs:
|
||||
|
||||
- uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: artifacts/*
|
||||
files: |
|
||||
artifacts/*
|
||||
install.sh
|
||||
install.ps1
|
||||
# Pre-release tags (e.g. v1.1.0-alpha.1) publish as pre-releases.
|
||||
prerelease: ${{ contains(github.ref_name, '-alpha') }}
|
||||
generate_release_notes: true
|
||||
|
||||
image:
|
||||
|
||||
+16
-15
@@ -1,21 +1,22 @@
|
||||
COMPLETED
|
||||
# Session Goal
|
||||
|
||||
давай теперь почистим проект от линего кода
|
||||
COMPLETED
|
||||
|
||||
выполни полностью намеченный сейчас план. автономно
|
||||
|
||||
## Plan
|
||||
|
||||
1. Аудит: ruff/pyflakes — неиспользуемые импорты по scimesh/ и tests/; grep — неиспользуемые функции/модули (после рефакторингов могли остаться мёртвые экспорты, например в descriptors/search/graph core и sdk/_validation).
|
||||
2. Удаление мёртвого кода: неиспользуемые импорты, функции, дубли (например write_descriptor_shards/concatenate_descriptor_shards, если вытеснены дефолтами batch), устаревшие файлы-обёртки.
|
||||
3. Проверка, что ничего публичного/API не сломано: pyright 0 ошибок, pytest зелёный, go test/vet, mkdocs build.
|
||||
4. Финал: полный прогон, COMPLETED.
|
||||
1. Фаза 1 — SQLite-хранилище: `coordinator/internal/storage/sqlite` (все порты, TxManager, миграции, `SCIMESH_DB=sqlite|postgres`, тесты).
|
||||
2. Фаза 2 — Встроенный userservice: перенос `users/internal/*` в `coordinator/internal/userservice/` (sqlite-хранилище), запуск на 127.0.0.1, BootstrapAdmin.
|
||||
3. Фаза 3 — `coordinator serve` (data-dir, всё-в-одном, --workers N, --open) + subcommand `coordinator agent`.
|
||||
4. Фаза 4 — Управляемый venv + install.sh/install.ps1 + ассеты релиза.
|
||||
5. Документация: mkdocs, README, PLAN.md (CTX-17 done, CTX-18), STATUS.md.
|
||||
6. Проверка: полный E2E без внешних сервисов + все тесты/lint/vet.
|
||||
|
||||
## Progress
|
||||
|
||||
Эта сессия (доп. задача): Go-агент доведён до паритета, Python-демон удалён.
|
||||
- [x] Go-агент: token provider (static + worker-key exchange + 401 refresh на API/download/upload), CLEANUP_AFTER_SECONDS (очистка attempt-директорий), тесты auth (exchange/cache/reject/select/401-retry).
|
||||
- [x] Python: удалены daemon.py, cli.py, config.py, coordinator.py, artifacts.py, auth.py, transport.py; `scimesh/worker/` = только task.py + runners.py (SDK-мост, allowlist из env) + models.py (ClaimedTask/RunResult); консольный скрипт scimesh-worker убран из pyproject.
|
||||
- [x] Тесты: удалены test_worker_daemon.py, test_worker_auth.py; 208 pytest зелёные.
|
||||
- [x] Демо/смок переведены на Go-агент (demo-ui.sh: build_agent + env; two-worker-smoke.sh: AGENT_BIN + TASK_RUNNER_JSON). `make smoke-two-worker` PASS: 4/4 шардов, worker-a=2, worker-b=2.
|
||||
- [x] Документация: README, AGENTS.md, STATUS, handoff, mkdocs worker-integration/cli.
|
||||
- [x] Верификация: ruff clean; 208 pytest; pyright 0 (scimesh+tests); go test 11 пакетов + vet; mkdocs 0 warnings.
|
||||
- ВСЕ ФАЗЫ ВЫПОЛНЕНЫ И ЗАПУШЕНЫ:
|
||||
- 9883def — SQLite-бэкенд (SCIMESH_DB=sqlite|postgres, миграции, тесты).
|
||||
- 1473bbe — встроенный userservice + serve/agent subcommands.
|
||||
- c06d867 — install.sh/install.ps1 + make serve.
|
||||
- 215325a — документация (README, mkdocs, PLAN CTX-17/CTX-18, STATUS).
|
||||
- E2E «чистая машина»: `coordinator serve` → health/login (embedded userservice) → molwt-filter джоб через локального агента → результат byte-точный.
|
||||
- Верификация: 208 pytest, pyright 0, 18 Go-пакетов ok, gofmt чист, vet чист, golangci 0 issues, postgres integration ok, все CI-раны success.
|
||||
|
||||
@@ -1003,11 +1003,12 @@ with as little manual configuration as possible: it provisions its own
|
||||
schema, and a `setup` command walks the operator through the remaining
|
||||
environment (database creation, secrets, admin account).
|
||||
|
||||
**Depends on:** the Go coordinator and the release build pipeline. Step 1
|
||||
(embedded migrations, `AUTO_MIGRATE`) and step 2 (the `coordinator setup`
|
||||
wizard: database reachability and creation, schema migration, `.env` with a
|
||||
generated `JWT_SECRET`, readiness summary) are implemented; step 3 — a fully
|
||||
embedded userservice — is the remaining work.
|
||||
**Depends on:** the Go coordinator and the release build pipeline. All steps
|
||||
are implemented: embedded migrations with `AUTO_MIGRATE`, the `setup` wizard,
|
||||
the embedded SQLite storage backend (`SCIMESH_DB=sqlite`), the embedded
|
||||
userservice, and the `coordinator serve` single-binary mode with local worker
|
||||
agents. The standalone `users/` service and the PostgreSQL engine remain for
|
||||
cluster deployments.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
@@ -1027,6 +1028,79 @@ embedded userservice — is the remaining work.
|
||||
|
||||
---
|
||||
|
||||
### CTX-18 — Single-binary platform (`coordinator serve`)
|
||||
|
||||
**Goal:** A scientist installs one file, runs one command, and gets the whole
|
||||
platform: coordinator, both databases, the userservice, and local workers —
|
||||
no PostgreSQL, no Docker, no Python setup.
|
||||
|
||||
**Depends on:** CTX-17 (embedded migrations, SQLite, embedded userservice).
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- `coordinator serve` provisions `~/.scimesh` (databases, secrets chmod 0600,
|
||||
generated admin password printed once, artifacts dir) and serves the UI on
|
||||
127.0.0.1:8080 by default; `--open` opens the browser;
|
||||
- `--workers N` spawns N `coordinator agent` subprocesses that claim and
|
||||
execute tasks locally; agents are stopped on shutdown;
|
||||
- the embedded userservice listens on the loopback interface and shares the
|
||||
JWT secret with the coordinator, so UI login/registration work unchanged;
|
||||
- a managed scientific runtime venv (`~/.scimesh/venv`) is bootstrapped on
|
||||
first start; `SCIMESH_PIP_PACKAGE` controls what gets installed (the PyPI
|
||||
name is not ours), and `TASK_RUNNER` points at the venv python;
|
||||
- `install.sh` / `install.ps1` detect the platform, download the release
|
||||
binary, and print the start command; both are release assets;
|
||||
- the PostgreSQL engine and the standalone userservice stay fully supported;
|
||||
- `coordinator serve` passes the full local E2E without any external service:
|
||||
health, login, upload, claim, compute, reduction, byte-exact result.
|
||||
|
||||
---
|
||||
|
||||
### CTX-19 — Coordinator Admin UI
|
||||
|
||||
**Goal:** Replace the demo-level operator pages with a real admin console
|
||||
(`/ui/admin`): system overview, job management with filters and pagination,
|
||||
worker management (trust, capabilities), users/roles and worker keys,
|
||||
workload enable/disable, settings (read-only), and storage metrics.
|
||||
|
||||
**Depends on:** CTX-11 (UI patterns, session/roles), CTX-15 (userservice
|
||||
proxy), the sqlite/postgres storage pair.
|
||||
|
||||
**Status: implemented.** Admin console at `/ui/admin`: system/storage/health,
|
||||
jobs (filter + pagination + owner resolution), workers with trust controls,
|
||||
users & worker keys (userservice admin endpoints), workload enable/disable
|
||||
(`workload_settings` migration in both engines, enforced at submit time and
|
||||
hidden from the job form), metrics (7-day buckets, failure rate), and
|
||||
settings with an audited worker-token reveal. All `/ui/admin/*` routes are
|
||||
admin-only and backed by bounded read models; sqlite + postgres parity;
|
||||
unit/permission/integration tests green.
|
||||
|
||||
### CTX-20 — Worker Setup UI
|
||||
|
||||
**Goal:** A local setup wizard embedded in the **worker-agent** binary
|
||||
(`worker-agent setup`, browser on 127.0.0.1) that turns any machine into a
|
||||
worker: coordinator URL + auth (serve token or worker key), work dir,
|
||||
connection check, start/stop, and a live status page. Runs on machines that
|
||||
have only the worker installed — no coordinator needed.
|
||||
|
||||
**Depends on:** the agent daemon; the worker-key exchange (CTX-15).
|
||||
|
||||
**Status: implemented.** `worker-agent setup` serves the local wizard on
|
||||
127.0.0.1 (default port 12700): coordinator URL + token or worker key,
|
||||
work dir and name, preflight check (coordinator/health, python3, scimesh),
|
||||
config saved to `~/.scimesh-worker/config.json` (0600), start/stop of the
|
||||
worker as a background process, and a live status page with the agent log.
|
||||
The daemon also gained `--config <path>` (environment still wins) and
|
||||
`--check`. End-to-end verified: the wizard started a real worker that
|
||||
registered with a coordinator and appeared in the admin console.
|
||||
|
||||
**Acceptance criteria:** full plan in
|
||||
[`docs/ui-admin-worker-plan.md`](docs/ui-admin-worker-plan.md) (section 4);
|
||||
the agent gains `setup`, `--config <path>`, and `--check`; end-to-end: the
|
||||
wizard starts a real worker that registers and completes a job.
|
||||
|
||||
---
|
||||
|
||||
## 10. Suggested assignment bundles
|
||||
|
||||
These bundles minimize overlap. Do not run tasks from the same bundle in
|
||||
@@ -1166,12 +1240,11 @@ Do not start these before CTX-12 is accepted.
|
||||
- Add shard caching and content-addressed input deduplication.
|
||||
- Add job priority and fair scheduling.
|
||||
- Add a CLI for submitting and monitoring remote jobs.
|
||||
- Implement CTX-17 step 3: a fully embedded userservice
|
||||
(`coordinator userservice` subcommand) so one binary can serve the whole
|
||||
platform without containers.
|
||||
- Replace PostgreSQL with an embedded SQLite backend for fully self-contained
|
||||
single-binary deployments (large storage-layer change; postgres row locks,
|
||||
transactions, and integration tests must be re-derived).
|
||||
- Publish the scimesh Python package to PyPI so the managed venv bootstrap
|
||||
(`SCIMESH_PIP_PACKAGE`) works out of the box on a scientist's machine.
|
||||
- Bundle a Python runtime (python-build-standalone) into the release so local
|
||||
workers need no system Python at all.
|
||||
- Native installers (.msi/.dmg/.deb) built by the release workflow.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
# SciMesh
|
||||
|
||||
SciMesh is a scientific-workload framework for molecular datasets. Its public CLI
|
||||
runs exact similarity search and sparse similarity-graph construction locally in
|
||||
one Python process; it creates no dense similarity matrix. The Go/PostgreSQL
|
||||
coordinator and Go worker agents (which execute SDK workloads in a Python
|
||||
subprocess) can run a shard-based `similarity-search`
|
||||
pipeline locally. After every shard succeeds, the coordinator deterministically
|
||||
merges its candidates into one final global top-k CSV. See
|
||||
[`STATUS.md`](STATUS.md).
|
||||
SciMesh is a local-first platform for scientific computation on molecular
|
||||
datasets. It turns a scientific run into independent tasks, dispatches them
|
||||
to worker agents, and deterministically combines the partial results into a
|
||||
checksum-protected final artifact.
|
||||
|
||||
The ChEMBL TSV database is intentionally not included in this repository. Download it separately and pass its path to the commands below. The expected columns are `chembl_id` and `canonical_smiles`.
|
||||
- **The Workload SDK (`scimesh.sdk`)** — a strict Python framework for
|
||||
authoring scientific workloads: `similarity-search` (exact top-k Tanimoto),
|
||||
`similarity-graph` (exact sparse graph), `descriptor-batch`, and
|
||||
`molwt-filter`. Workloads are ordinary user scripts built on the SDK; they
|
||||
run locally, in the conformance harness, and on claimed coordinator tasks
|
||||
without touching any other part of the program.
|
||||
- **The coordinator and worker agents** — a Go/PostgreSQL coordinator with an
|
||||
operator UI and Go worker agents that execute SDK workloads in a Python
|
||||
subprocess. The UI is workload-agnostic: the "New computation" form offers
|
||||
every workload from the embedded SDK library, and each workload declares its
|
||||
own form controls (`UIElement`) through the SDK.
|
||||
|
||||
The ChEMBL TSV database is intentionally not included in this repository.
|
||||
Download it separately and pass its path to the commands below. The expected
|
||||
columns are `chembl_id` and `canonical_smiles`. See
|
||||
[`STATUS.md`](STATUS.md) and [`PLAN.md`](PLAN.md).
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -28,6 +39,65 @@ conda install -c conda-forge rdkit
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
## Releases
|
||||
|
||||
Every `v*` tag pushes a GitHub Release with static binaries for `coordinator`
|
||||
and `worker-agent` on linux/darwin/windows × amd64/arm64 (plus SHA-256
|
||||
checksums), the installer scripts, and the `coordinator` image on GHCR:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/emil28092005/SciMesh/coordinator:latest
|
||||
```
|
||||
|
||||
For scientists: one command downloads the right binary, starts it, and opens
|
||||
the UI in the browser:
|
||||
|
||||
```bash
|
||||
# Linux / macOS — installs, starts and opens the admin console automatically
|
||||
curl -fsSL https://raw.githubusercontent.com/emil28092005/SciMesh/main/install.sh | bash
|
||||
|
||||
# Windows (PowerShell)
|
||||
powershell -ExecutionPolicy Bypass -c "irm https://raw.githubusercontent.com/emil28092005/SciMesh/main/install.ps1 | iex"
|
||||
```
|
||||
|
||||
Set `SCIMESH_AUTO_START=0` to install without starting anything. A standalone
|
||||
worker is installed the same way (`bash -s worker`, or
|
||||
`SCIMESH_COMPONENT=worker` on Windows); its installer opens the local setup
|
||||
wizard (`worker-agent setup`) in the browser automatically.
|
||||
|
||||
`coordinator serve` is the single-binary mode: it embeds SQLite (coordinator +
|
||||
userservice databases), the userservice itself, and local worker agents
|
||||
(`--workers N`, default 1). On first start it generates secrets and the admin
|
||||
password under `~/.scimesh`, prints the login, and opens the UI. No
|
||||
PostgreSQL, no Docker, no environment variables. The scientific runtime is a
|
||||
managed venv (`~/.scimesh/venv`); point `SCIMESH_PIP_PACKAGE` at your scimesh
|
||||
wheel to install it automatically.
|
||||
|
||||
The coordinator serves two operator surfaces: the **control room** (jobs,
|
||||
workloads, docs) and the **admin console** at `/ui/admin` — cluster health
|
||||
and storage, paginated job table, worker fleet with trust controls, users and
|
||||
worker keys, workload enable/disable, metrics and the worker token
|
||||
(`serve --open` lands on the admin console; login returns you to the page you
|
||||
asked for). The **worker** binary (`worker-agent`) carries its own local setup
|
||||
wizard for machines that run only a worker: `worker-agent setup` opens a
|
||||
browser wizard at `127.0.0.1` that collects the coordinator URL and
|
||||
credential, runs a preflight check, saves `~/.scimesh-worker/config.json` and
|
||||
starts/stops the worker with a live log (see the
|
||||
[standalone docs](mkdocs/standalone.md)).
|
||||
|
||||
Manual download and run of a release binary:
|
||||
|
||||
```bash
|
||||
curl -L -o coordinator https://github.com/emil28092005/SciMesh/releases/latest/download/coordinator-linux-amd64
|
||||
chmod +x coordinator
|
||||
./coordinator --version
|
||||
```
|
||||
|
||||
Cluster deployments keep the PostgreSQL engine (`SCIMESH_DB=postgres` with
|
||||
`DATABASE_URL`, or `coordinator setup` to provision it) and the standalone
|
||||
userservice (`users/`). `coordinator agent` runs a worker agent from the same
|
||||
binary.
|
||||
|
||||
## Quick start
|
||||
|
||||
Run the built-in help command for copy-paste examples of both workloads:
|
||||
@@ -47,9 +117,9 @@ scimesh similarity-graph --help
|
||||
|
||||
## Manual pipeline demo
|
||||
|
||||
To inspect the coordinator, Web UI, and distributed `similarity-search`
|
||||
pipeline by hand, install development dependencies once and start the isolated
|
||||
demo from the repository root:
|
||||
To inspect the coordinator, Web UI, and distributed pipeline by hand, install
|
||||
development dependencies once and start the isolated demo from the repository
|
||||
root:
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
@@ -64,17 +134,19 @@ covers the complete Workload SDK: guides (`mkdocs/sdk/`), the full
|
||||
auto-generated API reference for `scimesh.sdk` (`mkdocs/api/`), and the
|
||||
documentation rules the site is written by (`mkdocs/approach.md`).
|
||||
|
||||
Open `http://localhost:18080/ui` and sign in with username `operator` and
|
||||
password `demo-ui-secret`. The command starts PostgreSQL, the coordinator, and
|
||||
two Go worker agents (built by `make agent`; each executes the SDK workload
|
||||
in a Python subprocess). Upload a small ChEMBL TSV, then use the job page
|
||||
to follow shard progress, inspect bounded **Preview CSV** results, and see a
|
||||
live processing-speed chart in shards per minute. The **Workloads** page shows
|
||||
the installed SDK workload library (descriptions, parameters, and artifact
|
||||
schemas) from the embedded catalog; regenerate it with
|
||||
`make workloads-export` (or `scimesh workload export`) whenever workloads
|
||||
change. To change the worker count, run `make demo-ui WORKERS=3`; stop
|
||||
everything with `make demo-down`.
|
||||
Open `http://localhost:18080/ui` and sign in with username
|
||||
`root@scimesh.local` and password `rootpassword`. The command starts
|
||||
PostgreSQL, the coordinator, and two Go worker agents (built by `make agent`;
|
||||
each executes the SDK workload in a Python subprocess). The **New computation**
|
||||
form offers every upload-ready workload from the installed library — the
|
||||
controls come from each workload's own SDK declarations. Upload a small ChEMBL
|
||||
TSV, then use the job page to follow shard progress, inspect bounded
|
||||
**Preview CSV** results, and see a live processing-speed chart in shards per
|
||||
minute. The **Workloads** page shows the installed SDK workload library
|
||||
(descriptions, parameters, and artifact schemas) from the embedded catalog;
|
||||
regenerate it with `make workloads-export` (or `scimesh workload export`)
|
||||
whenever workloads change. To change the worker count, run
|
||||
`make demo-ui WORKERS=3`; stop everything with `make demo-down`.
|
||||
|
||||
Run `make help` to display these commands in the terminal.
|
||||
|
||||
@@ -146,6 +218,15 @@ pytest
|
||||
|
||||
The package separates common dataset parsing and fingerprints from independent workloads. Add future workloads through the workload registry without changing the main CLI.
|
||||
|
||||
The coordinator and worker agent are Go modules under `coordinator/` and `users/`:
|
||||
|
||||
```bash
|
||||
cd coordinator && make coordinator agent && go test ./...
|
||||
```
|
||||
|
||||
`make check` runs the full gate: vet, lint, race tests, the PostgreSQL
|
||||
integration suite, and the two-worker end-to-end smoke script.
|
||||
|
||||
## Workload SDK
|
||||
|
||||
`scimesh.sdk` is the framework only: strict and immutable workload manifests,
|
||||
@@ -153,14 +234,25 @@ typed artifact ports, static map/reduce plans, resource eligibility and local
|
||||
reservations, exact/canonical/numeric verifier primitives, installed-package
|
||||
allowlisting, and a local conformance executor. It contains no scientific
|
||||
workload code. Workloads are user scripts built on the SDK: the built-in
|
||||
`similarity-search`, `similarity-graph`, and `descriptor-batch` live in
|
||||
`scimesh/workloads/` (each a small package with `core.py` + `definition.py`),
|
||||
composed by `scimesh/workloads/library.py` and registered through
|
||||
`scimesh.workloads` entry points. The Worker Agent executes those SDK-built
|
||||
workloads directly (see `scimesh/worker/runners.py`), so the same scientific
|
||||
handlers run locally, in conformance, and on claimed coordinator tasks.
|
||||
`scimesh workload list` and `scimesh workload run` run any SDK workload from
|
||||
the command line. See the
|
||||
`similarity-search`, `similarity-graph`, `descriptor-batch`, and
|
||||
`molwt-filter` live in `scimesh/workloads/` (each a small package with
|
||||
`core.py` + `definition.py`), composed by `scimesh/workloads/library.py` and
|
||||
registered through `scimesh.workloads` entry points. The Worker Agent executes
|
||||
those SDK-built workloads directly (see `scimesh/worker/runners.py`), so the
|
||||
same scientific handlers run locally, in conformance, and on claimed
|
||||
coordinator tasks. `scimesh workload list` and `scimesh workload run` run any
|
||||
SDK workload from the command line; `scimesh workload export` writes the
|
||||
coordinator's embedded workload catalog, and `scimesh workload allowlist`
|
||||
prints the JSON for `SCIMESH_WORKLOAD_ALLOWLIST`.
|
||||
|
||||
Workloads can also declare how they should appear in the coordinator UI:
|
||||
a tuple of `UIElement`s (`scimesh.sdk.UIElement`) shapes the "New computation"
|
||||
form — widget, label, help, defaults, and ordering — plus the coordinator-side
|
||||
reduction mode (`reduction`: `top-k` or `ordered-concat`) and whether a single
|
||||
uploaded dataset can drive the workload (`upload_ready`). The strict parameter
|
||||
schema stays the authoritative validation contract.
|
||||
|
||||
See the
|
||||
[SDK author guide](docs/workload-sdk.md), [contract](docs/scimesh-sdk-contract.md),
|
||||
and [delivery roadmap](docs/scimesh-sdk-roadmap.md).
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# SciMesh Status
|
||||
|
||||
**Updated:** 2026-08-01
|
||||
**Branch baseline:** `main`; this revision adds the Workload SDK foundation.
|
||||
**Updated:** 2026-08-02
|
||||
**Branch baseline:** `main`; this revision adds the single-binary platform.
|
||||
|
||||
## Current state
|
||||
|
||||
@@ -17,11 +17,22 @@ the reference behaviour for future distributed execution:
|
||||
|
||||
The Go coordinator and its PostgreSQL-backed task lifecycle are implemented:
|
||||
registration, atomic claiming, lease renewal, artifact storage, dataset
|
||||
chunking, result/failure reporting, and job progress. The Python worker now
|
||||
uses the live coordinator contract. Completed similarity-search shard results
|
||||
are reduced once into a checksum-protected final CSV, which is downloadable
|
||||
through the coordinator. The full Go checks (including a fresh migration and
|
||||
real PostgreSQL smoke test) passed on 2026-07-24.
|
||||
chunking, result/failure reporting, and job progress. The Go worker agent now
|
||||
uses the live coordinator contract. Completed shard results are reduced once
|
||||
into a checksum-protected final CSV, downloadable through the coordinator.
|
||||
|
||||
**Single-binary platform (`coordinator serve`)**: the coordinator now ships
|
||||
an embedded SQLite storage backend (`SCIMESH_DB=sqlite`), an embedded
|
||||
userservice, and `serve`/`agent` subcommands, so one downloaded binary runs
|
||||
the whole platform — coordinator, both databases, UI logins, and local
|
||||
workers — with no PostgreSQL, no Docker, and no environment variables. The
|
||||
first start provisions `~/.scimesh` (secrets, admin password printed once,
|
||||
managed scientific-runtime venv) and opens the UI. `install.sh` / `install.ps1`
|
||||
download the right release binary in one command and are release assets. The
|
||||
PostgreSQL engine, the `setup` wizard, and the standalone `users/` service
|
||||
remain fully supported for cluster deployments. The full E2E passes with zero
|
||||
external services: health, UI login, job upload, local agent compute,
|
||||
reduction, and a byte-exact final CSV.
|
||||
|
||||
The User Service is merged into `main`. It owns user accounts, authentication,
|
||||
roles, and verified-contributor status; the coordinator scopes user jobs and
|
||||
@@ -37,24 +48,25 @@ the complete result-artifact SHA-256 before a task is accepted.
|
||||
| --- | --- | --- |
|
||||
| CTX-00 API and error contract | Implemented | Contract, OpenAPI, and request examples are in `docs/`. |
|
||||
| CTX-01 Go coordinator bootstrap | Implemented | Go service and Docker runtime in `coordinator/`. |
|
||||
| CTX-02 PostgreSQL migrations | Implemented | Applied by the Compose migration service. |
|
||||
| CTX-03 Transactional queue | Implemented | Real-PostgreSQL integration tests cover atomic claims and concurrency. |
|
||||
| CTX-02 PostgreSQL migrations | Implemented | Embedded into the binary (`AUTO_MIGRATE`); the CLI path is still available for managed databases. |
|
||||
| CTX-03 Transactional queue | Implemented | Real-PostgreSQL integration tests cover atomic claims and concurrency; the SQLite backend mirrors the semantics. |
|
||||
| CTX-04 Worker registry and HTTP API | Implemented | Registration, claim, heartbeat, result, failure, and status endpoints. |
|
||||
| CTX-05 Artifact storage | Implemented | Coordinator-owned inputs/results, checksum verification, and upload flow. |
|
||||
| CTX-06 Python Worker live-contract alignment | Superseded | The Python worker daemon was removed; the Go worker agent (`coordinator/internal/agent/` + `cmd/worker-agent`) now implements the lifecycle (register/claim/heartbeat/download/upload/submit/fail, token refresh, cleanup) and executes SDK workloads via the Python task entry `scimesh/worker/task.py`. E2E: `make smoke-two-worker` passes 4/4 shards with two agents. |
|
||||
| CTX-07 Distributed workload protocol | Implemented | Versioned Python contract models, registry, strict plan validation, and deterministic reduction ordering are in `scimesh/distributed/`. |
|
||||
| CTX-08 Distributed similarity-search | Implemented | Python planner resolves `query_id` once, creates deterministic shard plans, worker adapter emits exact partial top-k CSVs/metrics, and reducer matches the local reference. |
|
||||
| CTX-09 Reducer and final-result API | Implemented | Atomic `reducing` claim, deterministic coordinator-side top-k reducer, sanitized reducer failure, final artifact persistence, `result_uri`, and final CSV download. |
|
||||
| CTX-10 Distributed similarity-graph | Not started | Local reference exists; the SDK-built local graph workload already enforces the pair-coverage invariant. |
|
||||
| CTX-11 Dashboard/operator view | Implemented | Protected live control room: MkDocs documentation served at `/ui/docs/` (SCIMESH_DOCS_DIR; the demo mounts `site/` automatically), recent-run/worker overview, real pipeline-stage visualization, shard attempts and safe failures, validated similarity-search upload, coordinator artifacts, final-result download, bounded polling, a Workload library page rendering the embedded catalog from `scimesh workload export` (`/ui/workloads`, regenerated via `make workloads-export`). |
|
||||
| CTX-12 Reliability, security, CI | In progress | Unit, race, PostgreSQL integration, and smoke checks exist; CI hardening remains. |
|
||||
| CTX-15 User Service and access control | Implemented | User/owner scoping, verified contributors, worker keys, self-service enrollment, and quorum-backed untrusted workers are merged; local Go/Python and Docker/PostgreSQL checks passed. |
|
||||
| MkDocs documentation site | Implemented | A standalone documentation site (`mkdocs/`, `docs_dir: mkdocs`) covering the complete Workload SDK: guides (overview, authoring workloads, CLI, worker integration), the full auto-generated API reference for all 15 `scimesh.sdk` modules (mkdocstrings), and the writing rules (`mkdocs/approach.md`). Built with `make docs`, served inside the UI at `/ui/docs/`; the project's internal `docs/` directory is not part of the site. |
|
||||
| CTX-16 Workload SDK foundation | Implemented | `scimesh.sdk` provides strict immutable manifests/plans/artifacts, digest/trust-pinned tasks, typed DAGs, compatibility negotiation, verifier primitives with owner/binding-safe quorum inputs, resource eligibility/local allocation, measured package discovery, a trusted local core-batch conformance harness, and strict package discovery. Enforcing coordinator/Worker profiles remain fail-closed. |
|
||||
| SDK roadmap step 3: `descriptor-batch` | Implemented | The first SDK-built reference workload (`scimesh/workloads/descriptors/`): pinned 81-name RDKit 2D descriptor set, canonical one-row-per-input CSV, deterministic row-bounded shards, shard-index concatenation with one header, byte-identical local/distributed output, and a two-worker `untrusted_quorum` verifier test. |
|
||||
| SDK-built `similarity-search` and `similarity-graph` | Implemented | Both workloads are SDK-built packages (`scimesh/workloads/search/`, `scimesh/workloads/graph/`) built on the `MapReduceWorkload` authoring scaffold (`scimesh/sdk/batch.py`); they reuse the local scientific cores and are byte-identical to the single-process references (search; graph for both threshold directions and any block size). The graph reducer enforces the CTX-10 pair-coverage invariant. `scimesh/workloads/library.py` composes the built-in registry/runtime. |
|
||||
| SDK-built `molwt-filter` | Implemented | The minimal authoring example (`scimesh/workloads/molwt_filter/`): filters molecules by exact RDKit molecular weight with only one scientific hook, using the scaffold's new default sharding and concatenation hooks. Registered in the built-in library and as a `scimesh.workloads` entry point. |
|
||||
| SDK authoring scaffold | Implemented | `MapReduceWorkload` (exported from `scimesh.sdk`) assembles manifest, map/reduce stages, workflow, and digest-pinned handlers from three scientific hooks (partition/compute/merge), with overridable hooks for domain validation, plan-time resolution, custom task planning, and partial-key policy. The generic `scimesh workload list|run` CLI and the worker's allowlist-driven loading (`SCIMESH_WORKLOAD_ALLOWLIST`, `SCIMESH_CAPABILITIES`) let new workloads run without touching other code. |
|
||||
| CTX-06 Python Worker live-contract alignment | Superseded | The Python worker daemon was removed; the Go worker agent (`coordinator/internal/agent/` + `cmd/worker-agent`, or `coordinator agent`) implements the lifecycle and executes SDK workloads via `scimesh/worker/task.py`. |
|
||||
| CTX-07 Distributed workload protocol | Implemented | Versioned Python contract models, registry, strict plan validation, and deterministic reduction ordering. |
|
||||
| CTX-08 Distributed similarity-search | Implemented | Planner/worker/reducer match the local reference byte-exactly. |
|
||||
| CTX-09 Reducer and final-result API | Implemented | Atomic `reducing` claim, deterministic coordinator-side reducers (`top-k` and `ordered-concat`), sanitized failure, final artifact, `result_uri`. |
|
||||
| CTX-10 Distributed similarity-graph | Not started | Local reference exists; the SDK-built local graph workload enforces the pair-coverage invariant. |
|
||||
| CTX-11 Dashboard/operator view | Implemented | Protected live control room, workload library, workload-agnostic "New computation" form (SDK-declared `UIElement`s), MkDocs at `/ui/docs/`, final-result download. |
|
||||
| CTX-12 Reliability, security, CI | In progress | vet, gofmt, race tests, golangci-lint (0 issues), PostgreSQL integration, and smoke checks exist. |
|
||||
| CTX-15 User Service and access control | Implemented | User/owner scoping, verified contributors, worker keys, quorum; also embeddable (`coordinator serve`). |
|
||||
| CTX-16 Workload SDK foundation | Implemented | Strict immutable manifests/plans/artifacts, digest/trust-pinned tasks, negotiation, verifier primitives, conformance harness. |
|
||||
| CTX-17 Self-provisioning + setup wizard | Implemented | Embedded migrations, `coordinator setup`, SQLite backend, embedded userservice, `serve` mode. |
|
||||
| CTX-18 Single-binary platform | Implemented | `coordinator serve` (data dir, secrets, admin bootstrap, local agents, managed venv) + `install.sh`/`install.ps1`; full no-external-service E2E green. |
|
||||
| SDK roadmap step 3: `descriptor-batch` | Implemented | Byte-identical local/distributed output, quorum verifier test. |
|
||||
| SDK-built `similarity-search` and `similarity-graph` | Implemented | SDK-built packages, byte-identical to single-process references. |
|
||||
| SDK-built `molwt-filter` | Implemented | Minimal authoring example; also the single-binary E2E workload. |
|
||||
| SDK authoring scaffold | Implemented | `MapReduceWorkload` with `UIElement` declarations, `reduction`, `upload_ready`; generic `scimesh workload list|run|export|allowlist` CLI. |
|
||||
|
||||
## Next recommended assignment
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
.PHONY: help build run test test-integration vet lint tidy check migrate-up migrate-down up down down-clean logs ps rebuild psql smoke agent coordinator setup workloads-export demo-ui demo-down demo-reset demo-logs
|
||||
.PHONY: help build run test test-integration vet lint tidy check migrate-up migrate-down up down down-clean logs ps rebuild psql smoke agent coordinator setup serve workloads-export demo-ui demo-down demo-reset demo-logs
|
||||
|
||||
# `check` deliberately uses its own Compose project and host ports. This keeps
|
||||
# it from connecting to or replacing a developer's local PostgreSQL instance.
|
||||
@@ -57,6 +57,11 @@ coordinator:
|
||||
setup: coordinator
|
||||
./bin/coordinator setup $(SETUP_ARGS)
|
||||
|
||||
# The single-binary mode: everything embedded (sqlite + userservice + local
|
||||
# workers), no PostgreSQL or Docker. SETUP_ARGS=--workers 2 --open.
|
||||
serve: coordinator
|
||||
./bin/coordinator serve $(SETUP_ARGS)
|
||||
|
||||
workloads-export:
|
||||
cd .. && .venv/bin/scimesh workload export -o coordinator/$(WORKLOADS_JSON)
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/agent"
|
||||
)
|
||||
|
||||
// runAgent implements `coordinator agent`: the worker agent as a subcommand of
|
||||
// the same binary, so one file can serve the whole platform. `serve` spawns
|
||||
// these for its local workers.
|
||||
func runAgent(args []string) error {
|
||||
flags := flag.NewFlagSet("agent", flag.ContinueOnError)
|
||||
flags.Usage = func() {
|
||||
_, _ = fmt.Fprintf(flags.Output(), "usage: coordinator agent [options]\n")
|
||||
_, _ = fmt.Fprintf(flags.Output(), "Runs as a worker agent: claims tasks, executes SDK workloads in a\n")
|
||||
_, _ = fmt.Fprintf(flags.Output(), "Python subprocess, uploads results.\n\n")
|
||||
flags.PrintDefaults()
|
||||
}
|
||||
var (
|
||||
coordinatorURL = flags.String("coordinator-url", os.Getenv("COORDINATOR_URL"), "coordinator base URL")
|
||||
token = flags.String("token", os.Getenv("WORKER_AUTH_TOKEN"), "worker bearer token")
|
||||
workDir = flags.String("work-dir", os.Getenv("WORK_DIR"), "worker work directory")
|
||||
name = flags.String("name", os.Getenv("WORKER_NAME"), "worker name (default: hostname)")
|
||||
workerID = flags.String("worker-id", os.Getenv("WORKER_ID"), "persistent worker id (optional)")
|
||||
cpuCount = flags.Int("cpu", envInt("CPU_COUNT", 1), "advertised CPU cores")
|
||||
memoryMB = flags.Int("memory-mb", envInt("MEMORY_MB", 1024), "advertised memory in MiB")
|
||||
poll = flags.Duration("poll-interval", 2*time.Second, "claim poll interval")
|
||||
taskRunner = flags.String("task-runner", os.Getenv("TASK_RUNNER"), "python command + args that run scimesh.worker.task")
|
||||
maxTasks = flags.Int("max-tasks", envInt("MAX_TASKS", 0), "stop after N completed tasks (0 = unlimited)")
|
||||
exitWhenIdle = flags.Bool("exit-when-idle", false, "exit when the queue is empty")
|
||||
)
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.NArg() > 0 {
|
||||
return fmt.Errorf("agent takes no positional arguments")
|
||||
}
|
||||
if *coordinatorURL == "" || *token == "" || *workDir == "" {
|
||||
return fmt.Errorf("--coordinator-url, --token, and --work-dir are required")
|
||||
}
|
||||
if *taskRunner == "" {
|
||||
*taskRunner = "python -m scimesh.worker.task"
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||
config := agent.Config{
|
||||
CoordinatorURL: strings.TrimRight(*coordinatorURL, "/"),
|
||||
Capabilities: agent.DefaultCapabilities(),
|
||||
Token: *token,
|
||||
WorkerName: *name,
|
||||
WorkerID: *workerID,
|
||||
WorkDir: *workDir,
|
||||
CPUCount: *cpuCount,
|
||||
MemoryMB: *memoryMB,
|
||||
PollInterval: *poll,
|
||||
RequestTimeout: 30 * time.Second,
|
||||
Heartbeat: 15 * time.Second,
|
||||
TaskRunner: strings.Fields(*taskRunner),
|
||||
MaxTasks: *maxTasks,
|
||||
ExitWhenIdle: *exitWhenIdle,
|
||||
}
|
||||
tokens := agent.NewTokenProvider("", "", config.Token, config.RequestTimeout)
|
||||
client := agent.NewClient(config.CoordinatorURL, tokens, config.RequestTimeout)
|
||||
runner := agent.NewTaskRunner(config.TaskRunner)
|
||||
daemon := agent.NewDaemon(&config, client, runner, logger)
|
||||
return daemon.RunForever()
|
||||
}
|
||||
|
||||
func envInt(name string, fallback int) int {
|
||||
raw := os.Getenv(name)
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(raw, "%d", &n); err != nil {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/metrics"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/storage/blob"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/storage/postgres"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/storage/sqlite"
|
||||
httptransport "github.com/emil28092005/SciMesh/coordinator/internal/transport/http"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/workloads"
|
||||
@@ -25,11 +26,33 @@ var version = "dev"
|
||||
|
||||
func main() {
|
||||
args := os.Args[1:]
|
||||
if len(args) > 0 && args[0] == "setup" {
|
||||
if err := runSetup(args[1:]); err != nil {
|
||||
os.Exit(1)
|
||||
if len(args) > 0 {
|
||||
switch args[0] {
|
||||
case "setup":
|
||||
if err := runSetup(args[1:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "setup:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
case "serve":
|
||||
if err := runServe(args[1:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "serve:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
case "agent":
|
||||
if err := runAgent(args[1:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
case "token":
|
||||
if err := runToken(args[1:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "token:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
showVersion := flag.Bool("version", false, "print the build version and exit")
|
||||
flag.Parse()
|
||||
@@ -44,9 +67,27 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// storageDeps carries the engine-specific database handles and the repository
|
||||
// implementations. The usecases below only ever see the ports.
|
||||
type storageDeps struct {
|
||||
tx usecase.TxManager
|
||||
taskRepo usecase.TaskRepository
|
||||
jobRepo usecase.JobRepository
|
||||
workerRepo usecase.WorkerRepository
|
||||
artifactRepo usecase.ArtifactRepository
|
||||
uiReadRepo usecase.UIReadRepository
|
||||
adminReadRepo usecase.AdminReadRepository
|
||||
settingsRepo usecase.WorkloadSettingsRepository
|
||||
taskResultRepo usecase.TaskResultRepository
|
||||
statsRepo interface {
|
||||
Counts(ctx context.Context) (tasks, jobs, workers map[string]int, err error)
|
||||
}
|
||||
ready func(ctx context.Context) error
|
||||
migrate func(ctx context.Context, log *slog.Logger) error
|
||||
close func()
|
||||
}
|
||||
|
||||
func run() error {
|
||||
// Bootstrap logger, used only until config says where logs should go. It
|
||||
// writes to stderr so it never contaminates the configured stdout stream.
|
||||
boot := slog.New(slog.NewJSONHandler(os.Stderr, nil))
|
||||
|
||||
cfg, err := infra.LoadConfig()
|
||||
@@ -54,6 +95,16 @@ func run() error {
|
||||
boot.Error("load config", "err", err)
|
||||
return err
|
||||
}
|
||||
return runWithConfig(cfg)
|
||||
}
|
||||
|
||||
// runWithConfig boots the coordinator server with an explicit config. The
|
||||
// `serve` subcommand builds such a config for the single-binary mode; the
|
||||
// plain `coordinator` binary loads it from the environment.
|
||||
func runWithConfig(cfg infra.Config) error {
|
||||
// Bootstrap logger, used only until config says where logs should go. It
|
||||
// writes to stderr so it never contaminates the configured stdout stream.
|
||||
boot := slog.New(slog.NewJSONHandler(os.Stderr, nil))
|
||||
|
||||
// The real logger: stdout plus an optional rotated file (LOG_FILE).
|
||||
log, logCloser, err := infra.NewLogger(cfg)
|
||||
@@ -66,17 +117,25 @@ func run() error {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
pool, err := infra.NewPool(ctx, cfg, log)
|
||||
var deps *storageDeps
|
||||
switch cfg.DatabaseEngine {
|
||||
case "sqlite":
|
||||
deps, err = openSQLite(ctx, cfg, log)
|
||||
case "postgres":
|
||||
deps, err = openPostgres(ctx, cfg, log)
|
||||
default:
|
||||
err = fmt.Errorf("SCIMESH_DB must be sqlite or postgres")
|
||||
}
|
||||
if err != nil {
|
||||
log.Error("connect database", "err", err)
|
||||
log.Error("init storage", "err", err)
|
||||
return err
|
||||
}
|
||||
defer pool.Close()
|
||||
defer deps.close()
|
||||
|
||||
// A downloaded binary provisions its own schema; AUTO_MIGRATE=false keeps
|
||||
// out-of-band migration workflows (the migrate CLI, CI, managed databases).
|
||||
if cfg.AutoMigrate {
|
||||
if err := postgres.Migrate(ctx, cfg.DatabaseURL, log); err != nil {
|
||||
if err := deps.migrate(ctx, log); err != nil {
|
||||
log.Error("apply migrations", "err", err)
|
||||
return err
|
||||
}
|
||||
@@ -88,16 +147,9 @@ func run() error {
|
||||
return err
|
||||
}
|
||||
|
||||
var (
|
||||
clk = infra.NewClock()
|
||||
tx = postgres.NewTxManager(pool)
|
||||
taskRepo = postgres.NewTaskRepo(pool)
|
||||
jobRepo = postgres.NewJobRepo(pool)
|
||||
workerRepo = postgres.NewWorkerRepo(pool)
|
||||
artifactRepo = postgres.NewArtifactRepo(pool)
|
||||
uiReadRepo = postgres.NewUIReadRepo(pool)
|
||||
taskResultRepo = postgres.NewTaskResultRepo(pool)
|
||||
)
|
||||
clk := infra.NewClock()
|
||||
tx, taskRepo, jobRepo, workerRepo, artifactRepo, uiReadRepo, taskResultRepo :=
|
||||
deps.tx, deps.taskRepo, deps.jobRepo, deps.workerRepo, deps.artifactRepo, deps.uiReadRepo, deps.taskResultRepo
|
||||
|
||||
catalog, err := workloads.Load()
|
||||
if err != nil {
|
||||
@@ -108,7 +160,7 @@ func run() error {
|
||||
useCases := httptransport.UseCases{
|
||||
RegisterWorker: usecase.NewRegisterWorker(workerRepo, clk),
|
||||
CreateJob: usecase.NewCreateJob(jobRepo, taskRepo, tx, clk),
|
||||
SubmitDataset: usecase.NewSubmitDataset(blobStore, artifactRepo, jobRepo, taskRepo, tx, clk, cfg.DefaultMaxAttempts, catalog),
|
||||
SubmitDataset: usecase.NewSubmitDataset(blobStore, artifactRepo, jobRepo, taskRepo, tx, clk, cfg.DefaultMaxAttempts, catalog, deps.settingsRepo),
|
||||
ClaimTask: usecase.NewClaimTask(taskRepo, jobRepo, workerRepo, tx, clk, cfg.LeaseDuration, catalog),
|
||||
RenewLease: usecase.NewRenewLease(taskRepo, workerRepo, tx, clk, cfg.LeaseDuration),
|
||||
CompleteTask: usecase.NewCompleteTask(taskRepo, jobRepo, artifactRepo, workerRepo, taskResultRepo, tx, clk, cfg.QuorumSize, catalog),
|
||||
@@ -122,10 +174,25 @@ func run() error {
|
||||
GetTaskInput: usecase.NewGetTaskInput(taskRepo, artifactRepo, blobStore),
|
||||
Dashboard: usecase.NewDashboard(uiReadRepo, catalog),
|
||||
PreviewArtifact: usecase.NewPreviewArtifact(uiReadRepo, blobStore),
|
||||
Admin: usecase.NewAdmin(deps.adminReadRepo, uiReadRepo, workerRepo, deps.settingsRepo, catalog,
|
||||
usecase.AdminNodeInfo{
|
||||
Version: version,
|
||||
StartedAt: clk.Now(),
|
||||
Binary: executablePath(),
|
||||
Addr: cfg.Addr,
|
||||
DataDir: cfg.StorageDir,
|
||||
DBEngine: cfg.DatabaseEngine,
|
||||
PublicURL: cfg.PublicCoordinatorURL,
|
||||
Userservice: cfg.UserserviceURL,
|
||||
WorkerToken: func() string { return cfg.Token },
|
||||
}, deps.ready, clk.Now).
|
||||
WithAuditLog(log, func(ctx context.Context, action, detail string) {
|
||||
log.Info("admin audit", "action", action, "detail", detail)
|
||||
}),
|
||||
}
|
||||
|
||||
// Background reapers are tracked so shutdown can wait for them. Without this
|
||||
// the process would exit mid-UPDATE, and the deferred pool.Close() would pull
|
||||
// the process would exit mid-UPDATE, and the deferred close() would pull
|
||||
// connections out from under them.
|
||||
expireLeases := usecase.NewExpireLeases(taskRepo, jobRepo, tx, clk, catalog)
|
||||
markOffline := usecase.NewMarkWorkersOffline(workerRepo, clk, cfg.WorkerOfflineAfter)
|
||||
@@ -147,16 +214,15 @@ func run() error {
|
||||
|
||||
// Business metrics: gauges of tasks/jobs/workers by status, sampled from the
|
||||
// database on every Prometheus scrape.
|
||||
statsRepo := postgres.NewStatsRepo(pool)
|
||||
m := metrics.New()
|
||||
m.RegisterBusiness(func(ctx context.Context) (metrics.Stats, error) {
|
||||
tasks, jobs, workers, err := statsRepo.Counts(ctx)
|
||||
tasks, jobs, workers, err := deps.statsRepo.Counts(ctx)
|
||||
return metrics.Stats{Tasks: tasks, Jobs: jobs, Workers: workers}, err
|
||||
})
|
||||
|
||||
// pool.Ping backs /health: readiness means the database answers, not just
|
||||
// deps.ready backs /health: readiness means the database answers, not just
|
||||
// that the process is alive.
|
||||
api := httptransport.NewServer(useCases, log, cfg.RequestTimeout, cfg.HeartbeatInterval, cfg.MaxUploadBytes, cfg.JWTSecret, cfg.UserserviceURL, m, pool.Ping, cfg.PublicCoordinatorURL, cfg.PublicUserserviceURL, cfg.DocsDir)
|
||||
api := httptransport.NewServer(useCases, log, cfg.RequestTimeout, cfg.HeartbeatInterval, cfg.MaxUploadBytes, cfg.JWTSecret, cfg.UserserviceURL, m, deps.ready, cfg.PublicCoordinatorURL, cfg.PublicUserserviceURL, cfg.DocsDir)
|
||||
err = infra.RunServer(ctx, log, cfg.Addr, api.Handler(cfg.Token, cfg.UIToken))
|
||||
|
||||
// Shutdown order matters, and defers alone cannot express it (they run
|
||||
@@ -164,7 +230,7 @@ func run() error {
|
||||
//
|
||||
// 1. stop() cancel the context, telling the reaper to finish
|
||||
// 2. wg.Wait() let it return from its current tick
|
||||
// 3. deferred pool.Close() closes an idle pool, not a busy one
|
||||
// 3. deferred close() closes an idle pool, not a busy one
|
||||
//
|
||||
// Calling stop() here also covers the path where RunServer failed on its
|
||||
// own: the context would never be cancelled otherwise and wg.Wait()
|
||||
@@ -175,3 +241,64 @@ func run() error {
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// executablePath resolves the running binary for the admin console's node
|
||||
// information, falling back to the invocation name.
|
||||
func executablePath() string {
|
||||
path, err := os.Executable()
|
||||
if err != nil || path == "" {
|
||||
return os.Args[0]
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// openSQLite opens the embedded database and builds the sqlite repositories.
|
||||
func openSQLite(ctx context.Context, cfg infra.Config, log *slog.Logger) (*storageDeps, error) {
|
||||
if err := os.MkdirAll(cfg.StorageDir, 0o750); err != nil {
|
||||
return nil, fmt.Errorf("create storage dir: %w", err)
|
||||
}
|
||||
db, err := sqlite.Open(cfg.DBPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
closeOnce := &sync.Once{}
|
||||
return &storageDeps{
|
||||
tx: sqlite.NewTxManager(db),
|
||||
taskRepo: sqlite.NewTaskRepo(db),
|
||||
jobRepo: sqlite.NewJobRepo(db),
|
||||
workerRepo: sqlite.NewWorkerRepo(db),
|
||||
artifactRepo: sqlite.NewArtifactRepo(db),
|
||||
uiReadRepo: sqlite.NewUIReadRepo(db),
|
||||
adminReadRepo: sqlite.NewAdminReadRepo(db),
|
||||
settingsRepo: sqlite.NewWorkloadSettingsRepo(db),
|
||||
taskResultRepo: sqlite.NewTaskResultRepo(db),
|
||||
statsRepo: sqlite.NewStatsRepo(db),
|
||||
ready: func(ctx context.Context) error { return db.PingContext(ctx) },
|
||||
migrate: func(ctx context.Context, log *slog.Logger) error { return sqlite.Migrate(ctx, db, log) },
|
||||
close: func() { closeOnce.Do(func() { _ = db.Close() }) },
|
||||
}, nil
|
||||
}
|
||||
|
||||
// openPostgres connects to PostgreSQL and builds the postgres repositories.
|
||||
func openPostgres(ctx context.Context, cfg infra.Config, log *slog.Logger) (*storageDeps, error) {
|
||||
pool, err := infra.NewPool(ctx, cfg, log)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
closeOnce := &sync.Once{}
|
||||
return &storageDeps{
|
||||
tx: postgres.NewTxManager(pool),
|
||||
taskRepo: postgres.NewTaskRepo(pool),
|
||||
jobRepo: postgres.NewJobRepo(pool),
|
||||
workerRepo: postgres.NewWorkerRepo(pool),
|
||||
artifactRepo: postgres.NewArtifactRepo(pool),
|
||||
uiReadRepo: postgres.NewUIReadRepo(pool),
|
||||
adminReadRepo: postgres.NewAdminReadRepo(pool),
|
||||
settingsRepo: postgres.NewWorkloadSettingsRepo(pool),
|
||||
taskResultRepo: postgres.NewTaskResultRepo(pool),
|
||||
statsRepo: postgres.NewStatsRepo(pool),
|
||||
ready: func(ctx context.Context) error { return pool.Ping(ctx) },
|
||||
migrate: func(ctx context.Context, log *slog.Logger) error { return postgres.Migrate(ctx, cfg.DatabaseURL, log) },
|
||||
close: func() { closeOnce.Do(pool.Close) },
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/infra"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice"
|
||||
)
|
||||
|
||||
// runServe implements `coordinator serve`: the single-binary mode for a
|
||||
// scientist. It provisions a data directory (default ~/.scimesh) with the
|
||||
// coordinator and userservice sqlite databases, secrets, the admin account,
|
||||
// and optionally local worker agents — then runs the same server run() does.
|
||||
func runServe(args []string) error {
|
||||
flags := flag.NewFlagSet("serve", flag.ContinueOnError)
|
||||
flags.Usage = func() {
|
||||
_, _ = fmt.Fprintf(flags.Output(), "usage: coordinator serve [options]\n")
|
||||
_, _ = fmt.Fprintf(flags.Output(), "Runs the whole SciMesh platform from one binary: embedded databases, the\n")
|
||||
_, _ = fmt.Fprintf(flags.Output(), "userservice, and optional local workers. No PostgreSQL or Docker needed.\n\n")
|
||||
flags.PrintDefaults()
|
||||
}
|
||||
var (
|
||||
dataDir = flags.String("data-dir", defaultDataDir(), "data directory (default: ~/.scimesh)")
|
||||
addr = flags.String("addr", "127.0.0.1:8080", "listen address")
|
||||
workers = flags.Int("workers", 1, "number of local worker agents to spawn")
|
||||
open = flags.Bool("open", false, "open the UI in the browser")
|
||||
docsDir = flags.String("docs-dir", "", "built MkDocs site directory to serve at /ui/docs/")
|
||||
email = flags.String("admin-email", "admin@scimesh.local", "admin account email")
|
||||
password = flags.String("admin-password", "", "admin password (generated on first run when empty)")
|
||||
publicURL = flags.String("public-url", "", "browser/worker-facing coordinator URL (default: http://<addr>)")
|
||||
)
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.NArg() > 0 {
|
||||
return fmt.Errorf("serve takes no positional arguments")
|
||||
}
|
||||
if *workers < 0 {
|
||||
return fmt.Errorf("--workers must be >= 0")
|
||||
}
|
||||
|
||||
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||
if err := os.MkdirAll(*dataDir, 0o750); err != nil {
|
||||
return fmt.Errorf("create data dir: %w", err)
|
||||
}
|
||||
|
||||
// 1. Secrets, persisted in the data dir so restarts keep working.
|
||||
jwtSecret, err := loadOrGenerate(filepath.Join(*dataDir, "jwt.secret"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
workerToken, err := loadOrGenerate(filepath.Join(*dataDir, "worker.token"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. Admin account: generated once and printed, remembered for later boots.
|
||||
if *password == "" {
|
||||
*password, err = loadOrGenerate(filepath.Join(*dataDir, "admin.password"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Scientific runtime: ensure the managed venv (best effort).
|
||||
venvPython := filepath.Join(*dataDir, "venv", binName("bin/python"))
|
||||
ensureRuntime(log, *dataDir, venvPython)
|
||||
|
||||
// 4. Embedded userservice on the loopback interface.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
usersAddr, closeUsers, err := userservice.Serve(ctx, userservice.Config{
|
||||
DBPath: filepath.Join(*dataDir, "users.db"),
|
||||
JWTSecret: jwtSecret,
|
||||
AdminEmail: *email,
|
||||
AdminPassword: *password,
|
||||
Log: log,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("embedded userservice: %w", err)
|
||||
}
|
||||
defer func() { _ = closeUsers() }()
|
||||
|
||||
// 5. Local worker agents before the server, so they can claim immediately.
|
||||
coordinatorURL := "http://" + *addr
|
||||
agents, err := spawnAgents(ctx, log, *dataDir, *workers, coordinatorURL, workerToken, venvPython)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stopAgents(agents)
|
||||
|
||||
// 6. The coordinator server itself.
|
||||
coordinatorPublicURL := *publicURL
|
||||
if coordinatorPublicURL == "" {
|
||||
coordinatorPublicURL = "http://" + *addr
|
||||
}
|
||||
cfg := infra.Config{
|
||||
Addr: *addr,
|
||||
DatabaseEngine: "sqlite",
|
||||
DBPath: filepath.Join(*dataDir, "scimesh.db"),
|
||||
Token: workerToken,
|
||||
JWTSecret: jwtSecret,
|
||||
UserserviceURL: "http://" + usersAddr,
|
||||
PublicCoordinatorURL: coordinatorPublicURL,
|
||||
PublicUserserviceURL: "http://" + usersAddr,
|
||||
LogLevel: "info",
|
||||
StorageDir: filepath.Join(*dataDir, "artifacts"),
|
||||
DocsDir: *docsDir,
|
||||
MaxUploadBytes: 1 << 30,
|
||||
DBMaxConns: 4,
|
||||
DBConnectTimeout: 10 * time.Second,
|
||||
RequestTimeout: 15 * time.Second,
|
||||
HeartbeatInterval: 15 * time.Second,
|
||||
LeaseDuration: 2 * time.Minute,
|
||||
DefaultMaxAttempts: 3,
|
||||
QuorumSize: 2,
|
||||
ReaperInterval: 30 * time.Second,
|
||||
WorkerOfflineAfter: 1 * time.Minute,
|
||||
AutoMigrate: true,
|
||||
}
|
||||
if *open {
|
||||
openBrowser("http://" + *addr + "/ui/admin")
|
||||
}
|
||||
|
||||
// Print the login once the server is about to start.
|
||||
fmt.Printf("\nSciMesh is starting at http://%s/ui\n", *addr)
|
||||
fmt.Printf(" admin login: %s / %s\n", *email, *password)
|
||||
if runtimeStatus(venvPython) {
|
||||
fmt.Printf(" scientific runtime: ready (%s)\n", venvPython)
|
||||
} else {
|
||||
fmt.Printf(" scientific runtime: NOT ready — install Python 3, then restart serve\n")
|
||||
}
|
||||
fmt.Printf(" data directory: %s\n\n", *dataDir)
|
||||
|
||||
err = runWithConfig(cfg)
|
||||
cancel()
|
||||
stopAgents(agents)
|
||||
return err
|
||||
}
|
||||
|
||||
// defaultDataDir returns the platform-appropriate data directory.
|
||||
func defaultDataDir() string {
|
||||
if dir := os.Getenv("SCIMESH_DATA_DIR"); dir != "" {
|
||||
return dir
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
return ".scimesh"
|
||||
}
|
||||
return filepath.Join(home, ".scimesh")
|
||||
}
|
||||
|
||||
// loadOrGenerate reads a secret file, creating it with fresh random content
|
||||
// (chmod 0600) when missing.
|
||||
// #nosec G304 -- the path is an operator-supplied secret file inside the data dir.
|
||||
func loadOrGenerate(path string) (string, error) {
|
||||
if raw, err := os.ReadFile(path); err == nil {
|
||||
return strings.TrimSpace(string(raw)), nil
|
||||
}
|
||||
buffer := make([]byte, 32)
|
||||
if _, err := rand.Read(buffer); err != nil {
|
||||
return "", err
|
||||
}
|
||||
secret := hex.EncodeToString(buffer)
|
||||
if err := os.WriteFile(path, []byte(secret+"\n"), 0o600); err != nil {
|
||||
return "", fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
// spawnAgents starts `coordinator agent` subprocesses that claim tasks from
|
||||
// the coordinator. Each gets its own work directory under the data dir.
|
||||
func spawnAgents(ctx context.Context, log *slog.Logger, dataDir string, count int,
|
||||
coordinatorURL, token, venvPython string) ([]*exec.Cmd, error) {
|
||||
|
||||
var agents []*exec.Cmd
|
||||
for i := 0; i < count; i++ {
|
||||
workDir := filepath.Join(dataDir, "workers", fmt.Sprintf("%d", i))
|
||||
if err := os.MkdirAll(workDir, 0o750); err != nil {
|
||||
return agents, err
|
||||
}
|
||||
taskRunner := defaultTaskRunner(venvPython)
|
||||
// #nosec G204,G702 -- the command is this binary itself with operator flags.
|
||||
cmd := exec.CommandContext(ctx, os.Args[0], "agent",
|
||||
"--coordinator-url", coordinatorURL,
|
||||
"--token", token,
|
||||
"--work-dir", workDir,
|
||||
"--task-runner", taskRunner,
|
||||
)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
return agents, fmt.Errorf("start local agent %d: %w", i, err)
|
||||
}
|
||||
agents = append(agents, cmd)
|
||||
log.Info("local worker agent started", "index", i)
|
||||
}
|
||||
return agents, nil
|
||||
}
|
||||
|
||||
// stopAgents terminates the spawned agents and waits briefly for them.
|
||||
func stopAgents(agents []*exec.Cmd) {
|
||||
for _, agent := range agents {
|
||||
if agent.Process != nil {
|
||||
_ = agent.Process.Kill()
|
||||
}
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
for _, agent := range agents {
|
||||
_, _ = agent.Process.Wait()
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
}
|
||||
}
|
||||
|
||||
// defaultTaskRunner picks the managed venv python when present, else the
|
||||
// system `python`.
|
||||
func defaultTaskRunner(venvPython string) string {
|
||||
if runtimeStatus(venvPython) {
|
||||
return venvPython + " -m scimesh.worker.task"
|
||||
}
|
||||
return "python -m scimesh.worker.task"
|
||||
}
|
||||
|
||||
// ensureRuntime creates the managed venv and installs scimesh into it, unless
|
||||
// it already exists. Best effort: a missing Python only logs a hint.
|
||||
func ensureRuntime(log *slog.Logger, dataDir, venvPython string) {
|
||||
if runtimeStatus(venvPython) {
|
||||
return
|
||||
}
|
||||
python := findPython()
|
||||
if python == "" {
|
||||
log.Warn("python3 not found; local workers need it to run scientific workloads")
|
||||
return
|
||||
}
|
||||
log.Info("creating the scientific runtime venv", "python", python)
|
||||
venvDir := filepath.Dir(filepath.Dir(venvPython))
|
||||
// #nosec G204 -- python comes from PATH and venvDir from the data dir.
|
||||
create := exec.CommandContext(context.Background(), python, "-m", "venv", venvDir)
|
||||
if out, err := create.CombinedOutput(); err != nil {
|
||||
log.Warn("venv creation failed; local workers need a manual Python install", "err", err, "output", string(out))
|
||||
return
|
||||
}
|
||||
pip := filepath.Join(venvDir, binName("bin/pip"))
|
||||
// The scimesh package is installed from an explicit source only: the PyPI
|
||||
// name is not ours yet, so `pip install scimesh` would fetch a stranger's
|
||||
// package. Operators publish a wheel or index via SCIMESH_PIP_PACKAGE.
|
||||
source := os.Getenv("SCIMESH_PIP_PACKAGE")
|
||||
if source == "" {
|
||||
log.Warn("scientific runtime venv created, but scimesh is not installed",
|
||||
"hint", pip+" install <your scimesh wheel or index> (or set SCIMESH_PIP_PACKAGE)")
|
||||
return
|
||||
}
|
||||
// #nosec G204,G702 -- pip and source are operator-configured paths.
|
||||
install := exec.CommandContext(context.Background(), pip, "install", source)
|
||||
if out, err := install.CombinedOutput(); err != nil {
|
||||
log.Warn("pip install failed", "err", err, "output", string(out))
|
||||
return
|
||||
}
|
||||
log.Info("scientific runtime installed", "venv", venvDir)
|
||||
}
|
||||
|
||||
// findPython locates a usable python3.
|
||||
func findPython() string {
|
||||
for _, candidate := range []string{"python3", "python"} {
|
||||
path, err := exec.LookPath(candidate)
|
||||
if err == nil {
|
||||
return path
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// runtimeStatus reports whether the managed venv python exists.
|
||||
func runtimeStatus(venvPython string) bool {
|
||||
info, err := os.Stat(venvPython)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
// binName adapts a relative path to the platform layout.
|
||||
func binName(relative string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
parts := strings.Split(relative, "/")
|
||||
parts[len(parts)-1] += ".exe"
|
||||
return strings.Join(parts, string(filepath.Separator))
|
||||
}
|
||||
return relative
|
||||
}
|
||||
|
||||
// openBrowser opens the UI in the platform's default browser.
|
||||
func openBrowser(target string) {
|
||||
command := ""
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
command = "open"
|
||||
case "windows":
|
||||
command = "rundll32"
|
||||
default:
|
||||
command = "xdg-open"
|
||||
}
|
||||
if command == "rundll32" {
|
||||
// #nosec G204 -- target is the local UI URL the operator asked to open.
|
||||
_ = exec.CommandContext(context.Background(), "rundll32", "url.dll,FileProtocolHandler", target).Start()
|
||||
return
|
||||
}
|
||||
// #nosec G204 -- target is the local UI URL the operator asked to open.
|
||||
_ = exec.CommandContext(context.Background(), command, target).Start()
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// runToken implements `coordinator token`: prints the worker auth token of a
|
||||
// `coordinator serve` instance, so a scientist can join a worker without
|
||||
// hunting through the data directory. The file itself is what serve created.
|
||||
func runToken(args []string) error {
|
||||
flags := flag.NewFlagSet("token", flag.ContinueOnError)
|
||||
flags.Usage = func() {
|
||||
_, _ = fmt.Fprintf(flags.Output(), "usage: coordinator token [options]\n")
|
||||
_, _ = fmt.Fprintf(flags.Output(), "Prints the WORKER_AUTH_TOKEN of this coordinator's serve instance.\n\n")
|
||||
flags.PrintDefaults()
|
||||
}
|
||||
var dataDir = flags.String("data-dir", defaultDataDir(), "data directory (default: ~/.scimesh)")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.NArg() > 0 {
|
||||
return fmt.Errorf("token takes no positional arguments")
|
||||
}
|
||||
token, err := os.ReadFile(filepath.Join(*dataDir, "worker.token"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("no worker token found in %s — start the coordinator with `coordinator serve` first", *dataDir)
|
||||
}
|
||||
fmt.Print(string(token))
|
||||
return nil
|
||||
}
|
||||
@@ -1,14 +1,25 @@
|
||||
// Command worker-agent is the Go worker agent: a coordinator client that
|
||||
// executes SDK workloads in a Python subprocess per claimed task.
|
||||
// executes SDK workloads in a Python subprocess per claimed task. It also
|
||||
// carries the local setup wizard (`worker-agent setup`) so a machine that
|
||||
// installs only the worker can configure and start itself without a
|
||||
// coordinator on site.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/agent"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/agent/setupui"
|
||||
)
|
||||
|
||||
// version is injected at build time (-ldflags "-X main.version=...") and
|
||||
@@ -16,13 +27,44 @@ import (
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
showVersion := flag.Bool("version", false, "print the build version and exit")
|
||||
flag.Parse()
|
||||
if len(os.Args) > 1 && os.Args[1] == "setup" {
|
||||
os.Exit(runSetup(os.Args[2:]))
|
||||
}
|
||||
|
||||
fs := flag.NewFlagSet("worker-agent", flag.ExitOnError)
|
||||
showVersion := fs.Bool("version", false, "print the build version and exit")
|
||||
configPath := fs.String("config", "", "path to a JSON config file (SCIMESH_WORKER_CONFIG overrides the default)")
|
||||
checkMode := fs.Bool("check", false, "run the preflight check (coordinator + local runtime) and exit 0/1")
|
||||
checkURL := fs.String("coordinator-url", "", "coordinator URL to probe in --check mode")
|
||||
_ = fs.Parse(os.Args[1:])
|
||||
|
||||
agent.Version = version
|
||||
|
||||
if *showVersion {
|
||||
fmt.Println("worker-agent " + version)
|
||||
return
|
||||
}
|
||||
config, err := agent.LoadConfig()
|
||||
|
||||
if *checkMode {
|
||||
url := *checkURL
|
||||
if url == "" {
|
||||
url = os.Getenv("COORDINATOR_URL")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if url == "" {
|
||||
fmt.Println("check: no coordinator URL (pass --coordinator-url or set COORDINATOR_URL)")
|
||||
os.Exit(1)
|
||||
}
|
||||
report := agent.RunCheck(ctx, url)
|
||||
printCheck(report)
|
||||
if !report.Coordinator.OK || !report.Python.OK || !report.Scimesh.OK {
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
config, err := loadConfig(*configPath)
|
||||
if err != nil {
|
||||
slog.Error("invalid configuration", "error", err)
|
||||
os.Exit(2)
|
||||
@@ -42,3 +84,97 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// loadConfig prefers a --config file; environment variables override the file
|
||||
// (see agent.ConfigFile.Config). Without a file, the plain environment path is
|
||||
// used exactly as before.
|
||||
func loadConfig(configPath string) (*agent.Config, error) {
|
||||
if configPath != "" {
|
||||
return agent.LoadConfigFile(configPath)
|
||||
}
|
||||
envPath := os.Getenv("SCIMESH_WORKER_CONFIG")
|
||||
if envPath != "" {
|
||||
if _, err := os.Stat(envPath); err == nil { //nolint:gosec // G703: path is the operator's own env var
|
||||
return agent.LoadConfigFile(envPath)
|
||||
}
|
||||
}
|
||||
return agent.LoadConfig()
|
||||
}
|
||||
|
||||
func printCheck(report agent.CheckReport) {
|
||||
fmt.Printf("worker-agent %s\n", report.Agent)
|
||||
line := func(item agent.CheckItem) string {
|
||||
mark := "✗"
|
||||
if item.OK {
|
||||
mark = "✓"
|
||||
}
|
||||
detail := item.Detail
|
||||
if item.Latency > 0 {
|
||||
detail = fmt.Sprintf("%s (%d ms)", detail, item.Latency)
|
||||
}
|
||||
return fmt.Sprintf(" %s %s: %s", mark, item.Name, detail)
|
||||
}
|
||||
fmt.Println(line(report.Coordinator))
|
||||
fmt.Println(line(report.Auth))
|
||||
fmt.Println(line(report.Python))
|
||||
fmt.Println(line(report.Scimesh))
|
||||
}
|
||||
|
||||
// runSetup serves the local setup wizard until interrupted. It binds the
|
||||
// loopback interface only.
|
||||
func runSetup(args []string) int {
|
||||
fs := flag.NewFlagSet("worker-agent setup", flag.ContinueOnError)
|
||||
port := fs.Int("port", 0, "listen port (default 12700)")
|
||||
noOpen := fs.Bool("no-open", false, "do not open the browser automatically")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
server := setupui.New(logger, setupui.Options{
|
||||
Port: *port,
|
||||
OpenBrowser: func(url string) {
|
||||
if *noOpen {
|
||||
return
|
||||
}
|
||||
openBrowser(url)
|
||||
},
|
||||
})
|
||||
listener, err := server.Listen()
|
||||
if err != nil {
|
||||
logger.Error("setup wizard could not bind the loopback port", "err", err)
|
||||
return 1
|
||||
}
|
||||
url := "http://" + listener.Addr().String()
|
||||
logger.Info("SciMesh worker setup wizard", "url", url, "press-ctrl-c-to-stop", true)
|
||||
server.OpenBrowser(url)
|
||||
// Block until the signal arrives (never returns an error that matters: a
|
||||
// cancelled context is the normal exit path).
|
||||
err = server.Serve(ctx, listener)
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
logger.Error("setup wizard stopped", "err", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// openBrowser points the user's default browser at the wizard. Best-effort:
|
||||
// a missing browser must never fail the setup flow.
|
||||
func openBrowser(url string) {
|
||||
for _, candidate := range [][]string{
|
||||
{"xdg-open", url},
|
||||
{"open", url},
|
||||
{"cmd", "/c", "start", url},
|
||||
} {
|
||||
binary, err := exec.LookPath(candidate[0])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
//nolint:gosec // G204: candidates are our own fixed list; the url is a loopback literal
|
||||
_ = exec.CommandContext(context.Background(), binary, candidate[1:]...).Start()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,18 +16,26 @@ require (
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
|
||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.55.0 // indirect
|
||||
github.com/prometheus/procfs v0.21.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/crypto v0.17.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
modernc.org/libc v1.74.1 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.55.0 // indirect
|
||||
)
|
||||
|
||||
@@ -9,6 +9,8 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
@@ -29,8 +31,12 @@ github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq
|
||||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
|
||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
|
||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
|
||||
@@ -41,6 +47,8 @@ github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G
|
||||
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
|
||||
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
||||
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
@@ -51,6 +59,7 @@ golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
|
||||
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
@@ -63,3 +72,11 @@ gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYs
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
|
||||
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM=
|
||||
modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CheckItem is one line of the preflight report the setup wizard shows.
|
||||
type CheckItem struct {
|
||||
Name string `json:"name"`
|
||||
OK bool `json:"ok"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Latency int64 `json:"latency_ms,omitempty"`
|
||||
}
|
||||
|
||||
// CheckReport is the full preflight result of `worker-agent --check` and of
|
||||
// the wizard's test step.
|
||||
type CheckReport struct {
|
||||
Coordinator CheckItem `json:"coordinator"`
|
||||
Auth CheckItem `json:"auth"`
|
||||
Python CheckItem `json:"python"`
|
||||
Scimesh CheckItem `json:"scimesh"`
|
||||
Agent string `json:"agent_version"`
|
||||
CoordinatorVersion string `json:"coordinator_version,omitempty"`
|
||||
}
|
||||
|
||||
// checkHTTP runs one GET and reports reachability + latency, with a fallback
|
||||
// detail message when the server answers without JSON.
|
||||
func checkHTTP(ctx context.Context, url string, timeout time.Duration) (CheckItem, string) {
|
||||
started := time.Now()
|
||||
reqCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return CheckItem{Name: "coordinator", OK: false, Detail: "invalid URL"}, ""
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
detail := err.Error()
|
||||
if strings.Contains(detail, "connection refused") {
|
||||
detail = "no coordinator answering at this address"
|
||||
}
|
||||
return CheckItem{Name: "coordinator", OK: false, Detail: detail}, ""
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
version := ""
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
var body struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err == nil && body.Status == "ok" {
|
||||
return CheckItem{Name: "coordinator", OK: true, Latency: time.Since(started).Milliseconds()}, version
|
||||
}
|
||||
}
|
||||
return CheckItem{Name: "coordinator", OK: false, Detail: fmt.Sprintf("HTTP %d", resp.StatusCode)}, version
|
||||
}
|
||||
|
||||
// CheckCoordinator probes the coordinator's /health endpoint.
|
||||
func CheckCoordinator(ctx context.Context, url string, timeout time.Duration) CheckReport {
|
||||
report := CheckReport{Agent: Version}
|
||||
item, _ := checkHTTP(ctx, strings.TrimRight(url, "/")+"/health", timeout)
|
||||
report.Coordinator = item
|
||||
report.Auth = CheckItem{Name: "auth", OK: true, Detail: "no token configured — will be checked at registration"}
|
||||
return report
|
||||
}
|
||||
|
||||
// CheckEnvironment verifies the local runtime: Python present and the scimesh
|
||||
// package importable.
|
||||
func CheckEnvironment(ctx context.Context) CheckReport {
|
||||
report := CheckReport{Agent: Version}
|
||||
python, err := exec.LookPath("python3")
|
||||
if err != nil {
|
||||
report.Python = CheckItem{Name: "python", OK: false, Detail: "python3 not found on PATH"}
|
||||
return report
|
||||
}
|
||||
report.Python = CheckItem{Name: "python", OK: true, Detail: python}
|
||||
//nolint:gosec // G204: python comes from LookPath, the argument list is constant
|
||||
cmd := exec.CommandContext(ctx, python, "-c", "import scimesh; print(scimesh.__version__ if hasattr(scimesh, '__version__') else 'installed')")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
report.Scimesh = CheckItem{Name: "scimesh", OK: false, Detail: "install with: pip install scimesh"}
|
||||
return report
|
||||
}
|
||||
report.Scimesh = CheckItem{Name: "scimesh", OK: true, Detail: strings.TrimSpace(string(out))}
|
||||
return report
|
||||
}
|
||||
|
||||
// RunCheck combines the coordinator probe and the local environment probe; it
|
||||
// is the body behind `worker-agent --check` and the wizard's test step.
|
||||
func RunCheck(ctx context.Context, coordinatorURL string) CheckReport {
|
||||
report := CheckCoordinator(ctx, coordinatorURL, 15*time.Second)
|
||||
env := CheckEnvironment(ctx)
|
||||
report.Python = env.Python
|
||||
report.Scimesh = env.Scimesh
|
||||
return report
|
||||
}
|
||||
|
||||
// Version is the agent build version; main injects it via -ldflags and the
|
||||
// setup wizard mirrors it into the report. "dev" marks a local build.
|
||||
var Version = "dev"
|
||||
|
||||
// Platform is the host platform string shown on the wizard.
|
||||
func Platform() string { return runtime.GOOS + "/" + runtime.GOARCH }
|
||||
@@ -101,7 +101,7 @@ func LoadConfig() (*Config, error) {
|
||||
return nil, err
|
||||
}
|
||||
if len(capabilities) == 0 {
|
||||
capabilities = defaultCapabilities()
|
||||
capabilities = DefaultCapabilities()
|
||||
}
|
||||
runner, err := envList("TASK_RUNNER")
|
||||
if err != nil {
|
||||
@@ -160,11 +160,11 @@ func durationEnv(name string, fallback time.Duration) (time.Duration, error) {
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// defaultCapabilities derives the worker's advertised capabilities from the
|
||||
// DefaultCapabilities derives the worker's advertised capabilities from the
|
||||
// embedded workload catalog, so an agent is workload-agnostic out of the box:
|
||||
// it claims whatever enabled workloads the coordinator library declares.
|
||||
// Explicit CAPABILITIES still overrides this for operators who want a subset.
|
||||
func defaultCapabilities() []string {
|
||||
func DefaultCapabilities() []string {
|
||||
catalog, err := workloads.Load()
|
||||
if err != nil {
|
||||
return []string{"similarity-search"}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ConfigFile is the persisted worker configuration written by the setup
|
||||
// wizard and read back by `worker-agent --config`. Environment variables
|
||||
// still win: the file fills in what the environment left unset.
|
||||
type ConfigFile struct {
|
||||
CoordinatorURL string `json:"coordinator_url"`
|
||||
Token string `json:"token,omitempty"`
|
||||
WorkerKey string `json:"worker_key,omitempty"`
|
||||
UserserviceURL string `json:"userservice_url,omitempty"`
|
||||
WorkDir string `json:"work_dir"`
|
||||
WorkerName string `json:"worker_name,omitempty"`
|
||||
CPUCount int `json:"cpu_count"`
|
||||
MemoryMB int `json:"memory_mb"`
|
||||
TaskRunner []string `json:"task_runner,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultConfigPath is where the setup wizard stores the worker's
|
||||
// configuration. SCIMESH_WORKER_CONFIG overrides it.
|
||||
func DefaultConfigPath() string {
|
||||
if raw := os.Getenv("SCIMESH_WORKER_CONFIG"); raw != "" {
|
||||
return raw
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
return filepath.Join(".", ".scimesh-worker", "config.json")
|
||||
}
|
||||
return filepath.Join(home, ".scimesh-worker", "config.json")
|
||||
}
|
||||
|
||||
// LoadConfigFile reads and validates a persisted configuration. The file is
|
||||
// created by the wizard with 0600 permissions, so no credential is exposed to
|
||||
// other local users.
|
||||
func LoadConfigFile(path string) (*Config, error) {
|
||||
//nolint:gosec // G304: path is --config or SCIMESH_WORKER_CONFIG, operator-supplied
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config file: %w", err)
|
||||
}
|
||||
var file ConfigFile
|
||||
if err := json.Unmarshal(raw, &file); err != nil {
|
||||
return nil, fmt.Errorf("parse config file: %w", err)
|
||||
}
|
||||
return file.Config()
|
||||
}
|
||||
|
||||
// Config turns the file into the daemon configuration. Environment variables
|
||||
// take precedence so operators can still override any value per-process.
|
||||
func (f *ConfigFile) Config() (*Config, error) {
|
||||
config := &Config{}
|
||||
if env := os.Getenv("COORDINATOR_URL"); env != "" {
|
||||
config.CoordinatorURL = env
|
||||
} else {
|
||||
config.CoordinatorURL = strings.TrimSpace(f.CoordinatorURL)
|
||||
}
|
||||
if config.CoordinatorURL == "" {
|
||||
return nil, fmt.Errorf("coordinator_url is required")
|
||||
}
|
||||
if !strings.HasPrefix(config.CoordinatorURL, "http://") && !strings.HasPrefix(config.CoordinatorURL, "https://") {
|
||||
return nil, fmt.Errorf("coordinator_url must be an absolute HTTP(S) URL")
|
||||
}
|
||||
if env := os.Getenv("WORKER_AUTH_TOKEN"); env != "" {
|
||||
config.Token = env
|
||||
} else {
|
||||
config.Token = f.Token
|
||||
}
|
||||
if env := os.Getenv("WORKER_KEY"); env != "" {
|
||||
config.WorkerKey = env
|
||||
} else {
|
||||
config.WorkerKey = f.WorkerKey
|
||||
}
|
||||
if env := os.Getenv("USERSERVICE_URL"); env != "" {
|
||||
config.UserserviceURL = env
|
||||
} else {
|
||||
config.UserserviceURL = f.UserserviceURL
|
||||
}
|
||||
if env := os.Getenv("WORK_DIR"); env != "" {
|
||||
config.WorkDir = env
|
||||
} else if f.WorkDir != "" {
|
||||
config.WorkDir = f.WorkDir
|
||||
} else {
|
||||
config.WorkDir = "./scimesh-agent-data"
|
||||
}
|
||||
if env := os.Getenv("WORKER_NAME"); env != "" {
|
||||
config.WorkerName = env
|
||||
} else {
|
||||
config.WorkerName = f.WorkerName
|
||||
}
|
||||
config.CPUCount = f.CPUCount
|
||||
if config.CPUCount < 1 {
|
||||
config.CPUCount = 1
|
||||
}
|
||||
config.MemoryMB = f.MemoryMB
|
||||
if config.MemoryMB < 0 {
|
||||
config.MemoryMB = 0
|
||||
}
|
||||
if len(f.TaskRunner) > 0 {
|
||||
config.TaskRunner = f.TaskRunner
|
||||
}
|
||||
if len(config.TaskRunner) == 0 {
|
||||
config.TaskRunner = []string{"python", "-m", "scimesh.worker.task"}
|
||||
}
|
||||
config.PollInterval = 2 * time.Second
|
||||
config.RequestTimeout = 30 * time.Second
|
||||
config.Heartbeat = 15 * time.Second
|
||||
config.Capabilities = DefaultCapabilities()
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// Save writes the configuration file, creating the parent directory and
|
||||
// restricting permissions to the owner.
|
||||
func SaveConfigFile(path string, file ConfigFile) error {
|
||||
payload, err := json.MarshalIndent(file, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload = append(payload, '\n')
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return fmt.Errorf("create config directory: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(path, payload, 0o600); err != nil {
|
||||
return fmt.Errorf("write config file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
// Package setupui serves the local worker setup wizard: a small HTTP server
|
||||
// bound to 127.0.0.1 that writes the worker's config file, runs preflight
|
||||
// checks, and starts/stops the worker as a background process. It is part of
|
||||
// the worker-agent binary so a machine that installs only a worker never needs
|
||||
// a coordinator.
|
||||
package setupui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/agent"
|
||||
)
|
||||
|
||||
//go:embed template.html
|
||||
var templateFS embed.FS
|
||||
|
||||
const (
|
||||
defaultPort = 12700
|
||||
pidFileName = "worker.pid"
|
||||
logFileName = "worker.log"
|
||||
)
|
||||
|
||||
// Supervisor starts and stops the worker process and tracks its pid. It is an
|
||||
// interface so tests can substitute a fake.
|
||||
type Supervisor interface {
|
||||
// Start launches `worker-agent --config <path>` detached, writing output
|
||||
// into the log file. Returns the child pid.
|
||||
Start(configPath, logPath string) (int, error)
|
||||
// Stop terminates the process recorded in the pid file.
|
||||
Stop() error
|
||||
// Pid returns the recorded child pid, or 0 when none is recorded.
|
||||
Pid() int
|
||||
// Alive reports whether the recorded child is still running.
|
||||
Alive() bool
|
||||
}
|
||||
|
||||
// PIDSupervisor is the real Supervisor: it spawns the running binary with
|
||||
// --config and manages its pid file. Liveness comes from a Wait goroutine, so
|
||||
// it works on every platform (no signal probing, which Windows lacks).
|
||||
type PIDSupervisor struct {
|
||||
mu sync.Mutex
|
||||
pidPath string
|
||||
proc *os.Process
|
||||
done chan struct{} // closed when the spawned process exits; nil when not started
|
||||
}
|
||||
|
||||
func NewPIDSupervisor(pidPath string) *PIDSupervisor { return &PIDSupervisor{pidPath: pidPath} }
|
||||
|
||||
func (s *PIDSupervisor) Pid() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.readPid()
|
||||
}
|
||||
|
||||
func (s *PIDSupervisor) readPid() int {
|
||||
raw, err := os.ReadFile(s.pidPath)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(string(raw)))
|
||||
if err != nil || pid < 1 {
|
||||
return 0
|
||||
}
|
||||
return pid
|
||||
}
|
||||
|
||||
func (s *PIDSupervisor) Alive() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.done == nil {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case <-s.done:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PIDSupervisor) Start(configPath, logPath string) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.done != nil {
|
||||
select {
|
||||
case <-s.done:
|
||||
default:
|
||||
return s.readPid(), fmt.Errorf("worker is already running (pid %d)", s.readPid())
|
||||
}
|
||||
}
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("resolve worker binary: %w", err)
|
||||
}
|
||||
//nolint:gosec // G304: logPath lives in the wizard's own config directory
|
||||
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("open worker log: %w", err)
|
||||
}
|
||||
defer func() { _ = logFile.Close() }()
|
||||
//nolint:gosec // G204: exe is os.Executable, configPath is the wizard's own file;
|
||||
// Background ctx: the child's lifecycle is managed by the supervisor, not the context
|
||||
cmd := exec.CommandContext(context.Background(), exe, "--config", configPath)
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
cmd.Stdin = nil
|
||||
if err := cmd.Start(); err != nil {
|
||||
return 0, fmt.Errorf("start worker: %w", err)
|
||||
}
|
||||
// The child inherits our stdout/stderr descriptors pointing at the log
|
||||
// file, so we can close our copy; the child keeps it open.
|
||||
_ = logFile.Close()
|
||||
s.proc = cmd.Process
|
||||
s.done = make(chan struct{})
|
||||
go func() { _ = cmd.Wait(); close(s.done) }()
|
||||
if err := os.WriteFile(s.pidPath, []byte(strconv.Itoa(cmd.Process.Pid)+"\n"), 0o600); err != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
return 0, fmt.Errorf("write pid file: %w", err)
|
||||
}
|
||||
return cmd.Process.Pid, nil
|
||||
}
|
||||
|
||||
func (s *PIDSupervisor) Stop() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.done == nil {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-s.done:
|
||||
s.done = nil
|
||||
s.proc = nil
|
||||
_ = os.Remove(s.pidPath)
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
// Ask politely, then force. os.Interrupt terminates on Windows too.
|
||||
_ = s.proc.Signal(os.Interrupt)
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case <-s.done:
|
||||
s.done = nil
|
||||
s.proc = nil
|
||||
_ = os.Remove(s.pidPath)
|
||||
return nil
|
||||
default:
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
_ = s.proc.Kill()
|
||||
select {
|
||||
case <-s.done:
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
s.done = nil
|
||||
s.proc = nil
|
||||
_ = os.Remove(s.pidPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Server is the wizard HTTP server, bound to the loopback interface only.
|
||||
type Server struct {
|
||||
log *slog.Logger
|
||||
cfgPath string
|
||||
logPath string
|
||||
dir string
|
||||
sup Supervisor
|
||||
openBrowser func(string)
|
||||
port int
|
||||
}
|
||||
|
||||
// Options customises the wizard for tests and embedding.
|
||||
type Options struct {
|
||||
Port int
|
||||
ConfigPath string
|
||||
OpenBrowser func(url string)
|
||||
Supervisor Supervisor
|
||||
Dir string // directory for pid/log files; defaults to the config dir
|
||||
}
|
||||
|
||||
func New(log *slog.Logger, opts Options) *Server {
|
||||
cfgPath := opts.ConfigPath
|
||||
if cfgPath == "" {
|
||||
cfgPath = agent.DefaultConfigPath()
|
||||
}
|
||||
dir := opts.Dir
|
||||
if dir == "" {
|
||||
dir = filepath.Dir(cfgPath)
|
||||
}
|
||||
sup := opts.Supervisor
|
||||
if sup == nil {
|
||||
sup = NewPIDSupervisor(filepath.Join(dir, pidFileName))
|
||||
}
|
||||
open := opts.OpenBrowser
|
||||
if open == nil {
|
||||
open = func(string) {}
|
||||
}
|
||||
port := opts.Port
|
||||
if port == 0 {
|
||||
port = defaultPort
|
||||
}
|
||||
return &Server{log: log, cfgPath: cfgPath, logPath: filepath.Join(dir, logFileName), dir: dir, sup: sup, openBrowser: open, port: port}
|
||||
}
|
||||
|
||||
// Listen binds the loopback listener and returns it; Serve runs the server on
|
||||
// it. Split so tests can inspect the actual ephemeral port.
|
||||
func (s *Server) Listen() (net.Listener, error) {
|
||||
return (&net.ListenConfig{}).Listen(context.Background(), "tcp", fmt.Sprintf("127.0.0.1:%d", s.port))
|
||||
}
|
||||
|
||||
// OpenBrowser hands the wizard URL to the configured opener (default: no-op).
|
||||
func (s *Server) OpenBrowser(url string) { s.openBrowser(url) }
|
||||
|
||||
// Serve runs the wizard until ctx is cancelled.
|
||||
func (s *Server) Serve(ctx context.Context, listener net.Listener) error {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /", s.handleIndex)
|
||||
mux.HandleFunc("GET /api/status", s.handleStatus)
|
||||
mux.HandleFunc("POST /api/config", s.handleSaveConfig)
|
||||
mux.HandleFunc("POST /api/test", s.handleTest)
|
||||
mux.HandleFunc("POST /api/start", s.handleStart)
|
||||
mux.HandleFunc("POST /api/stop", s.handleStop)
|
||||
mux.HandleFunc("GET /api/logs", s.handleLogs)
|
||||
server := &http.Server{
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 3*time.Second)
|
||||
defer cancel()
|
||||
_ = server.Shutdown(shutdownCtx)
|
||||
}()
|
||||
return server.Serve(listener)
|
||||
}
|
||||
|
||||
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
html, err := templateFS.ReadFile("template.html")
|
||||
if err != nil {
|
||||
http.Error(w, "template unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write(html)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// statusView is what the wizard needs to paint the running/stopped state.
|
||||
type statusView struct {
|
||||
ConfigPresent bool `json:"config_present"`
|
||||
ConfigPath string `json:"config_path"`
|
||||
LogPath string `json:"log_path"`
|
||||
Running bool `json:"running"`
|
||||
Pid int `json:"pid"`
|
||||
WorkerName string `json:"worker_name,omitempty"`
|
||||
Coordinator string `json:"coordinator,omitempty"`
|
||||
WorkDir string `json:"work_dir,omitempty"`
|
||||
TokenSet bool `json:"token_set"`
|
||||
}
|
||||
|
||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
view := statusView{ConfigPath: s.cfgPath, LogPath: s.logPath, Running: s.sup.Alive(), Pid: s.sup.Pid()}
|
||||
if raw, err := os.ReadFile(s.cfgPath); err == nil {
|
||||
var file agent.ConfigFile
|
||||
if json.Unmarshal(raw, &file) == nil {
|
||||
view.ConfigPresent = true
|
||||
view.WorkerName = file.WorkerName
|
||||
view.Coordinator = file.CoordinatorURL
|
||||
view.WorkDir = file.WorkDir
|
||||
view.TokenSet = file.Token != "" || file.WorkerKey != ""
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
type saveConfigRequest struct {
|
||||
CoordinatorURL string `json:"coordinator_url"`
|
||||
Token string `json:"token"`
|
||||
WorkerKey string `json:"worker_key"`
|
||||
UserserviceURL string `json:"userservice_url"`
|
||||
WorkDir string `json:"work_dir"`
|
||||
WorkerName string `json:"worker_name"`
|
||||
CPUCount int `json:"cpu_count"`
|
||||
MemoryMB int `json:"memory_mb"`
|
||||
TaskRunner []string `json:"task_runner"`
|
||||
}
|
||||
|
||||
func (s *Server) handleSaveConfig(w http.ResponseWriter, r *http.Request) {
|
||||
var req saveConfigRequest
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
file := agent.ConfigFile{
|
||||
CoordinatorURL: strings.TrimSpace(req.CoordinatorURL),
|
||||
Token: req.Token,
|
||||
WorkerKey: req.WorkerKey,
|
||||
UserserviceURL: strings.TrimSpace(req.UserserviceURL),
|
||||
WorkDir: strings.TrimSpace(req.WorkDir),
|
||||
WorkerName: strings.TrimSpace(req.WorkerName),
|
||||
CPUCount: req.CPUCount,
|
||||
MemoryMB: req.MemoryMB,
|
||||
TaskRunner: req.TaskRunner,
|
||||
}
|
||||
if file.CoordinatorURL == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "coordinator_url is required"})
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(file.CoordinatorURL, "http://") && !strings.HasPrefix(file.CoordinatorURL, "https://") {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "coordinator_url must be an absolute HTTP(S) URL"})
|
||||
return
|
||||
}
|
||||
if file.WorkDir == "" {
|
||||
file.WorkDir = "./scimesh-agent-data"
|
||||
}
|
||||
if file.WorkerName == "" {
|
||||
if host, err := os.Hostname(); err == nil {
|
||||
file.WorkerName = host
|
||||
} else {
|
||||
file.WorkerName = "worker"
|
||||
}
|
||||
}
|
||||
if file.CPUCount < 1 {
|
||||
file.CPUCount = 1
|
||||
}
|
||||
if err := agent.SaveConfigFile(s.cfgPath, file); err != nil {
|
||||
s.log.Error("save wizard config", "err", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not write the config file"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"saved": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleTest(w http.ResponseWriter, r *http.Request) {
|
||||
var req saveConfigRequest
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
url := strings.TrimSpace(req.CoordinatorURL)
|
||||
if url == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "coordinator_url is required"})
|
||||
return
|
||||
}
|
||||
report := agent.RunCheck(r.Context(), url)
|
||||
writeJSON(w, http.StatusOK, report)
|
||||
}
|
||||
|
||||
func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) {
|
||||
if _, err := os.Stat(s.cfgPath); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "no configuration saved yet"})
|
||||
return
|
||||
}
|
||||
pid, err := s.sup.Start(s.cfgPath, s.logPath)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]int{"pid": pid})
|
||||
}
|
||||
|
||||
func (s *Server) handleStop(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.sup.Stop(); err != nil {
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"stopped": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) {
|
||||
raw, err := os.ReadFile(s.logPath)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"log": ""})
|
||||
return
|
||||
}
|
||||
lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n")
|
||||
tail := 200
|
||||
if n, err := strconv.Atoi(r.URL.Query().Get("tail")); err == nil && n > 0 && n < 5000 {
|
||||
tail = n
|
||||
}
|
||||
if len(lines) > tail {
|
||||
lines = lines[len(lines)-tail:]
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"log": strings.Join(lines, "\n")})
|
||||
}
|
||||
|
||||
// ErrCanceled mirrors context.Canceled for callers that treat a cancelled
|
||||
// wizard as a clean exit.
|
||||
var ErrCanceled = errors.New("setup wizard cancelled")
|
||||
@@ -0,0 +1,293 @@
|
||||
package setupui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/agent"
|
||||
)
|
||||
|
||||
func testLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T, sup Supervisor) (*Server, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
server := New(testLogger(), Options{
|
||||
// A distinct random port per test: Port 0 means "the default 12700" in
|
||||
// the server, which would let the shared http.Client pool reuse a stale
|
||||
// keep-alive connection across tests (EOF after a Shutdown).
|
||||
Port: freePort(t),
|
||||
ConfigPath: filepath.Join(dir, "config.json"),
|
||||
Dir: dir,
|
||||
Supervisor: sup,
|
||||
OpenBrowser: func(string) {},
|
||||
})
|
||||
listener, err := server.Listen()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = listener.Close() })
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
go func() { _ = server.Serve(ctx, listener) }()
|
||||
return server, "http://" + listener.Addr().String()
|
||||
}
|
||||
|
||||
type fakeSup struct {
|
||||
mu sync.Mutex
|
||||
started bool
|
||||
stopped bool
|
||||
pid int
|
||||
alive bool
|
||||
}
|
||||
|
||||
func (f *fakeSup) Start(configPath, logPath string) (int, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.started = true
|
||||
f.alive = true
|
||||
f.pid = 4242
|
||||
return f.pid, nil
|
||||
}
|
||||
|
||||
func (f *fakeSup) Stop() error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.stopped = true
|
||||
f.alive = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeSup) Pid() int { f.mu.Lock(); defer f.mu.Unlock(); return f.pid }
|
||||
func (f *fakeSup) Alive() bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.alive
|
||||
}
|
||||
|
||||
func postJSON(t *testing.T, base, path string, body any) (*httptest.ResponseRecorder, map[string]any) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, base+path, strings.NewReader(mustJSON(t, body)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
// No keep-alive pooling: a pooled connection to a shut-down test server
|
||||
// would surface as an EOF instead of a fresh dial.
|
||||
client := http.Client{Transport: &http.Transport{DisableKeepAlives: true}}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
rec := httptest.NewRecorder()
|
||||
rec.Code = resp.StatusCode
|
||||
data := map[string]any{}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&data)
|
||||
return rec, data
|
||||
}
|
||||
|
||||
// freePort reserves an ephemeral port and returns it. The listener is closed
|
||||
// immediately; the tiny reuse window is acceptable for tests and each test
|
||||
// gets a different port, so nothing can collide or share pooled connections.
|
||||
func freePort(t *testing.T) int {
|
||||
t.Helper()
|
||||
listener, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
_ = listener.Close()
|
||||
return port
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, v any) string {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func TestWizardSavesConfigWithStrictPermissions(t *testing.T) {
|
||||
sup := &fakeSup{}
|
||||
server, base := newTestServer(t, sup)
|
||||
|
||||
rec, _ := postJSON(t, base, "/api/config", map[string]any{
|
||||
"coordinator_url": "http://192.168.1.10:8080",
|
||||
"token": "sm_live_secret",
|
||||
"work_dir": "/home/emil/scimesh-worker",
|
||||
"worker_name": "emil-laptop",
|
||||
"cpu_count": 8,
|
||||
"memory_mb": 16384,
|
||||
})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("save config: got %d, want 200", rec.Code)
|
||||
}
|
||||
info, err := os.Stat(server.cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0o600 {
|
||||
t.Errorf("config perms = %o, want 600", perm)
|
||||
}
|
||||
config, err := agent.LoadConfigFile(server.cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.CoordinatorURL != "http://192.168.1.10:8080" || config.Token != "sm_live_secret" || config.WorkDir != "/home/emil/scimesh-worker" || config.WorkerName != "emil-laptop" {
|
||||
t.Errorf("config = %+v", config)
|
||||
}
|
||||
if config.CPUCount != 8 || config.MemoryMB != 16384 {
|
||||
t.Errorf("resources: cpu=%d mem=%d", config.CPUCount, config.MemoryMB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWizardRejectsInvalidConfig(t *testing.T) {
|
||||
sup := &fakeSup{}
|
||||
_, base := newTestServer(t, sup)
|
||||
|
||||
for _, body := range []map[string]any{
|
||||
{"coordinator_url": "", "token": "x"},
|
||||
{"coordinator_url": "not-a-url", "token": "x"},
|
||||
} {
|
||||
rec, _ := postJSON(t, base, "/api/config", body)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("body %v: got %d, want 400", body, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWizardStartStopLifecycle(t *testing.T) {
|
||||
sup := &fakeSup{}
|
||||
_, base := newTestServer(t, sup)
|
||||
|
||||
// Starting without a saved config is rejected.
|
||||
rec, _ := postJSON(t, base, "/api/start", map[string]any{})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("start without config: got %d, want 400", rec.Code)
|
||||
}
|
||||
|
||||
postJSON(t, base, "/api/config", map[string]any{
|
||||
"coordinator_url": "http://127.0.0.1:8080", "token": "t", "work_dir": ".",
|
||||
})
|
||||
rec, data := postJSON(t, base, "/api/start", map[string]any{})
|
||||
if rec.Code != http.StatusOK || int(data["pid"].(float64)) != 4242 {
|
||||
t.Errorf("start: got %d %v, want 200 pid 4242", rec.Code, data)
|
||||
}
|
||||
if !sup.started {
|
||||
t.Error("supervisor never started the worker")
|
||||
}
|
||||
|
||||
// Status reflects the running state.
|
||||
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, base+"/api/status", nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
var status map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&status)
|
||||
if status["running"] != true || status["pid"] != float64(4242) {
|
||||
t.Errorf("status = %v, want running pid 4242", status)
|
||||
}
|
||||
|
||||
rec, _ = postJSON(t, base, "/api/stop", map[string]any{})
|
||||
if rec.Code != http.StatusOK || !sup.stopped {
|
||||
t.Errorf("stop: got %d stopped=%v, want 200/true", rec.Code, sup.stopped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWizardStatusPrefillsSavedConfig(t *testing.T) {
|
||||
sup := &fakeSup{}
|
||||
_, base := newTestServer(t, sup)
|
||||
postJSON(t, base, "/api/config", map[string]any{
|
||||
"coordinator_url": "http://10.0.0.5:8080", "worker_key": "smk_abc", "work_dir": "/w", "worker_name": "n1",
|
||||
})
|
||||
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, base+"/api/status", nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
var status struct {
|
||||
ConfigPresent bool `json:"config_present"`
|
||||
WorkerName string `json:"worker_name"`
|
||||
Coordinator string `json:"coordinator"`
|
||||
TokenSet bool `json:"token_set"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&status)
|
||||
if !status.ConfigPresent || status.WorkerName != "n1" || status.Coordinator != "http://10.0.0.5:8080" || !status.TokenSet {
|
||||
t.Errorf("status = %+v", status)
|
||||
}
|
||||
// The secret must never appear in the status projection.
|
||||
if strings.Contains(strings.ToLower(mustJSON(t, status)), "smk_abc") {
|
||||
t.Error("status leaks the worker key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckCoordinatorReachable(t *testing.T) {
|
||||
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/health" {
|
||||
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer stub.Close()
|
||||
|
||||
report := agent.CheckCoordinator(context.Background(), stub.URL, 5*time.Second)
|
||||
if !report.Coordinator.OK {
|
||||
t.Errorf("coordinator check = %+v, want ok", report.Coordinator)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckCoordinatorUnreachable(t *testing.T) {
|
||||
report := agent.CheckCoordinator(context.Background(), "http://127.0.0.1:1", 2*time.Second)
|
||||
if report.Coordinator.OK {
|
||||
t.Error("unreachable coordinator reported ok")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigFileDefaultsAndEnvOverride(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.json")
|
||||
file := agent.ConfigFile{
|
||||
CoordinatorURL: "http://coord:8080",
|
||||
Token: "file-token",
|
||||
WorkDir: "/w",
|
||||
CPUCount: 4,
|
||||
}
|
||||
if err := agent.SaveConfigFile(path, file); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("COORDINATOR_URL", "http://env:9090")
|
||||
t.Setenv("WORKER_AUTH_TOKEN", "")
|
||||
config, err := agent.LoadConfigFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.CoordinatorURL != "http://env:9090" {
|
||||
t.Errorf("env must win: %s", config.CoordinatorURL)
|
||||
}
|
||||
if config.Token != "file-token" {
|
||||
t.Errorf("token = %q, want the file value", config.Token)
|
||||
}
|
||||
if config.CPUCount != 4 {
|
||||
t.Errorf("cpu = %d", config.CPUCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SciMesh Worker · Setup</title>
|
||||
<style>
|
||||
:root{--bg:#0b0e13;--panel:#11151d;--panel-2:#161c27;--border:#1f2634;--border-soft:#181f2b;--text:#e9ecf3;--text-2:#98a2b5;--text-3:#5d6879;--accent:#5b8cff;--accent-soft:#5b8cff1f;--accent-strong:#3f6fe0;--green:#3fce8a;--green-soft:#3fce8a1a;--amber:#e5b64f;--amber-soft:#e5b64f1a;--red:#f2647c;--red-soft:#f2647c1a;--mono:ui-monospace,"SF Mono",SFMono-Regular,Menlo,monospace;color-scheme:dark}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:radial-gradient(900px 500px at 50% -180px,#16233d66,transparent),var(--bg);color:var(--text);font:14px/1.55 Inter,-apple-system,"Segoe UI",Roboto,sans-serif;-webkit-font-smoothing:antialiased;min-height:100vh}
|
||||
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
|
||||
input{font:inherit;color:var(--text);background:var(--panel-2);border:1px solid var(--border);border-radius:9px;padding:10px 13px;width:100%;outline:none;transition:border-color .12s,box-shadow .12s}
|
||||
input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
|
||||
input::placeholder{color:var(--text-3)}
|
||||
code{font-family:var(--mono);font-size:.86em}
|
||||
.shell{max-width:660px;margin:0 auto;padding:44px 22px 70px}
|
||||
.brand{display:flex;align-items:center;justify-content:center;gap:11px;margin-bottom:8px}
|
||||
.brand-mark{display:grid;place-items:center;width:34px;height:34px;border-radius:10px;background:linear-gradient(135deg,#5b8cff,#7c5cff);box-shadow:0 4px 16px #5b8cff40}
|
||||
.brand-mark svg{width:18px;height:18px;stroke:#fff}
|
||||
.brand-name{font-weight:700;font-size:16px;letter-spacing:-.01em}
|
||||
.brand-name span{color:var(--text-3);font-weight:500}
|
||||
.tagline{text-align:center;color:var(--text-3);font-size:12.5px;margin-bottom:34px}
|
||||
.tagline code{color:var(--text-2)}
|
||||
.steps{display:flex;align-items:center;justify-content:center;gap:0;margin-bottom:30px}
|
||||
.step{display:flex;flex-direction:column;align-items:center;gap:7px;width:96px}
|
||||
.step-dot{display:grid;place-items:center;width:30px;height:30px;border-radius:50%;border:1.5px solid var(--border);background:var(--panel);color:var(--text-3);font-size:12.5px;font-weight:700;transition:all .2s}
|
||||
.step-label{font-size:11px;font-weight:600;color:var(--text-3);letter-spacing:.02em}
|
||||
.step.active .step-dot{border-color:var(--accent);background:var(--accent-soft);color:var(--accent);box-shadow:0 0 0 4px #5b8cff14}
|
||||
.step.active .step-label{color:var(--text)}
|
||||
.step.done .step-dot{border-color:var(--green);background:var(--green-soft);color:var(--green)}
|
||||
.step.done .step-label{color:var(--text-2)}
|
||||
.step-line{flex:1;max-width:44px;height:1.5px;background:var(--border);margin:0 6px 22px;position:relative;overflow:hidden}
|
||||
.step-line.done:after{content:"";position:absolute;inset:0;background:var(--green)}
|
||||
.card{background:var(--panel);border:1px solid var(--border-soft);border-radius:15px;padding:26px 28px;box-shadow:0 24px 60px #0000004d}
|
||||
.card h1{font-size:18px;font-weight:700;letter-spacing:-.02em;margin-bottom:4px}
|
||||
.card .sub{color:var(--text-2);font-size:13px;margin-bottom:22px}
|
||||
.field{margin-bottom:16px}
|
||||
.field label{display:block;font-size:12px;font-weight:650;letter-spacing:.04em;text-transform:uppercase;color:var(--text-3);margin-bottom:7px}
|
||||
.field .hint{margin-top:6px;font-size:12px;color:var(--text-3)}
|
||||
.field .hint code{color:var(--text-2)}
|
||||
.radio-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
||||
.radio-card{border:1px solid var(--border);border-radius:11px;padding:13px 14px;cursor:pointer;transition:all .13s;background:var(--panel-2)}
|
||||
.radio-card:hover{border-color:#2a3446}
|
||||
.radio-card.sel{border-color:var(--accent);background:var(--accent-soft);box-shadow:0 0 0 3px #5b8cff14}
|
||||
.radio-card b{display:flex;align-items:center;gap:8px;font-size:13.5px}
|
||||
.radio-card b svg{width:15px;height:15px;stroke:var(--accent)}
|
||||
.radio-card p{margin-top:4px;font-size:12px;color:var(--text-2)}
|
||||
.check-row{display:flex;align-items:center;gap:12px;padding:11px 14px;border:1px solid var(--border-soft);border-radius:10px;margin-bottom:9px;background:var(--panel-2)}
|
||||
.check-ic{display:grid;place-items:center;width:24px;height:24px;border-radius:50%;flex:none}
|
||||
.check-ic svg{width:13px;height:13px;stroke-width:2.6}
|
||||
.check-ok{background:var(--green-soft)}.check-ok svg{stroke:var(--green)}
|
||||
.check-bad{background:var(--red-soft)}.check-bad svg{stroke:var(--red)}
|
||||
.check-wait{background:#ffffff10}.check-wait svg{stroke:var(--text-3)}
|
||||
.check-row b{font-size:13.5px;font-weight:600}
|
||||
.check-row span{display:block;font-size:12px;color:var(--text-3)}
|
||||
.check-row .ms{margin-left:auto;font:11.5px var(--mono);color:var(--text-3)}
|
||||
.actions{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:24px}
|
||||
.btn{display:inline-flex;align-items:center;gap:8px;border-radius:9px;padding:10px 18px;font-weight:650;font-size:13.5px;border:1px solid transparent;transition:all .13s}
|
||||
.btn svg{width:15px;height:15px;stroke:currentColor}
|
||||
.btn-primary{background:var(--accent);color:#0a1222}
|
||||
.btn-primary:hover{background:var(--accent-strong);color:#fff}
|
||||
.btn-ghost{border-color:var(--border);color:var(--text-2);background:var(--panel-2)}
|
||||
.btn-ghost:hover{color:var(--text);border-color:#2a3446}
|
||||
.btn-danger{background:var(--red-soft);color:var(--red)}
|
||||
.btn-lg{padding:12px 24px;font-size:14.5px;border-radius:10px}
|
||||
.link{color:var(--text-3);font-size:13px}
|
||||
.link:hover{color:var(--text)}
|
||||
.status-head{display:flex;align-items:center;gap:14px;margin-bottom:22px}
|
||||
.pulse{position:relative;width:12px;height:12px;border-radius:50%;background:var(--green);flex:none}
|
||||
.pulse:after{content:"";position:absolute;inset:-5px;border-radius:50%;border:2px solid var(--green);opacity:.5;animation:ping 1.6s ease-out infinite}
|
||||
@keyframes ping{from{transform:scale(.6);opacity:.7}to{transform:scale(1.4);opacity:0}}
|
||||
.status-head h1{font-size:19px}
|
||||
.status-head .sub{font-size:12.5px;color:var(--text-3)}
|
||||
.stat-row{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:18px}
|
||||
.stat{background:var(--panel-2);border:1px solid var(--border-soft);border-radius:11px;padding:12px 14px}
|
||||
.stat b{display:block;font-size:20px;font-weight:700;letter-spacing:-.02em}
|
||||
.stat span{font-size:11px;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:var(--text-3)}
|
||||
.stat.bad b{color:var(--red)}
|
||||
.logbox{background:#0a0d12;border:1px solid var(--border-soft);border-radius:11px;padding:14px 16px;font:12px/1.7 var(--mono);color:#8fa3bf;max-height:210px;overflow-y:auto;white-space:pre-wrap;word-break:break-word}
|
||||
.meta-line{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}
|
||||
.chip{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--border);border-radius:99px;padding:4px 11px;font-size:12px;color:var(--text-2);background:var(--panel-2)}
|
||||
.chip svg{width:12px;height:12px;stroke:var(--text-3)}
|
||||
.wizard-page{display:none}.wizard-page.active{display:block;animation:fade .18s ease}
|
||||
@keyframes fade{from{opacity:0;transform:translateY(5px)}to{opacity:1}}
|
||||
.error-strip{background:var(--red-soft);border:1px solid #f2647c33;border-radius:9px;padding:9px 12px;font-size:12.5px;color:#ffb3c0;margin-bottom:14px}
|
||||
.spinner{display:inline-block;width:13px;height:13px;border:2px solid var(--text-3);border-top-color:transparent;border-radius:50%;animation:spin .7s linear infinite;vertical-align:-2px;margin-right:7px}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
.checks{padding-bottom:6px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<div class="brand">
|
||||
<div class="brand-mark"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round"><circle cx="6" cy="6" r="2.4"/><circle cx="18" cy="7" r="2.4"/><circle cx="12" cy="17" r="2.4"/><path d="M8 7.5l7.6-1M7 8.3l3.7 6.5M16.6 9.2l-3.2 5.6"/></svg></div>
|
||||
<div class="brand-name">SciMesh <span>· Worker setup</span></div>
|
||||
</div>
|
||||
<p class="tagline">Local wizard served by <code>worker-agent setup</code> · <code>127.0.0.1</code></p>
|
||||
|
||||
<!-- ═══ WIZARD VIEW ═══ -->
|
||||
<div id="view-wizard">
|
||||
<div class="steps" id="steps">
|
||||
<div class="step active" id="st1"><div class="step-dot">1</div><div class="step-label">Connect</div></div>
|
||||
<div class="step-line" id="sl1"></div>
|
||||
<div class="step" id="st2"><div class="step-dot">2</div><div class="step-label">Machine</div></div>
|
||||
<div class="step-line" id="sl2"></div>
|
||||
<div class="step" id="st3"><div class="step-dot">3</div><div class="step-label">Check</div></div>
|
||||
<div class="step-line" id="sl3"></div>
|
||||
<div class="step" id="st4"><div class="step-dot">4</div><div class="step-label">Run</div></div>
|
||||
</div>
|
||||
<div id="error-box"></div>
|
||||
|
||||
<!-- step 1: connect -->
|
||||
<div class="wizard-page active" id="wp1">
|
||||
<div class="card">
|
||||
<h1>Connect to a coordinator</h1>
|
||||
<p class="sub">The coordinator hands out work and collects results. Ask your cluster admin for its address.</p>
|
||||
<div class="field"><label>Coordinator URL</label><input id="in-url" placeholder="http://192.168.1.10:8080"><p class="hint">For a served instance this is the address printed by <code>coordinator serve</code>.</p></div>
|
||||
<div class="field"><label>Authentication</label>
|
||||
<div class="radio-grid" id="auth-grid">
|
||||
<div class="radio-card sel" data-mode="token"><b><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>Cluster token</b><p>Serve instances: one token for every worker.</p></div>
|
||||
<div class="radio-card" data-mode="key"><b><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><circle cx="8" cy="14" r="4"/><path d="M10.8 11.2L20 2M15 4l3 3"/></svg>Worker key</b><p>Shared clusters: a key tied to your account.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field" id="token-field"><label>Token</label><input id="in-token" type="password" placeholder="paste the token from coordinator token"><p class="hint">The admin can copy it from <code>coordinator token</code> on the server.</p></div>
|
||||
<div class="field" id="key-field" style="display:none"><label>Worker key + userservice URL</label><input id="in-key" type="password" placeholder="smk_…"><input id="in-users" placeholder="http://userservice-host:8081" style="margin-top:8px"><p class="hint">Create a key in the coordinator UI: Users → worker keys.</p></div>
|
||||
<div class="actions"><span class="link" id="l1">Step 1 of 4</span><button class="btn btn-primary" id="b1">Continue →</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- step 2: machine -->
|
||||
<div class="wizard-page" id="wp2">
|
||||
<div class="card">
|
||||
<h1>This machine</h1>
|
||||
<p class="sub">Where tasks run and how the machine appears in the cluster.</p>
|
||||
<div class="field"><label>Worker name</label><input id="in-name" placeholder="auto-detected"><p class="hint">Shown in the coordinator’s worker list.</p></div>
|
||||
<div class="field"><label>Work directory</label><input id="in-dir" placeholder="./scimesh-agent-data"><p class="hint">Datasets and shard results live here. ~1 GB free space recommended.</p></div>
|
||||
<div class="field"><label>Compute resources advertised</label>
|
||||
<div class="radio-grid">
|
||||
<div class="radio-card sel" data-cpu="auto"><b>Auto</b><p>Detect the machine’s CPU count.</p></div>
|
||||
<div class="radio-card" data-cpu="custom"><b>Custom…</b><p>Limit what this machine advertises.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field" id="cpu-field" style="display:none"><label>CPU count</label><input id="in-cpu" type="number" min="1" value="1"></div>
|
||||
<div class="actions"><button class="btn btn-ghost" id="b2b">← Back</button><button class="btn btn-primary" id="b2">Continue →</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- step 3: preflight -->
|
||||
<div class="wizard-page" id="wp3">
|
||||
<div class="card">
|
||||
<h1>Preflight check</h1>
|
||||
<p class="sub">Making sure this machine can reach the coordinator and run SciMesh workloads.</p>
|
||||
<div class="checks" id="checks"></div>
|
||||
<div class="actions"><button class="btn btn-ghost" id="b3b">← Back</button><button class="btn btn-primary" id="b3" disabled>Continue anyway →</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- step 4: run -->
|
||||
<div class="wizard-page" id="wp4">
|
||||
<div class="card" style="text-align:center;padding:40px 28px">
|
||||
<div class="brand-mark" style="margin:0 auto 18px;width:46px;height:46px;border-radius:13px"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" style="width:22px;height:22px"><path d="M6 4l14 8-14 8V4z"/></svg></div>
|
||||
<h1 style="font-size:20px">Ready to join the cluster</h1>
|
||||
<p class="sub" style="max-width:380px;margin:8px auto 26px">Configuration will be saved and the worker started as a background process.</p>
|
||||
<div class="actions" style="justify-content:space-between;margin-top:30px"><button class="btn btn-ghost" id="b4b">← Back</button><button class="btn btn-primary btn-lg" id="b4">Start worker</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ STATUS VIEW ═══ -->
|
||||
<div id="view-status" style="display:none">
|
||||
<div class="card">
|
||||
<div class="status-head">
|
||||
<div class="pulse" id="st-pulse"></div>
|
||||
<div><h1 id="st-title">Worker is working</h1><div class="sub" id="st-sub">—</div></div>
|
||||
<button class="btn btn-danger" id="st-stop" style="margin-left:auto"><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>Stop</button>
|
||||
</div>
|
||||
<div class="meta-line" id="st-meta"></div>
|
||||
<div class="logbox" id="st-log"></div>
|
||||
<div class="actions"><span class="link" id="st-cfg">—</span><button class="btn btn-ghost" id="st-reconfig">Reconfigure…</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $=id=>document.getElementById(id);
|
||||
let state={mode:'token',cpu:'auto'};
|
||||
let checksOk=false;
|
||||
|
||||
function err(msg){$('error-box').innerHTML=msg?'<div class="error-strip">'+msg+'</div>':''}
|
||||
function goto(n){
|
||||
['wp1','wp2','wp3','wp4'].forEach((id,i)=>$(id).classList.toggle('active',i===n-1));
|
||||
for(let i=1;i<=4;i++){
|
||||
const st=$('st'+i);
|
||||
st.classList.toggle('done',i<n);st.classList.toggle('active',i===n);
|
||||
st.querySelector('.step-dot').textContent=i<n?'✓':i;
|
||||
if(i<4)$('sl'+i).classList.toggle('done',i<n);
|
||||
}
|
||||
err('');
|
||||
}
|
||||
document.querySelectorAll('#auth-grid .radio-card').forEach(c=>c.addEventListener('click',()=>{
|
||||
document.querySelectorAll('#auth-grid .radio-card').forEach(x=>x.classList.remove('sel'));
|
||||
c.classList.add('sel');state.mode=c.dataset.mode;
|
||||
$('token-field').style.display=state.mode==='token'?'':'none';
|
||||
$('key-field').style.display=state.mode==='key'?'':'none';
|
||||
}));
|
||||
document.querySelectorAll('#wp2 .radio-card').forEach(c=>c.addEventListener('click',()=>{
|
||||
c.parentElement.querySelectorAll('.radio-card').forEach(x=>x.classList.remove('sel'));
|
||||
c.classList.add('sel');state.cpu=c.dataset.cpu;
|
||||
$('cpu-field').style.display=state.cpu==='custom'?'':'none';
|
||||
}));
|
||||
|
||||
async function postJSON(path,body){
|
||||
const r=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
||||
const data=await r.json().catch(()=>({}));
|
||||
return {status:r.status,data};
|
||||
}
|
||||
function draftConfig(){
|
||||
return {
|
||||
coordinator_url:$('in-url').value.trim(),
|
||||
token:state.mode==='token'?$('in-token').value.trim():'',
|
||||
worker_key:state.mode==='key'?$('in-key').value.trim():'',
|
||||
userservice_url:state.mode==='key'?$('in-users').value.trim():'',
|
||||
work_dir:$('in-dir').value.trim(),
|
||||
worker_name:$('in-name').value.trim(),
|
||||
cpu_count:state.cpu==='custom'?parseInt($('in-cpu').value||'1',10):0
|
||||
};
|
||||
}
|
||||
|
||||
$('b1').onclick=()=>{
|
||||
const c=draftConfig();
|
||||
if(!c.coordinator_url){err('Enter the coordinator URL.');return}
|
||||
if(state.mode==='token'&&!c.token){err('Enter the cluster token.');return}
|
||||
if(state.mode==='key'&&!c.worker_key){err('Enter the worker key.');return}
|
||||
goto(2);
|
||||
};
|
||||
$('b2b').onclick=()=>goto(1);
|
||||
$('b2').onclick=()=>{goto(3);runChecks()};
|
||||
$('b3b').onclick=()=>goto(2);
|
||||
$('b3').onclick=()=>goto(4);
|
||||
$('b4b').onclick=()=>goto(3);
|
||||
$('b4').onclick=async()=>{
|
||||
const c=draftConfig();
|
||||
const r=await postJSON('/api/config',c);
|
||||
if(r.status!==200){err('Could not save the configuration: '+(r.data.error||'unknown error'));return}
|
||||
const s=await postJSON('/api/start',{});
|
||||
if(s.status!==200){err('Could not start the worker: '+(s.data.error||'unknown error'));return}
|
||||
showStatus();
|
||||
};
|
||||
|
||||
const checkRow=(name,ok,detail,ms)=>
|
||||
'<div class="check-row"><div class="check-ic '+(ok===null?'check-wait':ok?'check-ok':'check-bad')+'"><svg viewBox="0 0 24 24" fill="none" stroke-linecap="round">'+(ok===null?'<path d="M12 7v5l3 3"/>':ok?'<path d="M4 12l5 5L20 6"/>':'<path d="M6 6l12 12M18 6L6 18"/>')+'</svg></div><div><b>'+name+'</b><span>'+(detail||'')+'</span></div>'+(ms?'<span class="ms">'+ms+' ms</span>':'')+'</div>';
|
||||
async function runChecks(){
|
||||
const box=$('checks');
|
||||
box.innerHTML=checkRow('Coordinator reachable','',null,null)+checkRow('Python 3','',null,null)+checkRow('scimesh package','',null,null);
|
||||
const r=await postJSON('/api/test',draftConfig());
|
||||
box.innerHTML='';
|
||||
checksOk=true;
|
||||
const items=[r.data.coordinator,r.data.python,r.data.scimesh];
|
||||
for(const item of items){
|
||||
if(item&&!item.ok)checksOk=false;
|
||||
box.insertAdjacentHTML('beforeend',checkRow(item?item.name:'?',item?item.ok:null,item?item.detail:'',item?item.latency_ms:null));
|
||||
}
|
||||
$('b3').disabled=!checksOk;
|
||||
}
|
||||
|
||||
async function showStatus(){
|
||||
$('view-wizard').style.display='none';
|
||||
$('view-status').style.display='block';
|
||||
await refreshStatus();
|
||||
setInterval(refreshStatus,2000);
|
||||
}
|
||||
async function refreshStatus(){
|
||||
const r=await fetch('/api/status');
|
||||
if(!r.ok)return;
|
||||
const v=await r.json();
|
||||
$('st-pulse').style.background=v.running?'var(--green)':'var(--text-3)';
|
||||
$('st-pulse').style.animation=v.running?'':'none';
|
||||
$('st-title').textContent=v.running?(v.worker_name||'Worker')+' is working':(v.worker_name||'Worker')+' is stopped';
|
||||
$('st-sub').textContent='pid '+(v.pid||'—')+' · config '+(v.config_present?v.config_path:'not saved yet');
|
||||
$('st-cfg').textContent='Configuration: '+(v.config_present?v.config_path:'—');
|
||||
const meta=$('st-meta');
|
||||
meta.innerHTML='';
|
||||
if(v.coordinator){const c=document.createElement('span');c.className='chip';c.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke-width="2"><circle cx="12" cy="12" r="9"/></svg>'+v.coordinator;meta.append(c)}
|
||||
if(v.work_dir){const d=document.createElement('span');d.className='chip';d.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke-width="2"><path d="M4 6h16M4 12h16M4 18h16"/></svg>'+v.work_dir;meta.append(d)}
|
||||
if(!v.token_set){const t=document.createElement('span');t.className='chip';t.style.color='var(--amber)';t.textContent='no credential set';meta.append(t)}
|
||||
const logs=await fetch('/api/logs?tail=200');
|
||||
const lv=await logs.json();
|
||||
$('st-log').textContent=lv.log||'(no log yet — the worker writes here once started)';
|
||||
}
|
||||
$('st-stop').onclick=async()=>{await postJSON('/api/stop',{});refreshStatus()};
|
||||
$('st-reconfig').onclick=()=>{
|
||||
$('view-status').style.display='none';
|
||||
$('view-wizard').style.display='block';
|
||||
goto(1);
|
||||
};
|
||||
|
||||
// Prefill from a saved configuration, then decide which view to show.
|
||||
(async()=>{
|
||||
const r=await fetch('/api/status');
|
||||
const v=await r.json();
|
||||
if(v.config_present){
|
||||
$('in-url').value=v.coordinator||'';
|
||||
$('in-dir').value=v.work_dir||'';
|
||||
$('in-name').value=v.worker_name||'';
|
||||
}
|
||||
if(v.running)showStatus();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -18,4 +18,5 @@ var (
|
||||
ErrResultConflict = errors.New("different result already recorded")
|
||||
ErrInvalidInput = errors.New("invalid input")
|
||||
ErrTaskNotLeased = errors.New("task is not currently leased")
|
||||
ErrWorkloadDisabled = errors.New("workload is disabled")
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"io/fs"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -82,6 +83,12 @@ type Config struct {
|
||||
// On by default so a downloaded binary provisions its own database; set
|
||||
// AUTO_MIGRATE=false when an operator manages migrations out of band.
|
||||
AutoMigrate bool
|
||||
// DatabaseEngine selects the storage backend: "sqlite" (embedded, the
|
||||
// single-binary default) or "postgres" (cluster deployments). The
|
||||
// postgres engine requires DATABASE_URL.
|
||||
DatabaseEngine string
|
||||
// DBPath is the sqlite database file (engine=sqlite only).
|
||||
DBPath string
|
||||
}
|
||||
|
||||
// Load reads the environment and fails fast on anything required-but-missing
|
||||
@@ -128,8 +135,16 @@ func LoadConfig() (Config, error) {
|
||||
WorkerOfflineAfter: 1 * time.Minute,
|
||||
}
|
||||
|
||||
if cfg.DatabaseURL == "" {
|
||||
return Config{}, fmt.Errorf("DATABASE_URL is required")
|
||||
cfg.DatabaseEngine = getEnv("SCIMESH_DB", "sqlite")
|
||||
switch cfg.DatabaseEngine {
|
||||
case "sqlite", "postgres":
|
||||
default:
|
||||
return Config{}, fmt.Errorf("SCIMESH_DB must be sqlite or postgres")
|
||||
}
|
||||
cfg.DBPath = getEnv("SCIMESH_DB_PATH", filepath.Join(cfg.StorageDir, "scimesh.db"))
|
||||
|
||||
if cfg.DatabaseEngine == "postgres" && cfg.DatabaseURL == "" {
|
||||
return Config{}, fmt.Errorf("DATABASE_URL is required for the postgres engine")
|
||||
}
|
||||
if cfg.UIToken != "" && cfg.Token != "" && cfg.UIToken == cfg.Token {
|
||||
return Config{}, fmt.Errorf("UI_AUTH_TOKEN must differ from the worker auth token")
|
||||
@@ -185,7 +200,6 @@ func LoadConfig() (Config, error) {
|
||||
}
|
||||
cfg.AutoMigrate = parsed
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -314,6 +314,17 @@ func (r *WorkerRepo) MarkStaleOffline(ctx context.Context, cutoff time.Time) (in
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (r *WorkerRepo) SetTrust(ctx context.Context, id uuid.UUID, trust domain.WorkerTrust) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
w, ok := r.workers[id]
|
||||
if !ok {
|
||||
return domain.ErrWorkerNotFound
|
||||
}
|
||||
w.TrustLevel = trust
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- ArtifactRepo --------------------------------------------------------
|
||||
|
||||
type ArtifactRepo struct {
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
|
||||
)
|
||||
|
||||
// AdminReadRepo backs the coordinator admin console: paginated jobs, status
|
||||
// counters, metrics buckets and storage figures. Read-only.
|
||||
type AdminReadRepo struct{ pool *pgxpool.Pool }
|
||||
|
||||
func NewAdminReadRepo(pool *pgxpool.Pool) *AdminReadRepo { return &AdminReadRepo{pool: pool} }
|
||||
|
||||
var _ usecase.AdminReadRepository = (*AdminReadRepo)(nil)
|
||||
|
||||
func (r *AdminReadRepo) ListJobsPaginated(ctx context.Context, status string, limit, offset int) ([]domain.Job, int, error) {
|
||||
if limit < 1 || limit > 100 || offset < 0 {
|
||||
return nil, 0, domain.ErrInvalidInput
|
||||
}
|
||||
countQ := psql.Select("COUNT(*)").From("jobs")
|
||||
listQ := psql.Select(jobColumns...).From("jobs")
|
||||
if status != "" {
|
||||
countQ = countQ.Where(sq.Eq{"status": status})
|
||||
listQ = listQ.Where(sq.Eq{"status": status})
|
||||
}
|
||||
countSQL, args, err := countQ.ToSql()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var total int
|
||||
if err := conn(ctx, r.pool).QueryRow(ctx, countSQL, args...).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count jobs: %w", err)
|
||||
}
|
||||
listSQL, args, err := listQ.OrderBy("created_at DESC", "id DESC").Limit(uint64(limit)).Offset(uint64(offset)).ToSql()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows, err := conn(ctx, r.pool).Query(ctx, listSQL, args...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("list jobs paginated: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
jobs := make([]domain.Job, 0)
|
||||
for rows.Next() {
|
||||
var j domain.Job
|
||||
var statusRaw string
|
||||
if err := rows.Scan(
|
||||
&j.ID, &j.Workload, &j.InputURI, &j.Parameters, &statusRaw, &j.CreatedAt, &j.CompletedAt,
|
||||
&j.InputArtifactID, &j.ResultArtifactID, &j.ErrorCode, &j.ErrorMessage, &j.ReducerStartedAt,
|
||||
&j.OwnerID,
|
||||
); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
j.Status = domain.JobStatus(statusRaw)
|
||||
jobs = append(jobs, j)
|
||||
}
|
||||
return jobs, total, rows.Err()
|
||||
}
|
||||
|
||||
func (r *AdminReadRepo) CountJobsByStatus(ctx context.Context) (map[string]int, error) {
|
||||
rows, err := conn(ctx, r.pool).Query(ctx, "SELECT status, COUNT(*) FROM jobs GROUP BY status")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("count jobs by status: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]int)
|
||||
for rows.Next() {
|
||||
var status string
|
||||
var count int
|
||||
if err := rows.Scan(&status, &count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[status] = count
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *AdminReadRepo) TaskCountsByJobs(ctx context.Context, jobIDs []uuid.UUID) (map[uuid.UUID]map[string]int, error) {
|
||||
out := make(map[uuid.UUID]map[string]int, len(jobIDs))
|
||||
if len(jobIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
sql, args, err := psql.Select("job_id", "status", "COUNT(*)").From("tasks").
|
||||
Where(sq.Eq{"job_id": jobIDs}).GroupBy("job_id", "status").ToSql()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := conn(ctx, r.pool).Query(ctx, sql, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("task counts by jobs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var jobID uuid.UUID
|
||||
var status string
|
||||
var count int
|
||||
if err := rows.Scan(&jobID, &status, &count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out[jobID] == nil {
|
||||
out[jobID] = make(map[string]int)
|
||||
}
|
||||
out[jobID][status] = count
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *AdminReadRepo) JobCountsByDay(ctx context.Context, since time.Time) (map[string]int, error) {
|
||||
rows, err := conn(ctx, r.pool).Query(ctx,
|
||||
"SELECT to_char(date_trunc('day', created_at AT TIME ZONE 'UTC'), 'YYYY-MM-DD') AS day, COUNT(*) FROM jobs WHERE created_at >= $1 GROUP BY 1",
|
||||
since.UTC())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("job counts by day: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]int)
|
||||
for rows.Next() {
|
||||
var day string
|
||||
var count int
|
||||
if err := rows.Scan(&day, &count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[day] = count
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *AdminReadRepo) JobCountsByWorkload(ctx context.Context) (map[string]int, error) {
|
||||
rows, err := conn(ctx, r.pool).Query(ctx, "SELECT workload, COUNT(*) FROM jobs GROUP BY workload")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("job counts by workload: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]int)
|
||||
for rows.Next() {
|
||||
var workload string
|
||||
var count int
|
||||
if err := rows.Scan(&workload, &count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[workload] = count
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *AdminReadRepo) TaskStats(ctx context.Context) (int64, int64, float64, error) {
|
||||
var completed, failed int64
|
||||
var avgSeconds *float64
|
||||
err := conn(ctx, r.pool).QueryRow(ctx, `
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0),
|
||||
AVG(CASE WHEN status = 'completed' AND started_at IS NOT NULL
|
||||
THEN EXTRACT(EPOCH FROM completed_at - started_at) END)::float8
|
||||
FROM tasks`).Scan(&completed, &failed, &avgSeconds)
|
||||
if err != nil {
|
||||
return 0, 0, 0, fmt.Errorf("task stats: %w", err)
|
||||
}
|
||||
var avg float64
|
||||
if avgSeconds != nil {
|
||||
avg = *avgSeconds
|
||||
}
|
||||
return completed, failed, avg, nil
|
||||
}
|
||||
|
||||
func (r *AdminReadRepo) ArtifactSizeByKind(ctx context.Context) (map[string]int64, error) {
|
||||
rows, err := conn(ctx, r.pool).Query(ctx, "SELECT kind, COALESCE(SUM(size_bytes), 0) FROM artifacts GROUP BY kind")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("artifact sizes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]int64)
|
||||
for rows.Next() {
|
||||
var kind string
|
||||
var size int64
|
||||
if err := rows.Scan(&kind, &size); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[kind] = size
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *AdminReadRepo) DatabaseSizeBytes(ctx context.Context) (int64, error) {
|
||||
var size int64
|
||||
if err := conn(ctx, r.pool).QueryRow(ctx, "SELECT pg_database_size(current_database())").Scan(&size); err != nil {
|
||||
return 0, fmt.Errorf("database size: %w", err)
|
||||
}
|
||||
return size, nil
|
||||
}
|
||||
@@ -54,6 +54,8 @@ func expectedMigrationName(version int) string {
|
||||
return "0012_worker_trust.up.sql"
|
||||
case 13:
|
||||
return "0013_task_results.up.sql"
|
||||
case 14:
|
||||
return "0014_workload_settings.up.sql"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
BEGIN;
|
||||
|
||||
DROP TABLE IF EXISTS workload_settings;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,11 @@
|
||||
BEGIN;
|
||||
|
||||
-- Per-workload enable/disable. Absence of a row means "enabled" (the catalog
|
||||
-- default); a row only exists once an admin flipped a workload off or back on.
|
||||
CREATE TABLE workload_settings (
|
||||
workload text NOT NULL PRIMARY KEY,
|
||||
enabled boolean NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
@@ -91,6 +91,24 @@ func (r *WorkerRepo) MarkStaleOffline(ctx context.Context, cutoff time.Time) (in
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
|
||||
func (r *WorkerRepo) SetTrust(ctx context.Context, id uuid.UUID, trust domain.WorkerTrust) error {
|
||||
sql, args, err := psql.Update("workers").
|
||||
SetMap(map[string]any{"trust_level": string(trust), "updated_at": time.Now()}).
|
||||
Where(sq.Eq{"id": id}).
|
||||
ToSql()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tag, err := conn(ctx, r.pool).Exec(ctx, sql, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set worker trust: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return domain.ErrWorkerNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanWorker(row pgx.Row) (*domain.Worker, error) {
|
||||
var (
|
||||
w domain.Worker
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
|
||||
)
|
||||
|
||||
// WorkloadSettingsRepo persists the per-workload enable/disable overrides.
|
||||
// Absence of a row means the workload is enabled (the catalog default).
|
||||
type WorkloadSettingsRepo struct{ pool *pgxpool.Pool }
|
||||
|
||||
func NewWorkloadSettingsRepo(pool *pgxpool.Pool) *WorkloadSettingsRepo {
|
||||
return &WorkloadSettingsRepo{pool: pool}
|
||||
}
|
||||
|
||||
var _ usecase.WorkloadSettingsRepository = (*WorkloadSettingsRepo)(nil)
|
||||
|
||||
func (r *WorkloadSettingsRepo) GetEnabled(ctx context.Context, workload string) (bool, error) {
|
||||
var enabled bool
|
||||
err := conn(ctx, r.pool).QueryRow(ctx,
|
||||
"SELECT enabled FROM workload_settings WHERE workload = $1", workload).Scan(&enabled)
|
||||
if err != nil && err.Error() == "no rows in result set" {
|
||||
return true, nil // no override: catalog default enabled
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("get workload setting: %w", err)
|
||||
}
|
||||
return enabled, nil
|
||||
}
|
||||
|
||||
func (r *WorkloadSettingsRepo) List(ctx context.Context) ([]usecase.WorkloadSetting, error) {
|
||||
rows, err := conn(ctx, r.pool).Query(ctx,
|
||||
"SELECT workload, enabled, updated_at FROM workload_settings ORDER BY workload ASC")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list workload settings: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []usecase.WorkloadSetting
|
||||
for rows.Next() {
|
||||
var s usecase.WorkloadSetting
|
||||
if err := rows.Scan(&s.Workload, &s.Enabled, &s.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *WorkloadSettingsRepo) SetEnabled(ctx context.Context, workload string, enabled bool, now time.Time) error {
|
||||
sql, args, err := psql.Insert("workload_settings").
|
||||
Columns("workload", "enabled", "updated_at").
|
||||
Values(workload, enabled, now).
|
||||
Suffix(`ON CONFLICT (workload) DO UPDATE SET enabled = EXCLUDED.enabled, updated_at = EXCLUDED.updated_at`).
|
||||
ToSql()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := conn(ctx, r.pool).Exec(ctx, sql, args...); err != nil {
|
||||
return fmt.Errorf("set workload setting: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||
)
|
||||
|
||||
func TestWorkloadSettingsRepoRoundTrip(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
repo := NewWorkloadSettingsRepo(db)
|
||||
|
||||
// No override: enabled by default.
|
||||
enabled, err := repo.GetEnabled(ctx, "similarity-search")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !enabled {
|
||||
t.Error("workload without an override must be enabled")
|
||||
}
|
||||
|
||||
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
|
||||
if err := repo.SetEnabled(ctx, "similarity-search", false, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enabled, err = repo.GetEnabled(ctx, "similarity-search")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if enabled {
|
||||
t.Error("workload must be disabled after the override")
|
||||
}
|
||||
|
||||
// Upsert flips it back and updates the timestamp.
|
||||
later := now.Add(time.Hour)
|
||||
if err := repo.SetEnabled(ctx, "similarity-search", true, later); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enabled, err = repo.GetEnabled(ctx, "similarity-search")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !enabled {
|
||||
t.Error("workload must be re-enabled after the upsert")
|
||||
}
|
||||
|
||||
list, err := repo.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list) != 1 || list[0].Workload != "similarity-search" || !list[0].Enabled {
|
||||
t.Errorf("list = %+v, want the single re-enabled override", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerSetTrust(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
repo := NewWorkerRepo(db)
|
||||
|
||||
worker, err := domain.NewWorker("lab-node", []string{"similarity-search"}, fixedTime())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.Insert(ctx, worker); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.SetTrust(ctx, worker.ID, domain.WorkerUntrusted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := repo.Get(ctx, worker.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.TrustLevel != domain.WorkerUntrusted {
|
||||
t.Errorf("trust = %q, want untrusted", got.TrustLevel)
|
||||
}
|
||||
if err := repo.SetTrust(ctx, worker.ID, domain.WorkerTrusted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.SetTrust(ctx, uuid.New(), domain.WorkerTrusted); !errors.Is(err, domain.ErrWorkerNotFound) {
|
||||
t.Errorf("unknown worker trust err = %v, want ErrWorkerNotFound", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
|
||||
)
|
||||
|
||||
// AdminReadRepo backs the coordinator admin console: paginated jobs, status
|
||||
// counters, metrics buckets and storage figures. Read-only.
|
||||
type AdminReadRepo struct{ db *sql.DB }
|
||||
|
||||
func NewAdminReadRepo(db *sql.DB) *AdminReadRepo { return &AdminReadRepo{db: db} }
|
||||
|
||||
var _ usecase.AdminReadRepository = (*AdminReadRepo)(nil)
|
||||
|
||||
func (r *AdminReadRepo) ListJobsPaginated(ctx context.Context, status string, limit, offset int) ([]domain.Job, int, error) {
|
||||
if limit < 1 || limit > 100 || offset < 0 {
|
||||
return nil, 0, domain.ErrInvalidInput
|
||||
}
|
||||
where := ""
|
||||
args := []any{}
|
||||
if status != "" {
|
||||
where = " WHERE status = ?"
|
||||
args = append(args, status)
|
||||
}
|
||||
var total int
|
||||
if err := conn(ctx, r.db).QueryRowContext(ctx, "SELECT COUNT(*) FROM jobs"+where, args...).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count jobs: %w", err)
|
||||
}
|
||||
rows, err := conn(ctx, r.db).QueryContext(ctx,
|
||||
"SELECT "+jobColumns+" FROM jobs"+where+" ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?",
|
||||
append(args, limit, offset)...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("list jobs paginated: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
jobs := make([]domain.Job, 0)
|
||||
for rows.Next() {
|
||||
job, err := scanJob(rows)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
jobs = append(jobs, *job)
|
||||
}
|
||||
return jobs, total, rows.Err()
|
||||
}
|
||||
|
||||
func (r *AdminReadRepo) CountJobsByStatus(ctx context.Context) (map[string]int, error) {
|
||||
rows, err := conn(ctx, r.db).QueryContext(ctx, "SELECT status, COUNT(*) FROM jobs GROUP BY status")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("count jobs by status: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
out := make(map[string]int)
|
||||
for rows.Next() {
|
||||
var status string
|
||||
var count int
|
||||
if err := rows.Scan(&status, &count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[status] = count
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *AdminReadRepo) TaskCountsByJobs(ctx context.Context, jobIDs []uuid.UUID) (map[uuid.UUID]map[string]int, error) {
|
||||
out := make(map[uuid.UUID]map[string]int, len(jobIDs))
|
||||
if len(jobIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
placeholders := make([]string, 0, len(jobIDs))
|
||||
args := make([]any, 0, len(jobIDs))
|
||||
for _, id := range jobIDs {
|
||||
placeholders = append(placeholders, "?")
|
||||
args = append(args, id.String())
|
||||
}
|
||||
rows, err := conn(ctx, r.db).QueryContext(ctx,
|
||||
"SELECT job_id, status, COUNT(*) FROM tasks WHERE job_id IN ("+strings.Join(placeholders, ", ")+") GROUP BY job_id, status",
|
||||
args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("task counts by jobs: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
for rows.Next() {
|
||||
var jobRaw, status string
|
||||
var count int
|
||||
if err := rows.Scan(&jobRaw, &status, &count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
jobID, err := uuid.Parse(jobRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("task counts: parse job id: %w", err)
|
||||
}
|
||||
if out[jobID] == nil {
|
||||
out[jobID] = make(map[string]int)
|
||||
}
|
||||
out[jobID][status] = count
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *AdminReadRepo) JobCountsByDay(ctx context.Context, since time.Time) (map[string]int, error) {
|
||||
// created_at is unix nanos; the bucket is the UTC calendar day.
|
||||
rows, err := conn(ctx, r.db).QueryContext(ctx,
|
||||
"SELECT strftime('%Y-%m-%d', created_at / 1000000000, 'unixepoch') AS day, COUNT(*) FROM jobs WHERE created_at >= ? GROUP BY day",
|
||||
since.UTC().UnixNano())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("job counts by day: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
out := make(map[string]int)
|
||||
for rows.Next() {
|
||||
var day string
|
||||
var count int
|
||||
if err := rows.Scan(&day, &count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[day] = count
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *AdminReadRepo) JobCountsByWorkload(ctx context.Context) (map[string]int, error) {
|
||||
rows, err := conn(ctx, r.db).QueryContext(ctx, "SELECT workload, COUNT(*) FROM jobs GROUP BY workload")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("job counts by workload: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
out := make(map[string]int)
|
||||
for rows.Next() {
|
||||
var workload string
|
||||
var count int
|
||||
if err := rows.Scan(&workload, &count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[workload] = count
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *AdminReadRepo) TaskStats(ctx context.Context) (int64, int64, float64, error) {
|
||||
var completed, failed int64
|
||||
var avgNanos sql.NullFloat64
|
||||
err := conn(ctx, r.db).QueryRowContext(ctx, `
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0),
|
||||
AVG(CASE WHEN status = 'completed' AND started_at IS NOT NULL THEN completed_at - started_at END)
|
||||
FROM tasks`).Scan(&completed, &failed, &avgNanos)
|
||||
if err != nil {
|
||||
return 0, 0, 0, fmt.Errorf("task stats: %w", err)
|
||||
}
|
||||
return completed, failed, avgNanos.Float64 / 1e9, nil
|
||||
}
|
||||
|
||||
func (r *AdminReadRepo) ArtifactSizeByKind(ctx context.Context) (map[string]int64, error) {
|
||||
rows, err := conn(ctx, r.db).QueryContext(ctx, "SELECT kind, COALESCE(SUM(size_bytes), 0) FROM artifacts GROUP BY kind")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("artifact sizes: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
out := make(map[string]int64)
|
||||
for rows.Next() {
|
||||
var kind string
|
||||
var size int64
|
||||
if err := rows.Scan(&kind, &size); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[kind] = size
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *AdminReadRepo) DatabaseSizeBytes(ctx context.Context) (int64, error) {
|
||||
var pageCount, pageSize int64
|
||||
if err := conn(ctx, r.db).QueryRowContext(ctx, "PRAGMA page_count").Scan(&pageCount); err != nil {
|
||||
return 0, fmt.Errorf("page count: %w", err)
|
||||
}
|
||||
if err := conn(ctx, r.db).QueryRowContext(ctx, "PRAGMA page_size").Scan(&pageSize); err != nil {
|
||||
return 0, fmt.Errorf("page size: %w", err)
|
||||
}
|
||||
return pageCount * pageSize, nil
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||
)
|
||||
|
||||
func TestAdminListJobsPaginatedAndCounts(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
jobRepo := NewJobRepo(db)
|
||||
adminRepo := NewAdminReadRepo(db)
|
||||
|
||||
jobs := make([]*domain.Job, 5)
|
||||
for i := range jobs {
|
||||
jobs[i] = seedJob(t, db, 2)
|
||||
}
|
||||
// Two completed, two running, one pending.
|
||||
if err := jobRepo.UpdateStatus(ctx, jobs[0].ID, domain.JobCompleted, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := jobRepo.UpdateStatus(ctx, jobs[1].ID, domain.JobCompleted, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := jobRepo.UpdateStatus(ctx, jobs[2].ID, domain.JobRunning, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := jobRepo.UpdateStatus(ctx, jobs[3].ID, domain.JobRunning, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
all, total, err := adminRepo.ListJobsPaginated(ctx, "", 100, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 5 || len(all) != 5 {
|
||||
t.Errorf("all: total=%d len=%d, want 5/5", total, len(all))
|
||||
}
|
||||
completed, total, err := adminRepo.ListJobsPaginated(ctx, "completed", 100, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 2 || len(completed) != 2 {
|
||||
t.Errorf("completed: total=%d len=%d, want 2/2", total, len(completed))
|
||||
}
|
||||
page, total, err := adminRepo.ListJobsPaginated(ctx, "", 2, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 5 || len(page) != 2 {
|
||||
t.Errorf("page: total=%d len=%d, want 5/2", total, len(page))
|
||||
}
|
||||
|
||||
counts, err := adminRepo.CountJobsByStatus(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if counts["completed"] != 2 || counts["running"] != 2 || counts["pending"] != 1 {
|
||||
t.Errorf("counts = %v, want completed=2 running=2 pending=1", counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminTaskCountsByJobs(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
job := seedJob(t, db, 3)
|
||||
if _, err := db.ExecContext(ctx, "UPDATE tasks SET status = 'completed', result_artifact_id = ? WHERE chunk_index = 0 AND job_id = ?", uuid.NewString(), job.ID.String()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, "UPDATE tasks SET status = 'failed' WHERE chunk_index = 1 AND job_id = ?", job.ID.String()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
counts, err := NewAdminReadRepo(db).TaskCountsByJobs(ctx, []uuid.UUID{job.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := counts[job.ID]
|
||||
if got["completed"] != 1 || got["failed"] != 1 || got["pending"] != 1 {
|
||||
t.Errorf("task counts = %v, want completed=1 failed=1 pending=1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminJobCountsByDay(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
job := seedJob(t, db, 1)
|
||||
// Move the seed job to two days ago; create two more today.
|
||||
old := fixedTime().Add(-48 * time.Hour)
|
||||
if _, err := db.ExecContext(ctx, "UPDATE jobs SET created_at = ? WHERE id = ?", old.UnixNano(), job.ID.String()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seedJob(t, db, 1)
|
||||
seedJob(t, db, 1)
|
||||
|
||||
repo := NewAdminReadRepo(db)
|
||||
counts, err := repo.JobCountsByDay(ctx, fixedTime().Add(-6*24*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
today := fixedTime().UTC().Format("2006-01-02")
|
||||
oldDay := old.UTC().Format("2006-01-02")
|
||||
if counts[today] != 2 {
|
||||
t.Errorf("today count = %d, want 2 (got %v)", counts[today], counts)
|
||||
}
|
||||
if counts[oldDay] != 1 {
|
||||
t.Errorf("old day count = %d, want 1 (got %v)", counts[oldDay], counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminTaskStatsAndStorage(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
repo := NewAdminReadRepo(db)
|
||||
|
||||
// One completed task with a known duration, one failed.
|
||||
job := seedJob(t, db, 2)
|
||||
start := fixedTime().Add(-2 * time.Minute)
|
||||
done := fixedTime().Add(-90 * time.Second)
|
||||
queries := []string{
|
||||
"UPDATE tasks SET status='completed', result_artifact_id=?, started_at=?, completed_at=? WHERE job_id=? AND chunk_index=0",
|
||||
"UPDATE tasks SET status='failed' WHERE job_id=? AND chunk_index=1",
|
||||
}
|
||||
for i, q := range queries {
|
||||
args := []any{uuid.NewString(), start.UnixNano(), done.UnixNano(), job.ID.String()}
|
||||
if i == 1 {
|
||||
args = []any{job.ID.String()}
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, q, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
completed, failed, avg, err := repo.TaskStats(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if completed != 1 || failed != 1 {
|
||||
t.Errorf("stats = completed %d failed %d, want 1/1", completed, failed)
|
||||
}
|
||||
if avg < 29 || avg > 31 {
|
||||
t.Errorf("avg duration = %.1fs, want ~30s", avg)
|
||||
}
|
||||
|
||||
// Artifact sizes by kind.
|
||||
for _, kind := range []string{"input", "shard", "final_result"} {
|
||||
if _, err := db.ExecContext(ctx, "INSERT INTO artifacts (id, job_id, kind, filename, storage_key, content_type, size_bytes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
uuid.NewString(), job.ID.String(), kind, kind+".csv", "key-"+kind, "text/csv", int64(len(kind)*1000), fixedTime().UnixNano()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
sizes, err := repo.ArtifactSizeByKind(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sizes["input"] != 5000 || sizes["shard"] != 5000 || sizes["final_result"] != 12000 {
|
||||
t.Errorf("sizes = %v", sizes)
|
||||
}
|
||||
dbBytes, err := repo.DatabaseSizeBytes(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dbBytes <= 0 {
|
||||
t.Errorf("database size = %d, want > 0", dbBytes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||
)
|
||||
|
||||
// ArtifactRepo implements usecase.ArtifactRepository on SQLite.
|
||||
type ArtifactRepo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewArtifactRepo(db *sql.DB) *ArtifactRepo {
|
||||
return &ArtifactRepo{db: db}
|
||||
}
|
||||
|
||||
const artifactColumns = `id, job_id, task_id, attempt, kind, filename, storage_key,
|
||||
content_type, size_bytes, sha256, created_at`
|
||||
|
||||
// scanArtifact maps one row onto a domain.Artifact.
|
||||
func scanArtifact(row interface{ Scan(dest ...any) error }) (*domain.Artifact, error) {
|
||||
var (
|
||||
a domain.Artifact
|
||||
kind string
|
||||
)
|
||||
var (
|
||||
taskID sql.NullString
|
||||
attempt sql.NullInt64
|
||||
createdAt sql.NullInt64
|
||||
)
|
||||
if err := row.Scan(
|
||||
&a.ID, &a.JobID, &taskID, &attempt, &kind, &a.Filename, &a.StorageKey,
|
||||
&a.ContentType, &a.SizeBytes, &a.SHA256, &createdAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.CreatedAt = decodeTime(createdAt.Int64)
|
||||
a.Kind = domain.ArtifactKind(kind)
|
||||
if taskID.Valid {
|
||||
if id, err := uuid.Parse(taskID.String); err == nil {
|
||||
a.TaskID = &id
|
||||
}
|
||||
}
|
||||
if attempt.Valid {
|
||||
value := int(attempt.Int64)
|
||||
a.Attempt = &value
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (r *ArtifactRepo) Insert(ctx context.Context, a *domain.Artifact) error {
|
||||
_, err := conn(ctx, r.db).ExecContext(ctx, `
|
||||
INSERT INTO artifacts (id, job_id, task_id, attempt, kind, filename, storage_key,
|
||||
content_type, size_bytes, sha256, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
a.ID.String(), a.JobID.String(), nullableUUID(a.TaskID), nullableInt(a.Attempt),
|
||||
string(a.Kind), a.Filename, a.StorageKey, a.ContentType, a.SizeBytes, a.SHA256,
|
||||
encodeTime(a.CreatedAt))
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *ArtifactRepo) Get(ctx context.Context, id uuid.UUID) (*domain.Artifact, error) {
|
||||
row := conn(ctx, r.db).QueryRowContext(ctx,
|
||||
"SELECT "+artifactColumns+" FROM artifacts WHERE id = ?", id.String())
|
||||
artifact, err := scanArtifact(row)
|
||||
return artifact, mapErrNoRows(err, domain.ErrArtifactNotFound)
|
||||
}
|
||||
|
||||
func (r *ArtifactRepo) FindPartialResult(ctx context.Context, taskID uuid.UUID, attempt int) (*domain.Artifact, error) {
|
||||
row := conn(ctx, r.db).QueryRowContext(ctx,
|
||||
"SELECT "+artifactColumns+" FROM artifacts WHERE task_id = ? AND attempt = ? AND kind = ?",
|
||||
taskID.String(), attempt, string(domain.ArtifactPartialResult))
|
||||
artifact, err := scanArtifact(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return artifact, err
|
||||
}
|
||||
|
||||
// nullableInt renders a nilable int as its value, or NULL.
|
||||
func nullableInt(n *int) any {
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
return *n
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||
)
|
||||
|
||||
// JobRepo implements usecase.JobRepository on SQLite.
|
||||
type JobRepo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewJobRepo(db *sql.DB) *JobRepo {
|
||||
return &JobRepo{db: db}
|
||||
}
|
||||
|
||||
const jobColumns = `id, workload, input_uri, parameters, status, created_at, completed_at,
|
||||
input_artifact_id, result_artifact_id, error_code, error_message, reducer_started_at, owner_id`
|
||||
|
||||
// scanJob maps one row onto a domain.Job. Scanned values follow the sqlite
|
||||
// column order exactly: ids are TEXT, parameters JSON TEXT, timestamps unix
|
||||
// nanoseconds (nullable), statuses plain strings.
|
||||
func scanJob(row interface{ Scan(dest ...any) error }) (*domain.Job, error) {
|
||||
var (
|
||||
j domain.Job
|
||||
status string
|
||||
params string
|
||||
)
|
||||
var (
|
||||
createdAt sql.NullInt64
|
||||
completedAt, reducerStartedAt sql.NullInt64
|
||||
inputArtifact, resultArtifact, ownerID sql.NullString
|
||||
errorCode, errorMessage sql.NullString
|
||||
)
|
||||
if err := row.Scan(
|
||||
&j.ID, &j.Workload, &j.InputURI, ¶ms, &status, &createdAt,
|
||||
&completedAt, &inputArtifact, &resultArtifact, &errorCode, &errorMessage,
|
||||
&reducerStartedAt, &ownerID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := decodeJSON(params, &j.Parameters); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
j.Status = domain.JobStatus(status)
|
||||
j.CreatedAt = decodeTime(createdAt.Int64)
|
||||
if completedAt.Valid {
|
||||
value := decodeTime(completedAt.Int64)
|
||||
j.CompletedAt = &value
|
||||
}
|
||||
if reducerStartedAt.Valid {
|
||||
value := decodeTime(reducerStartedAt.Int64)
|
||||
j.ReducerStartedAt = &value
|
||||
}
|
||||
if inputArtifact.Valid {
|
||||
if id, err := uuid.Parse(inputArtifact.String); err == nil {
|
||||
j.InputArtifactID = &id
|
||||
}
|
||||
}
|
||||
if resultArtifact.Valid {
|
||||
if id, err := uuid.Parse(resultArtifact.String); err == nil {
|
||||
j.ResultArtifactID = &id
|
||||
}
|
||||
}
|
||||
if ownerID.Valid {
|
||||
if id, err := uuid.Parse(ownerID.String); err == nil {
|
||||
j.OwnerID = &id
|
||||
}
|
||||
}
|
||||
if errorCode.Valid {
|
||||
j.ErrorCode = &errorCode.String
|
||||
}
|
||||
if errorMessage.Valid {
|
||||
j.ErrorMessage = &errorMessage.String
|
||||
}
|
||||
return &j, nil
|
||||
}
|
||||
|
||||
// Insert runs inside the caller's transaction alongside the job's tasks.
|
||||
func (r *JobRepo) Insert(ctx context.Context, j *domain.Job) error {
|
||||
_, err := conn(ctx, r.db).ExecContext(ctx, `
|
||||
INSERT INTO jobs (id, workload, input_uri, parameters, status, created_at, owner_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
j.ID.String(), j.Workload, j.InputURI, encodeJSON(j.Parameters), string(j.Status),
|
||||
encodeTime(j.CreatedAt), nullableUUID(j.OwnerID))
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *JobRepo) Get(ctx context.Context, id uuid.UUID) (*domain.Job, error) {
|
||||
row := conn(ctx, r.db).QueryRowContext(ctx,
|
||||
"SELECT "+jobColumns+" FROM jobs WHERE id = ?", id.String())
|
||||
job, err := scanJob(row)
|
||||
return job, mapErrNoRows(err, domain.ErrJobNotFound)
|
||||
}
|
||||
|
||||
func (r *JobRepo) ClaimReduction(ctx context.Context, id uuid.UUID, startedAt time.Time) (bool, error) {
|
||||
res, err := conn(ctx, r.db).ExecContext(ctx, `
|
||||
UPDATE jobs SET reducer_started_at = ?
|
||||
WHERE id = ? AND status = ? AND reducer_started_at IS NULL`,
|
||||
encodeTime(startedAt), id.String(), string(domain.JobReducing))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
affected, err := res.RowsAffected()
|
||||
return affected == 1, err
|
||||
}
|
||||
|
||||
func (r *JobRepo) CompleteWithResult(ctx context.Context, id, resultArtifactID uuid.UUID, completedAt time.Time) error {
|
||||
res, err := conn(ctx, r.db).ExecContext(ctx, `
|
||||
UPDATE jobs SET status = ?, result_artifact_id = ?, completed_at = ?,
|
||||
reducer_started_at = NULL, error_code = NULL, error_message = NULL
|
||||
WHERE id = ? AND status = ?`,
|
||||
string(domain.JobCompleted), resultArtifactID.String(), encodeTime(completedAt),
|
||||
id.String(), string(domain.JobReducing))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return domain.ErrJobNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *JobRepo) FailReduction(ctx context.Context, id uuid.UUID, code, message string, completedAt time.Time) error {
|
||||
_, err := conn(ctx, r.db).ExecContext(ctx, `
|
||||
UPDATE jobs SET status = ?, completed_at = ?, error_code = ?, error_message = ?,
|
||||
reducer_started_at = NULL
|
||||
WHERE id = ? AND status = ?`,
|
||||
string(domain.JobFailed), encodeTime(completedAt), code, message,
|
||||
id.String(), string(domain.JobReducing))
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *JobRepo) UpdateStatus(ctx context.Context, id uuid.UUID,
|
||||
status domain.JobStatus, completedAt *time.Time) error {
|
||||
|
||||
res, err := conn(ctx, r.db).ExecContext(ctx,
|
||||
"UPDATE jobs SET status = ?, completed_at = ? WHERE id = ?",
|
||||
string(status), encodeTimePtr(completedAt), id.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return domain.ErrJobNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// nullableUUID renders a nilable UUID as its text, or NULL.
|
||||
func nullableUUID(id *uuid.UUID) any {
|
||||
if id == nil {
|
||||
return nil
|
||||
}
|
||||
return id.String()
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationFiles embed.FS
|
||||
|
||||
var migrationNamePattern = regexp.MustCompile(`^([0-9]+)_[a-z0-9_]+\.sql$`)
|
||||
|
||||
// Migrate applies every embedded migration above the current PRAGMA
|
||||
// user_version watermark, each inside its own transaction. It is idempotent:
|
||||
// the watermark only advances after a migration commits.
|
||||
func Migrate(ctx context.Context, db *sql.DB, log *slog.Logger) error {
|
||||
entries, err := migrationFiles.ReadDir("migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read embedded migrations: %w", err)
|
||||
}
|
||||
type file struct {
|
||||
version int
|
||||
name string
|
||||
}
|
||||
var files []file
|
||||
byVersion := map[int]string{}
|
||||
for _, entry := range entries {
|
||||
match := migrationNamePattern.FindStringSubmatch(entry.Name())
|
||||
if match == nil {
|
||||
continue
|
||||
}
|
||||
version, err := strconv.Atoi(match[1])
|
||||
if err != nil {
|
||||
return fmt.Errorf("migration %q has an invalid version: %w", entry.Name(), err)
|
||||
}
|
||||
if _, duplicate := byVersion[version]; duplicate {
|
||||
return fmt.Errorf("migration version %d is duplicated", version)
|
||||
}
|
||||
body, err := migrationFiles.ReadFile("migrations/" + entry.Name())
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migration %q: %w", entry.Name(), err)
|
||||
}
|
||||
byVersion[version] = string(body)
|
||||
files = append(files, file{version: version, name: entry.Name()})
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return fmt.Errorf("no sqlite migrations are embedded")
|
||||
}
|
||||
sort.Slice(files, func(i, j int) bool { return files[i].version < files[j].version })
|
||||
|
||||
var applied int
|
||||
if err := db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&applied); err != nil {
|
||||
return fmt.Errorf("read schema version: %w", err)
|
||||
}
|
||||
for _, item := range files {
|
||||
if item.version <= applied {
|
||||
continue
|
||||
}
|
||||
if log != nil {
|
||||
log.Info("applying sqlite migration", "version", item.version, "file", item.name)
|
||||
}
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, byVersion[item.version]); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("apply migration %s: %w", item.name, err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", item.version)); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("advance schema version after %s: %w", item.name, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit migration %s: %w", item.name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
-- 0001: core schema. SQLite stores enums as TEXT with CHECK constraints and
|
||||
-- JSON documents as TEXT; timestamps are unix nanoseconds (INTEGER).
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
workload TEXT NOT NULL,
|
||||
input_uri TEXT NOT NULL DEFAULT '',
|
||||
parameters TEXT NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending','running','reducing','completed','failed','cancelled')),
|
||||
created_at INTEGER NOT NULL,
|
||||
completed_at INTEGER,
|
||||
input_artifact_id TEXT,
|
||||
result_artifact_id TEXT,
|
||||
error_code TEXT,
|
||||
error_message TEXT,
|
||||
reducer_started_at INTEGER,
|
||||
owner_id TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
|
||||
chunk_index INTEGER NOT NULL,
|
||||
workload TEXT NOT NULL,
|
||||
input_uri TEXT,
|
||||
input_artifact_id TEXT,
|
||||
input_sha256 TEXT NOT NULL,
|
||||
parameters TEXT NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending','leased','running','completed','failed','cancelled')),
|
||||
attempt INTEGER NOT NULL DEFAULT 0 CHECK (attempt >= 0),
|
||||
max_attempts INTEGER NOT NULL DEFAULT 3 CHECK (max_attempts > 0),
|
||||
lease_owner TEXT,
|
||||
lease_expires_at INTEGER,
|
||||
result_artifact_id TEXT,
|
||||
metrics TEXT,
|
||||
error_code TEXT,
|
||||
error_message TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
started_at INTEGER,
|
||||
completed_at INTEGER,
|
||||
version INTEGER NOT NULL DEFAULT 0,
|
||||
CONSTRAINT uq_tasks_job_chunk UNIQUE (job_id, chunk_index),
|
||||
CONSTRAINT ck_tasks_completed_result CHECK (
|
||||
status <> 'completed' OR (result_artifact_id IS NOT NULL)
|
||||
),
|
||||
CONSTRAINT ck_tasks_leased_owner CHECK (
|
||||
status <> 'leased' OR (lease_owner IS NOT NULL AND lease_expires_at IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_tasks_claim ON tasks (status, lease_expires_at, created_at);
|
||||
CREATE INDEX IF NOT EXISTS ix_tasks_job ON tasks (job_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS artifacts (
|
||||
id TEXT PRIMARY KEY,
|
||||
job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
|
||||
task_id TEXT,
|
||||
attempt INTEGER,
|
||||
kind TEXT NOT NULL
|
||||
CHECK (kind IN ('input','shard','partial_result','final_result','log')),
|
||||
filename TEXT NOT NULL,
|
||||
storage_key TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
sha256 TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
CONSTRAINT uq_partial_result_task_attempt UNIQUE (task_id, attempt)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_artifacts_job ON artifacts (job_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
capabilities TEXT NOT NULL DEFAULT '[]',
|
||||
status TEXT NOT NULL DEFAULT 'online'
|
||||
CHECK (status IN ('online','busy','offline')),
|
||||
owner_id TEXT,
|
||||
trust_level TEXT NOT NULL DEFAULT 'trusted'
|
||||
CHECK (trust_level IN ('trusted','untrusted')),
|
||||
last_heartbeat_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_results (
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
owner_id TEXT NOT NULL,
|
||||
result_sha256 TEXT NOT NULL,
|
||||
result_artifact_id TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (task_id, owner_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_task_results_task ON task_results (task_id);
|
||||
@@ -0,0 +1,8 @@
|
||||
-- 0002: per-workload enable/disable. Absence of a row means "enabled" (the
|
||||
-- catalog default); a row only exists once an admin flipped a workload off or
|
||||
-- back on.
|
||||
CREATE TABLE IF NOT EXISTS workload_settings (
|
||||
workload TEXT NOT NULL PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,172 @@
|
||||
// Package sqlite implements the usecase repository ports on an embedded
|
||||
// SQLite database. It is the single-binary storage backend: no external
|
||||
// service, one file per database, pure-Go driver (modernc.org/sqlite) so the
|
||||
// static release binaries stay static.
|
||||
//
|
||||
// Concurrency model: SQLite allows exactly one writer. Every repository write
|
||||
// runs inside a TxManager transaction, and the database is opened with a
|
||||
// busy_timeout, so concurrent writers serialize instead of failing. The
|
||||
// postgres claim path uses FOR UPDATE SKIP LOCKED; here the same guarantee
|
||||
// comes from the write lock of the surrounding transaction — ClaimNext is
|
||||
// always called inside WithinTx by the usecase layer, so SELECT + UPDATE
|
||||
// cannot interleave.
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
|
||||
)
|
||||
|
||||
// Open opens (and creates when missing) the database file, applying WAL,
|
||||
// foreign keys, and a busy timeout. Callers own the returned handle.
|
||||
func Open(path string) (*sql.DB, error) {
|
||||
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=synchronous(NORMAL)", path)
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite database: %w", err)
|
||||
}
|
||||
if err := db.PingContext(context.Background()); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("ping sqlite database: %w", err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// querier is satisfied by both *sql.DB and *sql.Tx, letting every repository
|
||||
// method run identically inside or outside a transaction.
|
||||
type querier interface {
|
||||
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
|
||||
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
|
||||
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
|
||||
}
|
||||
|
||||
// txKey is an unexported struct type, so no other package can collide with it
|
||||
// or reach the transaction we stash in the context.
|
||||
type txKey struct{}
|
||||
|
||||
// TxManager implements usecase.TxManager.
|
||||
type TxManager struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewTxManager(db *sql.DB) *TxManager {
|
||||
return &TxManager{db: db}
|
||||
}
|
||||
|
||||
var _ usecase.TxManager = (*TxManager)(nil)
|
||||
|
||||
// WithinTx runs fn inside one transaction, committing on success and rolling
|
||||
// back on any error or panic. The transaction travels in the context, the
|
||||
// same pattern as the postgres backend. SQLite write transactions are
|
||||
// serialized by the database's single-writer lock, so a concurrent writer
|
||||
// waits on the busy timeout instead of racing.
|
||||
func (m *TxManager) WithinTx(ctx context.Context, fn func(ctx context.Context) error) error {
|
||||
if _, ok := ctx.Value(txKey{}).(*sql.Tx); ok {
|
||||
return fn(ctx)
|
||||
}
|
||||
tx, err := m.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if err := fn(context.WithValue(ctx, txKey{}, tx)); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// conn returns the transaction bound to ctx, or the database when there is none.
|
||||
func conn(ctx context.Context, db *sql.DB) querier {
|
||||
if tx, ok := ctx.Value(txKey{}).(*sql.Tx); ok {
|
||||
return tx
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// --- JSON and null helpers ------------------------------------------------
|
||||
|
||||
// encodeJSON stores a Go value as JSON text, defaulting to "{}".
|
||||
func encodeJSON(value any) string {
|
||||
if value == nil {
|
||||
return "{}"
|
||||
}
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
// decodeJSON reads a JSON text column into the destination.
|
||||
func decodeJSON(raw any, destination any) error {
|
||||
text, ok := raw.(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal([]byte(text), destination)
|
||||
}
|
||||
|
||||
// encodeTime stores a time as unix nanoseconds (NULL for zero time).
|
||||
func encodeTime(t time.Time) any {
|
||||
if t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return t.UnixNano()
|
||||
}
|
||||
|
||||
// encodeTimePtr stores a nilable time as unix nanoseconds.
|
||||
func encodeTimePtr(t *time.Time) any {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
return t.UnixNano()
|
||||
}
|
||||
|
||||
// decodeTime reads a unix-nanosecond column back into a time.Time.
|
||||
func decodeTime(raw any) time.Time {
|
||||
switch v := raw.(type) {
|
||||
case int64:
|
||||
return time.Unix(0, v).UTC()
|
||||
case int:
|
||||
return time.Unix(0, int64(v)).UTC()
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// nullIfEmpty maps "" to SQL NULL.
|
||||
func nullIfEmpty(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// mapErrNoRows translates sql.ErrNoRows into the domain not-found errors.
|
||||
func mapErrNoRows(err error, notFound error) error {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return notFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
var (
|
||||
_ usecase.JobRepository = (*JobRepo)(nil)
|
||||
_ usecase.TaskRepository = (*TaskRepo)(nil)
|
||||
_ usecase.ArtifactRepository = (*ArtifactRepo)(nil)
|
||||
_ usecase.WorkerRepository = (*WorkerRepo)(nil)
|
||||
_ usecase.TaskResultRepository = (*TaskResultRepo)(nil)
|
||||
_ usecase.UIReadRepository = (*UIReadRepo)(nil)
|
||||
_ usecase.TxManager = (*TxManager)(nil)
|
||||
)
|
||||
@@ -0,0 +1,349 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
|
||||
)
|
||||
|
||||
// newTestDB opens an isolated on-disk database and applies the migrations.
|
||||
func newTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := Migrate(context.Background(), db, nil); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func fixedTime() time.Time {
|
||||
return time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
func seedJob(t *testing.T, db *sql.DB, n int) *domain.Job {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
tx := NewTxManager(db)
|
||||
chunks := make([]domain.ChunkSpec, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
chunks = append(chunks, domain.ChunkSpec{
|
||||
ChunkIndex: i,
|
||||
InputURI: "s3://chunk-" + string(rune('a'+i)),
|
||||
InputSHA256: "sha-" + string(rune('a'+i)),
|
||||
})
|
||||
}
|
||||
job, tasks, err := domain.NewJobWithTasks("similarity_search", "s3://ds", nil, chunks, fixedTime())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tx.WithinTx(ctx, func(ctx context.Context) error {
|
||||
if err := NewJobRepo(db).Insert(ctx, job); err != nil {
|
||||
return err
|
||||
}
|
||||
return NewTaskRepo(db).InsertBatch(ctx, tasks)
|
||||
}); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
return job
|
||||
}
|
||||
|
||||
func TestMigrateIsIdempotent(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
if err := Migrate(ctx, db, nil); err != nil {
|
||||
t.Fatalf("second migrate: %v", err)
|
||||
}
|
||||
var version int
|
||||
if err := db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if version != 2 {
|
||||
t.Errorf("user_version = %d, want 2", version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobRepoRoundTrip(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
job := seedJob(t, db, 1)
|
||||
|
||||
got, err := NewJobRepo(db).Get(ctx, job.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Workload != job.Workload || got.Status != domain.JobPending {
|
||||
t.Errorf("job = %+v", got)
|
||||
}
|
||||
if err := NewJobRepo(db).UpdateStatus(ctx, job.ID, domain.JobRunning, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err = NewJobRepo(db).Get(ctx, job.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Status != domain.JobRunning {
|
||||
t.Errorf("status = %q, want running", got.Status)
|
||||
}
|
||||
if _, err := NewJobRepo(db).Get(ctx, uuid.New()); !errors.Is(err, domain.ErrJobNotFound) {
|
||||
t.Errorf("missing job err = %v, want ErrJobNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimGivesEachTaskToExactlyOneWorker(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedJob(t, db, 3)
|
||||
repo := NewTaskRepo(db)
|
||||
|
||||
claimed := map[uuid.UUID]bool{}
|
||||
for i := 0; i < 3; i++ {
|
||||
task, err := repo.ClaimNext(ctx, usecase.ClaimFilter{
|
||||
Workloads: []string{"similarity_search"},
|
||||
Owner: "w1",
|
||||
Now: fixedTime(),
|
||||
LeaseUntil: fixedTime().Add(time.Minute),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if task == nil {
|
||||
t.Fatal("claim returned nil on a non-empty queue")
|
||||
}
|
||||
if claimed[task.ID] {
|
||||
t.Fatalf("task %s claimed twice", task.ID)
|
||||
}
|
||||
claimed[task.ID] = true
|
||||
if task.Status != domain.TaskLeased || task.Attempt != 1 || task.LeaseOwner == nil || *task.LeaseOwner != "w1" {
|
||||
t.Errorf("task = %+v", task)
|
||||
}
|
||||
}
|
||||
task, err := repo.ClaimNext(ctx, usecase.ClaimFilter{Workloads: []string{"similarity_search"}, Owner: "w1", Now: fixedTime(), LeaseUntil: fixedTime().Add(time.Minute)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if task != nil {
|
||||
t.Fatal("claim must return nil on an empty queue")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRejectsStaleVersion(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedJob(t, db, 1)
|
||||
repo := NewTaskRepo(db)
|
||||
task, err := repo.ClaimNext(ctx, usecase.ClaimFilter{Workloads: nil, Owner: "w1", Now: fixedTime(), LeaseUntil: fixedTime().Add(time.Minute)})
|
||||
if err != nil || task == nil {
|
||||
t.Fatalf("claim: %v", err)
|
||||
}
|
||||
stale := *task
|
||||
task.Status = domain.TaskRunning
|
||||
task.Version++ // as a domain method would have done
|
||||
if err := repo.Update(ctx, task); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stale.Status = domain.TaskCompleted
|
||||
stale.Version++
|
||||
if err := repo.Update(ctx, &stale); !errors.Is(err, domain.ErrLeaseConflict) {
|
||||
t.Errorf("stale update err = %v, want ErrLeaseConflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpireLeasesRequeuesElapsedTasks(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedJob(t, db, 1)
|
||||
repo := NewTaskRepo(db)
|
||||
task, err := repo.ClaimNext(ctx, usecase.ClaimFilter{Workloads: nil, Owner: "w1", Now: fixedTime(), LeaseUntil: fixedTime().Add(-time.Minute)})
|
||||
if err != nil || task == nil {
|
||||
t.Fatalf("claim: %v", err)
|
||||
}
|
||||
affected, err := repo.ExpireLeases(ctx, fixedTime())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(affected) != 1 {
|
||||
t.Fatalf("affected = %v, want 1 job", affected)
|
||||
}
|
||||
task, err = repo.ClaimNext(ctx, usecase.ClaimFilter{Workloads: nil, Owner: "w2", Now: fixedTime(), LeaseUntil: fixedTime().Add(time.Minute)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if task == nil || task.Attempt != 2 {
|
||||
t.Errorf("requeued task = %+v, want attempt 2", task)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpireLeasesFailsAfterFinalAttempt(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
seedJob(t, db, 1)
|
||||
repo := NewTaskRepo(db)
|
||||
for attempt := 1; attempt <= 3; attempt++ {
|
||||
task, err := repo.ClaimNext(ctx, usecase.ClaimFilter{Workloads: nil, Owner: "w1", Now: fixedTime(), LeaseUntil: fixedTime().Add(-time.Minute)})
|
||||
if err != nil || task == nil {
|
||||
t.Fatalf("claim %d: %v", attempt, err)
|
||||
}
|
||||
if _, err := repo.ExpireLeases(ctx, fixedTime()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
task, err := repo.ClaimNext(ctx, usecase.ClaimFilter{Workloads: nil, Owner: "w1", Now: fixedTime(), LeaseUntil: fixedTime().Add(time.Minute)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if task != nil {
|
||||
t.Fatal("exhausted task must not be claimable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactRepoRoundTripAndUniqueness(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
job := seedJob(t, db, 1)
|
||||
repo := NewArtifactRepo(db)
|
||||
attempt := 1
|
||||
artifact, err := domain.NewArtifact(job.ID, nil, domain.ArtifactShard, "shard-0.tsv", "text/tab-separated-values", fixedTime())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
artifact.SetContent("abc123", 42)
|
||||
if err := repo.Insert(ctx, artifact); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := repo.Get(ctx, artifact.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.SHA256 != "abc123" || got.SizeBytes != 42 {
|
||||
t.Errorf("artifact = %+v", got)
|
||||
}
|
||||
|
||||
partialTask := uuid.New()
|
||||
partial, err := domain.NewArtifact(job.ID, &partialTask, domain.ArtifactPartialResult, "p.csv", "text/csv", fixedTime())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
partial.Attempt = &attempt
|
||||
if err := repo.Insert(ctx, partial); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found, err := repo.FindPartialResult(ctx, partialTask, attempt)
|
||||
if err != nil || found == nil {
|
||||
t.Fatalf("find partial: %v", err)
|
||||
}
|
||||
duplicate, err := domain.NewArtifact(job.ID, &partialTask, domain.ArtifactPartialResult, "p2.csv", "text/csv", fixedTime())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
duplicate.Attempt = &attempt
|
||||
if err := repo.Insert(ctx, duplicate); err == nil {
|
||||
t.Fatal("duplicate partial for the same attempt must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerRepoRoundTripAndLiveness(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
repo := NewWorkerRepo(db)
|
||||
worker, err := domain.NewWorker("w1", []string{"similarity-search"}, fixedTime())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.Insert(ctx, worker); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := repo.Get(ctx, worker.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Name != "w1" || len(got.Capabilities) != 1 || got.TrustLevel != domain.WorkerTrusted {
|
||||
t.Errorf("worker = %+v", got)
|
||||
}
|
||||
if err := repo.Touch(ctx, worker.ID, fixedTime().Add(time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
changed, err := repo.MarkStaleOffline(ctx, fixedTime().Add(2*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if changed != 1 {
|
||||
t.Errorf("offline changes = %d, want 1", changed)
|
||||
}
|
||||
got, err = repo.Get(ctx, worker.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Status != domain.WorkerOffline {
|
||||
t.Errorf("status = %q, want offline", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskResultVotes(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
job := seedJob(t, db, 1)
|
||||
repo := NewTaskResultRepo(db)
|
||||
task, err := domain.NewTask(job.ID, 1, "similarity_search", "s3://in", "sha", nil, 3, fixedTime())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := NewTaskRepo(db).InsertBatch(ctx, []*domain.Task{task}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
taskID := task.ID
|
||||
artifactID := uuid.New()
|
||||
ownerA, ownerB := uuid.New(), uuid.New()
|
||||
if err := repo.RecordVote(ctx, taskID, ownerA, "hash", artifactID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.RecordVote(ctx, taskID, ownerB, "hash", artifactID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.RecordVote(ctx, taskID, ownerA, "hash2", artifactID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n, err := repo.CountAgreeing(ctx, taskID, "hash")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("agreeing = %d, want 1 (owner A changed its vote)", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelByJobInvalidatesTasks(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
job := seedJob(t, db, 2)
|
||||
repo := NewTaskRepo(db)
|
||||
task, err := repo.ClaimNext(ctx, usecase.ClaimFilter{Workloads: nil, Owner: "w1", Now: fixedTime(), LeaseUntil: fixedTime().Add(time.Minute)})
|
||||
if err != nil || task == nil {
|
||||
t.Fatalf("claim: %v", err)
|
||||
}
|
||||
cancelled, err := repo.CancelByJob(ctx, job.ID, fixedTime())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cancelled != 2 {
|
||||
t.Errorf("cancelled = %d, want 2", cancelled)
|
||||
}
|
||||
got, err := repo.Get(ctx, task.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Status != domain.TaskCancelled || got.LeaseOwner != nil {
|
||||
t.Errorf("cancelled task = %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||
)
|
||||
|
||||
// Known statuses per entity, so counts are zero-filled and every status is
|
||||
// always present in the metrics (a flat 0 line beats a gap on the dashboard).
|
||||
var (
|
||||
taskStatuses = []string{string(domain.TaskPending), string(domain.TaskLeased), string(domain.TaskRunning), string(domain.TaskCompleted), string(domain.TaskFailed), string(domain.TaskCancelled)}
|
||||
jobStatuses = []string{string(domain.JobPending), string(domain.JobRunning), string(domain.JobReducing), string(domain.JobCompleted), string(domain.JobFailed), string(domain.JobCancelled)}
|
||||
workerStatuses = []string{string(domain.WorkerOnline), string(domain.WorkerBusy), string(domain.WorkerOffline)}
|
||||
)
|
||||
|
||||
// StatsRepo answers the aggregate status counts the business metrics report.
|
||||
type StatsRepo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewStatsRepo(db *sql.DB) *StatsRepo {
|
||||
return &StatsRepo{db: db}
|
||||
}
|
||||
|
||||
// Counts returns status->count maps for tasks, jobs, and workers, each
|
||||
// zero-filled across its known statuses.
|
||||
func (r *StatsRepo) Counts(ctx context.Context) (tasks, jobs, workers map[string]int, err error) {
|
||||
if tasks, err = r.countByStatus(ctx, "tasks", taskStatuses); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if jobs, err = r.countByStatus(ctx, "jobs", jobStatuses); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if workers, err = r.countByStatus(ctx, "workers", workerStatuses); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
return tasks, jobs, workers, nil
|
||||
}
|
||||
|
||||
func (r *StatsRepo) countByStatus(ctx context.Context, table string, known []string) (map[string]int, error) {
|
||||
out := make(map[string]int, len(known))
|
||||
for _, s := range known {
|
||||
out[s] = 0 // zero-fill
|
||||
}
|
||||
// table is a fixed internal constant, never user input — safe to format.
|
||||
rows, err := r.db.QueryContext(ctx, fmt.Sprintf("SELECT status, count(*) FROM %s GROUP BY status", table))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("count %s by status: %w", table, err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
for rows.Next() {
|
||||
var status string
|
||||
var n int
|
||||
if err := rows.Scan(&status, &n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[status] = n
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
|
||||
)
|
||||
|
||||
// TaskRepo implements usecase.TaskRepository on SQLite.
|
||||
type TaskRepo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewTaskRepo(db *sql.DB) *TaskRepo {
|
||||
return &TaskRepo{db: db}
|
||||
}
|
||||
|
||||
const taskColumns = `id, job_id, chunk_index, workload, input_uri, input_artifact_id, input_sha256,
|
||||
parameters, status, attempt, max_attempts, lease_owner, lease_expires_at,
|
||||
result_artifact_id, metrics, error_code, error_message,
|
||||
created_at, started_at, completed_at, version`
|
||||
|
||||
// scanTask maps one row onto a domain.Task.
|
||||
func scanTask(row interface{ Scan(dest ...any) error }) (*domain.Task, error) {
|
||||
var (
|
||||
t domain.Task
|
||||
status string
|
||||
params string
|
||||
metrics sql.NullString
|
||||
)
|
||||
var (
|
||||
inputURI, leaseOwner, errorCode, errorMessage sql.NullString
|
||||
inputArtifact, resultArtifact sql.NullString
|
||||
leaseExpiresAt, startedAt, completedAt sql.NullInt64
|
||||
createdAt sql.NullInt64
|
||||
)
|
||||
if err := row.Scan(
|
||||
&t.ID, &t.JobID, &t.ChunkIndex, &t.Workload, &inputURI, &inputArtifact, &t.InputSHA256,
|
||||
¶ms, &status, &t.Attempt, &t.MaxAttempts, &leaseOwner, &leaseExpiresAt,
|
||||
&resultArtifact, &metrics, &errorCode, &errorMessage,
|
||||
&createdAt, &startedAt, &completedAt, &t.Version,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := decodeJSON(params, &t.Parameters); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if metrics.Valid && metrics.String != "" {
|
||||
if err := decodeJSON(metrics.String, &t.Metrics); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
t.Status = domain.TaskStatus(status)
|
||||
t.CreatedAt = decodeTime(createdAt.Int64)
|
||||
if inputURI.Valid {
|
||||
t.InputURI = inputURI.String
|
||||
}
|
||||
if leaseOwner.Valid {
|
||||
t.LeaseOwner = &leaseOwner.String
|
||||
}
|
||||
if inputArtifact.Valid {
|
||||
if id, err := uuid.Parse(inputArtifact.String); err == nil {
|
||||
t.InputArtifactID = &id
|
||||
}
|
||||
}
|
||||
if resultArtifact.Valid {
|
||||
if id, err := uuid.Parse(resultArtifact.String); err == nil {
|
||||
t.ResultArtifactID = &id
|
||||
}
|
||||
}
|
||||
if leaseExpiresAt.Valid {
|
||||
value := decodeTime(leaseExpiresAt.Int64)
|
||||
t.LeaseExpiresAt = &value
|
||||
}
|
||||
if startedAt.Valid {
|
||||
value := decodeTime(startedAt.Int64)
|
||||
t.StartedAt = &value
|
||||
}
|
||||
if completedAt.Valid {
|
||||
value := decodeTime(completedAt.Int64)
|
||||
t.CompletedAt = &value
|
||||
}
|
||||
if errorCode.Valid {
|
||||
t.ErrorCode = &errorCode.String
|
||||
}
|
||||
if errorMessage.Valid {
|
||||
t.ErrorMessage = &errorMessage.String
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// ClaimNext atomically leases the next eligible task. SQLite has no SKIP
|
||||
// LOCKED: the guarantee comes from the surrounding transaction's write lock —
|
||||
// the usecase layer always calls ClaimNext inside WithinTx, so SELECT + UPDATE
|
||||
// cannot interleave with another claimant.
|
||||
func (r *TaskRepo) ClaimNext(ctx context.Context, f usecase.ClaimFilter) (*domain.Task, error) {
|
||||
workloadClause := ""
|
||||
workloadArgs := []any{}
|
||||
if len(f.Workloads) > 0 {
|
||||
placeholders := make([]string, 0, len(f.Workloads))
|
||||
for _, w := range f.Workloads {
|
||||
placeholders = append(placeholders, "?")
|
||||
workloadArgs = append(workloadArgs, w)
|
||||
}
|
||||
workloadClause = " AND workload IN (" + strings.Join(placeholders, ", ") + ")"
|
||||
}
|
||||
voterClause := ""
|
||||
var voterArg any
|
||||
if f.VoterOwner != nil {
|
||||
voterClause = " AND NOT EXISTS (SELECT 1 FROM task_results tr WHERE tr.task_id = tasks.id AND tr.owner_id = ?)"
|
||||
voterArg = f.VoterOwner.String()
|
||||
}
|
||||
args := append([]any{f.Owner, f.LeaseUntil.UnixNano(), f.Now.UnixNano()}, workloadArgs...)
|
||||
if f.VoterOwner != nil {
|
||||
args = append(args, voterArg)
|
||||
}
|
||||
query := `
|
||||
UPDATE tasks SET
|
||||
status = 'leased',
|
||||
attempt = attempt + 1,
|
||||
lease_owner = ?,
|
||||
lease_expires_at = ?,
|
||||
started_at = COALESCE(started_at, ?),
|
||||
version = version + 1
|
||||
WHERE id IN (
|
||||
SELECT id FROM tasks
|
||||
WHERE status = 'pending' AND attempt < max_attempts` + workloadClause + voterClause + `
|
||||
ORDER BY created_at, chunk_index
|
||||
LIMIT 1
|
||||
)
|
||||
RETURNING ` + taskColumns
|
||||
|
||||
row := conn(ctx, r.db).QueryRowContext(ctx, query, args...)
|
||||
task, err := scanTask(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func (r *TaskRepo) Get(ctx context.Context, id uuid.UUID) (*domain.Task, error) {
|
||||
row := conn(ctx, r.db).QueryRowContext(ctx,
|
||||
"SELECT "+taskColumns+" FROM tasks WHERE id = ?", id.String())
|
||||
task, err := scanTask(row)
|
||||
return task, mapErrNoRows(err, domain.ErrTaskNotFound)
|
||||
}
|
||||
|
||||
// GetForUpdate reads a task. SQLite serializes writers inside a transaction,
|
||||
// so no row lock is needed: the surrounding write transaction already isolates
|
||||
// the read-modify-write sequence.
|
||||
func (r *TaskRepo) GetForUpdate(ctx context.Context, id uuid.UUID) (*domain.Task, error) {
|
||||
return r.Get(ctx, id)
|
||||
}
|
||||
|
||||
// Update writes the mutated entity back under optimistic concurrency.
|
||||
func (r *TaskRepo) Update(ctx context.Context, t *domain.Task) error {
|
||||
res, err := conn(ctx, r.db).ExecContext(ctx, `
|
||||
UPDATE tasks SET
|
||||
status = ?, attempt = ?, lease_owner = ?, lease_expires_at = ?,
|
||||
result_artifact_id = ?, metrics = ?, error_code = ?, error_message = ?,
|
||||
started_at = ?, completed_at = ?, version = ?
|
||||
WHERE id = ? AND version = ?`,
|
||||
string(t.Status), t.Attempt, nullableString(t.LeaseOwner), encodeTimePtr(t.LeaseExpiresAt),
|
||||
nullableUUID(t.ResultArtifactID), nullableMetrics(t.Metrics),
|
||||
nullableString(t.ErrorCode), nullableString(t.ErrorMessage),
|
||||
encodeTimePtr(t.StartedAt), encodeTimePtr(t.CompletedAt), t.Version,
|
||||
t.ID.String(), t.Version-1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return domain.ErrLeaseConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *TaskRepo) InsertBatch(ctx context.Context, tasks []*domain.Task) error {
|
||||
for _, t := range tasks {
|
||||
_, err := conn(ctx, r.db).ExecContext(ctx, `
|
||||
INSERT INTO tasks (id, job_id, chunk_index, workload, input_uri, input_artifact_id,
|
||||
input_sha256, parameters, status, attempt, max_attempts, created_at, version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
t.ID.String(), t.JobID.String(), t.ChunkIndex, t.Workload,
|
||||
nullIfEmpty(t.InputURI), nullableUUID(t.InputArtifactID),
|
||||
t.InputSHA256, encodeJSON(t.Parameters), string(t.Status),
|
||||
t.Attempt, t.MaxAttempts, encodeTime(t.CreatedAt), t.Version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *TaskRepo) ListCompleted(ctx context.Context, jobID uuid.UUID) ([]*domain.Task, error) {
|
||||
rows, err := conn(ctx, r.db).QueryContext(ctx,
|
||||
"SELECT "+taskColumns+" FROM tasks WHERE job_id = ? AND status = ? ORDER BY chunk_index",
|
||||
jobID.String(), string(domain.TaskCompleted))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
var tasks []*domain.Task
|
||||
for rows.Next() {
|
||||
task, err := scanTask(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tasks = append(tasks, task)
|
||||
}
|
||||
return tasks, rows.Err()
|
||||
}
|
||||
|
||||
func (r *TaskRepo) CountByStatus(ctx context.Context, jobID uuid.UUID) (map[domain.TaskStatus]int, error) {
|
||||
rows, err := conn(ctx, r.db).QueryContext(ctx,
|
||||
"SELECT status, count(*) FROM tasks WHERE job_id = ? GROUP BY status", jobID.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
counts := make(map[domain.TaskStatus]int)
|
||||
for rows.Next() {
|
||||
var (
|
||||
status string
|
||||
n int
|
||||
)
|
||||
if err := rows.Scan(&status, &n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
counts[domain.TaskStatus(status)] = n
|
||||
}
|
||||
return counts, rows.Err()
|
||||
}
|
||||
|
||||
func (r *TaskRepo) CancelByJob(ctx context.Context, jobID uuid.UUID, now time.Time) (int64, error) {
|
||||
res, err := conn(ctx, r.db).ExecContext(ctx, `
|
||||
UPDATE tasks SET
|
||||
status = 'cancelled',
|
||||
lease_owner = NULL,
|
||||
lease_expires_at = NULL,
|
||||
error_code = NULL,
|
||||
error_message = NULL,
|
||||
completed_at = ?,
|
||||
version = version + 1
|
||||
WHERE job_id = ? AND status IN ('pending','leased','running')`,
|
||||
now.UnixNano(), jobID.String())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
// ExpireLeases applies the lease-expiry rule set-based and returns the
|
||||
// distinct jobs whose aggregate status may have changed.
|
||||
func (r *TaskRepo) ExpireLeases(ctx context.Context, now time.Time) ([]uuid.UUID, error) {
|
||||
rows, err := conn(ctx, r.db).QueryContext(ctx, `
|
||||
UPDATE tasks SET
|
||||
status = CASE WHEN attempt < max_attempts THEN 'pending' ELSE 'failed' END,
|
||||
lease_owner = NULL,
|
||||
lease_expires_at = NULL,
|
||||
error_code = CASE WHEN attempt >= max_attempts THEN ? ELSE error_code END,
|
||||
error_message = CASE WHEN attempt >= max_attempts THEN ? ELSE error_message END,
|
||||
completed_at = CASE WHEN attempt >= max_attempts THEN ? ELSE completed_at END,
|
||||
version = version + 1
|
||||
WHERE status IN ('leased','running') AND lease_expires_at < ?
|
||||
RETURNING job_id`,
|
||||
domain.ErrCodeLeaseExpired, "lease expired after the final attempt", now.UnixNano(), now.UnixNano())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
var affected []uuid.UUID
|
||||
seen := map[uuid.UUID]bool{}
|
||||
for rows.Next() {
|
||||
var raw string
|
||||
if err := rows.Scan(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if id, err := uuid.Parse(raw); err == nil && !seen[id] {
|
||||
seen[id] = true
|
||||
affected = append(affected, id)
|
||||
}
|
||||
}
|
||||
return affected, rows.Err()
|
||||
}
|
||||
|
||||
// nullableString renders a nilable string, or NULL.
|
||||
func nullableString(s *string) any {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
// nullableMetrics stores nil metrics as NULL, else JSON text.
|
||||
func nullableMetrics(m map[string]any) any {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
return encodeJSON(m)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// TaskResultRepo records and tallies quorum votes for untrusted task results.
|
||||
type TaskResultRepo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewTaskResultRepo(db *sql.DB) *TaskResultRepo {
|
||||
return &TaskResultRepo{db: db}
|
||||
}
|
||||
|
||||
// RecordVote stores (or replaces) one owner's vote for a task's result.
|
||||
func (r *TaskResultRepo) RecordVote(ctx context.Context, taskID, ownerID uuid.UUID, sha256 string, artifactID uuid.UUID) error {
|
||||
_, err := conn(ctx, r.db).ExecContext(ctx, `
|
||||
INSERT INTO task_results (task_id, owner_id, result_sha256, result_artifact_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (task_id, owner_id) DO UPDATE SET
|
||||
result_sha256 = excluded.result_sha256,
|
||||
result_artifact_id = excluded.result_artifact_id,
|
||||
created_at = excluded.created_at`,
|
||||
taskID.String(), ownerID.String(), sha256, artifactID.String(), time.Now().UnixNano())
|
||||
return err
|
||||
}
|
||||
|
||||
// CountAgreeing returns how many distinct owners have voted for the given
|
||||
// result hash on this task.
|
||||
func (r *TaskResultRepo) CountAgreeing(ctx context.Context, taskID uuid.UUID, sha256 string) (int, error) {
|
||||
var n int
|
||||
err := conn(ctx, r.db).QueryRowContext(ctx,
|
||||
"SELECT count(DISTINCT owner_id) FROM task_results WHERE task_id = ? AND result_sha256 = ?",
|
||||
taskID.String(), sha256).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||
)
|
||||
|
||||
// UIReadRepo contains bounded, deterministic read queries for the operator UI.
|
||||
type UIReadRepo struct{ db *sql.DB }
|
||||
|
||||
func NewUIReadRepo(db *sql.DB) *UIReadRepo { return &UIReadRepo{db: db} }
|
||||
|
||||
func (r *UIReadRepo) GetJob(ctx context.Context, id uuid.UUID) (*domain.Job, error) {
|
||||
return NewJobRepo(r.db).Get(ctx, id)
|
||||
}
|
||||
|
||||
func (r *UIReadRepo) ListJobs(ctx context.Context, owner *uuid.UUID, limit int) ([]domain.Job, error) {
|
||||
if limit < 1 || limit > 100 {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
query := "SELECT " + jobColumns + " FROM jobs"
|
||||
args := []any{}
|
||||
if owner != nil {
|
||||
query += " WHERE owner_id = ?"
|
||||
args = append(args, owner.String())
|
||||
}
|
||||
query += " ORDER BY created_at DESC, id DESC LIMIT ?"
|
||||
args = append(args, limit)
|
||||
rows, err := conn(ctx, r.db).QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list jobs: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
jobs := make([]domain.Job, 0)
|
||||
for rows.Next() {
|
||||
job, err := scanJob(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
jobs = append(jobs, *job)
|
||||
}
|
||||
return jobs, rows.Err()
|
||||
}
|
||||
|
||||
func (r *UIReadRepo) ListTasksByJob(ctx context.Context, jobID uuid.UUID) ([]domain.Task, error) {
|
||||
rows, err := conn(ctx, r.db).QueryContext(ctx,
|
||||
"SELECT "+taskColumns+" FROM tasks WHERE job_id = ? ORDER BY chunk_index ASC", jobID.String())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list tasks: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
tasks := make([]domain.Task, 0)
|
||||
for rows.Next() {
|
||||
task, err := scanTask(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tasks = append(tasks, *task)
|
||||
}
|
||||
return tasks, rows.Err()
|
||||
}
|
||||
|
||||
func (r *UIReadRepo) ListTasksByJobs(ctx context.Context, jobIDs []uuid.UUID) (map[uuid.UUID][]domain.Task, error) {
|
||||
out := make(map[uuid.UUID][]domain.Task, len(jobIDs))
|
||||
if len(jobIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
placeholders := make([]string, 0, len(jobIDs))
|
||||
args := make([]any, 0, len(jobIDs))
|
||||
for _, id := range jobIDs {
|
||||
placeholders = append(placeholders, "?")
|
||||
args = append(args, id.String())
|
||||
}
|
||||
rows, err := conn(ctx, r.db).QueryContext(ctx,
|
||||
"SELECT "+taskColumns+" FROM tasks WHERE job_id IN ("+strings.Join(placeholders, ", ")+") ORDER BY job_id ASC, chunk_index ASC",
|
||||
args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list tasks by jobs: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
for rows.Next() {
|
||||
task, err := scanTask(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[task.JobID] = append(out[task.JobID], *task)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *UIReadRepo) ListWorkers(ctx context.Context, limit int) ([]domain.Worker, error) {
|
||||
return r.listWorkers(ctx, "", nil, limit)
|
||||
}
|
||||
|
||||
func (r *UIReadRepo) ListWorkersByOwner(ctx context.Context, owner uuid.UUID, limit int) ([]domain.Worker, error) {
|
||||
return r.listWorkers(ctx, " WHERE owner_id = ?", []any{owner.String()}, limit)
|
||||
}
|
||||
|
||||
func (r *UIReadRepo) listWorkers(ctx context.Context, clause string, args []any, limit int) ([]domain.Worker, error) {
|
||||
if limit < 1 || limit > 100 {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
query := "SELECT " + workerColumns + " FROM workers" + clause +
|
||||
" ORDER BY last_heartbeat_at DESC, id DESC LIMIT ?"
|
||||
fullArgs := append(args, limit)
|
||||
rows, err := conn(ctx, r.db).QueryContext(ctx, query, fullArgs...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list workers: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
workers := make([]domain.Worker, 0)
|
||||
for rows.Next() {
|
||||
worker, err := scanWorker(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
workers = append(workers, *worker)
|
||||
}
|
||||
return workers, rows.Err()
|
||||
}
|
||||
|
||||
func (r *UIReadRepo) ListArtifactsByJob(ctx context.Context, jobID uuid.UUID) ([]domain.Artifact, error) {
|
||||
rows, err := conn(ctx, r.db).QueryContext(ctx,
|
||||
"SELECT "+artifactColumns+" FROM artifacts WHERE job_id = ? ORDER BY created_at ASC, id ASC",
|
||||
jobID.String())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list artifacts: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
artifacts := make([]domain.Artifact, 0)
|
||||
for rows.Next() {
|
||||
artifact, err := scanArtifact(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
artifacts = append(artifacts, *artifact)
|
||||
}
|
||||
return artifacts, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||
)
|
||||
|
||||
// WorkerRepo implements usecase.WorkerRepository on SQLite.
|
||||
type WorkerRepo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewWorkerRepo(db *sql.DB) *WorkerRepo {
|
||||
return &WorkerRepo{db: db}
|
||||
}
|
||||
|
||||
const workerColumns = `id, name, capabilities, status, owner_id, trust_level, last_heartbeat_at, created_at, updated_at`
|
||||
|
||||
// scanWorker maps one row onto a domain.Worker.
|
||||
func scanWorker(row interface{ Scan(dest ...any) error }) (*domain.Worker, error) {
|
||||
var (
|
||||
w domain.Worker
|
||||
status string
|
||||
trust string
|
||||
caps string
|
||||
)
|
||||
var (
|
||||
ownerID sql.NullString
|
||||
lastHeartbeat, created, updated sql.NullInt64
|
||||
)
|
||||
if err := row.Scan(
|
||||
&w.ID, &w.Name, &caps, &status, &ownerID, &trust,
|
||||
&lastHeartbeat, &created, &updated,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w.LastHeartbeatAt = decodeTime(lastHeartbeat.Int64)
|
||||
w.CreatedAt = decodeTime(created.Int64)
|
||||
w.UpdatedAt = decodeTime(updated.Int64)
|
||||
if err := decodeJSON(caps, &w.Capabilities); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w.Status = domain.WorkerStatus(status)
|
||||
w.TrustLevel = domain.WorkerTrust(trust)
|
||||
if ownerID.Valid {
|
||||
if id, err := uuid.Parse(ownerID.String); err == nil {
|
||||
w.OwnerID = &id
|
||||
}
|
||||
}
|
||||
return &w, nil
|
||||
}
|
||||
|
||||
func (r *WorkerRepo) Insert(ctx context.Context, w *domain.Worker) error {
|
||||
_, err := conn(ctx, r.db).ExecContext(ctx, `
|
||||
INSERT INTO workers (id, name, capabilities, status, owner_id, trust_level,
|
||||
last_heartbeat_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
w.ID.String(), w.Name, encodeJSON(w.Capabilities), string(w.Status),
|
||||
nullableUUID(w.OwnerID), string(w.TrustLevel),
|
||||
encodeTime(w.LastHeartbeatAt), encodeTime(w.CreatedAt), encodeTime(w.UpdatedAt))
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *WorkerRepo) Get(ctx context.Context, id uuid.UUID) (*domain.Worker, error) {
|
||||
row := conn(ctx, r.db).QueryRowContext(ctx,
|
||||
"SELECT "+workerColumns+" FROM workers WHERE id = ?", id.String())
|
||||
worker, err := scanWorker(row)
|
||||
return worker, mapErrNoRows(err, domain.ErrWorkerNotFound)
|
||||
}
|
||||
|
||||
func (r *WorkerRepo) Touch(ctx context.Context, id uuid.UUID, at time.Time) error {
|
||||
_, err := conn(ctx, r.db).ExecContext(ctx,
|
||||
"UPDATE workers SET last_heartbeat_at = ?, status = ?, updated_at = ? WHERE id = ?",
|
||||
encodeTime(at), string(domain.WorkerOnline), encodeTime(at), id.String())
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *WorkerRepo) MarkStaleOffline(ctx context.Context, cutoff time.Time) (int64, error) {
|
||||
res, err := conn(ctx, r.db).ExecContext(ctx,
|
||||
"UPDATE workers SET status = ?, updated_at = ? WHERE last_heartbeat_at < ? AND status <> ?",
|
||||
string(domain.WorkerOffline), encodeTime(cutoff), encodeTime(cutoff),
|
||||
string(domain.WorkerOffline))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func (r *WorkerRepo) SetTrust(ctx context.Context, id uuid.UUID, trust domain.WorkerTrust) error {
|
||||
res, err := conn(ctx, r.db).ExecContext(ctx,
|
||||
"UPDATE workers SET trust_level = ?, updated_at = ? WHERE id = ?",
|
||||
string(trust), encodeTime(time.Now()), id.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return domain.ErrWorkerNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
|
||||
)
|
||||
|
||||
// WorkloadSettingsRepo persists the per-workload enable/disable overrides.
|
||||
// Absence of a row means the workload is enabled (the catalog default).
|
||||
type WorkloadSettingsRepo struct{ db *sql.DB }
|
||||
|
||||
func NewWorkloadSettingsRepo(db *sql.DB) *WorkloadSettingsRepo { return &WorkloadSettingsRepo{db: db} }
|
||||
|
||||
var _ usecase.WorkloadSettingsRepository = (*WorkloadSettingsRepo)(nil)
|
||||
|
||||
func (r *WorkloadSettingsRepo) GetEnabled(ctx context.Context, workload string) (bool, error) {
|
||||
var enabled int
|
||||
err := conn(ctx, r.db).QueryRowContext(ctx,
|
||||
"SELECT enabled FROM workload_settings WHERE workload = ?", workload).Scan(&enabled)
|
||||
if err == sql.ErrNoRows {
|
||||
return true, nil // no override: catalog default enabled
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("get workload setting: %w", err)
|
||||
}
|
||||
return enabled == 1, nil
|
||||
}
|
||||
|
||||
func (r *WorkloadSettingsRepo) List(ctx context.Context) ([]usecase.WorkloadSetting, error) {
|
||||
rows, err := conn(ctx, r.db).QueryContext(ctx,
|
||||
"SELECT workload, enabled, updated_at FROM workload_settings ORDER BY workload ASC")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list workload settings: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
var out []usecase.WorkloadSetting
|
||||
for rows.Next() {
|
||||
var (
|
||||
name string
|
||||
enabled int
|
||||
updatedAt int64
|
||||
)
|
||||
if err := rows.Scan(&name, &enabled, &updatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, usecase.WorkloadSetting{
|
||||
Workload: name,
|
||||
Enabled: enabled == 1,
|
||||
UpdatedAt: decodeTime(updatedAt),
|
||||
})
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *WorkloadSettingsRepo) SetEnabled(ctx context.Context, workload string, enabled bool, now time.Time) error {
|
||||
_, err := conn(ctx, r.db).ExecContext(ctx, `
|
||||
INSERT INTO workload_settings (workload, enabled, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT (workload) DO UPDATE SET enabled = excluded.enabled, updated_at = excluded.updated_at`,
|
||||
workload, boolInt(enabled), now.UnixNano())
|
||||
if err != nil {
|
||||
return fmt.Errorf("set workload setting: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func boolInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -42,7 +42,7 @@ func (s *Server) writeError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrInvalidInput):
|
||||
case errors.Is(err, domain.ErrInvalidInput), errors.Is(err, domain.ErrWorkloadDisabled):
|
||||
status = http.StatusBadRequest
|
||||
case errors.Is(err, domain.ErrJobNotFound), errors.Is(err, domain.ErrTaskNotFound),
|
||||
errors.Is(err, domain.ErrWorkerNotFound), errors.Is(err, domain.ErrArtifactNotFound):
|
||||
|
||||
@@ -35,6 +35,7 @@ type UseCases struct {
|
||||
GetTaskInput *usecase.GetTaskInput
|
||||
Dashboard *usecase.Dashboard
|
||||
PreviewArtifact *usecase.PreviewArtifact
|
||||
Admin *usecase.Admin
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
@@ -177,6 +178,20 @@ func (s *Server) Handler(token string, uiToken ...string) http.Handler {
|
||||
// Admin panel: session + admin role.
|
||||
ui.Handle("GET /ui/admin", chain(http.HandlerFunc(s.handleUIAdmin), gate, requireAdmin))
|
||||
ui.Handle("POST /ui/admin/user-action", chain(http.HandlerFunc(s.handleUIAdminUserAction), gate, requireAdmin))
|
||||
// Admin console APIs: session + admin role, bounded read models.
|
||||
ui.Handle("GET /ui/admin/api/system", chain(http.HandlerFunc(s.handleUIAdminSystemJSON), gate, requireAdmin))
|
||||
ui.Handle("GET /ui/admin/api/jobs", chain(http.HandlerFunc(s.handleUIAdminJobsJSON), gate, requireAdmin))
|
||||
ui.Handle("GET /ui/admin/api/metrics", chain(http.HandlerFunc(s.handleUIAdminMetricsJSON), gate, requireAdmin))
|
||||
ui.Handle("GET /ui/admin/api/workers", chain(http.HandlerFunc(s.handleUIAdminWorkersJSON), gate, requireAdmin))
|
||||
ui.Handle("POST /ui/admin/api/workers/{id}/trust", chain(http.HandlerFunc(s.handleUIAdminSetTrustJSON), gate, requireAdmin))
|
||||
ui.Handle("GET /ui/admin/api/users", chain(http.HandlerFunc(s.handleUIAdminUsersJSON), gate, requireAdmin))
|
||||
ui.Handle("POST /ui/admin/api/users/{id}/role", chain(http.HandlerFunc(s.handleUIAdminSetUserRoleJSON), gate, requireAdmin))
|
||||
ui.Handle("GET /ui/admin/api/worker-keys", chain(http.HandlerFunc(s.handleUIAdminWorkerKeysJSON), gate, requireAdmin))
|
||||
ui.Handle("POST /ui/admin/api/worker-keys/{id}/revoke", chain(http.HandlerFunc(s.handleUIAdminRevokeKeyJSON), gate, requireAdmin))
|
||||
ui.Handle("GET /ui/admin/api/workloads", chain(http.HandlerFunc(s.handleUIAdminWorkloadsJSON), gate, requireAdmin))
|
||||
ui.Handle("POST /ui/admin/api/workloads/{name}/enabled", chain(http.HandlerFunc(s.handleUIAdminSetWorkloadEnabledJSON), gate, requireAdmin))
|
||||
ui.Handle("GET /ui/admin/api/settings", chain(http.HandlerFunc(s.handleUIAdminSettingsJSON), gate, requireAdmin))
|
||||
ui.Handle("POST /ui/admin/api/token/reveal", chain(http.HandlerFunc(s.handleUIAdminRevealTokenJSON), gate, requireAdmin))
|
||||
} else {
|
||||
for _, rt := range app {
|
||||
ui.HandleFunc(rt.pattern, rt.handler)
|
||||
|
||||
@@ -39,6 +39,7 @@ func newEnvWithUIToken(t *testing.T, ready func(context.Context) error, configur
|
||||
work := memstore.NewWorkerRepo()
|
||||
arts := memstore.NewArtifactRepo()
|
||||
blobs := memstore.NewBlobStore()
|
||||
settings := memstoreSettings{}
|
||||
clk := memstore.NewClock(time.Date(2026, 7, 21, 12, 0, 0, 0, time.UTC))
|
||||
tx := memstore.Tx{}
|
||||
lease := 2 * time.Minute
|
||||
@@ -47,7 +48,7 @@ func newEnvWithUIToken(t *testing.T, ready func(context.Context) error, configur
|
||||
uc := coordhttp.UseCases{
|
||||
RegisterWorker: usecase.NewRegisterWorker(work, clk),
|
||||
CreateJob: usecase.NewCreateJob(jobs, tasks, tx, clk),
|
||||
SubmitDataset: usecase.NewSubmitDataset(blobs, arts, jobs, tasks, tx, clk, 3, testCatalog()),
|
||||
SubmitDataset: usecase.NewSubmitDataset(blobs, arts, jobs, tasks, tx, clk, 3, testCatalog(), settings),
|
||||
ClaimTask: usecase.NewClaimTask(tasks, jobs, work, tx, clk, lease, testCatalog()),
|
||||
RenewLease: usecase.NewRenewLease(tasks, work, tx, clk, lease),
|
||||
CompleteTask: usecase.NewCompleteTask(tasks, jobs, arts, work, memstore.NewTaskResultRepo(), tx, clk, 2, testCatalog()),
|
||||
@@ -739,3 +740,22 @@ func (e *env) uploadDataset(t *testing.T, workload string, rows int, tsv string)
|
||||
}
|
||||
|
||||
func itoa(n int) string { return strconv.Itoa(n) }
|
||||
|
||||
// memstoreSettings is an in-memory WorkloadSettingsRepository for tests.
|
||||
type memstoreSettings struct{ overrides map[string]bool }
|
||||
|
||||
func (m memstoreSettings) GetEnabled(ctx context.Context, workload string) (bool, error) {
|
||||
if enabled, ok := m.overrides[workload]; ok {
|
||||
return enabled, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (m memstoreSettings) List(ctx context.Context) ([]usecase.WorkloadSetting, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m memstoreSettings) SetEnabled(ctx context.Context, workload string, enabled bool, now time.Time) error {
|
||||
m.overrides[workload] = enabled
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -29,8 +29,12 @@
|
||||
<h2>Set it up</h2>
|
||||
<ol>
|
||||
<li><strong>Create a key</strong><br>Use the form; copy the command it generates.</li>
|
||||
<li><strong>Paste it in a terminal</strong><br>The command clones the project, sets up a Python environment, installs the worker, and starts it. The machine then appears under <a href="/ui">My machines</a>.</li>
|
||||
<li><strong>Paste it in a terminal</strong><br>The command installs the worker, points it at this coordinator, and starts it. The machine then appears under <a href="/ui">My machines</a>.</li>
|
||||
</ol>
|
||||
<h2 style="margin-top:24px">Single-binary mode?</h2>
|
||||
<p>If the coordinator runs as <code>coordinator serve</code>, skip the key:
|
||||
use the token in <code>~/.scimesh/worker.token</code> on the coordinator
|
||||
machine as <code>WORKER_AUTH_TOKEN</code> instead.</p>
|
||||
<h2 style="margin-top:24px">Will my results count?</h2>
|
||||
<p>Your worker is <strong>untrusted</strong> by default: its results are cross-checked and accepted once a second independent worker computes the same answer (quorum), or once an admin marks your account <strong>verified</strong> — then your workers are trusted and results count immediately.</p>
|
||||
<p><span class="cap">similarity-search</span> and other SDK workloads from the library run on volunteer workers.</p>
|
||||
@@ -43,8 +47,8 @@
|
||||
const keysBox=document.querySelector('#keys'),cmdBox=document.querySelector('#command'),form=document.querySelector('#create'),nameInput=document.querySelector('#key-name'),createBtn=document.querySelector('#create-btn'),error=document.querySelector('#error');
|
||||
const node=(tag,text,cls)=>{const n=document.createElement(tag);if(text!==undefined)n.textContent=text;if(cls)n.className=cls;return n};
|
||||
const shq=s=>"'"+String(s).replace(/'/g,"'\\''")+"'";
|
||||
const buildCommand=(key,name)=>['git clone https://github.com/emil28092005/SciMesh.git','cd SciMesh','python -m venv .venv','source .venv/bin/activate','pip install -e .','','SCIMESH_COORDINATOR_URL='+coord+' \\','SCIMESH_USERSERVICE_URL='+(users||'<your userservice URL>')+' \\','SCIMESH_WORKER_KEY='+key+' \\','scimesh-worker --worker-name '+shq(name||'my-machine')].join('\n');
|
||||
const showCommand=(key,name)=>{cmdBox.replaceChildren();cmdBox.append(node('strong','Key created — copy it now, it is shown only once.'));const pre=node('pre',buildCommand(key,name));cmdBox.append(pre);const copy=node('button','Copy command','button secondary');copy.type='button';copy.addEventListener('click',()=>{navigator.clipboard&&navigator.clipboard.writeText(buildCommand(key,name)).then(()=>{copy.textContent='Copied ✓'},()=>{copy.textContent='Press Ctrl+C to copy'})});cmdBox.append(document.createElement('br'),copy);if(!users){cmdBox.append(node('p','Set SCIMESH_USERSERVICE_URL to a userservice URL your machine can reach.','warn'))}cmdBox.classList.remove('hidden')};
|
||||
const buildCommand=(key,name)=>['# install the worker binary (or download worker-agent from the release page)','curl -fsSL https://raw.githubusercontent.com/emil28092005/SciMesh/main/install.sh | bash -s worker','','export COORDINATOR_URL='+coord,'export USERSERVICE_URL='+(users||'<your userservice URL>')+' \\','export WORKER_KEY='+key,'export WORKER_NAME='+shq(name||'my-machine'),'export WORK_DIR=~/scimesh-worker','worker-agent'].join('\n');
|
||||
const showCommand=(key,name)=>{cmdBox.replaceChildren();cmdBox.append(node('strong','Key created — copy it now, it is shown only once.'));const pre=node('pre',buildCommand(key,name));cmdBox.append(pre);const copy=node('button','Copy command','button secondary');copy.type='button';copy.addEventListener('click',()=>{navigator.clipboard&&navigator.clipboard.writeText(buildCommand(key,name)).then(()=>{copy.textContent='Copied ✓'},()=>{copy.textContent='Press Ctrl+C to copy'})});cmdBox.append(document.createElement('br'),copy);if(!users){cmdBox.append(node('p','Set USERSERVICE_URL to a userservice URL your machine can reach, or skip it and run the worker with WORKER_AUTH_TOKEN instead of a key.','warn'))}cmdBox.classList.remove('hidden')};
|
||||
const revoke=async id=>{const r=await fetch('/ui/api/worker-keys/'+encodeURIComponent(id)+'/revoke',{method:'POST'});if(r.status===204||r.ok){loadKeys()}else{error.textContent='Could not revoke the key.'}};
|
||||
const renderKeys=keys=>{keysBox.replaceChildren();if(!keys.length){keysBox.append(node('div','No keys yet. Create one above to connect a machine.','empty'));return}for(const k of keys){const row=node('div',undefined,'key'),left=node('div');left.append(node('div',k.name||'unnamed','kn'),node('div',k.prefix+'…','kp'),node('div','Created '+new Date(k.created_at).toLocaleString()+(k.last_used_at?' · last used '+new Date(k.last_used_at).toLocaleString():' · never used'),'kd'));const btn=node('button','Revoke','revoke');btn.type='button';btn.addEventListener('click',()=>revoke(k.id));row.append(left,btn);keysBox.append(row)}};
|
||||
const loadKeys=async()=>{try{const r=await fetch('/ui/api/worker-keys',{headers:{Accept:'application/json'}});if(!r.ok)throw Error();const data=await r.json();renderKeys(data.worker_keys||[])}catch(_){keysBox.replaceChildren(node('div','Could not load your keys.','empty'))}};
|
||||
|
||||
@@ -2,45 +2,534 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Admin · SciMesh</title>
|
||||
<style>
|
||||
:root{color:#e5efff;background:#08111f;font:16px/1.5 Inter,ui-sans-serif,system-ui,sans-serif;color-scheme:dark}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 10% -8%,#183f77 0,transparent 32rem),#08111f}.page{max-width:820px;margin:auto;padding:28px 22px 64px}a{color:#94bdff}.top{display:flex;justify-content:space-between;align-items:center;gap:12px}.eyebrow{margin:0;color:#7baaff;font-size:.78rem;font-weight:800;letter-spacing:.14em;text-transform:uppercase}h1{margin:6px 0 0;color:#f4f8ff;font-size:clamp(1.8rem,4vw,2.6rem);letter-spacing:-.04em}.lead{max-width:640px;margin:10px 0 0;color:#aabed9}.card{margin-top:24px;border:1px solid #294662;border-radius:15px;background:#0d1a2cdd;box-shadow:0 20px 45px #00000021;padding:22px}.card h2{margin:0 0 4px;color:#f1f6ff;font-size:1.1rem}.card p{margin:0;color:#9fb3cf;font-size:.92rem}label{display:block;margin:16px 0 5px;color:#eaf2ff;font-weight:750}input{width:100%;border:1px solid #42617f;border-radius:9px;padding:10px 11px;background:#0a1626;color:#e5efff;font:inherit}input:focus{outline:2px solid #5d97f5;outline-offset:1px}.actions{display:flex;flex-wrap:wrap;gap:9px;margin-top:16px}.btn{border:0;border-radius:9px;padding:10px 14px;font:inherit;font-weight:800;cursor:pointer}.btn-primary{background:#67e3b8;color:#062018}.btn-muted{background:#23344d;color:#dce8ff}.notice{margin-top:16px;border-radius:10px;padding:11px 13px;font-weight:700}.ok{background:#123f34;color:#76efb5}.err{background:#552334;color:#ff9bad}.muted{color:#8ba2c2}.hint{margin-top:4px;color:#92a9c6;font-size:.85rem}</style>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SciMesh · Coordinator Admin</title>
|
||||
<style>
|
||||
:root{--bg:#0b0e13;--panel:#11151d;--panel-2:#161c27;--border:#1f2634;--border-soft:#181f2b;--text:#e9ecf3;--text-2:#98a2b5;--text-3:#5d6879;--accent:#5b8cff;--accent-soft:#5b8cff1f;--accent-strong:#3f6fe0;--green:#3fce8a;--green-soft:#3fce8a1a;--amber:#e5b64f;--amber-soft:#e5b64f1a;--red:#f2647c;--red-soft:#f2647c1a;--mono:ui-monospace,"SF Mono",SFMono-Regular,Menlo,monospace;color-scheme:dark}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:var(--bg);color:var(--text);font:14px/1.55 Inter,-apple-system,"Segoe UI",Roboto,sans-serif;-webkit-font-smoothing:antialiased}
|
||||
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
|
||||
input,select{font:inherit;color:var(--text);background:var(--panel-2);border:1px solid var(--border);border-radius:8px;padding:8px 11px;outline:none}
|
||||
input:focus,select:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
|
||||
code{font-family:var(--mono);font-size:.86em}
|
||||
a{color:var(--accent);text-decoration:none}
|
||||
.layout{display:flex;min-height:100vh}
|
||||
.sidebar{position:sticky;top:0;height:100vh;width:232px;flex:none;display:flex;flex-direction:column;background:var(--panel);border-right:1px solid var(--border-soft)}
|
||||
.brand{display:flex;align-items:center;gap:11px;padding:20px 20px 18px;border-bottom:1px solid var(--border-soft)}
|
||||
.brand-mark{display:grid;place-items:center;width:32px;height:32px;border-radius:9px;background:linear-gradient(135deg,#5b8cff,#7c5cff);box-shadow:0 4px 14px #5b8cff40}
|
||||
.brand-mark svg{width:17px;height:17px;stroke:#fff}
|
||||
.brand-name{font-weight:700;font-size:14.5px;letter-spacing:-.01em}
|
||||
.brand-sub{font-size:11px;color:var(--text-3);letter-spacing:.02em}
|
||||
.nav{flex:1;overflow-y:auto;padding:14px 12px}
|
||||
.nav-label{margin:16px 10px 6px;font-size:10.5px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:var(--text-3)}
|
||||
.nav-label:first-child{margin-top:0}
|
||||
.nav-item{display:flex;align-items:center;gap:10px;width:100%;padding:8px 10px;border-radius:8px;color:var(--text-2);font-weight:500;text-align:left;transition:background .12s,color .12s}
|
||||
.nav-item svg{width:16px;height:16px;stroke:currentColor;flex:none}
|
||||
.nav-item:hover{background:var(--panel-2);color:var(--text)}
|
||||
.nav-item.active{background:var(--accent-soft);color:var(--accent);font-weight:600}
|
||||
.nav-item .count{margin-left:auto;font-size:11px;font-weight:600;color:var(--text-3);background:var(--panel-2);border-radius:99px;padding:1px 7px}
|
||||
.nav-item.active .count{color:var(--accent);background:#5b8cff26}
|
||||
.side-foot{padding:14px;border-top:1px solid var(--border-soft)}
|
||||
.user-chip{display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius:9px;background:var(--panel-2)}
|
||||
.avatar{display:grid;place-items:center;width:28px;height:28px;border-radius:8px;background:linear-gradient(135deg,#3fce8a,#2ea56c);color:#08130d;font-weight:800;font-size:12px;flex:none}
|
||||
.user-chip b{display:block;font-size:12.5px;line-height:1.25}
|
||||
.user-chip span{display:block;font-size:11px;color:var(--text-3)}
|
||||
.back-link{display:block;margin-top:9px;padding:7px 10px;color:var(--text-3);font-size:12.5px;text-decoration:none;border-radius:8px}
|
||||
.back-link:hover{color:var(--text);background:var(--panel-2)}
|
||||
.main{flex:1;min-width:0;display:flex;flex-direction:column}
|
||||
.topbar{position:sticky;top:0;z-index:5;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:16px 32px;background:#0b0e13e6;backdrop-filter:blur(10px);border-bottom:1px solid var(--border-soft)}
|
||||
.topbar h1{font-size:17px;font-weight:700;letter-spacing:-.015em}
|
||||
.topbar p{font-size:12.5px;color:var(--text-3);margin-top:1px}
|
||||
.env-badge{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text-2);border:1px solid var(--border);border-radius:99px;padding:5px 12px;background:var(--panel)}
|
||||
.env-badge i{width:7px;height:7px;border-radius:50%;background:var(--green);box-shadow:0 0 8px var(--green)}
|
||||
.content{flex:1;padding:26px 32px 60px;max-width:1120px;width:100%;margin:0 auto}
|
||||
.page{display:none}.page.active{display:block;animation:fade .18s ease}
|
||||
@keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1}}
|
||||
.card{background:var(--panel);border:1px solid var(--border-soft);border-radius:13px}
|
||||
.card-pad{padding:20px}
|
||||
.card-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:15px 20px;border-bottom:1px solid var(--border-soft)}
|
||||
.card-head h3{font-size:13.5px;font-weight:650}
|
||||
.card-head span{font-size:12px;color:var(--text-3)}
|
||||
.grid-kpi{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:14px}
|
||||
.kpi{background:var(--panel);border:1px solid var(--border-soft);border-radius:13px;padding:16px 18px}
|
||||
.kpi .k-label{display:flex;align-items:center;gap:7px;font-size:11.5px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text-3)}
|
||||
.kpi .k-value{margin-top:8px;font-size:26px;font-weight:700;letter-spacing:-.03em;line-height:1}
|
||||
.kpi .k-sub{margin-top:6px;font-size:12px;color:var(--text-2)}
|
||||
.pill{display:inline-flex;align-items:center;gap:6px;border-radius:99px;padding:3px 10px;font-size:11.5px;font-weight:650;white-space:nowrap}
|
||||
.pill i{width:6px;height:6px;border-radius:50%;background:currentColor}
|
||||
.pill-success{background:var(--green-soft);color:var(--green)}
|
||||
.pill-active{background:var(--accent-soft);color:var(--accent)}
|
||||
.pill-waiting{background:#ffffff12;color:var(--text-2)}
|
||||
.pill-danger{background:var(--red-soft);color:var(--red)}
|
||||
.pill-amber{background:var(--amber-soft);color:var(--amber)}
|
||||
.btn{display:inline-flex;align-items:center;gap:7px;border-radius:8px;padding:8px 14px;font-weight:600;font-size:13px;border:1px solid transparent;transition:filter .12s,background .12s}
|
||||
.btn svg{width:14px;height:14px;stroke:currentColor}
|
||||
.btn-primary{background:var(--accent);color:#0a1222}
|
||||
.btn-ghost{background:var(--panel-2);border-color:var(--border);color:var(--text-2)}
|
||||
.btn-ghost:hover{color:var(--text);border-color:#2a3446}
|
||||
.btn-danger{background:var(--red-soft);color:var(--red)}
|
||||
.btn-sm{padding:5px 10px;font-size:12px;border-radius:7px}
|
||||
table{width:100%;border-collapse:collapse}
|
||||
th{padding:10px 20px;text-align:left;font-size:11px;font-weight:650;letter-spacing:.07em;text-transform:uppercase;color:var(--text-3);border-bottom:1px solid var(--border-soft)}
|
||||
td{padding:12px 20px;border-bottom:1px solid var(--border-soft);vertical-align:middle}
|
||||
tr:last-child td{border-bottom:0}
|
||||
tbody tr{transition:background .1s}
|
||||
tbody tr:hover{background:var(--panel-2)}
|
||||
.t-main{font-weight:600;font-size:13.5px}
|
||||
.t-sub{font-size:11.5px;color:var(--text-3);font-family:var(--mono)}
|
||||
.bar{height:5px;width:130px;border-radius:99px;background:#ffffff10;overflow:hidden}
|
||||
.bar span{display:block;height:100%;border-radius:inherit;background:linear-gradient(90deg,#5b8cff,#3fce8a)}
|
||||
.bar-label{font-size:11.5px;color:var(--text-2);font-family:var(--mono);margin-top:5px}
|
||||
.tabs{display:flex;gap:4px;padding:4px;background:var(--panel);border:1px solid var(--border-soft);border-radius:10px;width:max-content;margin-bottom:14px;flex-wrap:wrap}
|
||||
.tab{padding:6px 13px;border-radius:7px;font-size:12.5px;font-weight:600;color:var(--text-2)}
|
||||
.tab:hover{color:var(--text)}
|
||||
.tab.active{background:var(--panel-2);color:var(--text);box-shadow:inset 0 0 0 1px var(--border)}
|
||||
.tab .n{color:var(--text-3);font-weight:500;margin-left:5px}
|
||||
.tab.active .n{color:var(--accent)}
|
||||
.section-title{margin:26px 0 12px;font-size:13px;font-weight:700;letter-spacing:-.01em;color:var(--text)}
|
||||
.section-title:first-child{margin-top:0}
|
||||
.section-note{font-size:12px;color:var(--text-3);margin:-8px 0 12px}
|
||||
.kv{display:grid;grid-template-columns:210px 1fr;row-gap:0}
|
||||
.kv dt{padding:11px 20px;font-size:12.5px;color:var(--text-3);border-bottom:1px solid var(--border-soft)}
|
||||
.kv dd{padding:11px 20px;font-size:13px;border-bottom:1px solid var(--border-soft)}
|
||||
.kv dt:last-of-type,.kv dd:last-of-type{border-bottom:0}
|
||||
.stack{display:grid;gap:14px}
|
||||
.split{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
||||
.storage-bar{display:flex;height:10px;border-radius:99px;overflow:hidden;margin:14px 20px 6px}
|
||||
.storage-bar div{height:100%}
|
||||
.legend{display:flex;gap:20px;padding:10px 20px 18px;flex-wrap:wrap}
|
||||
.legend span{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--text-2)}
|
||||
.legend i{width:9px;height:9px;border-radius:3px}
|
||||
.footer-row{display:flex;align-items:center;justify-content:space-between;padding:11px 20px;font-size:12px;color:var(--text-3)}
|
||||
.pager{display:flex;gap:4px}
|
||||
.pager button{width:26px;height:26px;border-radius:7px;font-size:12px;color:var(--text-2)}
|
||||
.pager button.cur{background:var(--accent-soft);color:var(--accent);font-weight:700}
|
||||
.pager button:disabled{opacity:.35;cursor:default}
|
||||
.chart{width:100%;height:auto;display:block}
|
||||
.chart-bar{fill:#2c3a52;rx:4}
|
||||
.chart-bar.hot{fill:var(--accent)}
|
||||
.chart-grid{stroke:#ffffff08}
|
||||
.chart-label{font:10px var(--mono);fill:var(--text-3)}
|
||||
.placeholder{border:1px dashed #2c3a52;border-radius:13px;padding:34px 24px;text-align:center;color:var(--text-2)}
|
||||
.placeholder b{display:block;color:var(--text);margin-bottom:6px}
|
||||
.empty{padding:26px;text-align:center;color:var(--text-3);font-size:13px}
|
||||
.sec-label{font-size:11px;font-weight:650;letter-spacing:.06em;text-transform:uppercase;color:var(--text-3);margin:14px 20px 6px}
|
||||
.sec-label:first-child{margin-top:18px}
|
||||
.field-row{display:flex;gap:10px;align-items:center;margin-bottom:10px}
|
||||
.field-row label{font-size:12px;color:var(--text-2);width:200px;flex:none}
|
||||
@media(max-width:960px){.sidebar{display:none}.grid-kpi,.split{grid-template-columns:1fr 1fr}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="page">
|
||||
<header class="top">
|
||||
<div><p class="eyebrow">Admin panel</p><h1>User & run control</h1></div>
|
||||
<div style="display:flex;gap:10px;align-items:center"><a href="/ui">← Dashboard</a><a href="/ui/profile">Profile</a><form method="post" action="/ui/logout" style="margin:0"><button class="btn btn-muted" type="submit">Log out</button></form></div>
|
||||
</header>
|
||||
<p class="lead">Signed in as <strong>{{.Role}}</strong>. Promote or verify a user by their id, and control every job from the dashboard.</p>
|
||||
<div class="layout">
|
||||
|
||||
{{if .Msg}}<div class="notice ok">{{.Msg}}</div>{{end}}
|
||||
{{if .Error}}<div class="notice err">{{.Error}}</div>{{end}}
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<div class="brand-mark"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round"><circle cx="6" cy="6" r="2.4"/><circle cx="18" cy="7" r="2.4"/><circle cx="12" cy="17" r="2.4"/><path d="M8 7.5l7.6-1M7 8.3l3.7 6.5M16.6 9.2l-3.2 5.6"/></svg></div>
|
||||
<div><div class="brand-name">SciMesh</div><div class="brand-sub">Coordinator Admin</div></div>
|
||||
</div>
|
||||
<nav class="nav" id="nav">
|
||||
<div class="nav-label">Operate</div>
|
||||
<button class="nav-item active" data-page="system"><svg viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round"><rect x="3" y="3" width="8" height="8" rx="2"/><rect x="13" y="3" width="8" height="5" rx="2"/><rect x="13" y="10" width="8" height="11" rx="2"/><rect x="3" y="13" width="8" height="8" rx="2"/></svg>System</button>
|
||||
<button class="nav-item" data-page="jobs"><svg viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round"><path d="M4 6h16M4 12h16M4 18h10"/></svg>Jobs</button>
|
||||
<button class="nav-item" data-page="workers"><svg viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/></svg>Workers</button>
|
||||
<div class="nav-label">Access</div>
|
||||
<button class="nav-item" data-page="users"><svg viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round"><circle cx="9" cy="8" r="3.2"/><path d="M3.5 19c.7-3 2.9-4.5 5.5-4.5s4.8 1.5 5.5 4.5"/><circle cx="17" cy="9" r="2.4"/><path d="M15.5 14.6c2.6.2 4.3 1.7 5 4.4"/></svg>Users & keys</button>
|
||||
<div class="nav-label">Platform</div>
|
||||
<button class="nav-item" data-page="workloads"><svg viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round"><path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3z"/><path d="M12 12l8-4.5M12 12v9M12 12L4 7.5"/></svg>Workloads</button>
|
||||
<button class="nav-item" data-page="metrics"><svg viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round"><path d="M4 19V5M4 19h16"/><path d="M8 15v-4M12 15V7M16 15v-6M20 15V9"/></svg>Metrics</button>
|
||||
<button class="nav-item" data-page="settings"><svg viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round"><circle cx="12" cy="12" r="3"/><path d="M19 12a7 7 0 0 0-.1-1.2l2-1.6-2-3.4-2.4 1a7 7 0 0 0-2-1.2L14 3h-4l-.5 2.6a7 7 0 0 0-2 1.2l-2.4-1-2 3.4 2 1.6A7 7 0 0 0 5 12c0 .4 0 .8.1 1.2l-2 1.6 2 3.4 2.4-1a7 7 0 0 0 2 1.2L10 21h4l.5-2.6a7 7 0 0 0 2-1.2l2.4 1 2-3.4-2-1.6c.1-.4.1-.8.1-1.2z"/></svg>Settings</button>
|
||||
</nav>
|
||||
<div class="side-foot">
|
||||
<div class="user-chip"><div class="avatar">{{.Role}}</div><div><b>Signed in as {{.Role}}</b><span>cluster administrator</span></div></div>
|
||||
<a class="back-link" href="/ui">← Back to control room</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="card">
|
||||
<h2>Manage a user</h2>
|
||||
<p>Paste the user id (the JWT <code>sub</code> / the value shown at registration). Actions are applied immediately.</p>
|
||||
<form method="post" action="/ui/admin/user-action">
|
||||
<label for="user_id">User id</label>
|
||||
<input id="user_id" name="user_id" placeholder="00000000-0000-0000-0000-000000000000" autocomplete="off" required>
|
||||
<p class="hint">Promote makes them an admin; Verify marks them a trusted contributor (their workers skip quorum).</p>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" name="action" value="promote" type="submit">Make admin</button>
|
||||
<button class="btn btn-muted" name="action" value="demote" type="submit">Remove admin</button>
|
||||
<button class="btn btn-primary" name="action" value="verify" type="submit">Verify</button>
|
||||
<button class="btn btn-muted" name="action" value="unverify" type="submit">Unverify</button>
|
||||
<div class="main">
|
||||
<header class="topbar">
|
||||
<div><h1 id="page-title">System</h1><p id="page-sub">Cluster state and node information</p></div>
|
||||
<div class="env-badge"><i></i><span id="env-label">admin console</span></div>
|
||||
</header>
|
||||
<div class="content">
|
||||
|
||||
<!-- ═══ SYSTEM ═══ -->
|
||||
<section class="page active" id="page-system">
|
||||
<div class="grid-kpi">
|
||||
<div class="kpi"><div class="k-label"><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3z"/></svg>Version</div><div class="k-value" id="k-version">—</div><div class="k-sub" id="k-version-sub">loading…</div></div>
|
||||
<div class="kpi"><div class="k-label"><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>Uptime</div><div class="k-value" id="k-uptime">—</div><div class="k-sub" id="k-started">loading…</div></div>
|
||||
<div class="kpi"><div class="k-label"><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><path d="M4 6h16M4 12h16M4 18h10"/></svg>Active jobs</div><div class="k-value" id="k-active">—</div><div class="k-sub" id="k-active-sub">loading…</div></div>
|
||||
<div class="kpi"><div class="k-label"><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8"/></svg>Workers online</div><div class="k-value" id="k-workers">—</div><div class="k-sub" id="k-workers-sub">loading…</div></div>
|
||||
</div>
|
||||
<div class="split">
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Storage usage</h3><span id="storage-total">—</span></div>
|
||||
<div class="storage-bar" id="storage-bar"><div style="width:0;background:#5b8cff"></div><div style="width:0;background:#7c5cff"></div><div style="width:0;background:#3fce8a"></div></div>
|
||||
<div class="legend">
|
||||
<span><i style="background:#5b8cff"></i>Datasets · <b id="storage-datasets">—</b></span>
|
||||
<span><i style="background:#7c5cff"></i>Artifacts · <b id="storage-artifacts">—</b></span>
|
||||
<span><i style="background:#3fce8a"></i>Database · <b id="storage-db">—</b></span>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Health</h3><span id="health-state">—</span></div>
|
||||
<dl class="kv">
|
||||
<dt>Database</dt><dd id="h-db">—</dd>
|
||||
<dt>Userservice</dt><dd id="h-users">—</dd>
|
||||
<dt>Reducer</dt><dd id="h-reducer">—</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-title">Node information</div>
|
||||
<div class="card">
|
||||
<dl class="kv">
|
||||
<dt>Binary</dt><dd><code id="n-binary">—</code></dd>
|
||||
<dt>Listen address</dt><dd><code id="n-addr">—</code></dd>
|
||||
<dt>Data directory</dt><dd><code id="n-datadir">—</code></dd>
|
||||
<dt>Database engine</dt><dd id="n-engine">—</dd>
|
||||
<dt>Public URL</dt><dd><code id="n-public">—</code></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Jobs & tasks</h2>
|
||||
<p>As an admin you already see <strong>every user's jobs</strong> on the dashboard, with per-task status and job cancellation. A regular user sees only their own.</p>
|
||||
<div class="actions"><a class="btn btn-muted" href="/ui" style="text-decoration:none">Open the dashboard →</a></div>
|
||||
</section>
|
||||
</main>
|
||||
<!-- ═══ JOBS ═══ -->
|
||||
<section class="page" id="page-jobs">
|
||||
<div class="tabs" id="job-tabs"></div>
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead><tr><th>Job</th><th>Workload</th><th>Owner</th><th>Status</th><th>Progress</th><th>Submitted</th></tr></thead>
|
||||
<tbody id="job-rows"></tbody>
|
||||
</table>
|
||||
<div class="footer-row"><span id="job-range">—</span><div class="pager"><button id="pg-prev" aria-label="previous">‹</button><button id="pg-next" aria-label="next">›</button></div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══ WORKERS ═══ -->
|
||||
<section class="page" id="page-workers">
|
||||
<div class="section-note">Workers register themselves. Trust decides whether a machine's results are accepted directly or need quorum.</div>
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead><tr><th>Worker</th><th>Status</th><th>Capabilities</th><th>Trust</th><th>Owner</th><th>Last signal</th></tr></thead>
|
||||
<tbody id="worker-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══ USERS & KEYS ═══ -->
|
||||
<section class="page" id="page-users">
|
||||
<div class="section-title">Users</div>
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead><tr><th>Email</th><th>Role</th><th>Verified</th><th>Created</th></tr></thead>
|
||||
<tbody id="user-rows"></tbody>
|
||||
</table>
|
||||
<div class="footer-row"><span id="user-count">—</span></div>
|
||||
</div>
|
||||
<div class="section-title">Worker keys</div>
|
||||
<div class="section-note">Keys let lab machines register as workers under a user account. Served instances can also use the cluster token.</div>
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead><tr><th>Label</th><th>Prefix</th><th>Owner</th><th>Created</th><th>Last used</th><th></th></tr></thead>
|
||||
<tbody id="key-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="section-title">Quick user action</div>
|
||||
<div class="card">
|
||||
<form method="post" action="/ui/admin/user-action" style="padding:16px 20px">
|
||||
<div class="field-row"><label for="user_id">User id</label><input id="user_id" name="user_id" placeholder="00000000-0000-0000-0000-000000000000" autocomplete="off" required style="flex:1"></div>
|
||||
<div style="display:flex;gap:8px;margin-left:210px;flex-wrap:wrap">
|
||||
<button class="btn btn-primary" name="action" value="promote" type="submit">Make admin</button>
|
||||
<button class="btn btn-ghost" name="action" value="demote" type="submit">Remove admin</button>
|
||||
<button class="btn btn-primary" name="action" value="verify" type="submit">Verify</button>
|
||||
<button class="btn btn-ghost" name="action" value="unverify" type="submit">Unverify</button>
|
||||
</div>
|
||||
{{if .Msg}}<div style="margin-left:210px;margin-top:10px;color:var(--green);font-size:13px">✓ {{.Msg}}</div>{{end}}
|
||||
{{if .Error}}<div style="margin-left:210px;margin-top:10px;color:var(--red);font-size:13px">✗ {{.Error}}</div>{{end}}
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══ WORKLOADS ═══ -->
|
||||
<section class="page" id="page-workloads">
|
||||
<div class="section-note">Disabled workloads are rejected at submit time and hidden from the job form. Settings persist in the database.</div>
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead><tr><th>Workload</th><th>Reduction</th><th>Parameters</th><th>Dataset upload</th><th>Enabled</th></tr></thead>
|
||||
<tbody id="workload-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══ METRICS ═══ -->
|
||||
<section class="page" id="page-metrics">
|
||||
<div class="grid-kpi">
|
||||
<div class="kpi"><div class="k-label">Jobs · 7 days</div><div class="k-value" id="m-jobs7">—</div><div class="k-sub">created in the last week</div></div>
|
||||
<div class="kpi"><div class="k-label">Shards completed</div><div class="k-value" id="m-shards">—</div><div class="k-sub" id="m-shards-sub">across all workers</div></div>
|
||||
<div class="kpi"><div class="k-label">Avg shard time</div><div class="k-value" id="m-avg">—</div><div class="k-sub">completed shards only</div></div>
|
||||
<div class="kpi"><div class="k-label">Failure rate</div><div class="k-value" id="m-failrate">—</div><div class="k-sub" id="m-failrate-sub">—</div></div>
|
||||
</div>
|
||||
<div class="split">
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Jobs per day</h3><span>last 7 days</span></div>
|
||||
<div style="padding:16px 20px 10px" id="chart-days"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Jobs by workload</h3><span>all time</span></div>
|
||||
<dl class="kv" id="chart-workloads"></dl>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══ SETTINGS ═══ -->
|
||||
<section class="page" id="page-settings">
|
||||
<div class="warn-strip" style="display:flex;gap:10px;align-items:flex-start;background:var(--amber-soft);border:1px solid #e5b64f33;border-radius:10px;padding:12px 14px;font-size:12.5px;color:#eecf8d"><span>The cluster token below authenticates <b>any</b> worker. Reveal it only on a trusted machine.</span></div>
|
||||
<div class="section-title">Cluster</div>
|
||||
<div class="card">
|
||||
<dl class="kv">
|
||||
<dt>Worker token</dt><dd><div class="secret"><code id="tok">••••••••••••••••••••••••</code><button class="btn btn-ghost btn-sm" id="reveal">Reveal</button></div></dd>
|
||||
<dt>Public URL</dt><dd><code id="s-public">—</code></dd>
|
||||
<dt>Listen address</dt><dd><code id="s-addr">—</code></dd>
|
||||
<dt>Data directory</dt><dd><code id="s-datadir">—</code></dd>
|
||||
<dt>Database engine</dt><dd id="s-engine">—</dd>
|
||||
<dt>Binary</dt><dd><code id="s-binary">—</code></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const titles={system:['System','Cluster state and node information'],jobs:['Jobs','Every computation, filterable and paginated'],workers:['Workers','Fleet overview and trust management'],users:['Users & keys','Accounts, roles and worker keys'],workloads:['Workloads','Catalog entries and availability'],metrics:['Metrics','Throughput and reliability, last 7 days'],settings:['Settings','Cluster, storage and security']};
|
||||
const statusLabel={pending:'Waiting',leased:'Assigned',running:'Running',reducing:'Merging',completed:'Completed',failed:'Failed',cancelled:'Cancelled'};
|
||||
const statusClass={pending:'pill-waiting',leased:'pill-active',running:'pill-active',reducing:'pill-active',completed:'pill-success',failed:'pill-danger',cancelled:'pill-waiting'};
|
||||
const fmtBytes=b=>{if(b==null||b<0)return '—';if(b<1024)return b+' B';if(b<1048576)return (b/1024).toFixed(1)+' KB';if(b<1073741824)return (b/1048576).toFixed(1)+' MB';return (b/1073741824).toFixed(2)+' GB'};
|
||||
const fmtTime=t=>t?new Date(t).toLocaleString():'—';
|
||||
const esc=s=>String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||
let timer=null,current={page:'system',jobsStatus:'',jobsPage:1};
|
||||
const setPage=page=>{current.page=page;document.querySelectorAll('.nav-item').forEach(b=>b.classList.toggle('active',b.dataset.page===page));document.querySelectorAll('.page').forEach(p=>p.classList.toggle('active',p.id==='page-'+page));const [t,s]=titles[page];document.getElementById('page-title').textContent=t;document.getElementById('page-sub').textContent=s;if(timer){clearInterval(timer);timer=null}refresh();timer=setInterval(refresh,5000)};
|
||||
document.querySelectorAll('.nav-item').forEach(b=>b.addEventListener('click',()=>setPage(b.dataset.page)));
|
||||
|
||||
const refresh=()=>{if(document.hidden)return;const p=current.page;if(p==='system')loadSystem();else if(p==='jobs')loadJobs();else if(p==='metrics')loadMetrics();else if(p==='workers')loadWorkers();else if(p==='users')loadUsers();else if(p==='workloads')loadWorkloads();else if(p==='settings')loadSettings()};
|
||||
document.addEventListener('visibilitychange',()=>{if(!document.hidden)refresh()});
|
||||
|
||||
async function loadSystem(){
|
||||
const r=await fetch('/ui/admin/api/system',{headers:{Accept:'application/json'}});
|
||||
if(!r.ok)return;
|
||||
const v=await r.json();
|
||||
document.getElementById('k-version').textContent=v.version;
|
||||
document.getElementById('k-version-sub').textContent=v.node.db_engine+' · '+navigator.platform;
|
||||
const secs=v.uptime_seconds;
|
||||
const uptime=secs<3600?(secs/60).toFixed(0)+' min':secs<86400?(secs/3600).toFixed(1)+' h':(secs/86400).toFixed(1)+' d';
|
||||
document.getElementById('k-uptime').textContent=uptime;
|
||||
document.getElementById('k-started').textContent='since '+fmtTime(v.started_at);
|
||||
document.getElementById('k-active').textContent=v.active_jobs;
|
||||
document.getElementById('k-active-sub').textContent=v.running_jobs+' running · '+v.waiting_jobs+' waiting';
|
||||
document.getElementById('k-workers').textContent=v.workers_online;
|
||||
document.getElementById('k-workers-sub').textContent=(v.workers_total-v.workers_online)+' offline · '+v.workers_busy+' busy';
|
||||
const total=v.storage.datasets_bytes+v.storage.artifacts_bytes+v.storage.database_bytes;
|
||||
document.getElementById('storage-total').textContent=fmtBytes(total);
|
||||
const pct=b=>total?Math.round(b*100/total)+'%':'0%';
|
||||
document.getElementById('storage-bar').children[0].style.width=pct(v.storage.datasets_bytes);
|
||||
document.getElementById('storage-bar').children[1].style.width=pct(v.storage.artifacts_bytes);
|
||||
document.getElementById('storage-bar').children[2].style.width=pct(v.storage.database_bytes);
|
||||
document.getElementById('storage-datasets').textContent=fmtBytes(v.storage.datasets_bytes);
|
||||
document.getElementById('storage-artifacts').textContent=fmtBytes(v.storage.artifacts_bytes);
|
||||
document.getElementById('storage-db').textContent=fmtBytes(v.storage.database_bytes);
|
||||
document.getElementById('h-db').innerHTML=pill(v.health.database==='connected'?'Connected':'Error','pill-success',v.health.database==='connected'?'pill-danger':null);
|
||||
document.getElementById('h-users').innerHTML=pill(cap(v.health.userservice),'pill-success',null);
|
||||
document.getElementById('h-reducer').innerHTML=pill(cap(v.health.reducer),'pill-waiting',null);
|
||||
document.getElementById('health-state').textContent=v.health.database==='connected'?'all checks pass':'database unreachable';
|
||||
document.getElementById('n-binary').textContent=v.node.binary||'—';
|
||||
document.getElementById('n-addr').textContent=v.node.addr;
|
||||
document.getElementById('n-datadir').textContent=v.node.data_dir||'—';
|
||||
document.getElementById('n-engine').textContent=v.node.db_engine;
|
||||
document.getElementById('n-public').textContent=v.node.public_url||'—';
|
||||
document.getElementById('env-label').textContent=(v.node.public_url||'').replace(/^https?:\/\//,'')+' · '+v.node.db_engine+' · '+v.node.addr;
|
||||
}
|
||||
const cap=s=>s?s.charAt(0).toUpperCase()+s.slice(1):'—';
|
||||
const pill=(text,cls,fail)=>{const c=fail||cls;return '<span class="pill '+c+'"><i></i>'+esc(text)+'</span>'};
|
||||
|
||||
let jobFilter={};
|
||||
async function loadJobs(){
|
||||
const page=current.jobsPage,status=current.jobsStatus;
|
||||
const r=await fetch('/ui/admin/api/jobs?page='+page+'&per_page=10'+(status?'&status='+status:''),{headers:{Accept:'application/json'}});
|
||||
if(!r.ok)return;
|
||||
const v=await r.json();
|
||||
const tabs=document.getElementById('job-tabs');
|
||||
const defs=[['', 'All'],['running','Running'],['pending','Waiting'],['completed','Completed'],['failed','Failed'],['cancelled','Cancelled']];
|
||||
tabs.replaceChildren();
|
||||
for(const [key,label] of defs){
|
||||
const n=v.counts[key]||0;
|
||||
const b=document.createElement('button');
|
||||
b.className='tab'+(key===status?' active':'');
|
||||
b.innerHTML=label+'<span class="n">'+n+'</span>';
|
||||
b.addEventListener('click',()=>{current.jobsStatus=key;current.jobsPage=1;loadJobs()});
|
||||
tabs.append(b);
|
||||
}
|
||||
const rows=document.getElementById('job-rows');
|
||||
rows.replaceChildren();
|
||||
if(!v.jobs.length){const tr=document.createElement('tr');tr.innerHTML='<td colspan="6"><div class="empty">No jobs'+(status?' with this status':'')+'.</div></td>';rows.append(tr)}
|
||||
for(const j of v.jobs){
|
||||
const tr=document.createElement('tr');
|
||||
const pct=j.total?Math.min(100,Math.round((j.completed+j.failed)*100/j.total)):0;
|
||||
tr.innerHTML='<td><div class="t-main">'+esc(j.workload)+'</div><div class="t-sub">'+esc(j.id.slice(0,8))+'…</div></td>'+
|
||||
'<td><code style="color:var(--text-2)">'+esc(j.workload)+'</code></td>'+
|
||||
'<td style="color:var(--text-2)">'+esc(j.owner)+'</td>'+
|
||||
'<td>'+pill(statusLabel[j.status]||j.status,statusClass[j.status]||'pill-waiting',null)+'</td>'+
|
||||
'<td><div class="bar"><span style="width:'+pct+'%"></span></div><div class="bar-label">'+j.completed+' / '+j.total+' shards'+(j.failed?' · '+j.failed+' failed':'')+'</div></td>'+
|
||||
'<td style="color:var(--text-2);font-size:12.5px">'+fmtTime(j.created_at)+'</td>';
|
||||
rows.append(tr);
|
||||
}
|
||||
const from=(v.page-1)*v.per_page+1,to=Math.min(v.page*v.per_page,v.total);
|
||||
document.getElementById('job-range').textContent=v.total?(from+'–'+to+' of '+v.total+' jobs'):'no jobs';
|
||||
const prev=document.getElementById('pg-prev'),next=document.getElementById('pg-next');
|
||||
prev.disabled=v.page<=1;next.disabled=v.page*v.per_page>=v.total;
|
||||
prev.onclick=()=>{current.jobsPage--;loadJobs()};
|
||||
next.onclick=()=>{current.jobsPage++;loadJobs()};
|
||||
}
|
||||
|
||||
function dayChart(days){
|
||||
const max=Math.max(1,...days.map(d=>d.count));
|
||||
const w=460,h=150,bw=44,gap=18,base=118;
|
||||
let bars='';
|
||||
days.forEach((d,i)=>{const x=12+i*(bw+gap),bh=Math.round(d.count*base/max);bars+='<rect class="chart-bar'+(d.count===max&&d.count>0?' hot':'')+'" x="'+x+'" y="'+(base-bh+6)+'" width="'+bw+'" height="'+bh+'"/>';bars+='<text class="chart-label" x="'+x+'" y="146">'+d.day.slice(5)+'</text>'});
|
||||
return '<svg class="chart" viewBox="0 0 '+w+' '+h+'"><line class="chart-grid" x1="0" y1="30" x2="'+w+'" y2="30"/><line class="chart-grid" x1="0" y1="60" x2="'+w+'" y2="60"/><line class="chart-grid" x1="0" y1="90" x2="'+w+'" y2="90"/>'+bars+'</svg>';
|
||||
}
|
||||
async function loadMetrics(){
|
||||
const r=await fetch('/ui/admin/api/metrics',{headers:{Accept:'application/json'}});
|
||||
if(!r.ok)return;
|
||||
const v=await r.json();
|
||||
document.getElementById('m-jobs7').textContent=v.jobs_last_7_days;
|
||||
document.getElementById('m-shards').textContent=v.shards_completed.toLocaleString();
|
||||
document.getElementById('m-shards-sub').textContent=(v.shards_completed+v.shards_failed)+' shards · '+v.shards_failed+' failed';
|
||||
document.getElementById('m-avg').textContent=v.avg_shard_seconds?v.avg_shard_seconds.toFixed(1)+'s':'—';
|
||||
document.getElementById('m-failrate').textContent=(v.failure_rate*100).toFixed(1)+'%';
|
||||
document.getElementById('m-failrate-sub').textContent=v.shards_failed+' of '+(v.shards_completed+v.shards_failed)+' shards failed';
|
||||
document.getElementById('chart-days').innerHTML=dayChart(v.jobs_by_day);
|
||||
const wl=document.getElementById('chart-workloads');
|
||||
wl.replaceChildren();
|
||||
if(!v.jobs_by_workload.length){const p=document.createElement('p');p.className='empty';p.textContent='No workloads used yet.';wl.append(p)}
|
||||
const max=Math.max(1,...v.jobs_by_workload.map(w=>w.count));
|
||||
for(const w of v.jobs_by_workload){
|
||||
const dt=document.createElement('dt');dt.textContent=w.workload;
|
||||
const dd=document.createElement('dd');
|
||||
dd.innerHTML='<div class="bar" style="width:100%"><span style="width:'+Math.round(w.count*100/max)+'%"></span></div>';
|
||||
wl.append(dt,dd);
|
||||
}
|
||||
}
|
||||
setPage('system');
|
||||
|
||||
const workerStatusPill=s=>({online:['Online','pill-success'],busy:['Busy','pill-active'],offline:['Offline','pill-waiting']}[s]||[s,'pill-waiting']);
|
||||
async function loadWorkers(){
|
||||
const r=await fetch('/ui/admin/api/workers',{headers:{Accept:'application/json'}});
|
||||
if(!r.ok)return;
|
||||
const v=await r.json();
|
||||
const rows=document.getElementById('worker-rows');
|
||||
rows.replaceChildren();
|
||||
if(!v.workers.length){const tr=document.createElement('tr');tr.innerHTML='<td colspan="6"><div class="empty">No worker is registered yet.</div></td>';rows.append(tr);return}
|
||||
for(const w of v.workers){
|
||||
const tr=document.createElement('tr');
|
||||
const [label,cls]=workerStatusPill(w.status);
|
||||
const trustSel='<select data-id="'+w.id+'" class="trust-sel" '+(w.status==='offline'?'disabled':'')+'><option value="trusted" '+(w.trust==='trusted'?'selected':'')+'>Trusted</option><option value="untrusted" '+(w.trust==='untrusted'?'selected':'')+'>Untrusted</option></select>';
|
||||
tr.innerHTML='<td><div class="t-main">'+esc(w.name)+'</div><div class="t-sub">'+esc(w.id.slice(0,8))+'…</div></td>'+
|
||||
'<td>'+pill(label,cls,null)+'</td>'+
|
||||
'<td>'+(w.capabilities||[]).map(c=>'<span class="cap">'+esc(c)+'</span>').join('')+'</td>'+
|
||||
'<td>'+trustSel+'</td>'+
|
||||
'<td style="color:var(--text-2)">'+esc(w.owner)+'</td>'+
|
||||
'<td style="color:var(--text-2);font-size:12.5px">'+fmtTime(w.last_heartbeat_at)+'</td>';
|
||||
rows.append(tr);
|
||||
}
|
||||
document.querySelectorAll('.trust-sel').forEach(sel=>sel.addEventListener('change',async()=>{
|
||||
await fetch('/ui/admin/api/workers/'+sel.dataset.id+'/trust',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({trusted:sel.value==='trusted'})});
|
||||
loadWorkers();
|
||||
}));
|
||||
}
|
||||
async function loadUsers(){
|
||||
const r=await fetch('/ui/admin/api/users',{headers:{Accept:'application/json'}});
|
||||
if(!r.ok)return;
|
||||
const v=await r.json();
|
||||
const rows=document.getElementById('user-rows');
|
||||
rows.replaceChildren();
|
||||
for(const u of v.users||[]){
|
||||
const tr=document.createElement('tr');
|
||||
const roleSel='<select class="role-sel" data-id="'+u.id+'"><option value="user" '+(u.role==='user'?'selected':'')+'>user</option><option value="admin" '+(u.role==='admin'?'selected':'')+'>admin</option></select>';
|
||||
tr.innerHTML='<td><div class="t-main">'+esc(u.email)+'</div></td>'+
|
||||
'<td>'+roleSel+'</td>'+
|
||||
'<td>'+pill(u.verified?'Verified':'—',u.verified?'pill-success':'pill-waiting',null)+'</td>'+
|
||||
'<td style="color:var(--text-2);font-size:12.5px">'+fmtTime(u.created_at)+'</td>';
|
||||
rows.append(tr);
|
||||
}
|
||||
document.getElementById('user-count').textContent=(v.users||[]).length+' users';
|
||||
document.querySelectorAll('.role-sel').forEach(sel=>sel.addEventListener('change',async()=>{
|
||||
await fetch('/ui/admin/api/users/'+sel.dataset.id+'/role',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({role:sel.value})});
|
||||
loadUsers();
|
||||
}));
|
||||
const keys=await (await fetch('/ui/admin/api/worker-keys',{headers:{Accept:'application/json'}})).json();
|
||||
const keyRows=document.getElementById('key-rows');
|
||||
keyRows.replaceChildren();
|
||||
const emailOf={};for(const u of v.users||[])emailOf[u.id]=u.email;
|
||||
for(const k of keys.worker_keys||[]){
|
||||
const tr=document.createElement('tr');
|
||||
tr.innerHTML='<td class="t-main">'+esc(k.name)+'</td>'+
|
||||
'<td><code style="color:var(--text-2)">'+esc(k.prefix)+'…</code></td>'+
|
||||
'<td style="color:var(--text-2)">'+esc(emailOf[k.user_id]||k.user_id.slice(0,8)+'…')+'</td>'+
|
||||
'<td style="color:var(--text-2);font-size:12.5px">'+fmtTime(k.created_at)+'</td>'+
|
||||
'<td style="color:var(--text-2);font-size:12.5px">'+(k.last_used_at?fmtTime(k.last_used_at):'never')+'</td>'+
|
||||
'<td>'+((k.revoked_at)?'<span class="pill pill-danger"><i></i>Revoked</span>':'<button class="btn btn-danger btn-sm key-revoke" data-id="'+k.id+'">Revoke</button>')+'</td>';
|
||||
keyRows.append(tr);
|
||||
}
|
||||
document.querySelectorAll('.key-revoke').forEach(btn=>btn.addEventListener('click',async()=>{
|
||||
if(!confirm('Revoke this worker key? The machine will be cut off on its next refresh.'))return;
|
||||
await fetch('/ui/admin/api/worker-keys/'+btn.dataset.id+'/revoke',{method:'POST'});
|
||||
loadUsers();
|
||||
}));
|
||||
}
|
||||
async function loadWorkloads(){
|
||||
const r=await fetch('/ui/admin/api/workloads',{headers:{Accept:'application/json'}});
|
||||
if(!r.ok)return;
|
||||
const v=await r.json();
|
||||
const rows=document.getElementById('workload-rows');
|
||||
rows.replaceChildren();
|
||||
for(const w of v.workloads||[]){
|
||||
const tr=document.createElement('tr');
|
||||
tr.innerHTML='<td><div class="t-main">'+esc(w.name)+'</div><div class="t-sub" style="font-family:inherit">'+esc(w.description||'')+'</div></td>'+
|
||||
'<td><span class="pill pill-active"><i></i>'+esc(w.reduction)+'</span></td>'+
|
||||
'<td style="color:var(--text-2);font-size:12.5px">'+w.parameters+' declared</td>'+
|
||||
'<td>'+pill(w.upload_ready?'ready':'—',w.upload_ready?'pill-success':'pill-waiting',null)+'</td>'+
|
||||
'<td><button class="toggle wl-toggle '+(w.enabled?'on':'')+'" data-name="'+w.name+'" aria-label="enabled"></button></td>';
|
||||
rows.append(tr);
|
||||
}
|
||||
document.querySelectorAll('.wl-toggle').forEach(t=>t.addEventListener('click',async()=>{
|
||||
const enabled=!t.classList.contains('on');
|
||||
await fetch('/ui/admin/api/workloads/'+encodeURIComponent(t.dataset.name)+'/enabled',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled})});
|
||||
t.classList.toggle('on',enabled);
|
||||
}));
|
||||
}
|
||||
async function loadSettings(){
|
||||
const r=await fetch('/ui/admin/api/settings',{headers:{Accept:'application/json'}});
|
||||
if(!r.ok)return;
|
||||
const v=await r.json();
|
||||
document.getElementById('s-public').textContent=v.public_url||'—';
|
||||
document.getElementById('s-addr').textContent=v.addr;
|
||||
document.getElementById('s-datadir').textContent=v.data_dir||'—';
|
||||
document.getElementById('s-engine').textContent=v.db_engine;
|
||||
document.getElementById('s-binary').textContent=v.binary||'—';
|
||||
}
|
||||
document.getElementById('reveal').addEventListener('click',async e=>{
|
||||
const tok=document.getElementById('tok');
|
||||
if(tok.textContent.startsWith('•')){
|
||||
const r=await fetch('/ui/admin/api/token/reveal',{method:'POST',headers:{Accept:'application/json'}});
|
||||
if(!r.ok)return;
|
||||
const v=await r.json();
|
||||
tok.textContent=v.token||'(none)';
|
||||
e.target.textContent='Hide';
|
||||
}else{tok.textContent='••••••••••••••••••••••••';e.target.textContent='Reveal'}
|
||||
});
|
||||
document.querySelectorAll('.toggle').forEach(t=>t.addEventListener('click',()=>t.classList.toggle('on')));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<p class="eyebrow">SciMesh</p>
|
||||
<h1>Sign in</h1>
|
||||
<form method="post" action="/ui/login">
|
||||
{{if .Next}}<input type="hidden" name="next" value="{{.Next}}">{{end}}
|
||||
<label for="email">Email</label>
|
||||
<input id="email" name="email" type="email" autocomplete="username" required autofocus>
|
||||
<label for="password">Password</label>
|
||||
|
||||
@@ -2,14 +2,17 @@ package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||
)
|
||||
|
||||
// adminUserActions are the userservice endpoints the admin panel may invoke, by
|
||||
@@ -23,12 +26,14 @@ var adminUserActions = map[string]bool{
|
||||
}
|
||||
|
||||
// requireAdmin gates a route on the session caller being an admin. It runs
|
||||
// inside withUISession, which has already stamped the requester. A non-admin is
|
||||
// sent back to the dashboard rather than shown the panel.
|
||||
// inside withUISession, which has already stamped the requester. A signed-in
|
||||
// non-admin is told why (and bounced to the login with the message); an
|
||||
// unauthenticated caller never gets here — the gate has already sent them to
|
||||
// the login page with the intended destination.
|
||||
func requireAdmin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if req, ok := authctx.From(r.Context()); !ok || !req.IsAdmin() {
|
||||
http.Redirect(w, r, "/ui", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/ui/login?error=admin+role+required", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
@@ -112,3 +117,271 @@ func (s *Server) callUserserviceAuthed(ctx context.Context, method, path, bearer
|
||||
}
|
||||
return resp.StatusCode, body, nil
|
||||
}
|
||||
|
||||
// handleUIAdminSystemJSON serves the admin "System" page: process info,
|
||||
// storage figures and health. Admin-only via the route chain.
|
||||
func (s *Server) handleUIAdminSystemJSON(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := s.reqCtx(r)
|
||||
defer cancel()
|
||||
view, err := s.uc.Admin.System(ctx)
|
||||
if err != nil {
|
||||
s.writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
// handleUIAdminJobsJSON serves one page of the admin jobs table. The owner
|
||||
// emails are resolved from the userservice when it is reachable; the resolver
|
||||
// failing is not fatal (cards fall back to short ids).
|
||||
func (s *Server) handleUIAdminJobsJSON(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := s.reqCtx(r)
|
||||
defer cancel()
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
perPage, _ := strconv.Atoi(r.URL.Query().Get("per_page"))
|
||||
status := strings.TrimSpace(r.URL.Query().Get("status"))
|
||||
view, err := s.uc.Admin.Jobs(ctx, status, page, perPage, s.adminOwnerEmails(r))
|
||||
if err != nil {
|
||||
s.writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
// handleUIAdminMetricsJSON serves the admin "Metrics" page.
|
||||
func (s *Server) handleUIAdminMetricsJSON(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := s.reqCtx(r)
|
||||
defer cancel()
|
||||
view, err := s.uc.Admin.Metrics(ctx)
|
||||
if err != nil {
|
||||
s.writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
// handleUIAdminWorkersJSON serves the admin "Workers" page.
|
||||
func (s *Server) handleUIAdminWorkersJSON(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := s.reqCtx(r)
|
||||
defer cancel()
|
||||
view, err := s.uc.Admin.Workers(ctx, s.adminOwnerEmails(r))
|
||||
if err != nil {
|
||||
s.writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
// handleUIAdminSetTrustJSON flips one worker's trust level.
|
||||
func (s *Server) handleUIAdminSetTrustJSON(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
s.writeError(w, r, domain.ErrInvalidInput)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Trusted bool `json:"trusted"`
|
||||
}
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
s.writeError(w, r, domain.ErrInvalidInput)
|
||||
return
|
||||
}
|
||||
ctx, cancel := s.reqCtx(r)
|
||||
defer cancel()
|
||||
if err := s.uc.Admin.SetTrust(ctx, id, body.Trusted); err != nil {
|
||||
s.writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleUIAdminWorkloadsJSON serves the catalog with persisted enable flags.
|
||||
func (s *Server) handleUIAdminWorkloadsJSON(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := s.reqCtx(r)
|
||||
defer cancel()
|
||||
view, err := s.uc.Admin.Workloads(ctx)
|
||||
if err != nil {
|
||||
s.writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
// handleUIAdminSetWorkloadEnabledJSON flips a workload's enable flag.
|
||||
func (s *Server) handleUIAdminSetWorkloadEnabledJSON(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
var body struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
s.writeError(w, r, domain.ErrInvalidInput)
|
||||
return
|
||||
}
|
||||
ctx, cancel := s.reqCtx(r)
|
||||
defer cancel()
|
||||
if err := s.uc.Admin.SetWorkloadEnabled(ctx, name, body.Enabled); err != nil {
|
||||
s.writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleUIAdminSettingsJSON serves the read-only cluster settings.
|
||||
func (s *Server) handleUIAdminSettingsJSON(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, s.uc.Admin.Settings())
|
||||
}
|
||||
|
||||
// handleUIAdminRevealTokenJSON reveals the shared worker token, auditing the
|
||||
// reveal. Admin-only via the route chain.
|
||||
func (s *Server) handleUIAdminRevealTokenJSON(w http.ResponseWriter, r *http.Request) {
|
||||
actor := "admin"
|
||||
if req, ok := authctx.From(r.Context()); ok {
|
||||
actor = req.Role + ":" + req.UserID.String()
|
||||
}
|
||||
ctx, cancel := s.reqCtx(r)
|
||||
defer cancel()
|
||||
writeJSON(w, http.StatusOK, map[string]string{"token": s.uc.Admin.RevealWorkerToken(ctx, actor)})
|
||||
}
|
||||
|
||||
// handleUIAdminUsersJSON serves the account table, proxied from the
|
||||
// userservice. The userservice projects away password hashes; a failure here
|
||||
// is a 502 rather than a silent empty table.
|
||||
func (s *Server) handleUIAdminUsersJSON(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil {
|
||||
redirectToLogin(w, r)
|
||||
return
|
||||
}
|
||||
status, body, err := s.callUserserviceAuthed(r.Context(), http.MethodGet, "/users", c.Value)
|
||||
if err != nil {
|
||||
s.writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
writeJSON(w, status, map[string]string{"error": "userservice: unexpected response"})
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
// handleUIAdminSetUserRoleJSON changes a user's role through the userservice
|
||||
// promote/demote actions.
|
||||
func (s *Server) handleUIAdminSetUserRoleJSON(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
s.writeError(w, r, domain.ErrInvalidInput)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
s.writeError(w, r, domain.ErrInvalidInput)
|
||||
return
|
||||
}
|
||||
action := ""
|
||||
switch body.Role {
|
||||
case "admin":
|
||||
action = "promote"
|
||||
case "user":
|
||||
action = "demote"
|
||||
default:
|
||||
s.writeError(w, r, domain.ErrInvalidInput)
|
||||
return
|
||||
}
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil {
|
||||
redirectToLogin(w, r)
|
||||
return
|
||||
}
|
||||
status, _, err := s.callUserserviceAuthed(r.Context(), http.MethodPost, "/users/"+id.String()+"/"+action, c.Value)
|
||||
if err != nil {
|
||||
s.writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
if status != http.StatusNoContent {
|
||||
writeJSON(w, status, map[string]string{"error": "userservice: unexpected response"})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleUIAdminWorkerKeysJSON serves every worker key with its owning user,
|
||||
// proxied from the userservice.
|
||||
func (s *Server) handleUIAdminWorkerKeysJSON(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil {
|
||||
redirectToLogin(w, r)
|
||||
return
|
||||
}
|
||||
status, body, err := s.callUserserviceAuthed(r.Context(), http.MethodGet, "/worker-keys/all", c.Value)
|
||||
if err != nil {
|
||||
s.writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
writeJSON(w, status, map[string]string{"error": "userservice: unexpected response"})
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
// handleUIAdminRevokeKeyJSON revokes any worker key through the userservice
|
||||
// (whose DELETE endpoint already lets an admin revoke keys of any owner).
|
||||
func (s *Server) handleUIAdminRevokeKeyJSON(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
s.writeError(w, r, domain.ErrInvalidInput)
|
||||
return
|
||||
}
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil {
|
||||
redirectToLogin(w, r)
|
||||
return
|
||||
}
|
||||
status, _, err := s.callUserserviceAuthed(r.Context(), http.MethodDelete, "/worker-keys/"+id.String(), c.Value)
|
||||
if err != nil {
|
||||
s.writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
if status != http.StatusNoContent {
|
||||
writeJSON(w, status, map[string]string{"error": "userservice: unexpected response"})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// adminOwnerEmails resolves job owner ids to emails through the userservice,
|
||||
// which is the only place email addresses live. It never blocks the page on
|
||||
// failure: an empty map leaves the admin jobs table on short ids.
|
||||
func (s *Server) adminOwnerEmails(r *http.Request) map[uuid.UUID]string {
|
||||
if s.userserviceURL == "" {
|
||||
return nil
|
||||
}
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
status, body, err := s.callUserserviceAuthed(r.Context(), http.MethodGet, "/users", c.Value)
|
||||
if err != nil || status != http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
var users []struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &users); err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[uuid.UUID]string, len(users))
|
||||
for _, user := range users {
|
||||
id, err := uuid.Parse(user.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out[id] = user.Email
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -30,15 +30,15 @@ func TestRequireAdminAllowsAdminOnly(t *testing.T) {
|
||||
t.Error("admin must reach the handler")
|
||||
}
|
||||
|
||||
// Plain user is redirected to the dashboard.
|
||||
// Plain user is redirected to the login with the reason.
|
||||
reached = false
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, adminReq(t, "user"))
|
||||
if reached {
|
||||
t.Error("non-admin must not reach the handler")
|
||||
}
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/ui" {
|
||||
t.Errorf("non-admin got %d -> %q, want 303 -> /ui", rec.Code, rec.Header().Get("Location"))
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/ui/login?error=admin+role+required" {
|
||||
t.Errorf("non-admin got %d -> %q, want 303 -> login with the admin-required error", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
|
||||
@@ -53,7 +55,7 @@ type tokenVerifier interface {
|
||||
}
|
||||
|
||||
func (s *Server) handleUILoginForm(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderUI(w, "login.html", map[string]any{"Error": r.URL.Query().Get("error")})
|
||||
s.renderUI(w, "login.html", map[string]any{"Error": r.URL.Query().Get("error"), "Next": r.URL.Query().Get("next")})
|
||||
}
|
||||
|
||||
func (s *Server) handleUIRegisterForm(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -85,7 +87,14 @@ func (s *Server) handleUILogin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
setSessionCookie(w, r, resp.Token)
|
||||
http.Redirect(w, r, "/ui", http.StatusSeeOther)
|
||||
// Land back where the user was headed (e.g. /ui/admin); never follow a
|
||||
// value that escapes the UI prefix — that would be an open redirect.
|
||||
next := strings.TrimSpace(r.FormValue("next"))
|
||||
if next == "" || !strings.HasPrefix(next, "/ui/") {
|
||||
next = "/ui"
|
||||
}
|
||||
//nolint:gosec // G710: next is validated to start with /ui/ just above
|
||||
http.Redirect(w, r, next, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleUIRegister creates an account through the userservice, then sends the
|
||||
@@ -167,5 +176,15 @@ func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func redirectToLogin(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/ui/login", http.StatusSeeOther)
|
||||
// Remember where the user was headed so a successful login lands back
|
||||
// there (e.g. /ui/admin) instead of the control room.
|
||||
next := r.URL.Path
|
||||
if !strings.HasPrefix(next, "/ui/") {
|
||||
next = ""
|
||||
}
|
||||
target := "/ui/login"
|
||||
if next != "" {
|
||||
target += "?next=" + url.QueryEscape(next)
|
||||
}
|
||||
http.Redirect(w, r, target, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -173,3 +173,56 @@ func TestHandleUILogoutClearsCookie(t *testing.T) {
|
||||
t.Error("logout must clear the session cookie")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUILoginRedirectsToNext(t *testing.T) {
|
||||
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"token":"t"}`))
|
||||
}))
|
||||
defer stub.Close()
|
||||
s := newLoginServer(stub)
|
||||
|
||||
// A UI-scoped next is honoured: the admin lands back on the console.
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleUILogin(rec, postForm("/ui/login", url.Values{"email": {"a@b.com"}, "password": {"p"}, "next": {"/ui/admin"}}))
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/ui/admin" {
|
||||
t.Errorf("got %d -> %q, want 303 -> /ui/admin", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
|
||||
// Anything outside the UI prefix must not become a redirect target.
|
||||
for _, next := range []string{"https://evil.example", "/", "//evil.example", "/api/jobs"} {
|
||||
rec = httptest.NewRecorder()
|
||||
s.handleUILogin(rec, postForm("/ui/login", url.Values{"email": {"a@b.com"}, "password": {"p"}, "next": {next}}))
|
||||
if loc := rec.Header().Get("Location"); loc != "/ui" {
|
||||
t.Errorf("next=%q landed on %q, want /ui (no open redirect)", next, loc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedirectToLoginCarriesNext(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
req := newReq(http.MethodGet, "/ui/admin", nil)
|
||||
redirectToLogin(rec, req)
|
||||
if loc := rec.Header().Get("Location"); loc != "/ui/login?next=%2Fui%2Fadmin" {
|
||||
t.Errorf("location = %q, want /ui/login?next=%%2Fui%%2Fadmin", loc)
|
||||
}
|
||||
|
||||
// Paths outside the UI stay on the plain login.
|
||||
rec = httptest.NewRecorder()
|
||||
req = newReq(http.MethodGet, "/health", nil)
|
||||
redirectToLogin(rec, req)
|
||||
if loc := rec.Header().Get("Location"); loc != "/ui/login" {
|
||||
t.Errorf("location = %q, want /ui/login", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginFormRendersNext(t *testing.T) {
|
||||
html := render(t, "login.html", map[string]any{"Next": "/ui/admin"})
|
||||
if !strings.Contains(html, `name="next" value="/ui/admin"`) {
|
||||
t.Error("login form must carry the next field")
|
||||
}
|
||||
html = render(t, "login.html", map[string]any{})
|
||||
if strings.Contains(html, `name="next"`) {
|
||||
t.Error("login form must not render next when absent")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/workloads"
|
||||
)
|
||||
|
||||
// AdminReadRepository is the bounded read projection behind the coordinator
|
||||
// admin console. Like UIReadRepository it exposes no storage paths or
|
||||
// credentials; unlike it, every method is admin-scoped (no owner filter).
|
||||
type AdminReadRepository interface {
|
||||
// ListJobsPaginated returns one page of jobs filtered by stored status;
|
||||
// an empty status returns all. total counts the filtered set (for the
|
||||
// pager).
|
||||
ListJobsPaginated(ctx context.Context, status string, limit, offset int) (jobs []domain.Job, total int, err error)
|
||||
// CountJobsByStatus powers the status tabs: every stored status, all jobs.
|
||||
CountJobsByStatus(ctx context.Context) (map[string]int, error)
|
||||
// TaskCountsByJobs aggregates task statuses per job for progress bars.
|
||||
TaskCountsByJobs(ctx context.Context, jobIDs []uuid.UUID) (map[uuid.UUID]map[string]int, error)
|
||||
// JobCountsByDay buckets jobs created since `since` by UTC day
|
||||
// ("2006-01-02").
|
||||
JobCountsByDay(ctx context.Context, since time.Time) (map[string]int, error)
|
||||
// JobCountsByWorkload counts all jobs per workload name.
|
||||
JobCountsByWorkload(ctx context.Context) (map[string]int, error)
|
||||
// TaskStats totals shard execution: completed/failed counts and the mean
|
||||
// run duration of completed shards (seconds; 0 when nothing completed).
|
||||
TaskStats(ctx context.Context) (completed, failed int64, avgSeconds float64, err error)
|
||||
// ArtifactSizeByKind sums stored bytes per artifact kind.
|
||||
ArtifactSizeByKind(ctx context.Context) (map[string]int64, error)
|
||||
// DatabaseSizeBytes reports the engine's own size figure (sqlite pages,
|
||||
// pg_database_size); 0 when the engine cannot say.
|
||||
DatabaseSizeBytes(ctx context.Context) (int64, error)
|
||||
}
|
||||
|
||||
// AdminNodeInfo describes the running coordinator process to the admin
|
||||
// console. It is static for the process lifetime and assembled at startup.
|
||||
type AdminNodeInfo struct {
|
||||
Version string
|
||||
StartedAt time.Time
|
||||
Binary string
|
||||
Addr string
|
||||
DataDir string
|
||||
DBEngine string
|
||||
PublicURL string
|
||||
Userservice string // base URL; empty when the UI runs without user auth
|
||||
// WorkerToken reads the shared worker token for the Settings page. It is a
|
||||
// func so serve mode can read the token file lazily after provisioning.
|
||||
WorkerToken func() string
|
||||
}
|
||||
|
||||
type AdminStorageView struct {
|
||||
DatasetsBytes int64 `json:"datasets_bytes"`
|
||||
ArtifactsBytes int64 `json:"artifacts_bytes"`
|
||||
DatabaseBytes int64 `json:"database_bytes"`
|
||||
}
|
||||
|
||||
type AdminHealthView struct {
|
||||
Database string `json:"database"` // connected | error
|
||||
Reducer string `json:"reducer"` // idle | active
|
||||
Userservice string `json:"userservice"` // embedded | external | disabled
|
||||
}
|
||||
|
||||
type AdminNodeView struct {
|
||||
Binary string `json:"binary"`
|
||||
Addr string `json:"addr"`
|
||||
DataDir string `json:"data_dir"`
|
||||
DBEngine string `json:"db_engine"`
|
||||
PublicURL string `json:"public_url"`
|
||||
}
|
||||
|
||||
type AdminSystemView struct {
|
||||
Version string `json:"version"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
UptimeSeconds int64 `json:"uptime_seconds"`
|
||||
ActiveJobs int `json:"active_jobs"`
|
||||
RunningJobs int `json:"running_jobs"`
|
||||
WaitingJobs int `json:"waiting_jobs"`
|
||||
WorkersOnline int `json:"workers_online"`
|
||||
WorkersBusy int `json:"workers_busy"`
|
||||
WorkersTotal int `json:"workers_total"`
|
||||
Storage AdminStorageView `json:"storage"`
|
||||
Health AdminHealthView `json:"health"`
|
||||
Node AdminNodeView `json:"node"`
|
||||
}
|
||||
|
||||
// AdminJobCard is one row of the admin jobs table. Owner is a display string
|
||||
// resolved by the caller (email when the userservice is reachable, a short id
|
||||
// or "cluster token" otherwise).
|
||||
type AdminJobCard struct {
|
||||
ID string `json:"id"`
|
||||
Workload string `json:"workload"`
|
||||
Status string `json:"status"`
|
||||
OwnerID string `json:"owner_id,omitempty"`
|
||||
Owner string `json:"owner"`
|
||||
Total int `json:"total"`
|
||||
Completed int `json:"completed"`
|
||||
Failed int `json:"failed"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
}
|
||||
|
||||
type AdminJobsView struct {
|
||||
Jobs []AdminJobCard `json:"jobs"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PerPage int `json:"per_page"`
|
||||
// Counts holds every stored status for the filter tabs (all jobs, not
|
||||
// just the current filter).
|
||||
Counts map[string]int `json:"counts"`
|
||||
}
|
||||
|
||||
type AdminDayCount struct {
|
||||
Day string `json:"day"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type AdminWorkloadCount struct {
|
||||
Workload string `json:"workload"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type AdminMetricsView struct {
|
||||
JobsLast7Days int `json:"jobs_last_7_days"`
|
||||
JobsByDay []AdminDayCount `json:"jobs_by_day"`
|
||||
JobsByWorkload []AdminWorkloadCount `json:"jobs_by_workload"`
|
||||
ShardsCompleted int64 `json:"shards_completed"`
|
||||
ShardsFailed int64 `json:"shards_failed"`
|
||||
AvgShardSeconds float64 `json:"avg_shard_seconds"`
|
||||
FailureRate float64 `json:"failure_rate"`
|
||||
}
|
||||
|
||||
// Admin answers the coordinator admin console from the bounded read model
|
||||
// plus process info supplied at startup.
|
||||
type Admin struct {
|
||||
read AdminReadRepository
|
||||
uiRead UIReadRepository
|
||||
workers WorkerRepository
|
||||
settings WorkloadSettingsRepository
|
||||
catalog *workloads.Catalog
|
||||
node AdminNodeInfo
|
||||
ready func(context.Context) error
|
||||
now func() time.Time
|
||||
log *slog.Logger
|
||||
audit func(ctx context.Context, action, detail string)
|
||||
}
|
||||
|
||||
func NewAdmin(read AdminReadRepository, uiRead UIReadRepository, workers WorkerRepository,
|
||||
settings WorkloadSettingsRepository, catalog *workloads.Catalog, node AdminNodeInfo,
|
||||
ready func(context.Context) error, now func() time.Time) *Admin {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &Admin{read: read, uiRead: uiRead, workers: workers, settings: settings, catalog: catalog, node: node, ready: ready, now: now}
|
||||
}
|
||||
|
||||
// WithAuditLog attaches an audit sink for sensitive actions (token reveal).
|
||||
// Without it the admin usecase stays silent about them.
|
||||
func (a *Admin) WithAuditLog(log *slog.Logger, audit func(ctx context.Context, action, detail string)) *Admin {
|
||||
a.log = log
|
||||
a.audit = audit
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *Admin) revealToken(ctx context.Context, actor string) string {
|
||||
if a.node.WorkerToken != nil {
|
||||
return a.node.WorkerToken()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *Admin) System(ctx context.Context) (AdminSystemView, error) {
|
||||
counts, err := a.read.CountJobsByStatus(ctx)
|
||||
if err != nil {
|
||||
return AdminSystemView{}, err
|
||||
}
|
||||
workers, err := a.uiRead.ListWorkers(ctx, 100)
|
||||
if err != nil {
|
||||
return AdminSystemView{}, err
|
||||
}
|
||||
sizes, err := a.read.ArtifactSizeByKind(ctx)
|
||||
if err != nil {
|
||||
return AdminSystemView{}, err
|
||||
}
|
||||
dbSize, err := a.read.DatabaseSizeBytes(ctx)
|
||||
if err != nil {
|
||||
return AdminSystemView{}, err
|
||||
}
|
||||
|
||||
out := AdminSystemView{
|
||||
Version: a.node.Version,
|
||||
StartedAt: a.node.StartedAt,
|
||||
WaitingJobs: counts[string(domain.JobPending)],
|
||||
RunningJobs: counts[string(domain.JobRunning)] + counts[string(domain.JobReducing)],
|
||||
}
|
||||
out.ActiveJobs = out.WaitingJobs + out.RunningJobs
|
||||
out.UptimeSeconds = int64(a.now().Sub(a.node.StartedAt).Seconds())
|
||||
if out.UptimeSeconds < 0 {
|
||||
out.UptimeSeconds = 0
|
||||
}
|
||||
for _, w := range workers {
|
||||
out.WorkersTotal++
|
||||
switch w.Status {
|
||||
case domain.WorkerOnline:
|
||||
out.WorkersOnline++
|
||||
case domain.WorkerBusy:
|
||||
out.WorkersOnline++
|
||||
out.WorkersBusy++
|
||||
}
|
||||
}
|
||||
for kind, size := range sizes {
|
||||
if kind == string(domain.ArtifactInput) {
|
||||
out.Storage.DatasetsBytes += size
|
||||
} else {
|
||||
out.Storage.ArtifactsBytes += size
|
||||
}
|
||||
}
|
||||
out.Storage.DatabaseBytes = dbSize
|
||||
|
||||
out.Health.Database = "connected"
|
||||
if a.ready != nil {
|
||||
if err := a.ready(ctx); err != nil {
|
||||
out.Health.Database = "error"
|
||||
}
|
||||
}
|
||||
out.Health.Reducer = "idle"
|
||||
if counts[string(domain.JobReducing)] > 0 {
|
||||
out.Health.Reducer = "active"
|
||||
}
|
||||
out.Health.Userservice = "disabled"
|
||||
if a.node.Userservice != "" {
|
||||
out.Health.Userservice = "external"
|
||||
// The embedded userservice always binds the loopback interface.
|
||||
if strings.Contains(a.node.Userservice, "127.0.0.1") || strings.Contains(a.node.Userservice, "localhost") {
|
||||
out.Health.Userservice = "embedded"
|
||||
}
|
||||
}
|
||||
out.Node = AdminNodeView{
|
||||
Binary: a.node.Binary,
|
||||
Addr: a.node.Addr,
|
||||
DataDir: a.node.DataDir,
|
||||
DBEngine: a.node.DBEngine,
|
||||
PublicURL: a.node.PublicURL,
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Jobs returns one page of the admin jobs table. The owner emails map may be
|
||||
// nil; cards then fall back to a short id or "cluster token".
|
||||
func (a *Admin) Jobs(ctx context.Context, status string, page, perPage int, ownerEmails map[uuid.UUID]string) (AdminJobsView, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 || perPage > 100 {
|
||||
perPage = 20
|
||||
}
|
||||
jobs, total, err := a.read.ListJobsPaginated(ctx, status, perPage, (page-1)*perPage)
|
||||
if err != nil {
|
||||
return AdminJobsView{}, err
|
||||
}
|
||||
counts, err := a.read.CountJobsByStatus(ctx)
|
||||
if err != nil {
|
||||
return AdminJobsView{}, err
|
||||
}
|
||||
jobIDs := make([]uuid.UUID, 0, len(jobs))
|
||||
for _, job := range jobs {
|
||||
jobIDs = append(jobIDs, job.ID)
|
||||
}
|
||||
taskCounts, err := a.read.TaskCountsByJobs(ctx, jobIDs)
|
||||
if err != nil {
|
||||
return AdminJobsView{}, err
|
||||
}
|
||||
out := AdminJobsView{
|
||||
Jobs: make([]AdminJobCard, 0, len(jobs)),
|
||||
Total: total,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
Counts: counts,
|
||||
}
|
||||
for _, job := range jobs {
|
||||
tc := taskCounts[job.ID]
|
||||
card := AdminJobCard{
|
||||
ID: job.ID.String(),
|
||||
Workload: job.Workload,
|
||||
CreatedAt: job.CreatedAt,
|
||||
CompletedAt: job.CompletedAt,
|
||||
Owner: "cluster token",
|
||||
}
|
||||
var pending, leased, cancelled int
|
||||
for status, n := range tc {
|
||||
card.Total += n
|
||||
switch domain.TaskStatus(status) {
|
||||
case domain.TaskCompleted:
|
||||
card.Completed = n
|
||||
case domain.TaskFailed:
|
||||
card.Failed = n
|
||||
case domain.TaskPending:
|
||||
pending = n
|
||||
case domain.TaskLeased, domain.TaskRunning:
|
||||
leased += n
|
||||
case domain.TaskCancelled:
|
||||
cancelled = n
|
||||
}
|
||||
}
|
||||
// Derive the status exactly like the operator dashboard does, so the
|
||||
// two views never disagree about the same job.
|
||||
progress := domain.JobProgress{Job: job, Total: card.Total, Pending: pending, Leased: leased, Done: card.Completed, Failed: card.Failed, Cancelled: cancelled}
|
||||
card.Status = string(progress.DeriveStatus())
|
||||
if job.OwnerID != nil {
|
||||
card.OwnerID = job.OwnerID.String()
|
||||
card.Owner = "user " + shortID(job.OwnerID.String())
|
||||
if email, ok := ownerEmails[*job.OwnerID]; ok && email != "" {
|
||||
card.Owner = email
|
||||
}
|
||||
}
|
||||
out.Jobs = append(out.Jobs, card)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *Admin) Metrics(ctx context.Context) (AdminMetricsView, error) {
|
||||
since := a.now().Add(-6 * 24 * time.Hour).Truncate(24 * time.Hour)
|
||||
byDay, err := a.read.JobCountsByDay(ctx, since)
|
||||
if err != nil {
|
||||
return AdminMetricsView{}, err
|
||||
}
|
||||
byWorkload, err := a.read.JobCountsByWorkload(ctx)
|
||||
if err != nil {
|
||||
return AdminMetricsView{}, err
|
||||
}
|
||||
completed, failed, avg, err := a.read.TaskStats(ctx)
|
||||
if err != nil {
|
||||
return AdminMetricsView{}, err
|
||||
}
|
||||
out := AdminMetricsView{
|
||||
JobsByDay: make([]AdminDayCount, 0, 7),
|
||||
JobsByWorkload: make([]AdminWorkloadCount, 0, len(byWorkload)),
|
||||
ShardsCompleted: completed,
|
||||
ShardsFailed: failed,
|
||||
AvgShardSeconds: avg,
|
||||
}
|
||||
if completed+failed > 0 {
|
||||
out.FailureRate = float64(failed) / float64(completed+failed)
|
||||
}
|
||||
for i := 0; i < 7; i++ {
|
||||
day := since.Add(time.Duration(i) * 24 * time.Hour).UTC().Format("2006-01-02")
|
||||
count := byDay[day]
|
||||
out.JobsByDay = append(out.JobsByDay, AdminDayCount{Day: day, Count: count})
|
||||
out.JobsLast7Days += count
|
||||
}
|
||||
for workload, count := range byWorkload {
|
||||
out.JobsByWorkload = append(out.JobsByWorkload, AdminWorkloadCount{Workload: workload, Count: count})
|
||||
}
|
||||
sort.Slice(out.JobsByWorkload, func(i, j int) bool {
|
||||
if out.JobsByWorkload[i].Count != out.JobsByWorkload[j].Count {
|
||||
return out.JobsByWorkload[i].Count > out.JobsByWorkload[j].Count
|
||||
}
|
||||
return out.JobsByWorkload[i].Workload < out.JobsByWorkload[j].Workload
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AdminWorkerCard is one row of the admin workers table.
|
||||
type AdminWorkerCard struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Trust string `json:"trust"`
|
||||
OwnerID string `json:"owner_id,omitempty"`
|
||||
Owner string `json:"owner"`
|
||||
Completed int `json:"completed"`
|
||||
LastHeartbeatAt time.Time `json:"last_heartbeat_at"`
|
||||
}
|
||||
|
||||
type AdminWorkersView struct {
|
||||
Workers []AdminWorkerCard `json:"workers"`
|
||||
}
|
||||
|
||||
// Workers lists the whole fleet for the admin console. Owner emails are
|
||||
// resolved through the same map as the jobs table (userservice-backed).
|
||||
func (a *Admin) Workers(ctx context.Context, ownerEmails map[uuid.UUID]string) (AdminWorkersView, error) {
|
||||
workers, err := a.uiRead.ListWorkers(ctx, 100)
|
||||
if err != nil {
|
||||
return AdminWorkersView{}, err
|
||||
}
|
||||
out := AdminWorkersView{Workers: make([]AdminWorkerCard, 0, len(workers))}
|
||||
for _, w := range workers {
|
||||
card := AdminWorkerCard{
|
||||
ID: w.ID.String(),
|
||||
Name: w.Name,
|
||||
Status: string(w.Status),
|
||||
Capabilities: w.Capabilities,
|
||||
Trust: string(w.TrustLevel),
|
||||
LastHeartbeatAt: w.LastHeartbeatAt,
|
||||
Owner: "cluster token",
|
||||
}
|
||||
if w.OwnerID != nil {
|
||||
card.OwnerID = w.OwnerID.String()
|
||||
card.Owner = "user " + shortID(w.OwnerID.String())
|
||||
if email, ok := ownerEmails[*w.OwnerID]; ok && email != "" {
|
||||
card.Owner = email
|
||||
}
|
||||
}
|
||||
out.Workers = append(out.Workers, card)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetTrust reclassifies one worker (trusted/untrusted).
|
||||
func (a *Admin) SetTrust(ctx context.Context, id uuid.UUID, trusted bool) error {
|
||||
trust := domain.WorkerUntrusted
|
||||
if trusted {
|
||||
trust = domain.WorkerTrusted
|
||||
}
|
||||
return a.workers.SetTrust(ctx, id, trust)
|
||||
}
|
||||
|
||||
// AdminWorkloadView is the catalog plus the persisted enable flag.
|
||||
type AdminWorkloadView struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Reduction string `json:"reduction"`
|
||||
Parameters int `json:"parameters"`
|
||||
UploadReady bool `json:"upload_ready"`
|
||||
Enabled bool `json:"enabled"`
|
||||
DefaultOn bool `json:"default_on"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type AdminWorkloadsView struct {
|
||||
Workloads []AdminWorkloadView `json:"workloads"`
|
||||
}
|
||||
|
||||
// Workloads lists the catalog with persisted enable/disable overrides.
|
||||
func (a *Admin) Workloads(ctx context.Context) (AdminWorkloadsView, error) {
|
||||
if a.catalog == nil {
|
||||
return AdminWorkloadsView{}, domain.ErrInvalidInput
|
||||
}
|
||||
items := a.catalog.Items()
|
||||
overrides, err := a.settings.List(ctx)
|
||||
if err != nil {
|
||||
return AdminWorkloadsView{}, err
|
||||
}
|
||||
enabled := make(map[string]WorkloadSetting, len(overrides))
|
||||
for _, s := range overrides {
|
||||
enabled[s.Workload] = s
|
||||
}
|
||||
out := AdminWorkloadsView{Workloads: make([]AdminWorkloadView, 0, len(items))}
|
||||
for _, item := range items {
|
||||
params := 0
|
||||
if properties, ok := item.Parameters["properties"].(map[string]any); ok {
|
||||
params = len(properties)
|
||||
}
|
||||
view := AdminWorkloadView{
|
||||
Name: item.Name,
|
||||
Description: item.Description,
|
||||
Reduction: item.Reduction,
|
||||
Parameters: params,
|
||||
UploadReady: item.UploadReady,
|
||||
Enabled: true,
|
||||
DefaultOn: true,
|
||||
}
|
||||
if s, ok := enabled[item.Name]; ok {
|
||||
view.Enabled = s.Enabled
|
||||
view.DefaultOn = false
|
||||
view.UpdatedAt = &s.UpdatedAt
|
||||
}
|
||||
out.Workloads = append(out.Workloads, view)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetWorkloadEnabled flips the persisted enable flag. An unknown workload is
|
||||
// rejected: the admin console must not invent catalog entries.
|
||||
func (a *Admin) SetWorkloadEnabled(ctx context.Context, name string, enabled bool) error {
|
||||
if a.catalog == nil || a.catalog.ByName(name) == nil {
|
||||
return domain.ErrInvalidInput
|
||||
}
|
||||
return a.settings.SetEnabled(ctx, name, enabled, a.now())
|
||||
}
|
||||
|
||||
// AdminSettingsView is the read-only cluster configuration the Settings page
|
||||
// shows. The token is never included; it is revealed only through
|
||||
// RevealWorkerToken, which audits.
|
||||
type AdminSettingsView struct {
|
||||
PublicURL string `json:"public_url"`
|
||||
Addr string `json:"addr"`
|
||||
DataDir string `json:"data_dir"`
|
||||
DBEngine string `json:"db_engine"`
|
||||
Binary string `json:"binary"`
|
||||
}
|
||||
|
||||
func (a *Admin) Settings() AdminSettingsView {
|
||||
return AdminSettingsView{
|
||||
PublicURL: a.node.PublicURL,
|
||||
Addr: a.node.Addr,
|
||||
DataDir: a.node.DataDir,
|
||||
DBEngine: a.node.DBEngine,
|
||||
Binary: a.node.Binary,
|
||||
}
|
||||
}
|
||||
|
||||
// RevealWorkerToken returns the shared worker token for the Settings page and
|
||||
// records the reveal in the audit log. It must only be called for an admin
|
||||
// session.
|
||||
func (a *Admin) RevealWorkerToken(ctx context.Context, actor string) string {
|
||||
token := a.revealToken(ctx, actor)
|
||||
if a.audit != nil {
|
||||
a.audit(ctx, "worker token revealed", "by "+actor)
|
||||
}
|
||||
if a.log != nil {
|
||||
a.log.Warn("admin console revealed the worker token", "actor", actor)
|
||||
}
|
||||
return token
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/workloads"
|
||||
)
|
||||
|
||||
type fakeAdminRead struct {
|
||||
jobs []domain.Job
|
||||
taskCounts map[uuid.UUID]map[string]int
|
||||
sizes map[string]int64
|
||||
byDay map[string]int
|
||||
byWorkload map[string]int
|
||||
completed int64
|
||||
failed int64
|
||||
avg float64
|
||||
dbSize int64
|
||||
}
|
||||
|
||||
func (f *fakeAdminRead) ListJobsPaginated(ctx context.Context, status string, limit, offset int) ([]domain.Job, int, error) {
|
||||
var out []domain.Job
|
||||
for _, j := range f.jobs {
|
||||
if status == "" || string(j.Status) == status {
|
||||
out = append(out, j)
|
||||
}
|
||||
}
|
||||
total := len(out)
|
||||
if offset >= len(out) {
|
||||
return nil, total, nil
|
||||
}
|
||||
if offset+limit < len(out) {
|
||||
out = out[offset : offset+limit]
|
||||
} else {
|
||||
out = out[offset:]
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
func (f *fakeAdminRead) CountJobsByStatus(ctx context.Context) (map[string]int, error) {
|
||||
out := map[string]int{}
|
||||
for _, j := range f.jobs {
|
||||
out[string(j.Status)]++
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeAdminRead) TaskCountsByJobs(ctx context.Context, jobIDs []uuid.UUID) (map[uuid.UUID]map[string]int, error) {
|
||||
return f.taskCounts, nil
|
||||
}
|
||||
|
||||
func (f *fakeAdminRead) JobCountsByDay(ctx context.Context, since time.Time) (map[string]int, error) {
|
||||
return f.byDay, nil
|
||||
}
|
||||
func (f *fakeAdminRead) JobCountsByWorkload(ctx context.Context) (map[string]int, error) {
|
||||
return f.byWorkload, nil
|
||||
}
|
||||
func (f *fakeAdminRead) TaskStats(ctx context.Context) (int64, int64, float64, error) {
|
||||
return f.completed, f.failed, f.avg, nil
|
||||
}
|
||||
func (f *fakeAdminRead) ArtifactSizeByKind(ctx context.Context) (map[string]int64, error) {
|
||||
return f.sizes, nil
|
||||
}
|
||||
func (f *fakeAdminRead) DatabaseSizeBytes(ctx context.Context) (int64, error) { return f.dbSize, nil }
|
||||
|
||||
type fakeUIRead struct {
|
||||
UIReadRepository // embedded: only ListWorkers is exercised
|
||||
workers []domain.Worker
|
||||
}
|
||||
|
||||
type fakeSettings struct {
|
||||
WorkloadSettingsRepository // embedded: only the methods below are exercised
|
||||
overrides map[string]bool
|
||||
}
|
||||
|
||||
func (f *fakeSettings) GetEnabled(ctx context.Context, workload string) (bool, error) {
|
||||
if enabled, ok := f.overrides[workload]; ok {
|
||||
return enabled, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (f *fakeSettings) List(ctx context.Context) ([]WorkloadSetting, error) {
|
||||
out := make([]WorkloadSetting, 0, len(f.overrides))
|
||||
for name, enabled := range f.overrides {
|
||||
out = append(out, WorkloadSetting{Workload: name, Enabled: enabled, UpdatedAt: time.Now()})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeSettings) SetEnabled(ctx context.Context, workload string, enabled bool, now time.Time) error {
|
||||
if f.overrides == nil {
|
||||
f.overrides = map[string]bool{}
|
||||
}
|
||||
f.overrides[workload] = enabled
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeUIRead) ListWorkers(ctx context.Context, limit int) ([]domain.Worker, error) {
|
||||
return f.workers, nil
|
||||
}
|
||||
|
||||
func adminFixture() *Admin {
|
||||
return NewAdmin(
|
||||
&fakeAdminRead{},
|
||||
&fakeUIRead{},
|
||||
nil, // workers repo
|
||||
&fakeSettings{},
|
||||
nil, // catalog
|
||||
AdminNodeInfo{
|
||||
Version: "1.1.0-alpha.1", StartedAt: time.Unix(1_000_000, 0).UTC(),
|
||||
Binary: "/usr/local/bin/coordinator", Addr: ":8080", DataDir: "/var/lib/scimesh",
|
||||
DBEngine: "sqlite", PublicURL: "http://192.168.1.10:8080", Userservice: "http://127.0.0.1:41273",
|
||||
},
|
||||
func(context.Context) error { return nil },
|
||||
func() time.Time { return time.Unix(1_000_000+3600*3, 0).UTC() },
|
||||
)
|
||||
}
|
||||
|
||||
func TestAdminSystemAssemblesKpis(t *testing.T) {
|
||||
owner := uuid.New()
|
||||
a := adminFixture()
|
||||
a.read = &fakeAdminRead{
|
||||
jobs: []domain.Job{
|
||||
{ID: uuid.New(), Status: domain.JobRunning, Workload: "similarity-search"},
|
||||
{ID: uuid.New(), Status: domain.JobPending, Workload: "similarity-search", OwnerID: &owner},
|
||||
{ID: uuid.New(), Status: domain.JobCompleted, Workload: "molwt-filter"},
|
||||
},
|
||||
sizes: map[string]int64{"input": 1 << 20, "shard": 2 << 20},
|
||||
dbSize: 34 << 20,
|
||||
}
|
||||
a.uiRead = &fakeUIRead{workers: []domain.Worker{
|
||||
{Status: domain.WorkerOnline},
|
||||
{Status: domain.WorkerBusy},
|
||||
{Status: domain.WorkerOffline},
|
||||
}}
|
||||
|
||||
v, err := a.System(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.Version != "1.1.0-alpha.1" {
|
||||
t.Errorf("version = %q", v.Version)
|
||||
}
|
||||
if v.ActiveJobs != 2 || v.WaitingJobs != 1 || v.RunningJobs != 1 {
|
||||
t.Errorf("jobs: active=%d waiting=%d running=%d, want 2/1/1", v.ActiveJobs, v.WaitingJobs, v.RunningJobs)
|
||||
}
|
||||
if v.WorkersOnline != 2 || v.WorkersBusy != 1 || v.WorkersTotal != 3 {
|
||||
t.Errorf("workers: online=%d busy=%d total=%d, want 2/1/3", v.WorkersOnline, v.WorkersBusy, v.WorkersTotal)
|
||||
}
|
||||
if v.UptimeSeconds != 10800 {
|
||||
t.Errorf("uptime = %d, want 10800", v.UptimeSeconds)
|
||||
}
|
||||
if v.Storage.DatasetsBytes != 1<<20 || v.Storage.ArtifactsBytes != 2<<20 || v.Storage.DatabaseBytes != 34<<20 {
|
||||
t.Errorf("storage = %+v", v.Storage)
|
||||
}
|
||||
if v.Health.Database != "connected" || v.Health.Userservice != "embedded" || v.Health.Reducer != "idle" {
|
||||
t.Errorf("health = %+v", v.Health)
|
||||
}
|
||||
if v.Node.Binary != "/usr/local/bin/coordinator" || v.Node.DBEngine != "sqlite" {
|
||||
t.Errorf("node = %+v", v.Node)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSystemReportsUnhealthyDatabase(t *testing.T) {
|
||||
a := adminFixture()
|
||||
a.ready = func(context.Context) error { return errors.New("connection refused") }
|
||||
v, err := a.System(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.Health.Database != "error" {
|
||||
t.Errorf("database health = %q, want error", v.Health.Database)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminJobsDerivesStatusAndResolvesOwners(t *testing.T) {
|
||||
jobID := uuid.New()
|
||||
owner := uuid.New()
|
||||
a := adminFixture()
|
||||
a.read = &fakeAdminRead{
|
||||
jobs: []domain.Job{{ID: jobID, Status: domain.JobPending, Workload: "similarity-graph", OwnerID: &owner, CreatedAt: time.Unix(100, 0)}},
|
||||
taskCounts: map[uuid.UUID]map[string]int{
|
||||
jobID: {"completed": 5, "failed": 1, "running": 2},
|
||||
},
|
||||
}
|
||||
|
||||
view, err := a.Jobs(context.Background(), "", 1, 20, map[uuid.UUID]string{owner: "alice@lab.org"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(view.Jobs) != 1 {
|
||||
t.Fatalf("jobs = %d, want 1", len(view.Jobs))
|
||||
}
|
||||
card := view.Jobs[0]
|
||||
if card.Owner != "alice@lab.org" || card.OwnerID != owner.String() {
|
||||
t.Errorf("owner = %q (%s)", card.Owner, card.OwnerID)
|
||||
}
|
||||
if card.Total != 8 || card.Completed != 5 || card.Failed != 1 {
|
||||
t.Errorf("progress: total=%d completed=%d failed=%d", card.Total, card.Completed, card.Failed)
|
||||
}
|
||||
if card.Status != "running" {
|
||||
t.Errorf("derived status = %q, want running (5 completed / 8 with 1 failed)", card.Status)
|
||||
}
|
||||
if view.Counts["pending"] != 1 {
|
||||
t.Errorf("counts = %v", view.Counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminJobsFallsBackWithoutOwners(t *testing.T) {
|
||||
jobID := uuid.New()
|
||||
a := adminFixture()
|
||||
a.read = &fakeAdminRead{jobs: []domain.Job{{ID: jobID, Status: domain.JobPending, Workload: "x", CreatedAt: time.Unix(100, 0)}}}
|
||||
view, err := a.Jobs(context.Background(), "", 1, 20, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if view.Jobs[0].Owner != "cluster token" {
|
||||
t.Errorf("owner fallback = %q, want cluster token", view.Jobs[0].Owner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminMetricsBuckets(t *testing.T) {
|
||||
now := time.Unix(1_000_000+3600*3, 0).UTC()
|
||||
since := now.Add(-6 * 24 * time.Hour).Truncate(24 * time.Hour)
|
||||
day := func(offset int) string { return since.Add(time.Duration(offset) * 24 * time.Hour).Format("2006-01-02") }
|
||||
a := adminFixture()
|
||||
a.read = &fakeAdminRead{
|
||||
byDay: map[string]int{day(1): 1, day(6): 4},
|
||||
byWorkload: map[string]int{"molwt-filter": 1, "similarity-search": 5},
|
||||
completed: 100, failed: 4, avg: 2.5,
|
||||
}
|
||||
v, err := a.Metrics(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(v.JobsByDay) != 7 || v.JobsLast7Days != 5 {
|
||||
t.Errorf("by day: %d entries, total %d (want 7 / 5)", len(v.JobsByDay), v.JobsLast7Days)
|
||||
}
|
||||
if v.JobsByDay[6].Count != 4 || v.JobsByDay[1].Count != 1 {
|
||||
t.Errorf("by day = %+v", v.JobsByDay)
|
||||
}
|
||||
if v.JobsByWorkload[0].Workload != "similarity-search" || v.JobsByWorkload[0].Count != 5 {
|
||||
t.Errorf("by workload = %+v", v.JobsByWorkload)
|
||||
}
|
||||
if v.FailureRate != 4.0/104.0 || v.AvgShardSeconds != 2.5 {
|
||||
t.Errorf("rate=%.4f avg=%.2f", v.FailureRate, v.AvgShardSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeWorkerRepo struct {
|
||||
WorkerRepository // embedded: only SetTrust is exercised
|
||||
}
|
||||
|
||||
func (f *fakeWorkerRepo) SetTrust(ctx context.Context, id uuid.UUID, trust domain.WorkerTrust) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAdminWorkersAndTrust(t *testing.T) {
|
||||
owner := uuid.New()
|
||||
a := adminFixture()
|
||||
a.uiRead = &fakeUIRead{workers: []domain.Worker{
|
||||
{ID: uuid.New(), Name: "lab-node-01", Status: domain.WorkerBusy, TrustLevel: domain.WorkerTrusted, Capabilities: []string{"similarity-search"}},
|
||||
{ID: uuid.New(), Name: "emil-laptop", Status: domain.WorkerOnline, TrustLevel: domain.WorkerUntrusted, OwnerID: &owner},
|
||||
}}
|
||||
view, err := a.Workers(context.Background(), map[uuid.UUID]string{owner: "alice@lab.org"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(view.Workers) != 2 {
|
||||
t.Fatalf("workers = %d, want 2", len(view.Workers))
|
||||
}
|
||||
if view.Workers[0].Trust != "trusted" || view.Workers[1].Trust != "untrusted" {
|
||||
t.Errorf("trust flags wrong: %+v", view.Workers)
|
||||
}
|
||||
if view.Workers[1].Owner != "alice@lab.org" {
|
||||
t.Errorf("owner = %q, want alice@lab.org", view.Workers[1].Owner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSetTrust(t *testing.T) {
|
||||
called := false
|
||||
a := adminFixture()
|
||||
a.workers = &fakeWorkerRepo{}
|
||||
_ = called
|
||||
if err := a.SetTrust(context.Background(), uuid.New(), false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminWorkloadsWithOverrides(t *testing.T) {
|
||||
catalog, err := workloads.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := adminFixture()
|
||||
a.catalog = catalog
|
||||
a.settings = &fakeSettings{overrides: map[string]bool{"molwt-filter": false}}
|
||||
view, err := a.Workloads(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, w := range view.Workloads {
|
||||
if w.Name == "molwt-filter" {
|
||||
found = true
|
||||
if w.Enabled || w.DefaultOn {
|
||||
t.Errorf("molwt-filter: enabled=%v default_on=%v, want disabled override", w.Enabled, w.DefaultOn)
|
||||
}
|
||||
}
|
||||
if w.Name == "similarity-search" && !w.Enabled {
|
||||
t.Error("similarity-search must stay enabled (no override)")
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("molwt-filter missing from the catalog view")
|
||||
}
|
||||
if err := a.SetWorkloadEnabled(context.Background(), "similarity-search", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.SetWorkloadEnabled(context.Background(), "nope", false); err == nil {
|
||||
t.Error("unknown workload must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRevealToken(t *testing.T) {
|
||||
a := adminFixture()
|
||||
a.node.WorkerToken = func() string { return "sm_live_secret" }
|
||||
if got := a.RevealWorkerToken(context.Background(), "admin:user"); got != "sm_live_secret" {
|
||||
t.Errorf("token = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,9 @@ type WorkerRepository interface {
|
||||
// MarkStaleOffline flips every worker last seen before cutoff to offline and
|
||||
// reports how many changed.
|
||||
MarkStaleOffline(ctx context.Context, cutoff time.Time) (int64, error)
|
||||
// SetTrust reclassifies a worker's trust level (trusted/untrusted). Returns
|
||||
// ErrNotFound when the id is unknown.
|
||||
SetTrust(ctx context.Context, id uuid.UUID, trust domain.WorkerTrust) error
|
||||
}
|
||||
|
||||
// ArtifactRepository persists artifact metadata. The bytes live in a BlobStore;
|
||||
@@ -131,6 +134,26 @@ type Clock interface {
|
||||
Now() time.Time
|
||||
}
|
||||
|
||||
// WorkloadSetting is one persisted enable/disable override from the admin
|
||||
// console. A workload with no row in the store is enabled by default.
|
||||
type WorkloadSetting struct {
|
||||
Workload string `json:"workload"`
|
||||
Enabled bool `json:"enabled"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// WorkloadSettingsRepository persists the admin enable/disable overrides on
|
||||
// top of the embedded workload catalog.
|
||||
type WorkloadSettingsRepository interface {
|
||||
// GetEnabled reports whether the workload is enabled. True when the
|
||||
// workload has no override row (catalog default).
|
||||
GetEnabled(ctx context.Context, workload string) (bool, error)
|
||||
// List returns every override row, newest update first.
|
||||
List(ctx context.Context) ([]WorkloadSetting, error)
|
||||
// SetEnabled upserts the override.
|
||||
SetEnabled(ctx context.Context, workload string, enabled bool, now time.Time) error
|
||||
}
|
||||
|
||||
// ErrNotImplemented marks scaffold code with no body yet. Unlike the errors in
|
||||
// domain, it describes the state of this codebase, not a business rule.
|
||||
var ErrNotImplemented = errors.New("not implemented")
|
||||
|
||||
@@ -24,17 +24,27 @@ type SubmitDataset struct {
|
||||
clk Clock
|
||||
maxAttempts int
|
||||
catalog *workloads.Catalog
|
||||
settings WorkloadSettingsRepository
|
||||
}
|
||||
|
||||
func NewSubmitDataset(blobs BlobStore, artifacts ArtifactRepository, jobs JobRepository,
|
||||
tasks TaskRepository, tx TxManager, clk Clock, maxAttempts int, catalog *workloads.Catalog) *SubmitDataset {
|
||||
return &SubmitDataset{blobs: blobs, artifacts: artifacts, jobs: jobs, tasks: tasks, tx: tx, clk: clk, maxAttempts: maxAttempts, catalog: catalog}
|
||||
tasks TaskRepository, tx TxManager, clk Clock, maxAttempts int, catalog *workloads.Catalog, settings WorkloadSettingsRepository) *SubmitDataset {
|
||||
return &SubmitDataset{blobs: blobs, artifacts: artifacts, jobs: jobs, tasks: tasks, tx: tx, clk: clk, maxAttempts: maxAttempts, catalog: catalog, settings: settings}
|
||||
}
|
||||
|
||||
func (uc *SubmitDataset) Execute(ctx context.Context, in SubmitDatasetInput) (SubmitDatasetResult, error) {
|
||||
if err := validateUploadedWorkload(uc.catalog, in.Workload, in.Parameters); err != nil {
|
||||
return SubmitDatasetResult{}, err
|
||||
}
|
||||
if uc.settings != nil {
|
||||
enabled, err := uc.settings.GetEnabled(ctx, in.Workload)
|
||||
if err != nil {
|
||||
return SubmitDatasetResult{}, err
|
||||
}
|
||||
if !enabled {
|
||||
return SubmitDatasetResult{}, domain.ErrWorkloadDisabled
|
||||
}
|
||||
}
|
||||
if uc.maxAttempts < 1 {
|
||||
return SubmitDatasetResult{}, domain.ErrInvalidInput
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ type harness struct {
|
||||
blobs *memstore.BlobStore
|
||||
clk *memstore.Clock
|
||||
taskResults *memstore.TaskResultRepo
|
||||
settings *memSettings
|
||||
|
||||
createJob *usecase.CreateJob
|
||||
submit *usecase.SubmitDataset
|
||||
@@ -70,10 +71,11 @@ func newHarness() *harness {
|
||||
blobs: memstore.NewBlobStore(),
|
||||
clk: memstore.NewClock(time.Date(2026, 7, 21, 12, 0, 0, 0, time.UTC)),
|
||||
taskResults: memstore.NewTaskResultRepo(),
|
||||
settings: newMemSettings(),
|
||||
}
|
||||
tx := memstore.Tx{}
|
||||
h.createJob = usecase.NewCreateJob(h.jobs, h.tasks, tx, h.clk)
|
||||
h.submit = usecase.NewSubmitDataset(h.blobs, h.arts, h.jobs, h.tasks, tx, h.clk, 3, testCatalog())
|
||||
h.submit = usecase.NewSubmitDataset(h.blobs, h.arts, h.jobs, h.tasks, tx, h.clk, 3, testCatalog(), h.settings)
|
||||
h.claim = usecase.NewClaimTask(h.tasks, h.jobs, h.work, tx, h.clk, lease, testCatalog())
|
||||
h.renew = usecase.NewRenewLease(h.tasks, h.work, tx, h.clk, lease)
|
||||
h.complete = usecase.NewCompleteTask(h.tasks, h.jobs, h.arts, h.work, h.taskResults, tx, h.clk, 2, testCatalog())
|
||||
@@ -935,3 +937,46 @@ func TestFinalLeaseExpiryPersistsFailedJobAndCannotBeCancelled(t *testing.T) {
|
||||
t.Errorf("cancel terminal lease failure = %v, want ErrJobNotCancellable", err)
|
||||
}
|
||||
}
|
||||
|
||||
// memSettings is an in-memory WorkloadSettingsRepository for tests.
|
||||
type memSettings struct {
|
||||
overrides map[string]bool
|
||||
}
|
||||
|
||||
func newMemSettings() *memSettings { return &memSettings{overrides: map[string]bool{}} }
|
||||
|
||||
func (m *memSettings) GetEnabled(ctx context.Context, workload string) (bool, error) {
|
||||
if enabled, ok := m.overrides[workload]; ok {
|
||||
return enabled, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (m *memSettings) List(ctx context.Context) ([]usecase.WorkloadSetting, error) { return nil, nil }
|
||||
|
||||
func (m *memSettings) SetEnabled(ctx context.Context, workload string, enabled bool, now time.Time) error {
|
||||
m.overrides[workload] = enabled
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestSubmitDatasetRejectsDisabledWorkload(t *testing.T) {
|
||||
h := newHarness()
|
||||
h.settings.SetEnabled(ctx, "molwt-filter", false, h.clk.Now())
|
||||
_, err := h.submit.Execute(ctx, usecase.SubmitDatasetInput{
|
||||
Workload: "molwt-filter", Parameters: map[string]any{"min_molwt": 100, "max_molwt": 600},
|
||||
RowsPerShard: 2, Filename: "m.tsv", ContentType: "text/tab-separated-values",
|
||||
Body: strings.NewReader("smiles\nCC\n"),
|
||||
})
|
||||
if !errors.Is(err, domain.ErrWorkloadDisabled) {
|
||||
t.Fatalf("err = %v, want ErrWorkloadDisabled", err)
|
||||
}
|
||||
// Re-enabling accepts the same submit.
|
||||
h.settings.SetEnabled(ctx, "molwt-filter", true, h.clk.Now())
|
||||
if _, err := h.submit.Execute(ctx, usecase.SubmitDatasetInput{
|
||||
Workload: "molwt-filter", Parameters: map[string]any{"min_molwt": 100, "max_molwt": 600},
|
||||
RowsPerShard: 2, Filename: "m.tsv", ContentType: "text/tab-separated-values",
|
||||
Body: strings.NewReader("chembl_id\tcanonical_smiles\nA\tCC\n"),
|
||||
}); err != nil {
|
||||
t.Fatalf("submit after re-enable: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
|
||||
)
|
||||
|
||||
// Claims is the payload of a signed token. Subject (from RegisteredClaims) is
|
||||
// the user id — it becomes the coordinator's jobs.owner_id; Role drives
|
||||
// authorization; Verified tells the coordinator whether this user's workers are
|
||||
// trusted (results accepted without quorum). Both services verify this token
|
||||
// locally with the shared HS256 secret, so no runtime call back to the
|
||||
// userservice is ever needed.
|
||||
type Claims struct {
|
||||
Role domain.Role `json:"role"`
|
||||
Verified bool `json:"verified"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// Issuer signs and verifies tokens with a shared HS256 secret.
|
||||
type Issuer struct {
|
||||
secret []byte
|
||||
ttl time.Duration
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewIssuer builds an Issuer. now defaults to time.Now when nil; tests inject a
|
||||
// fixed clock to make expiry deterministic.
|
||||
func NewIssuer(secret string, ttl time.Duration, now func() time.Time) Issuer {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return Issuer{secret: []byte(secret), ttl: ttl, now: now}
|
||||
}
|
||||
|
||||
// Issue returns a signed token for the user, valid for the configured TTL. It
|
||||
// takes the whole user so every trust-bearing field (role, verified) travels in
|
||||
// the token, keeping the two services from needing a runtime lookup.
|
||||
func (i Issuer) Issue(u *domain.User) (string, error) {
|
||||
now := i.now()
|
||||
claims := Claims{
|
||||
Role: u.Role,
|
||||
Verified: u.Verified,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: u.ID.String(),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(i.ttl)),
|
||||
},
|
||||
}
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(i.secret)
|
||||
}
|
||||
|
||||
// Verify checks the signature and expiry and returns the claims. It pins the
|
||||
// algorithm to HMAC, rejecting a token that asks for "none" or an RS256 public
|
||||
// key — the classic algorithm-substitution attack against naive verifiers.
|
||||
func (i Issuer) Verify(token string) (*Claims, error) {
|
||||
var claims Claims
|
||||
_, err := jwt.ParseWithClaims(token, &claims, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
return i.secret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &claims, nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
|
||||
)
|
||||
|
||||
const testSecret = "test-secret-at-least-32-bytes-long!!"
|
||||
|
||||
func TestIssueVerifyRoundTrip(t *testing.T) {
|
||||
iss := NewIssuer(testSecret, time.Hour, nil)
|
||||
id := uuid.New()
|
||||
|
||||
token, err := iss.Issue(&domain.User{ID: id, Role: domain.RoleAdmin, Verified: true})
|
||||
if err != nil {
|
||||
t.Fatalf("issue: %v", err)
|
||||
}
|
||||
|
||||
claims, err := iss.Verify(token)
|
||||
if err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
if claims.Subject != id.String() {
|
||||
t.Errorf("sub = %q, want %q", claims.Subject, id.String())
|
||||
}
|
||||
if claims.Role != domain.RoleAdmin {
|
||||
t.Errorf("role = %q, want admin", claims.Role)
|
||||
}
|
||||
if !claims.Verified {
|
||||
t.Error("verified claim not carried in token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsExpired(t *testing.T) {
|
||||
// Negative TTL: the token is already expired when issued.
|
||||
iss := NewIssuer(testSecret, -time.Minute, nil)
|
||||
token, _ := iss.Issue(&domain.User{ID: uuid.New(), Role: domain.RoleUser})
|
||||
|
||||
if _, err := iss.Verify(token); err == nil {
|
||||
t.Error("expired token accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsWrongSecret(t *testing.T) {
|
||||
token, _ := NewIssuer(testSecret, time.Hour, nil).Issue(&domain.User{ID: uuid.New(), Role: domain.RoleUser})
|
||||
|
||||
other := NewIssuer("another-secret-also-32-bytes-long!!!", time.Hour, nil)
|
||||
if _, err := other.Verify(token); err == nil {
|
||||
t.Error("token verified under the wrong secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsNoneAlgorithm(t *testing.T) {
|
||||
// Forge a token signed with "none" — the classic algorithm-substitution
|
||||
// attack. A verifier that trusts the header's alg would accept it.
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodNone, Claims{
|
||||
Role: domain.RoleAdmin,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: uuid.New().String(),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||
},
|
||||
})
|
||||
raw, err := tok.SignedString(jwt.UnsafeAllowNoneSignatureType)
|
||||
if err != nil {
|
||||
t.Fatalf("sign none: %v", err)
|
||||
}
|
||||
|
||||
iss := NewIssuer(testSecret, time.Hour, nil)
|
||||
if _, err := iss.Verify(raw); err == nil {
|
||||
t.Error("none-signed token accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Package auth holds the cryptographic adapters — password hashing and JWT
|
||||
// signing/verification. They implement use-case ports and keep bcrypt and the
|
||||
// JWT library out of the domain and use-case layers.
|
||||
package auth
|
||||
|
||||
import "golang.org/x/crypto/bcrypt"
|
||||
|
||||
// Hasher turns plaintext passwords into storable hashes and checks them back.
|
||||
type Hasher struct {
|
||||
cost int
|
||||
}
|
||||
|
||||
// NewHasher builds a Hasher. A cost of 0 uses bcrypt's default work factor.
|
||||
func NewHasher(cost int) Hasher {
|
||||
if cost == 0 {
|
||||
cost = bcrypt.DefaultCost
|
||||
}
|
||||
return Hasher{cost: cost}
|
||||
}
|
||||
|
||||
// Hash returns the bcrypt hash of password. The salt and the cost are embedded
|
||||
// in the returned string, so nothing else needs to be stored alongside it.
|
||||
//
|
||||
// bcrypt silently ignores input past 72 bytes; the use case rejects longer
|
||||
// passwords before reaching here so a truncated tail never becomes a security
|
||||
// surprise.
|
||||
func (h Hasher) Hash(password string) (string, error) {
|
||||
b, err := bcrypt.GenerateFromPassword([]byte(password), h.cost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// Compare reports whether password matches the stored hash. It returns a
|
||||
// non-nil error (bcrypt.ErrMismatchedHashAndPassword) on any mismatch, which
|
||||
// the caller collapses into a generic authentication failure.
|
||||
func (h Hasher) Compare(hash, password string) error {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package auth
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHashAndCompare(t *testing.T) {
|
||||
h := NewHasher(0) // default cost
|
||||
|
||||
hash, err := h.Hash("correct horse battery staple")
|
||||
if err != nil {
|
||||
t.Fatalf("hash: %v", err)
|
||||
}
|
||||
if hash == "correct horse battery staple" {
|
||||
t.Fatal("hash must not equal the plaintext")
|
||||
}
|
||||
if err := h.Compare(hash, "correct horse battery staple"); err != nil {
|
||||
t.Errorf("correct password rejected: %v", err)
|
||||
}
|
||||
if err := h.Compare(hash, "wrong password"); err == nil {
|
||||
t.Error("wrong password accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashSaltsEachTime(t *testing.T) {
|
||||
h := NewHasher(0)
|
||||
a, _ := h.Hash("same")
|
||||
b, _ := h.Hash("same")
|
||||
if a == b {
|
||||
t.Error("two hashes of the same password must differ (random salt)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package domain
|
||||
|
||||
import "errors"
|
||||
|
||||
// Domain validation errors. They describe an entity that cannot be constructed,
|
||||
// independent of storage or transport, and the HTTP layer maps them to 400.
|
||||
var (
|
||||
ErrEmptyEmail = errors.New("email is required")
|
||||
ErrInvalidEmail = errors.New("email is not a valid address")
|
||||
ErrEmptyPasswordHash = errors.New("password hash is required")
|
||||
|
||||
ErrWorkerKeyNameTooLong = errors.New("worker key name is too long")
|
||||
)
|
||||
@@ -0,0 +1,84 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleAdmin Role = "admin"
|
||||
RoleUser Role = "user"
|
||||
)
|
||||
|
||||
func (r Role) Valid() bool {
|
||||
switch r {
|
||||
case RoleAdmin, RoleUser:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID uuid.UUID
|
||||
Email string
|
||||
PasswordHash string
|
||||
Role Role
|
||||
// Verified marks a trusted contributor whose workers' results the
|
||||
// coordinator accepts without quorum. Distinct from Role; granted by an
|
||||
// admin, defaults to false.
|
||||
Verified bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// NewUser builds a freshly registered account. It normalises the email and
|
||||
// enforces every invariant a row must satisfy, so an invalid User cannot be
|
||||
// constructed. The caller supplies the already-hashed password — hashing is an
|
||||
// adapter's job, not the domain's.
|
||||
//
|
||||
// Registration always produces a plain user; promotion to admin is a manual,
|
||||
// out-of-band operation, never something a request can trigger.
|
||||
func NewUser(email, passwordHash string, now time.Time) (*User, error) {
|
||||
email = NormalizeEmail(email)
|
||||
if err := validateEmail(email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if passwordHash == "" {
|
||||
return nil, ErrEmptyPasswordHash
|
||||
}
|
||||
return &User{
|
||||
ID: uuid.New(),
|
||||
Email: email,
|
||||
PasswordHash: passwordHash,
|
||||
Role: RoleUser,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NormalizeEmail lower-cases and trims an address so that "Bob@X.com " and
|
||||
// "bob@x.com" resolve to the same account. Every lookup and every insert must
|
||||
// pass through here, matching the ck_users_email_lower database constraint.
|
||||
func NormalizeEmail(email string) string {
|
||||
return strings.ToLower(strings.TrimSpace(email))
|
||||
}
|
||||
|
||||
func validateEmail(email string) error {
|
||||
if email == "" {
|
||||
return ErrEmptyEmail
|
||||
}
|
||||
// A minimal shape check, not full RFC 5322: real deliverability is proven by
|
||||
// sending mail, not by a regex. mail.ParseAddress also accepts the
|
||||
// "Name <addr>" form, so we insist the parsed address equals the input.
|
||||
addr, err := mail.ParseAddress(email)
|
||||
if err != nil || addr.Address != email {
|
||||
return ErrInvalidEmail
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestNewUserNormalisesAndValidates(t *testing.T) {
|
||||
now := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
u, err := NewUser(" Bob@Example.COM ", "hashed", now)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if u.Email != "bob@example.com" {
|
||||
t.Errorf("email not normalised: got %q", u.Email)
|
||||
}
|
||||
if u.Role != RoleUser {
|
||||
t.Errorf("new user must default to RoleUser, got %q", u.Role)
|
||||
}
|
||||
if u.ID == uuid.Nil {
|
||||
t.Error("new user must get an id")
|
||||
}
|
||||
if !u.CreatedAt.Equal(now) || !u.UpdatedAt.Equal(now) {
|
||||
t.Error("timestamps not set from clock")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewUserRejectsBadInput(t *testing.T) {
|
||||
now := time.Now()
|
||||
cases := []struct {
|
||||
name string
|
||||
email string
|
||||
hash string
|
||||
wantErr error
|
||||
}{
|
||||
{"empty email", "", "h", ErrEmptyEmail},
|
||||
{"no domain", "bob", "h", ErrInvalidEmail},
|
||||
{"name form", "Bob <bob@x.com>", "h", ErrInvalidEmail},
|
||||
{"empty hash", "bob@x.com", "", ErrEmptyPasswordHash},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := NewUser(tc.email, tc.hash, now)
|
||||
if !errors.Is(err, tc.wantErr) {
|
||||
t.Errorf("got %v, want %v", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleValid(t *testing.T) {
|
||||
if !RoleUser.Valid() || !RoleAdmin.Valid() {
|
||||
t.Error("user and admin must be valid")
|
||||
}
|
||||
if Role("root").Valid() {
|
||||
t.Error("unknown role must be invalid")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
// workerKeyLabel makes a key self-describing when it turns up in a log or an
|
||||
// env var, and lets a client sanity-check the shape before exchanging it.
|
||||
workerKeyLabel = "scimesh_wk_live_"
|
||||
// workerKeyRandomBytes is the entropy behind the secret. 24 bytes (192 bits)
|
||||
// is far beyond guessable, which is why the stored hash needs no salt.
|
||||
workerKeyRandomBytes = 24
|
||||
// workerKeyPrefixChars is how much of the random tail we keep, alongside the
|
||||
// label, as the non-secret identifier shown in the UI.
|
||||
workerKeyPrefixChars = 8
|
||||
// workerKeyNameMax caps the user-supplied label.
|
||||
workerKeyNameMax = 100
|
||||
// workerKeyDefaultName is used when the caller supplies no label.
|
||||
workerKeyDefaultName = "my machine"
|
||||
)
|
||||
|
||||
// WorkerKey is a long-lived, per-user credential for running a worker. The
|
||||
// secret itself is never stored — only TokenHash — so the plaintext returned by
|
||||
// NewWorkerKey is the one and only chance to show it to the user.
|
||||
type WorkerKey struct {
|
||||
ID uuid.UUID
|
||||
UserID uuid.UUID
|
||||
Name string
|
||||
TokenHash string
|
||||
Prefix string
|
||||
CreatedAt time.Time
|
||||
LastUsedAt *time.Time
|
||||
RevokedAt *time.Time
|
||||
}
|
||||
|
||||
// NewWorkerKey mints a key for a user and returns both the entity (carrying only
|
||||
// the hash) and the one-time plaintext to hand back to the caller. The label is
|
||||
// trimmed and defaulted; an over-long one is rejected.
|
||||
func NewWorkerKey(userID uuid.UUID, name string, now time.Time) (*WorkerKey, string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
name = workerKeyDefaultName
|
||||
}
|
||||
if len(name) > workerKeyNameMax {
|
||||
return nil, "", ErrWorkerKeyNameTooLong
|
||||
}
|
||||
|
||||
b := make([]byte, workerKeyRandomBytes)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
// URL-safe, unpadded: the key rides in env vars and shell commands, so it
|
||||
// must contain no '=', '+', or '/' that a shell might mangle.
|
||||
raw := workerKeyLabel + base64.RawURLEncoding.EncodeToString(b)
|
||||
|
||||
key := &WorkerKey{
|
||||
ID: uuid.New(),
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
TokenHash: HashWorkerKey(raw),
|
||||
Prefix: raw[:len(workerKeyLabel)+workerKeyPrefixChars],
|
||||
CreatedAt: now,
|
||||
}
|
||||
return key, raw, nil
|
||||
}
|
||||
|
||||
// HashWorkerKey returns the hex SHA-256 of a presented key. Exchange hashes the
|
||||
// incoming key the same way and looks the row up by it, so the plaintext never
|
||||
// has to be compared directly.
|
||||
func HashWorkerKey(raw string) string {
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// Revoked reports whether the key has been retired and must no longer exchange.
|
||||
func (k *WorkerKey) Revoked() bool { return k.RevokedAt != nil }
|
||||
@@ -0,0 +1,62 @@
|
||||
package domain_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
|
||||
)
|
||||
|
||||
func TestNewWorkerKeyShape(t *testing.T) {
|
||||
owner := uuid.New()
|
||||
now := time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
key, raw, err := domain.NewWorkerKey(owner, "home-desktop", now)
|
||||
if err != nil {
|
||||
t.Fatalf("NewWorkerKey: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(raw, "scimesh_wk_live_") {
|
||||
t.Errorf("raw key has no recognisable label: %q", raw)
|
||||
}
|
||||
if key.TokenHash != domain.HashWorkerKey(raw) {
|
||||
t.Error("stored hash does not match the plaintext")
|
||||
}
|
||||
if key.TokenHash == raw || strings.Contains(key.TokenHash, raw) {
|
||||
t.Error("plaintext leaked into the stored hash")
|
||||
}
|
||||
if !strings.HasPrefix(raw, key.Prefix) {
|
||||
t.Errorf("prefix %q is not a leading slice of the key", key.Prefix)
|
||||
}
|
||||
if key.UserID != owner || key.CreatedAt != now || key.Revoked() {
|
||||
t.Errorf("unexpected key metadata: %+v", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWorkerKeyDefaultsBlankName(t *testing.T) {
|
||||
key, _, err := domain.NewWorkerKey(uuid.New(), " ", time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("NewWorkerKey: %v", err)
|
||||
}
|
||||
if key.Name == "" {
|
||||
t.Error("blank name was not defaulted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWorkerKeyRejectsLongName(t *testing.T) {
|
||||
_, _, err := domain.NewWorkerKey(uuid.New(), strings.Repeat("x", 101), time.Now())
|
||||
if !errors.Is(err, domain.ErrWorkerKeyNameTooLong) {
|
||||
t.Errorf("got %v, want ErrWorkerKeyNameTooLong", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewWorkerKeyUniquePerCall(t *testing.T) {
|
||||
a, rawA, _ := domain.NewWorkerKey(uuid.New(), "a", time.Now())
|
||||
b, rawB, _ := domain.NewWorkerKey(uuid.New(), "b", time.Now())
|
||||
if rawA == rawB || a.TokenHash == b.TokenHash || a.ID == b.ID {
|
||||
t.Error("two keys collided; generation is not random")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Package userservice is the SciMesh authentication service, embedded into the
|
||||
// coordinator binary for single-binary deployments. The packages here are the
|
||||
// same code the standalone `users/` service runs, with its PostgreSQL storage
|
||||
// replaced by an embedded SQLite backend. The coordinator's HTTP layer talks
|
||||
// to it through the usual USERSERVICE_URL proxy, so no proxy code changes.
|
||||
package userservice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/auth"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/storage/sqlite"
|
||||
usershttp "github.com/emil28092005/SciMesh/coordinator/internal/userservice/transport/http"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/usecase"
|
||||
)
|
||||
|
||||
// Config wires the embedded userservice.
|
||||
type Config struct {
|
||||
// DBPath is the sqlite database file (for example <data-dir>/users.db).
|
||||
DBPath string
|
||||
// JWTSecret must equal the coordinator's JWT_SECRET so tokens verify.
|
||||
JWTSecret string
|
||||
// AdminEmail/AdminPassword bootstrap the first admin on first run.
|
||||
AdminEmail string
|
||||
AdminPassword string
|
||||
// Log receives the service's log lines.
|
||||
Log *slog.Logger
|
||||
}
|
||||
|
||||
// Serve runs the embedded userservice until ctx is cancelled. It listens only
|
||||
// on the loopback interface; the coordinator proxies to it internally.
|
||||
func Serve(ctx context.Context, cfg Config) (string, func() error, error) {
|
||||
db, err := sqlite.Open(cfg.DBPath)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if err := sqlite.Migrate(ctx, db, cfg.Log); err != nil {
|
||||
_ = db.Close()
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
clock := NewClock()
|
||||
users := sqlite.NewUserRepo(db)
|
||||
workerKeys := sqlite.NewWorkerKeyRepo(db)
|
||||
hasher := auth.NewHasher(0)
|
||||
issuer := auth.NewIssuer(cfg.JWTSecret, 24*time.Hour, clock.Now)
|
||||
|
||||
uc := usershttp.UseCases{
|
||||
Register: usecase.NewRegister(users, hasher, clock),
|
||||
Login: usecase.NewLogin(users, hasher, issuer),
|
||||
SetVerified: usecase.NewSetVerified(users),
|
||||
SetRole: usecase.NewSetRole(users),
|
||||
CreateWorkerKey: usecase.NewCreateWorkerKey(workerKeys, clock),
|
||||
ListWorkerKeys: usecase.NewListWorkerKeys(workerKeys),
|
||||
ListWorkerKeysAll: usecase.NewListWorkerKeysAll(workerKeys),
|
||||
RevokeWorkerKey: usecase.NewRevokeWorkerKey(workerKeys),
|
||||
RevokeWorkerKeyAdmin: usecase.NewRevokeWorkerKeyAdmin(workerKeys),
|
||||
ExchangeWorkerKey: usecase.NewExchangeWorkerKey(workerKeys, users, issuer, 24*time.Hour),
|
||||
ListUsers: usecase.NewListUsers(users),
|
||||
Users: users,
|
||||
}
|
||||
|
||||
if cfg.AdminEmail != "" && cfg.AdminPassword != "" {
|
||||
created, err := usecase.NewBootstrapAdmin(users, hasher, clock).
|
||||
Execute(ctx, cfg.AdminEmail, cfg.AdminPassword)
|
||||
if err != nil {
|
||||
_ = db.Close()
|
||||
return "", nil, fmt.Errorf("bootstrap admin: %w", err)
|
||||
}
|
||||
if created {
|
||||
cfg.Log.Info("embedded userservice created the admin account", "email", cfg.AdminEmail)
|
||||
}
|
||||
}
|
||||
|
||||
handler := usershttp.NewServer(cfg.Log, uc, issuer)
|
||||
handler = http.TimeoutHandler(handler, 15*time.Second, `{"error":"request timeout"}`)
|
||||
|
||||
// Bind an ephemeral loopback port so a second serve instance can never
|
||||
// collide with the first; the coordinator's proxy uses the returned
|
||||
// address and needs no fixed-port assumption.
|
||||
listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
_ = db.Close()
|
||||
return "", nil, fmt.Errorf("listen for embedded userservice: %w", err)
|
||||
}
|
||||
server := &http.Server{
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = server.Shutdown(shutdownCtx)
|
||||
}()
|
||||
go func() {
|
||||
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
cfg.Log.Error("embedded userservice stopped", "err", err)
|
||||
}
|
||||
}()
|
||||
return listener.Addr().String(), func() error { return db.Close() }, nil
|
||||
}
|
||||
|
||||
// NewClock returns the userservice's wall clock.
|
||||
func NewClock() *Clock { return &Clock{} }
|
||||
|
||||
// Clock implements the userservice usecase clock.
|
||||
type Clock struct{}
|
||||
|
||||
// Now returns the current UTC time.
|
||||
func (c *Clock) Now() time.Time { return time.Now().UTC() }
|
||||
@@ -0,0 +1,189 @@
|
||||
// Package memstore provides in-memory implementations of the usecase ports for
|
||||
// fast, deterministic tests that need no database.
|
||||
package memstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/usecase"
|
||||
)
|
||||
|
||||
// UserRepo is an in-memory usecase.UserRepository. It stores copies, so callers
|
||||
// mutating a returned user cannot corrupt the store.
|
||||
type UserRepo struct {
|
||||
mu sync.Mutex
|
||||
byID map[uuid.UUID]domain.User
|
||||
byEmail map[string]uuid.UUID
|
||||
}
|
||||
|
||||
func NewUserRepo() *UserRepo {
|
||||
return &UserRepo{
|
||||
byID: make(map[uuid.UUID]domain.User),
|
||||
byEmail: make(map[string]uuid.UUID),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *UserRepo) Insert(_ context.Context, u *domain.User) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if _, ok := r.byEmail[u.Email]; ok {
|
||||
return usecase.ErrEmailExists
|
||||
}
|
||||
r.byID[u.ID] = *u
|
||||
r.byEmail[u.Email] = u.ID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *UserRepo) GetByEmail(_ context.Context, email string) (*domain.User, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
id, ok := r.byEmail[email]
|
||||
if !ok {
|
||||
return nil, usecase.ErrUserNotFound
|
||||
}
|
||||
u := r.byID[id]
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (r *UserRepo) GetByID(_ context.Context, id uuid.UUID) (*domain.User, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
u, ok := r.byID[id]
|
||||
if !ok {
|
||||
return nil, usecase.ErrUserNotFound
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (r *UserRepo) SetVerified(_ context.Context, id uuid.UUID, verified bool) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
u, ok := r.byID[id]
|
||||
if !ok {
|
||||
return usecase.ErrUserNotFound
|
||||
}
|
||||
u.Verified = verified
|
||||
r.byID[id] = u
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *UserRepo) SetRole(_ context.Context, id uuid.UUID, role domain.Role) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
u, ok := r.byID[id]
|
||||
if !ok {
|
||||
return usecase.ErrUserNotFound
|
||||
}
|
||||
u.Role = role
|
||||
r.byID[id] = u
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListUsers returns every account, oldest first. It copies, so callers cannot
|
||||
// corrupt the store through the returned slice.
|
||||
func (r *UserRepo) ListUsers(_ context.Context) ([]*domain.User, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
users := make([]*domain.User, 0, len(r.byID))
|
||||
for _, u := range r.byID {
|
||||
copy := u
|
||||
users = append(users, ©)
|
||||
}
|
||||
sort.Slice(users, func(i, j int) bool { return users[i].CreatedAt.Before(users[j].CreatedAt) })
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// Clock is a fixed usecase.Clock for deterministic tests.
|
||||
type Clock struct{ T time.Time }
|
||||
|
||||
func (c Clock) Now() time.Time { return c.T }
|
||||
|
||||
// WorkerKeyRepo is an in-memory usecase.WorkerKeyRepository.
|
||||
type WorkerKeyRepo struct {
|
||||
mu sync.Mutex
|
||||
keys map[uuid.UUID]*domain.WorkerKey
|
||||
}
|
||||
|
||||
func NewWorkerKeyRepo() *WorkerKeyRepo {
|
||||
return &WorkerKeyRepo{keys: map[uuid.UUID]*domain.WorkerKey{}}
|
||||
}
|
||||
|
||||
func (r *WorkerKeyRepo) Insert(_ context.Context, k *domain.WorkerKey) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.keys[k.ID] = k
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *WorkerKeyRepo) ListByUser(_ context.Context, userID uuid.UUID) ([]*domain.WorkerKey, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
var out []*domain.WorkerKey
|
||||
for _, k := range r.keys {
|
||||
if k.UserID == userID && !k.Revoked() {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *WorkerKeyRepo) ListAll(_ context.Context) ([]*domain.WorkerKey, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]*domain.WorkerKey, 0, len(r.keys))
|
||||
for _, k := range r.keys {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *WorkerKeyRepo) GetActiveByHash(_ context.Context, tokenHash string) (*domain.WorkerKey, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for _, k := range r.keys {
|
||||
if k.TokenHash == tokenHash && !k.Revoked() {
|
||||
return k, nil
|
||||
}
|
||||
}
|
||||
return nil, usecase.ErrWorkerKeyNotFound
|
||||
}
|
||||
|
||||
func (r *WorkerKeyRepo) Revoke(_ context.Context, id, userID uuid.UUID) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
k, ok := r.keys[id]
|
||||
if !ok || k.UserID != userID || k.Revoked() {
|
||||
return usecase.ErrWorkerKeyNotFound
|
||||
}
|
||||
now := time.Now()
|
||||
k.RevokedAt = &now
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *WorkerKeyRepo) RevokeAny(_ context.Context, id uuid.UUID) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
k, ok := r.keys[id]
|
||||
if !ok || k.Revoked() {
|
||||
return usecase.ErrWorkerKeyNotFound
|
||||
}
|
||||
now := time.Now()
|
||||
k.RevokedAt = &now
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *WorkerKeyRepo) TouchLastUsed(_ context.Context, id uuid.UUID) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if k, ok := r.keys[id]; ok {
|
||||
now := time.Now()
|
||||
k.LastUsedAt = &now
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationFiles embed.FS
|
||||
|
||||
var migrationNamePattern = regexp.MustCompile(`^([0-9]+)_[a-z0-9_]+\.sql$`)
|
||||
|
||||
// Migrate applies every embedded migration above the PRAGMA user_version
|
||||
// watermark, each inside its own transaction.
|
||||
func Migrate(ctx context.Context, db *sql.DB, log *slog.Logger) error {
|
||||
entries, err := migrationFiles.ReadDir("migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read embedded migrations: %w", err)
|
||||
}
|
||||
type file struct {
|
||||
version int
|
||||
name string
|
||||
}
|
||||
var files []file
|
||||
byVersion := map[int]string{}
|
||||
for _, entry := range entries {
|
||||
match := migrationNamePattern.FindStringSubmatch(entry.Name())
|
||||
if match == nil {
|
||||
continue
|
||||
}
|
||||
version, err := strconv.Atoi(match[1])
|
||||
if err != nil {
|
||||
return fmt.Errorf("migration %q has an invalid version: %w", entry.Name(), err)
|
||||
}
|
||||
body, err := migrationFiles.ReadFile("migrations/" + entry.Name())
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migration %q: %w", entry.Name(), err)
|
||||
}
|
||||
byVersion[version] = string(body)
|
||||
files = append(files, file{version: version, name: entry.Name()})
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return fmt.Errorf("no userservice migrations are embedded")
|
||||
}
|
||||
sort.Slice(files, func(i, j int) bool { return files[i].version < files[j].version })
|
||||
|
||||
var applied int
|
||||
if err := db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&applied); err != nil {
|
||||
return fmt.Errorf("read schema version: %w", err)
|
||||
}
|
||||
for _, item := range files {
|
||||
if item.version <= applied {
|
||||
continue
|
||||
}
|
||||
if log != nil {
|
||||
log.Info("applying userservice migration", "version", item.version, "file", item.name)
|
||||
}
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, byVersion[item.version]); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("apply migration %s: %w", item.name, err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", item.version)); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("advance schema version after %s: %w", item.name, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit migration %s: %w", item.name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
-- 0001: userservice schema. Users and long-lived worker keys, in the same
|
||||
-- style as the coordinator's sqlite schema: TEXT ids, INTEGER timestamps.
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('admin','user')),
|
||||
verified INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS worker_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
prefix TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used_at INTEGER,
|
||||
revoked_at INTEGER
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_worker_keys_user ON worker_keys (user_id);
|
||||
@@ -0,0 +1,304 @@
|
||||
// Package sqlite implements the userservice repository ports on an embedded
|
||||
// SQLite database, mirroring the coordinator's single-binary storage choice.
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/usecase"
|
||||
)
|
||||
|
||||
const userColumns = `id, email, password_hash, role, verified, created_at, updated_at`
|
||||
|
||||
// scanUser maps one row onto a domain.User.
|
||||
func scanUser(row interface{ Scan(dest ...any) error }) (*domain.User, error) {
|
||||
var (
|
||||
u domain.User
|
||||
role string
|
||||
verified int64
|
||||
)
|
||||
var created, updated sql.NullInt64
|
||||
if err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &role, &verified, &created, &updated); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.Role = domain.Role(role)
|
||||
u.Verified = verified != 0
|
||||
u.CreatedAt = decodeTime(created.Int64)
|
||||
u.UpdatedAt = decodeTime(updated.Int64)
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// UserRepo implements usecase.UserRepository on SQLite.
|
||||
type UserRepo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewUserRepo(db *sql.DB) *UserRepo {
|
||||
return &UserRepo{db: db}
|
||||
}
|
||||
|
||||
func (r *UserRepo) Insert(ctx context.Context, u *domain.User) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO users (id, email, password_hash, role, verified, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
u.ID.String(), u.Email, u.PasswordHash, string(u.Role), boolInt(u.Verified),
|
||||
u.CreatedAt.UnixNano(), u.UpdatedAt.UnixNano())
|
||||
if err != nil && isUnique(err) {
|
||||
return usecase.ErrEmailExists
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *UserRepo) GetByEmail(ctx context.Context, email string) (*domain.User, error) {
|
||||
return r.getBy(ctx, "email = ?", email)
|
||||
}
|
||||
|
||||
func (r *UserRepo) GetByID(ctx context.Context, id uuid.UUID) (*domain.User, error) {
|
||||
return r.getBy(ctx, "id = ?", id.String())
|
||||
}
|
||||
|
||||
func (r *UserRepo) getBy(ctx context.Context, clause string, arg any) (*domain.User, error) {
|
||||
// #nosec G202 -- clause is an internal constant, never user input.
|
||||
row := r.db.QueryRowContext(ctx, "SELECT "+userColumns+" FROM users WHERE "+clause, arg)
|
||||
user, err := scanUser(row)
|
||||
return user, mapErrNoRows(err, usecase.ErrUserNotFound)
|
||||
}
|
||||
|
||||
func (r *UserRepo) SetVerified(ctx context.Context, id uuid.UUID, verified bool) error {
|
||||
res, err := r.db.ExecContext(ctx,
|
||||
"UPDATE users SET verified = ?, updated_at = ? WHERE id = ?",
|
||||
boolInt(verified), time.Now().UnixNano(), id.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rowsAffectedOrNotFound(res, usecase.ErrUserNotFound)
|
||||
}
|
||||
|
||||
func (r *UserRepo) SetRole(ctx context.Context, id uuid.UUID, role domain.Role) error {
|
||||
res, err := r.db.ExecContext(ctx,
|
||||
"UPDATE users SET role = ?, updated_at = ? WHERE id = ?",
|
||||
string(role), time.Now().UnixNano(), id.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rowsAffectedOrNotFound(res, usecase.ErrUserNotFound)
|
||||
}
|
||||
|
||||
// ListUsers returns every account, oldest first.
|
||||
func (r *UserRepo) ListUsers(ctx context.Context) ([]*domain.User, error) {
|
||||
rows, err := r.db.QueryContext(ctx, "SELECT "+userColumns+" FROM users ORDER BY created_at ASC, id ASC")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
var users []*domain.User
|
||||
for rows.Next() {
|
||||
user, err := scanUser(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users = append(users, user)
|
||||
}
|
||||
return users, rows.Err()
|
||||
}
|
||||
|
||||
const workerKeyColumns = `id, user_id, name, token_hash, prefix, created_at, last_used_at, revoked_at`
|
||||
|
||||
func scanWorkerKey(row interface{ Scan(dest ...any) error }) (*domain.WorkerKey, error) {
|
||||
var (
|
||||
k domain.WorkerKey
|
||||
lastUsed sql.NullInt64
|
||||
revoked sql.NullInt64
|
||||
created sql.NullInt64
|
||||
)
|
||||
if err := row.Scan(&k.ID, &k.UserID, &k.Name, &k.TokenHash, &k.Prefix, &created, &lastUsed, &revoked); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k.CreatedAt = decodeTime(created.Int64)
|
||||
if lastUsed.Valid {
|
||||
value := decodeTime(lastUsed.Int64)
|
||||
k.LastUsedAt = &value
|
||||
}
|
||||
if revoked.Valid {
|
||||
value := decodeTime(revoked.Int64)
|
||||
k.RevokedAt = &value
|
||||
}
|
||||
return &k, nil
|
||||
}
|
||||
|
||||
// WorkerKeyRepo implements usecase.WorkerKeyRepository on SQLite.
|
||||
type WorkerKeyRepo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewWorkerKeyRepo(db *sql.DB) *WorkerKeyRepo {
|
||||
return &WorkerKeyRepo{db: db}
|
||||
}
|
||||
|
||||
func (r *WorkerKeyRepo) Insert(ctx context.Context, k *domain.WorkerKey) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO worker_keys (id, user_id, name, token_hash, prefix, created_at, last_used_at, revoked_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
k.ID.String(), k.UserID.String(), k.Name, k.TokenHash, k.Prefix,
|
||||
k.CreatedAt.UnixNano(), nullableTime(k.LastUsedAt), nullableTime(k.RevokedAt))
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *WorkerKeyRepo) ListByUser(ctx context.Context, userID uuid.UUID) ([]*domain.WorkerKey, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
"SELECT "+workerKeyColumns+" FROM worker_keys WHERE user_id = ? AND revoked_at IS NULL ORDER BY created_at DESC",
|
||||
userID.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
var keys []*domain.WorkerKey
|
||||
for rows.Next() {
|
||||
key, err := scanWorkerKey(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys, rows.Err()
|
||||
}
|
||||
|
||||
// ListAll returns every key, revoked included, newest first. Admin-only.
|
||||
func (r *WorkerKeyRepo) ListAll(ctx context.Context) ([]*domain.WorkerKey, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
"SELECT "+workerKeyColumns+" FROM worker_keys ORDER BY created_at DESC, id DESC")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
var keys []*domain.WorkerKey
|
||||
for rows.Next() {
|
||||
key, err := scanWorkerKey(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys, rows.Err()
|
||||
}
|
||||
|
||||
func (r *WorkerKeyRepo) GetActiveByHash(ctx context.Context, tokenHash string) (*domain.WorkerKey, error) {
|
||||
row := r.db.QueryRowContext(ctx,
|
||||
"SELECT "+workerKeyColumns+" FROM worker_keys WHERE token_hash = ? AND revoked_at IS NULL",
|
||||
tokenHash)
|
||||
key, err := scanWorkerKey(row)
|
||||
return key, mapErrNoRows(err, usecase.ErrWorkerKeyNotFound)
|
||||
}
|
||||
|
||||
func (r *WorkerKeyRepo) Revoke(ctx context.Context, id, userID uuid.UUID) error {
|
||||
res, err := r.db.ExecContext(ctx,
|
||||
"UPDATE worker_keys SET revoked_at = ? WHERE id = ? AND user_id = ? AND revoked_at IS NULL",
|
||||
time.Now().UnixNano(), id.String(), userID.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rowsAffectedOrNotFound(res, usecase.ErrWorkerKeyNotFound)
|
||||
}
|
||||
|
||||
// RevokeAny retires a key by id regardless of its owner.
|
||||
func (r *WorkerKeyRepo) RevokeAny(ctx context.Context, id uuid.UUID) error {
|
||||
res, err := r.db.ExecContext(ctx,
|
||||
"UPDATE worker_keys SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL",
|
||||
time.Now().UnixNano(), id.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rowsAffectedOrNotFound(res, usecase.ErrWorkerKeyNotFound)
|
||||
}
|
||||
|
||||
func (r *WorkerKeyRepo) TouchLastUsed(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
"UPDATE worker_keys SET last_used_at = ? WHERE id = ?",
|
||||
time.Now().UnixNano(), id.String())
|
||||
return err
|
||||
}
|
||||
|
||||
// --- helpers ---------------------------------------------------------------
|
||||
|
||||
func boolInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func nullableTime(t *time.Time) any {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
return t.UnixNano()
|
||||
}
|
||||
|
||||
func decodeTime(raw any) time.Time {
|
||||
switch v := raw.(type) {
|
||||
case int64:
|
||||
return time.Unix(0, v).UTC()
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func mapErrNoRows(err error, notFound error) error {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return notFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func rowsAffectedOrNotFound(res sql.Result, notFound error) error {
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return notFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isUnique reports whether the error is a SQLite unique-constraint violation.
|
||||
func isUnique(err error) bool {
|
||||
return err != nil && (contains(err.Error(), "UNIQUE constraint failed") ||
|
||||
contains(err.Error(), "constraint failed"))
|
||||
}
|
||||
|
||||
func contains(haystack, needle string) bool {
|
||||
return len(haystack) >= len(needle) && (haystack == needle || len(haystack) > len(needle) &&
|
||||
(indexOf(haystack, needle) >= 0))
|
||||
}
|
||||
|
||||
func indexOf(haystack, needle string) int {
|
||||
for i := 0; i+len(needle) <= len(haystack); i++ {
|
||||
if haystack[i:i+len(needle)] == needle {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// Open opens (and creates when missing) the userservice database file.
|
||||
func Open(path string) (*sql.DB, error) {
|
||||
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=synchronous(NORMAL)", path)
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open userservice database: %w", err)
|
||||
}
|
||||
if err := db.PingContext(context.Background()); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("ping userservice database: %w", err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/usecase"
|
||||
)
|
||||
|
||||
func newTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := Open(filepath.Join(t.TempDir(), "users.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := Migrate(context.Background(), db, nil); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestMigrateIsIdempotent(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
if err := Migrate(context.Background(), db, nil); err != nil {
|
||||
t.Fatalf("second migrate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRepoRoundTripAndSentinels(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
repo := NewUserRepo(db)
|
||||
|
||||
user, err := domain.NewUser("root@scimesh.local", "hash", time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.Insert(ctx, user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := repo.GetByEmail(ctx, "root@scimesh.local")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ID != user.ID || got.Role != domain.RoleUser || got.Verified {
|
||||
t.Errorf("user = %+v", got)
|
||||
}
|
||||
duplicate, err := domain.NewUser("ROOT@scimesh.local", "hash2", time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.Insert(ctx, duplicate); !errors.Is(err, usecase.ErrEmailExists) {
|
||||
t.Errorf("duplicate email err = %v, want ErrEmailExists", err)
|
||||
}
|
||||
if _, err := repo.GetByID(ctx, uuid.New()); !errors.Is(err, usecase.ErrUserNotFound) {
|
||||
t.Errorf("missing user err = %v, want ErrUserNotFound", err)
|
||||
}
|
||||
if err := repo.SetVerified(ctx, user.ID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err = repo.GetByID(ctx, user.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.Verified {
|
||||
t.Error("verified flag did not persist")
|
||||
}
|
||||
if err := repo.SetRole(ctx, user.ID, domain.RoleAdmin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err = repo.GetByID(ctx, user.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Role != domain.RoleAdmin {
|
||||
t.Errorf("role = %q, want admin", got.Role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerKeyRepoLifecycle(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
repo := NewWorkerKeyRepo(db)
|
||||
|
||||
user, err := domain.NewUser("worker@scimesh.local", "hash", time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := NewUserRepo(db).Insert(ctx, user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
key, plaintext, err := domain.NewWorkerKey(user.ID, "my machine", time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if plaintext == "" || key.TokenHash == "" {
|
||||
t.Fatal("key must carry a hash and return a plaintext")
|
||||
}
|
||||
if err := repo.Insert(ctx, key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found, err := repo.GetActiveByHash(ctx, key.TokenHash)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if found.ID != key.ID {
|
||||
t.Errorf("key = %+v", found)
|
||||
}
|
||||
keys, err := repo.ListByUser(ctx, user.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(keys) != 1 {
|
||||
t.Errorf("keys = %d, want 1", len(keys))
|
||||
}
|
||||
if err := repo.TouchLastUsed(ctx, key.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.Revoke(ctx, key.ID, user.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := repo.GetActiveByHash(ctx, key.TokenHash); !errors.Is(err, usecase.ErrWorkerKeyNotFound) {
|
||||
t.Errorf("revoked key err = %v, want ErrWorkerKeyNotFound", err)
|
||||
}
|
||||
if err := repo.Revoke(ctx, key.ID, user.ID); !errors.Is(err, usecase.ErrWorkerKeyNotFound) {
|
||||
t.Errorf("double revoke err = %v, want ErrWorkerKeyNotFound", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
|
||||
)
|
||||
|
||||
// registerRequest / loginRequest are the JSON bodies clients POST. Kept separate
|
||||
// from the domain so the wire format can evolve without touching the entity.
|
||||
type registerRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// userResponse is the public view of a user. It never carries the password hash.
|
||||
type userResponse struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
Verified bool `json:"verified"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type loginResponse struct {
|
||||
Token string `json:"token"`
|
||||
User userResponse `json:"user"`
|
||||
}
|
||||
|
||||
func toUserResponse(u *domain.User) userResponse {
|
||||
return userResponse{
|
||||
ID: u.ID.String(),
|
||||
Email: u.Email,
|
||||
Role: string(u.Role),
|
||||
Verified: u.Verified,
|
||||
CreatedAt: u.CreatedAt.UTC().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
// createWorkerKeyRequest is the body for minting a worker key. Name is an
|
||||
// optional human label; the domain defaults it when blank.
|
||||
type createWorkerKeyRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// exchangeWorkerKeyRequest trades a worker key for a short-lived JWT.
|
||||
type exchangeWorkerKeyRequest struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
type exchangeWorkerKeyResponse struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
// workerKeyResponse is the public view of a key. It never carries the secret —
|
||||
// only the non-secret prefix used to identify a row.
|
||||
type workerKeyResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Prefix string `json:"prefix"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastUsedAt string `json:"last_used_at,omitempty"`
|
||||
}
|
||||
|
||||
// createdWorkerKeyResponse extends the public view with the one-time plaintext,
|
||||
// returned only from the create call and never again.
|
||||
type createdWorkerKeyResponse struct {
|
||||
workerKeyResponse
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
type workerKeysResponse struct {
|
||||
WorkerKeys []workerKeyResponse `json:"worker_keys"`
|
||||
}
|
||||
|
||||
// adminWorkerKeyResponse extends the public view with the owning user and the
|
||||
// revocation state, both needed by the coordinator admin console.
|
||||
type adminWorkerKeyResponse struct {
|
||||
workerKeyResponse
|
||||
UserID string `json:"user_id"`
|
||||
RevokedAt string `json:"revoked_at,omitempty"`
|
||||
}
|
||||
|
||||
func toAdminWorkerKeyResponse(k *domain.WorkerKey) adminWorkerKeyResponse {
|
||||
resp := adminWorkerKeyResponse{workerKeyResponse: toWorkerKeyResponse(k), UserID: k.UserID.String()}
|
||||
if k.RevokedAt != nil {
|
||||
resp.RevokedAt = k.RevokedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// usersResponse is the admin list of accounts, password hashes excluded.
|
||||
type usersResponse struct {
|
||||
Users []userResponse `json:"users"`
|
||||
}
|
||||
|
||||
func toWorkerKeyResponse(k *domain.WorkerKey) workerKeyResponse {
|
||||
resp := workerKeyResponse{
|
||||
ID: k.ID.String(),
|
||||
Name: k.Name,
|
||||
Prefix: k.Prefix,
|
||||
CreatedAt: k.CreatedAt.UTC().Format(time.RFC3339),
|
||||
}
|
||||
if k.LastUsedAt != nil {
|
||||
resp.LastUsedAt = k.LastUsedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/usecase"
|
||||
)
|
||||
|
||||
// maxJSONBody caps a request body. Credentials are tiny; anything larger is a
|
||||
// mistake or an attack, so reject it before allocating.
|
||||
const maxJSONBody = 1 << 20 // 1 MiB
|
||||
|
||||
type errorResponse struct {
|
||||
Error string `json:"error"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// writeError maps a domain or use-case error to an HTTP status and a safe
|
||||
// message, logging only genuine server faults (5xx). Client errors (4xx) are
|
||||
// expected and stay out of the error log.
|
||||
func writeError(w http.ResponseWriter, r *http.Request, log *slog.Logger, err error) {
|
||||
status, msg := statusForError(err)
|
||||
if status >= http.StatusInternalServerError {
|
||||
log.Error("request failed",
|
||||
"err", err,
|
||||
"request_id", requestIDFrom(r.Context()),
|
||||
"path", r.URL.Path,
|
||||
)
|
||||
}
|
||||
writeJSON(w, status, errorResponse{Error: msg, RequestID: requestIDFrom(r.Context())})
|
||||
}
|
||||
|
||||
func statusForError(err error) (int, string) {
|
||||
switch {
|
||||
case errors.Is(err, usecase.ErrEmailExists):
|
||||
return http.StatusConflict, "email already registered"
|
||||
case errors.Is(err, usecase.ErrInvalidCredentials):
|
||||
return http.StatusUnauthorized, "invalid email or password"
|
||||
case errors.Is(err, usecase.ErrUserNotFound):
|
||||
return http.StatusNotFound, "user not found"
|
||||
case errors.Is(err, usecase.ErrWorkerKeyNotFound):
|
||||
return http.StatusNotFound, "worker key not found"
|
||||
case errors.Is(err, usecase.ErrInvalidWorkerKey):
|
||||
return http.StatusUnauthorized, "invalid worker key"
|
||||
case errors.Is(err, domain.ErrWorkerKeyNameTooLong):
|
||||
return http.StatusBadRequest, "worker key name is too long"
|
||||
case errors.Is(err, usecase.ErrPasswordTooShort):
|
||||
return http.StatusBadRequest, "password must be at least 8 characters"
|
||||
case errors.Is(err, usecase.ErrPasswordTooLong):
|
||||
return http.StatusBadRequest, "password must be at most 72 bytes"
|
||||
case errors.Is(err, usecase.ErrInvalidRole):
|
||||
return http.StatusBadRequest, "invalid role"
|
||||
case errors.Is(err, domain.ErrEmptyEmail), errors.Is(err, domain.ErrInvalidEmail):
|
||||
return http.StatusBadRequest, "email is not a valid address"
|
||||
default:
|
||||
// Don't leak internals; the real error is in the log under request_id.
|
||||
return http.StatusInternalServerError, "internal error"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/usecase"
|
||||
)
|
||||
|
||||
// Handlers holds the use cases each endpoint drives.
|
||||
type Handlers struct {
|
||||
register *usecase.Register
|
||||
login *usecase.Login
|
||||
setVerified *usecase.SetVerified
|
||||
setRole *usecase.SetRole
|
||||
createWorkerKey *usecase.CreateWorkerKey
|
||||
listWorkerKeys *usecase.ListWorkerKeys
|
||||
listWorkerKeysAll *usecase.ListWorkerKeysAll
|
||||
revokeWorkerKey *usecase.RevokeWorkerKey
|
||||
revokeWorkerKeyAdmin *usecase.RevokeWorkerKeyAdmin
|
||||
exchangeWorkerKey *usecase.ExchangeWorkerKey
|
||||
listUsers *usecase.ListUsers
|
||||
users usecase.UserRepository
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// handleHealth is an unauthenticated liveness probe for the container and load
|
||||
// balancer.
|
||||
func (h *Handlers) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// handleRegister creates an account. It returns 201 with the public user view,
|
||||
// 409 if the email is taken, or 400 on a malformed body / weak password.
|
||||
func (h *Handlers) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
var req registerRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
u, err := h.register.Execute(r.Context(), req.Email, req.Password)
|
||||
if err != nil {
|
||||
writeError(w, r, h.log, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toUserResponse(u))
|
||||
}
|
||||
|
||||
// handleLogin verifies credentials and returns a signed token plus the user.
|
||||
func (h *Handlers) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var req loginRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
token, u, err := h.login.Execute(r.Context(), req.Email, req.Password)
|
||||
if err != nil {
|
||||
writeError(w, r, h.log, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, loginResponse{Token: token, User: toUserResponse(u)})
|
||||
}
|
||||
|
||||
// handleMe returns the caller's own account, proving the token works end to end.
|
||||
// It reads the user id the JWT middleware stashed in the context.
|
||||
func (h *Handlers) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := userIDFrom(r.Context())
|
||||
if !ok {
|
||||
unauthorized(w, r)
|
||||
return
|
||||
}
|
||||
u, err := h.users.GetByID(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(w, r, h.log, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toUserResponse(u))
|
||||
}
|
||||
|
||||
// handleSetVerified grants (verified=true) or revokes (false) the trusted-
|
||||
// contributor badge for the user in the path. Admin-only; the withAdmin
|
||||
// middleware has already enforced the role by the time this runs.
|
||||
func (h *Handlers) handleSetVerified(verified bool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, errorResponse{
|
||||
Error: "invalid user id",
|
||||
RequestID: requestIDFrom(r.Context()),
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := h.setVerified.Execute(r.Context(), id, verified); err != nil {
|
||||
writeError(w, r, h.log, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// handleSetRole promotes (admin) or demotes (user) the user in the path. Admin-
|
||||
// only; the withAdmin middleware has already enforced the caller's role.
|
||||
func (h *Handlers) handleSetRole(role domain.Role) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, errorResponse{
|
||||
Error: "invalid user id",
|
||||
RequestID: requestIDFrom(r.Context()),
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := h.setRole.Execute(r.Context(), id, role); err != nil {
|
||||
writeError(w, r, h.log, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// handleCreateWorkerKey mints a long-lived worker key for the authenticated
|
||||
// caller and returns it once, plaintext included. The user copies it into their
|
||||
// worker's SCIMESH_WORKER_KEY; it is never retrievable again.
|
||||
func (h *Handlers) handleCreateWorkerKey(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := userIDFrom(r.Context())
|
||||
if !ok {
|
||||
unauthorized(w, r)
|
||||
return
|
||||
}
|
||||
// A signed JWT can outlive a demo reset or an account deletion. Check that
|
||||
// its subject still exists before attempting the insert, otherwise the
|
||||
// worker_keys foreign key would turn a stale session into an internal error.
|
||||
if _, err := h.users.GetByID(r.Context(), id); err != nil {
|
||||
if errors.Is(err, usecase.ErrUserNotFound) {
|
||||
unauthorized(w, r)
|
||||
return
|
||||
}
|
||||
writeError(w, r, h.log, err)
|
||||
return
|
||||
}
|
||||
var req createWorkerKeyRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
key, raw, err := h.createWorkerKey.Execute(r.Context(), id, req.Name)
|
||||
if err != nil {
|
||||
writeError(w, r, h.log, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, createdWorkerKeyResponse{
|
||||
workerKeyResponse: toWorkerKeyResponse(key),
|
||||
Key: raw,
|
||||
})
|
||||
}
|
||||
|
||||
// handleListWorkerKeys returns the caller's live keys (no secrets) for display
|
||||
// and revocation.
|
||||
func (h *Handlers) handleListWorkerKeys(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := userIDFrom(r.Context())
|
||||
if !ok {
|
||||
unauthorized(w, r)
|
||||
return
|
||||
}
|
||||
keys, err := h.listWorkerKeys.Execute(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(w, r, h.log, err)
|
||||
return
|
||||
}
|
||||
out := make([]workerKeyResponse, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
out = append(out, toWorkerKeyResponse(k))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, workerKeysResponse{WorkerKeys: out})
|
||||
}
|
||||
|
||||
// handleListWorkerKeysAll returns every key in the service — revoked included,
|
||||
// with the owning user id — for the coordinator admin console. Admin-only.
|
||||
func (h *Handlers) handleListWorkerKeysAll(w http.ResponseWriter, r *http.Request) {
|
||||
keys, err := h.listWorkerKeysAll.Execute(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, r, h.log, err)
|
||||
return
|
||||
}
|
||||
out := make([]adminWorkerKeyResponse, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
out = append(out, toAdminWorkerKeyResponse(k))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
WorkerKeys []adminWorkerKeyResponse `json:"worker_keys"`
|
||||
}{WorkerKeys: out})
|
||||
}
|
||||
|
||||
// handleListUsers returns every account for the coordinator admin console.
|
||||
// Password hashes never leave the service: only the public projection is sent.
|
||||
func (h *Handlers) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
users, err := h.listUsers.Execute(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, r, h.log, err)
|
||||
return
|
||||
}
|
||||
out := make([]userResponse, 0, len(users))
|
||||
for _, u := range users {
|
||||
out = append(out, toUserResponse(u))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, usersResponse{Users: out})
|
||||
}
|
||||
|
||||
// handleRevokeWorkerKey retires one of the caller's keys. The repository scopes
|
||||
// the delete to the owner, so a mismatched id is a clean 404, not another user's
|
||||
// key.
|
||||
func (h *Handlers) handleRevokeWorkerKey(w http.ResponseWriter, r *http.Request) {
|
||||
keyID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, errorResponse{
|
||||
Error: "invalid worker key id",
|
||||
RequestID: requestIDFrom(r.Context()),
|
||||
})
|
||||
return
|
||||
}
|
||||
// An admin may revoke any key; a plain user only their own.
|
||||
if role, ok := r.Context().Value(roleKey).(domain.Role); ok && role == domain.RoleAdmin {
|
||||
if err := h.revokeWorkerKeyAdmin.Execute(r.Context(), keyID); err != nil {
|
||||
writeError(w, r, h.log, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
userID, ok := userIDFrom(r.Context())
|
||||
if !ok {
|
||||
unauthorized(w, r)
|
||||
return
|
||||
}
|
||||
if err := h.revokeWorkerKey.Execute(r.Context(), userID, keyID); err != nil {
|
||||
writeError(w, r, h.log, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleExchangeWorkerKey trades a worker key for a short-lived JWT. It is
|
||||
// unauthenticated: the key itself is the credential. A worker calls this on
|
||||
// startup and again to refresh before the JWT expires.
|
||||
func (h *Handlers) handleExchangeWorkerKey(w http.ResponseWriter, r *http.Request) {
|
||||
var req exchangeWorkerKeyRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
token, expiresIn, err := h.exchangeWorkerKey.Execute(r.Context(), req.Key)
|
||||
if err != nil {
|
||||
writeError(w, r, h.log, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, exchangeWorkerKeyResponse{Token: token, ExpiresIn: expiresIn})
|
||||
}
|
||||
|
||||
// decodeJSON reads a size-capped JSON body into dst, rejecting unknown fields.
|
||||
// It writes a 400 and returns false on any problem, so callers can `if
|
||||
// !decodeJSON(...) { return }`.
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxJSONBody)
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(dst); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, errorResponse{
|
||||
Error: "invalid JSON body",
|
||||
RequestID: requestIDFrom(r.Context()),
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/auth"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
|
||||
)
|
||||
|
||||
type ctxKey string
|
||||
|
||||
const (
|
||||
requestIDKey ctxKey = "request_id"
|
||||
userIDKey ctxKey = "user_id"
|
||||
roleKey ctxKey = "role"
|
||||
)
|
||||
|
||||
// withRequestID stamps every request with an ID for correlated logs and error
|
||||
// bodies. It wraps the auth middleware rather than the other way round, so even
|
||||
// a rejected request carries an ID the caller can quote in a bug report.
|
||||
func withRequestID(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := newRequestID()
|
||||
w.Header().Set("X-Request-ID", id)
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), requestIDKey, id)))
|
||||
})
|
||||
}
|
||||
|
||||
func requestIDFrom(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(requestIDKey).(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func newRequestID() string {
|
||||
var b [8]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// tokenVerifier is the slice of auth.Issuer the JWT middleware needs. Taking an
|
||||
// interface keeps the middleware testable with a stub verifier.
|
||||
type tokenVerifier interface {
|
||||
Verify(token string) (*auth.Claims, error)
|
||||
}
|
||||
|
||||
// withJWT verifies the Bearer token and stashes the caller's id and role in the
|
||||
// request context. It rejects any request without a valid, unexpired HS256
|
||||
// token — this is what protects endpoints that act on a specific user.
|
||||
func withJWT(v tokenVerifier) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
raw := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
if raw == "" {
|
||||
unauthorized(w, r)
|
||||
return
|
||||
}
|
||||
claims, err := v.Verify(raw)
|
||||
if err != nil {
|
||||
unauthorized(w, r)
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(claims.Subject)
|
||||
if err != nil {
|
||||
unauthorized(w, r)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), userIDKey, id)
|
||||
ctx = context.WithValue(ctx, roleKey, claims.Role)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func unauthorized(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("WWW-Authenticate", "Bearer")
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||
Error: "unauthorized",
|
||||
RequestID: requestIDFrom(r.Context()),
|
||||
})
|
||||
}
|
||||
|
||||
// userIDFrom returns the authenticated caller's id, set by withJWT.
|
||||
func userIDFrom(ctx context.Context) (uuid.UUID, bool) {
|
||||
id, ok := ctx.Value(userIDKey).(uuid.UUID)
|
||||
return id, ok
|
||||
}
|
||||
|
||||
// withAdmin rejects any caller whose token role is not admin. It must sit inside
|
||||
// withJWT, which stamps the role after verifying the token.
|
||||
func withAdmin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if role, ok := r.Context().Value(roleKey).(domain.Role); !ok || role != domain.RoleAdmin {
|
||||
writeJSON(w, http.StatusForbidden, errorResponse{
|
||||
Error: "admin role required",
|
||||
RequestID: requestIDFrom(r.Context()),
|
||||
})
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// statusRecorder captures the status code for the access log.
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (s *statusRecorder) WriteHeader(code int) {
|
||||
s.status = code
|
||||
s.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// withAccessLog records one structured line per request — the minimum needed to
|
||||
// debug a distributed system after the fact.
|
||||
func withAccessLog(log *slog.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(rec, r)
|
||||
log.Info("request",
|
||||
"request_id", requestIDFrom(r.Context()),
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", rec.status,
|
||||
"duration_ms", time.Since(start).Milliseconds(),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// chain applies middleware so that the first argument is the outermost layer.
|
||||
func chain(h http.Handler, mw ...func(http.Handler) http.Handler) http.Handler {
|
||||
for i := len(mw) - 1; i >= 0; i-- {
|
||||
h = mw[i](h)
|
||||
}
|
||||
return h
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Package http exposes the userservice over HTTP: registration, login, and a
|
||||
// token-protected /me. It owns routing, request decoding, and error mapping;
|
||||
// business rules live in the usecase layer.
|
||||
package http
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/auth"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/usecase"
|
||||
)
|
||||
|
||||
// UseCases bundles the application services the handlers drive.
|
||||
type UseCases struct {
|
||||
Register *usecase.Register
|
||||
Login *usecase.Login
|
||||
SetVerified *usecase.SetVerified
|
||||
SetRole *usecase.SetRole
|
||||
CreateWorkerKey *usecase.CreateWorkerKey
|
||||
ListWorkerKeys *usecase.ListWorkerKeys
|
||||
ListWorkerKeysAll *usecase.ListWorkerKeysAll
|
||||
RevokeWorkerKey *usecase.RevokeWorkerKey
|
||||
RevokeWorkerKeyAdmin *usecase.RevokeWorkerKeyAdmin
|
||||
ExchangeWorkerKey *usecase.ExchangeWorkerKey
|
||||
ListUsers *usecase.ListUsers
|
||||
Users usecase.UserRepository
|
||||
}
|
||||
|
||||
// NewServer wires the routes and the middleware stack and returns the handler.
|
||||
// The issuer verifies tokens for the JWT-protected routes.
|
||||
func NewServer(log *slog.Logger, uc UseCases, issuer auth.Issuer) http.Handler {
|
||||
h := &Handlers{
|
||||
register: uc.Register,
|
||||
login: uc.Login,
|
||||
setVerified: uc.SetVerified,
|
||||
setRole: uc.SetRole,
|
||||
createWorkerKey: uc.CreateWorkerKey,
|
||||
listWorkerKeys: uc.ListWorkerKeys,
|
||||
listWorkerKeysAll: uc.ListWorkerKeysAll,
|
||||
revokeWorkerKey: uc.RevokeWorkerKey,
|
||||
revokeWorkerKeyAdmin: uc.RevokeWorkerKeyAdmin,
|
||||
exchangeWorkerKey: uc.ExchangeWorkerKey,
|
||||
listUsers: uc.ListUsers,
|
||||
users: uc.Users,
|
||||
log: log,
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
// Method-aware patterns (Go 1.22+): a GET to /register is a 405, not a match.
|
||||
mux.HandleFunc("GET /health", h.handleHealth)
|
||||
mux.HandleFunc("POST /register", h.handleRegister)
|
||||
mux.HandleFunc("POST /login", h.handleLogin)
|
||||
// /me proves a token round-trips; it sits behind JWT auth.
|
||||
mux.Handle("GET /me", chain(http.HandlerFunc(h.handleMe), withJWT(issuer)))
|
||||
|
||||
// Worker keys: a user mints a long-lived key (JWT-protected), and a worker
|
||||
// trades it for a short-lived JWT on the public exchange endpoint — the key
|
||||
// itself is the credential there, so no prior token is required.
|
||||
mux.HandleFunc("POST /worker-tokens/exchange", h.handleExchangeWorkerKey)
|
||||
mux.Handle("POST /worker-keys", chain(http.HandlerFunc(h.handleCreateWorkerKey), withJWT(issuer)))
|
||||
mux.Handle("GET /worker-keys", chain(http.HandlerFunc(h.handleListWorkerKeys), withJWT(issuer)))
|
||||
mux.Handle("DELETE /worker-keys/{id}", chain(http.HandlerFunc(h.handleRevokeWorkerKey), withJWT(issuer)))
|
||||
|
||||
// Admin-only: grant or revoke the trusted-contributor badge. withAdmin sits
|
||||
// inside withJWT so the role is available from the verified token.
|
||||
mux.Handle("POST /users/{id}/verify",
|
||||
chain(h.handleSetVerified(true), withJWT(issuer), withAdmin))
|
||||
mux.Handle("POST /users/{id}/unverify",
|
||||
chain(h.handleSetVerified(false), withJWT(issuer), withAdmin))
|
||||
mux.Handle("POST /users/{id}/promote",
|
||||
chain(h.handleSetRole(domain.RoleAdmin), withJWT(issuer), withAdmin))
|
||||
mux.Handle("POST /users/{id}/demote",
|
||||
chain(h.handleSetRole(domain.RoleUser), withJWT(issuer), withAdmin))
|
||||
|
||||
// Admin console: lists of every account and every worker key, and the key
|
||||
// revoke path the admin console calls (the same DELETE endpoint already
|
||||
// lets an admin revoke any key).
|
||||
mux.Handle("GET /users", chain(http.HandlerFunc(h.handleListUsers), withJWT(issuer), withAdmin))
|
||||
mux.Handle("GET /worker-keys/all", chain(http.HandlerFunc(h.handleListWorkerKeysAll), withJWT(issuer), withAdmin))
|
||||
|
||||
// Outermost first: every request gets an ID and an access-log line.
|
||||
return chain(mux, withRequestID, withAccessLog(log))
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
package http_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/auth"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/memstore"
|
||||
apihttp "github.com/emil28092005/SciMesh/coordinator/internal/userservice/transport/http"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/usecase"
|
||||
)
|
||||
|
||||
const secret = "server-test-secret-32-bytes-long!!!!"
|
||||
|
||||
func newTestServer() http.Handler {
|
||||
users := memstore.NewUserRepo()
|
||||
keys := memstore.NewWorkerKeyRepo()
|
||||
hasher := auth.NewHasher(4)
|
||||
clk := memstore.Clock{T: time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC)}
|
||||
// Real clock for the issuer so tokens are valid at verification time.
|
||||
issuer := auth.NewIssuer(secret, time.Hour, nil)
|
||||
|
||||
uc := apihttp.UseCases{
|
||||
Register: usecase.NewRegister(users, hasher, clk),
|
||||
Login: usecase.NewLogin(users, hasher, issuer),
|
||||
SetVerified: usecase.NewSetVerified(users),
|
||||
SetRole: usecase.NewSetRole(users),
|
||||
CreateWorkerKey: usecase.NewCreateWorkerKey(keys, clk),
|
||||
ListWorkerKeys: usecase.NewListWorkerKeys(keys),
|
||||
ListWorkerKeysAll: usecase.NewListWorkerKeysAll(keys),
|
||||
RevokeWorkerKey: usecase.NewRevokeWorkerKey(keys),
|
||||
RevokeWorkerKeyAdmin: usecase.NewRevokeWorkerKeyAdmin(keys),
|
||||
ExchangeWorkerKey: usecase.NewExchangeWorkerKey(keys, users, issuer, time.Hour),
|
||||
ListUsers: usecase.NewListUsers(users),
|
||||
Users: users,
|
||||
}
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
return apihttp.NewServer(log, uc, issuer)
|
||||
}
|
||||
|
||||
func do(t *testing.T, h http.Handler, method, path, token string, body any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if body != nil {
|
||||
if err := json.NewEncoder(&buf).Encode(body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
req, err := http.NewRequestWithContext(context.Background(), method, path, &buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestRegisterThenLoginThenMe(t *testing.T) {
|
||||
h := newTestServer()
|
||||
creds := map[string]string{"email": "flow@example.com", "password": "password123"}
|
||||
|
||||
// Register -> 201
|
||||
rec := do(t, h, http.MethodPost, "/register", "", creds)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("register: got %d, body %s", rec.Code, rec.Body)
|
||||
}
|
||||
|
||||
// Login -> 200 with a token
|
||||
rec = do(t, h, http.MethodPost, "/login", "", creds)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("login: got %d, body %s", rec.Code, rec.Body)
|
||||
}
|
||||
var lr struct {
|
||||
Token string `json:"token"`
|
||||
User struct {
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
} `json:"user"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &lr); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lr.Token == "" || lr.User.Email != "flow@example.com" || lr.User.Role != "user" {
|
||||
t.Fatalf("unexpected login body: %+v", lr)
|
||||
}
|
||||
|
||||
// /me with the token -> 200, same user
|
||||
rec = do(t, h, http.MethodGet, "/me", lr.Token, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("me: got %d, body %s", rec.Code, rec.Body)
|
||||
}
|
||||
var me struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &me); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if me.Email != "flow@example.com" {
|
||||
t.Errorf("me email = %q", me.Email)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterDuplicate(t *testing.T) {
|
||||
h := newTestServer()
|
||||
creds := map[string]string{"email": "dup@example.com", "password": "password123"}
|
||||
_ = do(t, h, http.MethodPost, "/register", "", creds)
|
||||
|
||||
rec := do(t, h, http.MethodPost, "/register", "", creds)
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Errorf("duplicate register: got %d, want 409", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterValidation(t *testing.T) {
|
||||
h := newTestServer()
|
||||
cases := []struct {
|
||||
name string
|
||||
body map[string]string
|
||||
}{
|
||||
{"weak password", map[string]string{"email": "a@b.com", "password": "short"}},
|
||||
{"bad email", map[string]string{"email": "nope", "password": "password123"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rec := do(t, h, http.MethodPost, "/register", "", tc.body)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("got %d, want 400", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRejectsUnknownFields(t *testing.T) {
|
||||
h := newTestServer()
|
||||
rec := do(t, h, http.MethodPost, "/register", "", map[string]string{
|
||||
"email": "a@b.com", "password": "password123", "role": "admin",
|
||||
})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("unknown field must be rejected: got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginWrongPassword(t *testing.T) {
|
||||
h := newTestServer()
|
||||
_ = do(t, h, http.MethodPost, "/register", "", map[string]string{
|
||||
"email": "x@example.com", "password": "password123",
|
||||
})
|
||||
rec := do(t, h, http.MethodPost, "/login", "", map[string]string{
|
||||
"email": "x@example.com", "password": "wrongpass1",
|
||||
})
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("got %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeRequiresToken(t *testing.T) {
|
||||
h := newTestServer()
|
||||
if rec := do(t, h, http.MethodGet, "/me", "", nil); rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("no token: got %d, want 401", rec.Code)
|
||||
}
|
||||
if rec := do(t, h, http.MethodGet, "/me", "garbage.token.here", nil); rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("bad token: got %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealth(t *testing.T) {
|
||||
h := newTestServer()
|
||||
if rec := do(t, h, http.MethodGet, "/health", "", nil); rec.Code != http.StatusOK {
|
||||
t.Errorf("health: got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// failingUsers is a UserRepository whose reads fail with an unexpected (non-
|
||||
// sentinel) error, so the handler must map it to 500 and not leak internals.
|
||||
type failingUsers struct{ usecase.UserRepository }
|
||||
|
||||
func (failingUsers) GetByID(context.Context, uuid.UUID) (*domain.User, error) {
|
||||
return nil, errors.New("db exploded")
|
||||
}
|
||||
|
||||
func TestMeInternalError(t *testing.T) {
|
||||
hasher := auth.NewHasher(4)
|
||||
clk := memstore.Clock{T: time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC)}
|
||||
issuer := auth.NewIssuer(secret, time.Hour, nil)
|
||||
|
||||
users := failingUsers{UserRepository: memstore.NewUserRepo()}
|
||||
uc := apihttp.UseCases{
|
||||
Register: usecase.NewRegister(users, hasher, clk),
|
||||
Login: usecase.NewLogin(users, hasher, issuer),
|
||||
SetVerified: usecase.NewSetVerified(users),
|
||||
SetRole: usecase.NewSetRole(users),
|
||||
Users: users,
|
||||
}
|
||||
h := apihttp.NewServer(slog.New(slog.NewTextHandler(io.Discard, nil)), uc, issuer)
|
||||
|
||||
// A structurally valid token for a caller the failing repo can't load.
|
||||
token, err := issuer.Issue(&domain.User{ID: uuid.New(), Role: domain.RoleUser})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec := do(t, h, http.MethodGet, "/me", token, nil)
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Errorf("got %d, want 500", rec.Code)
|
||||
}
|
||||
// The body must not disclose the underlying error.
|
||||
if bytes.Contains(rec.Body.Bytes(), []byte("db exploded")) {
|
||||
t.Error("internal error leaked to the client")
|
||||
}
|
||||
}
|
||||
|
||||
// mintToken issues a token with the package secret for a synthetic caller of the
|
||||
// given role — enough to drive the admin-gated endpoints. userID defaults to a
|
||||
// fresh random id; pass one to act as an existing account.
|
||||
func mintToken(t *testing.T, role domain.Role) string {
|
||||
t.Helper()
|
||||
return mintTokenFor(t, role, uuid.New())
|
||||
}
|
||||
|
||||
func mintTokenFor(t *testing.T, role domain.Role, userID uuid.UUID) string {
|
||||
t.Helper()
|
||||
token, err := auth.NewIssuer(secret, time.Hour, nil).Issue(&domain.User{ID: userID, Role: role})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
// registerUser creates an account and returns its id.
|
||||
func registerUser(t *testing.T, h http.Handler, email string) string {
|
||||
t.Helper()
|
||||
rec := do(t, h, http.MethodPost, "/register", "", map[string]string{"email": email, "password": "password123"})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("register: %d", rec.Code)
|
||||
}
|
||||
var reg struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), ®); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return reg.ID
|
||||
}
|
||||
|
||||
func TestAdminVerifiesUserEndToEnd(t *testing.T) {
|
||||
h := newTestServer()
|
||||
id := registerUser(t, h, "contrib@example.com")
|
||||
|
||||
// Admin grants the badge.
|
||||
rec := do(t, h, http.MethodPost, "/users/"+id+"/verify", mintToken(t, domain.RoleAdmin), nil)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("admin verify: got %d, body %s", rec.Code, rec.Body)
|
||||
}
|
||||
|
||||
// The change is visible when the contributor logs in.
|
||||
rec = do(t, h, http.MethodPost, "/login", "", map[string]string{"email": "contrib@example.com", "password": "password123"})
|
||||
var lr struct {
|
||||
User struct {
|
||||
Verified bool `json:"verified"`
|
||||
} `json:"user"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &lr); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !lr.User.Verified {
|
||||
t.Error("verified badge not reflected after admin granted it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRequiresAdminRole(t *testing.T) {
|
||||
h := newTestServer()
|
||||
id := registerUser(t, h, "someone@example.com")
|
||||
|
||||
// A plain user token must not be able to grant the badge.
|
||||
rec := do(t, h, http.MethodPost, "/users/"+id+"/verify", mintToken(t, domain.RoleUser), nil)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("plain user: got %d, want 403", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRequiresAuth(t *testing.T) {
|
||||
h := newTestServer()
|
||||
rec := do(t, h, http.MethodPost, "/users/"+uuid.NewString()+"/verify", "", nil)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("no token: got %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyInvalidID(t *testing.T) {
|
||||
h := newTestServer()
|
||||
rec := do(t, h, http.MethodPost, "/users/not-a-uuid/verify", mintToken(t, domain.RoleAdmin), nil)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("bad id: got %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyUnknownUser(t *testing.T) {
|
||||
h := newTestServer()
|
||||
rec := do(t, h, http.MethodPost, "/users/"+uuid.NewString()+"/verify", mintToken(t, domain.RoleAdmin), nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("unknown user: got %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPromotesAndDemotes(t *testing.T) {
|
||||
h := newTestServer()
|
||||
id := registerUser(t, h, "promote@example.com")
|
||||
admin := mintToken(t, domain.RoleAdmin)
|
||||
|
||||
if rec := do(t, h, http.MethodPost, "/users/"+id+"/promote", admin, nil); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("promote: got %d, body %s", rec.Code, rec.Body)
|
||||
}
|
||||
// The promoted user now logs in as an admin.
|
||||
rec := do(t, h, http.MethodPost, "/login", "", map[string]string{"email": "promote@example.com", "password": "password123"})
|
||||
var lr struct {
|
||||
User struct {
|
||||
Role string `json:"role"`
|
||||
} `json:"user"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &lr)
|
||||
if lr.User.Role != "admin" {
|
||||
t.Errorf("role after promote = %q, want admin", lr.User.Role)
|
||||
}
|
||||
|
||||
if rec := do(t, h, http.MethodPost, "/users/"+id+"/demote", admin, nil); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("demote: got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromoteRequiresAdmin(t *testing.T) {
|
||||
h := newTestServer()
|
||||
id := registerUser(t, h, "target@example.com")
|
||||
|
||||
if rec := do(t, h, http.MethodPost, "/users/"+id+"/promote", mintToken(t, domain.RoleUser), nil); rec.Code != http.StatusForbidden {
|
||||
t.Errorf("plain user promote: got %d, want 403", rec.Code)
|
||||
}
|
||||
if rec := do(t, h, http.MethodPost, "/users/"+id+"/promote", "", nil); rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("no token: got %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromoteUnknownUser(t *testing.T) {
|
||||
h := newTestServer()
|
||||
rec := do(t, h, http.MethodPost, "/users/"+uuid.NewString()+"/promote", mintToken(t, domain.RoleAdmin), nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("unknown user promote: got %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnverifyRevokes(t *testing.T) {
|
||||
h := newTestServer()
|
||||
id := registerUser(t, h, "revoke@example.com")
|
||||
admin := mintToken(t, domain.RoleAdmin)
|
||||
|
||||
if rec := do(t, h, http.MethodPost, "/users/"+id+"/verify", admin, nil); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("verify: %d", rec.Code)
|
||||
}
|
||||
if rec := do(t, h, http.MethodPost, "/users/"+id+"/unverify", admin, nil); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("unverify: %d", rec.Code)
|
||||
}
|
||||
|
||||
rec := do(t, h, http.MethodPost, "/login", "", map[string]string{"email": "revoke@example.com", "password": "password123"})
|
||||
var lr struct {
|
||||
User struct {
|
||||
Verified bool `json:"verified"`
|
||||
} `json:"user"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &lr)
|
||||
if lr.User.Verified {
|
||||
t.Error("verified should be false after unverify")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminListsUsersAndKeys(t *testing.T) {
|
||||
h := newTestServer()
|
||||
userID, _ := uuid.Parse(registerUser(t, h, "listed@example.com"))
|
||||
userToken := mintTokenFor(t, domain.RoleUser, userID)
|
||||
// Mint a worker key as the plain user.
|
||||
keyRec := do(t, h, http.MethodPost, "/worker-keys", userToken, map[string]string{"name": "lab-node"})
|
||||
if keyRec.Code != http.StatusCreated {
|
||||
t.Fatalf("create key: got %d, body %s", keyRec.Code, keyRec.Body)
|
||||
}
|
||||
var created struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
_ = json.Unmarshal(keyRec.Body.Bytes(), &created)
|
||||
|
||||
// Admin lists users: emails present, password hashes absent.
|
||||
rec := do(t, h, http.MethodGet, "/users", mintToken(t, domain.RoleAdmin), nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("list users: got %d, body %s", rec.Code, rec.Body)
|
||||
}
|
||||
var users struct {
|
||||
Users []struct {
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
} `json:"users"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &users); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, u := range users.Users {
|
||||
if u.PasswordHash != "" {
|
||||
t.Error("password hash leaked through the admin users list")
|
||||
}
|
||||
if u.Email == "listed@example.com" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("listed user missing from the admin list")
|
||||
}
|
||||
|
||||
// Admin lists all keys: the owner is attached, no secret.
|
||||
rec = do(t, h, http.MethodGet, "/worker-keys/all", mintToken(t, domain.RoleAdmin), nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("list all keys: got %d", rec.Code)
|
||||
}
|
||||
var keys struct {
|
||||
WorkerKeys []struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"worker_keys"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &keys); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(keys.WorkerKeys) != 1 || keys.WorkerKeys[0].UserID != userID.String() {
|
||||
t.Errorf("all keys = %+v, want the one key owned by %s", keys.WorkerKeys, userID)
|
||||
}
|
||||
|
||||
// Plain users cannot see either list.
|
||||
for _, path := range []string{"/users", "/worker-keys/all"} {
|
||||
if rec := do(t, h, http.MethodGet, path, mintToken(t, domain.RoleUser), nil); rec.Code != http.StatusForbidden {
|
||||
t.Errorf("%s as user: got %d, want 403", path, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// An admin revokes a key that belongs to another user; the plain owner of
|
||||
// that key could not (it would be a 404, scoped to their own keys).
|
||||
// The owner of the key revokes it themselves: 204.
|
||||
if rec := do(t, h, http.MethodDelete, "/worker-keys/"+created.ID, userToken, nil); rec.Code != http.StatusNoContent {
|
||||
t.Errorf("user revoke own key: got %d, want 204", rec.Code)
|
||||
}
|
||||
// Another plain user cannot revoke it: scoped to their own keys, so a
|
||||
// mismatch reads as 404.
|
||||
otherID, _ := uuid.Parse(registerUser(t, h, "other@example.com"))
|
||||
if rec := do(t, h, http.MethodDelete, "/worker-keys/"+created.ID, mintTokenFor(t, domain.RoleUser, otherID), nil); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("other user revoke: got %d, want 404", rec.Code)
|
||||
}
|
||||
// An admin revokes a key that belongs to someone else: 204.
|
||||
keyRec = do(t, h, http.MethodPost, "/worker-keys", userToken, map[string]string{"name": "lab-node-2"})
|
||||
var second struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
_ = json.Unmarshal(keyRec.Body.Bytes(), &second)
|
||||
if rec := do(t, h, http.MethodDelete, "/worker-keys/"+second.ID, mintToken(t, domain.RoleAdmin), nil); rec.Code != http.StatusNoContent {
|
||||
t.Errorf("admin revoke other's key: got %d, want 204", rec.Code)
|
||||
}
|
||||
// Admin cannot revoke an unknown key.
|
||||
if rec := do(t, h, http.MethodDelete, "/worker-keys/"+uuid.NewString(), mintToken(t, domain.RoleAdmin), nil); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("admin revoke unknown key: got %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
|
||||
)
|
||||
|
||||
// BootstrapAdmin seeds the first admin account. It exists because there is no
|
||||
// other way to create one: /register always makes a plain user, and promoting a
|
||||
// user to admin requires an already-existing admin. Running it at startup with
|
||||
// operator-supplied credentials breaks that chicken-and-egg.
|
||||
type BootstrapAdmin struct {
|
||||
users UserRepository
|
||||
hasher PasswordHasher
|
||||
clk Clock
|
||||
}
|
||||
|
||||
func NewBootstrapAdmin(users UserRepository, hasher PasswordHasher, clk Clock) *BootstrapAdmin {
|
||||
return &BootstrapAdmin{users: users, hasher: hasher, clk: clk}
|
||||
}
|
||||
|
||||
// Execute creates the admin if it does not already exist, reporting whether it
|
||||
// created one. It is idempotent: a second run (a restart) finds the account and
|
||||
// does nothing, so it is safe to call on every boot.
|
||||
func (uc *BootstrapAdmin) Execute(ctx context.Context, email, password string) (created bool, err error) {
|
||||
email = domain.NormalizeEmail(email)
|
||||
|
||||
if _, err := uc.users.GetByEmail(ctx, email); err == nil {
|
||||
return false, nil // already bootstrapped
|
||||
} else if !errors.Is(err, ErrUserNotFound) {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if len(password) < minPasswordLen {
|
||||
return false, ErrPasswordTooShort
|
||||
}
|
||||
if len(password) > maxPasswordLen {
|
||||
return false, ErrPasswordTooLong
|
||||
}
|
||||
|
||||
hash, err := uc.hasher.Hash(password)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
u, err := domain.NewUser(email, hash, uc.clk.Now())
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
// Direct role assignment is safe here: this is a trusted server-side seed,
|
||||
// not a request. A root admin is also a trusted contributor.
|
||||
u.Role = domain.RoleAdmin
|
||||
u.Verified = true
|
||||
|
||||
if err := uc.users.Insert(ctx, u); err != nil {
|
||||
// A concurrent bootstrap (two replicas booting at once) is fine: whoever
|
||||
// lost the race just observes the account now exists.
|
||||
if errors.Is(err, ErrEmailExists) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package usecase_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/auth"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/memstore"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/usecase"
|
||||
)
|
||||
|
||||
func newBootstrap() (*usecase.BootstrapAdmin, *memstore.UserRepo) {
|
||||
users := memstore.NewUserRepo()
|
||||
hasher := auth.NewHasher(4)
|
||||
clk := memstore.Clock{T: time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC)}
|
||||
return usecase.NewBootstrapAdmin(users, hasher, clk), users
|
||||
}
|
||||
|
||||
func TestBootstrapCreatesAdmin(t *testing.T) {
|
||||
bs, users := newBootstrap()
|
||||
|
||||
created, err := bs.Execute(context.Background(), "Root@Example.com", "rootpassword")
|
||||
if err != nil {
|
||||
t.Fatalf("bootstrap: %v", err)
|
||||
}
|
||||
if !created {
|
||||
t.Fatal("expected an admin to be created")
|
||||
}
|
||||
|
||||
u, err := users.GetByEmail(context.Background(), "root@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("admin not persisted: %v", err)
|
||||
}
|
||||
if u.Role != domain.RoleAdmin {
|
||||
t.Errorf("role = %q, want admin", u.Role)
|
||||
}
|
||||
if !u.Verified {
|
||||
t.Error("bootstrap admin should be verified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapIsIdempotent(t *testing.T) {
|
||||
bs, users := newBootstrap()
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := bs.Execute(ctx, "root@example.com", "rootpassword"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
created, err := bs.Execute(ctx, "root@example.com", "rootpassword")
|
||||
if err != nil {
|
||||
t.Fatalf("second run: %v", err)
|
||||
}
|
||||
if created {
|
||||
t.Error("second run must not create a duplicate admin")
|
||||
}
|
||||
|
||||
// The account must still be a single admin.
|
||||
if u, _ := users.GetByEmail(ctx, "root@example.com"); u.Role != domain.RoleAdmin {
|
||||
t.Errorf("role changed: %q", u.Role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapRejectsWeakPassword(t *testing.T) {
|
||||
bs, _ := newBootstrap()
|
||||
if _, err := bs.Execute(context.Background(), "root@example.com", "short"); !errors.Is(err, usecase.ErrPasswordTooShort) {
|
||||
t.Errorf("got %v, want ErrPasswordTooShort", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package usecase_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/usecase"
|
||||
)
|
||||
|
||||
// These stubs let a test inject failures the happy-path memstore never produces,
|
||||
// so the use cases' error branches are exercised too.
|
||||
|
||||
var errBoom = errors.New("boom")
|
||||
|
||||
type stubRepo struct {
|
||||
getByEmail func() (*domain.User, error)
|
||||
insert func() error
|
||||
}
|
||||
|
||||
func (s stubRepo) Insert(context.Context, *domain.User) error { return s.insert() }
|
||||
func (s stubRepo) GetByEmail(context.Context, string) (*domain.User, error) {
|
||||
return s.getByEmail()
|
||||
}
|
||||
func (s stubRepo) GetByID(context.Context, uuid.UUID) (*domain.User, error) {
|
||||
return nil, usecase.ErrUserNotFound
|
||||
}
|
||||
func (s stubRepo) SetVerified(context.Context, uuid.UUID, bool) error {
|
||||
return usecase.ErrUserNotFound
|
||||
}
|
||||
func (s stubRepo) SetRole(context.Context, uuid.UUID, domain.Role) error {
|
||||
return usecase.ErrUserNotFound
|
||||
}
|
||||
func (s stubRepo) ListUsers(context.Context) ([]*domain.User, error) { return nil, nil }
|
||||
|
||||
type stubHasher struct {
|
||||
hashErr error
|
||||
compareErr error
|
||||
}
|
||||
|
||||
func (s stubHasher) Hash(string) (string, error) {
|
||||
if s.hashErr != nil {
|
||||
return "", s.hashErr
|
||||
}
|
||||
return "hashed", nil
|
||||
}
|
||||
func (s stubHasher) Compare(string, string) error { return s.compareErr }
|
||||
|
||||
type stubIssuer struct{ err error }
|
||||
|
||||
func (s stubIssuer) Issue(*domain.User) (string, error) {
|
||||
if s.err != nil {
|
||||
return "", s.err
|
||||
}
|
||||
return "token", nil
|
||||
}
|
||||
|
||||
func TestRegisterPropagatesHasherError(t *testing.T) {
|
||||
clk := stubClock{time.Now()}
|
||||
reg := usecase.NewRegister(stubRepo{}, stubHasher{hashErr: errBoom}, clk)
|
||||
|
||||
_, err := reg.Execute(context.Background(), "a@b.com", "password123")
|
||||
if !errors.Is(err, errBoom) {
|
||||
t.Errorf("got %v, want errBoom", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterPropagatesInsertError(t *testing.T) {
|
||||
clk := stubClock{time.Now()}
|
||||
repo := stubRepo{insert: func() error { return errBoom }}
|
||||
reg := usecase.NewRegister(repo, stubHasher{}, clk)
|
||||
|
||||
_, err := reg.Execute(context.Background(), "a@b.com", "password123")
|
||||
if !errors.Is(err, errBoom) {
|
||||
t.Errorf("got %v, want errBoom", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginPropagatesRepoError(t *testing.T) {
|
||||
// A non-ErrUserNotFound repo error must surface as-is, not be masked as
|
||||
// ErrInvalidCredentials.
|
||||
repo := stubRepo{getByEmail: func() (*domain.User, error) { return nil, errBoom }}
|
||||
login := usecase.NewLogin(repo, stubHasher{}, stubIssuer{})
|
||||
|
||||
_, _, err := login.Execute(context.Background(), "a@b.com", "password123")
|
||||
if !errors.Is(err, errBoom) {
|
||||
t.Errorf("got %v, want errBoom", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginPropagatesIssuerError(t *testing.T) {
|
||||
repo := stubRepo{getByEmail: func() (*domain.User, error) {
|
||||
return &domain.User{ID: uuid.New(), Email: "a@b.com", Role: domain.RoleUser}, nil
|
||||
}}
|
||||
// Hasher accepts the password (nil compareErr) so we reach token issuance.
|
||||
login := usecase.NewLogin(repo, stubHasher{}, stubIssuer{err: errBoom})
|
||||
|
||||
_, _, err := login.Execute(context.Background(), "a@b.com", "password123")
|
||||
if !errors.Is(err, errBoom) {
|
||||
t.Errorf("got %v, want errBoom", err)
|
||||
}
|
||||
}
|
||||
|
||||
type stubClock struct{ t time.Time }
|
||||
|
||||
func (c stubClock) Now() time.Time { return c.t }
|
||||
@@ -0,0 +1,27 @@
|
||||
package usecase
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
// Repository-contract errors, returned by UserRepository implementations.
|
||||
ErrEmailExists = errors.New("email already registered")
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
|
||||
// ErrWorkerKeyNotFound is returned by WorkerKeyRepository when no live key
|
||||
// matches (by id for revoke, by hash for exchange).
|
||||
ErrWorkerKeyNotFound = errors.New("worker key not found")
|
||||
// ErrInvalidWorkerKey is surfaced to the transport layer for a key that does
|
||||
// not exchange (unknown, revoked, or owner gone). Deliberately opaque so a
|
||||
// caller cannot distinguish the cases while probing.
|
||||
ErrInvalidWorkerKey = errors.New("invalid worker key")
|
||||
|
||||
// Use-case errors surfaced to the transport layer.
|
||||
//
|
||||
// ErrInvalidCredentials is deliberately returned for both an unknown email
|
||||
// and a wrong password, so an attacker cannot use the response to learn
|
||||
// which emails are registered.
|
||||
ErrInvalidCredentials = errors.New("invalid email or password")
|
||||
ErrPasswordTooShort = errors.New("password too short")
|
||||
ErrPasswordTooLong = errors.New("password too long")
|
||||
ErrInvalidRole = errors.New("invalid role")
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
|
||||
)
|
||||
|
||||
// Login verifies credentials and issues a signed token.
|
||||
type Login struct {
|
||||
users UserRepository
|
||||
hasher PasswordHasher
|
||||
tokens TokenIssuer
|
||||
}
|
||||
|
||||
func NewLogin(users UserRepository, hasher PasswordHasher, tokens TokenIssuer) *Login {
|
||||
return &Login{users: users, hasher: hasher, tokens: tokens}
|
||||
}
|
||||
|
||||
// Execute returns a signed token and the user on success. It returns
|
||||
// ErrInvalidCredentials for both an unknown email and a wrong password so the
|
||||
// two cases are indistinguishable to a caller probing for valid accounts.
|
||||
func (l *Login) Execute(ctx context.Context, email, password string) (string, *domain.User, error) {
|
||||
u, err := l.users.GetByEmail(ctx, domain.NormalizeEmail(email))
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUserNotFound) {
|
||||
return "", nil, ErrInvalidCredentials
|
||||
}
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
if err := l.hasher.Compare(u.PasswordHash, password); err != nil {
|
||||
return "", nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
token, err := l.tokens.Issue(u)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return token, u, nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Package usecase holds the application logic — registration and login — plus
|
||||
// the ports (interfaces) it depends on. The concrete adapters (PostgreSQL,
|
||||
// bcrypt, JWT) are injected from cmd, so this package never imports them.
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
|
||||
)
|
||||
|
||||
// UserRepository persists and looks up users. Implementations return the
|
||||
// sentinel errors in errors.go so the use cases can react without knowing about
|
||||
// SQL or driver types.
|
||||
type UserRepository interface {
|
||||
// Insert stores a new user, returning ErrEmailExists if the email is taken.
|
||||
Insert(ctx context.Context, u *domain.User) error
|
||||
// GetByEmail returns the user with the (normalised) email, or ErrUserNotFound.
|
||||
GetByEmail(ctx context.Context, email string) (*domain.User, error)
|
||||
// GetByID returns the user with id, or ErrUserNotFound.
|
||||
GetByID(ctx context.Context, id uuid.UUID) (*domain.User, error)
|
||||
// SetVerified toggles the verified flag, returning ErrUserNotFound if no
|
||||
// such user exists.
|
||||
SetVerified(ctx context.Context, id uuid.UUID, verified bool) error
|
||||
// SetRole changes a user's role, returning ErrUserNotFound if no such user
|
||||
// exists.
|
||||
SetRole(ctx context.Context, id uuid.UUID, role domain.Role) error
|
||||
// ListUsers returns every account, oldest first. Admin-only: used by the
|
||||
// coordinator admin console; the response must never carry password hashes
|
||||
// (the caller projects the entity).
|
||||
ListUsers(ctx context.Context) ([]*domain.User, error)
|
||||
}
|
||||
|
||||
// WorkerKeyRepository persists and looks up the long-lived worker keys a user
|
||||
// creates to run a worker bound to their account. Implementations return the
|
||||
// sentinel errors in errors.go so the use cases stay free of SQL types.
|
||||
type WorkerKeyRepository interface {
|
||||
// Insert stores a freshly minted key.
|
||||
Insert(ctx context.Context, k *domain.WorkerKey) error
|
||||
// ListByUser returns a user's live (non-revoked) keys, newest first.
|
||||
ListByUser(ctx context.Context, userID uuid.UUID) ([]*domain.WorkerKey, error)
|
||||
// ListAll returns every key (revoked included), newest first. Admin-only:
|
||||
// backs the coordinator admin console's key table.
|
||||
ListAll(ctx context.Context) ([]*domain.WorkerKey, error)
|
||||
// GetActiveByHash returns the non-revoked key with the given hash, or
|
||||
// ErrWorkerKeyNotFound.
|
||||
GetActiveByHash(ctx context.Context, tokenHash string) (*domain.WorkerKey, error)
|
||||
// Revoke retires a key the user owns, returning ErrWorkerKeyNotFound when no
|
||||
// live key with that id belongs to the user.
|
||||
Revoke(ctx context.Context, id, userID uuid.UUID) error
|
||||
// RevokeAny retires a key by id regardless of its owner. Admin-only; the
|
||||
// coordinator admin console uses it to cut a key immediately.
|
||||
RevokeAny(ctx context.Context, id uuid.UUID) error
|
||||
// TouchLastUsed records a successful exchange. Best-effort: a failure here
|
||||
// must not fail the exchange itself.
|
||||
TouchLastUsed(ctx context.Context, id uuid.UUID) error
|
||||
}
|
||||
|
||||
// PasswordHasher hashes and verifies passwords. The bcrypt adapter satisfies it.
|
||||
type PasswordHasher interface {
|
||||
Hash(password string) (string, error)
|
||||
Compare(hash, password string) error
|
||||
}
|
||||
|
||||
// TokenIssuer mints a signed access token for an authenticated user. It takes
|
||||
// the whole user so trust-bearing claims (role, verified) travel in the token.
|
||||
type TokenIssuer interface {
|
||||
Issue(u *domain.User) (string, error)
|
||||
}
|
||||
|
||||
// Clock reads the current time; a fake one makes tests deterministic.
|
||||
type Clock interface {
|
||||
Now() time.Time
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
// minPasswordLen is a floor, not a policy engine — enough to reject the
|
||||
// obviously weak without pretending to measure real strength.
|
||||
minPasswordLen = 8
|
||||
// maxPasswordLen is bcrypt's hard input limit: it ignores bytes past 72, so
|
||||
// accepting a longer password would silently hash only its prefix.
|
||||
maxPasswordLen = 72
|
||||
)
|
||||
|
||||
// Register creates a new account: it validates the password, hashes it, builds
|
||||
// the domain user, and persists it.
|
||||
type Register struct {
|
||||
users UserRepository
|
||||
hasher PasswordHasher
|
||||
clk Clock
|
||||
}
|
||||
|
||||
func NewRegister(users UserRepository, hasher PasswordHasher, clk Clock) *Register {
|
||||
return &Register{users: users, hasher: hasher, clk: clk}
|
||||
}
|
||||
|
||||
// Execute registers email/password and returns the persisted user. The returned
|
||||
// user carries no plaintext password, only its hash.
|
||||
func (r *Register) Execute(ctx context.Context, email, password string) (*domain.User, error) {
|
||||
if len(password) < minPasswordLen {
|
||||
return nil, ErrPasswordTooShort
|
||||
}
|
||||
if len(password) > maxPasswordLen {
|
||||
return nil, ErrPasswordTooLong
|
||||
}
|
||||
|
||||
hash, err := r.hasher.Hash(password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// NewUser normalises the email and enforces its shape; it returns a domain
|
||||
// validation error the transport layer maps to 400.
|
||||
u, err := domain.NewUser(email, hash, r.clk.Now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := r.users.Insert(ctx, u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
|
||||
)
|
||||
|
||||
// SetRole promotes or demotes a user. Only an admin may call this (enforced in
|
||||
// the transport layer); the use case validates the target role and applies it.
|
||||
type SetRole struct {
|
||||
users UserRepository
|
||||
}
|
||||
|
||||
func NewSetRole(users UserRepository) *SetRole {
|
||||
return &SetRole{users: users}
|
||||
}
|
||||
|
||||
// Execute assigns role to the user, returning ErrInvalidRole for an unknown role
|
||||
// or ErrUserNotFound if the user does not exist.
|
||||
func (uc *SetRole) Execute(ctx context.Context, id uuid.UUID, role domain.Role) error {
|
||||
if !role.Valid() {
|
||||
return ErrInvalidRole
|
||||
}
|
||||
return uc.users.SetRole(ctx, id, role)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package usecase_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/auth"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/memstore"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/usecase"
|
||||
)
|
||||
|
||||
const secret = "usecase-test-secret-32-bytes-long!!!"
|
||||
|
||||
func newFixtures() (*usecase.Register, *usecase.Login, *memstore.UserRepo) {
|
||||
users := memstore.NewUserRepo()
|
||||
hasher := auth.NewHasher(4) // low cost keeps tests fast
|
||||
clk := memstore.Clock{T: time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC)}
|
||||
// The issuer uses the real clock (nil): token expiry is validated against
|
||||
// wall-clock time, so a fixed issue-time would make tokens instantly stale.
|
||||
issuer := auth.NewIssuer(secret, time.Hour, nil)
|
||||
|
||||
reg := usecase.NewRegister(users, hasher, clk)
|
||||
login := usecase.NewLogin(users, hasher, issuer)
|
||||
return reg, login, users
|
||||
}
|
||||
|
||||
func TestRegisterSuccess(t *testing.T) {
|
||||
reg, _, users := newFixtures()
|
||||
|
||||
u, err := reg.Execute(context.Background(), "Alice@Example.com", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
if u.Email != "alice@example.com" {
|
||||
t.Errorf("email not normalised: %q", u.Email)
|
||||
}
|
||||
if u.Role != domain.RoleUser {
|
||||
t.Errorf("role = %q, want user", u.Role)
|
||||
}
|
||||
if strings.Contains(u.PasswordHash, "password123") {
|
||||
t.Error("password stored in cleartext")
|
||||
}
|
||||
if _, err := users.GetByEmail(context.Background(), "alice@example.com"); err != nil {
|
||||
t.Errorf("user not persisted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterDuplicateEmail(t *testing.T) {
|
||||
reg, _, _ := newFixtures()
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := reg.Execute(ctx, "dup@example.com", "password123"); err != nil {
|
||||
t.Fatalf("first register: %v", err)
|
||||
}
|
||||
_, err := reg.Execute(ctx, "Dup@example.com", "password123") // different case, same email
|
||||
if !errors.Is(err, usecase.ErrEmailExists) {
|
||||
t.Errorf("got %v, want ErrEmailExists", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterPasswordPolicy(t *testing.T) {
|
||||
reg, _, _ := newFixtures()
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := reg.Execute(ctx, "a@b.com", "short"); !errors.Is(err, usecase.ErrPasswordTooShort) {
|
||||
t.Errorf("short password: got %v", err)
|
||||
}
|
||||
long := strings.Repeat("x", 73)
|
||||
if _, err := reg.Execute(ctx, "a@b.com", long); !errors.Is(err, usecase.ErrPasswordTooLong) {
|
||||
t.Errorf("long password: got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterInvalidEmail(t *testing.T) {
|
||||
reg, _, _ := newFixtures()
|
||||
_, err := reg.Execute(context.Background(), "not-an-email", "password123")
|
||||
if !errors.Is(err, domain.ErrInvalidEmail) {
|
||||
t.Errorf("got %v, want ErrInvalidEmail", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginSuccess(t *testing.T) {
|
||||
reg, login, _ := newFixtures()
|
||||
ctx := context.Background()
|
||||
if _, err := reg.Execute(ctx, "user@example.com", "password123"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
token, u, err := login.Execute(ctx, "User@Example.com", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("login: %v", err)
|
||||
}
|
||||
if token == "" {
|
||||
t.Error("empty token")
|
||||
}
|
||||
if u.Email != "user@example.com" {
|
||||
t.Errorf("wrong user returned: %q", u.Email)
|
||||
}
|
||||
|
||||
// The token must verify and carry this user's id.
|
||||
claims, err := auth.NewIssuer(secret, time.Hour, nil).Verify(token)
|
||||
if err != nil {
|
||||
t.Fatalf("issued token does not verify: %v", err)
|
||||
}
|
||||
if claims.Subject != u.ID.String() {
|
||||
t.Errorf("token sub = %q, want %q", claims.Subject, u.ID.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginWrongPassword(t *testing.T) {
|
||||
reg, login, _ := newFixtures()
|
||||
ctx := context.Background()
|
||||
if _, err := reg.Execute(ctx, "user@example.com", "password123"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, _, err := login.Execute(ctx, "user@example.com", "wrongpass1")
|
||||
if !errors.Is(err, usecase.ErrInvalidCredentials) {
|
||||
t.Errorf("got %v, want ErrInvalidCredentials", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginUnknownEmailIsIndistinguishable(t *testing.T) {
|
||||
_, login, _ := newFixtures()
|
||||
_, _, err := login.Execute(context.Background(), "ghost@example.com", "password123")
|
||||
if !errors.Is(err, usecase.ErrInvalidCredentials) {
|
||||
t.Errorf("unknown email must return ErrInvalidCredentials, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
|
||||
)
|
||||
|
||||
// ListUsers returns every account for the coordinator admin console. The
|
||||
// handler must project the entities so password hashes never leave the service.
|
||||
type ListUsers struct {
|
||||
users UserRepository
|
||||
}
|
||||
|
||||
func NewListUsers(users UserRepository) *ListUsers {
|
||||
return &ListUsers{users: users}
|
||||
}
|
||||
|
||||
func (uc *ListUsers) Execute(ctx context.Context) ([]*domain.User, error) {
|
||||
return uc.users.ListUsers(ctx)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// SetVerified grants or revokes a user's trusted-contributor badge. Only an
|
||||
// admin may call this (enforced in the transport layer); the use case itself
|
||||
// just applies the change.
|
||||
type SetVerified struct {
|
||||
users UserRepository
|
||||
}
|
||||
|
||||
func NewSetVerified(users UserRepository) *SetVerified {
|
||||
return &SetVerified{users: users}
|
||||
}
|
||||
|
||||
// Execute sets the verified flag on the target user, returning ErrUserNotFound
|
||||
// if the user does not exist.
|
||||
func (uc *SetVerified) Execute(ctx context.Context, id uuid.UUID, verified bool) error {
|
||||
return uc.users.SetVerified(ctx, id, verified)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package usecase_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/memstore"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/usecase"
|
||||
)
|
||||
|
||||
func TestSetVerifiedGrantsAndRevokes(t *testing.T) {
|
||||
reg, _, users := newFixtures()
|
||||
ctx := context.Background()
|
||||
|
||||
u, err := reg.Execute(ctx, "contrib@example.com", "password123")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u.Verified {
|
||||
t.Fatal("a fresh account must be unverified")
|
||||
}
|
||||
|
||||
sv := usecase.NewSetVerified(users)
|
||||
|
||||
if err := sv.Execute(ctx, u.ID, true); err != nil {
|
||||
t.Fatalf("grant: %v", err)
|
||||
}
|
||||
got, _ := users.GetByID(ctx, u.ID)
|
||||
if !got.Verified {
|
||||
t.Error("verified flag not set")
|
||||
}
|
||||
|
||||
if err := sv.Execute(ctx, u.ID, false); err != nil {
|
||||
t.Fatalf("revoke: %v", err)
|
||||
}
|
||||
got, _ = users.GetByID(ctx, u.ID)
|
||||
if got.Verified {
|
||||
t.Error("verified flag not cleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetVerifiedUnknownUser(t *testing.T) {
|
||||
users := memstore.NewUserRepo()
|
||||
sv := usecase.NewSetVerified(users)
|
||||
|
||||
if err := sv.Execute(context.Background(), uuid.New(), true); !errors.Is(err, usecase.ErrUserNotFound) {
|
||||
t.Errorf("got %v, want ErrUserNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginTokenCarriesVerified(t *testing.T) {
|
||||
reg, login, users := newFixtures()
|
||||
ctx := context.Background()
|
||||
|
||||
u, err := reg.Execute(ctx, "trusted@example.com", "password123")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := usecase.NewSetVerified(users).Execute(ctx, u.ID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, loggedIn, err := login.Execute(ctx, "trusted@example.com", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("login: %v", err)
|
||||
}
|
||||
if !loggedIn.Verified {
|
||||
t.Error("login must reflect the granted verified flag")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
|
||||
)
|
||||
|
||||
// CreateWorkerKey mints a long-lived worker key for a user and returns the
|
||||
// one-time plaintext to show once.
|
||||
type CreateWorkerKey struct {
|
||||
keys WorkerKeyRepository
|
||||
clock Clock
|
||||
}
|
||||
|
||||
func NewCreateWorkerKey(keys WorkerKeyRepository, clock Clock) *CreateWorkerKey {
|
||||
return &CreateWorkerKey{keys: keys, clock: clock}
|
||||
}
|
||||
|
||||
// Execute returns the stored key (hash only) and the plaintext secret. The
|
||||
// secret is never persisted, so this is the sole moment it can be surfaced.
|
||||
func (uc *CreateWorkerKey) Execute(ctx context.Context, userID uuid.UUID, name string) (*domain.WorkerKey, string, error) {
|
||||
key, raw, err := domain.NewWorkerKey(userID, name, uc.clock.Now())
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := uc.keys.Insert(ctx, key); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return key, raw, nil
|
||||
}
|
||||
|
||||
// ListWorkerKeys returns a user's live keys for display and management.
|
||||
type ListWorkerKeys struct {
|
||||
keys WorkerKeyRepository
|
||||
}
|
||||
|
||||
func NewListWorkerKeys(keys WorkerKeyRepository) *ListWorkerKeys {
|
||||
return &ListWorkerKeys{keys: keys}
|
||||
}
|
||||
|
||||
func (uc *ListWorkerKeys) Execute(ctx context.Context, userID uuid.UUID) ([]*domain.WorkerKey, error) {
|
||||
return uc.keys.ListByUser(ctx, userID)
|
||||
}
|
||||
|
||||
// ListWorkerKeysAll returns every key in the service, revoked included, for
|
||||
// the coordinator admin console. Admin-only.
|
||||
type ListWorkerKeysAll struct {
|
||||
keys WorkerKeyRepository
|
||||
}
|
||||
|
||||
func NewListWorkerKeysAll(keys WorkerKeyRepository) *ListWorkerKeysAll {
|
||||
return &ListWorkerKeysAll{keys: keys}
|
||||
}
|
||||
|
||||
func (uc *ListWorkerKeysAll) Execute(ctx context.Context) ([]*domain.WorkerKey, error) {
|
||||
return uc.keys.ListAll(ctx)
|
||||
}
|
||||
|
||||
// RevokeWorkerKeyAdmin retires any key, regardless of owner. Admin-only; used
|
||||
// by the coordinator admin console when a key must be cut immediately.
|
||||
type RevokeWorkerKeyAdmin struct {
|
||||
keys WorkerKeyRepository
|
||||
}
|
||||
|
||||
func NewRevokeWorkerKeyAdmin(keys WorkerKeyRepository) *RevokeWorkerKeyAdmin {
|
||||
return &RevokeWorkerKeyAdmin{keys: keys}
|
||||
}
|
||||
|
||||
func (uc *RevokeWorkerKeyAdmin) Execute(ctx context.Context, id uuid.UUID) error {
|
||||
return uc.keys.RevokeAny(ctx, id)
|
||||
}
|
||||
|
||||
// RevokeWorkerKey retires one of the caller's keys.
|
||||
type RevokeWorkerKey struct {
|
||||
keys WorkerKeyRepository
|
||||
}
|
||||
|
||||
func NewRevokeWorkerKey(keys WorkerKeyRepository) *RevokeWorkerKey {
|
||||
return &RevokeWorkerKey{keys: keys}
|
||||
}
|
||||
|
||||
func (uc *RevokeWorkerKey) Execute(ctx context.Context, userID, id uuid.UUID) error {
|
||||
return uc.keys.Revoke(ctx, id, userID)
|
||||
}
|
||||
|
||||
// ExchangeWorkerKey trades a valid worker key for a short-lived JWT. The JWT
|
||||
// carries the owner's current role and verified flag, so a worker that refreshes
|
||||
// after an admin verifies the owner picks up the upgraded trust on its next
|
||||
// registration.
|
||||
type ExchangeWorkerKey struct {
|
||||
keys WorkerKeyRepository
|
||||
users UserRepository
|
||||
tokens TokenIssuer
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
func NewExchangeWorkerKey(keys WorkerKeyRepository, users UserRepository, tokens TokenIssuer, ttl time.Duration) *ExchangeWorkerKey {
|
||||
return &ExchangeWorkerKey{keys: keys, users: users, tokens: tokens, ttl: ttl}
|
||||
}
|
||||
|
||||
// Execute returns a signed token and its lifetime in seconds. Every failure to
|
||||
// resolve the key to a usable owner collapses to ErrInvalidWorkerKey so a caller
|
||||
// cannot tell an unknown key from a revoked one or a deleted owner.
|
||||
func (uc *ExchangeWorkerKey) Execute(ctx context.Context, rawKey string) (string, int, error) {
|
||||
if rawKey == "" {
|
||||
return "", 0, ErrInvalidWorkerKey
|
||||
}
|
||||
|
||||
key, err := uc.keys.GetActiveByHash(ctx, domain.HashWorkerKey(rawKey))
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrWorkerKeyNotFound) {
|
||||
return "", 0, ErrInvalidWorkerKey
|
||||
}
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
u, err := uc.users.GetByID(ctx, key.UserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUserNotFound) {
|
||||
return "", 0, ErrInvalidWorkerKey
|
||||
}
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
token, err := uc.tokens.Issue(u)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
// Best-effort: a failed timestamp update must not sink an otherwise valid
|
||||
// exchange the worker depends on to keep running.
|
||||
_ = uc.keys.TouchLastUsed(ctx, key.ID)
|
||||
|
||||
return token, int(uc.ttl.Seconds()), nil
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package usecase_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/auth"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/memstore"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/usecase"
|
||||
)
|
||||
|
||||
// fakeKeyRepo is an in-memory WorkerKeyRepository for the use-case tests.
|
||||
type fakeKeyRepo struct {
|
||||
byHash map[string]*domain.WorkerKey
|
||||
byID map[uuid.UUID]*domain.WorkerKey
|
||||
touched []uuid.UUID
|
||||
}
|
||||
|
||||
func newFakeKeyRepo() *fakeKeyRepo {
|
||||
return &fakeKeyRepo{byHash: map[string]*domain.WorkerKey{}, byID: map[uuid.UUID]*domain.WorkerKey{}}
|
||||
}
|
||||
|
||||
func (r *fakeKeyRepo) Insert(_ context.Context, k *domain.WorkerKey) error {
|
||||
r.byHash[k.TokenHash] = k
|
||||
r.byID[k.ID] = k
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeKeyRepo) ListByUser(_ context.Context, userID uuid.UUID) ([]*domain.WorkerKey, error) {
|
||||
var out []*domain.WorkerKey
|
||||
for _, k := range r.byID {
|
||||
if k.UserID == userID && !k.Revoked() {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *fakeKeyRepo) GetActiveByHash(_ context.Context, hash string) (*domain.WorkerKey, error) {
|
||||
k, ok := r.byHash[hash]
|
||||
if !ok || k.Revoked() {
|
||||
return nil, usecase.ErrWorkerKeyNotFound
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
func (r *fakeKeyRepo) Revoke(_ context.Context, id, userID uuid.UUID) error {
|
||||
k, ok := r.byID[id]
|
||||
if !ok || k.UserID != userID || k.Revoked() {
|
||||
return usecase.ErrWorkerKeyNotFound
|
||||
}
|
||||
now := time.Now()
|
||||
k.RevokedAt = &now
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeKeyRepo) TouchLastUsed(_ context.Context, id uuid.UUID) error {
|
||||
r.touched = append(r.touched, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeKeyRepo) ListAll(_ context.Context) ([]*domain.WorkerKey, error) {
|
||||
out := make([]*domain.WorkerKey, 0, len(r.byID))
|
||||
for _, k := range r.byID {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *fakeKeyRepo) RevokeAny(_ context.Context, id uuid.UUID) error {
|
||||
k, ok := r.byID[id]
|
||||
if !ok || k.Revoked() {
|
||||
return usecase.ErrWorkerKeyNotFound
|
||||
}
|
||||
now := time.Now()
|
||||
k.RevokedAt = &now
|
||||
return nil
|
||||
}
|
||||
|
||||
func newKeyFixtures(t *testing.T) (*usecase.CreateWorkerKey, *usecase.ExchangeWorkerKey, *usecase.RevokeWorkerKey, *usecase.ListWorkerKeys, *fakeKeyRepo, *domain.User) {
|
||||
t.Helper()
|
||||
users := memstore.NewUserRepo()
|
||||
hasher := auth.NewHasher(4)
|
||||
clk := memstore.Clock{T: time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC)}
|
||||
issuer := auth.NewIssuer(secret, time.Hour, nil)
|
||||
keys := newFakeKeyRepo()
|
||||
|
||||
u, err := usecase.NewRegister(users, hasher, clk).Execute(context.Background(), "worker@example.com", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
|
||||
return usecase.NewCreateWorkerKey(keys, clk),
|
||||
usecase.NewExchangeWorkerKey(keys, users, issuer, time.Hour),
|
||||
usecase.NewRevokeWorkerKey(keys),
|
||||
usecase.NewListWorkerKeys(keys),
|
||||
keys, u
|
||||
}
|
||||
|
||||
func TestCreateAndExchangeWorkerKey(t *testing.T) {
|
||||
create, exchange, _, _, keys, u := newKeyFixtures(t)
|
||||
ctx := context.Background()
|
||||
|
||||
key, raw, err := create.Execute(ctx, u.ID, "home-desktop")
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if key.Name != "home-desktop" || raw == "" {
|
||||
t.Fatalf("unexpected key %+v raw=%q", key, raw)
|
||||
}
|
||||
|
||||
token, expiresIn, err := exchange.Execute(ctx, raw)
|
||||
if err != nil {
|
||||
t.Fatalf("exchange: %v", err)
|
||||
}
|
||||
if expiresIn != int((time.Hour).Seconds()) {
|
||||
t.Errorf("expires_in = %d, want 3600", expiresIn)
|
||||
}
|
||||
|
||||
claims, err := auth.NewIssuer(secret, time.Hour, nil).Verify(token)
|
||||
if err != nil {
|
||||
t.Fatalf("issued token does not verify: %v", err)
|
||||
}
|
||||
if claims.Subject != u.ID.String() {
|
||||
t.Errorf("token sub = %q, want owner %q", claims.Subject, u.ID)
|
||||
}
|
||||
if len(keys.touched) != 1 || keys.touched[0] != key.ID {
|
||||
t.Errorf("exchange did not record last-used, touched=%v", keys.touched)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeUnknownKeyIsInvalid(t *testing.T) {
|
||||
_, exchange, _, _, _, _ := newKeyFixtures(t)
|
||||
if _, _, err := exchange.Execute(context.Background(), "scimesh_wk_live_nope"); !errors.Is(err, usecase.ErrInvalidWorkerKey) {
|
||||
t.Errorf("got %v, want ErrInvalidWorkerKey", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeEmptyKeyIsInvalid(t *testing.T) {
|
||||
_, exchange, _, _, _, _ := newKeyFixtures(t)
|
||||
if _, _, err := exchange.Execute(context.Background(), ""); !errors.Is(err, usecase.ErrInvalidWorkerKey) {
|
||||
t.Errorf("got %v, want ErrInvalidWorkerKey", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeRevokedKeyIsInvalid(t *testing.T) {
|
||||
create, exchange, revoke, _, _, u := newKeyFixtures(t)
|
||||
ctx := context.Background()
|
||||
|
||||
key, raw, err := create.Execute(ctx, u.ID, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := revoke.Execute(ctx, u.ID, key.ID); err != nil {
|
||||
t.Fatalf("revoke: %v", err)
|
||||
}
|
||||
if _, _, err := exchange.Execute(ctx, raw); !errors.Is(err, usecase.ErrInvalidWorkerKey) {
|
||||
t.Errorf("revoked key still exchanges: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeIsScopedToOwner(t *testing.T) {
|
||||
create, _, revoke, _, _, u := newKeyFixtures(t)
|
||||
ctx := context.Background()
|
||||
|
||||
key, _, err := create.Execute(ctx, u.ID, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A different user must not be able to revoke this key.
|
||||
if err := revoke.Execute(ctx, uuid.New(), key.ID); !errors.Is(err, usecase.ErrWorkerKeyNotFound) {
|
||||
t.Errorf("cross-owner revoke returned %v, want ErrWorkerKeyNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListReturnsOnlyLiveKeys(t *testing.T) {
|
||||
create, _, revoke, list, _, u := newKeyFixtures(t)
|
||||
ctx := context.Background()
|
||||
|
||||
live, _, err := create.Execute(ctx, u.ID, "keep")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dead, _, err := create.Execute(ctx, u.ID, "drop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := revoke.Execute(ctx, u.ID, dead.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := list.Execute(ctx, u.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].ID != live.ID {
|
||||
t.Errorf("list = %d keys, want only the live one", len(got))
|
||||
}
|
||||
}
|
||||
@@ -176,6 +176,12 @@ func (c *Catalog) Enabled() []*Workload {
|
||||
return result
|
||||
}
|
||||
|
||||
// Items returns every workload in the catalog, sorted by name (the same
|
||||
// ordering as Enabled). The caller must not mutate the entries.
|
||||
func (c *Catalog) Items() []*Workload {
|
||||
return c.workloads
|
||||
}
|
||||
|
||||
// ByName returns the workload with the given name, or nil.
|
||||
func (c *Catalog) ByName(name string) *Workload {
|
||||
for _, workload := range c.workloads {
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
# План реализации: Coordinator Admin UI и Worker Setup UI
|
||||
|
||||
> Статус: **реализовано (M1–M3), E2E проверено**. Визуал утверждён — мокапы:
|
||||
> [`ui-mockups/coordinator-admin.html`](ui-mockups/coordinator-admin.html),
|
||||
> [`ui-mockups/worker-setup.html`](ui-mockups/worker-setup.html).
|
||||
> Два UI живут в **разных бинарниках**: админка кластера — в `coordinator`
|
||||
> (`/ui/admin`), визард установки воркера — в `worker-agent`
|
||||
> (`worker-agent setup`, локально на 127.0.0.1).
|
||||
|
||||
## 1. Цель
|
||||
|
||||
1. **Coordinator Admin UI** — консоль админа распределённого кластера:
|
||||
System, Jobs, Workers, Users & keys, Workloads, Metrics, Settings.
|
||||
Реальные данные, никакой симуляции; только роль `admin`.
|
||||
2. **Worker Setup UI** — локальный визард в бинарнике `worker-agent` для
|
||||
машины, на которой стоит только воркер: подключение (URL + токен/ключ),
|
||||
параметры машины, preflight-проверка, запуск/остановка, статус и лог.
|
||||
|
||||
## 2. Что уже есть в коде (фундамент)
|
||||
|
||||
- `requireAdmin` middleware и `/ui/admin` (`internal/transport/http/ui_admin.go`)
|
||||
— сейчас минимальная панель: прокси `promote/demote/verify/unverify` в
|
||||
userservice.
|
||||
- Userservice (`internal/userservice/`): `POST /users/{id}/promote|demote|
|
||||
verify|unverify` (admin), `GET/POST/DELETE /worker-keys` (per-user),
|
||||
`POST /worker-tokens/exchange`. **Нет**: list users, list all keys.
|
||||
- `domain.Worker.TrustLevel` (`trusted`/`untrusted`) + quorum для untrusted —
|
||||
колонка в БД есть, нужен только метод смены и отображение.
|
||||
- `domain.Job.OwnerID *uuid.UUID` — владелец джобы из JWT `sub` (может быть nil).
|
||||
- Каталог ворклоадов `internal/workloads` (embedded `workloads.json`) —
|
||||
enable/disable кладём поверх через таблицу настроек.
|
||||
- UIReadRepo (`ui_read_repo.go` в обоих движках) — bounded read model для UI.
|
||||
- Агент целиком конфигурируется env (таблица в `mkdocs/sdk/worker-integration.md`);
|
||||
`--config` маппится на ту же поверхность `agent.Config`.
|
||||
|
||||
## 3. Coordinator Admin UI
|
||||
|
||||
### 3.1 Страницы ↔ API ↔ данные
|
||||
|
||||
Все API — JSON, под `withUISession` + `requireAdmin`, префикс
|
||||
`/ui/admin/api/`. Страница `admin.html` — оболочка из мокапа (сайдбар,
|
||||
7 разделов), данные подтягивает JS через fetch (паттерн dashboard).
|
||||
|
||||
| Раздел | API | Данные |
|
||||
| --- | --- | --- |
|
||||
| System | `GET /system` | version, uptime, storage stats, health (db/userservice/reducer), node info |
|
||||
| Jobs | `GET /jobs?status=&page=` | пагинированный список + счётчики по статусам; owner email резолвится через userservice |
|
||||
| Workers | `GET /workers`, `POST /workers/{id}/trust` | список + trust, смена trust (trusted/untrusted) |
|
||||
| Users & keys | `GET /users`, `POST /users/{id}/role`, `GET /worker-keys`, `POST /worker-keys/{id}/revoke` | прокси/агрегация userservice |
|
||||
| Workloads | `GET /workloads`, `POST /workloads/{name}/enabled` | каталог + persisted enabled-флаг |
|
||||
| Metrics | `GET /metrics` | jobs/day (7d), jobs by workload, shards, failure rate, avg shard time |
|
||||
| Settings | `GET /settings`, `POST /token/reveal` | read-only конфиг + reveal токена (audit-лог) |
|
||||
|
||||
### 3.2 Userservice — новые эндпоинты
|
||||
|
||||
- `GET /users` (admin) — `id, email, role, verified, created_at`.
|
||||
- `GET /worker-keys/all` (admin) — все ключи + owner email.
|
||||
- Репозитории: `ListUsers(ctx)`, `ListWorkerKeysAll(ctx)` в `memstore` и
|
||||
`storage/sqlite` (+ тесты). Координатор вызывает их через
|
||||
`callUserserviceAuthed` и отдаёт в свой bounded API — браузер юзерсервис
|
||||
не касается.
|
||||
|
||||
### 3.3 Storage координатора — новые методы
|
||||
|
||||
- `WorkerRepository.SetTrust(ctx, id, trust)` — оба движка + тесты.
|
||||
- `UIReadRepo.ListJobsPaginated(ctx, status string, limit, offset int)`
|
||||
→ `([]UIJob, total int, counts map[string]int)` — фильтр по статусу,
|
||||
счётчики для табов.
|
||||
- `UIReadRepo.JobMetrics(ctx, since time.Time)` → jobs/day, by workload,
|
||||
shards completed/failed, avg shard duration (из `tasks`).
|
||||
- `UIReadRepo.StorageStats(ctx)` → суммы байт по kind артефактов
|
||||
(datasets/artifacts) + размер файла БД (sqlite: `page_count*page_size`;
|
||||
postgres: `pg_database_size(current_database())`).
|
||||
- Миграция `0002_workload_settings` (оба движка):
|
||||
`workload_settings(workload TEXT PRIMARY KEY, enabled BOOL NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL)`; отсутствие строки = enabled (default).
|
||||
Repo: `WorkloadSettingsRepo{ List, Set }`.
|
||||
- **Enforcement**: `SubmitDataset` отклоняет disabled-ворклоад;
|
||||
`/ui/api/workloads` и форма new-job помечают disabled (скрываем из выбора).
|
||||
|
||||
### 3.4 Usecase — `internal/usecase/admin.go`
|
||||
|
||||
`AdminSystem`, `ListJobsAdmin(status,page)` (+owner emails), `ListWorkersAdmin`,
|
||||
`SetWorkerTrust`, `ListUsersAdmin`, `SetUserRole` (через userservice promote/demote),
|
||||
`ListWorkerKeysAdmin`, `RevokeWorkerKeyAdmin`, `ListWorkloadsAdmin`,
|
||||
`SetWorkloadEnabled`, `AdminMetrics`, `RevealWorkerToken` (читает
|
||||
`worker.token`/env, пишет audit-лог).
|
||||
|
||||
### 3.5 Шаблон
|
||||
|
||||
`templates/admin.html` заменяется на дизайн мокапа: сайдбар (Operate /
|
||||
Access / Platform), topbar с env-бейджем (serve/postgres, addr), 7 разделов,
|
||||
рендер через JS. Имя ворклоада — реальное `descriptor-batch`.
|
||||
|
||||
## 4. Worker Setup UI (бинарник `worker-agent`)
|
||||
|
||||
### 4.1 Новые файлы
|
||||
|
||||
```
|
||||
coordinator/internal/agent/
|
||||
configfile.go # ConfigFile (json), Load/Save (0600), путь по умолчанию
|
||||
check.go # CheckCoordinator(url, auth) — health + версии + python/scimesh
|
||||
setupui/
|
||||
server.go # локальный HTTP 127.0.0.1:12700, API + запуск/остановка
|
||||
template.html # визард + статус (по мокапу), go:embed
|
||||
coordinator/cmd/worker-agent/main.go # + setup / --config / --check
|
||||
```
|
||||
|
||||
### 4.2 CLI
|
||||
|
||||
- `worker-agent setup [--port 12700] [--no-open]` — визард; печатает URL,
|
||||
открывает браузер.
|
||||
- `worker-agent --config <path>` — демон из JSON-конфига; env имеет приоритет.
|
||||
- `worker-agent --check [--coordinator-url URL]` — пинг `/health`, auth
|
||||
(token → claim-endpoint 401/200 probe или exchange для ключа), python3 +
|
||||
`import scimesh`; exit 0/1. Используется визардом на шаге 3.
|
||||
|
||||
### 4.3 config.json
|
||||
|
||||
`~/.scimesh-worker/config.json` (переопределяется `WORKER_CONFIG`), права 0600:
|
||||
```json
|
||||
{
|
||||
"coordinator_url": "http://192.168.1.10:8080",
|
||||
"token": "…", // или
|
||||
"worker_key": "…", "userservice_url": "http://…:8081",
|
||||
"work_dir": "…", "worker_name": "emil-laptop",
|
||||
"cpu_count": 8, "memory_mb": 16384,
|
||||
"task_runner": ["python", "-m", "scimesh.worker.task"]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 API визарда (только 127.0.0.1, без auth)
|
||||
|
||||
| Метод | Путь | Назначение |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/status` | конфиг (секрет маскирован), running (pid alive), статистика из лога, runtime |
|
||||
| POST | `/api/config` | валидация + сохранение `config.json` (0600) |
|
||||
| POST | `/api/test` | preflight: coordinator reachable, auth ok, python, scimesh |
|
||||
| POST | `/api/start` | spawn `worker-agent --config <path>` (лог → `worker.log`, pid-файл) |
|
||||
| POST | `/api/stop` | SIGTERM по pid-файлу |
|
||||
| GET | `/api/logs?tail=` | хвост `worker.log` |
|
||||
|
||||
Статистика — парсинг лога (registered/claimed/completed/failed/heartbeat).
|
||||
Spawner — интерфейс, в тестах подменяется.
|
||||
|
||||
## 5. Милестоуны
|
||||
|
||||
- **M1. Admin-фундамент** — ✅ done: оболочка `admin.html` по мокапу +
|
||||
`GET /system` (storage stats, health, node) + `GET /jobs`
|
||||
(фильтр+пагинация+счётчики) + `GET /metrics`; тесты на sqlite, 403 для
|
||||
не-админа.
|
||||
- **M2. Access & Platform** — ✅ done: userservice `ListUsers`/
|
||||
`ListWorkerKeysAll` + admin revoke (mem+sqlite+http), `SetTrust` (оба
|
||||
движка), users/keys/trust разделы, `workload_settings` (миграции 0002/
|
||||
0014) + enforcement в `SubmitDataset` и форме, settings + audited token
|
||||
reveal. Все секции админки на живых данных.
|
||||
- **M3. Worker Setup** — ✅ done: `configfile.go` (0600), `--config` (env
|
||||
wins), `--check`, `setupui` (сервер на 127.0.0.1, 6 API, шаблон по мокапу),
|
||||
тесты (roundtrip, права, check, API с fake supervisor).
|
||||
- **M4. E2E + docs** — ✅ done: браузерный E2E (admin видит реальные
|
||||
данные; визард запустил реального воркера → `wizard-machine online` в
|
||||
админке), обновлены `mkdocs/index.md` и `mkdocs/standalone.md`
|
||||
(`worker-agent setup`), README, статусы CTX-19/CTX-20 в `PLAN.md`.
|
||||
|
||||
## 6. Тестирование
|
||||
|
||||
- **Go unit**: usecase admin (fakes + sqlite), repos обоих движков
|
||||
(SetTrust, pagination, metrics, settings, storage stats), userservice
|
||||
List/ListAll (mem+sqlite), HTTP permission (user → 403 на все `/ui/admin/api/*`),
|
||||
enforcement disabled-ворклоада, configfile/check/setupui агента.
|
||||
- **Go integration (postgres)**: миграция 0002 и parity новых методов —
|
||||
через существующий docker-хелпер, skip без docker.
|
||||
- **E2E браузер**: M4.
|
||||
|
||||
## 7. Открытые решения (зафиксировано)
|
||||
|
||||
- Локальный визард без аутентификации — слушает только 127.0.0.1.
|
||||
- Settings-раздел v1 — read-only + reveal токена; редактирование конфигурации
|
||||
и prune/reset (danger zone) — v2, в UI помечены как таковые.
|
||||
- `owner` джобы: email из userservice по `OwnerID`; при nil — «cluster token».
|
||||
- Статистика воркера в визарде — из лога, без новых эндпоинтов координатора.
|
||||
@@ -0,0 +1,388 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SciMesh · Coordinator Admin</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0b0e13;--panel:#11151d;--panel-2:#161c27;--border:#1f2634;--border-soft:#181f2b;
|
||||
--text:#e9ecf3;--text-2:#98a2b5;--text-3:#5d6879;
|
||||
--accent:#5b8cff;--accent-soft:#5b8cff1f;--accent-strong:#3f6fe0;
|
||||
--green:#3fce8a;--green-soft:#3fce8a1a;--amber:#e5b64f;--amber-soft:#e5b64f1a;--red:#f2647c;--red-soft:#f2647c1a;
|
||||
--mono:ui-monospace,"SF Mono",SFMono-Regular,Menlo,monospace;
|
||||
color-scheme:dark;
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:var(--bg);color:var(--text);font:14px/1.55 Inter,-apple-system,"Segoe UI",Roboto,sans-serif;-webkit-font-smoothing:antialiased}
|
||||
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
|
||||
input,select{font:inherit;color:var(--text);background:var(--panel-2);border:1px solid var(--border);border-radius:8px;padding:8px 11px;outline:none}
|
||||
input:focus,select:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
|
||||
code{font-family:var(--mono);font-size:.86em}
|
||||
|
||||
.layout{display:flex;min-height:100vh}
|
||||
|
||||
/* ── sidebar ─────────────────────────────── */
|
||||
.sidebar{position:sticky;top:0;height:100vh;width:232px;flex:none;display:flex;flex-direction:column;background:var(--panel);border-right:1px solid var(--border-soft)}
|
||||
.brand{display:flex;align-items:center;gap:11px;padding:20px 20px 18px;border-bottom:1px solid var(--border-soft)}
|
||||
.brand-mark{display:grid;place-items:center;width:32px;height:32px;border-radius:9px;background:linear-gradient(135deg,#5b8cff,#7c5cff);box-shadow:0 4px 14px #5b8cff40}
|
||||
.brand-mark svg{width:17px;height:17px;stroke:#fff}
|
||||
.brand-name{font-weight:700;font-size:14.5px;letter-spacing:-.01em}
|
||||
.brand-sub{font-size:11px;color:var(--text-3);letter-spacing:.02em}
|
||||
.nav{flex:1;overflow-y:auto;padding:14px 12px}
|
||||
.nav-label{margin:16px 10px 6px;font-size:10.5px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:var(--text-3)}
|
||||
.nav-label:first-child{margin-top:0}
|
||||
.nav-item{display:flex;align-items:center;gap:10px;width:100%;padding:8px 10px;border-radius:8px;color:var(--text-2);font-weight:500;text-align:left;transition:background .12s,color .12s}
|
||||
.nav-item svg{width:16px;height:16px;stroke:currentColor;flex:none}
|
||||
.nav-item:hover{background:var(--panel-2);color:var(--text)}
|
||||
.nav-item.active{background:var(--accent-soft);color:var(--accent);font-weight:600}
|
||||
.nav-item .count{margin-left:auto;font-size:11px;font-weight:600;color:var(--text-3);background:var(--panel-2);border-radius:99px;padding:1px 7px}
|
||||
.nav-item.active .count{color:var(--accent);background:#5b8cff26}
|
||||
.side-foot{padding:14px;border-top:1px solid var(--border-soft)}
|
||||
.user-chip{display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius:9px;background:var(--panel-2)}
|
||||
.avatar{display:grid;place-items:center;width:28px;height:28px;border-radius:8px;background:linear-gradient(135deg,#3fce8a,#2ea56c);color:#08130d;font-weight:800;font-size:12px;flex:none}
|
||||
.user-chip b{display:block;font-size:12.5px;line-height:1.25}
|
||||
.user-chip span{display:block;font-size:11px;color:var(--text-3)}
|
||||
.back-link{display:block;margin-top:9px;padding:7px 10px;color:var(--text-3);font-size:12.5px;text-decoration:none;border-radius:8px}
|
||||
.back-link:hover{color:var(--text);background:var(--panel-2)}
|
||||
|
||||
/* ── main ────────────────────────────────── */
|
||||
.main{flex:1;min-width:0;display:flex;flex-direction:column}
|
||||
.topbar{position:sticky;top:0;z-index:5;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:16px 32px;background:#0b0e13e6;backdrop-filter:blur(10px);border-bottom:1px solid var(--border-soft)}
|
||||
.topbar h1{font-size:17px;font-weight:700;letter-spacing:-.015em}
|
||||
.topbar p{font-size:12.5px;color:var(--text-3);margin-top:1px}
|
||||
.env-badge{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text-2);border:1px solid var(--border);border-radius:99px;padding:5px 12px;background:var(--panel)}
|
||||
.env-badge i{width:7px;height:7px;border-radius:50%;background:var(--green);box-shadow:0 0 8px var(--green)}
|
||||
.content{flex:1;padding:26px 32px 60px;max-width:1120px;width:100%;margin:0 auto}
|
||||
.page{display:none}.page.active{display:block;animation:fade .18s ease}
|
||||
@keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1}}
|
||||
|
||||
/* ── shared components ───────────────────── */
|
||||
.card{background:var(--panel);border:1px solid var(--border-soft);border-radius:13px}
|
||||
.card-pad{padding:20px}
|
||||
.card-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:15px 20px;border-bottom:1px solid var(--border-soft)}
|
||||
.card-head h3{font-size:13.5px;font-weight:650}
|
||||
.card-head span{font-size:12px;color:var(--text-3)}
|
||||
.grid-kpi{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:14px}
|
||||
.kpi{background:var(--panel);border:1px solid var(--border-soft);border-radius:13px;padding:16px 18px}
|
||||
.kpi .k-label{display:flex;align-items:center;gap:7px;font-size:11.5px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text-3)}
|
||||
.kpi .k-label svg{width:14px;height:14px;stroke:currentColor}
|
||||
.kpi .k-value{margin-top:8px;font-size:26px;font-weight:700;letter-spacing:-.03em;line-height:1}
|
||||
.kpi .k-sub{margin-top:6px;font-size:12px;color:var(--text-2)}
|
||||
.kpi .k-sub.up{color:var(--green)}
|
||||
.pill{display:inline-flex;align-items:center;gap:6px;border-radius:99px;padding:3px 10px;font-size:11.5px;font-weight:650;white-space:nowrap}
|
||||
.pill i{width:6px;height:6px;border-radius:50%;background:currentColor}
|
||||
.pill-success{background:var(--green-soft);color:var(--green)}
|
||||
.pill-active{background:var(--accent-soft);color:var(--accent)}
|
||||
.pill-waiting{background:#ffffff12;color:var(--text-2)}
|
||||
.pill-danger{background:var(--red-soft);color:var(--red)}
|
||||
.pill-amber{background:var(--amber-soft);color:var(--amber)}
|
||||
.btn{display:inline-flex;align-items:center;gap:7px;border-radius:8px;padding:8px 14px;font-weight:600;font-size:13px;border:1px solid transparent;transition:filter .12s,background .12s}
|
||||
.btn svg{width:14px;height:14px;stroke:currentColor}
|
||||
.btn-primary{background:var(--accent);color:#0a1222}
|
||||
.btn-primary:hover{background:var(--accent-strong);color:#fff}
|
||||
.btn-ghost{background:var(--panel-2);border-color:var(--border);color:var(--text-2)}
|
||||
.btn-ghost:hover{color:var(--text);border-color:#2a3446}
|
||||
.btn-danger{background:var(--red-soft);color:var(--red)}
|
||||
.btn-danger:hover{background:#f2647c2e}
|
||||
.btn-sm{padding:5px 10px;font-size:12px;border-radius:7px}
|
||||
table{width:100%;border-collapse:collapse}
|
||||
th{padding:10px 20px;text-align:left;font-size:11px;font-weight:650;letter-spacing:.07em;text-transform:uppercase;color:var(--text-3);border-bottom:1px solid var(--border-soft)}
|
||||
td{padding:12px 20px;border-bottom:1px solid var(--border-soft);vertical-align:middle}
|
||||
tr:last-child td{border-bottom:0}
|
||||
tbody tr{transition:background .1s}
|
||||
tbody tr:hover{background:var(--panel-2)}
|
||||
.t-main{font-weight:600;font-size:13.5px}
|
||||
.t-sub{font-size:11.5px;color:var(--text-3);font-family:var(--mono)}
|
||||
.cap{display:inline-block;margin:2px 4px 2px 0;padding:2px 7px;border:1px solid var(--border);border-radius:6px;font:11px var(--mono);color:var(--text-2)}
|
||||
.bar{height:5px;width:130px;border-radius:99px;background:#ffffff10;overflow:hidden}
|
||||
.bar span{display:block;height:100%;border-radius:inherit;background:linear-gradient(90deg,#5b8cff,#3fce8a)}
|
||||
.bar-label{font-size:11.5px;color:var(--text-2);font-family:var(--mono);margin-top:5px}
|
||||
.tabs{display:flex;gap:4px;padding:4px;background:var(--panel);border:1px solid var(--border-soft);border-radius:10px;width:max-content;margin-bottom:14px}
|
||||
.tab{padding:6px 13px;border-radius:7px;font-size:12.5px;font-weight:600;color:var(--text-2)}
|
||||
.tab:hover{color:var(--text)}
|
||||
.tab.active{background:var(--panel-2);color:var(--text);box-shadow:inset 0 0 0 1px var(--border)}
|
||||
.tab .n{color:var(--text-3);font-weight:500;margin-left:5px}
|
||||
.tab.active .n{color:var(--accent)}
|
||||
.section-title{margin:26px 0 12px;font-size:13px;font-weight:700;letter-spacing:-.01em;color:var(--text)}
|
||||
.section-title:first-child{margin-top:0}
|
||||
.section-note{font-size:12px;color:var(--text-3);margin:-8px 0 12px}
|
||||
.kv{display:grid;grid-template-columns:210px 1fr;row-gap:0}
|
||||
.kv dt{padding:11px 20px;font-size:12.5px;color:var(--text-3);border-bottom:1px solid var(--border-soft)}
|
||||
.kv dd{padding:11px 20px;font-size:13px;border-bottom:1px solid var(--border-soft)}
|
||||
.kv dt:last-of-type,.kv dd:last-of-type{border-bottom:0}
|
||||
.stack{display:grid;gap:14px}
|
||||
.split{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
||||
.toggle{position:relative;width:36px;height:20px;border-radius:99px;background:#ffffff17;transition:background .15s;flex:none}
|
||||
.toggle:after{content:"";position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:#8b95a5;transition:transform .15s,background .15s}
|
||||
.toggle.on{background:var(--accent)}
|
||||
.toggle.on:after{transform:translateX(16px);background:#fff}
|
||||
.storage-bar{display:flex;height:10px;border-radius:99px;overflow:hidden;margin:14px 20px 6px}
|
||||
.storage-bar div{height:100%}
|
||||
.legend{display:flex;gap:20px;padding:10px 20px 18px;flex-wrap:wrap}
|
||||
.legend span{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--text-2)}
|
||||
.legend i{width:9px;height:9px;border-radius:3px}
|
||||
.footer-row{display:flex;align-items:center;justify-content:space-between;padding:11px 20px;font-size:12px;color:var(--text-3)}
|
||||
.pager{display:flex;gap:4px}
|
||||
.pager button{width:26px;height:26px;border-radius:7px;font-size:12px;color:var(--text-2)}
|
||||
.pager button.cur{background:var(--accent-soft);color:var(--accent);font-weight:700}
|
||||
.secret{display:flex;align-items:center;gap:10px}
|
||||
.secret code{background:var(--panel-2);border:1px solid var(--border);border-radius:7px;padding:6px 10px;letter-spacing:.08em}
|
||||
.chart{width:100%;height:auto;display:block}
|
||||
.chart-bar{fill:#2c3a52;rx:4}
|
||||
.chart-bar.hot{fill:var(--accent)}
|
||||
.chart-grid{stroke:#ffffff08}
|
||||
.chart-label{font:10px var(--mono);fill:var(--text-3)}
|
||||
.warn-strip{display:flex;gap:10px;align-items:flex-start;background:var(--amber-soft);border:1px solid #e5b64f33;border-radius:10px;padding:12px 14px;font-size:12.5px;color:#eecf8d}
|
||||
.warn-strip svg{width:15px;height:15px;stroke:var(--amber);flex:none;margin-top:1px}
|
||||
@media(max-width:960px){.sidebar{display:none}.grid-kpi,.split{grid-template-columns:1fr 1fr}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<div class="brand-mark"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round"><circle cx="6" cy="6" r="2.4"/><circle cx="18" cy="7" r="2.4"/><circle cx="12" cy="17" r="2.4"/><path d="M8 7.5l7.6-1M7 8.3l3.7 6.5M16.6 9.2l-3.2 5.6"/></svg></div>
|
||||
<div><div class="brand-name">SciMesh</div><div class="brand-sub">Coordinator Admin</div></div>
|
||||
</div>
|
||||
<nav class="nav" id="nav">
|
||||
<div class="nav-label">Operate</div>
|
||||
<button class="nav-item active" data-page="system"><svg viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round"><rect x="3" y="3" width="8" height="8" rx="2"/><rect x="13" y="3" width="8" height="5" rx="2"/><rect x="13" y="10" width="8" height="11" rx="2"/><rect x="3" y="13" width="8" height="8" rx="2"/></svg>System</button>
|
||||
<button class="nav-item" data-page="jobs"><svg viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round"><path d="M4 6h16M4 12h16M4 18h10"/></svg>Jobs<span class="count">26</span></button>
|
||||
<button class="nav-item" data-page="workers"><svg viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/></svg>Workers<span class="count">3</span></button>
|
||||
<div class="nav-label">Access</div>
|
||||
<button class="nav-item" data-page="users"><svg viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round"><circle cx="9" cy="8" r="3.2"/><path d="M3.5 19c.7-3 2.9-4.5 5.5-4.5s4.8 1.5 5.5 4.5"/><circle cx="17" cy="9" r="2.4"/><path d="M15.5 14.6c2.6.2 4.3 1.7 5 4.4"/></svg>Users & keys</button>
|
||||
<div class="nav-label">Platform</div>
|
||||
<button class="nav-item" data-page="workloads"><svg viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round"><path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3z"/><path d="M12 12l8-4.5M12 12v9M12 12L4 7.5"/></svg>Workloads</button>
|
||||
<button class="nav-item" data-page="metrics"><svg viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round"><path d="M4 19V5M4 19h16"/><path d="M8 15v-4M12 15V7M16 15v-6M20 15V9"/></svg>Metrics</button>
|
||||
<button class="nav-item" data-page="settings"><svg viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round"><circle cx="12" cy="12" r="3"/><path d="M19 12a7 7 0 0 0-.1-1.2l2-1.6-2-3.4-2.4 1a7 7 0 0 0-2-1.2L14 3h-4l-.5 2.6a7 7 0 0 0-2 1.2l-2.4-1-2 3.4 2 1.6A7 7 0 0 0 5 12c0 .4 0 .8.1 1.2l-2 1.6 2 3.4 2.4-1a7 7 0 0 0 2 1.2L10 21h4l.5-2.6a7 7 0 0 0 2-1.2l2.4 1 2-3.4-2-1.6c.1-.4.1-.8.1-1.2z"/></svg>Settings</button>
|
||||
</nav>
|
||||
<div class="side-foot">
|
||||
<div class="user-chip"><div class="avatar">A</div><div><b>admin@scimesh.local</b><span>admin · serve bootstrap</span></div></div>
|
||||
<a class="back-link" href="#">← Back to control room</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="main">
|
||||
<header class="topbar">
|
||||
<div><h1 id="page-title">System</h1><p id="page-sub">Cluster state and node information</p></div>
|
||||
<div class="env-badge"><i></i>serve mode · sqlite · 127.0.0.1:8080</div>
|
||||
</header>
|
||||
<div class="content">
|
||||
|
||||
<!-- ═══ SYSTEM ═══ -->
|
||||
<section class="page active" id="page-system">
|
||||
<div class="grid-kpi">
|
||||
<div class="kpi"><div class="k-label"><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3z"/></svg>Version</div><div class="k-value">1.1.0</div><div class="k-sub">alpha.1 · linux/amd64</div></div>
|
||||
<div class="kpi"><div class="k-label"><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>Uptime</div><div class="k-value">3d 14h</div><div class="k-sub">since Jul 30, 09:12</div></div>
|
||||
<div class="kpi"><div class="k-label"><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><path d="M4 6h16M4 12h16M4 18h10"/></svg>Active jobs</div><div class="k-value">2</div><div class="k-sub up">1 running · 1 waiting</div></div>
|
||||
<div class="kpi"><div class="k-label"><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8"/></svg>Workers online</div><div class="k-value">3<span style="font-size:15px;color:var(--text-3);font-weight:500"> / 5</span></div><div class="k-sub">2 busy · 1 idle</div></div>
|
||||
</div>
|
||||
<div class="split">
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Storage usage</h3><span>2.1 GB total</span></div>
|
||||
<div class="storage-bar"><div style="width:38%;background:#5b8cff"></div><div style="width:57%;background:#7c5cff"></div><div style="width:5%;background:#3fce8a"></div></div>
|
||||
<div class="legend">
|
||||
<span><i style="background:#5b8cff"></i>Datasets · 812 MB</span>
|
||||
<span><i style="background:#7c5cff"></i>Artifacts · 1.2 GB</span>
|
||||
<span><i style="background:#3fce8a"></i>Database · 34 MB</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Health</h3><span>all checks pass</span></div>
|
||||
<dl class="kv">
|
||||
<dt>Database</dt><dd><span class="pill pill-success"><i></i>Connected</span> <span style="color:var(--text-3);font-size:12px">sqlite · wal</span></dd>
|
||||
<dt>Userservice</dt><dd><span class="pill pill-success"><i></i>Embedded</span> <span style="color:var(--text-3);font-size:12px">127.0.0.1:41273</span></dd>
|
||||
<dt>Reducer</dt><dd><span class="pill pill-waiting"><i></i>Idle</span> <span style="color:var(--text-3);font-size:12px">last merge 12 min ago</span></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-title">Node information</div>
|
||||
<div class="card">
|
||||
<dl class="kv">
|
||||
<dt>Binary</dt><dd><code>/usr/local/bin/coordinator</code></dd>
|
||||
<dt>Listen address</dt><dd><code>127.0.0.1:8080</code></dd>
|
||||
<dt>Data directory</dt><dd><code>~/.scimesh</code> <span style="color:var(--text-3)">(jwt.secret · worker.token · venv)</span></dd>
|
||||
<dt>Database engine</dt><dd>sqlite · <code>~/.scimesh/scimesh.db</code></dd>
|
||||
<dt>Public URL</dt><dd><code>http://192.168.1.10:8080</code></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══ JOBS ═══ -->
|
||||
<section class="page" id="page-jobs">
|
||||
<div class="tabs">
|
||||
<button class="tab active">All<span class="n">26</span></button>
|
||||
<button class="tab">Running<span class="n">1</span></button>
|
||||
<button class="tab">Waiting<span class="n">1</span></button>
|
||||
<button class="tab">Completed<span class="n">21</span></button>
|
||||
<button class="tab">Failed<span class="n">2</span></button>
|
||||
<button class="tab">Cancelled<span class="n">1</span></button>
|
||||
</div>
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead><tr><th>Job</th><th>Workload</th><th>Owner</th><th>Status</th><th>Progress</th><th>Submitted</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><div class="t-main">kinase-inhibitors-topk</div><div class="t-sub">7f3a…c91e</div></td><td><code style="color:var(--text-2)">similarity-search</code></td><td style="color:var(--text-2)">alice@lab.org</td><td><span class="pill pill-active"><i></i>Running</span></td><td><div class="bar"><span style="width:62%"></span></div><div class="bar-label">124 / 200 shards</div></td><td style="color:var(--text-2);font-size:12.5px">12 min ago</td><td><button class="btn btn-ghost btn-sm">Open</button></td></tr>
|
||||
<tr><td><div class="t-main">chembl-2d-descriptors</div><div class="t-sub">a102…77bd</div></td><td><code style="color:var(--text-2)">descriptors</code></td><td style="color:var(--text-2)">bob@lab.org</td><td><span class="pill pill-waiting"><i></i>Waiting</span></td><td><div class="bar"><span style="width:0%"></span></div><div class="bar-label">0 / 48 shards</div></td><td style="color:var(--text-2);font-size:12.5px">4 min ago</td><td><button class="btn btn-ghost btn-sm">Open</button></td></tr>
|
||||
<tr><td><div class="t-main">fragment-library-graph</div><div class="t-sub">55e0…0a3f</div></td><td><code style="color:var(--text-2)">similarity-graph</code></td><td style="color:var(--text-2)">alice@lab.org</td><td><span class="pill pill-success"><i></i>Completed</span></td><td><div class="bar"><span style="width:100%"></span></div><div class="bar-label">96 / 96 shards · 3m 41s</div></td><td style="color:var(--text-2);font-size:12.5px">1 h ago</td><td><button class="btn btn-ghost btn-sm">Open</button></td></tr>
|
||||
<tr><td><div class="t-main">leadopt-molwt-450</div><div class="t-sub">9b41…e2c8</div></td><td><code style="color:var(--text-2)">molwt-filter</code></td><td style="color:var(--text-2)">admin@scimesh.local</td><td><span class="pill pill-success"><i></i>Completed</span></td><td><div class="bar"><span style="width:100%"></span></div><div class="bar-label">12 / 12 shards · 24s</div></td><td style="color:var(--text-2);font-size:12.5px">3 h ago</td><td><button class="btn btn-ghost btn-sm">Open</button></td></tr>
|
||||
<tr><td><div class="t-main">hts-plate-14-search</div><div class="t-sub">c7d9…4b60</div></td><td><code style="color:var(--text-2)">similarity-search</code></td><td style="color:var(--text-2)">bob@lab.org</td><td><span class="pill pill-danger"><i></i>Failed</span></td><td><div class="bar"><span style="width:34%;background:linear-gradient(90deg,#5b8cff,#f2647c)"></span></div><div class="bar-label">18 / 53 shards · 2 failed</div></td><td style="color:var(--text-2);font-size:12.5px">yesterday</td><td><button class="btn btn-ghost btn-sm">Open</button></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="footer-row"><span>1–5 of 26 jobs</span><div class="pager"><button>‹</button><button class="cur">1</button><button>2</button><button>3</button><button>›</button></div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══ WORKERS ═══ -->
|
||||
<section class="page" id="page-workers">
|
||||
<div class="section-note">Workers register themselves. Trust controls whether a machine may claim tasks from this cluster.</div>
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead><tr><th>Worker</th><th>Status</th><th>Capabilities</th><th>Trust</th><th>Completed</th><th>Last signal</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><div class="t-main">lab-node-01</div><div class="t-sub">w-01HZZ…a4</div></td><td><span class="pill pill-active"><i></i>Busy</span></td><td><span class="cap">similarity-search</span><span class="cap">similarity-graph</span></td><td><select><option selected>Trusted</option><option>Untrusted</option></select></td><td style="font-family:var(--mono);font-size:12.5px">341</td><td style="color:var(--text-2);font-size:12.5px">4 s ago</td></tr>
|
||||
<tr><td><div class="t-main">emil-laptop</div><div class="t-sub">w-01HZZ…9c</div></td><td><span class="pill pill-success"><i></i>Idle</span></td><td><span class="cap">similarity-search</span><span class="cap">descriptors</span><span class="cap">molwt-filter</span></td><td><select><option selected>Trusted</option><option>Untrusted</option></select></td><td style="font-family:var(--mono);font-size:12.5px">87</td><td style="color:var(--text-2);font-size:12.5px">2 s ago</td></tr>
|
||||
<tr><td><div class="t-main">lab-node-02</div><div class="t-sub">w-01HZZ…d1</div></td><td><span class="pill pill-active"><i></i>Busy</span></td><td><span class="cap">similarity-search</span></td><td><select><option>Trusted</option><option selected>Untrusted</option></select></td><td style="font-family:var(--mono);font-size:12.5px">12</td><td style="color:var(--text-2);font-size:12.5px">6 s ago</td></tr>
|
||||
<tr><td><div class="t-main">old-workstation</div><div class="t-sub">w-01HZX…7f</div></td><td><span class="pill pill-waiting"><i></i>Offline</span></td><td><span class="cap">similarity-search</span></td><td><select><option selected>Trusted</option><option>Untrusted</option></select></td><td style="font-family:var(--mono);font-size:12.5px">1 204</td><td style="color:var(--text-2);font-size:12.5px">2 days ago</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══ USERS & KEYS ═══ -->
|
||||
<section class="page" id="page-users">
|
||||
<div class="section-title">Users</div>
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead><tr><th>Email</th><th>Role</th><th>Jobs</th><th>Created</th><th>Last active</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><div class="t-main">admin@scimesh.local</div></td><td><span class="pill pill-amber"><i></i>admin</span></td><td style="font-family:var(--mono);font-size:12.5px">9</td><td style="color:var(--text-2);font-size:12.5px">Jul 30</td><td style="color:var(--text-2);font-size:12.5px">now</td><td><button class="btn btn-ghost btn-sm">Edit</button></td></tr>
|
||||
<tr><td><div class="t-main">alice@lab.org</div></td><td><select><option selected>user</option><option>admin</option></select></td><td style="font-family:var(--mono);font-size:12.5px">11</td><td style="color:var(--text-2);font-size:12.5px">Jul 30</td><td style="color:var(--text-2);font-size:12.5px">12 min ago</td><td><button class="btn btn-ghost btn-sm">Edit</button></td></tr>
|
||||
<tr><td><div class="t-main">bob@lab.org</div></td><td><select><option selected>user</option><option>admin</option></select></td><td style="font-family:var(--mono);font-size:12.5px">6</td><td style="color:var(--text-2);font-size:12.5px">Jul 31</td><td style="color:var(--text-2);font-size:12.5px">1 h ago</td><td><button class="btn btn-ghost btn-sm">Edit</button></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="footer-row"><span>3 users · bootstrap admin cannot be demoted</span><button class="btn btn-ghost btn-sm"><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>Invite user</button></div>
|
||||
</div>
|
||||
<div class="section-title">Worker keys</div>
|
||||
<div class="section-note">Keys let lab machines register as workers under a user account. Served instances can also use the cluster token.</div>
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead><tr><th>Label</th><th>Key prefix</th><th>Owner</th><th>Created</th><th>Last used</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="t-main">lab-node-01</td><td><code style="color:var(--text-2)">smk_7f3a…</code></td><td style="color:var(--text-2)">alice@lab.org</td><td style="color:var(--text-2);font-size:12.5px">Jul 30</td><td style="color:var(--text-2);font-size:12.5px">4 s ago</td><td><button class="btn btn-danger btn-sm">Revoke</button></td></tr>
|
||||
<tr><td class="t-main">emil-laptop</td><td><code style="color:var(--text-2)">smk_b291…</code></td><td style="color:var(--text-2)">admin@scimesh.local</td><td style="color:var(--text-2);font-size:12.5px">Jul 31</td><td style="color:var(--text-2);font-size:12.5px">2 s ago</td><td><button class="btn btn-danger btn-sm">Revoke</button></td></tr>
|
||||
<tr><td class="t-main" style="color:var(--text-3)">old-workstation</td><td><code style="color:var(--text-3)">smk_44cd…</code></td><td style="color:var(--text-3)">alice@lab.org</td><td style="color:var(--text-3);font-size:12.5px">Jul 30</td><td style="color:var(--text-3);font-size:12.5px">2 days ago</td><td><span class="pill pill-danger"><i></i>Revoked</span></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="footer-row"><span>2 active keys</span><button class="btn btn-primary btn-sm"><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>Issue key</button></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══ WORKLOADS ═══ -->
|
||||
<section class="page" id="page-workloads">
|
||||
<div class="section-note">Disabled workloads are rejected at submit time and hidden from the job form. The catalog is compiled into the binary; settings persist in the database.</div>
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead><tr><th>Workload</th><th>Reduction</th><th>Parameters</th><th>Dataset upload</th><th>Enabled</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><div class="t-main">similarity-search</div><div class="t-sub">top-k Tanimoto search over a TSV of SMILES</div></td><td><span class="pill pill-active"><i></i>top-k</span></td><td style="color:var(--text-2);font-size:12.5px">4 declared</td><td><span class="pill pill-success"><i></i>ready</span></td><td><button class="toggle on" aria-label="enabled"></button></td></tr>
|
||||
<tr><td><div class="t-main">similarity-graph</div><div class="t-sub">threshold graph edges between all pairs</div></td><td><span class="pill pill-active"><i></i>ordered-concat</span></td><td style="color:var(--text-2);font-size:12.5px">2 declared</td><td><span class="pill pill-success"><i></i>ready</span></td><td><button class="toggle on" aria-label="enabled"></button></td></tr>
|
||||
<tr><td><div class="t-main">descriptors</div><div class="t-sub">2D RDKit descriptors per molecule</div></td><td><span class="pill pill-active"><i></i>ordered-concat</span></td><td style="color:var(--text-2);font-size:12.5px">1 declared</td><td><span class="pill pill-success"><i></i>ready</span></td><td><button class="toggle" aria-label="disabled"></button></td></tr>
|
||||
<tr><td><div class="t-main">molwt-filter</div><div class="t-sub">keep molecules inside a weight window</div></td><td><span class="pill pill-active"><i></i>ordered-concat</span></td><td style="color:var(--text-2);font-size:12.5px">2 declared</td><td><span class="pill pill-success"><i></i>ready</span></td><td><button class="toggle on" aria-label="enabled"></button></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══ METRICS ═══ -->
|
||||
<section class="page" id="page-metrics">
|
||||
<div class="grid-kpi">
|
||||
<div class="kpi"><div class="k-label">Jobs · 7 days</div><div class="k-value">26</div><div class="k-sub up">+8 vs previous week</div></div>
|
||||
<div class="kpi"><div class="k-label">Shards completed</div><div class="k-value">1 644</div><div class="k-sub">across 5 workers</div></div>
|
||||
<div class="kpi"><div class="k-label">Avg shard time</div><div class="k-value">2.3s</div><div class="k-sub up">−0.4s this week</div></div>
|
||||
<div class="kpi"><div class="k-label">Failure rate</div><div class="k-value" style="color:var(--amber)">1.2%</div><div class="k-sub">20 failed shards</div></div>
|
||||
</div>
|
||||
<div class="split">
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Jobs per day</h3><span>last 7 days</span></div>
|
||||
<div style="padding:16px 20px 10px">
|
||||
<svg class="chart" viewBox="0 0 460 150">
|
||||
<line class="chart-grid" x1="0" y1="30" x2="460" y2="30"/><line class="chart-grid" x1="0" y1="70" x2="460" y2="70"/><line class="chart-grid" x1="0" y1="110" x2="460" y2="110"/>
|
||||
<rect class="chart-bar" x="18" y="70" width="44" height="60"/><rect class="chart-bar" x="82" y="50" width="44" height="80"/>
|
||||
<rect class="chart-bar" x="146" y="90" width="44" height="40"/><rect class="chart-bar" x="210" y="40" width="44" height="90"/>
|
||||
<rect class="chart-bar hot" x="274" y="20" width="44" height="110"/><rect class="chart-bar" x="338" y="60" width="44" height="70"/>
|
||||
<rect class="chart-bar" x="402" y="80" width="44" height="50"/>
|
||||
<text class="chart-label" x="18" y="145">Mon</text><text class="chart-label" x="82" y="145">Tue</text><text class="chart-label" x="146" y="145">Wed</text>
|
||||
<text class="chart-label" x="210" y="145">Thu</text><text class="chart-label" x="274" y="145">Fri</text><text class="chart-label" x="338" y="145">Sat</text>
|
||||
<text class="chart-label" x="402" y="145">Sun</text>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Jobs by workload</h3><span>all time</span></div>
|
||||
<dl class="kv">
|
||||
<dt>similarity-search</dt><dd><div class="bar" style="width:100%"><span style="width:78%"></span></div></dd>
|
||||
<dt>similarity-graph</dt><dd><div class="bar" style="width:100%"><span style="width:41%"></span></div></dd>
|
||||
<dt>molwt-filter</dt><dd><div class="bar" style="width:100%"><span style="width:22%"></span></div></dd>
|
||||
<dt>descriptors</dt><dd><div class="bar" style="width:100%"><span style="width:9%"></span></div></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══ SETTINGS ═══ -->
|
||||
<section class="page" id="page-settings">
|
||||
<div class="warn-strip"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round"><path d="M12 9v4M12 17h.01M10.3 3.9L2.6 17a2 2 0 0 0 1.7 3h15.4a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/></svg><div>The cluster token below authenticates <b>any</b> worker. Reveal it only on a trusted machine, and rotate it if it may have leaked.</div></div>
|
||||
<div class="section-title">Cluster</div>
|
||||
<div class="card">
|
||||
<dl class="kv">
|
||||
<dt>Worker token</dt><dd><div class="secret"><code id="tok">••••••••••••••••••••••••</code><button class="btn btn-ghost btn-sm" id="reveal">Reveal</button><button class="btn btn-ghost btn-sm">Rotate…</button></div></dd>
|
||||
<dt>Public URL</dt><dd><input value="http://192.168.1.10:8080" style="width:320px"></dd>
|
||||
<dt>Listen address</dt><dd><input value="127.0.0.1:8080" style="width:200px"> <span style="color:var(--text-3);font-size:12px">takes effect after restart</span></dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="section-title">Storage</div>
|
||||
<div class="card">
|
||||
<dl class="kv">
|
||||
<dt>Data directory</dt><dd><code>~/.scimesh</code></dd>
|
||||
<dt>Database engine</dt><dd><select><option selected>sqlite (embedded)</option><option>postgres (external)</option></select></dd>
|
||||
<dt>Auto-migrate on start</dt><dd><button class="toggle on"></button></dd>
|
||||
</dl>
|
||||
<div class="footer-row"><span></span><button class="btn btn-primary btn-sm">Save changes</button></div>
|
||||
</div>
|
||||
<div class="section-title" style="color:var(--red)">Danger zone</div>
|
||||
<div class="card" style="border-color:#f2647c33">
|
||||
<dl class="kv">
|
||||
<dt>Prune artifacts</dt><dd style="display:flex;justify-content:space-between;align-items:center"><span style="color:var(--text-2);font-size:12.5px">Delete results of completed jobs older than 30 days · frees ~400 MB</span><button class="btn btn-ghost btn-sm">Prune…</button></dd>
|
||||
<dt>Reset cluster</dt><dd style="display:flex;justify-content:space-between;align-items:center"><span style="color:var(--text-2);font-size:12.5px">Drop all jobs, workers and keys. Users are kept.</span><button class="btn btn-danger btn-sm">Reset…</button></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const titles={system:['System','Cluster state and node information'],jobs:['Jobs','Every computation, filterable and paginated'],workers:['Workers','Fleet overview and trust management'],users:['Users & keys','Accounts, roles and worker keys'],workloads:['Workloads','Catalog entries and availability'],metrics:['Metrics','Throughput and reliability, last 7 days'],settings:['Settings','Cluster, storage and security']};
|
||||
document.querySelectorAll('.nav-item').forEach(btn=>btn.addEventListener('click',()=>{
|
||||
document.querySelectorAll('.nav-item').forEach(b=>b.classList.remove('active'));
|
||||
document.querySelectorAll('.page').forEach(p=>p.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
document.getElementById('page-'+btn.dataset.page).classList.add('active');
|
||||
const [t,s]=titles[btn.dataset.page];
|
||||
document.getElementById('page-title').textContent=t;
|
||||
document.getElementById('page-sub').textContent=s;
|
||||
}));
|
||||
document.querySelectorAll('.toggle').forEach(t=>t.addEventListener('click',()=>t.classList.toggle('on')));
|
||||
document.getElementById('reveal').addEventListener('click',e=>{
|
||||
const tok=document.getElementById('tok');
|
||||
const shown=tok.textContent.startsWith('•');
|
||||
tok.textContent=shown?'sm_live_9f2c7ab41d06e8f3c5a7':'••••••••••••••••••••••••';
|
||||
e.target.textContent=shown?'Hide':'Reveal';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,244 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SciMesh Worker · Setup</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0b0e13;--panel:#11151d;--panel-2:#161c27;--border:#1f2634;--border-soft:#181f2b;
|
||||
--text:#e9ecf3;--text-2:#98a2b5;--text-3:#5d6879;
|
||||
--accent:#5b8cff;--accent-soft:#5b8cff1f;--accent-strong:#3f6fe0;
|
||||
--green:#3fce8a;--green-soft:#3fce8a1a;--amber:#e5b64f;--amber-soft:#e5b64f1a;--red:#f2647c;--red-soft:#f2647c1a;
|
||||
--mono:ui-monospace,"SF Mono",SFMono-Regular,Menlo,monospace;
|
||||
color-scheme:dark;
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:radial-gradient(900px 500px at 50% -180px,#16233d66,transparent),var(--bg);color:var(--text);font:14px/1.55 Inter,-apple-system,"Segoe UI",Roboto,sans-serif;-webkit-font-smoothing:antialiased;min-height:100vh}
|
||||
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
|
||||
input{font:inherit;color:var(--text);background:var(--panel-2);border:1px solid var(--border);border-radius:9px;padding:10px 13px;width:100%;outline:none;transition:border-color .12s,box-shadow .12s}
|
||||
input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
|
||||
input::placeholder{color:var(--text-3)}
|
||||
code{font-family:var(--mono);font-size:.86em}
|
||||
|
||||
.shell{max-width:660px;margin:0 auto;padding:44px 22px 70px}
|
||||
.brand{display:flex;align-items:center;justify-content:center;gap:11px;margin-bottom:8px}
|
||||
.brand-mark{display:grid;place-items:center;width:34px;height:34px;border-radius:10px;background:linear-gradient(135deg,#5b8cff,#7c5cff);box-shadow:0 4px 16px #5b8cff40}
|
||||
.brand-mark svg{width:18px;height:18px;stroke:#fff}
|
||||
.brand-name{font-weight:700;font-size:16px;letter-spacing:-.01em}
|
||||
.brand-name span{color:var(--text-3);font-weight:500}
|
||||
.tagline{text-align:center;color:var(--text-3);font-size:12.5px;margin-bottom:34px}
|
||||
.tagline code{color:var(--text-2)}
|
||||
|
||||
/* step indicator */
|
||||
.steps{display:flex;align-items:center;justify-content:center;gap:0;margin-bottom:30px}
|
||||
.step{display:flex;flex-direction:column;align-items:center;gap:7px;width:96px}
|
||||
.step-dot{display:grid;place-items:center;width:30px;height:30px;border-radius:50%;border:1.5px solid var(--border);background:var(--panel);color:var(--text-3);font-size:12.5px;font-weight:700;transition:all .2s}
|
||||
.step-label{font-size:11px;font-weight:600;color:var(--text-3);letter-spacing:.02em}
|
||||
.step.active .step-dot{border-color:var(--accent);background:var(--accent-soft);color:var(--accent);box-shadow:0 0 0 4px #5b8cff14}
|
||||
.step.active .step-label{color:var(--text)}
|
||||
.step.done .step-dot{border-color:var(--green);background:var(--green-soft);color:var(--green)}
|
||||
.step.done .step-label{color:var(--text-2)}
|
||||
.step-line{flex:1;max-width:44px;height:1.5px;background:var(--border);margin:0 6px 22px;position:relative;overflow:hidden}
|
||||
.step-line.done:after{content:"";position:absolute;inset:0;background:var(--green)}
|
||||
|
||||
.card{background:var(--panel);border:1px solid var(--border-soft);border-radius:15px;padding:26px 28px;box-shadow:0 24px 60px #0000004d}
|
||||
.card h1{font-size:18px;font-weight:700;letter-spacing:-.02em;margin-bottom:4px}
|
||||
.card .sub{color:var(--text-2);font-size:13px;margin-bottom:22px}
|
||||
.field{margin-bottom:16px}
|
||||
.field label{display:block;font-size:12px;font-weight:650;letter-spacing:.04em;text-transform:uppercase;color:var(--text-3);margin-bottom:7px}
|
||||
.field .hint{margin-top:6px;font-size:12px;color:var(--text-3)}
|
||||
.field .hint code{color:var(--text-2)}
|
||||
|
||||
.radio-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
||||
.radio-card{border:1px solid var(--border);border-radius:11px;padding:13px 14px;cursor:pointer;transition:all .13s;background:var(--panel-2)}
|
||||
.radio-card:hover{border-color:#2a3446}
|
||||
.radio-card.sel{border-color:var(--accent);background:var(--accent-soft);box-shadow:0 0 0 3px #5b8cff14}
|
||||
.radio-card b{display:flex;align-items:center;gap:8px;font-size:13.5px}
|
||||
.radio-card b svg{width:15px;height:15px;stroke:var(--accent)}
|
||||
.radio-card p{margin-top:4px;font-size:12px;color:var(--text-2)}
|
||||
|
||||
.check-row{display:flex;align-items:center;gap:12px;padding:11px 14px;border:1px solid var(--border-soft);border-radius:10px;margin-bottom:9px;background:var(--panel-2)}
|
||||
.check-ic{display:grid;place-items:center;width:24px;height:24px;border-radius:50%;flex:none}
|
||||
.check-ic svg{width:13px;height:13px;stroke-width:2.6}
|
||||
.check-ok{background:var(--green-soft)}.check-ok svg{stroke:var(--green)}
|
||||
.check-bad{background:var(--red-soft)}.check-bad svg{stroke:var(--red)}
|
||||
.check-wait{background:#ffffff10}.check-wait svg{stroke:var(--text-3)}
|
||||
.check-row b{font-size:13.5px;font-weight:600}
|
||||
.check-row span{display:block;font-size:12px;color:var(--text-3)}
|
||||
.check-row .ms{margin-left:auto;font:11.5px var(--mono);color:var(--text-3)}
|
||||
|
||||
.actions{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:24px}
|
||||
.btn{display:inline-flex;align-items:center;gap:8px;border-radius:9px;padding:10px 18px;font-weight:650;font-size:13.5px;border:1px solid transparent;transition:all .13s}
|
||||
.btn svg{width:15px;height:15px;stroke:currentColor}
|
||||
.btn-primary{background:var(--accent);color:#0a1222}
|
||||
.btn-primary:hover{background:var(--accent-strong);color:#fff}
|
||||
.btn-ghost{border-color:var(--border);color:var(--text-2);background:var(--panel-2)}
|
||||
.btn-ghost:hover{color:var(--text);border-color:#2a3446}
|
||||
.btn-danger{background:var(--red-soft);color:var(--red)}
|
||||
.btn-lg{padding:12px 24px;font-size:14.5px;border-radius:10px}
|
||||
.link{color:var(--text-3);font-size:13px}
|
||||
.link:hover{color:var(--text)}
|
||||
|
||||
/* status view */
|
||||
.status-head{display:flex;align-items:center;gap:14px;margin-bottom:22px}
|
||||
.pulse{position:relative;width:12px;height:12px;border-radius:50%;background:var(--green);flex:none}
|
||||
.pulse:after{content:"";position:absolute;inset:-5px;border-radius:50%;border:2px solid var(--green);opacity:.5;animation:ping 1.6s ease-out infinite}
|
||||
@keyframes ping{from{transform:scale(.6);opacity:.7}to{transform:scale(1.4);opacity:0}}
|
||||
.status-head h1{font-size:19px}
|
||||
.status-head .sub{font-size:12.5px;color:var(--text-3)}
|
||||
.stat-row{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:18px}
|
||||
.stat{background:var(--panel-2);border:1px solid var(--border-soft);border-radius:11px;padding:12px 14px}
|
||||
.stat b{display:block;font-size:20px;font-weight:700;letter-spacing:-.02em}
|
||||
.stat span{font-size:11px;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:var(--text-3)}
|
||||
.stat.bad b{color:var(--red)}
|
||||
.logbox{background:#0a0d12;border:1px solid var(--border-soft);border-radius:11px;padding:14px 16px;font:12px/1.7 var(--mono);color:#8fa3bf;max-height:190px;overflow-y:auto}
|
||||
.logbox .t{color:var(--text-3)}
|
||||
.logbox .ok{color:var(--green)}
|
||||
.logbox .hl{color:#c9d6ea}
|
||||
.meta-line{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}
|
||||
.chip{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--border);border-radius:99px;padding:4px 11px;font-size:12px;color:var(--text-2);background:var(--panel-2)}
|
||||
.chip svg{width:12px;height:12px;stroke:var(--text-3)}
|
||||
|
||||
.wizard-page{display:none}.wizard-page.active{display:block;animation:fade .18s ease}
|
||||
@keyframes fade{from{opacity:0;transform:translateY(5px)}to{opacity:1}}
|
||||
.demo-toggle{position:fixed;bottom:14px;right:16px;font-size:11.5px;color:var(--text-3);background:var(--panel);border:1px solid var(--border);border-radius:99px;padding:6px 13px;opacity:.8}
|
||||
.demo-toggle:hover{color:var(--text)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<div class="brand">
|
||||
<div class="brand-mark"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round"><circle cx="6" cy="6" r="2.4"/><circle cx="18" cy="7" r="2.4"/><circle cx="12" cy="17" r="2.4"/><path d="M8 7.5l7.6-1M7 8.3l3.7 6.5M16.6 9.2l-3.2 5.6"/></svg></div>
|
||||
<div class="brand-name">SciMesh <span>· Worker setup</span></div>
|
||||
</div>
|
||||
<p class="tagline">Local wizard served by <code>worker-agent setup</code> · <code>127.0.0.1:12700</code></p>
|
||||
|
||||
<!-- ═══ WIZARD VIEW ═══ -->
|
||||
<div id="view-wizard">
|
||||
<div class="steps">
|
||||
<div class="step done" id="st1"><div class="step-dot">✓</div><div class="step-label">Connect</div></div>
|
||||
<div class="step-line done" id="sl1"></div>
|
||||
<div class="step active" id="st2"><div class="step-dot">2</div><div class="step-label">Machine</div></div>
|
||||
<div class="step-line" id="sl2"></div>
|
||||
<div class="step" id="st3"><div class="step-dot">3</div><div class="step-label">Check</div></div>
|
||||
<div class="step-line" id="sl3"></div>
|
||||
<div class="step" id="st4"><div class="step-dot">4</div><div class="step-label">Run</div></div>
|
||||
</div>
|
||||
|
||||
<!-- step 1 (kept in DOM for the mockup; step 2 shown) -->
|
||||
<div class="wizard-page" id="wp1">
|
||||
<div class="card">
|
||||
<h1>Connect to a coordinator</h1>
|
||||
<p class="sub">The coordinator hands out work and collects results. Ask your cluster admin for its address.</p>
|
||||
<div class="field"><label>Coordinator URL</label><input value="http://192.168.1.10:8080" placeholder="http://192.168.1.10:8080"><p class="hint">For a served instance this is the address printed by <code>coordinator serve</code>.</p></div>
|
||||
<div class="field"><label>Authentication</label>
|
||||
<div class="radio-grid">
|
||||
<div class="radio-card sel"><b><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>Cluster token</b><p>Serve instances: one token for every worker.</p></div>
|
||||
<div class="radio-card"><b><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><circle cx="8" cy="14" r="4"/><path d="M10.8 11.2L20 2M15 4l3 3"/></svg>Worker key</b><p>Shared clusters: a key tied to your account.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field"><label>Token</label><input type="password" value="sm_live_9f2c7ab41d06e8f3c5a7"><p class="hint">The admin can copy it from <code>coordinator token</code> on the server.</p></div>
|
||||
<div class="actions"><span class="link">Step 1 of 4</span><button class="btn btn-primary" onclick="goto(2)">Continue →</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- step 2 -->
|
||||
<div class="wizard-page active" id="wp2">
|
||||
<div class="card">
|
||||
<h1>This machine</h1>
|
||||
<p class="sub">Where tasks run and how the machine appears in the cluster.</p>
|
||||
<div class="field"><label>Worker name</label><input value="emil-laptop"><p class="hint">Shown in the coordinator’s worker list.</p></div>
|
||||
<div class="field"><label>Work directory</label><input value="/home/emil/scimesh-worker"><p class="hint">Datasets and shard results live here. ~1 GB free space recommended.</p></div>
|
||||
<div class="field"><label>Compute resources advertised</label>
|
||||
<div class="radio-grid">
|
||||
<div class="radio-card sel"><b>8 CPUs · 16 GB</b><p>Detected automatically.</p></div>
|
||||
<div class="radio-card"><b>Custom…</b><p>Limit what this machine advertises.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions"><button class="btn btn-ghost" onclick="goto(1)">← Back</button><button class="btn btn-primary" onclick="goto(3)">Continue →</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- step 3 -->
|
||||
<div class="wizard-page" id="wp3">
|
||||
<div class="card">
|
||||
<h1>Preflight check</h1>
|
||||
<p class="sub">Making sure this machine can reach the coordinator and run SciMesh workloads.</p>
|
||||
<div class="check-row"><div class="check-ic check-ok"><svg viewBox="0 0 24 24" fill="none" stroke-linecap="round"><path d="M4 12l5 5L20 6"/></svg></div><div><b>Coordinator reachable</b><span>http://192.168.1.10:8080 · coordinator 1.1.0-alpha.1</span></div><span class="ms">34 ms</span></div>
|
||||
<div class="check-row"><div class="check-ic check-ok"><svg viewBox="0 0 24 24" fill="none" stroke-linecap="round"><path d="M4 12l5 5L20 6"/></svg></div><div><b>Token accepted</b><span>authenticated as cluster worker</span></div><span class="ms">51 ms</span></div>
|
||||
<div class="check-row"><div class="check-ic check-ok"><svg viewBox="0 0 24 24" fill="none" stroke-linecap="round"><path d="M4 12l5 5L20 6"/></svg></div><div><b>Python 3.12 found</b><span>/usr/bin/python3</span></div><span class="ms">8 ms</span></div>
|
||||
<div class="check-row"><div class="check-ic check-bad"><svg viewBox="0 0 24 24" fill="none" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg></div><div><b>scimesh package missing</b><span>run: pip install scimesh — or let the wizard install it</span></div><button class="btn btn-ghost" style="margin-left:auto;padding:6px 12px;font-size:12px">Install</button></div>
|
||||
<div class="actions"><button class="btn btn-ghost" onclick="goto(2)">← Back</button><button class="btn btn-primary" onclick="goto(4)">Continue anyway →</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- step 4 -->
|
||||
<div class="wizard-page" id="wp4">
|
||||
<div class="card" style="text-align:center;padding:40px 28px">
|
||||
<div class="brand-mark" style="margin:0 auto 18px;width:46px;height:46px;border-radius:13px"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" style="width:22px;height:22px"><path d="M6 4l14 8-14 8V4z"/></svg></div>
|
||||
<h1 style="font-size:20px">Ready to join the cluster</h1>
|
||||
<p class="sub" style="max-width:380px;margin:8px auto 26px">The wizard will save your configuration to <code>~/.scimesh-worker/config.json</code> and start the worker as a background process.</p>
|
||||
<div class="meta-line" style="justify-content:center">
|
||||
<span class="chip"><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><circle cx="12" cy="12" r="9"/></svg>emil-laptop</span>
|
||||
<span class="chip"><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><path d="M4 6h16M4 12h16M4 18h16"/></svg>8 CPUs · 16 GB</span>
|
||||
<span class="chip"><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3z"/></svg>4 workloads</span>
|
||||
</div>
|
||||
<div class="actions" style="justify-content:space-between;margin-top:30px"><button class="btn btn-ghost" onclick="goto(3)">← Back</button><button class="btn btn-primary btn-lg" onclick="showStatus()">Start worker</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ STATUS VIEW ═══ -->
|
||||
<div id="view-status" style="display:none">
|
||||
<div class="card">
|
||||
<div class="status-head">
|
||||
<div class="pulse"></div>
|
||||
<div><h1>emil-laptop is working</h1><div class="sub">worker id <code>w-01HZZ4T9C6</code> · registered with 192.168.1.10:8080 · up 42 min</div></div>
|
||||
<button class="btn btn-danger" style="margin-left:auto"><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>Stop</button>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<div class="stat"><b>14</b><span>claimed</span></div>
|
||||
<div class="stat"><b>12</b><span>completed</span></div>
|
||||
<div class="stat bad"><b>0</b><span>failed</span></div>
|
||||
<div class="stat"><b>2.1s</b><span>avg shard</span></div>
|
||||
</div>
|
||||
<div class="meta-line">
|
||||
<span class="chip">similarity-search</span><span class="chip">similarity-graph</span><span class="chip">descriptors</span><span class="chip">molwt-filter</span>
|
||||
<span class="chip" style="margin-left:auto"><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>heartbeat 2 s ago</span>
|
||||
</div>
|
||||
<div class="logbox">
|
||||
<div><span class="t">14:02:11</span> agent 1.1.0-alpha.1 · config ~/.scimesh-worker/config.json</div>
|
||||
<div><span class="t">14:02:11</span> coordinator http://192.168.1.10:8080 · token auth</div>
|
||||
<div><span class="t">14:02:12</span> <span class="ok">registered</span> as <span class="hl">emil-laptop</span> (w-01HZZ4T9C6)</div>
|
||||
<div><span class="t">14:02:12</span> capabilities: similarity-search, similarity-graph, descriptors, molwt-filter</div>
|
||||
<div><span class="t">14:03:40</span> claimed task <span class="hl">7f3a…c91e/007</span> · similarity-search</div>
|
||||
<div><span class="t">14:03:42</span> <span class="ok">completed</span> 7f3a…c91e/007 · uploaded shard (2.1s)</div>
|
||||
<div><span class="t">14:05:03</span> claimed task <span class="hl">7f3a…c91e/031</span> · similarity-search</div>
|
||||
<div><span class="t">14:05:05</span> <span class="ok">completed</span> 7f3a…c91e/031 · uploaded shard (1.9s)</div>
|
||||
</div>
|
||||
<div class="actions"><span class="link">Configuration: <code>~/.scimesh-worker/config.json</code></span><button class="btn btn-ghost" onclick="showWizard()">Reconfigure…</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="demo-toggle" onclick="toggleView()">mockup · toggle wizard / status</button>
|
||||
|
||||
<script>
|
||||
const pages=['wp1','wp2','wp3','wp4'];
|
||||
function goto(n){
|
||||
pages.forEach((id,i)=>document.getElementById(id).classList.toggle('active',i===n-1));
|
||||
for(let i=1;i<=4;i++){
|
||||
const st=document.getElementById('st'+i);
|
||||
st.classList.toggle('done',i<n);st.classList.toggle('active',i===n);
|
||||
st.querySelector('.step-dot').textContent=i<n?'✓':i;
|
||||
if(i<4)document.getElementById('sl'+i).classList.toggle('done',i<n);
|
||||
}
|
||||
}
|
||||
function showStatus(){document.getElementById('view-wizard').style.display='none';document.getElementById('view-status').style.display='block'}
|
||||
function showWizard(){document.getElementById('view-wizard').style.display='block';document.getElementById('view-status').style.display='none'}
|
||||
function toggleView(){document.getElementById('view-status').style.display==='none'?showStatus():showWizard()}
|
||||
document.querySelectorAll('.radio-grid').forEach(g=>g.querySelectorAll('.radio-card').forEach(c=>c.addEventListener('click',()=>{g.querySelectorAll('.radio-card').forEach(x=>x.classList.remove('sel'));c.classList.add('sel')})));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
# SciMesh installer for Windows: downloads the coordinator binary for this
|
||||
# machine from the latest GitHub release and installs it under %LOCALAPPDATA%.
|
||||
#
|
||||
# powershell -ExecutionPolicy Bypass -c "irm https://raw.githubusercontent.com/emil28092005/SciMesh/main/install.ps1 | iex"
|
||||
#
|
||||
# Then run:
|
||||
#
|
||||
# coordinator serve --open
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$Repo = "emil28092005/SciMesh"
|
||||
$Component = if ($env:SCIMESH_COMPONENT) { $env:SCIMESH_COMPONENT } else { "coordinator" }
|
||||
$Version = if ($env:SCIMESH_VERSION) { $env:SCIMESH_VERSION } else { "latest" }
|
||||
$InstallDir = if ($env:SCIMESH_INSTALL_DIR) {
|
||||
$env:SCIMESH_INSTALL_DIR
|
||||
} else {
|
||||
Join-Path $env:LOCALAPPDATA "SciMesh"
|
||||
}
|
||||
# Auto-start the component right after install and open its UI (the control
|
||||
# room for the coordinator, the local setup wizard for the worker). Set
|
||||
# SCIMESH_AUTO_START=0 to install only.
|
||||
$AutoStart = if ($env:SCIMESH_AUTO_START) { $env:SCIMESH_AUTO_START } else { "1" }
|
||||
|
||||
switch ($Component) {
|
||||
"coordinator" { $Binary = "coordinator" }
|
||||
"worker" { $Binary = "worker-agent" }
|
||||
default { throw "unknown component: $Component (use 'coordinator' or 'worker')" }
|
||||
}
|
||||
|
||||
$Arch = switch ($env:PROCESSOR_ARCHITECTURE) {
|
||||
"AMD64" { "amd64" }
|
||||
"ARM64" { "arm64" }
|
||||
default { throw "unsupported architecture: $env:PROCESSOR_ARCHITECTURE" }
|
||||
}
|
||||
|
||||
if ($Version -eq "latest") {
|
||||
Write-Host "Resolving the newest SciMesh release (including pre-releases)..."
|
||||
# /releases/latest only sees stable releases; the API list is newest-first
|
||||
# across all channels.
|
||||
try {
|
||||
$Releases = Invoke-RestMethod -Uri "https://api.github.com/repos/$Repo/releases?per_page=1"
|
||||
if ($Releases -and $Releases[0].tag_name) {
|
||||
$Version = $Releases[0].tag_name
|
||||
Write-Host " -> $Version"
|
||||
} else {
|
||||
Write-Host " -> falling back to the stable latest release"
|
||||
}
|
||||
} catch {
|
||||
Write-Host " -> falling back to the stable latest release"
|
||||
}
|
||||
}
|
||||
|
||||
$Url = "https://github.com/$Repo/releases/download/$Version/$Binary-windows-$Arch.exe"
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
|
||||
$Target = Join-Path $InstallDir "$Binary.exe"
|
||||
|
||||
Write-Host "Downloading $Url"
|
||||
Invoke-WebRequest -Uri $Url -OutFile "$Target.tmp"
|
||||
Move-Item -Force "$Target.tmp" $Target
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "SciMesh $Component installed: $Target"
|
||||
& $Target --version
|
||||
Write-Host ""
|
||||
if ($Component -eq "coordinator") {
|
||||
if ($AutoStart -eq "1") {
|
||||
Write-Host "Starting the platform and opening the admin console in your browser..."
|
||||
Write-Host "(stop it with Ctrl-C; it keeps your data in ~\.scimesh)"
|
||||
Write-Host ""
|
||||
& $Target serve --open
|
||||
} else {
|
||||
Write-Host "Start the platform (one command, everything embedded):"
|
||||
Write-Host " $Target serve --open"
|
||||
Write-Host ""
|
||||
Write-Host "Your data lives in ~\.scimesh. The admin login is printed on first start."
|
||||
}
|
||||
} else {
|
||||
if ($AutoStart -eq "1") {
|
||||
Write-Host "Starting the local setup wizard in your browser..."
|
||||
Write-Host "(stop it with Ctrl-C; it keeps the configuration in ~\.scimesh-worker)"
|
||||
Write-Host ""
|
||||
& $Target setup
|
||||
} else {
|
||||
Write-Host "The worker needs Python 3 with the scimesh package, then a coordinator"
|
||||
Write-Host "to connect to. Point the local wizard at it:"
|
||||
Write-Host ""
|
||||
Write-Host " $Target setup"
|
||||
Write-Host ""
|
||||
Write-Host "Or run it with environment variables:"
|
||||
Write-Host ""
|
||||
Write-Host " set COORDINATOR_URL=http://COORDINATOR_HOST:8080"
|
||||
Write-Host " set WORKER_AUTH_TOKEN=<worker token from the coordinator>"
|
||||
Write-Host " set WORK_DIR=%USERPROFILE%\scimesh-worker"
|
||||
Write-Host " $Target"
|
||||
Write-Host ""
|
||||
Write-Host "For a coordinator started with 'coordinator serve', the worker token is"
|
||||
Write-Host "in ~\.scimesh\worker.token on that machine. Set SCIMESH_PIP_PACKAGE to"
|
||||
Write-Host "install scimesh into a managed venv, or install it yourself:"
|
||||
Write-Host " pip install scimesh"
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env bash
|
||||
# SciMesh installer: downloads a binary for this OS/architecture from the
|
||||
# newest GitHub release, installs it locally, then starts it and opens its UI
|
||||
# in the browser (SCIMESH_AUTO_START=0 installs only). One command:
|
||||
#
|
||||
# curl -fsSL https://raw.githubusercontent.com/emil28092005/SciMesh/main/install.sh | bash
|
||||
# curl -fsSL https://raw.githubusercontent.com/emil28092005/SciMesh/main/install.sh | bash -s worker
|
||||
#
|
||||
# The first form installs the coordinator (the whole platform in one binary:
|
||||
# databases, userservice, local workers) and opens the admin console. The
|
||||
# second installs a standalone worker agent that joins an existing
|
||||
# coordinator, and opens its local setup wizard. Installed to ~/.local/bin
|
||||
# (Linux/macOS) or %LOCALAPPDATA%\SciMesh (Windows).
|
||||
set -eu
|
||||
|
||||
REPO="emil28092005/SciMesh"
|
||||
COMPONENT="${1:-coordinator}"
|
||||
VERSION="${SCIMESH_VERSION:-latest}"
|
||||
INSTALL_DIR="${SCIMESH_INSTALL_DIR:-$HOME/.local/bin}"
|
||||
# Auto-start the component right after install and open its UI (the control
|
||||
# room for the coordinator, the local setup wizard for the worker). Set
|
||||
# SCIMESH_AUTO_START=0 to install only.
|
||||
AUTO_START="${SCIMESH_AUTO_START:-1}"
|
||||
|
||||
case "$COMPONENT" in
|
||||
coordinator) BINARY="coordinator" ;;
|
||||
worker) BINARY="worker-agent" ;;
|
||||
*) echo "unknown component: $COMPONENT (use 'coordinator' or 'worker')" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
case "$(uname -s)" in
|
||||
Linux) OS="linux" ;;
|
||||
Darwin) OS="darwin" ;;
|
||||
*) echo "unsupported OS: $(uname -s)" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) ARCH="amd64" ;;
|
||||
aarch64|arm64) ARCH="arm64" ;;
|
||||
*) echo "unsupported architecture: $(uname -m)" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
if [ "$VERSION" = "latest" ]; then
|
||||
echo "Resolving the newest SciMesh release (including pre-releases)..."
|
||||
# GitHub's /releases/latest only sees stable releases; the API list is
|
||||
# newest-first across all channels. Without jq, pull the first tag_name.
|
||||
RESOLVED=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases?per_page=1" \
|
||||
| sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)
|
||||
if [ -n "$RESOLVED" ]; then
|
||||
VERSION="$RESOLVED"
|
||||
echo " -> $VERSION"
|
||||
else
|
||||
echo " -> falling back to the stable latest release"
|
||||
fi
|
||||
fi
|
||||
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
TARGET="$INSTALL_DIR/$BINARY"
|
||||
|
||||
URL="https://github.com/${REPO}/releases/download/${VERSION}/${BINARY}-${OS}-${ARCH}"
|
||||
echo "Downloading $URL"
|
||||
curl -fsSL -o "$TARGET.tmp" "$URL"
|
||||
chmod +x "$TARGET.tmp"
|
||||
mv "$TARGET.tmp" "$TARGET"
|
||||
|
||||
echo
|
||||
echo "SciMesh $COMPONENT installed: $TARGET"
|
||||
INSTALLED_VERSION=$("$TARGET" --version 2>/dev/null | awk '{print $2}')
|
||||
"$TARGET" --version
|
||||
if [ -n "$INSTALLED_VERSION" ] && [ "$INSTALLED_VERSION" != "${VERSION#v}" ]; then
|
||||
echo
|
||||
echo "WARNING: expected version $VERSION but got $INSTALLED_VERSION."
|
||||
echo "This is usually a stale download cache. Re-run the installer in a few"
|
||||
echo "minutes, or pin the version explicitly:"
|
||||
echo " SCIMESH_VERSION=${VERSION} bash <(curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh)"
|
||||
fi
|
||||
|
||||
if [ "$COMPONENT" = "coordinator" ]; then
|
||||
if [ "$AUTO_START" = "1" ]; then
|
||||
echo
|
||||
echo "Starting the platform and opening the admin console in your browser..."
|
||||
echo "(stop it with Ctrl-C; it keeps your data in ~/.scimesh)"
|
||||
echo
|
||||
exec "$TARGET" serve --open
|
||||
fi
|
||||
echo
|
||||
echo "Start the platform (one command, everything embedded):"
|
||||
echo " $TARGET serve --open"
|
||||
echo
|
||||
echo "Your data lives in ~/.scimesh. The admin login is printed on first start."
|
||||
else
|
||||
if [ "$AUTO_START" = "1" ]; then
|
||||
echo
|
||||
echo "Starting the local setup wizard in your browser..."
|
||||
echo "(stop it with Ctrl-C; it keeps the configuration in ~/.scimesh-worker)"
|
||||
echo
|
||||
exec "$TARGET" setup
|
||||
fi
|
||||
echo
|
||||
echo "The worker needs Python 3 with the scimesh package, then a coordinator"
|
||||
echo "to connect to. Point the local wizard at it:"
|
||||
echo
|
||||
echo " $TARGET setup"
|
||||
echo
|
||||
echo "Or run it with environment variables:"
|
||||
echo
|
||||
echo " export COORDINATOR_URL=http://COORDINATOR_HOST:8080"
|
||||
echo " export WORKER_AUTH_TOKEN=<worker token from the coordinator>"
|
||||
echo " export WORK_DIR=~/scimesh-worker"
|
||||
echo " $TARGET"
|
||||
echo
|
||||
echo "For a coordinator started with 'coordinator serve', the worker token is"
|
||||
echo "in ~/.scimesh/worker.token on that machine. Set SCIMESH_PIP_PACKAGE to"
|
||||
echo "install scimesh into a managed venv, or install it yourself:"
|
||||
echo " pip install scimesh"
|
||||
fi
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user