Merge user service and coordinator integration
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
name: users
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "users/**"
|
||||
- ".github/workflows/users.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "users/**"
|
||||
- ".github/workflows/users.yml"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: users
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_USER: scimesh
|
||||
POSTGRES_PASSWORD: scimesh
|
||||
POSTGRES_DB: scimesh_users
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U scimesh"
|
||||
--health-interval 5s
|
||||
--health-timeout 3s
|
||||
--health-retries 10
|
||||
|
||||
env:
|
||||
TEST_DATABASE_URL: postgres://scimesh:scimesh@localhost:5432/scimesh_users?sslmode=disable
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: users/go.mod
|
||||
cache-dependency-path: users/go.sum
|
||||
|
||||
- name: go vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: gofmt
|
||||
run: test -z "$(gofmt -l .)" || (gofmt -l . && exit 1)
|
||||
|
||||
- name: unit tests (race)
|
||||
run: go test -race ./...
|
||||
|
||||
- name: lint
|
||||
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 run --build-tags=integration ./...
|
||||
|
||||
- name: install migrate CLI
|
||||
run: go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@v4.17.1
|
||||
|
||||
- name: apply migrations
|
||||
run: migrate -path migrations -database "$TEST_DATABASE_URL" up
|
||||
|
||||
- name: integration tests
|
||||
run: go test -tags=integration ./internal/storage/postgres/ -v
|
||||
@@ -872,7 +872,9 @@ workload logic into the service.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- the service has a versioned, documented API and owns user identity data;
|
||||
- the service has a versioned, documented API in
|
||||
[`docs/user-service-api-contract.md`](docs/user-service-api-contract.md) and
|
||||
owns user identity data;
|
||||
- credentials and authentication tokens are stored and handled securely; they
|
||||
are never logged or exposed to workers;
|
||||
- authenticated identity is propagated to coordinator requests through an
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# build fails with "the --mount option requires BuildKit".
|
||||
|
||||
# --- build stage ----------------------------------------------------------
|
||||
FROM golang:1.24-alpine AS build
|
||||
FROM golang:1.25-alpine AS build
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ help:
|
||||
' make demo-down Stop the demo services and workers.' \
|
||||
' make test / make vet Run Go verification.' \
|
||||
'' \
|
||||
'Demo UI: http://localhost:18080/ui (operator / demo-ui-secret).'
|
||||
'Demo UI: http://localhost:18080/ui (login page; admin root@scimesh.local / rootpassword).'
|
||||
|
||||
demo-ui:
|
||||
@DEMO_PROJECT="$(DEMO_PROJECT)" \
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"syscall"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/infra"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/metrics"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/storage/blob"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/storage/postgres"
|
||||
httptransport "github.com/emil28092005/SciMesh/coordinator/internal/transport/http"
|
||||
@@ -59,13 +60,14 @@ func run() error {
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
)
|
||||
|
||||
useCases := httptransport.UseCases{
|
||||
@@ -74,12 +76,12 @@ func run() error {
|
||||
SubmitDataset: usecase.NewSubmitDataset(blobStore, artifactRepo, jobRepo, taskRepo, tx, clk, cfg.DefaultMaxAttempts),
|
||||
ClaimTask: usecase.NewClaimTask(taskRepo, jobRepo, workerRepo, tx, clk, cfg.LeaseDuration),
|
||||
RenewLease: usecase.NewRenewLease(taskRepo, workerRepo, tx, clk, cfg.LeaseDuration),
|
||||
CompleteTask: usecase.NewCompleteTask(taskRepo, jobRepo, artifactRepo, tx, clk),
|
||||
CompleteTask: usecase.NewCompleteTask(taskRepo, jobRepo, artifactRepo, workerRepo, taskResultRepo, tx, clk, cfg.QuorumSize),
|
||||
ReduceJob: usecase.NewReduceJob(jobRepo, taskRepo, artifactRepo, blobStore, tx, clk),
|
||||
FailTask: usecase.NewFailTask(taskRepo, jobRepo, tx, clk),
|
||||
FailTask: usecase.NewFailTask(taskRepo, jobRepo, workerRepo, tx, clk),
|
||||
GetJobStatus: usecase.NewGetJobStatus(jobRepo, taskRepo),
|
||||
CancelJob: usecase.NewCancelJob(jobRepo, taskRepo, tx, clk),
|
||||
UploadArtifact: usecase.NewUploadArtifact(taskRepo, artifactRepo, blobStore, tx, clk),
|
||||
UploadArtifact: usecase.NewUploadArtifact(taskRepo, workerRepo, artifactRepo, blobStore, tx, clk),
|
||||
DownloadArtifact: usecase.NewDownloadArtifact(artifactRepo, blobStore),
|
||||
GetJobResult: usecase.NewGetJobResult(jobRepo, usecase.NewDownloadArtifact(artifactRepo, blobStore)),
|
||||
GetTaskInput: usecase.NewGetTaskInput(taskRepo, artifactRepo, blobStore),
|
||||
@@ -108,9 +110,18 @@ func run() error {
|
||||
}(r.name, r.fn)
|
||||
}
|
||||
|
||||
// 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)
|
||||
return metrics.Stats{Tasks: tasks, Jobs: jobs, Workers: workers}, err
|
||||
})
|
||||
|
||||
// pool.Ping 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, pool.Ping)
|
||||
api := httptransport.NewServer(useCases, log, cfg.RequestTimeout, cfg.HeartbeatInterval, cfg.MaxUploadBytes, cfg.JWTSecret, cfg.UserserviceURL, m, pool.Ping)
|
||||
err = infra.RunServer(ctx, log, cfg.Addr, api.Handler(cfg.Token, cfg.UIToken))
|
||||
|
||||
// Shutdown order matters, and defers alone cannot express it (they run
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Demo overlay: Prometheus scrapes the coordinator's /metrics, Grafana shows the
|
||||
# provisioned SciMesh dashboard. Merged by scripts/demo-ui.sh with a third -f.
|
||||
# Both share the coordinator's compose network, so Prometheus reaches it by name.
|
||||
|
||||
services:
|
||||
prometheus:
|
||||
image: prom/prometheus:v2.54.1
|
||||
volumes:
|
||||
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
ports:
|
||||
- "${PROMETHEUS_PORT:-19090}:9090"
|
||||
restart: unless-stopped
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:11.2.0
|
||||
depends_on:
|
||||
- prometheus
|
||||
environment:
|
||||
GF_SECURITY_ADMIN_USER: admin
|
||||
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD:-admin}
|
||||
# Anonymous viewing so the demo dashboard opens without a login.
|
||||
GF_AUTH_ANONYMOUS_ENABLED: "true"
|
||||
GF_AUTH_ANONYMOUS_ORG_ROLE: Viewer
|
||||
GF_USERS_DEFAULT_THEME: dark
|
||||
volumes:
|
||||
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||
ports:
|
||||
- "${GRAFANA_PORT:-13000}:3000"
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,66 @@
|
||||
# Demo overlay: adds the userservice (its own Postgres + migrations) alongside
|
||||
# the coordinator and wires the two together with a shared JWT secret, so the
|
||||
# operator UI authenticates through userservice login/registration.
|
||||
#
|
||||
# Used only by scripts/demo-ui.sh, merged onto docker-compose.yml with a second
|
||||
# -f. Not part of the plain `make up` stack.
|
||||
|
||||
services:
|
||||
postgres-users:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-scimesh}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-scimesh}
|
||||
POSTGRES_DB: scimesh_users
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-scimesh} -d scimesh_users"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
start_period: 5s
|
||||
|
||||
migrate-users:
|
||||
image: migrate/migrate:v4.17.1
|
||||
depends_on:
|
||||
postgres-users:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ../users/migrations:/migrations:ro
|
||||
command:
|
||||
- -path=/migrations
|
||||
- -database=postgres://${POSTGRES_USER:-scimesh}:${POSTGRES_PASSWORD:-scimesh}@postgres-users:5432/scimesh_users?sslmode=disable
|
||||
- up
|
||||
restart: on-failure
|
||||
|
||||
userservice:
|
||||
build:
|
||||
context: ../users
|
||||
depends_on:
|
||||
postgres-users:
|
||||
condition: service_healthy
|
||||
migrate-users:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
USERSERVICE_ADDR: ":8081"
|
||||
DATABASE_URL: postgres://${POSTGRES_USER:-scimesh}:${POSTGRES_PASSWORD:-scimesh}@postgres-users:5432/scimesh_users?sslmode=disable
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
# Seeds the first admin the very first time it boots (idempotent after).
|
||||
BOOTSTRAP_ADMIN_EMAIL: ${BOOTSTRAP_ADMIN_EMAIL:-root@scimesh.local}
|
||||
BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
ports:
|
||||
- "${USERSERVICE_PORT:-18081}:8081"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8081/health"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
restart: unless-stopped
|
||||
|
||||
# Turn the coordinator UI into session mode: the same shared secret verifies
|
||||
# userservice tokens locally, and USERSERVICE_URL is where login/register proxy.
|
||||
coordinator:
|
||||
environment:
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
USERSERVICE_URL: http://userservice:8081
|
||||
+13
-3
@@ -1,23 +1,33 @@
|
||||
module github.com/emil28092005/SciMesh/coordinator
|
||||
|
||||
go 1.22
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/Masterminds/squirrel v1.5.4
|
||||
github.com/cenkalti/backoff/v4 v4.3.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/prometheus/client_golang v1.19.1
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // 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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // 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
|
||||
golang.org/x/crypto v0.17.0 // indirect
|
||||
golang.org/x/sync v0.1.0 // indirect
|
||||
golang.org/x/text v0.14.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
|
||||
)
|
||||
|
||||
+28
-6
@@ -1,10 +1,18 @@
|
||||
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
|
||||
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
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/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=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
@@ -21,20 +29,34 @@ 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/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/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=
|
||||
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
|
||||
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/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=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
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.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
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.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=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// Package authctx carries the authenticated requester across the transport and
|
||||
// use-case layers without either one importing the other. The HTTP middleware
|
||||
// stamps a Requester after verifying a user's JWT; the job use cases read it to
|
||||
// record ownership and to enforce that a non-admin only touches their own jobs.
|
||||
package authctx
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Requester is the identity behind a request, derived from a verified JWT.
|
||||
// A request authenticated only by the shared worker/service token carries no
|
||||
// Requester at all (From returns ok=false), which is how worker traffic and
|
||||
// legacy unauthenticated-user traffic stay owner-less.
|
||||
type Requester struct {
|
||||
UserID uuid.UUID
|
||||
Role string
|
||||
Verified bool
|
||||
}
|
||||
|
||||
// IsAdmin reports whether the requester may act on any user's jobs.
|
||||
func (r Requester) IsAdmin() bool { return r.Role == "admin" }
|
||||
|
||||
// IsTrusted reports whether workers this requester registers produce results
|
||||
// the coordinator accepts without quorum. Admins and verified contributors are
|
||||
// trusted; a plain unverified user is not.
|
||||
func (r Requester) IsTrusted() bool { return r.IsAdmin() || r.Verified }
|
||||
|
||||
type ctxKey struct{}
|
||||
|
||||
// With returns a copy of ctx carrying r.
|
||||
func With(ctx context.Context, r Requester) context.Context {
|
||||
return context.WithValue(ctx, ctxKey{}, r)
|
||||
}
|
||||
|
||||
// From returns the requester stamped by the middleware, or ok=false when the
|
||||
// request was not authenticated as a user.
|
||||
func From(ctx context.Context) (Requester, bool) {
|
||||
r, ok := ctx.Value(ctxKey{}).(Requester)
|
||||
return r, ok
|
||||
}
|
||||
@@ -19,7 +19,11 @@ const (
|
||||
|
||||
// Job is one user submission that fans out into one or more tasks.
|
||||
type Job struct {
|
||||
ID uuid.UUID
|
||||
ID uuid.UUID
|
||||
// OwnerID is the userservice user who submitted the job (JWT `sub`). nil
|
||||
// when the job was created without user authentication. Not a foreign key:
|
||||
// users live in a separate service/database.
|
||||
OwnerID *uuid.UUID
|
||||
Workload string
|
||||
InputURI string // external input URI; empty for uploaded datasets
|
||||
InputArtifactID *uuid.UUID // uploaded input artifact; nil for URI submissions
|
||||
|
||||
@@ -24,6 +24,10 @@ const (
|
||||
// ErrCodeLeaseExpired marks tasks failed by the reaper rather than by a worker.
|
||||
const ErrCodeLeaseExpired = "lease_expired"
|
||||
|
||||
// ErrCodeQuorumFailed marks a task whose untrusted results never reached a
|
||||
// verifying quorum before its attempts ran out.
|
||||
const ErrCodeQuorumFailed = "quorum_failed"
|
||||
|
||||
// Task is one independently executable chunk of a job.
|
||||
//
|
||||
// Nullable columns are pointers so "no lease" stays distinguishable from
|
||||
@@ -216,6 +220,33 @@ func (t *Task) CompleteWith(resultArtifactID uuid.UUID, metrics map[string]any,
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReleaseAfterVote returns an untrusted worker's task to the queue after its
|
||||
// result was recorded as a quorum vote but quorum was not yet reached, so a
|
||||
// different owner can compute it independently. When no attempts remain the task
|
||||
// fails: its untrusted results could not be verified.
|
||||
func (t *Task) ReleaseAfterVote(worker string, attempt int, now time.Time) error {
|
||||
if t.Status == TaskCompleted {
|
||||
return nil // settled by a concurrent quorum
|
||||
}
|
||||
if err := t.verifyLease(worker, attempt, now); err != nil {
|
||||
return err
|
||||
}
|
||||
t.LeaseOwner = nil
|
||||
t.LeaseExpiresAt = nil
|
||||
t.Version++
|
||||
|
||||
if t.CanRetry() {
|
||||
t.Status = TaskPending
|
||||
return nil
|
||||
}
|
||||
code, msg := ErrCodeQuorumFailed, "untrusted results did not reach quorum"
|
||||
t.ErrorCode = &code
|
||||
t.ErrorMessage = &msg
|
||||
t.Status = TaskFailed
|
||||
t.CompletedAt = &now
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fail records a worker-reported failure. A retryable failure with attempts
|
||||
// left returns the task to the queue; otherwise it terminates as failed.
|
||||
func (t *Task) Fail(worker string, attempt int, code, message string, retryable bool, now time.Time) error {
|
||||
|
||||
@@ -14,14 +14,30 @@ const (
|
||||
WorkerOffline WorkerStatus = "offline"
|
||||
)
|
||||
|
||||
// WorkerTrust says whether a worker's results are accepted directly or must
|
||||
// clear quorum cross-checking.
|
||||
type WorkerTrust string
|
||||
|
||||
const (
|
||||
// WorkerTrusted — lab machine (shared token) or a verified/admin contributor.
|
||||
WorkerTrusted WorkerTrust = "trusted"
|
||||
// WorkerUntrusted — a plain enthusiast; results are quarantined until quorum.
|
||||
WorkerUntrusted WorkerTrust = "untrusted"
|
||||
)
|
||||
|
||||
// Worker is a registered process/machine allowed to claim tasks. Its
|
||||
// capabilities are the allowlisted workload names it can run; the coordinator
|
||||
// never hands it a task outside that set.
|
||||
type Worker struct {
|
||||
ID uuid.UUID
|
||||
Name string
|
||||
Capabilities []string
|
||||
Status WorkerStatus
|
||||
ID uuid.UUID
|
||||
Name string
|
||||
Capabilities []string
|
||||
Status WorkerStatus
|
||||
// OwnerID is the userservice user who registered this worker; nil for a
|
||||
// worker registered with the shared service token.
|
||||
OwnerID *uuid.UUID
|
||||
// TrustLevel decides whether this worker's results need quorum.
|
||||
TrustLevel WorkerTrust
|
||||
LastHeartbeatAt time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
@@ -29,6 +45,9 @@ type Worker struct {
|
||||
|
||||
// NewWorker registers a worker. A worker with no capabilities could never be
|
||||
// handed a task, so an empty set is rejected rather than silently stored.
|
||||
//
|
||||
// Trust defaults to WorkerTrusted (the shared-token lab worker); the caller
|
||||
// overrides it for a volunteer registered through the userservice.
|
||||
func NewWorker(name string, capabilities []string, now time.Time) (*Worker, error) {
|
||||
if len(capabilities) == 0 {
|
||||
return nil, ErrInvalidInput
|
||||
@@ -38,6 +57,7 @@ func NewWorker(name string, capabilities []string, now time.Time) (*Worker, erro
|
||||
Name: name,
|
||||
Capabilities: capabilities,
|
||||
Status: WorkerOnline,
|
||||
TrustLevel: WorkerTrusted,
|
||||
LastHeartbeatAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
|
||||
@@ -26,6 +26,17 @@ type Config struct {
|
||||
Token string
|
||||
// Local operator UI credential. Empty disables the embedded UI entirely.
|
||||
UIToken string
|
||||
// Shared HS256 secret used to verify userservice-issued JWTs. When set, a
|
||||
// submitter may authenticate with a JWT (in addition to workers using the
|
||||
// shared token) and their jobs are stamped with owner_id. Empty disables
|
||||
// user-JWT auth entirely — the pre-userservice behaviour. Must match the
|
||||
// userservice's JWT_SECRET.
|
||||
JWTSecret string
|
||||
// Base URL of the userservice, e.g. http://userservice:8081. When set
|
||||
// together with JWTSecret, the operator UI authenticates via userservice
|
||||
// login/registration (cookie session) instead of the static UI_AUTH_TOKEN
|
||||
// basic auth. Empty keeps the basic-auth UI.
|
||||
UserserviceURL string
|
||||
|
||||
// Minimum log level: debug, info, warn, error.
|
||||
LogLevel string
|
||||
@@ -50,6 +61,9 @@ type Config struct {
|
||||
LeaseDuration time.Duration
|
||||
// Default attempt ceiling for newly created tasks.
|
||||
DefaultMaxAttempts int
|
||||
// How many distinct owners must agree on an untrusted result before it is
|
||||
// accepted (trusted workers are accepted directly).
|
||||
QuorumSize int
|
||||
// How often the background lease-reaper runs.
|
||||
ReaperInterval time.Duration
|
||||
// A worker silent for longer than this is marked offline by the reaper.
|
||||
@@ -80,6 +94,8 @@ func LoadConfig() (Config, error) {
|
||||
// former name, still honoured so existing .env files keep working.
|
||||
Token: getEnv("COORDINATOR_TOKEN", os.Getenv("WORKER_AUTH_TOKEN")),
|
||||
UIToken: os.Getenv("UI_AUTH_TOKEN"),
|
||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||
UserserviceURL: os.Getenv("USERSERVICE_URL"),
|
||||
LogLevel: getEnv("LOG_LEVEL", "info"),
|
||||
LogFile: os.Getenv("LOG_FILE"),
|
||||
StorageDir: getEnv("COORDINATOR_STORAGE_DIR", "./data"),
|
||||
@@ -90,6 +106,7 @@ func LoadConfig() (Config, error) {
|
||||
HeartbeatInterval: 15 * time.Second,
|
||||
LeaseDuration: 2 * time.Minute,
|
||||
DefaultMaxAttempts: 3,
|
||||
QuorumSize: 2,
|
||||
ReaperInterval: 30 * time.Second,
|
||||
WorkerOfflineAfter: 1 * time.Minute,
|
||||
}
|
||||
@@ -100,6 +117,11 @@ func LoadConfig() (Config, error) {
|
||||
if cfg.UIToken != "" && cfg.Token != "" && cfg.UIToken == cfg.Token {
|
||||
return Config{}, fmt.Errorf("UI_AUTH_TOKEN must differ from the worker auth token")
|
||||
}
|
||||
// A short secret makes the HMAC brute-forceable; refuse a weak one rather
|
||||
// than verify tokens against it.
|
||||
if cfg.JWTSecret != "" && len(cfg.JWTSecret) < 32 {
|
||||
return Config{}, fmt.Errorf("JWT_SECRET must be at least 32 bytes")
|
||||
}
|
||||
|
||||
var err error
|
||||
if cfg.DBMaxConns, err = getEnvInt32("DB_MAX_CONNS", cfg.DBMaxConns); err != nil {
|
||||
@@ -129,6 +151,12 @@ func LoadConfig() (Config, error) {
|
||||
if cfg.DefaultMaxAttempts, err = getEnvInt("DEFAULT_MAX_ATTEMPTS", cfg.DefaultMaxAttempts); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.QuorumSize, err = getEnvInt("QUORUM_SIZE", cfg.QuorumSize); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.QuorumSize < 1 {
|
||||
return Config{}, fmt.Errorf("QUORUM_SIZE must be positive")
|
||||
}
|
||||
if cfg.DefaultMaxAttempts < 1 {
|
||||
return Config{}, fmt.Errorf("DEFAULT_MAX_ATTEMPTS must be positive")
|
||||
}
|
||||
|
||||
@@ -417,3 +417,36 @@ func contains(ss []string, s string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TaskResultRepo is an in-memory usecase.TaskResultRepository: one vote per
|
||||
// (task, owner).
|
||||
type TaskResultRepo struct {
|
||||
mu sync.Mutex
|
||||
votes map[uuid.UUID]map[uuid.UUID]string // taskID -> ownerID -> sha256
|
||||
}
|
||||
|
||||
func NewTaskResultRepo() *TaskResultRepo {
|
||||
return &TaskResultRepo{votes: make(map[uuid.UUID]map[uuid.UUID]string)}
|
||||
}
|
||||
|
||||
func (r *TaskResultRepo) RecordVote(_ context.Context, taskID, ownerID uuid.UUID, sha256 string, _ uuid.UUID) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.votes[taskID] == nil {
|
||||
r.votes[taskID] = make(map[uuid.UUID]string)
|
||||
}
|
||||
r.votes[taskID][ownerID] = sha256
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *TaskResultRepo) CountAgreeing(_ context.Context, taskID uuid.UUID, sha256 string) (int, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
n := 0
|
||||
for _, s := range r.votes[taskID] {
|
||||
if s == sha256 {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ var _ usecase.UIReadRepository = (*UIReadRepo)(nil)
|
||||
func (r *UIReadRepo) GetJob(ctx context.Context, id uuid.UUID) (*domain.Job, error) {
|
||||
return r.jobs.Get(ctx, id)
|
||||
}
|
||||
func (r *UIReadRepo) ListJobs(_ context.Context, limit int) ([]domain.Job, error) {
|
||||
func (r *UIReadRepo) ListJobs(_ context.Context, owner *uuid.UUID, limit int) ([]domain.Job, error) {
|
||||
if limit < 1 || limit > 100 {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
@@ -35,6 +35,9 @@ func (r *UIReadRepo) ListJobs(_ context.Context, limit int) ([]domain.Job, error
|
||||
defer r.jobs.mu.Unlock()
|
||||
out := make([]domain.Job, 0, len(r.jobs.jobs))
|
||||
for _, job := range r.jobs.jobs {
|
||||
if owner != nil && (job.OwnerID == nil || *job.OwnerID != *owner) {
|
||||
continue
|
||||
}
|
||||
out = append(out, *job)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
// Stats is a point-in-time snapshot of the coordinator's domain state: counts of
|
||||
// tasks, jobs, and workers keyed by their status. Maps are expected to be
|
||||
// zero-filled by the provider so every known status is always present, giving
|
||||
// the dashboard flat zero lines instead of gaps.
|
||||
type Stats struct {
|
||||
Tasks map[string]int
|
||||
Jobs map[string]int
|
||||
Workers map[string]int
|
||||
}
|
||||
|
||||
// StatsFunc returns the current snapshot. It is called on every scrape, so it
|
||||
// must be a cheap aggregate query.
|
||||
type StatsFunc func(context.Context) (Stats, error)
|
||||
|
||||
// RegisterBusiness registers a collector that reports domain-state gauges
|
||||
// (scimesh_tasks/jobs/workers by status) sourced from collect on each scrape.
|
||||
// Deriving the gauges at scrape time keeps them fresh without a background
|
||||
// goroutine, and a failed query simply yields no samples for that scrape.
|
||||
func (m *Metrics) RegisterBusiness(collect StatsFunc) {
|
||||
m.reg.MustRegister(&businessCollector{
|
||||
collect: collect,
|
||||
tasks: prometheus.NewDesc("scimesh_tasks", "Tasks by status.", []string{"status"}, nil),
|
||||
jobs: prometheus.NewDesc("scimesh_jobs", "Jobs by status.", []string{"status"}, nil),
|
||||
workers: prometheus.NewDesc("scimesh_workers", "Workers by status.", []string{"status"}, nil),
|
||||
})
|
||||
}
|
||||
|
||||
type businessCollector struct {
|
||||
collect StatsFunc
|
||||
tasks, jobs, workers *prometheus.Desc
|
||||
}
|
||||
|
||||
func (c *businessCollector) Describe(ch chan<- *prometheus.Desc) {
|
||||
ch <- c.tasks
|
||||
ch <- c.jobs
|
||||
ch <- c.workers
|
||||
}
|
||||
|
||||
func (c *businessCollector) Collect(ch chan<- prometheus.Metric) {
|
||||
// A bounded query so one slow scrape cannot stall Prometheus.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
s, err := c.collect(ctx)
|
||||
if err != nil {
|
||||
return // no samples this scrape; Prometheus keeps the last value
|
||||
}
|
||||
emit(ch, c.tasks, s.Tasks)
|
||||
emit(ch, c.jobs, s.Jobs)
|
||||
emit(ch, c.workers, s.Workers)
|
||||
}
|
||||
|
||||
func emit(ch chan<- prometheus.Metric, desc *prometheus.Desc, counts map[string]int) {
|
||||
for status, n := range counts {
|
||||
ch <- prometheus.MustNewConstMetric(desc, prometheus.GaugeValue, float64(n), status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func scrape(t *testing.T, m *Metrics) string {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "/metrics", nil)
|
||||
m.Handler().ServeHTTP(rec, req)
|
||||
return rec.Body.String()
|
||||
}
|
||||
|
||||
func TestBusinessCollectorEmitsGauges(t *testing.T) {
|
||||
m := New()
|
||||
m.RegisterBusiness(func(context.Context) (Stats, error) {
|
||||
return Stats{
|
||||
Tasks: map[string]int{"pending": 3, "running": 1, "completed": 0},
|
||||
Jobs: map[string]int{"running": 2},
|
||||
Workers: map[string]int{"online": 4},
|
||||
}, nil
|
||||
})
|
||||
|
||||
body := scrape(t, m)
|
||||
for _, want := range []string{
|
||||
`scimesh_tasks{status="pending"} 3`,
|
||||
`scimesh_tasks{status="completed"} 0`,
|
||||
`scimesh_jobs{status="running"} 2`,
|
||||
`scimesh_workers{status="online"} 4`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("metrics missing %q\n%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusinessCollectorSkipsOnError(t *testing.T) {
|
||||
m := New()
|
||||
m.RegisterBusiness(func(context.Context) (Stats, error) {
|
||||
return Stats{}, errors.New("db down")
|
||||
})
|
||||
if strings.Contains(scrape(t, m), "scimesh_tasks") {
|
||||
t.Error("a failed snapshot must emit no business samples")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Package metrics exposes Prometheus instrumentation for the coordinator: an
|
||||
// HTTP RED middleware (rate, errors, duration) plus the standard Go runtime and
|
||||
// process collectors, all on a private registry so nothing leaks in from global
|
||||
// state.
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/collectors"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
type Metrics struct {
|
||||
reg *prometheus.Registry
|
||||
requests *prometheus.CounterVec
|
||||
duration *prometheus.HistogramVec
|
||||
}
|
||||
|
||||
// New builds the registry and registers the runtime, process, and HTTP metrics.
|
||||
func New() *Metrics {
|
||||
reg := prometheus.NewRegistry()
|
||||
reg.MustRegister(
|
||||
collectors.NewGoCollector(),
|
||||
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
|
||||
)
|
||||
|
||||
requests := prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Namespace: "scimesh",
|
||||
Subsystem: "http",
|
||||
Name: "requests_total",
|
||||
Help: "HTTP requests, labelled by method, normalized route, and status.",
|
||||
}, []string{"method", "route", "status"})
|
||||
|
||||
duration := prometheus.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Namespace: "scimesh",
|
||||
Subsystem: "http",
|
||||
Name: "request_duration_seconds",
|
||||
Help: "HTTP request duration in seconds.",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
}, []string{"method", "route"})
|
||||
|
||||
reg.MustRegister(requests, duration)
|
||||
return &Metrics{reg: reg, requests: requests, duration: duration}
|
||||
}
|
||||
|
||||
// Handler serves the metrics in Prometheus text format.
|
||||
func (m *Metrics) Handler() http.Handler {
|
||||
return promhttp.HandlerFor(m.reg, promhttp.HandlerOpts{})
|
||||
}
|
||||
|
||||
// Registry exposes the registry so callers can register extra collectors.
|
||||
func (m *Metrics) Registry() *prometheus.Registry { return m.reg }
|
||||
|
||||
// Middleware records one request into the RED metrics. It normalizes the path
|
||||
// so per-id routes collapse to a single low-cardinality label.
|
||||
func (m *Metrics) Middleware(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)
|
||||
|
||||
route := normalizeRoute(r.URL.Path)
|
||||
m.requests.WithLabelValues(r.Method, route, strconv.Itoa(rec.status)).Inc()
|
||||
m.duration.WithLabelValues(r.Method, route).Observe(time.Since(start).Seconds())
|
||||
})
|
||||
}
|
||||
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (s *statusRecorder) WriteHeader(code int) {
|
||||
s.status = code
|
||||
s.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
var uuidRe = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
|
||||
|
||||
// normalizeRoute collapses uuid and numeric path segments to {id}, keeping the
|
||||
// route label cardinality bounded (otherwise every job/task id would be its own
|
||||
// time series).
|
||||
func normalizeRoute(path string) string {
|
||||
if path == "" {
|
||||
return "/"
|
||||
}
|
||||
segs := strings.Split(path, "/")
|
||||
for i, s := range segs {
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
if uuidRe.MatchString(s) || isAllDigits(s) {
|
||||
segs[i] = "{id}"
|
||||
}
|
||||
}
|
||||
return strings.Join(segs, "/")
|
||||
}
|
||||
|
||||
func isAllDigits(s string) bool {
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return s != ""
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeRoute(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"/health": "/health",
|
||||
"/jobs/3f2504e0-4f89-41d3-9a0c-0305e82c3301": "/jobs/{id}",
|
||||
"/tasks/3f2504e0-4f89-41d3-9a0c-0305e82c3301/result": "/tasks/{id}/result",
|
||||
"/ui/jobs/12345": "/ui/jobs/{id}",
|
||||
"/": "/",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalizeRoute(in); got != want {
|
||||
t.Errorf("normalizeRoute(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareAndHandler(t *testing.T) {
|
||||
m := New()
|
||||
h := m.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}))
|
||||
|
||||
req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, "/jobs/3f2504e0-4f89-41d3-9a0c-0305e82c3301", nil)
|
||||
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
// Scrape and confirm the request was recorded under the normalized route.
|
||||
rec := httptest.NewRecorder()
|
||||
greq, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "/metrics", nil)
|
||||
m.Handler().ServeHTTP(rec, greq)
|
||||
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, `scimesh_http_requests_total{method="POST",route="/jobs/{id}",status="201"}`) {
|
||||
t.Errorf("requests_total not recorded as expected; body:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "go_goroutines") {
|
||||
t.Error("Go runtime collector not registered")
|
||||
}
|
||||
}
|
||||
@@ -144,7 +144,7 @@ func TestUIReadRepoListsReducerFields(t *testing.T) {
|
||||
if claimed, err := jobs.ClaimReduction(ctx, job.ID, time.Now().UTC()); err != nil || !claimed {
|
||||
t.Fatalf("claim reduction = (%v, %v)", claimed, err)
|
||||
}
|
||||
listed, err := NewUIReadRepo(pool).ListJobs(ctx, 20)
|
||||
listed, err := NewUIReadRepo(pool).ListJobs(ctx, nil, 20)
|
||||
if err != nil {
|
||||
t.Fatalf("list UI jobs: %v", err)
|
||||
}
|
||||
@@ -406,8 +406,9 @@ func TestCompleteTaskReplayIsIdempotent(t *testing.T) {
|
||||
job, _ := seedJob(t, pool, 1)
|
||||
|
||||
tasks, jobs, artifacts, tx := NewTaskRepo(pool), NewJobRepo(pool), NewArtifactRepo(pool), NewTxManager(pool)
|
||||
workers, results := NewWorkerRepo(pool), NewTaskResultRepo(pool)
|
||||
clk := fixedClock{now: time.Now().UTC()}
|
||||
uc := usecase.NewCompleteTask(tasks, jobs, artifacts, tx, clk)
|
||||
uc := usecase.NewCompleteTask(tasks, jobs, artifacts, workers, results, tx, clk, 2)
|
||||
|
||||
claimed, err := tasks.ClaimNext(ctx, usecase.ClaimFilter{
|
||||
Owner: "worker-1", Now: clk.now, LeaseUntil: clk.now.Add(time.Minute),
|
||||
|
||||
@@ -28,14 +28,15 @@ var _ usecase.JobRepository = (*JobRepo)(nil)
|
||||
var jobColumns = []string{
|
||||
"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",
|
||||
}
|
||||
|
||||
// Insert runs inside the caller's transaction, alongside the job's tasks — that
|
||||
// is what makes "all tasks or none" hold.
|
||||
func (r *JobRepo) Insert(ctx context.Context, j *domain.Job) error {
|
||||
sql, args, err := psql.Insert("jobs").
|
||||
Columns("id", "workload", "input_uri", "parameters", "status", "created_at").
|
||||
Values(j.ID, j.Workload, j.InputURI, jsonbOrEmpty(j.Parameters), string(j.Status), j.CreatedAt).
|
||||
Columns("id", "workload", "input_uri", "parameters", "status", "created_at", "owner_id").
|
||||
Values(j.ID, j.Workload, j.InputURI, jsonbOrEmpty(j.Parameters), string(j.Status), j.CreatedAt, j.OwnerID).
|
||||
ToSql()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -59,7 +60,8 @@ func (r *JobRepo) Get(ctx context.Context, id uuid.UUID) (*domain.Job, error) {
|
||||
)
|
||||
err = conn(ctx, r.pool).QueryRow(ctx, sql, args...).Scan(
|
||||
&j.ID, &j.Workload, &j.InputURI, &j.Parameters, &status, &j.CreatedAt, &j.CompletedAt,
|
||||
&j.InputArtifactID, &j.ResultArtifactID, &j.ErrorCode, &j.ErrorMessage, &j.ReducerStartedAt)
|
||||
&j.InputArtifactID, &j.ResultArtifactID, &j.ErrorCode, &j.ErrorMessage, &j.ReducerStartedAt,
|
||||
&j.OwnerID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, domain.ErrJobNotFound
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"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. It
|
||||
// runs one cheap GROUP BY per entity; the collector calls this on every scrape.
|
||||
type StatsRepo struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewStatsRepo(pool *pgxpool.Pool) *StatsRepo {
|
||||
return &StatsRepo{pool: pool}
|
||||
}
|
||||
|
||||
// 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.pool.Query(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 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 // an unknown status still shows up, which is a useful signal
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -84,6 +84,9 @@ WITH candidate AS (
|
||||
WHERE status = 'pending'
|
||||
AND attempt < max_attempts
|
||||
AND (cardinality($1::text[]) = 0 OR workload = ANY($1))
|
||||
AND ($5::uuid IS NULL OR NOT EXISTS (
|
||||
SELECT 1 FROM task_results tr
|
||||
WHERE tr.task_id = tasks.id AND tr.owner_id = $5))
|
||||
ORDER BY created_at, chunk_index
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 1
|
||||
@@ -108,7 +111,7 @@ func (r *TaskRepo) ClaimNext(ctx context.Context, f usecase.ClaimFilter) (*domai
|
||||
|
||||
var task *domain.Task
|
||||
err := withRetry(ctx, func(ctx context.Context) error {
|
||||
row := conn(ctx, r.pool).QueryRow(ctx, claimNextSQL, workloads, f.Owner, f.LeaseUntil, f.Now)
|
||||
row := conn(ctx, r.pool).QueryRow(ctx, claimNextSQL, workloads, f.Owner, f.LeaseUntil, f.Now, f.VoterOwner)
|
||||
t, err := scanTask(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
task = nil
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// TaskResultRepo records and tallies quorum votes for untrusted task results.
|
||||
type TaskResultRepo struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewTaskResultRepo(pool *pgxpool.Pool) *TaskResultRepo {
|
||||
return &TaskResultRepo{pool: pool}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
const sql = `
|
||||
INSERT INTO task_results (task_id, owner_id, result_sha256, result_artifact_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (task_id, owner_id) DO UPDATE
|
||||
SET result_sha256 = EXCLUDED.result_sha256,
|
||||
result_artifact_id = EXCLUDED.result_artifact_id,
|
||||
created_at = now()`
|
||||
if _, err := conn(ctx, r.pool).Exec(ctx, sql, taskID, ownerID, sha256, artifactID); err != nil {
|
||||
return fmt.Errorf("record vote: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountAgreeing returns how many distinct owners have voted for the given result
|
||||
// hash on this task — the size of the agreeing set the quorum is measured
|
||||
// against.
|
||||
func (r *TaskResultRepo) CountAgreeing(ctx context.Context, taskID uuid.UUID, sha256 string) (int, error) {
|
||||
const sql = `SELECT count(DISTINCT owner_id) FROM task_results WHERE task_id = $1 AND result_sha256 = $2`
|
||||
var n int
|
||||
if err := conn(ctx, r.pool).QueryRow(ctx, sql, taskID, sha256).Scan(&n); err != nil {
|
||||
return 0, fmt.Errorf("count agreeing: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -24,11 +24,15 @@ func (r *UIReadRepo) GetJob(ctx context.Context, id uuid.UUID) (*domain.Job, err
|
||||
return job, err
|
||||
}
|
||||
|
||||
func (r *UIReadRepo) ListJobs(ctx context.Context, limit int) ([]domain.Job, error) {
|
||||
func (r *UIReadRepo) ListJobs(ctx context.Context, owner *uuid.UUID, limit int) ([]domain.Job, error) {
|
||||
if limit < 1 || limit > 100 {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
sql, args, err := psql.Select(jobColumns...).From("jobs").OrderBy("created_at DESC", "id DESC").Limit(uint64(limit)).ToSql()
|
||||
q := psql.Select(jobColumns...).From("jobs")
|
||||
if owner != nil {
|
||||
q = q.Where(sq.Eq{"owner_id": *owner})
|
||||
}
|
||||
sql, args, err := q.OrderBy("created_at DESC", "id DESC").Limit(uint64(limit)).ToSql()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -44,6 +48,7 @@ func (r *UIReadRepo) ListJobs(ctx context.Context, limit int) ([]domain.Job, err
|
||||
if err := rows.Scan(
|
||||
&j.ID, &j.Workload, &j.InputURI, &j.Parameters, &status, &j.CreatedAt, &j.CompletedAt,
|
||||
&j.InputArtifactID, &j.ResultArtifactID, &j.ErrorCode, &j.ErrorMessage, &j.ReducerStartedAt,
|
||||
&j.OwnerID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -23,13 +23,13 @@ func NewWorkerRepo(pool *pgxpool.Pool) *WorkerRepo {
|
||||
return &WorkerRepo{pool: pool}
|
||||
}
|
||||
|
||||
var workerColumns = []string{"id", "name", "capabilities", "status", "last_heartbeat_at", "created_at", "updated_at"}
|
||||
var workerColumns = []string{"id", "name", "capabilities", "status", "owner_id", "trust_level", "last_heartbeat_at", "created_at", "updated_at"}
|
||||
|
||||
func (r *WorkerRepo) Insert(ctx context.Context, w *domain.Worker) error {
|
||||
sql, args, err := psql.Insert("workers").
|
||||
Columns(workerColumns...).
|
||||
// capabilities is a jsonb column; pgx marshals the []string to a JSON array.
|
||||
Values(w.ID, w.Name, w.Capabilities, string(w.Status),
|
||||
Values(w.ID, w.Name, w.Capabilities, string(w.Status), w.OwnerID, string(w.TrustLevel),
|
||||
w.LastHeartbeatAt, w.CreatedAt, w.UpdatedAt).
|
||||
ToSql()
|
||||
if err != nil {
|
||||
@@ -95,11 +95,13 @@ func scanWorker(row pgx.Row) (*domain.Worker, error) {
|
||||
var (
|
||||
w domain.Worker
|
||||
status string
|
||||
trust string
|
||||
)
|
||||
if err := row.Scan(&w.ID, &w.Name, &w.Capabilities, &status,
|
||||
if err := row.Scan(&w.ID, &w.Name, &w.Capabilities, &status, &w.OwnerID, &trust,
|
||||
&w.LastHeartbeatAt, &w.CreatedAt, &w.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w.Status = domain.WorkerStatus(status)
|
||||
w.TrustLevel = domain.WorkerTrust(trust)
|
||||
return &w, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// Package token verifies the HS256 JWTs minted by the userservice. The
|
||||
// coordinator only ever *verifies* — it never issues — so this is a deliberately
|
||||
// small counterpart to the userservice's issuer. Verification is local: the
|
||||
// shared secret is enough, with no runtime call back to the userservice.
|
||||
package token
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Claims is the subset of a userservice token the coordinator cares about.
|
||||
type Claims struct {
|
||||
UserID uuid.UUID
|
||||
Role string
|
||||
Verified bool
|
||||
}
|
||||
|
||||
// Verifier checks tokens against the shared HS256 secret.
|
||||
type Verifier struct {
|
||||
secret []byte
|
||||
}
|
||||
|
||||
// NewVerifier returns a Verifier, or nil when secret is empty — a nil Verifier
|
||||
// means user-JWT auth is disabled and only the shared service token is accepted.
|
||||
func NewVerifier(secret string) *Verifier {
|
||||
if secret == "" {
|
||||
return nil
|
||||
}
|
||||
return &Verifier{secret: []byte(secret)}
|
||||
}
|
||||
|
||||
type claims struct {
|
||||
Role string `json:"role"`
|
||||
Verified bool `json:"verified"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// Verify checks the signature and expiry and returns the identity. It pins the
|
||||
// algorithm to HMAC, rejecting a token that asks for "none" or an RS256 public
|
||||
// key — the classic algorithm-substitution attack.
|
||||
func (v *Verifier) Verify(raw string) (Claims, error) {
|
||||
var c claims
|
||||
_, err := jwt.ParseWithClaims(raw, &c, 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 v.secret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return Claims{}, err
|
||||
}
|
||||
id, err := uuid.Parse(c.Subject)
|
||||
if err != nil {
|
||||
return Claims{}, fmt.Errorf("token subject is not a uuid: %w", err)
|
||||
}
|
||||
return Claims{UserID: id, Role: c.Role, Verified: c.Verified}, nil
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package token
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const secret = "coordinator-verify-secret-32-bytes!!"
|
||||
|
||||
func sign(t *testing.T, method jwt.SigningMethod, key any, sub, role string, exp time.Time) string {
|
||||
t.Helper()
|
||||
return signVerified(t, method, key, sub, role, false, exp)
|
||||
}
|
||||
|
||||
func signVerified(t *testing.T, method jwt.SigningMethod, key any, sub, role string, verified bool, exp time.Time) string {
|
||||
t.Helper()
|
||||
tok := jwt.NewWithClaims(method, claims{
|
||||
Role: role,
|
||||
Verified: verified,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: sub,
|
||||
ExpiresAt: jwt.NewNumericDate(exp),
|
||||
},
|
||||
})
|
||||
raw, err := tok.SignedString(key)
|
||||
if err != nil {
|
||||
t.Fatalf("sign: %v", err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func TestVerifyCarriesVerifiedClaim(t *testing.T) {
|
||||
v := NewVerifier(secret)
|
||||
raw := signVerified(t, jwt.SigningMethodHS256, []byte(secret), uuid.New().String(), "user", true, time.Now().Add(time.Hour))
|
||||
|
||||
claims, err := v.Verify(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
if !claims.Verified {
|
||||
t.Error("verified claim not read from token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewVerifierNilWhenNoSecret(t *testing.T) {
|
||||
if NewVerifier("") != nil {
|
||||
t.Error("empty secret must yield a nil verifier (auth disabled)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRoundTrip(t *testing.T) {
|
||||
v := NewVerifier(secret)
|
||||
id := uuid.New()
|
||||
raw := sign(t, jwt.SigningMethodHS256, []byte(secret), id.String(), "admin", time.Now().Add(time.Hour))
|
||||
|
||||
claims, err := v.Verify(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
if claims.UserID != id {
|
||||
t.Errorf("UserID = %v, want %v", claims.UserID, id)
|
||||
}
|
||||
if claims.Role != "admin" {
|
||||
t.Errorf("Role = %q, want admin", claims.Role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsExpired(t *testing.T) {
|
||||
v := NewVerifier(secret)
|
||||
raw := sign(t, jwt.SigningMethodHS256, []byte(secret), uuid.New().String(), "user", time.Now().Add(-time.Minute))
|
||||
if _, err := v.Verify(raw); err == nil {
|
||||
t.Error("expired token accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsWrongSecret(t *testing.T) {
|
||||
raw := sign(t, jwt.SigningMethodHS256, []byte(secret), uuid.New().String(), "user", time.Now().Add(time.Hour))
|
||||
if _, err := NewVerifier("another-secret-also-at-least-32-byte").Verify(raw); err == nil {
|
||||
t.Error("token verified under the wrong secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsNoneAlg(t *testing.T) {
|
||||
raw := sign(t, jwt.SigningMethodNone, jwt.UnsafeAllowNoneSignatureType, uuid.New().String(), "admin", time.Now().Add(time.Hour))
|
||||
if _, err := NewVerifier(secret).Verify(raw); err == nil {
|
||||
t.Error("none-signed token accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsNonUUIDSubject(t *testing.T) {
|
||||
raw := sign(t, jwt.SigningMethodHS256, []byte(secret), "not-a-uuid", "user", time.Now().Add(time.Hour))
|
||||
if _, err := NewVerifier(secret).Verify(raw); err == nil {
|
||||
t.Error("non-uuid subject accepted")
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
|
||||
)
|
||||
@@ -56,10 +57,24 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
worker, err := s.uc.RegisterWorker.Execute(ctx, usecase.RegisterWorkerInput{
|
||||
// Resolve the worker's trust tier from how the caller authenticated:
|
||||
// - shared service token (no requester) -> trusted lab worker
|
||||
// - verified/admin user JWT -> trusted volunteer
|
||||
// - plain user JWT -> untrusted (quarantined)
|
||||
in := usecase.RegisterWorkerInput{
|
||||
Name: req.Name,
|
||||
Capabilities: req.Capabilities,
|
||||
})
|
||||
TrustLevel: domain.WorkerTrusted,
|
||||
}
|
||||
if requester, ok := authctx.From(ctx); ok {
|
||||
id := requester.UserID
|
||||
in.OwnerID = &id
|
||||
if !requester.IsTrusted() {
|
||||
in.TrustLevel = domain.WorkerUntrusted
|
||||
}
|
||||
}
|
||||
|
||||
worker, err := s.uc.RegisterWorker.Execute(ctx, in)
|
||||
if err != nil {
|
||||
s.writeError(w, r, err)
|
||||
return
|
||||
|
||||
@@ -9,6 +9,9 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
|
||||
tokenpkg "github.com/emil28092005/SciMesh/coordinator/internal/token"
|
||||
)
|
||||
|
||||
type ctxKey string
|
||||
@@ -41,25 +44,46 @@ func newRequestID() string {
|
||||
|
||||
// withAuth enforces the shared bearer token every worker presents.
|
||||
// An empty token disables the check (local development only).
|
||||
func withAuth(token string) func(http.Handler) http.Handler {
|
||||
// withAuth authenticates a request one of two ways. Workers (and legacy
|
||||
// submitters) present the shared service token. When user-JWT auth is enabled
|
||||
// (verifier != nil), a submitter may instead present a userservice JWT; on
|
||||
// success the requester is stamped into the context so the job use cases can
|
||||
// record owner_id and enforce ownership. An empty token with no verifier
|
||||
// disables auth entirely (dev only).
|
||||
func withAuth(token string, verifier *tokenpkg.Verifier) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if token == "" {
|
||||
if token == "" && verifier == nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
presented := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
// Constant-time compare: a byte-by-byte early exit would let an
|
||||
// attacker recover the token by timing responses.
|
||||
if subtle.ConstantTimeCompare([]byte(presented), []byte(token)) != 1 {
|
||||
w.Header().Set("WWW-Authenticate", "Bearer")
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||
Error: "unauthorized",
|
||||
RequestID: requestIDFrom(r.Context()),
|
||||
})
|
||||
|
||||
// Shared service token: constant-time compare so a byte-by-byte
|
||||
// early exit cannot leak the token through response timing.
|
||||
if token != "" && subtle.ConstantTimeCompare([]byte(presented), []byte(token)) == 1 {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
|
||||
// Otherwise try a user JWT, if that path is configured.
|
||||
if verifier != nil && presented != "" {
|
||||
if claims, err := verifier.Verify(presented); err == nil {
|
||||
ctx := authctx.With(r.Context(), authctx.Requester{
|
||||
UserID: claims.UserID,
|
||||
Role: claims.Role,
|
||||
Verified: claims.Verified,
|
||||
})
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("WWW-Authenticate", "Bearer")
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse{
|
||||
Error: "unauthorized",
|
||||
RequestID: requestIDFrom(r.Context()),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,11 @@ import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/metrics"
|
||||
tokenpkg "github.com/emil28092005/SciMesh/coordinator/internal/token"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
|
||||
)
|
||||
|
||||
@@ -40,23 +43,47 @@ type Server struct {
|
||||
requestTimeout time.Duration
|
||||
heartbeatInterval time.Duration
|
||||
maxUploadBytes int64
|
||||
// verifier validates userservice JWTs. nil disables user-JWT auth, leaving
|
||||
// only the shared service token — the pre-userservice behaviour.
|
||||
verifier *tokenpkg.Verifier
|
||||
// userserviceURL is the base URL the UI proxies login/registration to. Empty
|
||||
// keeps the static basic-auth UI.
|
||||
userserviceURL string
|
||||
// httpClient makes the login/register calls to the userservice.
|
||||
httpClient *http.Client
|
||||
// metrics holds the Prometheus registry and HTTP instrumentation.
|
||||
metrics *metrics.Metrics
|
||||
// ready probes downstream dependencies (the database) for /health. Kept as
|
||||
// a func so the transport layer never imports pgx.
|
||||
ready func(context.Context) error
|
||||
}
|
||||
|
||||
func NewServer(uc UseCases, log *slog.Logger, requestTimeout, heartbeatInterval time.Duration,
|
||||
maxUploadBytes int64, ready func(context.Context) error) *Server {
|
||||
maxUploadBytes int64, jwtSecret, userserviceURL string, m *metrics.Metrics, ready func(context.Context) error) *Server {
|
||||
if m == nil {
|
||||
m = metrics.New()
|
||||
}
|
||||
return &Server{
|
||||
uc: uc,
|
||||
log: log,
|
||||
requestTimeout: requestTimeout,
|
||||
heartbeatInterval: heartbeatInterval,
|
||||
maxUploadBytes: maxUploadBytes,
|
||||
verifier: tokenpkg.NewVerifier(jwtSecret),
|
||||
userserviceURL: strings.TrimRight(userserviceURL, "/"),
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
metrics: m,
|
||||
ready: ready,
|
||||
}
|
||||
}
|
||||
|
||||
// uiSessionMode reports whether the operator UI authenticates via userservice
|
||||
// login (cookie session) rather than the static basic-auth token. It needs both
|
||||
// a verifier (to check the JWT locally) and a userservice URL (to issue it).
|
||||
func (s *Server) uiSessionMode() bool {
|
||||
return s.verifier != nil && s.userserviceURL != ""
|
||||
}
|
||||
|
||||
// Handler builds the router. Go 1.22's ServeMux matches on method and path
|
||||
// wildcards, so no third-party router is needed.
|
||||
func (s *Server) Handler(token string, uiToken ...string) http.Handler {
|
||||
@@ -77,19 +104,57 @@ func (s *Server) Handler(token string, uiToken ...string) http.Handler {
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /health", s.handleHealth)
|
||||
if len(uiToken) > 0 && uiToken[0] != "" && s.uc.Dashboard != nil {
|
||||
// Unauthenticated like /health, so a Prometheus scraper needs no credential.
|
||||
mux.Handle("GET /metrics", s.metrics.Handler())
|
||||
|
||||
hasBasicAuth := len(uiToken) > 0 && uiToken[0] != ""
|
||||
if s.uc.Dashboard != nil && (s.uiSessionMode() || hasBasicAuth) {
|
||||
ui := http.NewServeMux()
|
||||
ui.HandleFunc("GET /ui", s.handleUIHome)
|
||||
ui.HandleFunc("GET /ui/jobs/new", s.handleUINewJob)
|
||||
ui.HandleFunc("GET /ui/jobs/{job_id}", s.handleUIJob)
|
||||
ui.HandleFunc("GET /ui/api/overview", s.handleUIOverviewJSON)
|
||||
ui.HandleFunc("GET /ui/api/jobs/{job_id}", s.handleUIJobJSON)
|
||||
ui.HandleFunc("POST /ui/api/jobs/{job_id}/cancel", s.handleCancelJob)
|
||||
ui.HandleFunc("POST /ui/api/jobs/upload", s.handleUploadDataset)
|
||||
ui.HandleFunc("GET /ui/jobs/{job_id}/artifacts/{artifact_id}", s.handleUIArtifactDownload)
|
||||
ui.HandleFunc("GET /ui/jobs/{job_id}/artifacts/{artifact_id}/preview", s.handleUIArtifactPreview)
|
||||
mux.Handle("/ui", chain(ui, withRequestID, withAccessLog(s.log), withBasicAuth(uiToken[0]), withSameOrigin))
|
||||
mux.Handle("/ui/", chain(ui, withRequestID, withAccessLog(s.log), withBasicAuth(uiToken[0]), withSameOrigin))
|
||||
|
||||
// The operator application routes, all requiring an authenticated caller.
|
||||
app := []struct {
|
||||
pattern string
|
||||
handler http.HandlerFunc
|
||||
}{
|
||||
{"GET /ui", s.handleUIHome},
|
||||
{"GET /ui/jobs/new", s.handleUINewJob},
|
||||
{"GET /ui/jobs/{job_id}", s.handleUIJob},
|
||||
{"GET /ui/api/overview", s.handleUIOverviewJSON},
|
||||
{"GET /ui/api/jobs/{job_id}", s.handleUIJobJSON},
|
||||
{"POST /ui/api/jobs/{job_id}/cancel", s.handleCancelJob},
|
||||
{"POST /ui/api/jobs/upload", s.handleUploadDataset},
|
||||
{"GET /ui/jobs/{job_id}/artifacts/{artifact_id}", s.handleUIArtifactDownload},
|
||||
{"GET /ui/jobs/{job_id}/artifacts/{artifact_id}/preview", s.handleUIArtifactPreview},
|
||||
}
|
||||
|
||||
if s.uiSessionMode() {
|
||||
// Public auth pages — reachable without a session so a user can log in.
|
||||
ui.HandleFunc("GET /ui/login", s.handleUILoginForm)
|
||||
ui.HandleFunc("POST /ui/login", s.handleUILogin)
|
||||
ui.HandleFunc("GET /ui/register", s.handleUIRegisterForm)
|
||||
ui.HandleFunc("POST /ui/register", s.handleUIRegister)
|
||||
ui.HandleFunc("POST /ui/logout", s.handleUILogout)
|
||||
gate := withUISession(s.verifier)
|
||||
for _, rt := range app {
|
||||
ui.Handle(rt.pattern, gate(rt.handler))
|
||||
}
|
||||
ui.Handle("GET /ui/profile", gate(http.HandlerFunc(s.handleUIProfile)))
|
||||
// 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))
|
||||
} else {
|
||||
for _, rt := range app {
|
||||
ui.HandleFunc(rt.pattern, rt.handler)
|
||||
}
|
||||
}
|
||||
|
||||
common := []func(http.Handler) http.Handler{withRequestID, withAccessLog(s.log)}
|
||||
if !s.uiSessionMode() {
|
||||
common = append(common, withBasicAuth(uiToken[0]))
|
||||
}
|
||||
common = append(common, withSameOrigin)
|
||||
mux.Handle("/ui", chain(ui, common...))
|
||||
mux.Handle("/ui/", chain(ui, common...))
|
||||
} else {
|
||||
// More specific than the protected catch-all: UI absence is not an auth
|
||||
// failure and does not disclose that a UI feature is configured elsewhere.
|
||||
@@ -99,9 +164,10 @@ func (s *Server) Handler(token string, uiToken ...string) http.Handler {
|
||||
mux.Handle("/", chain(protected,
|
||||
withRequestID, // outermost: every response gets an ID,
|
||||
withAccessLog(s.log), // including the 401s below
|
||||
withAuth(token),
|
||||
withAuth(token, s.verifier),
|
||||
))
|
||||
return mux
|
||||
// Measure every request once, outermost, with a normalized route label.
|
||||
return s.metrics.Middleware(mux)
|
||||
}
|
||||
|
||||
// handleHealth reports readiness. It probes the database so an orchestrator
|
||||
|
||||
@@ -50,13 +50,13 @@ func newEnvWithUIToken(t *testing.T, ready func(context.Context) error, configur
|
||||
SubmitDataset: usecase.NewSubmitDataset(blobs, arts, jobs, tasks, tx, clk, 3),
|
||||
ClaimTask: usecase.NewClaimTask(tasks, jobs, work, tx, clk, lease),
|
||||
RenewLease: usecase.NewRenewLease(tasks, work, tx, clk, lease),
|
||||
CompleteTask: usecase.NewCompleteTask(tasks, jobs, arts, tx, clk),
|
||||
CompleteTask: usecase.NewCompleteTask(tasks, jobs, arts, work, memstore.NewTaskResultRepo(), tx, clk, 2),
|
||||
ReduceJob: usecase.NewReduceJob(jobs, tasks, arts, blobs, tx, clk),
|
||||
FailTask: usecase.NewFailTask(tasks, jobs, tx, clk),
|
||||
FailTask: usecase.NewFailTask(tasks, jobs, work, tx, clk),
|
||||
GetJobStatus: usecase.NewGetJobStatus(jobs, tasks),
|
||||
GetJobResult: usecase.NewGetJobResult(jobs, downloadArtifact),
|
||||
CancelJob: usecase.NewCancelJob(jobs, tasks, tx, clk),
|
||||
UploadArtifact: usecase.NewUploadArtifact(tasks, arts, blobs, tx, clk),
|
||||
UploadArtifact: usecase.NewUploadArtifact(tasks, work, arts, blobs, tx, clk),
|
||||
DownloadArtifact: downloadArtifact,
|
||||
GetTaskInput: usecase.NewGetTaskInput(tasks, arts, blobs),
|
||||
Dashboard: usecase.NewDashboard(memstore.NewUIReadRepo(jobs, tasks, work, arts)),
|
||||
@@ -68,7 +68,7 @@ func newEnvWithUIToken(t *testing.T, ready func(context.Context) error, configur
|
||||
if err != nil {
|
||||
t.Fatalf("register test worker: %v", err)
|
||||
}
|
||||
srv := coordhttp.NewServer(uc, slog.New(slog.NewTextHandler(io.Discard, nil)), 5*time.Second, 15*time.Second, 1<<30, ready)
|
||||
srv := coordhttp.NewServer(uc, slog.New(slog.NewTextHandler(io.Discard, nil)), 5*time.Second, 15*time.Second, 1<<30, "", "", nil, ready)
|
||||
ts := httptest.NewServer(srv.Handler(token, configuredUIToken))
|
||||
t.Cleanup(ts.Close)
|
||||
return &env{ts: ts, blobs: blobs, workerID: worker.ID.String()}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
{{define "admin.html"}}
|
||||
<!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>
|
||||
</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>
|
||||
|
||||
{{if .Msg}}<div class="notice ok">{{.Msg}}</div>{{end}}
|
||||
{{if .Error}}<div class="notice err">{{.Error}}</div>{{end}}
|
||||
|
||||
<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>
|
||||
</form>
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -13,7 +13,7 @@
|
||||
<main class="page">
|
||||
<header class="top">
|
||||
<div><p class="eyebrow">Local scientific compute</p><h1 class="title">SciMesh control room</h1><p class="lead">Follow the real path from a molecular TSV to a globally reduced similarity result—without reading coordinator logs.</p><div class="live"><i class="pulse"></i><span id="refresh-state">Live overview · refreshes every 2 seconds</span></div></div>
|
||||
<a class="button" href="/ui/jobs/new">+ New similarity search</a>
|
||||
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">{{if .Session}}<span class="live" style="margin-top:0">Signed in · {{.Session.Role}}</span>{{end}}{{if .Session}}<a class="button" href="/ui/profile" style="background:#23344d;color:#dce8ff;box-shadow:none">Profile</a>{{end}}{{if and .Session (eq .Session.Role "admin")}}<a class="button" href="/ui/admin" style="background:#23344d;color:#dce8ff;box-shadow:none">Admin</a>{{end}}<a class="button" href="/ui/jobs/new">+ New similarity search</a>{{if .Session}}<form method="post" action="/ui/logout" style="margin:0"><button class="button" type="submit" style="background:#23344d;color:#dce8ff;box-shadow:none">Log out</button></form>{{end}}</div>
|
||||
</header>
|
||||
<section class="summary" aria-label="Pipeline summary">
|
||||
<div class="panel"><strong>How a search becomes a result</strong><div class="pipeline"><div class="flow-step"><span><i class="dot"></i>01</span><b>Upload TSV</b><small>The coordinator validates and slices the dataset.</small></div><div class="flow-step"><span><i class="dot"></i>02</span><b>Run shards</b><small>Workers fingerprint molecules and return shard top-k CSVs.</small></div><div class="flow-step"><span><i class="dot"></i>03</span><b>Merge exactly</b><small>The coordinator ranks retained candidates deterministically.</small></div><div class="flow-step"><span><i class="dot"></i>04</span><b>Download CSV</b><small>A checksum-protected global result is ready.</small></div></div></div>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<main class="page">
|
||||
<a class="back" href="/ui">← Back to control room</a>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px"><a class="back" href="/ui">← Back to control room</a>{{if .Session}}<div style="display:flex;gap:10px;align-items:center"><a class="back" href="/ui/profile">Profile</a><form method="post" action="/ui/logout" style="margin:0"><button type="submit" style="border:0;border-radius:10px;padding:9px 14px;background:#23344d;color:#dce8ff;font:inherit;font-weight:800;cursor:pointer">Log out</button></form></div>{{end}}</div>
|
||||
<div class="top"><div><p class="eyebrow">{{workloadLabel .Workload}}</p><h1 class="title">Live pipeline</h1><p class="subtitle">One job, shown from accepted input through its final coordinator-owned scientific result.</p></div><div class="live" id="refresh-state">Live · refreshes every 2 seconds</div></div>
|
||||
<section class="panel summary"><div class="summary-top"><div><span id="status" class="badge badge-{{statusClass .Status}}">{{statusLabel .Status}}</span><p id="hint" class="hint">{{statusHint .Status}}</p></div><div id="stop-wrap" {{if not (cancellable .Status)}}class="hidden"{{end}}><button id="stop-job" class="stop" type="button">Stop unfinished shards</button><div class="live">Completed shards are preserved.</div></div></div><div class="bar"><span id="progress-bar" style="width:{{progressPercent .Completed .Failed .Cancelled .Total}}%"></span></div><p class="progress-line" id="progress">{{.Completed}} of {{.Total}} shards complete</p><div class="metrics"><div class="metric"><b id="total">{{.Total}}</b><small>total shards</small></div><div class="metric"><b id="completed">{{.Completed}}</b><small>completed</small></div><div class="metric"><b id="pending">{{.Pending}}</b><small>waiting</small></div><div class="metric"><b id="active">{{add .Leased .Running}}</b><small>with workers</small></div><div class="metric"><b id="failed">{{.Failed}}</b><small>failed</small></div><div class="metric"><b id="cancelled">{{.Cancelled}}</b><small>stopped</small></div></div></section>
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{{define "login.html"}}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sign in · 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;min-height:100vh;display:grid;place-items:center;background:radial-gradient(circle at 10% -8%,#183f77 0,transparent 32rem),#08111f}a{color:#94bdff}.card{width:min(92vw,380px);border:1px solid #294662;border-radius:15px;background:#0d1a2cdd;box-shadow:0 20px 45px #00000021;padding:28px}.eyebrow{margin:0 0 4px;color:#7baaff;font-size:.78rem;font-weight:800;letter-spacing:.14em;text-transform:uppercase}h1{margin:0 0 18px;color:#f4f8ff;font-size:1.7rem;letter-spacing:-.03em}label{display:block;margin:14px 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}.button{display:block;width:100%;margin-top:22px;border:0;border-radius:10px;padding:12px 16px;background:#67e3b8;color:#062018;font:inherit;font-weight:850;cursor:pointer}.error{margin:14px 0 0;color:#ffacba}.alt{margin:18px 0 0;color:#9fb3cf;font-size:.92rem}</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="card">
|
||||
<p class="eyebrow">SciMesh</p>
|
||||
<h1>Sign in</h1>
|
||||
<form method="post" action="/ui/login">
|
||||
<label for="email">Email</label>
|
||||
<input id="email" name="email" type="email" autocomplete="username" required autofocus>
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
||||
<button class="button" type="submit">Sign in</button>
|
||||
</form>
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
<p class="alt">No account? <a href="/ui/register">Register</a></p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,32 @@
|
||||
{{define "profile.html"}}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Profile · 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:720px;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}.btn{border:0;border-radius:9px;padding:10px 14px;font:inherit;font-weight:800;cursor:pointer;text-decoration:none}.btn-muted{background:#23344d;color:#dce8ff}.card{margin-top:24px;border:1px solid #294662;border-radius:15px;background:#0d1a2cdd;box-shadow:0 20px 45px #00000021;padding:6px 22px}.err{margin-top:24px;border-radius:10px;padding:12px 14px;background:#552334;color:#ff9bad;font-weight:700}.row{display:flex;justify-content:space-between;gap:16px;padding:15px 0;border-bottom:1px solid #1d3350}.row:last-child{border-bottom:0}.k{color:#9fb3cf}.v{color:#f2f7ff;font-weight:700;text-align:right;word-break:break-all}.mono{font-family:ui-monospace,SFMono-Regular,monospace;font-size:.9rem}.pill{display:inline-block;border-radius:999px;padding:3px 10px;font-size:.82rem;font-weight:800}.pill-yes{background:#123f34;color:#76efb5}.pill-no{background:#23344d;color:#b9cce9}.hint{margin-top:14px;color:#8ba2c2;font-size:.86rem}</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="page">
|
||||
<header class="top">
|
||||
<div><p class="eyebrow">Account</p><h1>Your profile</h1></div>
|
||||
<div style="display:flex;gap:10px;align-items:center"><a href="/ui">← Dashboard</a><form method="post" action="/ui/logout" style="margin:0"><button class="btn btn-muted" type="submit">Log out</button></form></div>
|
||||
</header>
|
||||
|
||||
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
|
||||
{{with .Profile}}
|
||||
<section class="card">
|
||||
<div class="row"><span class="k">User id</span><span class="v mono">{{.ID}}</span></div>
|
||||
<div class="row"><span class="k">Email</span><span class="v">{{.Email}}</span></div>
|
||||
<div class="row"><span class="k">Role</span><span class="v">{{.Role}}</span></div>
|
||||
<div class="row"><span class="k">Verified contributor</span><span class="v">{{if .Verified}}<span class="pill pill-yes">yes</span>{{else}}<span class="pill pill-no">no</span>{{end}}</span></div>
|
||||
<div class="row"><span class="k">Member since</span><span class="v mono">{{.CreatedAt}}</span></div>
|
||||
</section>
|
||||
<p class="hint">Your user id is what the coordinator stores as the owner of every job you submit. Give it to an admin to be promoted or verified.</p>
|
||||
{{end}}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,28 @@
|
||||
{{define "register.html"}}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Register · 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;min-height:100vh;display:grid;place-items:center;background:radial-gradient(circle at 10% -8%,#183f77 0,transparent 32rem),#08111f}a{color:#94bdff}.card{width:min(92vw,380px);border:1px solid #294662;border-radius:15px;background:#0d1a2cdd;box-shadow:0 20px 45px #00000021;padding:28px}.eyebrow{margin:0 0 4px;color:#7baaff;font-size:.78rem;font-weight:800;letter-spacing:.14em;text-transform:uppercase}h1{margin:0 0 18px;color:#f4f8ff;font-size:1.7rem;letter-spacing:-.03em}label{display:block;margin:14px 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}.hint{margin:5px 0 0;color:#92a9c6;font-size:.85rem}.button{display:block;width:100%;margin-top:22px;border:0;border-radius:10px;padding:12px 16px;background:#67e3b8;color:#062018;font:inherit;font-weight:850;cursor:pointer}.error{margin:14px 0 0;color:#ffacba}.alt{margin:18px 0 0;color:#9fb3cf;font-size:.92rem}</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="card">
|
||||
<p class="eyebrow">SciMesh</p>
|
||||
<h1>Create account</h1>
|
||||
<form method="post" action="/ui/register">
|
||||
<label for="email">Email</label>
|
||||
<input id="email" name="email" type="email" autocomplete="username" required autofocus>
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" autocomplete="new-password" minlength="8" maxlength="72" required>
|
||||
<p class="hint">At least 8 characters.</p>
|
||||
<button class="button" type="submit">Register</button>
|
||||
</form>
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
<p class="alt">Already have an account? <a href="/ui/login">Sign in</a></p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,114 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
|
||||
)
|
||||
|
||||
// adminUserActions are the userservice endpoints the admin panel may invoke, by
|
||||
// their path suffix. A whitelist so a crafted form can never proxy an arbitrary
|
||||
// path.
|
||||
var adminUserActions = map[string]bool{
|
||||
"promote": true,
|
||||
"demote": true,
|
||||
"verify": true,
|
||||
"unverify": true,
|
||||
}
|
||||
|
||||
// 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.
|
||||
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)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleUIAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
role := ""
|
||||
if req, ok := authctx.From(r.Context()); ok {
|
||||
role = req.Role
|
||||
}
|
||||
s.renderUI(w, "admin.html", map[string]any{
|
||||
"Role": role,
|
||||
"Msg": r.URL.Query().Get("msg"),
|
||||
"Error": r.URL.Query().Get("error"),
|
||||
})
|
||||
}
|
||||
|
||||
// handleUIAdminUserAction proxies a user-management action to the userservice,
|
||||
// forwarding the admin's session token so the userservice re-checks the role.
|
||||
// The user id and action come from the form, so a single static form action can
|
||||
// drive every operation.
|
||||
func (s *Server) handleUIAdminUserAction(w http.ResponseWriter, r *http.Request) {
|
||||
userID := strings.TrimSpace(r.FormValue("user_id"))
|
||||
action := r.FormValue("action")
|
||||
|
||||
if !adminUserActions[action] {
|
||||
http.Redirect(w, r, "/ui/admin?error=unknown+action", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if _, err := uuid.Parse(userID); err != nil {
|
||||
http.Redirect(w, r, "/ui/admin?error=invalid+user+id", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil {
|
||||
redirectToLogin(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
status, _, err := s.callUserserviceAuthed(r.Context(), http.MethodPost, "/users/"+userID+"/"+action, c.Value)
|
||||
if err != nil {
|
||||
s.log.Error("admin action proxy", "err", err, "action", action)
|
||||
http.Redirect(w, r, "/ui/admin?error=service+unavailable", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
switch status {
|
||||
case http.StatusNoContent:
|
||||
http.Redirect(w, r, "/ui/admin?msg="+url.QueryEscape(action+" applied"), http.StatusSeeOther)
|
||||
case http.StatusNotFound:
|
||||
http.Redirect(w, r, "/ui/admin?error=user+not+found", http.StatusSeeOther)
|
||||
case http.StatusForbidden, http.StatusUnauthorized:
|
||||
http.Redirect(w, r, "/ui/admin?error=not+authorized", http.StatusSeeOther)
|
||||
default:
|
||||
http.Redirect(w, r, "/ui/admin?error=action+failed", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
// callUserserviceAuthed makes an authenticated call to the userservice, passing
|
||||
// the caller's JWT through as a bearer token. Used for admin actions; login and
|
||||
// registration use the unauthenticated callUserservice.
|
||||
func (s *Server) callUserserviceAuthed(ctx context.Context, method, path, bearer string) (int, []byte, error) {
|
||||
// path is not attacker-controlled: the caller composes it only from a
|
||||
// uuid-validated id and an action from a fixed whitelist, and the host is
|
||||
// the operator-configured userservice — so the SSRF taint gosec sees here
|
||||
// cannot reach an arbitrary destination.
|
||||
req, err := http.NewRequestWithContext(ctx, method, s.userserviceURL+path, nil) //nolint:gosec // G704: path is validated, host is config
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
|
||||
resp, err := s.httpClient.Do(req) //nolint:gosec // G704: see above
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return resp.StatusCode, body, nil
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
|
||||
)
|
||||
|
||||
func adminReq(t *testing.T, role string) *http.Request {
|
||||
t.Helper()
|
||||
req := newReq(http.MethodGet, "/ui/admin", nil)
|
||||
return req.WithContext(authctx.With(context.Background(), authctx.Requester{UserID: uuid.New(), Role: role}))
|
||||
}
|
||||
|
||||
func TestRequireAdminAllowsAdminOnly(t *testing.T) {
|
||||
reached := false
|
||||
h := requireAdmin(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true }))
|
||||
|
||||
// Admin passes through.
|
||||
h.ServeHTTP(httptest.NewRecorder(), adminReq(t, "admin"))
|
||||
if !reached {
|
||||
t.Error("admin must reach the handler")
|
||||
}
|
||||
|
||||
// Plain user is redirected to the dashboard.
|
||||
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"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUserActionForwardsBearer(t *testing.T) {
|
||||
targetID := uuid.NewString()
|
||||
var gotAuth, gotPath string
|
||||
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
gotPath = r.URL.Path
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer stub.Close()
|
||||
s := newLoginServer(stub)
|
||||
|
||||
req := newReq(http.MethodPost, "/ui/admin/user-action",
|
||||
strings.NewReader(url.Values{"user_id": {targetID}, "action": {"promote"}}.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "admin.jwt.token"})
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleUIAdminUserAction(rec, req)
|
||||
|
||||
if gotAuth != "Bearer admin.jwt.token" {
|
||||
t.Errorf("forwarded auth = %q, want the admin bearer", gotAuth)
|
||||
}
|
||||
if gotPath != "/users/"+targetID+"/promote" {
|
||||
t.Errorf("forwarded path = %q", gotPath)
|
||||
}
|
||||
if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "msg=") {
|
||||
t.Errorf("got %d -> %q, want 303 with a success msg", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUserActionRejectsUnknownAction(t *testing.T) {
|
||||
s := newLoginServer(httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
t.Fatal("userservice must not be called for an invalid action")
|
||||
})))
|
||||
req := newReq(http.MethodPost, "/ui/admin/user-action",
|
||||
strings.NewReader(url.Values{"user_id": {uuid.NewString()}, "action": {"delete"}}.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "x"})
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleUIAdminUserAction(rec, req)
|
||||
if !strings.Contains(rec.Header().Get("Location"), "error=") {
|
||||
t.Errorf("unknown action redirect = %q, want an error", rec.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUserActionRejectsBadID(t *testing.T) {
|
||||
s := newLoginServer(httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
t.Fatal("userservice must not be called for an invalid id")
|
||||
})))
|
||||
req := newReq(http.MethodPost, "/ui/admin/user-action",
|
||||
strings.NewReader(url.Values{"user_id": {"not-a-uuid"}, "action": {"promote"}}.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "x"})
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleUIAdminUserAction(rec, req)
|
||||
if !strings.Contains(rec.Header().Get("Location"), "error=") {
|
||||
t.Errorf("bad id redirect = %q, want an error", rec.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardAdminLinkOnlyForAdmin(t *testing.T) {
|
||||
admin := render(t, "dashboard.html", usecase.DashboardView{Session: &usecase.SessionView{Role: "admin"}})
|
||||
if !strings.Contains(admin, "/ui/admin") {
|
||||
t.Error("admin must see the Admin link")
|
||||
}
|
||||
user := render(t, "dashboard.html", usecase.DashboardView{Session: &usecase.SessionView{Role: "user"}})
|
||||
if strings.Contains(user, "/ui/admin") {
|
||||
t.Error("a plain user must not see the Admin link")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
|
||||
tokenpkg "github.com/emil28092005/SciMesh/coordinator/internal/token"
|
||||
)
|
||||
|
||||
// sessionCookie holds the userservice JWT for the operator UI. It is httpOnly so
|
||||
// page scripts cannot read the token, and scoped to /ui so it never rides along
|
||||
// with worker API calls.
|
||||
const sessionCookie = "scimesh_session"
|
||||
|
||||
// withUISession gates the operator UI on a valid userservice session cookie.
|
||||
// A missing or invalid token redirects to the login page rather than returning
|
||||
// 401, because the caller here is a browser, not an API client. On success it
|
||||
// stamps the requester so downstream handlers can scope views by owner.
|
||||
func withUISession(v tokenVerifier) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil || c.Value == "" {
|
||||
redirectToLogin(w, r)
|
||||
return
|
||||
}
|
||||
claims, err := v.Verify(c.Value)
|
||||
if err != nil {
|
||||
// Expired or tampered: drop the stale cookie and re-authenticate.
|
||||
clearSessionCookie(w, r)
|
||||
redirectToLogin(w, r)
|
||||
return
|
||||
}
|
||||
ctx := authctx.With(r.Context(), authctx.Requester{
|
||||
UserID: claims.UserID,
|
||||
Role: claims.Role,
|
||||
Verified: claims.Verified,
|
||||
})
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// tokenVerifier is satisfied by *token.Verifier; taking an interface keeps the
|
||||
// UI auth testable with a stub.
|
||||
type tokenVerifier interface {
|
||||
Verify(raw string) (tokenpkg.Claims, error)
|
||||
}
|
||||
|
||||
func (s *Server) handleUILoginForm(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderUI(w, "login.html", map[string]any{"Error": r.URL.Query().Get("error")})
|
||||
}
|
||||
|
||||
func (s *Server) handleUIRegisterForm(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderUI(w, "register.html", map[string]any{"Error": r.URL.Query().Get("error")})
|
||||
}
|
||||
|
||||
// handleUILogin exchanges the submitted credentials for a userservice token and
|
||||
// stores it in the session cookie. The coordinator never sees or stores the
|
||||
// password beyond forwarding it once.
|
||||
func (s *Server) handleUILogin(w http.ResponseWriter, r *http.Request) {
|
||||
email, password := r.FormValue("email"), r.FormValue("password")
|
||||
|
||||
status, body, err := s.callUserservice(r.Context(), "/login", email, password)
|
||||
if err != nil {
|
||||
s.log.Error("userservice login call", "err", err)
|
||||
http.Redirect(w, r, "/ui/login?error=service+unavailable", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
http.Redirect(w, r, "/ui/login?error=invalid+email+or+password", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &resp); err != nil || resp.Token == "" {
|
||||
http.Redirect(w, r, "/ui/login?error=service+unavailable", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
setSessionCookie(w, r, resp.Token)
|
||||
http.Redirect(w, r, "/ui", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleUIRegister creates an account through the userservice, then sends the
|
||||
// user to the login page. The new account is a plain user until an admin
|
||||
// promotes or verifies it.
|
||||
func (s *Server) handleUIRegister(w http.ResponseWriter, r *http.Request) {
|
||||
email, password := r.FormValue("email"), r.FormValue("password")
|
||||
|
||||
status, _, err := s.callUserservice(r.Context(), "/register", email, password)
|
||||
if err != nil {
|
||||
s.log.Error("userservice register call", "err", err)
|
||||
http.Redirect(w, r, "/ui/register?error=service+unavailable", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
switch status {
|
||||
case http.StatusCreated:
|
||||
http.Redirect(w, r, "/ui/login?error=registered,+please+log+in", http.StatusSeeOther)
|
||||
case http.StatusConflict:
|
||||
http.Redirect(w, r, "/ui/register?error=email+already+registered", http.StatusSeeOther)
|
||||
default:
|
||||
http.Redirect(w, r, "/ui/register?error=invalid+email+or+password", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleUILogout(w http.ResponseWriter, r *http.Request) {
|
||||
clearSessionCookie(w, r)
|
||||
http.Redirect(w, r, "/ui/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// callUserservice POSTs credentials to the userservice and returns its status
|
||||
// and body. It is the only runtime dependency on the userservice — login and
|
||||
// registration; token verification stays local.
|
||||
func (s *Server) callUserservice(ctx context.Context, path, email, password string) (int, []byte, error) {
|
||||
payload, _ := json.Marshal(map[string]string{"email": email, "password": password})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.userserviceURL+path, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
// Cap the response; login/register bodies are tiny.
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return resp.StatusCode, body, nil
|
||||
}
|
||||
|
||||
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string) {
|
||||
// Secure is set under TLS; a local demo runs plain HTTP, where forcing
|
||||
// Secure would stop the browser from ever sending the cookie back.
|
||||
http.SetCookie(w, &http.Cookie{ //nolint:gosec // G124: Secure follows r.TLS by design
|
||||
Name: sessionCookie,
|
||||
Value: token,
|
||||
Path: "/ui",
|
||||
HttpOnly: true,
|
||||
Secure: r.TLS != nil,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: time.Now().Add(24 * time.Hour),
|
||||
})
|
||||
}
|
||||
|
||||
func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{ //nolint:gosec // G124: Secure follows r.TLS by design
|
||||
Name: sessionCookie,
|
||||
Value: "",
|
||||
Path: "/ui",
|
||||
HttpOnly: true,
|
||||
Secure: r.TLS != nil,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
|
||||
func redirectToLogin(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/ui/login", http.StatusSeeOther)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
|
||||
tokenpkg "github.com/emil28092005/SciMesh/coordinator/internal/token"
|
||||
)
|
||||
|
||||
// newReq builds a request carrying a context, which http.NewRequestWithContext
|
||||
// provides on go1.22 (httptest.NewRequestWithContext needs go1.23).
|
||||
func newReq(method, target string, body io.Reader) *http.Request {
|
||||
req, err := http.NewRequestWithContext(context.Background(), method, target, body)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
type stubVerifier struct {
|
||||
claims tokenpkg.Claims
|
||||
err error
|
||||
}
|
||||
|
||||
func (s stubVerifier) Verify(string) (tokenpkg.Claims, error) { return s.claims, s.err }
|
||||
|
||||
func TestWithUISessionRedirectsWithoutCookie(t *testing.T) {
|
||||
h := withUISession(stubVerifier{})(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
t.Fatal("handler must not run without a session")
|
||||
}))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, newReq(http.MethodGet, "/ui", nil))
|
||||
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("got %d, want 303", rec.Code)
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); loc != "/ui/login" {
|
||||
t.Errorf("redirect = %q, want /ui/login", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithUISessionAcceptsValidCookieAndStampsRequester(t *testing.T) {
|
||||
id := uuid.New()
|
||||
verifier := stubVerifier{claims: tokenpkg.Claims{UserID: id, Role: "admin", Verified: true}}
|
||||
|
||||
var gotReq authctx.Requester
|
||||
var ok bool
|
||||
h := withUISession(verifier)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
gotReq, ok = authctx.From(r.Context())
|
||||
}))
|
||||
|
||||
req := newReq(http.MethodGet, "/ui", nil)
|
||||
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "valid.jwt"})
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if !ok || gotReq.UserID != id || gotReq.Role != "admin" || !gotReq.Verified {
|
||||
t.Errorf("requester = %+v (ok=%v), want id=%v admin verified", gotReq, ok, id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithUISessionClearsInvalidCookie(t *testing.T) {
|
||||
h := withUISession(stubVerifier{err: errors.New("expired")})(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
t.Fatal("handler must not run with an invalid token")
|
||||
}))
|
||||
req := newReq(http.MethodGet, "/ui", nil)
|
||||
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "stale.jwt"})
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("got %d, want 303", rec.Code)
|
||||
}
|
||||
if c := rec.Result().Cookies(); len(c) == 0 || c[0].MaxAge >= 0 {
|
||||
t.Error("stale cookie must be cleared (MaxAge < 0)")
|
||||
}
|
||||
}
|
||||
|
||||
// newLoginServer builds a Server whose userservice calls hit stub.
|
||||
func newLoginServer(stub *httptest.Server) *Server {
|
||||
return &Server{
|
||||
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
userserviceURL: strings.TrimRight(stub.URL, "/"),
|
||||
httpClient: stub.Client(),
|
||||
}
|
||||
}
|
||||
|
||||
func postForm(path string, form url.Values) *http.Request {
|
||||
req := newReq(http.MethodPost, path, strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
return req
|
||||
}
|
||||
|
||||
func TestHandleUILoginSetsCookieOnSuccess(t *testing.T) {
|
||||
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/login" {
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"token":"issued.jwt.here"}`))
|
||||
}))
|
||||
defer stub.Close()
|
||||
s := newLoginServer(stub)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleUILogin(rec, postForm("/ui/login", url.Values{"email": {"a@b.com"}, "password": {"password123"}}))
|
||||
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/ui" {
|
||||
t.Fatalf("got %d -> %q, want 303 -> /ui", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
cookies := rec.Result().Cookies()
|
||||
if len(cookies) == 0 || cookies[0].Name != sessionCookie || cookies[0].Value != "issued.jwt.here" {
|
||||
t.Errorf("session cookie not set: %+v", cookies)
|
||||
}
|
||||
if !cookies[0].HttpOnly {
|
||||
t.Error("session cookie must be httpOnly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUILoginRejectsBadCredentials(t *testing.T) {
|
||||
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
defer stub.Close()
|
||||
s := newLoginServer(stub)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleUILogin(rec, postForm("/ui/login", url.Values{"email": {"a@b.com"}, "password": {"wrong"}}))
|
||||
|
||||
if rec.Code != http.StatusSeeOther || !strings.HasPrefix(rec.Header().Get("Location"), "/ui/login?error=") {
|
||||
t.Fatalf("got %d -> %q, want 303 -> /ui/login?error=", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
if len(rec.Result().Cookies()) != 0 {
|
||||
t.Error("no cookie must be set on failed login")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUIRegisterConflict(t *testing.T) {
|
||||
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
}))
|
||||
defer stub.Close()
|
||||
s := newLoginServer(stub)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleUIRegister(rec, postForm("/ui/register", url.Values{"email": {"dup@b.com"}, "password": {"password123"}}))
|
||||
|
||||
if got := rec.Header().Get("Location"); !strings.Contains(got, "already+registered") {
|
||||
t.Errorf("register conflict redirect = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUILogoutClearsCookie(t *testing.T) {
|
||||
s := &Server{log: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleUILogout(rec, newReq(http.MethodPost, "/ui/logout", nil))
|
||||
|
||||
if rec.Header().Get("Location") != "/ui/login" {
|
||||
t.Errorf("logout redirect = %q", rec.Header().Get("Location"))
|
||||
}
|
||||
c := rec.Result().Cookies()
|
||||
if len(c) == 0 || c[0].MaxAge >= 0 {
|
||||
t.Error("logout must clear the session cookie")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
|
||||
)
|
||||
|
||||
func render(t *testing.T, name string, data any) string {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if err := uiTemplates.ExecuteTemplate(&buf, name, data); err != nil {
|
||||
t.Fatalf("render %s: %v", name, err)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestDashboardLogoutOnlyInSession(t *testing.T) {
|
||||
withSession := render(t, "dashboard.html", usecase.DashboardView{Session: &usecase.SessionView{Role: "admin"}})
|
||||
if !strings.Contains(withSession, "/ui/logout") || !strings.Contains(withSession, "Log out") {
|
||||
t.Error("dashboard must show a logout control in session mode")
|
||||
}
|
||||
|
||||
noSession := render(t, "dashboard.html", usecase.DashboardView{})
|
||||
if strings.Contains(noSession, "/ui/logout") {
|
||||
t.Error("dashboard must not show logout under basic auth (no session)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobLogoutOnlyInSession(t *testing.T) {
|
||||
withSession := render(t, "job.html", usecase.JobDetailView{Session: &usecase.SessionView{Role: "user"}})
|
||||
if !strings.Contains(withSession, "/ui/logout") {
|
||||
t.Error("job page must show a logout control in session mode")
|
||||
}
|
||||
|
||||
noSession := render(t, "job.html", usecase.JobDetailView{})
|
||||
if strings.Contains(noSession, "/ui/logout") {
|
||||
t.Error("job page must not show logout under basic auth (no session)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// profileView is the account data shown on the profile page, mirroring the
|
||||
// userservice /me response.
|
||||
type profileView struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
Verified bool `json:"verified"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// handleUIProfile shows the signed-in user's own account. It proxies the
|
||||
// session token to the userservice /me endpoint, which is the authority on the
|
||||
// account (email and created_at are not in the JWT).
|
||||
func (s *Server) handleUIProfile(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, "/me", c.Value)
|
||||
if err != nil {
|
||||
s.log.Error("profile /me proxy", "err", err)
|
||||
s.renderUI(w, "profile.html", map[string]any{"Error": "userservice unavailable"})
|
||||
return
|
||||
}
|
||||
if status == http.StatusUnauthorized {
|
||||
clearSessionCookie(w, r)
|
||||
redirectToLogin(w, r)
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
s.renderUI(w, "profile.html", map[string]any{"Error": "could not load your account"})
|
||||
return
|
||||
}
|
||||
|
||||
var p profileView
|
||||
if err := json.Unmarshal(body, &p); err != nil {
|
||||
s.renderUI(w, "profile.html", map[string]any{"Error": "could not read your account"})
|
||||
return
|
||||
}
|
||||
s.renderUI(w, "profile.html", map[string]any{"Profile": p})
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProfileProxiesMe(t *testing.T) {
|
||||
var gotAuth, gotPath string
|
||||
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth, gotPath = r.Header.Get("Authorization"), r.URL.Path
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"id":"11111111-1111-1111-1111-111111111111","email":"me@example.com","role":"user","verified":false,"created_at":"2026-07-26T00:00:00Z"}`))
|
||||
}))
|
||||
defer stub.Close()
|
||||
s := newLoginServer(stub)
|
||||
|
||||
req := newReq(http.MethodGet, "/ui/profile", nil)
|
||||
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "my.jwt"})
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleUIProfile(rec, req)
|
||||
|
||||
if gotAuth != "Bearer my.jwt" || gotPath != "/me" {
|
||||
t.Fatalf("proxy: auth=%q path=%q", gotAuth, gotPath)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "me@example.com") || !strings.Contains(body, "11111111-1111-1111-1111-111111111111") {
|
||||
t.Error("profile page must show the email and id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileRedirectsWithoutCookie(t *testing.T) {
|
||||
s := newLoginServer(httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
t.Fatal("must not call userservice without a session")
|
||||
})))
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleUIProfile(rec, newReq(http.MethodGet, "/ui/profile", nil))
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Errorf("no cookie: got %d, want 303 redirect", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -12,18 +12,22 @@ import (
|
||||
// UploadArtifact stores a worker's partial-result bytes and records the metadata.
|
||||
type UploadArtifact struct {
|
||||
tasks TaskRepository
|
||||
workers WorkerRepository
|
||||
artifacts ArtifactRepository
|
||||
blobs BlobStore
|
||||
tx TxManager
|
||||
clk Clock
|
||||
}
|
||||
|
||||
func NewUploadArtifact(tasks TaskRepository, artifacts ArtifactRepository,
|
||||
func NewUploadArtifact(tasks TaskRepository, workers WorkerRepository, artifacts ArtifactRepository,
|
||||
blobs BlobStore, tx TxManager, clk Clock) *UploadArtifact {
|
||||
return &UploadArtifact{tasks: tasks, artifacts: artifacts, blobs: blobs, tx: tx, clk: clk}
|
||||
return &UploadArtifact{tasks: tasks, workers: workers, artifacts: artifacts, blobs: blobs, tx: tx, clk: clk}
|
||||
}
|
||||
|
||||
func (uc *UploadArtifact) Execute(ctx context.Context, in UploadArtifactInput) (*domain.Artifact, error) {
|
||||
if err := authorizeWorkerOwner(ctx, uc.workers, in.WorkerID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task, err := uc.tasks.Get(ctx, in.TaskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"io"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||
)
|
||||
|
||||
// Use-case boundary types. Adapters map their wire formats onto these, so the
|
||||
@@ -28,6 +30,11 @@ type ChunkInput struct {
|
||||
type RegisterWorkerInput struct {
|
||||
Name string
|
||||
Capabilities []string
|
||||
// OwnerID is the userservice user registering this worker; nil for a
|
||||
// shared-token registration. TrustLevel is resolved by the transport layer
|
||||
// from how the caller authenticated.
|
||||
OwnerID *uuid.UUID
|
||||
TrustLevel domain.WorkerTrust
|
||||
}
|
||||
|
||||
type ClaimTaskInput struct {
|
||||
|
||||
@@ -53,6 +53,7 @@ func (uc *CreateJob) Execute(ctx context.Context, in CreateJobInput) (*domain.Jo
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
job.OwnerID = ownerFromContext(ctx)
|
||||
|
||||
err = uc.tx.WithinTx(ctx, func(ctx context.Context) error {
|
||||
if err := uc.jobs.Insert(ctx, job); err != nil {
|
||||
@@ -97,6 +98,9 @@ func (uc *CancelJob) Execute(ctx context.Context, jobID uuid.UUID) (int64, error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := authorizeJobAccess(ctx, job); err != nil {
|
||||
return err
|
||||
}
|
||||
if job.Status == domain.JobCancelled {
|
||||
return nil
|
||||
}
|
||||
@@ -132,6 +136,9 @@ func (uc *GetJobStatus) Execute(ctx context.Context, jobID uuid.UUID) (domain.Jo
|
||||
if err != nil {
|
||||
return domain.JobProgress{}, err
|
||||
}
|
||||
if err := authorizeJobAccess(ctx, job); err != nil {
|
||||
return domain.JobProgress{}, err
|
||||
}
|
||||
counts, err := uc.tasks.CountByStatus(ctx, jobID)
|
||||
if err != nil {
|
||||
return domain.JobProgress{}, err
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||
)
|
||||
|
||||
// ownerFromContext returns the authenticated user id to stamp on a new job, or
|
||||
// nil when the request was not authenticated as a user — worker or legacy
|
||||
// traffic, or user-JWT auth disabled. A nil owner is stored as NULL.
|
||||
func ownerFromContext(ctx context.Context) *uuid.UUID {
|
||||
if r, ok := authctx.From(ctx); ok {
|
||||
id := r.UserID
|
||||
return &id
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// uiOwnerFilter returns the owner a UI listing must be restricted to: nil for an
|
||||
// operator/admin or an unauthenticated (basic-auth) session, which see all jobs,
|
||||
// or the caller's id for a plain user, who sees only their own.
|
||||
func uiOwnerFilter(ctx context.Context) *uuid.UUID {
|
||||
r, ok := authctx.From(ctx)
|
||||
if !ok || r.IsAdmin() {
|
||||
return nil
|
||||
}
|
||||
id := r.UserID
|
||||
return &id
|
||||
}
|
||||
|
||||
// authorizeJobAccess enforces that a non-admin user may only act on their own
|
||||
// job. It returns ErrJobNotFound — not a 403 — on a mismatch, so the response
|
||||
// never reveals that another user's job exists.
|
||||
//
|
||||
// Requests with no authenticated user (worker/legacy traffic, or JWT auth
|
||||
// disabled) are not restricted here: the shared service token already gated
|
||||
// them, and worker endpoints legitimately operate across all jobs.
|
||||
func authorizeJobAccess(ctx context.Context, job *domain.Job) error {
|
||||
r, ok := authctx.From(ctx)
|
||||
if !ok || r.IsAdmin() {
|
||||
return nil
|
||||
}
|
||||
if job.OwnerID == nil || *job.OwnerID != r.UserID {
|
||||
return domain.ErrJobNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||
)
|
||||
|
||||
func TestOwnerFromContext(t *testing.T) {
|
||||
if ownerFromContext(context.Background()) != nil {
|
||||
t.Error("no requester must yield a nil owner")
|
||||
}
|
||||
id := uuid.New()
|
||||
ctx := authctx.With(context.Background(), authctx.Requester{UserID: id, Role: "user"})
|
||||
got := ownerFromContext(ctx)
|
||||
if got == nil || *got != id {
|
||||
t.Errorf("owner = %v, want %v", got, id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizeJobAccess(t *testing.T) {
|
||||
owner := uuid.New()
|
||||
other := uuid.New()
|
||||
job := &domain.Job{ID: uuid.New(), OwnerID: &owner}
|
||||
|
||||
ctxOf := func(id uuid.UUID, role string) context.Context {
|
||||
return authctx.With(context.Background(), authctx.Requester{UserID: id, Role: role})
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
wantErr bool
|
||||
}{
|
||||
{"no requester (worker/legacy) allowed", context.Background(), false},
|
||||
{"owner allowed", ctxOf(owner, "user"), false},
|
||||
{"admin allowed", ctxOf(other, "admin"), false},
|
||||
{"non-owner denied", ctxOf(other, "user"), true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := authorizeJobAccess(tc.ctx, job)
|
||||
if tc.wantErr {
|
||||
if !errors.Is(err, domain.ErrJobNotFound) {
|
||||
t.Errorf("got %v, want ErrJobNotFound", err)
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizeJobAccessNilOwner(t *testing.T) {
|
||||
// A legacy job with no owner must not be readable by an arbitrary user.
|
||||
job := &domain.Job{ID: uuid.New(), OwnerID: nil}
|
||||
ctx := authctx.With(context.Background(), authctx.Requester{UserID: uuid.New(), Role: "user"})
|
||||
if err := authorizeJobAccess(ctx, job); !errors.Is(err, domain.ErrJobNotFound) {
|
||||
t.Errorf("got %v, want ErrJobNotFound", err)
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,15 @@ type ClaimFilter struct {
|
||||
Owner string // worker ID taking the lease
|
||||
Now time.Time
|
||||
LeaseUntil time.Time
|
||||
// VoterOwner, when set, excludes tasks this owner has already voted on, so
|
||||
// an untrusted worker never verifies its own chunk twice.
|
||||
VoterOwner *uuid.UUID
|
||||
}
|
||||
|
||||
// TaskResultRepository records and tallies quorum votes for untrusted results.
|
||||
type TaskResultRepository interface {
|
||||
RecordVote(ctx context.Context, taskID, ownerID uuid.UUID, sha256 string, artifactID uuid.UUID) error
|
||||
CountAgreeing(ctx context.Context, taskID uuid.UUID, sha256 string) (int, error)
|
||||
}
|
||||
|
||||
// TaskRepository persists tasks.
|
||||
|
||||
@@ -53,6 +53,11 @@ func (p *PreviewArtifact) Execute(ctx context.Context, jobID, artifactID uuid.UU
|
||||
if err != nil {
|
||||
return ArtifactPreviewView{}, err
|
||||
}
|
||||
// Another user's job (and not admin): report not-found, matching the
|
||||
// artifact-absent response so nothing about it leaks.
|
||||
if err := authorizeJobAccess(ctx, job); err != nil {
|
||||
return ArtifactPreviewView{}, domain.ErrArtifactNotFound
|
||||
}
|
||||
artifacts, err := p.read.ListArtifactsByJob(ctx, jobID)
|
||||
if err != nil {
|
||||
return ArtifactPreviewView{}, err
|
||||
|
||||
@@ -126,6 +126,9 @@ func (uc *GetJobResult) Execute(ctx context.Context, jobID uuid.UUID) (*domain.A
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := authorizeJobAccess(ctx, job); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if job.Status != domain.JobCompleted || job.ResultArtifactID == nil {
|
||||
return nil, nil, domain.ErrArtifactNotFound
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -46,11 +47,25 @@ func (uc *ClaimTask) Execute(ctx context.Context, in ClaimTaskInput) (*domain.Cl
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
workloads := in.Workloads
|
||||
var voterOwner *uuid.UUID
|
||||
if workerID, err := uuid.Parse(in.WorkerID); err == nil {
|
||||
worker, err := uc.workers.Get(ctx, workerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Bind the caller to the worker it claims as. A JWT-authenticated
|
||||
// volunteer may operate only its own workers; without this the trust
|
||||
// tier would be read off a caller-supplied worker_id, letting anyone who
|
||||
// knows a trusted worker's id claim as it. A shared-token caller (no
|
||||
// requester) is a lab operator and may act as any worker.
|
||||
if err := authorizeWorkerOwner(ctx, uc.workers, in.WorkerID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// An untrusted volunteer may claim, but never a chunk its owner has
|
||||
// already voted on — so quorum needs genuinely independent computations.
|
||||
if worker.TrustLevel == domain.WorkerUntrusted {
|
||||
voterOwner = worker.OwnerID
|
||||
}
|
||||
// Never trust caller-supplied capabilities: registration is the durable
|
||||
// worker identity and its allowlist.
|
||||
workloads = worker.Capabilities
|
||||
@@ -71,6 +86,7 @@ func (uc *ClaimTask) Execute(ctx context.Context, in ClaimTaskInput) (*domain.Cl
|
||||
Owner: in.WorkerID,
|
||||
Now: now,
|
||||
LeaseUntil: now.Add(uc.leaseDuration),
|
||||
VoterOwner: voterOwner,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -106,6 +122,9 @@ func NewRenewLease(tasks TaskRepository, workers WorkerRepository, tx TxManager,
|
||||
// locked: two concurrent heartbeats must not interleave into a lost update.
|
||||
// Whether the caller may renew at all is decided by the entity, not here.
|
||||
func (uc *RenewLease) Execute(ctx context.Context, in RenewLeaseInput) (*domain.ClaimedTask, error) {
|
||||
if err := authorizeWorkerOwner(ctx, uc.workers, in.WorkerID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var claimed domain.ClaimedTask
|
||||
|
||||
err := uc.tx.WithinTx(ctx, func(ctx context.Context) error {
|
||||
@@ -141,13 +160,22 @@ type CompleteTask struct {
|
||||
tasks TaskRepository
|
||||
jobs JobRepository
|
||||
artifacts ArtifactRepository
|
||||
workers WorkerRepository
|
||||
results TaskResultRepository
|
||||
tx TxManager
|
||||
clock Clock
|
||||
// quorum is how many distinct owners must agree on an untrusted result
|
||||
// before it is accepted; a trusted worker's result is accepted directly.
|
||||
quorum int
|
||||
}
|
||||
|
||||
func NewCompleteTask(tasks TaskRepository, jobs JobRepository, artifacts ArtifactRepository,
|
||||
tx TxManager, clock Clock) *CompleteTask {
|
||||
return &CompleteTask{tasks: tasks, jobs: jobs, artifacts: artifacts, tx: tx, clock: clock}
|
||||
workers WorkerRepository, results TaskResultRepository, tx TxManager, clock Clock, quorum int) *CompleteTask {
|
||||
if quorum < 1 {
|
||||
quorum = 2
|
||||
}
|
||||
return &CompleteTask{tasks: tasks, jobs: jobs, artifacts: artifacts, workers: workers,
|
||||
results: results, tx: tx, clock: clock, quorum: quorum}
|
||||
}
|
||||
|
||||
// Execute applies the result and, when that was the job's last outstanding
|
||||
@@ -157,6 +185,9 @@ func NewCompleteTask(tasks TaskRepository, jobs JobRepository, artifacts Artifac
|
||||
// Lease ownership, staleness, and idempotent replays are all decided by
|
||||
// Task.CompleteWith; this use case only orchestrates.
|
||||
func (uc *CompleteTask) Execute(ctx context.Context, in CompleteTaskInput) (*domain.Task, error) {
|
||||
if err := authorizeWorkerOwner(ctx, uc.workers, in.WorkerID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out *domain.Task
|
||||
|
||||
err := uc.tx.WithinTx(ctx, func(ctx context.Context) error {
|
||||
@@ -166,24 +197,35 @@ func (uc *CompleteTask) Execute(ctx context.Context, in CompleteTaskInput) (*dom
|
||||
}
|
||||
// Rule 10: never trust a worker-supplied artifact reference. The result
|
||||
// must be an artifact the coordinator itself stored for *this* task.
|
||||
if err := uc.verifyResultArtifact(ctx, in.TaskID, in.Attempt, in.ResultArtifactID); err != nil {
|
||||
art, err := uc.verifyResultArtifact(ctx, in.TaskID, in.Attempt, in.ResultArtifactID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
trusted, ownerID, err := uc.workerTrust(ctx, in.WorkerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := uc.clock.Now()
|
||||
|
||||
// Untrusted (volunteer) worker: record a vote and only complete once a
|
||||
// quorum of distinct owners agree; otherwise return the task to the queue.
|
||||
if !trusted {
|
||||
return uc.recordVote(ctx, task, in, art, ownerID, now, &out)
|
||||
}
|
||||
|
||||
// Trusted worker (lab token, verified, or admin): accept directly.
|
||||
before := task.Version
|
||||
if err := task.CompleteWith(in.ResultArtifactID, in.Metrics,
|
||||
in.WorkerID, in.Attempt, now); err != nil {
|
||||
if err := task.CompleteWith(in.ResultArtifactID, in.Metrics, in.WorkerID, in.Attempt, now); err != nil {
|
||||
return err
|
||||
}
|
||||
out = task
|
||||
|
||||
// A replay of an already-recorded result leaves the entity untouched.
|
||||
// Writing anyway would fail the optimistic-concurrency guard (the stored
|
||||
// version already equals ours) and turn an idempotent call into a 409.
|
||||
if task.Version == before {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := uc.tasks.Update(ctx, task); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -195,36 +237,105 @@ func (uc *CompleteTask) Execute(ctx context.Context, in CompleteTaskInput) (*dom
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// verifyResultArtifact enforces that the referenced artifact was stored by the
|
||||
// coordinator for this exact task. It stops a worker from completing task B with
|
||||
// an artifact it uploaded for task A, and from naming an id that isn't a result.
|
||||
func (uc *CompleteTask) verifyResultArtifact(ctx context.Context, taskID uuid.UUID, attempt int, artifactID uuid.UUID) error {
|
||||
art, err := uc.artifacts.Get(ctx, artifactID)
|
||||
// recordVote handles an untrusted result: it stores the vote, then completes the
|
||||
// task when the submitter's result hash has reached quorum, or returns the task
|
||||
// to the queue so another owner can compute it independently.
|
||||
func (uc *CompleteTask) recordVote(ctx context.Context, task *domain.Task, in CompleteTaskInput,
|
||||
art *domain.Artifact, ownerID uuid.UUID, now time.Time, out **domain.Task) error {
|
||||
|
||||
*out = task
|
||||
if task.Status == domain.TaskCompleted {
|
||||
return nil // already settled by an earlier quorum; nothing to record
|
||||
}
|
||||
if err := uc.results.RecordVote(ctx, task.ID, ownerID, art.SHA256, in.ResultArtifactID); err != nil {
|
||||
return err
|
||||
}
|
||||
agree, err := uc.results.CountAgreeing(ctx, task.ID, art.SHA256)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if art.TaskID == nil || *art.TaskID != taskID || art.Attempt == nil || *art.Attempt != attempt || art.Kind != domain.ArtifactPartialResult {
|
||||
return domain.ErrResultConflict
|
||||
|
||||
if agree >= uc.quorum {
|
||||
// The submitter's own (already verified) artifact carries the winning
|
||||
// hash, so complete with it.
|
||||
if err := task.CompleteWith(in.ResultArtifactID, in.Metrics, in.WorkerID, in.Attempt, now); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := task.ReleaseAfterVote(in.WorkerID, in.Attempt, now); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
||||
if err := uc.tasks.Update(ctx, task); err != nil {
|
||||
return err
|
||||
}
|
||||
return syncJobStatus(ctx, uc.jobs, uc.tasks, task.JobID, now)
|
||||
}
|
||||
|
||||
// workerTrust reports whether the worker's results are accepted directly, and
|
||||
// the owner to attribute a vote to when they are not.
|
||||
func (uc *CompleteTask) workerTrust(ctx context.Context, workerID string) (trusted bool, ownerID uuid.UUID, err error) {
|
||||
// When the worker can't be resolved, default to trusted — the pre-quorum
|
||||
// behaviour. This is safe because completing a task requires holding its
|
||||
// lease, and the lease owner is always a real registered worker whose trust
|
||||
// is therefore known; only an untrusted worker ever takes the quorum path.
|
||||
id, err := uuid.Parse(workerID)
|
||||
if err != nil {
|
||||
// An unparseable worker id means the worker can't be resolved; fall back
|
||||
// to the trusted default rather than surfacing the parse error.
|
||||
return true, uuid.Nil, nil //nolint:nilerr // unresolvable worker → trusted (pre-quorum default)
|
||||
}
|
||||
w, err := uc.workers.Get(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrWorkerNotFound) {
|
||||
return true, uuid.Nil, nil
|
||||
}
|
||||
return false, uuid.Nil, err
|
||||
}
|
||||
if w.TrustLevel != domain.WorkerUntrusted {
|
||||
return true, uuid.Nil, nil
|
||||
}
|
||||
if w.OwnerID == nil {
|
||||
// An untrusted worker always has an owner (it registered via a user JWT);
|
||||
// a missing one is a data error, not a silent trust upgrade.
|
||||
return false, uuid.Nil, domain.ErrInvalidInput
|
||||
}
|
||||
return false, *w.OwnerID, nil
|
||||
}
|
||||
|
||||
// verifyResultArtifact enforces that the referenced artifact was stored by the
|
||||
// coordinator for this exact task. It stops a worker from completing task B with
|
||||
// an artifact it uploaded for task A, and from naming an id that isn't a result.
|
||||
func (uc *CompleteTask) verifyResultArtifact(ctx context.Context, taskID uuid.UUID, attempt int, artifactID uuid.UUID) (*domain.Artifact, error) {
|
||||
art, err := uc.artifacts.Get(ctx, artifactID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if art.TaskID == nil || *art.TaskID != taskID || art.Attempt == nil || *art.Attempt != attempt || art.Kind != domain.ArtifactPartialResult {
|
||||
return nil, domain.ErrResultConflict
|
||||
}
|
||||
return art, nil
|
||||
}
|
||||
|
||||
// --- FailTask ------------------------------------------------------------
|
||||
|
||||
type FailTask struct {
|
||||
tasks TaskRepository
|
||||
jobs JobRepository
|
||||
tx TxManager
|
||||
clock Clock
|
||||
tasks TaskRepository
|
||||
jobs JobRepository
|
||||
workers WorkerRepository
|
||||
tx TxManager
|
||||
clock Clock
|
||||
}
|
||||
|
||||
func NewFailTask(tasks TaskRepository, jobs JobRepository, tx TxManager, clock Clock) *FailTask {
|
||||
return &FailTask{tasks: tasks, jobs: jobs, tx: tx, clock: clock}
|
||||
func NewFailTask(tasks TaskRepository, jobs JobRepository, workers WorkerRepository, tx TxManager, clock Clock) *FailTask {
|
||||
return &FailTask{tasks: tasks, jobs: jobs, workers: workers, tx: tx, clock: clock}
|
||||
}
|
||||
|
||||
// Execute delegates the requeue-or-terminate decision to Task.Fail, then keeps
|
||||
// the parent job's status consistent in the same transaction.
|
||||
func (uc *FailTask) Execute(ctx context.Context, in FailTaskInput) (*domain.Task, error) {
|
||||
if err := authorizeWorkerOwner(ctx, uc.workers, in.WorkerID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out *domain.Task
|
||||
|
||||
err := uc.tx.WithinTx(ctx, func(ctx context.Context) error {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||
)
|
||||
|
||||
@@ -14,7 +15,9 @@ import (
|
||||
// It intentionally exposes no storage paths or credentials.
|
||||
type UIReadRepository interface {
|
||||
GetJob(ctx context.Context, jobID uuid.UUID) (*domain.Job, error)
|
||||
ListJobs(ctx context.Context, limit int) ([]domain.Job, error)
|
||||
// ListJobs returns the most recent jobs. A non-nil owner restricts the list
|
||||
// to that user's jobs; nil returns all (operator/admin view).
|
||||
ListJobs(ctx context.Context, owner *uuid.UUID, limit int) ([]domain.Job, error)
|
||||
ListTasksByJob(ctx context.Context, jobID uuid.UUID) ([]domain.Task, error)
|
||||
ListTasksByJobs(ctx context.Context, jobIDs []uuid.UUID) (map[uuid.UUID][]domain.Task, error)
|
||||
ListWorkers(ctx context.Context, limit int) ([]domain.Worker, error)
|
||||
@@ -85,13 +88,35 @@ type DashboardView struct {
|
||||
ActiveJobs int `json:"active_jobs"`
|
||||
FinishedJobs int `json:"finished_jobs"`
|
||||
OnlineWorkers int `json:"online_workers"`
|
||||
// Session is the signed-in user, when the UI runs in session mode. nil under
|
||||
// basic auth. Template-only, never serialised to the polling JSON.
|
||||
Session *SessionView `json:"-"`
|
||||
}
|
||||
|
||||
// SessionView is the minimal identity the UI header needs to show who is signed
|
||||
// in and to offer a logout control.
|
||||
type SessionView struct {
|
||||
Role string
|
||||
Verified bool
|
||||
}
|
||||
|
||||
// sessionViewFrom builds the header session info from the request context, or
|
||||
// nil when the caller is not an authenticated user (basic-auth operator).
|
||||
func sessionViewFrom(ctx context.Context) *SessionView {
|
||||
r, ok := authctx.From(ctx)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return &SessionView{Role: r.Role, Verified: r.Verified}
|
||||
}
|
||||
|
||||
type JobDetailView struct {
|
||||
JobCard
|
||||
Tasks []TaskCard `json:"tasks"`
|
||||
Artifacts []ArtifactCard `json:"artifacts"`
|
||||
Parameters []ParameterCard `json:"parameters"`
|
||||
FinalResultAvailable bool `json:"final_result_available"`
|
||||
Session *SessionView `json:"-"`
|
||||
}
|
||||
|
||||
type Dashboard struct{ read UIReadRepository }
|
||||
@@ -99,7 +124,7 @@ type Dashboard struct{ read UIReadRepository }
|
||||
func NewDashboard(read UIReadRepository) *Dashboard { return &Dashboard{read: read} }
|
||||
|
||||
func (d *Dashboard) Overview(ctx context.Context, limit int) (DashboardView, error) {
|
||||
jobs, err := d.read.ListJobs(ctx, limit)
|
||||
jobs, err := d.read.ListJobs(ctx, uiOwnerFilter(ctx), limit)
|
||||
if err != nil {
|
||||
return DashboardView{}, err
|
||||
}
|
||||
@@ -132,6 +157,7 @@ func (d *Dashboard) Overview(ctx context.Context, limit int) (DashboardView, err
|
||||
out.OnlineWorkers++
|
||||
}
|
||||
}
|
||||
out.Session = sessionViewFrom(ctx)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -140,6 +166,11 @@ func (d *Dashboard) JobDetail(ctx context.Context, jobID uuid.UUID) (JobDetailVi
|
||||
if err != nil {
|
||||
return JobDetailView{}, err
|
||||
}
|
||||
// A plain user may only open their own job; a mismatch reads as not-found so
|
||||
// the page never reveals another user's job exists.
|
||||
if err := authorizeJobAccess(ctx, job); err != nil {
|
||||
return JobDetailView{}, err
|
||||
}
|
||||
tasks, err := d.read.ListTasksByJob(ctx, jobID)
|
||||
if err != nil {
|
||||
return JobDetailView{}, err
|
||||
@@ -161,6 +192,7 @@ func (d *Dashboard) JobDetail(ctx context.Context, jobID uuid.UUID) (JobDetailVi
|
||||
Tasks: make([]TaskCard, 0, len(tasks)),
|
||||
Artifacts: make([]ArtifactCard, 0, len(artifacts)),
|
||||
Parameters: uiParameters(job.Parameters),
|
||||
Session: sessionViewFrom(ctx),
|
||||
}
|
||||
for _, task := range tasks {
|
||||
card := TaskCard{ID: task.ID.String(), ChunkIndex: task.ChunkIndex, Status: string(task.Status), Attempt: task.Attempt, MaxAttempts: task.MaxAttempts, LeaseExpiresAt: task.LeaseExpiresAt, StartedAt: task.StartedAt, CompletedAt: task.CompletedAt}
|
||||
@@ -198,6 +230,10 @@ func (d *Dashboard) DownloadableArtifactBelongsToJob(ctx context.Context, jobID,
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
// Not the caller's job (and not admin): treat as if the artifact is absent.
|
||||
if err := authorizeJobAccess(ctx, job); err != nil {
|
||||
return false, nil //nolint:nilerr // masking the authz error as "not found" is intentional
|
||||
}
|
||||
artifacts, err := d.read.ListArtifactsByJob(ctx, jobID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package usecase_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/memstore"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
|
||||
)
|
||||
|
||||
func newDashboard() (*usecase.Dashboard, *memstore.JobRepo) {
|
||||
jobs := memstore.NewJobRepo()
|
||||
tasks := memstore.NewTaskRepo()
|
||||
workers := memstore.NewWorkerRepo()
|
||||
artifacts := memstore.NewArtifactRepo()
|
||||
return usecase.NewDashboard(memstore.NewUIReadRepo(jobs, tasks, workers, artifacts)), jobs
|
||||
}
|
||||
|
||||
func ownedJob(t *testing.T, jobs *memstore.JobRepo, owner uuid.UUID) uuid.UUID {
|
||||
t.Helper()
|
||||
o := owner
|
||||
job := &domain.Job{ID: uuid.New(), Workload: "similarity-search", Status: domain.JobRunning, OwnerID: &o, CreatedAt: time.Now().UTC()}
|
||||
if err := jobs.Insert(context.Background(), job); err != nil {
|
||||
t.Fatalf("insert owned job: %v", err)
|
||||
}
|
||||
return job.ID
|
||||
}
|
||||
|
||||
func userCtx(id uuid.UUID, role string) context.Context {
|
||||
return authctx.With(context.Background(), authctx.Requester{UserID: id, Role: role})
|
||||
}
|
||||
|
||||
func TestOverviewScopesJobsByOwner(t *testing.T) {
|
||||
dash, jobs := newDashboard()
|
||||
alice, bob := uuid.New(), uuid.New()
|
||||
ownedJob(t, jobs, alice)
|
||||
ownedJob(t, jobs, bob)
|
||||
|
||||
// A plain user sees only their own job.
|
||||
v, err := dash.Overview(userCtx(alice, "user"), 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(v.Jobs) != 1 {
|
||||
t.Errorf("alice sees %d jobs, want 1", len(v.Jobs))
|
||||
}
|
||||
|
||||
// An admin sees every job.
|
||||
if v, _ := dash.Overview(userCtx(uuid.New(), "admin"), 20); len(v.Jobs) != 2 {
|
||||
t.Errorf("admin sees %d jobs, want 2", len(v.Jobs))
|
||||
}
|
||||
|
||||
// No requester (basic-auth operator) sees every job — unchanged behaviour.
|
||||
if v, _ := dash.Overview(context.Background(), 20); len(v.Jobs) != 2 {
|
||||
t.Errorf("operator sees %d jobs, want 2", len(v.Jobs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobDetailRejectsAnotherUsersJob(t *testing.T) {
|
||||
dash, jobs := newDashboard()
|
||||
alice, bob := uuid.New(), uuid.New()
|
||||
jobID := ownedJob(t, jobs, alice)
|
||||
|
||||
// Bob cannot open Alice's job.
|
||||
if _, err := dash.JobDetail(userCtx(bob, "user"), jobID); !errors.Is(err, domain.ErrJobNotFound) {
|
||||
t.Errorf("bob: got %v, want ErrJobNotFound", err)
|
||||
}
|
||||
// Alice can.
|
||||
if _, err := dash.JobDetail(userCtx(alice, "user"), jobID); err != nil {
|
||||
t.Errorf("alice: unexpected error %v", err)
|
||||
}
|
||||
// Admin can.
|
||||
if _, err := dash.JobDetail(userCtx(uuid.New(), "admin"), jobID); err != nil {
|
||||
t.Errorf("admin: unexpected error %v", err)
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ func (uc *SubmitDataset) Execute(ctx context.Context, in SubmitDatasetInput) (Su
|
||||
if err != nil {
|
||||
return SubmitDatasetResult{}, err
|
||||
}
|
||||
job.OwnerID = ownerFromContext(ctx)
|
||||
|
||||
// Everything written to blob storage, so a failed transaction can undo it.
|
||||
var putKeys []string
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/memstore"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
|
||||
@@ -34,12 +35,13 @@ func (s expiringBlobStore) Put(ctx context.Context, key string, body io.Reader)
|
||||
// harness wires every use case to in-memory stores so orchestration can be
|
||||
// tested without a database.
|
||||
type harness struct {
|
||||
tasks *memstore.TaskRepo
|
||||
jobs *memstore.JobRepo
|
||||
work *memstore.WorkerRepo
|
||||
arts *memstore.ArtifactRepo
|
||||
blobs *memstore.BlobStore
|
||||
clk *memstore.Clock
|
||||
tasks *memstore.TaskRepo
|
||||
jobs *memstore.JobRepo
|
||||
work *memstore.WorkerRepo
|
||||
arts *memstore.ArtifactRepo
|
||||
blobs *memstore.BlobStore
|
||||
clk *memstore.Clock
|
||||
taskResults *memstore.TaskResultRepo
|
||||
|
||||
createJob *usecase.CreateJob
|
||||
submit *usecase.SubmitDataset
|
||||
@@ -61,24 +63,25 @@ type harness struct {
|
||||
|
||||
func newHarness() *harness {
|
||||
h := &harness{
|
||||
tasks: memstore.NewTaskRepo(),
|
||||
jobs: memstore.NewJobRepo(),
|
||||
work: memstore.NewWorkerRepo(),
|
||||
arts: memstore.NewArtifactRepo(),
|
||||
blobs: memstore.NewBlobStore(),
|
||||
clk: memstore.NewClock(time.Date(2026, 7, 21, 12, 0, 0, 0, time.UTC)),
|
||||
tasks: memstore.NewTaskRepo(),
|
||||
jobs: memstore.NewJobRepo(),
|
||||
work: memstore.NewWorkerRepo(),
|
||||
arts: memstore.NewArtifactRepo(),
|
||||
blobs: memstore.NewBlobStore(),
|
||||
clk: memstore.NewClock(time.Date(2026, 7, 21, 12, 0, 0, 0, time.UTC)),
|
||||
taskResults: memstore.NewTaskResultRepo(),
|
||||
}
|
||||
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)
|
||||
h.claim = usecase.NewClaimTask(h.tasks, h.jobs, h.work, tx, h.clk, lease)
|
||||
h.renew = usecase.NewRenewLease(h.tasks, h.work, tx, h.clk, lease)
|
||||
h.complete = usecase.NewCompleteTask(h.tasks, h.jobs, h.arts, tx, h.clk)
|
||||
h.fail = usecase.NewFailTask(h.tasks, h.jobs, tx, h.clk)
|
||||
h.complete = usecase.NewCompleteTask(h.tasks, h.jobs, h.arts, h.work, h.taskResults, tx, h.clk, 2)
|
||||
h.fail = usecase.NewFailTask(h.tasks, h.jobs, h.work, tx, h.clk)
|
||||
h.status = usecase.NewGetJobStatus(h.jobs, h.tasks)
|
||||
h.results = usecase.NewListResults(h.tasks)
|
||||
h.register = usecase.NewRegisterWorker(h.work, h.clk)
|
||||
h.uploadArt = usecase.NewUploadArtifact(h.tasks, h.arts, h.blobs, tx, h.clk)
|
||||
h.uploadArt = usecase.NewUploadArtifact(h.tasks, h.work, h.arts, h.blobs, tx, h.clk)
|
||||
h.downloadArt = usecase.NewDownloadArtifact(h.arts, h.blobs)
|
||||
h.getInput = usecase.NewGetTaskInput(h.tasks, h.arts, h.blobs)
|
||||
h.expire = usecase.NewExpireLeases(h.tasks, h.jobs, tx, h.clk)
|
||||
@@ -265,6 +268,184 @@ func TestClaimEmptyQueueReturnsNil(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterWorkerDefaultsToTrusted(t *testing.T) {
|
||||
h := newHarness()
|
||||
// A shared-token registration carries no owner and no explicit trust.
|
||||
w, err := h.register.Execute(ctx, usecase.RegisterWorkerInput{
|
||||
Name: "lab", Capabilities: []string{"w"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.TrustLevel != domain.WorkerTrusted {
|
||||
t.Errorf("trust = %q, want trusted", w.TrustLevel)
|
||||
}
|
||||
if w.OwnerID != nil {
|
||||
t.Errorf("owner = %v, want nil for a shared-token worker", w.OwnerID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterWorkerRecordsOwnerAndUntrusted(t *testing.T) {
|
||||
h := newHarness()
|
||||
owner := uuid.New()
|
||||
w, err := h.register.Execute(ctx, usecase.RegisterWorkerInput{
|
||||
Name: "volunteer", Capabilities: []string{"w"},
|
||||
OwnerID: &owner, TrustLevel: domain.WorkerUntrusted,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.TrustLevel != domain.WorkerUntrusted {
|
||||
t.Errorf("trust = %q, want untrusted", w.TrustLevel)
|
||||
}
|
||||
if w.OwnerID == nil || *w.OwnerID != owner {
|
||||
t.Errorf("owner = %v, want %v", w.OwnerID, owner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTCallerCannotClaimAsAnotherUsersWorker(t *testing.T) {
|
||||
h := newHarness()
|
||||
h.seedJob(t, "w", 1)
|
||||
|
||||
// A trusted lab worker owned by nobody (shared-token registration).
|
||||
victim, _ := h.register.Execute(ctx, usecase.RegisterWorkerInput{Name: "lab", Capabilities: []string{"w"}})
|
||||
|
||||
// An attacker authenticated as a JWT user tries to claim as the lab worker.
|
||||
attacker := authctx.With(ctx, authctx.Requester{UserID: uuid.New(), Role: "user"})
|
||||
claimed, err := h.claim.Execute(attacker, usecase.ClaimTaskInput{WorkerID: victim.ID.String()})
|
||||
if !errors.Is(err, domain.ErrWorkerNotFound) {
|
||||
t.Fatalf("claim as another's worker = (%v, %v), want ErrWorkerNotFound", claimed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTCallerCannotMutateAnotherUsersWorkerLease(t *testing.T) {
|
||||
h := newHarness()
|
||||
h.seedJob(t, "w", 1)
|
||||
victimOwner := uuid.New()
|
||||
victim, err := h.register.Execute(ctx, usecase.RegisterWorkerInput{
|
||||
Name: "victim", Capabilities: []string{"w"}, OwnerID: &victimOwner, TrustLevel: domain.WorkerTrusted,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
claimed, err := h.claim.Execute(ctx, usecase.ClaimTaskInput{WorkerID: victim.ID.String()})
|
||||
if err != nil || claimed == nil {
|
||||
t.Fatalf("claim = (%v, %v)", claimed, err)
|
||||
}
|
||||
attacker := authctx.With(ctx, authctx.Requester{UserID: uuid.New(), Role: "user"})
|
||||
|
||||
if _, err := h.renew.Execute(attacker, usecase.RenewLeaseInput{TaskID: claimed.TaskID, WorkerID: victim.ID.String(), Attempt: claimed.Attempt}); !errors.Is(err, domain.ErrWorkerNotFound) {
|
||||
t.Errorf("foreign heartbeat err = %v, want ErrWorkerNotFound", err)
|
||||
}
|
||||
if _, err := h.fail.Execute(attacker, usecase.FailTaskInput{TaskID: claimed.TaskID, WorkerID: victim.ID.String(), Attempt: claimed.Attempt, ErrorCode: "x"}); !errors.Is(err, domain.ErrWorkerNotFound) {
|
||||
t.Errorf("foreign failure err = %v, want ErrWorkerNotFound", err)
|
||||
}
|
||||
if _, err := h.uploadArt.Execute(attacker, usecase.UploadArtifactInput{TaskID: claimed.TaskID, WorkerID: victim.ID.String(), Attempt: claimed.Attempt, Filename: "x.csv", ContentType: "text/csv", Body: strings.NewReader("x")}); !errors.Is(err, domain.ErrWorkerNotFound) {
|
||||
t.Errorf("foreign upload err = %v, want ErrWorkerNotFound", err)
|
||||
}
|
||||
if _, err := h.complete.Execute(attacker, usecase.CompleteTaskInput{TaskID: claimed.TaskID, WorkerID: victim.ID.String(), Attempt: claimed.Attempt, ResultArtifactID: uuid.New()}); !errors.Is(err, domain.ErrWorkerNotFound) {
|
||||
t.Errorf("foreign result err = %v, want ErrWorkerNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTCallerClaimsAsOwnTrustedWorker(t *testing.T) {
|
||||
h := newHarness()
|
||||
h.seedJob(t, "w", 1)
|
||||
owner := uuid.New()
|
||||
|
||||
// The user's own worker, trusted (e.g. a verified contributor).
|
||||
mine, _ := h.register.Execute(ctx, usecase.RegisterWorkerInput{
|
||||
Name: "mine", Capabilities: []string{"w"}, OwnerID: &owner, TrustLevel: domain.WorkerTrusted,
|
||||
})
|
||||
|
||||
callerCtx := authctx.With(ctx, authctx.Requester{UserID: owner, Role: "user", Verified: true})
|
||||
got, err := h.claim.Execute(callerCtx, usecase.ClaimTaskInput{WorkerID: mine.ID.String()})
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("own trusted worker claim = (%v, %v), want a task", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUntrustedWorkerCanClaim(t *testing.T) {
|
||||
h := newHarness()
|
||||
h.seedJob(t, "w", 1)
|
||||
owner := uuid.New()
|
||||
worker, err := h.register.Execute(ctx, usecase.RegisterWorkerInput{
|
||||
Name: "volunteer", Capabilities: []string{"w"},
|
||||
OwnerID: &owner, TrustLevel: domain.WorkerUntrusted,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Volunteers are no longer quarantined — they may claim; their results are
|
||||
// gated by quorum at completion, not by withholding work.
|
||||
claimed, err := h.claim.Execute(ctx, usecase.ClaimTaskInput{WorkerID: worker.ID.String()})
|
||||
if err != nil || claimed == nil {
|
||||
t.Fatalf("untrusted claim = (%v, %v), want a task", claimed, err)
|
||||
}
|
||||
}
|
||||
|
||||
// registerUntrusted registers a volunteer worker under a fresh owner.
|
||||
func (h *harness) registerUntrusted(t *testing.T, name, workload string) (*domain.Worker, uuid.UUID) {
|
||||
t.Helper()
|
||||
owner := uuid.New()
|
||||
w, err := h.register.Execute(ctx, usecase.RegisterWorkerInput{
|
||||
Name: name, Capabilities: []string{workload},
|
||||
OwnerID: &owner, TrustLevel: domain.WorkerUntrusted,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return w, owner
|
||||
}
|
||||
|
||||
func TestUntrustedResultNeedsQuorum(t *testing.T) {
|
||||
h := newHarness()
|
||||
jobID := h.seedJob(t, "w", 1)
|
||||
if err := h.jobs.UpdateStatus(ctx, jobID, domain.JobRunning, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w1, _ := h.registerUntrusted(t, "v1", "w")
|
||||
w2, _ := h.registerUntrusted(t, "v2", "w")
|
||||
|
||||
// First volunteer computes and submits — one vote, not yet quorum (2).
|
||||
taskID, attempt := h.leaseOne(t, w1.ID.String(), "w")
|
||||
art1 := h.uploadResult(t, taskID, w1.ID.String(), attempt)
|
||||
if _, err := h.complete.Execute(ctx, usecase.CompleteTaskInput{TaskID: taskID, WorkerID: w1.ID.String(), Attempt: attempt, ResultArtifactID: art1}); err != nil {
|
||||
t.Fatalf("first vote: %v", err)
|
||||
}
|
||||
if tk, _ := h.tasks.Get(ctx, taskID); tk.Status != domain.TaskPending {
|
||||
t.Fatalf("after one vote status = %s, want pending", tk.Status)
|
||||
}
|
||||
|
||||
// Second volunteer (distinct owner) computes the same bytes -> quorum -> done.
|
||||
taskID2, attempt2 := h.leaseOne(t, w2.ID.String(), "w")
|
||||
art2 := h.uploadResult(t, taskID2, w2.ID.String(), attempt2)
|
||||
if _, err := h.complete.Execute(ctx, usecase.CompleteTaskInput{TaskID: taskID2, WorkerID: w2.ID.String(), Attempt: attempt2, ResultArtifactID: art2}); err != nil {
|
||||
t.Fatalf("second vote: %v", err)
|
||||
}
|
||||
if tk, _ := h.tasks.Get(ctx, taskID); tk.Status != domain.TaskCompleted {
|
||||
t.Fatalf("after quorum status = %s, want completed", tk.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedResultCompletesDirectly(t *testing.T) {
|
||||
h := newHarness()
|
||||
jobID := h.seedJob(t, "w", 1)
|
||||
if err := h.jobs.UpdateStatus(ctx, jobID, domain.JobRunning, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A trusted (default) worker's single result completes the task immediately.
|
||||
worker, _ := h.register.Execute(ctx, usecase.RegisterWorkerInput{Name: "lab", Capabilities: []string{"w"}})
|
||||
taskID, attempt := h.leaseOne(t, worker.ID.String(), "w")
|
||||
art := h.uploadResult(t, taskID, worker.ID.String(), attempt)
|
||||
if _, err := h.complete.Execute(ctx, usecase.CompleteTaskInput{TaskID: taskID, WorkerID: worker.ID.String(), Attempt: attempt, ResultArtifactID: art}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tk, _ := h.tasks.Get(ctx, taskID); tk.Status != domain.TaskCompleted {
|
||||
t.Fatalf("trusted result status = %s, want completed", tk.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimRequiresWorkerID(t *testing.T) {
|
||||
h := newHarness()
|
||||
if _, err := h.claim.Execute(ctx, usecase.ClaimTaskInput{}); !errors.Is(err, domain.ErrInvalidInput) {
|
||||
@@ -400,7 +581,7 @@ func TestUploadRejectsLeaseThatExpiresDuringStreaming(t *testing.T) {
|
||||
h.seedJob(t, "w", 1)
|
||||
taskID, attempt := h.leaseOne(t, "w1", "w")
|
||||
h.uploadArt = usecase.NewUploadArtifact(
|
||||
h.tasks, h.arts, expiringBlobStore{BlobStore: h.blobs, clock: h.clk}, memstore.Tx{}, h.clk,
|
||||
h.tasks, h.work, h.arts, expiringBlobStore{BlobStore: h.blobs, clock: h.clk}, memstore.Tx{}, h.clk,
|
||||
)
|
||||
|
||||
_, err := h.uploadArt.Execute(ctx, usecase.UploadArtifactInput{
|
||||
|
||||
@@ -22,6 +22,13 @@ func (uc *RegisterWorker) Execute(ctx context.Context, in RegisterWorkerInput) (
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w.OwnerID = in.OwnerID
|
||||
// The transport layer resolves trust from the caller's credentials; fall
|
||||
// back to the domain default (trusted) only when it was left unset, so a
|
||||
// zero-value input never silently downgrades a shared-token worker.
|
||||
if in.TrustLevel != "" {
|
||||
w.TrustLevel = in.TrustLevel
|
||||
}
|
||||
if err := uc.workers.Insert(ctx, w); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||
)
|
||||
|
||||
// authorizeWorkerOwner binds a JWT-authenticated requester to a worker. The
|
||||
// shared coordinator token intentionally has no requester and retains its
|
||||
// existing operator privileges.
|
||||
func authorizeWorkerOwner(ctx context.Context, workers WorkerRepository, workerID string) error {
|
||||
requester, ok := authctx.From(ctx)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
id, err := uuid.Parse(workerID)
|
||||
if err != nil {
|
||||
return domain.ErrWorkerNotFound
|
||||
}
|
||||
worker, err := workers.Get(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if worker.OwnerID == nil || *worker.OwnerID != requester.UserID {
|
||||
// Mask ownership and existence from another user.
|
||||
return domain.ErrWorkerNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
BEGIN;
|
||||
|
||||
DROP INDEX IF EXISTS ix_jobs_owner;
|
||||
ALTER TABLE jobs DROP COLUMN IF EXISTS owner_id;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,14 @@
|
||||
BEGIN;
|
||||
|
||||
-- Who submitted this job. Equals users.id from the userservice, taken from the
|
||||
-- JWT `sub` claim. NOT a foreign key: users live in a separate service/database,
|
||||
-- so integrity is guaranteed by the signed token, not by the DB.
|
||||
--
|
||||
-- Nullable because rows created before auth existed have no owner; new inserts
|
||||
-- must supply it (enforced in the app, not the schema, during the MVP).
|
||||
ALTER TABLE jobs ADD COLUMN owner_id uuid;
|
||||
|
||||
-- "List my jobs" / "admin filters by owner" scans by owner.
|
||||
CREATE INDEX ix_jobs_owner ON jobs (owner_id);
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,8 @@
|
||||
BEGIN;
|
||||
|
||||
DROP INDEX IF EXISTS ix_workers_owner;
|
||||
ALTER TABLE workers DROP COLUMN IF EXISTS trust_level;
|
||||
ALTER TABLE workers DROP COLUMN IF EXISTS owner_id;
|
||||
DROP TYPE IF EXISTS worker_trust;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,18 @@
|
||||
BEGIN;
|
||||
|
||||
-- Whether a worker's results are accepted directly or must clear quorum.
|
||||
-- 'trusted' — lab machine (shared token) or a verified/admin contributor.
|
||||
-- 'untrusted' — a plain enthusiast; results are quarantined until quorum (C2).
|
||||
CREATE TYPE worker_trust AS ENUM ('trusted', 'untrusted');
|
||||
|
||||
-- Who registered this worker (userservice user id, from the JWT sub). NULL for
|
||||
-- workers registered with the shared service token. Not a foreign key: users
|
||||
-- live in a separate service/database.
|
||||
ALTER TABLE workers ADD COLUMN owner_id uuid;
|
||||
|
||||
-- Existing rows were all shared-token lab workers, hence 'trusted'.
|
||||
ALTER TABLE workers ADD COLUMN trust_level worker_trust NOT NULL DEFAULT 'trusted';
|
||||
|
||||
CREATE INDEX ix_workers_owner ON workers (owner_id);
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,5 @@
|
||||
BEGIN;
|
||||
|
||||
DROP TABLE IF EXISTS task_results;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,23 @@
|
||||
BEGIN;
|
||||
|
||||
-- Quorum votes for a task computed by untrusted (volunteer) workers. A trusted
|
||||
-- worker's result completes the task directly and never lands here; an untrusted
|
||||
-- result is recorded as one vote, and the task is only completed once enough
|
||||
-- distinct owners submit the same result_sha256.
|
||||
--
|
||||
-- One vote per (task, owner): a single volunteer cannot stuff the ballot by
|
||||
-- running many workers under one account. A resubmission updates their vote.
|
||||
CREATE TABLE task_results (
|
||||
task_id uuid NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
owner_id uuid NOT NULL,
|
||||
result_sha256 text NOT NULL,
|
||||
result_artifact_id uuid NOT NULL REFERENCES artifacts(id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
PRIMARY KEY (task_id, owner_id)
|
||||
);
|
||||
|
||||
-- Quorum check groups a task's votes by result_sha256.
|
||||
CREATE INDEX ix_task_results_quorum ON task_results (task_id, result_sha256);
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,154 @@
|
||||
{
|
||||
"annotations": { "list": [] },
|
||||
"editable": true,
|
||||
"graphTooltip": 1,
|
||||
"schemaVersion": 39,
|
||||
"tags": ["scimesh"],
|
||||
"time": { "from": "now-15m", "to": "now" },
|
||||
"refresh": "5s",
|
||||
"title": "SciMesh Coordinator",
|
||||
"uid": "scimesh-coordinator",
|
||||
"panels": [
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "HTTP request rate by route",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
|
||||
"fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] },
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "sum by (route) (rate(scimesh_http_requests_total[1m]))",
|
||||
"legendFormat": "{{route}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "p95 latency by route",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
|
||||
"fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] },
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "histogram_quantile(0.95, sum by (le, route) (rate(scimesh_http_request_duration_seconds_bucket[5m])))",
|
||||
"legendFormat": "{{route}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "Requests by status",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
|
||||
"fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] },
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "sum by (status) (rate(scimesh_http_requests_total[1m]))",
|
||||
"legendFormat": "{{status}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "Goroutines",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 8, "w": 6, "x": 12, "y": 8 },
|
||||
"fieldConfig": { "defaults": { "unit": "short" }, "overrides": [] },
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "go_goroutines{job=\"coordinator\"}",
|
||||
"legendFormat": "goroutines"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "Resident memory",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 8, "w": 6, "x": 18, "y": 8 },
|
||||
"fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] },
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "process_resident_memory_bytes{job=\"coordinator\"}",
|
||||
"legendFormat": "rss"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "row",
|
||||
"title": "Domain state",
|
||||
"collapsed": false,
|
||||
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 16 }
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "Tasks by status",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 17 },
|
||||
"fieldConfig": { "defaults": { "unit": "short", "custom": { "stacking": { "mode": "normal" }, "fillOpacity": 30 } }, "overrides": [] },
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "scimesh_tasks",
|
||||
"legendFormat": "{{status}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "Jobs by status",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 17 },
|
||||
"fieldConfig": { "defaults": { "unit": "short", "custom": { "stacking": { "mode": "normal" }, "fillOpacity": 30 } }, "overrides": [] },
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "scimesh_jobs",
|
||||
"legendFormat": "{{status}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "Queue depth (pending tasks)",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 8, "w": 6, "x": 0, "y": 25 },
|
||||
"fieldConfig": { "defaults": { "unit": "short", "color": { "mode": "thresholds" }, "thresholds": { "steps": [ { "color": "green", "value": null }, { "color": "yellow", "value": 50 }, { "color": "red", "value": 500 } ] } }, "overrides": [] },
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "sum(scimesh_tasks{status=\"pending\"})",
|
||||
"legendFormat": "pending"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "Workers by status",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 8, "w": 18, "x": 6, "y": 25 },
|
||||
"fieldConfig": { "defaults": { "unit": "short", "custom": { "stacking": { "mode": "normal" }, "fillOpacity": 30 } }, "overrides": [] },
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "scimesh_workers",
|
||||
"legendFormat": "{{status}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
apiVersion: 1
|
||||
|
||||
providers:
|
||||
- name: SciMesh
|
||||
type: file
|
||||
disableDeletion: false
|
||||
allowUiUpdates: true
|
||||
options:
|
||||
path: /var/lib/grafana/dashboards
|
||||
foldersFromFilesStructure: false
|
||||
@@ -0,0 +1,10 @@
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: Prometheus
|
||||
uid: prometheus
|
||||
type: prometheus
|
||||
access: proxy
|
||||
url: http://prometheus:9090
|
||||
isDefault: true
|
||||
editable: false
|
||||
@@ -0,0 +1,10 @@
|
||||
# Prometheus scrape config for the SciMesh demo. Prometheus runs in the same
|
||||
# compose network as the coordinator, so it reaches it by service name.
|
||||
global:
|
||||
scrape_interval: 5s
|
||||
evaluation_interval: 5s
|
||||
|
||||
scrape_configs:
|
||||
- job_name: coordinator
|
||||
static_configs:
|
||||
- targets: ["coordinator:8080"]
|
||||
@@ -11,8 +11,17 @@ repo_dir=$(CDPATH= cd -- "$coordinator_dir/.." && pwd)
|
||||
project=${DEMO_PROJECT:-scimesh-demo}
|
||||
postgres_port=${DEMO_POSTGRES_PORT:-55432}
|
||||
coordinator_port=${DEMO_COORDINATOR_PORT:-18080}
|
||||
userservice_port=${DEMO_USERSERVICE_PORT:-18081}
|
||||
prometheus_port=${DEMO_PROMETHEUS_PORT:-19090}
|
||||
grafana_port=${DEMO_GRAFANA_PORT:-13000}
|
||||
ui_token=${DEMO_UI_TOKEN:-demo-ui-secret}
|
||||
worker_token=${DEMO_WORKER_TOKEN:-demo-worker-token}
|
||||
# Shared HS256 secret; the coordinator verifies userservice tokens with it. Must
|
||||
# be at least 32 bytes (both services refuse a shorter one).
|
||||
jwt_secret=${DEMO_JWT_SECRET:-demo-jwt-secret-please-change-me-0123456789}
|
||||
# The first admin, seeded into the userservice on first boot.
|
||||
admin_email=${DEMO_ADMIN_EMAIL:-root@scimesh.local}
|
||||
admin_password=${DEMO_ADMIN_PASSWORD:-rootpassword}
|
||||
workers=${DEMO_WORKERS:-2}
|
||||
demo_dir=${DEMO_DIR:-.demo}
|
||||
case "$demo_dir" in
|
||||
@@ -26,9 +35,18 @@ logs_dir="$demo_dir/logs"
|
||||
compose() {
|
||||
POSTGRES_PORT="$postgres_port" \
|
||||
COORDINATOR_PORT="$coordinator_port" \
|
||||
USERSERVICE_PORT="$userservice_port" \
|
||||
PROMETHEUS_PORT="$prometheus_port" \
|
||||
GRAFANA_PORT="$grafana_port" \
|
||||
UI_AUTH_TOKEN="$ui_token" \
|
||||
WORKER_AUTH_TOKEN="$worker_token" \
|
||||
docker compose -p "$project" -f "$coordinator_dir/docker-compose.yml" "$@"
|
||||
JWT_SECRET="$jwt_secret" \
|
||||
BOOTSTRAP_ADMIN_EMAIL="$admin_email" \
|
||||
BOOTSTRAP_ADMIN_PASSWORD="$admin_password" \
|
||||
docker compose -p "$project" \
|
||||
-f "$coordinator_dir/docker-compose.yml" \
|
||||
-f "$coordinator_dir/docker-compose.users.yml" \
|
||||
-f "$coordinator_dir/docker-compose.monitoring.yml" "$@"
|
||||
}
|
||||
|
||||
stop_workers() {
|
||||
@@ -57,10 +75,29 @@ wait_for_coordinator() {
|
||||
done
|
||||
}
|
||||
|
||||
wait_for_userservice() {
|
||||
local attempt=0
|
||||
until curl --fail --silent --show-error "http://localhost:$userservice_port/health" >/dev/null; do
|
||||
attempt=$((attempt + 1))
|
||||
if (( attempt >= 45 )); then
|
||||
echo "Userservice did not become ready. Recent logs:" >&2
|
||||
compose logs --tail=80 userservice >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
wait_for_workers() {
|
||||
local attempt=0 registered overview
|
||||
local attempt=0 registered overview cookie="$demo_dir/session.cookies"
|
||||
# The dashboard API is behind a userservice session now, not basic auth. Log in
|
||||
# as the seeded admin (who sees every worker) to obtain a session cookie.
|
||||
curl --fail --silent -c "$cookie" \
|
||||
--data-urlencode "email=$admin_email" \
|
||||
--data-urlencode "password=$admin_password" \
|
||||
"http://localhost:$coordinator_port/ui/login" >/dev/null 2>&1 || true
|
||||
until false; do
|
||||
overview=$(curl --fail --silent --show-error --user "operator:$ui_token" \
|
||||
overview=$(curl --fail --silent --show-error -b "$cookie" \
|
||||
"http://localhost:$coordinator_port/ui/api/overview" 2>/dev/null || true)
|
||||
# The overview contains no jobs at demo startup, so every `id` belongs to
|
||||
# a registered worker. Avoid adding jq just for this local helper.
|
||||
@@ -96,6 +133,8 @@ start() {
|
||||
compose up -d --build
|
||||
echo "Waiting for the coordinator on http://localhost:$coordinator_port ..."
|
||||
wait_for_coordinator
|
||||
echo "Waiting for the userservice on http://localhost:$userservice_port ..."
|
||||
wait_for_userservice
|
||||
|
||||
: > "$pid_file"
|
||||
for index in $(seq 1 "$workers"); do
|
||||
@@ -115,13 +154,17 @@ start() {
|
||||
|
||||
SciMesh manual demo is ready.
|
||||
|
||||
UI: http://localhost:$coordinator_port/ui
|
||||
Username: operator
|
||||
Password: $ui_token
|
||||
Workers: $workers local reference workers
|
||||
UI: http://localhost:$coordinator_port/ui (shows a login page)
|
||||
Admin login: $admin_email / $admin_password
|
||||
Userservice: http://localhost:$userservice_port
|
||||
Grafana: http://localhost:$grafana_port (anonymous view; admin/${GRAFANA_PASSWORD:-admin} to edit)
|
||||
Prometheus: http://localhost:$prometheus_port
|
||||
Workers: $workers local reference workers
|
||||
|
||||
Upload a small ChEMBL TSV through “New similarity search”, then watch the job
|
||||
page update. Worker logs are in $logs_dir. Stop everything with:
|
||||
Sign in with the admin above, or register a new account from the login page.
|
||||
The admin sees every job; a plain user sees only their own. Upload a small
|
||||
ChEMBL TSV through “New similarity search”, then watch the job page update.
|
||||
Worker logs are in $logs_dir. Stop everything with:
|
||||
|
||||
make demo-down
|
||||
EOF
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# SciMesh User Service API contract (v1)
|
||||
|
||||
**Status:** `v1`. The User Service owns user accounts and issues access tokens.
|
||||
The coordinator never receives user passwords and never accesses the User
|
||||
Service database.
|
||||
|
||||
## Authentication boundary
|
||||
|
||||
- User Service signs access tokens; coordinator verifies them before accepting
|
||||
user-scoped requests.
|
||||
- Tokens contain a UUID `sub`, `role` (`user` or `admin`), `verified`, `iat`,
|
||||
and `exp` claims.
|
||||
- A user-authenticated caller may operate only workers whose `owner_id` equals
|
||||
`sub`. This applies to claim, heartbeat, result, failure, and artifact upload.
|
||||
- Worker traffic authenticated with the coordinator's shared worker token has
|
||||
no user identity and remains an operator-only compatibility path.
|
||||
- Role or verification changes take effect when the access token is renewed.
|
||||
Deployments needing immediate revocation must use a short token lifetime or a
|
||||
revocation mechanism before enabling volunteer-worker trust.
|
||||
|
||||
## Endpoints
|
||||
|
||||
All JSON request bodies reject unknown fields and are size-limited. Error
|
||||
responses are JSON with a stable `error` value and request ID.
|
||||
|
||||
| Method | Path | Auth | Success |
|
||||
| --- | --- | --- | --- |
|
||||
| `GET` | `/health` | none | `200 {"status":"ok"}` |
|
||||
| `POST` | `/register` | none | `201` user object |
|
||||
| `POST` | `/login` | none | `200` user object and access token |
|
||||
| `GET` | `/me` | Bearer access token | `200` current user |
|
||||
| `POST` | `/users/{id}/verify` | Bearer admin token | `204` |
|
||||
| `POST` | `/users/{id}/unverify` | Bearer admin token | `204` |
|
||||
| `POST` | `/users/{id}/promote` | Bearer admin token | `204` |
|
||||
| `POST` | `/users/{id}/demote` | Bearer admin token | `204` |
|
||||
|
||||
`POST /register` accepts `{ "email": string, "password": string }` and
|
||||
always creates role `user` with `verified: false`. `POST /login` accepts the
|
||||
same shape and returns `{ "token": string, "user": User }`. Password hashes,
|
||||
JWT signing material, and raw tokens must never be logged.
|
||||
|
||||
## Coordinator integration tests
|
||||
|
||||
The coordinator must test that a JWT user cannot claim or mutate another
|
||||
user's worker lease, including heartbeat, failure, result, and artifact upload.
|
||||
Job and artifact access is restricted to the job owner unless the caller has
|
||||
the admin role.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Keep the build context small and never bake secrets or local state into an image.
|
||||
.env
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
Makefile
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
||||
# Local build artifacts
|
||||
/userservice
|
||||
/bin/
|
||||
*.out
|
||||
/data/
|
||||
/logs/
|
||||
@@ -0,0 +1,29 @@
|
||||
# Copy to .env and adjust. All settings are read from the environment.
|
||||
|
||||
USERSERVICE_ADDR=:8081
|
||||
DATABASE_URL=postgres://scimesh:scimesh@localhost:5433/scimesh_users?sslmode=disable
|
||||
|
||||
# Shared HS256 secret used to sign JWTs. The coordinator verifies tokens with
|
||||
# this SAME secret, so the two values must match exactly. Minimum 32 bytes.
|
||||
JWT_SECRET=change-me-to-a-long-random-secret-min-32-bytes
|
||||
# How long an issued token stays valid.
|
||||
JWT_TTL=24h
|
||||
# bcrypt work factor. Empty/0 uses the library default (10).
|
||||
# BCRYPT_COST=10
|
||||
|
||||
# First-admin bootstrap. When both are set and no such account exists, the
|
||||
# service creates it with role=admin on startup (idempotent). This is the only
|
||||
# way to get the first admin. Leave empty in production once seeded.
|
||||
# BOOTSTRAP_ADMIN_EMAIL=root@scimesh.local
|
||||
# BOOTSTRAP_ADMIN_PASSWORD=change-me-strong
|
||||
|
||||
# Logging. LOG_LEVEL: debug|info|warn|error. LOG_FILE empty = stdout only;
|
||||
# set a path to also write a size-rotated file (kept across restarts).
|
||||
LOG_LEVEL=info
|
||||
# LOG_FILE=./logs/userservice.log
|
||||
|
||||
# Optional tuning (defaults shown).
|
||||
DB_MAX_CONNS=10
|
||||
# How long to keep retrying the initial DB connection while Postgres boots.
|
||||
DB_CONNECT_TIMEOUT=30s
|
||||
REQUEST_TIMEOUT=15s
|
||||
@@ -0,0 +1,6 @@
|
||||
/userservice
|
||||
/bin/
|
||||
.env
|
||||
*.out
|
||||
/logs/
|
||||
/data/
|
||||
@@ -0,0 +1,54 @@
|
||||
version: "2"
|
||||
|
||||
run:
|
||||
timeout: 3m
|
||||
|
||||
linters:
|
||||
# "standard" = errcheck, govet, ineffassign, staticcheck, unused.
|
||||
default: standard
|
||||
enable:
|
||||
# Catches `err == ErrFoo` where errors.Is is required. Directly relevant
|
||||
# here: domain exposes sentinel errors that use cases may wrap with %w.
|
||||
- errorlint
|
||||
# Returning nil after checking a non-nil error — a silent bug factory.
|
||||
- nilerr
|
||||
# http.Get/Do without a context: every outbound call must be cancellable.
|
||||
- noctx
|
||||
# Unclosed response bodies leak connections.
|
||||
- bodyclose
|
||||
# Common security mistakes (weak crypto, unhandled file perms).
|
||||
- gosec
|
||||
# Style and naming consistency.
|
||||
- revive
|
||||
- misspell
|
||||
- unconvert
|
||||
|
||||
settings:
|
||||
errcheck:
|
||||
# Deferred Close/Rollback are intentionally ignored in a few places
|
||||
# (rollback after commit is a documented no-op).
|
||||
check-type-assertions: true
|
||||
revive:
|
||||
rules:
|
||||
- name: exported
|
||||
disabled: true # internal packages need no exported-symbol comments
|
||||
gosec:
|
||||
excludes:
|
||||
- G404 # math/rand is fine for jitter; nothing here is security-sensitive
|
||||
|
||||
exclusions:
|
||||
rules:
|
||||
# Tests may skip error checks and use long literals freely.
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- errcheck
|
||||
- gosec
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
- goimports
|
||||
settings:
|
||||
goimports:
|
||||
local-prefixes:
|
||||
- github.com/emil28092005/SciMesh/users
|
||||
@@ -0,0 +1,52 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
#
|
||||
# Requires BuildKit (the RUN --mount cache lines below). Docker 23+ enables it
|
||||
# by default when the buildx plugin is present; install `docker-buildx` if a
|
||||
# build fails with "the --mount option requires BuildKit".
|
||||
|
||||
# --- build stage ----------------------------------------------------------
|
||||
FROM golang:1.24-alpine AS build
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Copy manifests first: this layer stays cached until dependencies actually
|
||||
# change, so editing Go sources does not re-download the module graph.
|
||||
COPY go.mod go.sum ./
|
||||
RUN --mount=type=cache,target=/go/pkg/mod go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
# The cache mounts persist the module cache and the compiler's build cache
|
||||
# *across* builds, so a rebuild after a code edit recompiles only what changed
|
||||
# instead of the whole dependency tree.
|
||||
#
|
||||
# CGO_ENABLED=0 produces a fully static binary, so the runtime image needs no
|
||||
# libc. -trimpath strips local paths; -s -w drop the symbol table and DWARF.
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
CGO_ENABLED=0 GOOS=linux go build \
|
||||
-trimpath -ldflags="-s -w" \
|
||||
-o /out/userservice ./cmd/userservice
|
||||
|
||||
# --- runtime stage --------------------------------------------------------
|
||||
FROM alpine:3.20
|
||||
|
||||
# ca-certificates for outbound TLS; wget backs the container healthcheck.
|
||||
RUN apk add --no-cache ca-certificates wget \
|
||||
&& adduser -D -H -u 10001 userservice \
|
||||
# Pre-create the log dir owned by the non-root user. A named volume mounted
|
||||
# here inherits this ownership from the image, so the process can write to it —
|
||||
# a host bind mount, owned by root, could not.
|
||||
&& mkdir -p /var/log/scimesh \
|
||||
&& chown -R userservice:userservice /var/log/scimesh
|
||||
|
||||
COPY --from=build /out/userservice /usr/local/bin/userservice
|
||||
|
||||
# Never run as root: a compromised process should not own the container.
|
||||
USER userservice
|
||||
|
||||
EXPOSE 8081
|
||||
|
||||
# Exec form, not shell: the binary becomes PID 1 and receives SIGTERM directly,
|
||||
# which is what its graceful shutdown depends on.
|
||||
ENTRYPOINT ["/usr/local/bin/userservice"]
|
||||
@@ -0,0 +1,96 @@
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
.PHONY: help build run test test-integration vet lint tidy check migrate-up migrate-down up down down-clean logs ps psql smoke
|
||||
|
||||
# `check` uses its own Compose project and host ports so it never touches a
|
||||
# developer's local PostgreSQL or the normal `make up` stack.
|
||||
CHECK_PROJECT ?= scimesh-users-check
|
||||
CHECK_POSTGRES_PORT ?= 55433
|
||||
CHECK_USERSERVICE_PORT ?= 18081
|
||||
CHECK_HOST ?= http://localhost:$(CHECK_USERSERVICE_PORT)
|
||||
CHECK_DATABASE_URL ?= postgres://scimesh:scimesh@localhost:$(CHECK_POSTGRES_PORT)/scimesh_users?sslmode=disable
|
||||
CHECK_COMPOSE = POSTGRES_PORT=$(CHECK_POSTGRES_PORT) USERSERVICE_PORT=$(CHECK_USERSERVICE_PORT) docker compose -p $(CHECK_PROJECT)
|
||||
|
||||
help:
|
||||
@printf '%s\n' \
|
||||
'SciMesh userservice commands:' \
|
||||
' make up / make down Start or stop the userservice stack (Postgres + migrate + service).' \
|
||||
' make check One command: vet, lint, race tests, integration, smoke (needs Docker).' \
|
||||
' make test / make vet Run Go verification.' \
|
||||
' make smoke Exercise the live API against a running service.'
|
||||
|
||||
# --- build / run ---------------------------------------------------------
|
||||
build:
|
||||
go build ./...
|
||||
|
||||
run:
|
||||
go run ./cmd/userservice
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
# Needs a running PostgreSQL with the migrations applied:
|
||||
# make test-integration TEST_DATABASE_URL='postgres://...'
|
||||
test-integration:
|
||||
TEST_DATABASE_URL="$(TEST_DATABASE_URL)" go test -tags=integration ./... -v
|
||||
|
||||
vet:
|
||||
go vet ./...
|
||||
|
||||
# Runs golangci-lint without installing it system-wide.
|
||||
LINT_VERSION := v2.12.2
|
||||
lint:
|
||||
@command -v golangci-lint >/dev/null 2>&1 \
|
||||
&& golangci-lint run --build-tags=integration ./... \
|
||||
|| go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(LINT_VERSION) run --build-tags=integration ./...
|
||||
|
||||
tidy:
|
||||
go mod tidy
|
||||
|
||||
# One command for a reviewer: unit tests + vet + lint, then the stack up and the
|
||||
# integration suite and the end-to-end smoke test. Needs Docker.
|
||||
check: vet lint
|
||||
go test -race ./...
|
||||
$(CHECK_COMPOSE) up -d --build
|
||||
@echo "waiting for the userservice to be ready..."
|
||||
@attempt=0; until curl -fsS "$(CHECK_HOST)/health" >/dev/null; do \
|
||||
attempt=$$((attempt + 1)); \
|
||||
if [ $$attempt -ge 30 ]; then $(CHECK_COMPOSE) logs userservice; exit 1; fi; \
|
||||
sleep 1; \
|
||||
done
|
||||
TEST_DATABASE_URL="$(CHECK_DATABASE_URL)" \
|
||||
go test -tags=integration ./internal/storage/postgres/ -v
|
||||
HOST="$(CHECK_HOST)" ./scripts/smoke.sh
|
||||
@echo "\nall checks passed ✓"
|
||||
|
||||
# --- migrations ----------------------------------------------------------
|
||||
# Requires the golang-migrate CLI and DATABASE_URL, e.g.:
|
||||
# export DATABASE_URL='postgres://scimesh:scimesh@localhost:5433/scimesh_users?sslmode=disable'
|
||||
migrate-up:
|
||||
migrate -path migrations -database "$(DATABASE_URL)" up
|
||||
|
||||
migrate-down:
|
||||
migrate -path migrations -database "$(DATABASE_URL)" down 1
|
||||
|
||||
# --- docker --------------------------------------------------------------
|
||||
up:
|
||||
docker compose up -d --build
|
||||
|
||||
down:
|
||||
docker compose down
|
||||
|
||||
down-clean:
|
||||
docker compose down -v
|
||||
|
||||
logs:
|
||||
docker compose logs -f userservice
|
||||
|
||||
ps:
|
||||
docker compose ps
|
||||
|
||||
psql:
|
||||
docker compose exec postgres psql -U scimesh -d scimesh_users
|
||||
|
||||
# --- api ------------------------------------------------------------------
|
||||
smoke:
|
||||
./scripts/smoke.sh
|
||||
@@ -0,0 +1,82 @@
|
||||
# SciMesh userservice
|
||||
|
||||
Authentication service for SciMesh, in Go on PostgreSQL. It owns user accounts
|
||||
and issues the JWTs the coordinator trusts. It is a **separate bounded context**
|
||||
from the coordinator: its own database, its own binary. The only thing shared
|
||||
between the two services is the JWT signing secret.
|
||||
|
||||
The versioned external contract is
|
||||
[`docs/user-service-api-contract.md`](../docs/user-service-api-contract.md).
|
||||
|
||||
Built as a modular monolith following Clean Architecture — one binary, four
|
||||
layers, dependencies pointing strictly inward:
|
||||
|
||||
```
|
||||
infra config, DB pool, clock, HTTP server ← drivers
|
||||
transport HTTP handlers + JWT middleware ← incoming
|
||||
storage SQL repository ← outgoing
|
||||
usecase Register / Login + PORTS (interfaces) ← application rules
|
||||
domain User, Role, invariants ← business rules
|
||||
auth bcrypt hasher, HS256 JWT issuer ← crypto adapters
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Auth | Purpose |
|
||||
|--------|---------------------------|--------------|---------------------------------------------|
|
||||
| GET | `/health` | none | Liveness probe (checks the database) |
|
||||
| POST | `/register` | none | Create an account (always role `user`) |
|
||||
| POST | `/login` | none | Verify credentials, return a signed JWT |
|
||||
| GET | `/me` | Bearer JWT | Return the caller's own account |
|
||||
| POST | `/users/{id}/verify` | Bearer admin | Grant the trusted-contributor badge |
|
||||
| POST | `/users/{id}/unverify` | Bearer admin | Revoke the badge |
|
||||
| POST | `/users/{id}/promote` | Bearer admin | Set the user's role to admin |
|
||||
| POST | `/users/{id}/demote` | Bearer admin | Set the user's role back to user |
|
||||
|
||||
Two independent attributes live on an account:
|
||||
|
||||
- **`role`** — `user` or `admin`. Governs what you may do with your own jobs.
|
||||
Registration always creates a `user`; promotion to `admin` is a manual
|
||||
database operation, never a request.
|
||||
- **`verified`** — a boolean trust badge, granted **only by an admin** (the
|
||||
`/verify` endpoints above, 403 for anyone else). It tells the coordinator
|
||||
whether this user's volunteer workers are trusted: a verified contributor's
|
||||
results are accepted directly, an unverified one's must pass quorum
|
||||
cross-checking. Defaults to false.
|
||||
|
||||
Both attributes ride in the JWT (`role`, `verified` claims), so the coordinator
|
||||
reads them from the signed token without ever calling this service.
|
||||
|
||||
## How it connects to the coordinator
|
||||
|
||||
The coordinator never calls this service at runtime. A client logs in here, gets
|
||||
a JWT, and presents it to the coordinator, which verifies the signature locally
|
||||
with the same `JWT_SECRET` and reads `sub` (the user id) into `jobs.owner_id`.
|
||||
|
||||
That link is **off by default**: until the coordinator is given a matching
|
||||
`JWT_SECRET`, it accepts only the shared worker token and stores `owner_id` as
|
||||
NULL. Set the same secret (≥ 32 bytes, byte-for-byte identical) on both services
|
||||
to turn it on.
|
||||
|
||||
## Run
|
||||
|
||||
```sh
|
||||
# whole stack: Postgres + migrations + the service on :8081
|
||||
make up
|
||||
|
||||
# or locally against your own Postgres
|
||||
cp .env.example .env # then edit JWT_SECRET and DATABASE_URL
|
||||
make run
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
```sh
|
||||
make test # unit tests
|
||||
make check # vet, lint, race, integration, smoke — needs Docker
|
||||
make smoke # end-to-end against a running service
|
||||
```
|
||||
|
||||
Password hashing uses bcrypt (`golang.org/x/crypto/bcrypt`); the salt and cost
|
||||
are embedded in the stored hash, so there is no separate salt column. Tokens are
|
||||
HS256 (`github.com/golang-jwt/jwt/v5`).
|
||||
@@ -0,0 +1,82 @@
|
||||
// Command userservice runs the SciMesh authentication service: it registers
|
||||
// users, verifies logins, and issues the HS256 JWTs the coordinator trusts.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
nethttp "net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/emil28092005/SciMesh/users/internal/auth"
|
||||
"github.com/emil28092005/SciMesh/users/internal/infra"
|
||||
"github.com/emil28092005/SciMesh/users/internal/storage/postgres"
|
||||
apihttp "github.com/emil28092005/SciMesh/users/internal/transport/http"
|
||||
"github.com/emil28092005/SciMesh/users/internal/usecase"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "fatal:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
// Cancelled on SIGINT/SIGTERM so the HTTP server drains in-flight requests
|
||||
// instead of dropping them.
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
cfg, err := infra.LoadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log, closer, err := infra.NewLogger(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = closer.Close() }()
|
||||
|
||||
pool, err := infra.NewPool(ctx, cfg, log)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
// Adapters implementing the usecase ports.
|
||||
users := postgres.NewUserRepo(pool)
|
||||
hasher := auth.NewHasher(cfg.BcryptCost)
|
||||
clock := infra.NewClock()
|
||||
issuer := auth.NewIssuer(cfg.JWTSecret, cfg.TokenTTL, clock.Now)
|
||||
|
||||
uc := apihttp.UseCases{
|
||||
Register: usecase.NewRegister(users, hasher, clock),
|
||||
Login: usecase.NewLogin(users, hasher, issuer),
|
||||
SetVerified: usecase.NewSetVerified(users),
|
||||
SetRole: usecase.NewSetRole(users),
|
||||
Users: users,
|
||||
}
|
||||
|
||||
// Seed the first admin, if configured. Idempotent: a no-op once it exists.
|
||||
if cfg.BootstrapAdminEmail != "" && cfg.BootstrapAdminPassword != "" {
|
||||
created, err := usecase.NewBootstrapAdmin(users, hasher, clock).
|
||||
Execute(ctx, cfg.BootstrapAdminEmail, cfg.BootstrapAdminPassword)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bootstrap admin: %w", err)
|
||||
}
|
||||
if created {
|
||||
log.Info("bootstrap admin created", "email", cfg.BootstrapAdminEmail)
|
||||
}
|
||||
}
|
||||
|
||||
handler := apihttp.NewServer(log, uc, issuer)
|
||||
// A blanket per-request deadline: bcrypt is bounded, so anything slower is a
|
||||
// stuck handler we want to shed rather than hold a connection open.
|
||||
handler = nethttp.TimeoutHandler(handler, cfg.RequestTimeout, `{"error":"request timeout"}`)
|
||||
|
||||
return infra.RunServer(ctx, log, cfg.Addr, handler)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
# A self-contained stack for the userservice: its own PostgreSQL (a separate
|
||||
# database from the coordinator's — different bounded context), a one-shot
|
||||
# migration step, and the service. The project name and host ports differ from
|
||||
# the coordinator's so both stacks can run side by side on one machine.
|
||||
name: scimesh-users
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-scimesh}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-scimesh}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-scimesh_users}
|
||||
ports:
|
||||
# 5433 on the host, so it never clashes with the coordinator's 5432.
|
||||
- "${POSTGRES_PORT:-5433}:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
# Everything else waits on this, so the check must prove the server
|
||||
# accepts queries — not merely that the port is open.
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-scimesh} -d ${POSTGRES_DB:-scimesh_users}"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
start_period: 5s
|
||||
|
||||
# One-shot: applies migrations, then exits. Schema changes stay an explicit
|
||||
# deployment step — the service binary never migrates on startup.
|
||||
migrate:
|
||||
image: migrate/migrate:v4.17.1
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./migrations:/migrations:ro
|
||||
command:
|
||||
- -path=/migrations
|
||||
- -database=postgres://${POSTGRES_USER:-scimesh}:${POSTGRES_PASSWORD:-scimesh}@postgres:5432/${POSTGRES_DB:-scimesh_users}?sslmode=disable
|
||||
- up
|
||||
restart: on-failure
|
||||
|
||||
userservice:
|
||||
build:
|
||||
context: .
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
# Start only once the schema exists, otherwise the first query fails.
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
USERSERVICE_ADDR: ":8081"
|
||||
# Host is the service name: compose resolves it on the project network.
|
||||
DATABASE_URL: postgres://${POSTGRES_USER:-scimesh}:${POSTGRES_PASSWORD:-scimesh}@postgres:5432/${POSTGRES_DB:-scimesh_users}?sslmode=disable
|
||||
# MUST match the coordinator's JWT secret so it can verify these tokens.
|
||||
JWT_SECRET: ${JWT_SECRET:-dev-secret-change-me-at-least-32-bytes}
|
||||
JWT_TTL: ${JWT_TTL:-24h}
|
||||
DB_MAX_CONNS: "10"
|
||||
REQUEST_TIMEOUT: "15s"
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
# Logs are teed to stdout (docker logs) and this rotated file on a named
|
||||
# volume, so they survive a rebuild.
|
||||
LOG_FILE: /var/log/scimesh/userservice.log
|
||||
ports:
|
||||
- "${USERSERVICE_PORT:-8081}:8081"
|
||||
# A named volume (not a host bind mount): it inherits the image's directory
|
||||
# ownership, so the non-root process can write to it.
|
||||
volumes:
|
||||
- userservice_logs:/var/log/scimesh
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8081/health"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
userservice_logs:
|
||||
@@ -0,0 +1,24 @@
|
||||
module github.com/emil28092005/SciMesh/users
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/Masterminds/squirrel v1.5.4
|
||||
github.com/cenkalti/backoff/v4 v4.3.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
golang.org/x/crypto v0.17.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
)
|
||||
|
||||
require (
|
||||
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
|
||||
golang.org/x/sync v0.1.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
|
||||
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
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/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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
||||
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw=
|
||||
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
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=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
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.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
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=
|
||||
@@ -0,0 +1,72 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/emil28092005/SciMesh/users/internal/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/users/internal/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,11 @@
|
||||
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")
|
||||
)
|
||||
@@ -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,13 @@
|
||||
// Clock: the real implementation of the usecase.Clock port. It lives out here
|
||||
// because reading the system clock is infrastructure; tests substitute a fixed one.
|
||||
package infra
|
||||
|
||||
import "time"
|
||||
|
||||
type System struct{}
|
||||
|
||||
func NewClock() System { return System{} }
|
||||
|
||||
// Now returns UTC so every timestamp this service writes is comparable
|
||||
// regardless of the host's timezone.
|
||||
func (System) Now() time.Time { return time.Now().UTC() }
|
||||
@@ -0,0 +1,160 @@
|
||||
// Config: userservice settings, read only from the environment, so the same
|
||||
// binary behaves identically in CI, local, and prod.
|
||||
package infra
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"math"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
// defaultEnvFile is loaded by LoadConfig unless ENV_FILE points elsewhere.
|
||||
const defaultEnvFile = ".env"
|
||||
|
||||
type Config struct {
|
||||
// HTTP listen address, e.g. ":8081".
|
||||
Addr string
|
||||
// PostgreSQL connection string (pgx format / libpq URL).
|
||||
DatabaseURL string
|
||||
|
||||
// Shared HS256 secret used to sign JWTs. The coordinator verifies tokens
|
||||
// with this same secret, so the two values MUST match. This is the only
|
||||
// secret shared between the services.
|
||||
JWTSecret string
|
||||
// How long an issued token stays valid.
|
||||
TokenTTL time.Duration
|
||||
// bcrypt work factor. 0 falls back to the library default (currently 10).
|
||||
BcryptCost int
|
||||
|
||||
// Optional first-admin bootstrap. When both are set and no such account
|
||||
// exists, the service creates it with role=admin on startup — the only way
|
||||
// to get the first admin, since /register always makes a plain user and
|
||||
// promotion needs an existing admin. Idempotent: a no-op once created.
|
||||
BootstrapAdminEmail string
|
||||
BootstrapAdminPassword string
|
||||
|
||||
// Minimum log level: debug, info, warn, error.
|
||||
LogLevel string
|
||||
// Path to a rotated log file. Empty logs to stdout only.
|
||||
LogFile string
|
||||
|
||||
// Connection pool upper bound.
|
||||
DBMaxConns int32
|
||||
// How long to keep retrying the initial database connection at startup
|
||||
// before giving up. Covers a Postgres container that is still booting.
|
||||
DBConnectTimeout time.Duration
|
||||
// Per-request timeout applied to every handler.
|
||||
RequestTimeout time.Duration
|
||||
}
|
||||
|
||||
// LoadConfig reads the environment and fails fast on anything required-but-
|
||||
// missing or malformed, so a misconfigured process never limps along half-wired.
|
||||
//
|
||||
// A .env file (path overridable via ENV_FILE) is loaded first as a local-dev
|
||||
// convenience. It only fills variables the environment does not already define.
|
||||
func LoadConfig() (Config, error) {
|
||||
envFile := os.Getenv("ENV_FILE")
|
||||
if envFile == "" {
|
||||
envFile = defaultEnvFile
|
||||
}
|
||||
// godotenv.Load never overwrites variables already present in the
|
||||
// environment, so an orchestrator's values always beat the file. A missing
|
||||
// file is expected in production, where env vars are injected directly.
|
||||
if err := godotenv.Load(envFile); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return Config{}, fmt.Errorf("load env file %q: %w", envFile, err)
|
||||
}
|
||||
|
||||
cfg := Config{
|
||||
Addr: getEnv("USERSERVICE_ADDR", ":8081"),
|
||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||
BootstrapAdminEmail: os.Getenv("BOOTSTRAP_ADMIN_EMAIL"),
|
||||
BootstrapAdminPassword: os.Getenv("BOOTSTRAP_ADMIN_PASSWORD"),
|
||||
LogLevel: getEnv("LOG_LEVEL", "info"),
|
||||
LogFile: os.Getenv("LOG_FILE"),
|
||||
TokenTTL: 24 * time.Hour,
|
||||
DBMaxConns: 10,
|
||||
DBConnectTimeout: 30 * time.Second,
|
||||
RequestTimeout: 15 * time.Second,
|
||||
}
|
||||
|
||||
if cfg.DatabaseURL == "" {
|
||||
return Config{}, fmt.Errorf("DATABASE_URL is required")
|
||||
}
|
||||
if cfg.JWTSecret == "" {
|
||||
return Config{}, fmt.Errorf("JWT_SECRET is required")
|
||||
}
|
||||
// A short secret makes the HMAC brute-forceable; refuse to start with one.
|
||||
if len(cfg.JWTSecret) < 32 {
|
||||
return Config{}, fmt.Errorf("JWT_SECRET must be at least 32 bytes")
|
||||
}
|
||||
|
||||
var err error
|
||||
if cfg.TokenTTL, err = getEnvDuration("JWT_TTL", cfg.TokenTTL); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.BcryptCost, err = getEnvInt("BCRYPT_COST", cfg.BcryptCost); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.DBMaxConns, err = getEnvInt32("DB_MAX_CONNS", cfg.DBMaxConns); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.DBConnectTimeout, err = getEnvDuration("DB_CONNECT_TIMEOUT", cfg.DBConnectTimeout); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.RequestTimeout, err = getEnvDuration("REQUEST_TIMEOUT", cfg.RequestTimeout); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func getEnv(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func getEnvInt(key string, def int) (int, error) {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return def, nil
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func getEnvInt32(key string, def int32) (int32, error) {
|
||||
n, err := getEnvInt(key, int(def))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// On 64-bit builds int is wider than int32, so an oversized value would
|
||||
// wrap silently — DB_MAX_CONNS=2147483648 becoming a negative pool size.
|
||||
if n < math.MinInt32 || n > math.MaxInt32 {
|
||||
return 0, fmt.Errorf("%s: %d is out of range for int32", key, n)
|
||||
}
|
||||
return int32(n), nil
|
||||
}
|
||||
|
||||
func getEnvDuration(key string, def time.Duration) (time.Duration, error) {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return def, nil
|
||||
}
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const validSecret = "a-secret-that-is-at-least-32-bytes!!"
|
||||
|
||||
// setBaseEnv wires the minimum valid environment. ENV_FILE points at a path that
|
||||
// does not exist so a developer's stray .env never leaks into the test.
|
||||
func setBaseEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("ENV_FILE", "/nonexistent/.env")
|
||||
t.Setenv("DATABASE_URL", "postgres://u:p@localhost:5432/db?sslmode=disable")
|
||||
t.Setenv("JWT_SECRET", validSecret)
|
||||
}
|
||||
|
||||
func TestLoadConfigDefaults(t *testing.T) {
|
||||
setBaseEnv(t)
|
||||
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg.Addr != ":8081" {
|
||||
t.Errorf("Addr default = %q, want :8081", cfg.Addr)
|
||||
}
|
||||
if cfg.TokenTTL != 24*time.Hour {
|
||||
t.Errorf("TokenTTL default = %v, want 24h", cfg.TokenTTL)
|
||||
}
|
||||
if cfg.JWTSecret != validSecret {
|
||||
t.Errorf("JWTSecret = %q", cfg.JWTSecret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigRequiresDatabaseURL(t *testing.T) {
|
||||
setBaseEnv(t)
|
||||
t.Setenv("DATABASE_URL", "")
|
||||
|
||||
if _, err := LoadConfig(); err == nil {
|
||||
t.Error("expected error when DATABASE_URL is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigRequiresJWTSecret(t *testing.T) {
|
||||
setBaseEnv(t)
|
||||
t.Setenv("JWT_SECRET", "")
|
||||
|
||||
if _, err := LoadConfig(); err == nil {
|
||||
t.Error("expected error when JWT_SECRET is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigRejectsShortJWTSecret(t *testing.T) {
|
||||
setBaseEnv(t)
|
||||
t.Setenv("JWT_SECRET", "too-short")
|
||||
|
||||
if _, err := LoadConfig(); err == nil {
|
||||
t.Error("expected error when JWT_SECRET is under 32 bytes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigOverrides(t *testing.T) {
|
||||
setBaseEnv(t)
|
||||
t.Setenv("USERSERVICE_ADDR", ":9000")
|
||||
t.Setenv("JWT_TTL", "1h")
|
||||
t.Setenv("BCRYPT_COST", "6")
|
||||
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg.Addr != ":9000" {
|
||||
t.Errorf("Addr = %q, want :9000", cfg.Addr)
|
||||
}
|
||||
if cfg.TokenTTL != time.Hour {
|
||||
t.Errorf("TokenTTL = %v, want 1h", cfg.TokenTTL)
|
||||
}
|
||||
if cfg.BcryptCost != 6 {
|
||||
t.Errorf("BcryptCost = %d, want 6", cfg.BcryptCost)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigRejectsMalformedDuration(t *testing.T) {
|
||||
setBaseEnv(t)
|
||||
t.Setenv("JWT_TTL", "not-a-duration")
|
||||
|
||||
if _, err := LoadConfig(); err == nil {
|
||||
t.Error("expected error for malformed JWT_TTL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigRejectsMalformedInt(t *testing.T) {
|
||||
setBaseEnv(t)
|
||||
t.Setenv("BCRYPT_COST", "abc")
|
||||
|
||||
if _, err := LoadConfig(); err == nil {
|
||||
t.Error("expected error for malformed BCRYPT_COST")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// DB: the PostgreSQL connection pool.
|
||||
package infra
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// NewPool builds the single shared pool. The caller owns its lifetime and must
|
||||
// Close() it on shutdown.
|
||||
func NewPool(ctx context.Context, cfg Config, log *slog.Logger) (*pgxpool.Pool, error) {
|
||||
poolCfg, err := pgxpool.ParseConfig(cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
poolCfg.MaxConns = cfg.DBMaxConns
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, poolCfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// pgxpool.New is lazy, so a ping is needed to actually reach the server.
|
||||
// It is retried because at startup — especially under docker-compose, where
|
||||
// this service can boot before Postgres is accepting connections — a
|
||||
// service should wait for its database rather than crash-loop.
|
||||
if err := pingWithRetry(ctx, pool, cfg.DBConnectTimeout, log); err != nil {
|
||||
pool.Close()
|
||||
return nil, err
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
// pingWithRetry waits for the database to accept connections, backing off
|
||||
// between attempts until the budget elapses or ctx is cancelled.
|
||||
//
|
||||
// Unlike the transaction retry in storage/postgres, this retries *any* ping
|
||||
// error: at startup a "connection refused" is the expected, retryable state,
|
||||
// not an anomaly.
|
||||
func pingWithRetry(ctx context.Context, pool *pgxpool.Pool, budget time.Duration, log *slog.Logger) error {
|
||||
b := backoff.NewExponentialBackOff()
|
||||
b.InitialInterval = 200 * time.Millisecond
|
||||
b.MaxInterval = 3 * time.Second
|
||||
b.MaxElapsedTime = budget
|
||||
|
||||
attempt := 0
|
||||
return backoff.RetryNotify(
|
||||
func() error {
|
||||
// A bounded per-attempt timeout so one hung dial cannot eat the
|
||||
// whole budget in a single try.
|
||||
pingCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
return pool.Ping(pingCtx)
|
||||
},
|
||||
backoff.WithContext(b, ctx),
|
||||
func(err error, next time.Duration) {
|
||||
attempt++
|
||||
log.Warn("database not ready, retrying",
|
||||
"attempt", attempt, "retry_in", next.String(), "err", err)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
)
|
||||
|
||||
// NewLogger builds the process logger.
|
||||
//
|
||||
// It always writes JSON to stdout, so `docker logs` and any 12-factor log
|
||||
// collector keep working. When LogFile is set it *also* writes to a
|
||||
// size-rotated file, so logs survive a container rebuild instead of vanishing
|
||||
// with the previous stdout stream. Rotation is delegated to lumberjack rather
|
||||
// than hand-rolled.
|
||||
//
|
||||
// The returned Closer flushes and closes the file; call it on shutdown.
|
||||
func NewLogger(cfg Config) (*slog.Logger, io.Closer, error) {
|
||||
opts := &slog.HandlerOptions{Level: parseLevel(cfg.LogLevel)}
|
||||
|
||||
var (
|
||||
out io.Writer = os.Stdout
|
||||
closer io.Closer = noopCloser{}
|
||||
)
|
||||
|
||||
if cfg.LogFile != "" {
|
||||
if err := os.MkdirAll(filepath.Dir(cfg.LogFile), 0o750); err != nil {
|
||||
return nil, nil, fmt.Errorf("create log directory: %w", err)
|
||||
}
|
||||
rotator := &lumberjack.Logger{
|
||||
Filename: cfg.LogFile,
|
||||
MaxSize: 50, // megabytes before a rotation
|
||||
MaxBackups: 5, // keep this many rotated files
|
||||
MaxAge: 30, // days
|
||||
Compress: true,
|
||||
}
|
||||
// Tee to both: the console stays live while the file is the durable copy.
|
||||
out = io.MultiWriter(os.Stdout, rotator)
|
||||
closer = rotator
|
||||
}
|
||||
|
||||
return slog.New(slog.NewJSONHandler(out, opts)), closer, nil
|
||||
}
|
||||
|
||||
func parseLevel(s string) slog.Level {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "debug":
|
||||
return slog.LevelDebug
|
||||
case "warn", "warning":
|
||||
return slog.LevelWarn
|
||||
case "error":
|
||||
return slog.LevelError
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
|
||||
type noopCloser struct{}
|
||||
|
||||
func (noopCloser) Close() error { return nil }
|
||||
@@ -0,0 +1,44 @@
|
||||
// Server: the HTTP listener, shut down cleanly on a signal.
|
||||
package infra
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const shutdownGrace = 15 * time.Second
|
||||
|
||||
// RunServer serves handler until ctx is cancelled, then drains in-flight requests.
|
||||
func RunServer(ctx context.Context, log *slog.Logger, addr string, handler http.Handler) error {
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
// Buffered so this goroutine can exit even when nobody reads the channel
|
||||
// (the ctx.Done branch below) — an unbuffered send would leak it forever.
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
log.Info("userservice listening", "addr", addr)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
log.Info("shutdown signal received")
|
||||
}
|
||||
|
||||
// A fresh context: ctx is already cancelled, and reusing it would abort the
|
||||
// very requests we are trying to let finish.
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownGrace)
|
||||
defer cancel()
|
||||
return srv.Shutdown(shutdownCtx)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Package memstore provides in-memory implementations of the usecase ports for
|
||||
// fast, deterministic tests that need no database.
|
||||
package memstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/emil28092005/SciMesh/users/internal/domain"
|
||||
"github.com/emil28092005/SciMesh/users/internal/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
|
||||
}
|
||||
|
||||
// Clock is a fixed usecase.Clock for deterministic tests.
|
||||
type Clock struct{ T time.Time }
|
||||
|
||||
func (c Clock) Now() time.Time { return c.T }
|
||||
@@ -0,0 +1,11 @@
|
||||
package postgres
|
||||
|
||||
import sq "github.com/Masterminds/squirrel"
|
||||
|
||||
// psql is the shared statement builder, fixed to PostgreSQL $N placeholders so
|
||||
// no call site repeats PlaceholderFormat(sq.Dollar).
|
||||
//
|
||||
// Not everything goes through it. Two genuinely set-based statements stay as
|
||||
// raw SQL — claimNext (a FOR UPDATE SKIP LOCKED CTE) and expireLeases (CASE
|
||||
// logic in the SET) — because a builder would obscure them, not clarify them.
|
||||
var psql = sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
|
||||
@@ -0,0 +1,165 @@
|
||||
//go:build integration
|
||||
|
||||
// Integration tests run against a real PostgreSQL instance supplied through
|
||||
// TEST_DATABASE_URL, with the userservice migrations already applied. A real DB
|
||||
// is required because the guarantees under test — the unique-email constraint
|
||||
// mapping to ErrEmailExists, the ck_users_email_lower check — are properties of
|
||||
// Postgres, not of the Go code.
|
||||
//
|
||||
// docker compose up -d
|
||||
// TEST_DATABASE_URL='postgres://scimesh:scimesh@localhost:5432/scimesh?sslmode=disable' \
|
||||
// go test -tags=integration ./internal/storage/postgres/ -v
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/emil28092005/SciMesh/users/internal/domain"
|
||||
"github.com/emil28092005/SciMesh/users/internal/usecase"
|
||||
)
|
||||
|
||||
func testPool(t *testing.T) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL is not set")
|
||||
}
|
||||
pool, err := pgxpool.New(context.Background(), url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
return pool
|
||||
}
|
||||
|
||||
// seedUser inserts a user with a unique email and removes it afterwards, so
|
||||
// tests stay independent of each other and of leftovers from earlier runs.
|
||||
func seedUser(t *testing.T, repo *UserRepo) *domain.User {
|
||||
t.Helper()
|
||||
email := fmt.Sprintf("it-%s@example.com", uuid.NewString())
|
||||
u, err := domain.NewUser(email, "$2a$04$abcdefghijklmnopqrstuv", time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatalf("build user: %v", err)
|
||||
}
|
||||
if err := repo.Insert(context.Background(), u); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = repo.pool.Exec(context.Background(), "DELETE FROM users WHERE id = $1", u.ID)
|
||||
})
|
||||
return u
|
||||
}
|
||||
|
||||
func TestUserRepoInsertAndGet(t *testing.T) {
|
||||
repo := NewUserRepo(testPool(t))
|
||||
ctx := context.Background()
|
||||
want := seedUser(t, repo)
|
||||
|
||||
byEmail, err := repo.GetByEmail(ctx, want.Email)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByEmail: %v", err)
|
||||
}
|
||||
if byEmail.ID != want.ID || byEmail.Email != want.Email || byEmail.Role != domain.RoleUser {
|
||||
t.Errorf("GetByEmail mismatch: %+v", byEmail)
|
||||
}
|
||||
|
||||
byID, err := repo.GetByID(ctx, want.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByID: %v", err)
|
||||
}
|
||||
if byID.Email != want.Email {
|
||||
t.Errorf("GetByID mismatch: %+v", byID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRepoDuplicateEmail(t *testing.T) {
|
||||
repo := NewUserRepo(testPool(t))
|
||||
existing := seedUser(t, repo)
|
||||
|
||||
// A second user with the same email must hit the unique constraint and map
|
||||
// to the port's sentinel error.
|
||||
dup, err := domain.NewUser(existing.Email, "$2a$04$abcdefghijklmnopqrstuv", time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = repo.Insert(context.Background(), dup)
|
||||
if !errors.Is(err, usecase.ErrEmailExists) {
|
||||
t.Errorf("got %v, want ErrEmailExists", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRepoNotFound(t *testing.T) {
|
||||
repo := NewUserRepo(testPool(t))
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := repo.GetByID(ctx, uuid.New()); !errors.Is(err, usecase.ErrUserNotFound) {
|
||||
t.Errorf("GetByID unknown: got %v, want ErrUserNotFound", err)
|
||||
}
|
||||
if _, err := repo.GetByEmail(ctx, "ghost@example.com"); !errors.Is(err, usecase.ErrUserNotFound) {
|
||||
t.Errorf("GetByEmail unknown: got %v, want ErrUserNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRepoSetVerified(t *testing.T) {
|
||||
repo := NewUserRepo(testPool(t))
|
||||
ctx := context.Background()
|
||||
u := seedUser(t, repo)
|
||||
|
||||
// A fresh row defaults to unverified.
|
||||
got, err := repo.GetByID(ctx, u.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Verified {
|
||||
t.Fatal("new user must default to unverified")
|
||||
}
|
||||
|
||||
if err := repo.SetVerified(ctx, u.ID, true); err != nil {
|
||||
t.Fatalf("grant: %v", err)
|
||||
}
|
||||
got, _ = repo.GetByID(ctx, u.ID)
|
||||
if !got.Verified {
|
||||
t.Error("verified flag not persisted")
|
||||
}
|
||||
|
||||
if err := repo.SetVerified(ctx, u.ID, false); err != nil {
|
||||
t.Fatalf("revoke: %v", err)
|
||||
}
|
||||
got, _ = repo.GetByID(ctx, u.ID)
|
||||
if got.Verified {
|
||||
t.Error("verified flag not cleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRepoSetVerifiedUnknown(t *testing.T) {
|
||||
repo := NewUserRepo(testPool(t))
|
||||
if err := repo.SetVerified(context.Background(), uuid.New(), true); !errors.Is(err, usecase.ErrUserNotFound) {
|
||||
t.Errorf("got %v, want ErrUserNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRepoSetRole(t *testing.T) {
|
||||
repo := NewUserRepo(testPool(t))
|
||||
ctx := context.Background()
|
||||
u := seedUser(t, repo)
|
||||
|
||||
if err := repo.SetRole(ctx, u.ID, domain.RoleAdmin); err != nil {
|
||||
t.Fatalf("promote: %v", err)
|
||||
}
|
||||
got, _ := repo.GetByID(ctx, u.ID)
|
||||
if got.Role != domain.RoleAdmin {
|
||||
t.Errorf("role = %q, want admin", got.Role)
|
||||
}
|
||||
|
||||
if err := repo.SetRole(ctx, uuid.New(), domain.RoleAdmin); !errors.Is(err, usecase.ErrUserNotFound) {
|
||||
t.Errorf("unknown user: got %v, want ErrUserNotFound", err)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user