Add admin console access and platform pages (trust, users, keys, workloads, settings)
This commit is contained in:
@@ -77,6 +77,7 @@ type storageDeps struct {
|
|||||||
artifactRepo usecase.ArtifactRepository
|
artifactRepo usecase.ArtifactRepository
|
||||||
uiReadRepo usecase.UIReadRepository
|
uiReadRepo usecase.UIReadRepository
|
||||||
adminReadRepo usecase.AdminReadRepository
|
adminReadRepo usecase.AdminReadRepository
|
||||||
|
settingsRepo usecase.WorkloadSettingsRepository
|
||||||
taskResultRepo usecase.TaskResultRepository
|
taskResultRepo usecase.TaskResultRepository
|
||||||
statsRepo interface {
|
statsRepo interface {
|
||||||
Counts(ctx context.Context) (tasks, jobs, workers map[string]int, err error)
|
Counts(ctx context.Context) (tasks, jobs, workers map[string]int, err error)
|
||||||
@@ -159,7 +160,7 @@ func runWithConfig(cfg infra.Config) error {
|
|||||||
useCases := httptransport.UseCases{
|
useCases := httptransport.UseCases{
|
||||||
RegisterWorker: usecase.NewRegisterWorker(workerRepo, clk),
|
RegisterWorker: usecase.NewRegisterWorker(workerRepo, clk),
|
||||||
CreateJob: usecase.NewCreateJob(jobRepo, taskRepo, tx, clk),
|
CreateJob: usecase.NewCreateJob(jobRepo, taskRepo, tx, clk),
|
||||||
SubmitDataset: usecase.NewSubmitDataset(blobStore, artifactRepo, jobRepo, taskRepo, tx, clk, cfg.DefaultMaxAttempts, catalog),
|
SubmitDataset: usecase.NewSubmitDataset(blobStore, artifactRepo, jobRepo, taskRepo, tx, clk, cfg.DefaultMaxAttempts, catalog, deps.settingsRepo),
|
||||||
ClaimTask: usecase.NewClaimTask(taskRepo, jobRepo, workerRepo, tx, clk, cfg.LeaseDuration, catalog),
|
ClaimTask: usecase.NewClaimTask(taskRepo, jobRepo, workerRepo, tx, clk, cfg.LeaseDuration, catalog),
|
||||||
RenewLease: usecase.NewRenewLease(taskRepo, workerRepo, tx, clk, cfg.LeaseDuration),
|
RenewLease: usecase.NewRenewLease(taskRepo, workerRepo, tx, clk, cfg.LeaseDuration),
|
||||||
CompleteTask: usecase.NewCompleteTask(taskRepo, jobRepo, artifactRepo, workerRepo, taskResultRepo, tx, clk, cfg.QuorumSize, catalog),
|
CompleteTask: usecase.NewCompleteTask(taskRepo, jobRepo, artifactRepo, workerRepo, taskResultRepo, tx, clk, cfg.QuorumSize, catalog),
|
||||||
@@ -173,16 +174,21 @@ func runWithConfig(cfg infra.Config) error {
|
|||||||
GetTaskInput: usecase.NewGetTaskInput(taskRepo, artifactRepo, blobStore),
|
GetTaskInput: usecase.NewGetTaskInput(taskRepo, artifactRepo, blobStore),
|
||||||
Dashboard: usecase.NewDashboard(uiReadRepo, catalog),
|
Dashboard: usecase.NewDashboard(uiReadRepo, catalog),
|
||||||
PreviewArtifact: usecase.NewPreviewArtifact(uiReadRepo, blobStore),
|
PreviewArtifact: usecase.NewPreviewArtifact(uiReadRepo, blobStore),
|
||||||
Admin: usecase.NewAdmin(deps.adminReadRepo, uiReadRepo, usecase.AdminNodeInfo{
|
Admin: usecase.NewAdmin(deps.adminReadRepo, uiReadRepo, workerRepo, deps.settingsRepo, catalog,
|
||||||
Version: version,
|
usecase.AdminNodeInfo{
|
||||||
StartedAt: clk.Now(),
|
Version: version,
|
||||||
Binary: executablePath(),
|
StartedAt: clk.Now(),
|
||||||
Addr: cfg.Addr,
|
Binary: executablePath(),
|
||||||
DataDir: cfg.StorageDir,
|
Addr: cfg.Addr,
|
||||||
DBEngine: cfg.DatabaseEngine,
|
DataDir: cfg.StorageDir,
|
||||||
PublicURL: cfg.PublicCoordinatorURL,
|
DBEngine: cfg.DatabaseEngine,
|
||||||
Userservice: cfg.UserserviceURL,
|
PublicURL: cfg.PublicCoordinatorURL,
|
||||||
}, deps.ready, clk.Now),
|
Userservice: cfg.UserserviceURL,
|
||||||
|
WorkerToken: func() string { return cfg.Token },
|
||||||
|
}, deps.ready, clk.Now).
|
||||||
|
WithAuditLog(log, func(ctx context.Context, action, detail string) {
|
||||||
|
log.Info("admin audit", "action", action, "detail", detail)
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Background reapers are tracked so shutdown can wait for them. Without this
|
// Background reapers are tracked so shutdown can wait for them. Without this
|
||||||
@@ -264,6 +270,7 @@ func openSQLite(ctx context.Context, cfg infra.Config, log *slog.Logger) (*stora
|
|||||||
artifactRepo: sqlite.NewArtifactRepo(db),
|
artifactRepo: sqlite.NewArtifactRepo(db),
|
||||||
uiReadRepo: sqlite.NewUIReadRepo(db),
|
uiReadRepo: sqlite.NewUIReadRepo(db),
|
||||||
adminReadRepo: sqlite.NewAdminReadRepo(db),
|
adminReadRepo: sqlite.NewAdminReadRepo(db),
|
||||||
|
settingsRepo: sqlite.NewWorkloadSettingsRepo(db),
|
||||||
taskResultRepo: sqlite.NewTaskResultRepo(db),
|
taskResultRepo: sqlite.NewTaskResultRepo(db),
|
||||||
statsRepo: sqlite.NewStatsRepo(db),
|
statsRepo: sqlite.NewStatsRepo(db),
|
||||||
ready: func(ctx context.Context) error { return db.PingContext(ctx) },
|
ready: func(ctx context.Context) error { return db.PingContext(ctx) },
|
||||||
@@ -287,6 +294,7 @@ func openPostgres(ctx context.Context, cfg infra.Config, log *slog.Logger) (*sto
|
|||||||
artifactRepo: postgres.NewArtifactRepo(pool),
|
artifactRepo: postgres.NewArtifactRepo(pool),
|
||||||
uiReadRepo: postgres.NewUIReadRepo(pool),
|
uiReadRepo: postgres.NewUIReadRepo(pool),
|
||||||
adminReadRepo: postgres.NewAdminReadRepo(pool),
|
adminReadRepo: postgres.NewAdminReadRepo(pool),
|
||||||
|
settingsRepo: postgres.NewWorkloadSettingsRepo(pool),
|
||||||
taskResultRepo: postgres.NewTaskResultRepo(pool),
|
taskResultRepo: postgres.NewTaskResultRepo(pool),
|
||||||
statsRepo: postgres.NewStatsRepo(pool),
|
statsRepo: postgres.NewStatsRepo(pool),
|
||||||
ready: func(ctx context.Context) error { return pool.Ping(ctx) },
|
ready: func(ctx context.Context) error { return pool.Ping(ctx) },
|
||||||
|
|||||||
@@ -18,4 +18,5 @@ var (
|
|||||||
ErrResultConflict = errors.New("different result already recorded")
|
ErrResultConflict = errors.New("different result already recorded")
|
||||||
ErrInvalidInput = errors.New("invalid input")
|
ErrInvalidInput = errors.New("invalid input")
|
||||||
ErrTaskNotLeased = errors.New("task is not currently leased")
|
ErrTaskNotLeased = errors.New("task is not currently leased")
|
||||||
|
ErrWorkloadDisabled = errors.New("workload is disabled")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -314,6 +314,17 @@ func (r *WorkerRepo) MarkStaleOffline(ctx context.Context, cutoff time.Time) (in
|
|||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *WorkerRepo) SetTrust(ctx context.Context, id uuid.UUID, trust domain.WorkerTrust) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
w, ok := r.workers[id]
|
||||||
|
if !ok {
|
||||||
|
return domain.ErrWorkerNotFound
|
||||||
|
}
|
||||||
|
w.TrustLevel = trust
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// --- ArtifactRepo --------------------------------------------------------
|
// --- ArtifactRepo --------------------------------------------------------
|
||||||
|
|
||||||
type ArtifactRepo struct {
|
type ArtifactRepo struct {
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ func expectedMigrationName(version int) string {
|
|||||||
return "0012_worker_trust.up.sql"
|
return "0012_worker_trust.up.sql"
|
||||||
case 13:
|
case 13:
|
||||||
return "0013_task_results.up.sql"
|
return "0013_task_results.up.sql"
|
||||||
|
case 14:
|
||||||
|
return "0014_workload_settings.up.sql"
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS workload_settings;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Per-workload enable/disable. Absence of a row means "enabled" (the catalog
|
||||||
|
-- default); a row only exists once an admin flipped a workload off or back on.
|
||||||
|
CREATE TABLE workload_settings (
|
||||||
|
workload text NOT NULL PRIMARY KEY,
|
||||||
|
enabled boolean NOT NULL,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -91,6 +91,24 @@ func (r *WorkerRepo) MarkStaleOffline(ctx context.Context, cutoff time.Time) (in
|
|||||||
return tag.RowsAffected(), nil
|
return tag.RowsAffected(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *WorkerRepo) SetTrust(ctx context.Context, id uuid.UUID, trust domain.WorkerTrust) error {
|
||||||
|
sql, args, err := psql.Update("workers").
|
||||||
|
SetMap(map[string]any{"trust_level": string(trust), "updated_at": time.Now()}).
|
||||||
|
Where(sq.Eq{"id": id}).
|
||||||
|
ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tag, err := conn(ctx, r.pool).Exec(ctx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set worker trust: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return domain.ErrWorkerNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func scanWorker(row pgx.Row) (*domain.Worker, error) {
|
func scanWorker(row pgx.Row) (*domain.Worker, error) {
|
||||||
var (
|
var (
|
||||||
w domain.Worker
|
w domain.Worker
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WorkloadSettingsRepo persists the per-workload enable/disable overrides.
|
||||||
|
// Absence of a row means the workload is enabled (the catalog default).
|
||||||
|
type WorkloadSettingsRepo struct{ pool *pgxpool.Pool }
|
||||||
|
|
||||||
|
func NewWorkloadSettingsRepo(pool *pgxpool.Pool) *WorkloadSettingsRepo {
|
||||||
|
return &WorkloadSettingsRepo{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ usecase.WorkloadSettingsRepository = (*WorkloadSettingsRepo)(nil)
|
||||||
|
|
||||||
|
func (r *WorkloadSettingsRepo) GetEnabled(ctx context.Context, workload string) (bool, error) {
|
||||||
|
var enabled bool
|
||||||
|
err := conn(ctx, r.pool).QueryRow(ctx,
|
||||||
|
"SELECT enabled FROM workload_settings WHERE workload = $1", workload).Scan(&enabled)
|
||||||
|
if err != nil && err.Error() == "no rows in result set" {
|
||||||
|
return true, nil // no override: catalog default enabled
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("get workload setting: %w", err)
|
||||||
|
}
|
||||||
|
return enabled, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkloadSettingsRepo) List(ctx context.Context) ([]usecase.WorkloadSetting, error) {
|
||||||
|
rows, err := conn(ctx, r.pool).Query(ctx,
|
||||||
|
"SELECT workload, enabled, updated_at FROM workload_settings ORDER BY workload ASC")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list workload settings: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []usecase.WorkloadSetting
|
||||||
|
for rows.Next() {
|
||||||
|
var s usecase.WorkloadSetting
|
||||||
|
if err := rows.Scan(&s.Workload, &s.Enabled, &s.UpdatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkloadSettingsRepo) SetEnabled(ctx context.Context, workload string, enabled bool, now time.Time) error {
|
||||||
|
sql, args, err := psql.Insert("workload_settings").
|
||||||
|
Columns("workload", "enabled", "updated_at").
|
||||||
|
Values(workload, enabled, now).
|
||||||
|
Suffix(`ON CONFLICT (workload) DO UPDATE SET enabled = EXCLUDED.enabled, updated_at = EXCLUDED.updated_at`).
|
||||||
|
ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := conn(ctx, r.pool).Exec(ctx, sql, args...); err != nil {
|
||||||
|
return fmt.Errorf("set workload setting: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWorkloadSettingsRepoRoundTrip(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := NewWorkloadSettingsRepo(db)
|
||||||
|
|
||||||
|
// No override: enabled by default.
|
||||||
|
enabled, err := repo.GetEnabled(ctx, "similarity-search")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !enabled {
|
||||||
|
t.Error("workload without an override must be enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
|
||||||
|
if err := repo.SetEnabled(ctx, "similarity-search", false, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
enabled, err = repo.GetEnabled(ctx, "similarity-search")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if enabled {
|
||||||
|
t.Error("workload must be disabled after the override")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert flips it back and updates the timestamp.
|
||||||
|
later := now.Add(time.Hour)
|
||||||
|
if err := repo.SetEnabled(ctx, "similarity-search", true, later); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
enabled, err = repo.GetEnabled(ctx, "similarity-search")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !enabled {
|
||||||
|
t.Error("workload must be re-enabled after the upsert")
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := repo.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(list) != 1 || list[0].Workload != "similarity-search" || !list[0].Enabled {
|
||||||
|
t.Errorf("list = %+v, want the single re-enabled override", list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkerSetTrust(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := NewWorkerRepo(db)
|
||||||
|
|
||||||
|
worker, err := domain.NewWorker("lab-node", []string{"similarity-search"}, fixedTime())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := repo.Insert(ctx, worker); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := repo.SetTrust(ctx, worker.ID, domain.WorkerUntrusted); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := repo.Get(ctx, worker.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.TrustLevel != domain.WorkerUntrusted {
|
||||||
|
t.Errorf("trust = %q, want untrusted", got.TrustLevel)
|
||||||
|
}
|
||||||
|
if err := repo.SetTrust(ctx, worker.ID, domain.WorkerTrusted); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := repo.SetTrust(ctx, uuid.New(), domain.WorkerTrusted); err != domain.ErrWorkerNotFound {
|
||||||
|
t.Errorf("unknown worker trust err = %v, want ErrWorkerNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- 0002: per-workload enable/disable. Absence of a row means "enabled" (the
|
||||||
|
-- catalog default); a row only exists once an admin flipped a workload off or
|
||||||
|
-- back on.
|
||||||
|
CREATE TABLE IF NOT EXISTS workload_settings (
|
||||||
|
workload TEXT NOT NULL PRIMARY KEY,
|
||||||
|
enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
@@ -69,8 +69,8 @@ func TestMigrateIsIdempotent(t *testing.T) {
|
|||||||
if err := db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil {
|
if err := db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if version != 1 {
|
if version != 2 {
|
||||||
t.Errorf("user_version = %d, want 1", version)
|
t.Errorf("user_version = %d, want 2", version)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,3 +90,20 @@ func (r *WorkerRepo) MarkStaleOffline(ctx context.Context, cutoff time.Time) (in
|
|||||||
}
|
}
|
||||||
return res.RowsAffected()
|
return res.RowsAffected()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *WorkerRepo) SetTrust(ctx context.Context, id uuid.UUID, trust domain.WorkerTrust) error {
|
||||||
|
res, err := conn(ctx, r.db).ExecContext(ctx,
|
||||||
|
"UPDATE workers SET trust_level = ?, updated_at = ? WHERE id = ?",
|
||||||
|
string(trust), encodeTime(time.Now()), id.String())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected == 0 {
|
||||||
|
return domain.ErrWorkerNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WorkloadSettingsRepo persists the per-workload enable/disable overrides.
|
||||||
|
// Absence of a row means the workload is enabled (the catalog default).
|
||||||
|
type WorkloadSettingsRepo struct{ db *sql.DB }
|
||||||
|
|
||||||
|
func NewWorkloadSettingsRepo(db *sql.DB) *WorkloadSettingsRepo { return &WorkloadSettingsRepo{db: db} }
|
||||||
|
|
||||||
|
var _ usecase.WorkloadSettingsRepository = (*WorkloadSettingsRepo)(nil)
|
||||||
|
|
||||||
|
func (r *WorkloadSettingsRepo) GetEnabled(ctx context.Context, workload string) (bool, error) {
|
||||||
|
var enabled int
|
||||||
|
err := conn(ctx, r.db).QueryRowContext(ctx,
|
||||||
|
"SELECT enabled FROM workload_settings WHERE workload = ?", workload).Scan(&enabled)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return true, nil // no override: catalog default enabled
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("get workload setting: %w", err)
|
||||||
|
}
|
||||||
|
return enabled == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkloadSettingsRepo) List(ctx context.Context) ([]usecase.WorkloadSetting, error) {
|
||||||
|
rows, err := conn(ctx, r.db).QueryContext(ctx,
|
||||||
|
"SELECT workload, enabled, updated_at FROM workload_settings ORDER BY workload ASC")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list workload settings: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
var out []usecase.WorkloadSetting
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
name string
|
||||||
|
enabled int
|
||||||
|
updatedAt int64
|
||||||
|
)
|
||||||
|
if err := rows.Scan(&name, &enabled, &updatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, usecase.WorkloadSetting{
|
||||||
|
Workload: name,
|
||||||
|
Enabled: enabled == 1,
|
||||||
|
UpdatedAt: decodeTime(updatedAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkloadSettingsRepo) SetEnabled(ctx context.Context, workload string, enabled bool, now time.Time) error {
|
||||||
|
_, err := conn(ctx, r.db).ExecContext(ctx, `
|
||||||
|
INSERT INTO workload_settings (workload, enabled, updated_at) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT (workload) DO UPDATE SET enabled = excluded.enabled, updated_at = excluded.updated_at`,
|
||||||
|
workload, boolInt(enabled), now.UnixNano())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set workload setting: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolInt(b bool) int {
|
||||||
|
if b {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -42,7 +42,7 @@ func (s *Server) writeError(w http.ResponseWriter, r *http.Request, err error) {
|
|||||||
|
|
||||||
status := http.StatusInternalServerError
|
status := http.StatusInternalServerError
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, domain.ErrInvalidInput):
|
case errors.Is(err, domain.ErrInvalidInput), errors.Is(err, domain.ErrWorkloadDisabled):
|
||||||
status = http.StatusBadRequest
|
status = http.StatusBadRequest
|
||||||
case errors.Is(err, domain.ErrJobNotFound), errors.Is(err, domain.ErrTaskNotFound),
|
case errors.Is(err, domain.ErrJobNotFound), errors.Is(err, domain.ErrTaskNotFound),
|
||||||
errors.Is(err, domain.ErrWorkerNotFound), errors.Is(err, domain.ErrArtifactNotFound):
|
errors.Is(err, domain.ErrWorkerNotFound), errors.Is(err, domain.ErrArtifactNotFound):
|
||||||
|
|||||||
@@ -182,6 +182,16 @@ func (s *Server) Handler(token string, uiToken ...string) http.Handler {
|
|||||||
ui.Handle("GET /ui/admin/api/system", chain(http.HandlerFunc(s.handleUIAdminSystemJSON), gate, requireAdmin))
|
ui.Handle("GET /ui/admin/api/system", chain(http.HandlerFunc(s.handleUIAdminSystemJSON), gate, requireAdmin))
|
||||||
ui.Handle("GET /ui/admin/api/jobs", chain(http.HandlerFunc(s.handleUIAdminJobsJSON), gate, requireAdmin))
|
ui.Handle("GET /ui/admin/api/jobs", chain(http.HandlerFunc(s.handleUIAdminJobsJSON), gate, requireAdmin))
|
||||||
ui.Handle("GET /ui/admin/api/metrics", chain(http.HandlerFunc(s.handleUIAdminMetricsJSON), gate, requireAdmin))
|
ui.Handle("GET /ui/admin/api/metrics", chain(http.HandlerFunc(s.handleUIAdminMetricsJSON), gate, requireAdmin))
|
||||||
|
ui.Handle("GET /ui/admin/api/workers", chain(http.HandlerFunc(s.handleUIAdminWorkersJSON), gate, requireAdmin))
|
||||||
|
ui.Handle("POST /ui/admin/api/workers/{id}/trust", chain(http.HandlerFunc(s.handleUIAdminSetTrustJSON), gate, requireAdmin))
|
||||||
|
ui.Handle("GET /ui/admin/api/users", chain(http.HandlerFunc(s.handleUIAdminUsersJSON), gate, requireAdmin))
|
||||||
|
ui.Handle("POST /ui/admin/api/users/{id}/role", chain(http.HandlerFunc(s.handleUIAdminSetUserRoleJSON), gate, requireAdmin))
|
||||||
|
ui.Handle("GET /ui/admin/api/worker-keys", chain(http.HandlerFunc(s.handleUIAdminWorkerKeysJSON), gate, requireAdmin))
|
||||||
|
ui.Handle("POST /ui/admin/api/worker-keys/{id}/revoke", chain(http.HandlerFunc(s.handleUIAdminRevokeKeyJSON), gate, requireAdmin))
|
||||||
|
ui.Handle("GET /ui/admin/api/workloads", chain(http.HandlerFunc(s.handleUIAdminWorkloadsJSON), gate, requireAdmin))
|
||||||
|
ui.Handle("POST /ui/admin/api/workloads/{name}/enabled", chain(http.HandlerFunc(s.handleUIAdminSetWorkloadEnabledJSON), gate, requireAdmin))
|
||||||
|
ui.Handle("GET /ui/admin/api/settings", chain(http.HandlerFunc(s.handleUIAdminSettingsJSON), gate, requireAdmin))
|
||||||
|
ui.Handle("POST /ui/admin/api/token/reveal", chain(http.HandlerFunc(s.handleUIAdminRevealTokenJSON), gate, requireAdmin))
|
||||||
} else {
|
} else {
|
||||||
for _, rt := range app {
|
for _, rt := range app {
|
||||||
ui.HandleFunc(rt.pattern, rt.handler)
|
ui.HandleFunc(rt.pattern, rt.handler)
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ func newEnvWithUIToken(t *testing.T, ready func(context.Context) error, configur
|
|||||||
work := memstore.NewWorkerRepo()
|
work := memstore.NewWorkerRepo()
|
||||||
arts := memstore.NewArtifactRepo()
|
arts := memstore.NewArtifactRepo()
|
||||||
blobs := memstore.NewBlobStore()
|
blobs := memstore.NewBlobStore()
|
||||||
|
settings := memstoreSettings{}
|
||||||
clk := memstore.NewClock(time.Date(2026, 7, 21, 12, 0, 0, 0, time.UTC))
|
clk := memstore.NewClock(time.Date(2026, 7, 21, 12, 0, 0, 0, time.UTC))
|
||||||
tx := memstore.Tx{}
|
tx := memstore.Tx{}
|
||||||
lease := 2 * time.Minute
|
lease := 2 * time.Minute
|
||||||
@@ -47,7 +48,7 @@ func newEnvWithUIToken(t *testing.T, ready func(context.Context) error, configur
|
|||||||
uc := coordhttp.UseCases{
|
uc := coordhttp.UseCases{
|
||||||
RegisterWorker: usecase.NewRegisterWorker(work, clk),
|
RegisterWorker: usecase.NewRegisterWorker(work, clk),
|
||||||
CreateJob: usecase.NewCreateJob(jobs, tasks, tx, clk),
|
CreateJob: usecase.NewCreateJob(jobs, tasks, tx, clk),
|
||||||
SubmitDataset: usecase.NewSubmitDataset(blobs, arts, jobs, tasks, tx, clk, 3, testCatalog()),
|
SubmitDataset: usecase.NewSubmitDataset(blobs, arts, jobs, tasks, tx, clk, 3, testCatalog(), settings),
|
||||||
ClaimTask: usecase.NewClaimTask(tasks, jobs, work, tx, clk, lease, testCatalog()),
|
ClaimTask: usecase.NewClaimTask(tasks, jobs, work, tx, clk, lease, testCatalog()),
|
||||||
RenewLease: usecase.NewRenewLease(tasks, work, tx, clk, lease),
|
RenewLease: usecase.NewRenewLease(tasks, work, tx, clk, lease),
|
||||||
CompleteTask: usecase.NewCompleteTask(tasks, jobs, arts, work, memstore.NewTaskResultRepo(), tx, clk, 2, testCatalog()),
|
CompleteTask: usecase.NewCompleteTask(tasks, jobs, arts, work, memstore.NewTaskResultRepo(), tx, clk, 2, testCatalog()),
|
||||||
@@ -739,3 +740,22 @@ func (e *env) uploadDataset(t *testing.T, workload string, rows int, tsv string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func itoa(n int) string { return strconv.Itoa(n) }
|
func itoa(n int) string { return strconv.Itoa(n) }
|
||||||
|
|
||||||
|
// memstoreSettings is an in-memory WorkloadSettingsRepository for tests.
|
||||||
|
type memstoreSettings struct{ overrides map[string]bool }
|
||||||
|
|
||||||
|
func (m memstoreSettings) GetEnabled(ctx context.Context, workload string) (bool, error) {
|
||||||
|
if enabled, ok := m.overrides[workload]; ok {
|
||||||
|
return enabled, nil
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m memstoreSettings) List(ctx context.Context) ([]usecase.WorkloadSetting, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m memstoreSettings) SetEnabled(ctx context.Context, workload string, enabled bool, now time.Time) error {
|
||||||
|
m.overrides[workload] = enabled
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -205,13 +205,35 @@ tbody tr:hover{background:var(--panel-2)}
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ═══ WORKERS (M2) ═══ -->
|
<!-- ═══ WORKERS ═══ -->
|
||||||
<section class="page" id="page-workers">
|
<section class="page" id="page-workers">
|
||||||
<div class="card"><div class="card-head"><h3>Workers</h3><span>milestone M2</span></div><div class="placeholder"><b>Trust management lands with milestone M2</b>Worker list, trust dropdown and heartbeat overview are wired next. The dashboard already shows the live fleet.</div></div>
|
<div class="section-note">Workers register themselves. Trust decides whether a machine's results are accepted directly or need quorum.</div>
|
||||||
|
<div class="card">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Worker</th><th>Status</th><th>Capabilities</th><th>Trust</th><th>Owner</th><th>Last signal</th></tr></thead>
|
||||||
|
<tbody id="worker-rows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ═══ USERS & KEYS (M2) ═══ -->
|
<!-- ═══ USERS & KEYS ═══ -->
|
||||||
<section class="page" id="page-users">
|
<section class="page" id="page-users">
|
||||||
|
<div class="section-title">Users</div>
|
||||||
|
<div class="card">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Email</th><th>Role</th><th>Verified</th><th>Created</th></tr></thead>
|
||||||
|
<tbody id="user-rows"></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="footer-row"><span id="user-count">—</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="section-title">Worker keys</div>
|
||||||
|
<div class="section-note">Keys let lab machines register as workers under a user account. Served instances can also use the cluster token.</div>
|
||||||
|
<div class="card">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Label</th><th>Prefix</th><th>Owner</th><th>Created</th><th>Last used</th><th></th></tr></thead>
|
||||||
|
<tbody id="key-rows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
<div class="section-title">Quick user action</div>
|
<div class="section-title">Quick user action</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<form method="post" action="/ui/admin/user-action" style="padding:16px 20px">
|
<form method="post" action="/ui/admin/user-action" style="padding:16px 20px">
|
||||||
@@ -226,13 +248,17 @@ tbody tr:hover{background:var(--panel-2)}
|
|||||||
{{if .Error}}<div style="margin-left:210px;margin-top:10px;color:var(--red);font-size:13px">✗ {{.Error}}</div>{{end}}
|
{{if .Error}}<div style="margin-left:210px;margin-top:10px;color:var(--red);font-size:13px">✗ {{.Error}}</div>{{end}}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="section-title">Accounts and worker keys</div>
|
|
||||||
<div class="card"><div class="placeholder"><b>User list and key management land with milestone M2</b>The userservice gains admin list endpoints; the console gets tables, role selects and key revoke.</div></div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ═══ WORKLOADS (M2) ═══ -->
|
<!-- ═══ WORKLOADS ═══ -->
|
||||||
<section class="page" id="page-workloads">
|
<section class="page" id="page-workloads">
|
||||||
<div class="card"><div class="placeholder"><b>Workload enable/disable lands with milestone M2</b>The catalog is already served to the job form; persisted on/off switches arrive with the settings migration.</div></div>
|
<div class="section-note">Disabled workloads are rejected at submit time and hidden from the job form. Settings persist in the database.</div>
|
||||||
|
<div class="card">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Workload</th><th>Reduction</th><th>Parameters</th><th>Dataset upload</th><th>Enabled</th></tr></thead>
|
||||||
|
<tbody id="workload-rows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ═══ METRICS ═══ -->
|
<!-- ═══ METRICS ═══ -->
|
||||||
@@ -255,9 +281,20 @@ tbody tr:hover{background:var(--panel-2)}
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ═══ SETTINGS (M2) ═══ -->
|
<!-- ═══ SETTINGS ═══ -->
|
||||||
<section class="page" id="page-settings">
|
<section class="page" id="page-settings">
|
||||||
<div class="card"><div class="placeholder"><b>Cluster settings land with milestone M2</b>Worker token reveal, public URL and storage settings follow in the next milestone.</div></div>
|
<div class="warn-strip" style="display:flex;gap:10px;align-items:flex-start;background:var(--amber-soft);border:1px solid #e5b64f33;border-radius:10px;padding:12px 14px;font-size:12.5px;color:#eecf8d"><span>The cluster token below authenticates <b>any</b> worker. Reveal it only on a trusted machine.</span></div>
|
||||||
|
<div class="section-title">Cluster</div>
|
||||||
|
<div class="card">
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>Worker token</dt><dd><div class="secret"><code id="tok">••••••••••••••••••••••••</code><button class="btn btn-ghost btn-sm" id="reveal">Reveal</button></div></dd>
|
||||||
|
<dt>Public URL</dt><dd><code id="s-public">—</code></dd>
|
||||||
|
<dt>Listen address</dt><dd><code id="s-addr">—</code></dd>
|
||||||
|
<dt>Data directory</dt><dd><code id="s-datadir">—</code></dd>
|
||||||
|
<dt>Database engine</dt><dd id="s-engine">—</dd>
|
||||||
|
<dt>Binary</dt><dd><code id="s-binary">—</code></dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -272,10 +309,10 @@ const fmtBytes=b=>{if(b==null||b<0)return '—';if(b<1024)return b+' B';if(b<104
|
|||||||
const fmtTime=t=>t?new Date(t).toLocaleString():'—';
|
const fmtTime=t=>t?new Date(t).toLocaleString():'—';
|
||||||
const esc=s=>String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
const esc=s=>String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||||
let timer=null,current={page:'system',jobsStatus:'',jobsPage:1};
|
let timer=null,current={page:'system',jobsStatus:'',jobsPage:1};
|
||||||
const setPage=page=>{current.page=page;document.querySelectorAll('.nav-item').forEach(b=>b.classList.toggle('active',b.dataset.page===page));document.querySelectorAll('.page').forEach(p=>p.classList.toggle('active',p.id==='page-'+page));const [t,s]=titles[page];document.getElementById('page-title').textContent=t;document.getElementById('page-sub').textContent=s;if(timer){clearInterval(timer);timer=null}if(page==='system'||page==='jobs'||page==='metrics'){refresh();timer=setInterval(refresh,5000)}};
|
const setPage=page=>{current.page=page;document.querySelectorAll('.nav-item').forEach(b=>b.classList.toggle('active',b.dataset.page===page));document.querySelectorAll('.page').forEach(p=>p.classList.toggle('active',p.id==='page-'+page));const [t,s]=titles[page];document.getElementById('page-title').textContent=t;document.getElementById('page-sub').textContent=s;if(timer){clearInterval(timer);timer=null}refresh();timer=setInterval(refresh,5000)};
|
||||||
document.querySelectorAll('.nav-item').forEach(b=>b.addEventListener('click',()=>setPage(b.dataset.page)));
|
document.querySelectorAll('.nav-item').forEach(b=>b.addEventListener('click',()=>setPage(b.dataset.page)));
|
||||||
|
|
||||||
const refresh=()=>{if(document.hidden)return;const p=current.page;if(p==='system')loadSystem();else if(p==='jobs')loadJobs();else if(p==='metrics')loadMetrics()};
|
const refresh=()=>{if(document.hidden)return;const p=current.page;if(p==='system')loadSystem();else if(p==='jobs')loadJobs();else if(p==='metrics')loadMetrics();else if(p==='workers')loadWorkers();else if(p==='users')loadUsers();else if(p==='workloads')loadWorkloads();else if(p==='settings')loadSettings()};
|
||||||
document.addEventListener('visibilitychange',()=>{if(!document.hidden)refresh()});
|
document.addEventListener('visibilitychange',()=>{if(!document.hidden)refresh()});
|
||||||
|
|
||||||
async function loadSystem(){
|
async function loadSystem(){
|
||||||
@@ -384,6 +421,114 @@ async function loadMetrics(){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setPage('system');
|
setPage('system');
|
||||||
|
|
||||||
|
const workerStatusPill=s=>({online:['Online','pill-success'],busy:['Busy','pill-active'],offline:['Offline','pill-waiting']}[s]||[s,'pill-waiting']);
|
||||||
|
async function loadWorkers(){
|
||||||
|
const r=await fetch('/ui/admin/api/workers',{headers:{Accept:'application/json'}});
|
||||||
|
if(!r.ok)return;
|
||||||
|
const v=await r.json();
|
||||||
|
const rows=document.getElementById('worker-rows');
|
||||||
|
rows.replaceChildren();
|
||||||
|
if(!v.workers.length){const tr=document.createElement('tr');tr.innerHTML='<td colspan="6"><div class="empty">No worker is registered yet.</div></td>';rows.append(tr);return}
|
||||||
|
for(const w of v.workers){
|
||||||
|
const tr=document.createElement('tr');
|
||||||
|
const [label,cls]=workerStatusPill(w.status);
|
||||||
|
const trustSel='<select data-id="'+w.id+'" class="trust-sel" '+(w.status==='offline'?'disabled':'')+'><option value="trusted" '+(w.trust==='trusted'?'selected':'')+'>Trusted</option><option value="untrusted" '+(w.trust==='untrusted'?'selected':'')+'>Untrusted</option></select>';
|
||||||
|
tr.innerHTML='<td><div class="t-main">'+esc(w.name)+'</div><div class="t-sub">'+esc(w.id.slice(0,8))+'…</div></td>'+
|
||||||
|
'<td>'+pill(label,cls,null)+'</td>'+
|
||||||
|
'<td>'+(w.capabilities||[]).map(c=>'<span class="cap">'+esc(c)+'</span>').join('')+'</td>'+
|
||||||
|
'<td>'+trustSel+'</td>'+
|
||||||
|
'<td style="color:var(--text-2)">'+esc(w.owner)+'</td>'+
|
||||||
|
'<td style="color:var(--text-2);font-size:12.5px">'+fmtTime(w.last_heartbeat_at)+'</td>';
|
||||||
|
rows.append(tr);
|
||||||
|
}
|
||||||
|
document.querySelectorAll('.trust-sel').forEach(sel=>sel.addEventListener('change',async()=>{
|
||||||
|
await fetch('/ui/admin/api/workers/'+sel.dataset.id+'/trust',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({trusted:sel.value==='trusted'})});
|
||||||
|
loadWorkers();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
async function loadUsers(){
|
||||||
|
const r=await fetch('/ui/admin/api/users',{headers:{Accept:'application/json'}});
|
||||||
|
if(!r.ok)return;
|
||||||
|
const v=await r.json();
|
||||||
|
const rows=document.getElementById('user-rows');
|
||||||
|
rows.replaceChildren();
|
||||||
|
for(const u of v.users||[]){
|
||||||
|
const tr=document.createElement('tr');
|
||||||
|
const roleSel='<select class="role-sel" data-id="'+u.id+'"><option value="user" '+(u.role==='user'?'selected':'')+'>user</option><option value="admin" '+(u.role==='admin'?'selected':'')+'>admin</option></select>';
|
||||||
|
tr.innerHTML='<td><div class="t-main">'+esc(u.email)+'</div></td>'+
|
||||||
|
'<td>'+roleSel+'</td>'+
|
||||||
|
'<td>'+pill(u.verified?'Verified':'—',u.verified?'pill-success':'pill-waiting',null)+'</td>'+
|
||||||
|
'<td style="color:var(--text-2);font-size:12.5px">'+fmtTime(u.created_at)+'</td>';
|
||||||
|
rows.append(tr);
|
||||||
|
}
|
||||||
|
document.getElementById('user-count').textContent=(v.users||[]).length+' users';
|
||||||
|
document.querySelectorAll('.role-sel').forEach(sel=>sel.addEventListener('change',async()=>{
|
||||||
|
await fetch('/ui/admin/api/users/'+sel.dataset.id+'/role',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({role:sel.value})});
|
||||||
|
loadUsers();
|
||||||
|
}));
|
||||||
|
const keys=await (await fetch('/ui/admin/api/worker-keys',{headers:{Accept:'application/json'}})).json();
|
||||||
|
const keyRows=document.getElementById('key-rows');
|
||||||
|
keyRows.replaceChildren();
|
||||||
|
const emailOf={};for(const u of v.users||[])emailOf[u.id]=u.email;
|
||||||
|
for(const k of keys.worker_keys||[]){
|
||||||
|
const tr=document.createElement('tr');
|
||||||
|
tr.innerHTML='<td class="t-main">'+esc(k.name)+'</td>'+
|
||||||
|
'<td><code style="color:var(--text-2)">'+esc(k.prefix)+'…</code></td>'+
|
||||||
|
'<td style="color:var(--text-2)">'+esc(emailOf[k.user_id]||k.user_id.slice(0,8)+'…')+'</td>'+
|
||||||
|
'<td style="color:var(--text-2);font-size:12.5px">'+fmtTime(k.created_at)+'</td>'+
|
||||||
|
'<td style="color:var(--text-2);font-size:12.5px">'+(k.last_used_at?fmtTime(k.last_used_at):'never')+'</td>'+
|
||||||
|
'<td>'+((k.revoked_at)?'<span class="pill pill-danger"><i></i>Revoked</span>':'<button class="btn btn-danger btn-sm key-revoke" data-id="'+k.id+'">Revoke</button>')+'</td>';
|
||||||
|
keyRows.append(tr);
|
||||||
|
}
|
||||||
|
document.querySelectorAll('.key-revoke').forEach(btn=>btn.addEventListener('click',async()=>{
|
||||||
|
if(!confirm('Revoke this worker key? The machine will be cut off on its next refresh.'))return;
|
||||||
|
await fetch('/ui/admin/api/worker-keys/'+btn.dataset.id+'/revoke',{method:'POST'});
|
||||||
|
loadUsers();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
async function loadWorkloads(){
|
||||||
|
const r=await fetch('/ui/admin/api/workloads',{headers:{Accept:'application/json'}});
|
||||||
|
if(!r.ok)return;
|
||||||
|
const v=await r.json();
|
||||||
|
const rows=document.getElementById('workload-rows');
|
||||||
|
rows.replaceChildren();
|
||||||
|
for(const w of v.workloads||[]){
|
||||||
|
const tr=document.createElement('tr');
|
||||||
|
tr.innerHTML='<td><div class="t-main">'+esc(w.name)+'</div><div class="t-sub" style="font-family:inherit">'+esc(w.description||'')+'</div></td>'+
|
||||||
|
'<td><span class="pill pill-active"><i></i>'+esc(w.reduction)+'</span></td>'+
|
||||||
|
'<td style="color:var(--text-2);font-size:12.5px">'+w.parameters+' declared</td>'+
|
||||||
|
'<td>'+pill(w.upload_ready?'ready':'—',w.upload_ready?'pill-success':'pill-waiting',null)+'</td>'+
|
||||||
|
'<td><button class="toggle wl-toggle '+(w.enabled?'on':'')+'" data-name="'+w.name+'" aria-label="enabled"></button></td>';
|
||||||
|
rows.append(tr);
|
||||||
|
}
|
||||||
|
document.querySelectorAll('.wl-toggle').forEach(t=>t.addEventListener('click',async()=>{
|
||||||
|
const enabled=!t.classList.contains('on');
|
||||||
|
await fetch('/ui/admin/api/workloads/'+encodeURIComponent(t.dataset.name)+'/enabled',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled})});
|
||||||
|
t.classList.toggle('on',enabled);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
async function loadSettings(){
|
||||||
|
const r=await fetch('/ui/admin/api/settings',{headers:{Accept:'application/json'}});
|
||||||
|
if(!r.ok)return;
|
||||||
|
const v=await r.json();
|
||||||
|
document.getElementById('s-public').textContent=v.public_url||'—';
|
||||||
|
document.getElementById('s-addr').textContent=v.addr;
|
||||||
|
document.getElementById('s-datadir').textContent=v.data_dir||'—';
|
||||||
|
document.getElementById('s-engine').textContent=v.db_engine;
|
||||||
|
document.getElementById('s-binary').textContent=v.binary||'—';
|
||||||
|
}
|
||||||
|
document.getElementById('reveal').addEventListener('click',async e=>{
|
||||||
|
const tok=document.getElementById('tok');
|
||||||
|
if(tok.textContent.startsWith('•')){
|
||||||
|
const r=await fetch('/ui/admin/api/token/reveal',{method:'POST',headers:{Accept:'application/json'}});
|
||||||
|
if(!r.ok)return;
|
||||||
|
const v=await r.json();
|
||||||
|
tok.textContent=v.token||'(none)';
|
||||||
|
e.target.textContent='Hide';
|
||||||
|
}else{tok.textContent='••••••••••••••••••••••••';e.target.textContent='Reveal'}
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.toggle').forEach(t=>t.addEventListener('click',()=>t.classList.toggle('on')));
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
|
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// adminUserActions are the userservice endpoints the admin panel may invoke, by
|
// adminUserActions are the userservice endpoints the admin panel may invoke, by
|
||||||
@@ -157,6 +158,199 @@ func (s *Server) handleUIAdminMetricsJSON(w http.ResponseWriter, r *http.Request
|
|||||||
writeJSON(w, http.StatusOK, view)
|
writeJSON(w, http.StatusOK, view)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleUIAdminWorkersJSON serves the admin "Workers" page.
|
||||||
|
func (s *Server) handleUIAdminWorkersJSON(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := s.reqCtx(r)
|
||||||
|
defer cancel()
|
||||||
|
view, err := s.uc.Admin.Workers(ctx, s.adminOwnerEmails(r))
|
||||||
|
if err != nil {
|
||||||
|
s.writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUIAdminSetTrustJSON flips one worker's trust level.
|
||||||
|
func (s *Server) handleUIAdminSetTrustJSON(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := uuid.Parse(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
s.writeError(w, r, domain.ErrInvalidInput)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Trusted bool `json:"trusted"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &body); err != nil {
|
||||||
|
s.writeError(w, r, domain.ErrInvalidInput)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := s.reqCtx(r)
|
||||||
|
defer cancel()
|
||||||
|
if err := s.uc.Admin.SetTrust(ctx, id, body.Trusted); err != nil {
|
||||||
|
s.writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUIAdminWorkloadsJSON serves the catalog with persisted enable flags.
|
||||||
|
func (s *Server) handleUIAdminWorkloadsJSON(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := s.reqCtx(r)
|
||||||
|
defer cancel()
|
||||||
|
view, err := s.uc.Admin.Workloads(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUIAdminSetWorkloadEnabledJSON flips a workload's enable flag.
|
||||||
|
func (s *Server) handleUIAdminSetWorkloadEnabledJSON(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := r.PathValue("name")
|
||||||
|
var body struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &body); err != nil {
|
||||||
|
s.writeError(w, r, domain.ErrInvalidInput)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := s.reqCtx(r)
|
||||||
|
defer cancel()
|
||||||
|
if err := s.uc.Admin.SetWorkloadEnabled(ctx, name, body.Enabled); err != nil {
|
||||||
|
s.writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUIAdminSettingsJSON serves the read-only cluster settings.
|
||||||
|
func (s *Server) handleUIAdminSettingsJSON(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, s.uc.Admin.Settings())
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUIAdminRevealTokenJSON reveals the shared worker token, auditing the
|
||||||
|
// reveal. Admin-only via the route chain.
|
||||||
|
func (s *Server) handleUIAdminRevealTokenJSON(w http.ResponseWriter, r *http.Request) {
|
||||||
|
actor := "admin"
|
||||||
|
if req, ok := authctx.From(r.Context()); ok {
|
||||||
|
actor = req.Role + ":" + req.UserID.String()
|
||||||
|
}
|
||||||
|
ctx, cancel := s.reqCtx(r)
|
||||||
|
defer cancel()
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"token": s.uc.Admin.RevealWorkerToken(ctx, actor)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUIAdminUsersJSON serves the account table, proxied from the
|
||||||
|
// userservice. The userservice projects away password hashes; a failure here
|
||||||
|
// is a 502 rather than a silent empty table.
|
||||||
|
func (s *Server) handleUIAdminUsersJSON(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c, err := r.Cookie(sessionCookie)
|
||||||
|
if err != nil {
|
||||||
|
redirectToLogin(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status, body, err := s.callUserserviceAuthed(r.Context(), http.MethodGet, "/users", c.Value)
|
||||||
|
if err != nil {
|
||||||
|
s.writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if status != http.StatusOK {
|
||||||
|
writeJSON(w, status, map[string]string{"error": "userservice: unexpected response"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUIAdminSetUserRoleJSON changes a user's role through the userservice
|
||||||
|
// promote/demote actions.
|
||||||
|
func (s *Server) handleUIAdminSetUserRoleJSON(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := uuid.Parse(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
s.writeError(w, r, domain.ErrInvalidInput)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &body); err != nil {
|
||||||
|
s.writeError(w, r, domain.ErrInvalidInput)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
action := ""
|
||||||
|
switch body.Role {
|
||||||
|
case "admin":
|
||||||
|
action = "promote"
|
||||||
|
case "user":
|
||||||
|
action = "demote"
|
||||||
|
default:
|
||||||
|
s.writeError(w, r, domain.ErrInvalidInput)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c, err := r.Cookie(sessionCookie)
|
||||||
|
if err != nil {
|
||||||
|
redirectToLogin(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status, _, err := s.callUserserviceAuthed(r.Context(), http.MethodPost, "/users/"+id.String()+"/"+action, c.Value)
|
||||||
|
if err != nil {
|
||||||
|
s.writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if status != http.StatusNoContent {
|
||||||
|
writeJSON(w, status, map[string]string{"error": "userservice: unexpected response"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUIAdminWorkerKeysJSON serves every worker key with its owning user,
|
||||||
|
// proxied from the userservice.
|
||||||
|
func (s *Server) handleUIAdminWorkerKeysJSON(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c, err := r.Cookie(sessionCookie)
|
||||||
|
if err != nil {
|
||||||
|
redirectToLogin(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status, body, err := s.callUserserviceAuthed(r.Context(), http.MethodGet, "/worker-keys/all", c.Value)
|
||||||
|
if err != nil {
|
||||||
|
s.writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if status != http.StatusOK {
|
||||||
|
writeJSON(w, status, map[string]string{"error": "userservice: unexpected response"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUIAdminRevokeKeyJSON revokes any worker key through the userservice
|
||||||
|
// (whose DELETE endpoint already lets an admin revoke keys of any owner).
|
||||||
|
func (s *Server) handleUIAdminRevokeKeyJSON(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := uuid.Parse(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
s.writeError(w, r, domain.ErrInvalidInput)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c, err := r.Cookie(sessionCookie)
|
||||||
|
if err != nil {
|
||||||
|
redirectToLogin(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status, _, err := s.callUserserviceAuthed(r.Context(), http.MethodDelete, "/worker-keys/"+id.String(), c.Value)
|
||||||
|
if err != nil {
|
||||||
|
s.writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if status != http.StatusNoContent {
|
||||||
|
writeJSON(w, status, map[string]string{"error": "userservice: unexpected response"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
// adminOwnerEmails resolves job owner ids to emails through the userservice,
|
// adminOwnerEmails resolves job owner ids to emails through the userservice,
|
||||||
// which is the only place email addresses live. It never blocks the page on
|
// which is the only place email addresses live. It never blocks the page on
|
||||||
// failure: an empty map leaves the admin jobs table on short ids.
|
// failure: an empty map leaves the admin jobs table on short ids.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package usecase
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"log/slog"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -9,6 +10,7 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/workloads"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AdminReadRepository is the bounded read projection behind the coordinator
|
// AdminReadRepository is the bounded read projection behind the coordinator
|
||||||
@@ -49,6 +51,9 @@ type AdminNodeInfo struct {
|
|||||||
DBEngine string
|
DBEngine string
|
||||||
PublicURL string
|
PublicURL string
|
||||||
Userservice string // base URL; empty when the UI runs without user auth
|
Userservice string // base URL; empty when the UI runs without user auth
|
||||||
|
// WorkerToken reads the shared worker token for the Settings page. It is a
|
||||||
|
// func so serve mode can read the token file lazily after provisioning.
|
||||||
|
WorkerToken func() string
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminStorageView struct {
|
type AdminStorageView struct {
|
||||||
@@ -135,18 +140,40 @@ type AdminMetricsView struct {
|
|||||||
// Admin answers the coordinator admin console from the bounded read model
|
// Admin answers the coordinator admin console from the bounded read model
|
||||||
// plus process info supplied at startup.
|
// plus process info supplied at startup.
|
||||||
type Admin struct {
|
type Admin struct {
|
||||||
read AdminReadRepository
|
read AdminReadRepository
|
||||||
uiRead UIReadRepository
|
uiRead UIReadRepository
|
||||||
node AdminNodeInfo
|
workers WorkerRepository
|
||||||
ready func(context.Context) error
|
settings WorkloadSettingsRepository
|
||||||
now func() time.Time
|
catalog *workloads.Catalog
|
||||||
|
node AdminNodeInfo
|
||||||
|
ready func(context.Context) error
|
||||||
|
now func() time.Time
|
||||||
|
log *slog.Logger
|
||||||
|
audit func(ctx context.Context, action, detail string)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAdmin(read AdminReadRepository, uiRead UIReadRepository, node AdminNodeInfo, ready func(context.Context) error, now func() time.Time) *Admin {
|
func NewAdmin(read AdminReadRepository, uiRead UIReadRepository, workers WorkerRepository,
|
||||||
|
settings WorkloadSettingsRepository, catalog *workloads.Catalog, node AdminNodeInfo,
|
||||||
|
ready func(context.Context) error, now func() time.Time) *Admin {
|
||||||
if now == nil {
|
if now == nil {
|
||||||
now = time.Now
|
now = time.Now
|
||||||
}
|
}
|
||||||
return &Admin{read: read, uiRead: uiRead, node: node, ready: ready, now: now}
|
return &Admin{read: read, uiRead: uiRead, workers: workers, settings: settings, catalog: catalog, node: node, ready: ready, now: now}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithAuditLog attaches an audit sink for sensitive actions (token reveal).
|
||||||
|
// Without it the admin usecase stays silent about them.
|
||||||
|
func (a *Admin) WithAuditLog(log *slog.Logger, audit func(ctx context.Context, action, detail string)) *Admin {
|
||||||
|
a.log = log
|
||||||
|
a.audit = audit
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Admin) revealToken(ctx context.Context, actor string) string {
|
||||||
|
if a.node.WorkerToken != nil {
|
||||||
|
return a.node.WorkerToken()
|
||||||
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Admin) System(ctx context.Context) (AdminSystemView, error) {
|
func (a *Admin) System(ctx context.Context) (AdminSystemView, error) {
|
||||||
@@ -339,3 +366,158 @@ func (a *Admin) Metrics(ctx context.Context) (AdminMetricsView, error) {
|
|||||||
})
|
})
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminWorkerCard is one row of the admin workers table.
|
||||||
|
type AdminWorkerCard struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Capabilities []string `json:"capabilities"`
|
||||||
|
Trust string `json:"trust"`
|
||||||
|
OwnerID string `json:"owner_id,omitempty"`
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Completed int `json:"completed"`
|
||||||
|
LastHeartbeatAt time.Time `json:"last_heartbeat_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminWorkersView struct {
|
||||||
|
Workers []AdminWorkerCard `json:"workers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Workers lists the whole fleet for the admin console. Owner emails are
|
||||||
|
// resolved through the same map as the jobs table (userservice-backed).
|
||||||
|
func (a *Admin) Workers(ctx context.Context, ownerEmails map[uuid.UUID]string) (AdminWorkersView, error) {
|
||||||
|
workers, err := a.uiRead.ListWorkers(ctx, 100)
|
||||||
|
if err != nil {
|
||||||
|
return AdminWorkersView{}, err
|
||||||
|
}
|
||||||
|
out := AdminWorkersView{Workers: make([]AdminWorkerCard, 0, len(workers))}
|
||||||
|
for _, w := range workers {
|
||||||
|
card := AdminWorkerCard{
|
||||||
|
ID: w.ID.String(),
|
||||||
|
Name: w.Name,
|
||||||
|
Status: string(w.Status),
|
||||||
|
Capabilities: w.Capabilities,
|
||||||
|
Trust: string(w.TrustLevel),
|
||||||
|
LastHeartbeatAt: w.LastHeartbeatAt,
|
||||||
|
Owner: "cluster token",
|
||||||
|
}
|
||||||
|
if w.OwnerID != nil {
|
||||||
|
card.OwnerID = w.OwnerID.String()
|
||||||
|
card.Owner = "user " + shortID(w.OwnerID.String())
|
||||||
|
if email, ok := ownerEmails[*w.OwnerID]; ok && email != "" {
|
||||||
|
card.Owner = email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.Workers = append(out.Workers, card)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTrust reclassifies one worker (trusted/untrusted).
|
||||||
|
func (a *Admin) SetTrust(ctx context.Context, id uuid.UUID, trusted bool) error {
|
||||||
|
trust := domain.WorkerUntrusted
|
||||||
|
if trusted {
|
||||||
|
trust = domain.WorkerTrusted
|
||||||
|
}
|
||||||
|
return a.workers.SetTrust(ctx, id, trust)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminWorkloadView is the catalog plus the persisted enable flag.
|
||||||
|
type AdminWorkloadView struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Reduction string `json:"reduction"`
|
||||||
|
Parameters int `json:"parameters"`
|
||||||
|
UploadReady bool `json:"upload_ready"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
DefaultOn bool `json:"default_on"`
|
||||||
|
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminWorkloadsView struct {
|
||||||
|
Workloads []AdminWorkloadView `json:"workloads"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Workloads lists the catalog with persisted enable/disable overrides.
|
||||||
|
func (a *Admin) Workloads(ctx context.Context) (AdminWorkloadsView, error) {
|
||||||
|
if a.catalog == nil {
|
||||||
|
return AdminWorkloadsView{}, domain.ErrInvalidInput
|
||||||
|
}
|
||||||
|
items := a.catalog.Items()
|
||||||
|
overrides, err := a.settings.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return AdminWorkloadsView{}, err
|
||||||
|
}
|
||||||
|
enabled := make(map[string]WorkloadSetting, len(overrides))
|
||||||
|
for _, s := range overrides {
|
||||||
|
enabled[s.Workload] = s
|
||||||
|
}
|
||||||
|
out := AdminWorkloadsView{Workloads: make([]AdminWorkloadView, 0, len(items))}
|
||||||
|
for _, item := range items {
|
||||||
|
params := 0
|
||||||
|
if properties, ok := item.Parameters["properties"].(map[string]any); ok {
|
||||||
|
params = len(properties)
|
||||||
|
}
|
||||||
|
view := AdminWorkloadView{
|
||||||
|
Name: item.Name,
|
||||||
|
Description: item.Description,
|
||||||
|
Reduction: item.Reduction,
|
||||||
|
Parameters: params,
|
||||||
|
UploadReady: item.UploadReady,
|
||||||
|
Enabled: true,
|
||||||
|
DefaultOn: true,
|
||||||
|
}
|
||||||
|
if s, ok := enabled[item.Name]; ok {
|
||||||
|
view.Enabled = s.Enabled
|
||||||
|
view.DefaultOn = false
|
||||||
|
view.UpdatedAt = &s.UpdatedAt
|
||||||
|
}
|
||||||
|
out.Workloads = append(out.Workloads, view)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetWorkloadEnabled flips the persisted enable flag. An unknown workload is
|
||||||
|
// rejected: the admin console must not invent catalog entries.
|
||||||
|
func (a *Admin) SetWorkloadEnabled(ctx context.Context, name string, enabled bool) error {
|
||||||
|
if a.catalog == nil || a.catalog.ByName(name) == nil {
|
||||||
|
return domain.ErrInvalidInput
|
||||||
|
}
|
||||||
|
return a.settings.SetEnabled(ctx, name, enabled, a.now())
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminSettingsView is the read-only cluster configuration the Settings page
|
||||||
|
// shows. The token is never included; it is revealed only through
|
||||||
|
// RevealWorkerToken, which audits.
|
||||||
|
type AdminSettingsView struct {
|
||||||
|
PublicURL string `json:"public_url"`
|
||||||
|
Addr string `json:"addr"`
|
||||||
|
DataDir string `json:"data_dir"`
|
||||||
|
DBEngine string `json:"db_engine"`
|
||||||
|
Binary string `json:"binary"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Admin) Settings() AdminSettingsView {
|
||||||
|
return AdminSettingsView{
|
||||||
|
PublicURL: a.node.PublicURL,
|
||||||
|
Addr: a.node.Addr,
|
||||||
|
DataDir: a.node.DataDir,
|
||||||
|
DBEngine: a.node.DBEngine,
|
||||||
|
Binary: a.node.Binary,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevealWorkerToken returns the shared worker token for the Settings page and
|
||||||
|
// records the reveal in the audit log. It must only be called for an admin
|
||||||
|
// session.
|
||||||
|
func (a *Admin) RevealWorkerToken(ctx context.Context, actor string) string {
|
||||||
|
token := a.revealToken(ctx, actor)
|
||||||
|
if a.audit != nil {
|
||||||
|
a.audit(ctx, "worker token revealed", "by "+actor)
|
||||||
|
}
|
||||||
|
if a.log != nil {
|
||||||
|
a.log.Warn("admin console revealed the worker token", "actor", actor)
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/workloads"
|
||||||
)
|
)
|
||||||
|
|
||||||
type fakeAdminRead struct {
|
type fakeAdminRead struct {
|
||||||
@@ -73,6 +74,34 @@ type fakeUIRead struct {
|
|||||||
workers []domain.Worker
|
workers []domain.Worker
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type fakeSettings struct {
|
||||||
|
WorkloadSettingsRepository // embedded: only the methods below are exercised
|
||||||
|
overrides map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSettings) GetEnabled(ctx context.Context, workload string) (bool, error) {
|
||||||
|
if enabled, ok := f.overrides[workload]; ok {
|
||||||
|
return enabled, nil
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSettings) List(ctx context.Context) ([]WorkloadSetting, error) {
|
||||||
|
out := make([]WorkloadSetting, 0, len(f.overrides))
|
||||||
|
for name, enabled := range f.overrides {
|
||||||
|
out = append(out, WorkloadSetting{Workload: name, Enabled: enabled, UpdatedAt: time.Now()})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSettings) SetEnabled(ctx context.Context, workload string, enabled bool, now time.Time) error {
|
||||||
|
if f.overrides == nil {
|
||||||
|
f.overrides = map[string]bool{}
|
||||||
|
}
|
||||||
|
f.overrides[workload] = enabled
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeUIRead) ListWorkers(ctx context.Context, limit int) ([]domain.Worker, error) {
|
func (f *fakeUIRead) ListWorkers(ctx context.Context, limit int) ([]domain.Worker, error) {
|
||||||
return f.workers, nil
|
return f.workers, nil
|
||||||
}
|
}
|
||||||
@@ -81,6 +110,9 @@ func adminFixture() *Admin {
|
|||||||
return NewAdmin(
|
return NewAdmin(
|
||||||
&fakeAdminRead{},
|
&fakeAdminRead{},
|
||||||
&fakeUIRead{},
|
&fakeUIRead{},
|
||||||
|
nil, // workers repo
|
||||||
|
&fakeSettings{},
|
||||||
|
nil, // catalog
|
||||||
AdminNodeInfo{
|
AdminNodeInfo{
|
||||||
Version: "1.1.0-alpha.1", StartedAt: time.Unix(1_000_000, 0).UTC(),
|
Version: "1.1.0-alpha.1", StartedAt: time.Unix(1_000_000, 0).UTC(),
|
||||||
Binary: "/usr/local/bin/coordinator", Addr: ":8080", DataDir: "/var/lib/scimesh",
|
Binary: "/usr/local/bin/coordinator", Addr: ":8080", DataDir: "/var/lib/scimesh",
|
||||||
@@ -221,3 +253,86 @@ func TestAdminMetricsBuckets(t *testing.T) {
|
|||||||
t.Errorf("rate=%.4f avg=%.2f", v.FailureRate, v.AvgShardSeconds)
|
t.Errorf("rate=%.4f avg=%.2f", v.FailureRate, v.AvgShardSeconds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type fakeWorkerRepo struct {
|
||||||
|
WorkerRepository // embedded: only SetTrust is exercised
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeWorkerRepo) SetTrust(ctx context.Context, id uuid.UUID, trust domain.WorkerTrust) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminWorkersAndTrust(t *testing.T) {
|
||||||
|
owner := uuid.New()
|
||||||
|
a := adminFixture()
|
||||||
|
a.uiRead = &fakeUIRead{workers: []domain.Worker{
|
||||||
|
{ID: uuid.New(), Name: "lab-node-01", Status: domain.WorkerBusy, TrustLevel: domain.WorkerTrusted, Capabilities: []string{"similarity-search"}},
|
||||||
|
{ID: uuid.New(), Name: "emil-laptop", Status: domain.WorkerOnline, TrustLevel: domain.WorkerUntrusted, OwnerID: &owner},
|
||||||
|
}}
|
||||||
|
view, err := a.Workers(context.Background(), map[uuid.UUID]string{owner: "alice@lab.org"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(view.Workers) != 2 {
|
||||||
|
t.Fatalf("workers = %d, want 2", len(view.Workers))
|
||||||
|
}
|
||||||
|
if view.Workers[0].Trust != "trusted" || view.Workers[1].Trust != "untrusted" {
|
||||||
|
t.Errorf("trust flags wrong: %+v", view.Workers)
|
||||||
|
}
|
||||||
|
if view.Workers[1].Owner != "alice@lab.org" {
|
||||||
|
t.Errorf("owner = %q, want alice@lab.org", view.Workers[1].Owner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminSetTrust(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
a := adminFixture()
|
||||||
|
a.workers = &fakeWorkerRepo{}
|
||||||
|
_ = called
|
||||||
|
if err := a.SetTrust(context.Background(), uuid.New(), false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminWorkloadsWithOverrides(t *testing.T) {
|
||||||
|
catalog, err := workloads.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
a := adminFixture()
|
||||||
|
a.catalog = catalog
|
||||||
|
a.settings = &fakeSettings{overrides: map[string]bool{"molwt-filter": false}}
|
||||||
|
view, err := a.Workloads(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, w := range view.Workloads {
|
||||||
|
if w.Name == "molwt-filter" {
|
||||||
|
found = true
|
||||||
|
if w.Enabled || w.DefaultOn {
|
||||||
|
t.Errorf("molwt-filter: enabled=%v default_on=%v, want disabled override", w.Enabled, w.DefaultOn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if w.Name == "similarity-search" && !w.Enabled {
|
||||||
|
t.Error("similarity-search must stay enabled (no override)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("molwt-filter missing from the catalog view")
|
||||||
|
}
|
||||||
|
if err := a.SetWorkloadEnabled(context.Background(), "similarity-search", false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := a.SetWorkloadEnabled(context.Background(), "nope", false); err == nil {
|
||||||
|
t.Error("unknown workload must be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminRevealToken(t *testing.T) {
|
||||||
|
a := adminFixture()
|
||||||
|
a.node.WorkerToken = func() string { return "sm_live_secret" }
|
||||||
|
if got := a.RevealWorkerToken(context.Background(), "admin:user"); got != "sm_live_secret" {
|
||||||
|
t.Errorf("token = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -94,6 +94,9 @@ type WorkerRepository interface {
|
|||||||
// MarkStaleOffline flips every worker last seen before cutoff to offline and
|
// MarkStaleOffline flips every worker last seen before cutoff to offline and
|
||||||
// reports how many changed.
|
// reports how many changed.
|
||||||
MarkStaleOffline(ctx context.Context, cutoff time.Time) (int64, error)
|
MarkStaleOffline(ctx context.Context, cutoff time.Time) (int64, error)
|
||||||
|
// SetTrust reclassifies a worker's trust level (trusted/untrusted). Returns
|
||||||
|
// ErrNotFound when the id is unknown.
|
||||||
|
SetTrust(ctx context.Context, id uuid.UUID, trust domain.WorkerTrust) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArtifactRepository persists artifact metadata. The bytes live in a BlobStore;
|
// ArtifactRepository persists artifact metadata. The bytes live in a BlobStore;
|
||||||
@@ -131,6 +134,26 @@ type Clock interface {
|
|||||||
Now() time.Time
|
Now() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WorkloadSetting is one persisted enable/disable override from the admin
|
||||||
|
// console. A workload with no row in the store is enabled by default.
|
||||||
|
type WorkloadSetting struct {
|
||||||
|
Workload string `json:"workload"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkloadSettingsRepository persists the admin enable/disable overrides on
|
||||||
|
// top of the embedded workload catalog.
|
||||||
|
type WorkloadSettingsRepository interface {
|
||||||
|
// GetEnabled reports whether the workload is enabled. True when the
|
||||||
|
// workload has no override row (catalog default).
|
||||||
|
GetEnabled(ctx context.Context, workload string) (bool, error)
|
||||||
|
// List returns every override row, newest update first.
|
||||||
|
List(ctx context.Context) ([]WorkloadSetting, error)
|
||||||
|
// SetEnabled upserts the override.
|
||||||
|
SetEnabled(ctx context.Context, workload string, enabled bool, now time.Time) error
|
||||||
|
}
|
||||||
|
|
||||||
// ErrNotImplemented marks scaffold code with no body yet. Unlike the errors in
|
// ErrNotImplemented marks scaffold code with no body yet. Unlike the errors in
|
||||||
// domain, it describes the state of this codebase, not a business rule.
|
// domain, it describes the state of this codebase, not a business rule.
|
||||||
var ErrNotImplemented = errors.New("not implemented")
|
var ErrNotImplemented = errors.New("not implemented")
|
||||||
|
|||||||
@@ -24,17 +24,27 @@ type SubmitDataset struct {
|
|||||||
clk Clock
|
clk Clock
|
||||||
maxAttempts int
|
maxAttempts int
|
||||||
catalog *workloads.Catalog
|
catalog *workloads.Catalog
|
||||||
|
settings WorkloadSettingsRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSubmitDataset(blobs BlobStore, artifacts ArtifactRepository, jobs JobRepository,
|
func NewSubmitDataset(blobs BlobStore, artifacts ArtifactRepository, jobs JobRepository,
|
||||||
tasks TaskRepository, tx TxManager, clk Clock, maxAttempts int, catalog *workloads.Catalog) *SubmitDataset {
|
tasks TaskRepository, tx TxManager, clk Clock, maxAttempts int, catalog *workloads.Catalog, settings WorkloadSettingsRepository) *SubmitDataset {
|
||||||
return &SubmitDataset{blobs: blobs, artifacts: artifacts, jobs: jobs, tasks: tasks, tx: tx, clk: clk, maxAttempts: maxAttempts, catalog: catalog}
|
return &SubmitDataset{blobs: blobs, artifacts: artifacts, jobs: jobs, tasks: tasks, tx: tx, clk: clk, maxAttempts: maxAttempts, catalog: catalog, settings: settings}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (uc *SubmitDataset) Execute(ctx context.Context, in SubmitDatasetInput) (SubmitDatasetResult, error) {
|
func (uc *SubmitDataset) Execute(ctx context.Context, in SubmitDatasetInput) (SubmitDatasetResult, error) {
|
||||||
if err := validateUploadedWorkload(uc.catalog, in.Workload, in.Parameters); err != nil {
|
if err := validateUploadedWorkload(uc.catalog, in.Workload, in.Parameters); err != nil {
|
||||||
return SubmitDatasetResult{}, err
|
return SubmitDatasetResult{}, err
|
||||||
}
|
}
|
||||||
|
if uc.settings != nil {
|
||||||
|
enabled, err := uc.settings.GetEnabled(ctx, in.Workload)
|
||||||
|
if err != nil {
|
||||||
|
return SubmitDatasetResult{}, err
|
||||||
|
}
|
||||||
|
if !enabled {
|
||||||
|
return SubmitDatasetResult{}, domain.ErrWorkloadDisabled
|
||||||
|
}
|
||||||
|
}
|
||||||
if uc.maxAttempts < 1 {
|
if uc.maxAttempts < 1 {
|
||||||
return SubmitDatasetResult{}, domain.ErrInvalidInput
|
return SubmitDatasetResult{}, domain.ErrInvalidInput
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ type harness struct {
|
|||||||
blobs *memstore.BlobStore
|
blobs *memstore.BlobStore
|
||||||
clk *memstore.Clock
|
clk *memstore.Clock
|
||||||
taskResults *memstore.TaskResultRepo
|
taskResults *memstore.TaskResultRepo
|
||||||
|
settings *memSettings
|
||||||
|
|
||||||
createJob *usecase.CreateJob
|
createJob *usecase.CreateJob
|
||||||
submit *usecase.SubmitDataset
|
submit *usecase.SubmitDataset
|
||||||
@@ -70,10 +71,11 @@ func newHarness() *harness {
|
|||||||
blobs: memstore.NewBlobStore(),
|
blobs: memstore.NewBlobStore(),
|
||||||
clk: memstore.NewClock(time.Date(2026, 7, 21, 12, 0, 0, 0, time.UTC)),
|
clk: memstore.NewClock(time.Date(2026, 7, 21, 12, 0, 0, 0, time.UTC)),
|
||||||
taskResults: memstore.NewTaskResultRepo(),
|
taskResults: memstore.NewTaskResultRepo(),
|
||||||
|
settings: newMemSettings(),
|
||||||
}
|
}
|
||||||
tx := memstore.Tx{}
|
tx := memstore.Tx{}
|
||||||
h.createJob = usecase.NewCreateJob(h.jobs, h.tasks, tx, h.clk)
|
h.createJob = usecase.NewCreateJob(h.jobs, h.tasks, tx, h.clk)
|
||||||
h.submit = usecase.NewSubmitDataset(h.blobs, h.arts, h.jobs, h.tasks, tx, h.clk, 3, testCatalog())
|
h.submit = usecase.NewSubmitDataset(h.blobs, h.arts, h.jobs, h.tasks, tx, h.clk, 3, testCatalog(), h.settings)
|
||||||
h.claim = usecase.NewClaimTask(h.tasks, h.jobs, h.work, tx, h.clk, lease, testCatalog())
|
h.claim = usecase.NewClaimTask(h.tasks, h.jobs, h.work, tx, h.clk, lease, testCatalog())
|
||||||
h.renew = usecase.NewRenewLease(h.tasks, h.work, tx, h.clk, lease)
|
h.renew = usecase.NewRenewLease(h.tasks, h.work, tx, h.clk, lease)
|
||||||
h.complete = usecase.NewCompleteTask(h.tasks, h.jobs, h.arts, h.work, h.taskResults, tx, h.clk, 2, testCatalog())
|
h.complete = usecase.NewCompleteTask(h.tasks, h.jobs, h.arts, h.work, h.taskResults, tx, h.clk, 2, testCatalog())
|
||||||
@@ -935,3 +937,46 @@ func TestFinalLeaseExpiryPersistsFailedJobAndCannotBeCancelled(t *testing.T) {
|
|||||||
t.Errorf("cancel terminal lease failure = %v, want ErrJobNotCancellable", err)
|
t.Errorf("cancel terminal lease failure = %v, want ErrJobNotCancellable", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// memSettings is an in-memory WorkloadSettingsRepository for tests.
|
||||||
|
type memSettings struct {
|
||||||
|
overrides map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMemSettings() *memSettings { return &memSettings{overrides: map[string]bool{}} }
|
||||||
|
|
||||||
|
func (m *memSettings) GetEnabled(ctx context.Context, workload string) (bool, error) {
|
||||||
|
if enabled, ok := m.overrides[workload]; ok {
|
||||||
|
return enabled, nil
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memSettings) List(ctx context.Context) ([]usecase.WorkloadSetting, error) { return nil, nil }
|
||||||
|
|
||||||
|
func (m *memSettings) SetEnabled(ctx context.Context, workload string, enabled bool, now time.Time) error {
|
||||||
|
m.overrides[workload] = enabled
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubmitDatasetRejectsDisabledWorkload(t *testing.T) {
|
||||||
|
h := newHarness()
|
||||||
|
h.settings.SetEnabled(ctx, "molwt-filter", false, h.clk.Now())
|
||||||
|
_, err := h.submit.Execute(ctx, usecase.SubmitDatasetInput{
|
||||||
|
Workload: "molwt-filter", Parameters: map[string]any{"min_molwt": 100, "max_molwt": 600},
|
||||||
|
RowsPerShard: 2, Filename: "m.tsv", ContentType: "text/tab-separated-values",
|
||||||
|
Body: strings.NewReader("smiles\nCC\n"),
|
||||||
|
})
|
||||||
|
if !errors.Is(err, domain.ErrWorkloadDisabled) {
|
||||||
|
t.Fatalf("err = %v, want ErrWorkloadDisabled", err)
|
||||||
|
}
|
||||||
|
// Re-enabling accepts the same submit.
|
||||||
|
h.settings.SetEnabled(ctx, "molwt-filter", true, h.clk.Now())
|
||||||
|
if _, err := h.submit.Execute(ctx, usecase.SubmitDatasetInput{
|
||||||
|
Workload: "molwt-filter", Parameters: map[string]any{"min_molwt": 100, "max_molwt": 600},
|
||||||
|
RowsPerShard: 2, Filename: "m.tsv", ContentType: "text/tab-separated-values",
|
||||||
|
Body: strings.NewReader("chembl_id\tcanonical_smiles\nA\tCC\n"),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("submit after re-enable: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,15 +51,18 @@ func Serve(ctx context.Context, cfg Config) (string, func() error, error) {
|
|||||||
issuer := auth.NewIssuer(cfg.JWTSecret, 24*time.Hour, clock.Now)
|
issuer := auth.NewIssuer(cfg.JWTSecret, 24*time.Hour, clock.Now)
|
||||||
|
|
||||||
uc := usershttp.UseCases{
|
uc := usershttp.UseCases{
|
||||||
Register: usecase.NewRegister(users, hasher, clock),
|
Register: usecase.NewRegister(users, hasher, clock),
|
||||||
Login: usecase.NewLogin(users, hasher, issuer),
|
Login: usecase.NewLogin(users, hasher, issuer),
|
||||||
SetVerified: usecase.NewSetVerified(users),
|
SetVerified: usecase.NewSetVerified(users),
|
||||||
SetRole: usecase.NewSetRole(users),
|
SetRole: usecase.NewSetRole(users),
|
||||||
CreateWorkerKey: usecase.NewCreateWorkerKey(workerKeys, clock),
|
CreateWorkerKey: usecase.NewCreateWorkerKey(workerKeys, clock),
|
||||||
ListWorkerKeys: usecase.NewListWorkerKeys(workerKeys),
|
ListWorkerKeys: usecase.NewListWorkerKeys(workerKeys),
|
||||||
RevokeWorkerKey: usecase.NewRevokeWorkerKey(workerKeys),
|
ListWorkerKeysAll: usecase.NewListWorkerKeysAll(workerKeys),
|
||||||
ExchangeWorkerKey: usecase.NewExchangeWorkerKey(workerKeys, users, issuer, 24*time.Hour),
|
RevokeWorkerKey: usecase.NewRevokeWorkerKey(workerKeys),
|
||||||
Users: users,
|
RevokeWorkerKeyAdmin: usecase.NewRevokeWorkerKeyAdmin(workerKeys),
|
||||||
|
ExchangeWorkerKey: usecase.NewExchangeWorkerKey(workerKeys, users, issuer, 24*time.Hour),
|
||||||
|
ListUsers: usecase.NewListUsers(users),
|
||||||
|
Users: users,
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.AdminEmail != "" && cfg.AdminPassword != "" {
|
if cfg.AdminEmail != "" && cfg.AdminPassword != "" {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package memstore
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -84,7 +85,105 @@ func (r *UserRepo) SetRole(_ context.Context, id uuid.UUID, role domain.Role) er
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListUsers returns every account, oldest first. It copies, so callers cannot
|
||||||
|
// corrupt the store through the returned slice.
|
||||||
|
func (r *UserRepo) ListUsers(_ context.Context) ([]*domain.User, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
users := make([]*domain.User, 0, len(r.byID))
|
||||||
|
for _, u := range r.byID {
|
||||||
|
copy := u
|
||||||
|
users = append(users, ©)
|
||||||
|
}
|
||||||
|
sort.Slice(users, func(i, j int) bool { return users[i].CreatedAt.Before(users[j].CreatedAt) })
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Clock is a fixed usecase.Clock for deterministic tests.
|
// Clock is a fixed usecase.Clock for deterministic tests.
|
||||||
type Clock struct{ T time.Time }
|
type Clock struct{ T time.Time }
|
||||||
|
|
||||||
func (c Clock) Now() time.Time { return c.T }
|
func (c Clock) Now() time.Time { return c.T }
|
||||||
|
|
||||||
|
// WorkerKeyRepo is an in-memory usecase.WorkerKeyRepository.
|
||||||
|
type WorkerKeyRepo struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
keys map[uuid.UUID]*domain.WorkerKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWorkerKeyRepo() *WorkerKeyRepo {
|
||||||
|
return &WorkerKeyRepo{keys: map[uuid.UUID]*domain.WorkerKey{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkerKeyRepo) Insert(_ context.Context, k *domain.WorkerKey) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.keys[k.ID] = k
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkerKeyRepo) ListByUser(_ context.Context, userID uuid.UUID) ([]*domain.WorkerKey, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
var out []*domain.WorkerKey
|
||||||
|
for _, k := range r.keys {
|
||||||
|
if k.UserID == userID && !k.Revoked() {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkerKeyRepo) ListAll(_ context.Context) ([]*domain.WorkerKey, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
out := make([]*domain.WorkerKey, 0, len(r.keys))
|
||||||
|
for _, k := range r.keys {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkerKeyRepo) GetActiveByHash(_ context.Context, tokenHash string) (*domain.WorkerKey, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
for _, k := range r.keys {
|
||||||
|
if k.TokenHash == tokenHash && !k.Revoked() {
|
||||||
|
return k, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, usecase.ErrWorkerKeyNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkerKeyRepo) Revoke(_ context.Context, id, userID uuid.UUID) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
k, ok := r.keys[id]
|
||||||
|
if !ok || k.UserID != userID || k.Revoked() {
|
||||||
|
return usecase.ErrWorkerKeyNotFound
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
k.RevokedAt = &now
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkerKeyRepo) RevokeAny(_ context.Context, id uuid.UUID) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
k, ok := r.keys[id]
|
||||||
|
if !ok || k.Revoked() {
|
||||||
|
return usecase.ErrWorkerKeyNotFound
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
k.RevokedAt = &now
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkerKeyRepo) TouchLastUsed(_ context.Context, id uuid.UUID) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if k, ok := r.keys[id]; ok {
|
||||||
|
now := time.Now()
|
||||||
|
k.LastUsedAt = &now
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -93,6 +93,24 @@ func (r *UserRepo) SetRole(ctx context.Context, id uuid.UUID, role domain.Role)
|
|||||||
return rowsAffectedOrNotFound(res, usecase.ErrUserNotFound)
|
return rowsAffectedOrNotFound(res, usecase.ErrUserNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListUsers returns every account, oldest first.
|
||||||
|
func (r *UserRepo) ListUsers(ctx context.Context) ([]*domain.User, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, "SELECT "+userColumns+" FROM users ORDER BY created_at ASC, id ASC")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
var users []*domain.User
|
||||||
|
for rows.Next() {
|
||||||
|
user, err := scanUser(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
users = append(users, user)
|
||||||
|
}
|
||||||
|
return users, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
const workerKeyColumns = `id, user_id, name, token_hash, prefix, created_at, last_used_at, revoked_at`
|
const workerKeyColumns = `id, user_id, name, token_hash, prefix, created_at, last_used_at, revoked_at`
|
||||||
|
|
||||||
func scanWorkerKey(row interface{ Scan(dest ...any) error }) (*domain.WorkerKey, error) {
|
func scanWorkerKey(row interface{ Scan(dest ...any) error }) (*domain.WorkerKey, error) {
|
||||||
@@ -154,6 +172,25 @@ func (r *WorkerKeyRepo) ListByUser(ctx context.Context, userID uuid.UUID) ([]*do
|
|||||||
return keys, rows.Err()
|
return keys, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListAll returns every key, revoked included, newest first. Admin-only.
|
||||||
|
func (r *WorkerKeyRepo) ListAll(ctx context.Context) ([]*domain.WorkerKey, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx,
|
||||||
|
"SELECT "+workerKeyColumns+" FROM worker_keys ORDER BY created_at DESC, id DESC")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
var keys []*domain.WorkerKey
|
||||||
|
for rows.Next() {
|
||||||
|
key, err := scanWorkerKey(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
return keys, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
func (r *WorkerKeyRepo) GetActiveByHash(ctx context.Context, tokenHash string) (*domain.WorkerKey, error) {
|
func (r *WorkerKeyRepo) GetActiveByHash(ctx context.Context, tokenHash string) (*domain.WorkerKey, error) {
|
||||||
row := r.db.QueryRowContext(ctx,
|
row := r.db.QueryRowContext(ctx,
|
||||||
"SELECT "+workerKeyColumns+" FROM worker_keys WHERE token_hash = ? AND revoked_at IS NULL",
|
"SELECT "+workerKeyColumns+" FROM worker_keys WHERE token_hash = ? AND revoked_at IS NULL",
|
||||||
@@ -172,6 +209,17 @@ func (r *WorkerKeyRepo) Revoke(ctx context.Context, id, userID uuid.UUID) error
|
|||||||
return rowsAffectedOrNotFound(res, usecase.ErrWorkerKeyNotFound)
|
return rowsAffectedOrNotFound(res, usecase.ErrWorkerKeyNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RevokeAny retires a key by id regardless of its owner.
|
||||||
|
func (r *WorkerKeyRepo) RevokeAny(ctx context.Context, id uuid.UUID) error {
|
||||||
|
res, err := r.db.ExecContext(ctx,
|
||||||
|
"UPDATE worker_keys SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL",
|
||||||
|
time.Now().UnixNano(), id.String())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return rowsAffectedOrNotFound(res, usecase.ErrWorkerKeyNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
func (r *WorkerKeyRepo) TouchLastUsed(ctx context.Context, id uuid.UUID) error {
|
func (r *WorkerKeyRepo) TouchLastUsed(ctx context.Context, id uuid.UUID) error {
|
||||||
_, err := r.db.ExecContext(ctx,
|
_, err := r.db.ExecContext(ctx,
|
||||||
"UPDATE worker_keys SET last_used_at = ? WHERE id = ?",
|
"UPDATE worker_keys SET last_used_at = ? WHERE id = ?",
|
||||||
|
|||||||
@@ -79,6 +79,27 @@ type workerKeysResponse struct {
|
|||||||
WorkerKeys []workerKeyResponse `json:"worker_keys"`
|
WorkerKeys []workerKeyResponse `json:"worker_keys"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// adminWorkerKeyResponse extends the public view with the owning user and the
|
||||||
|
// revocation state, both needed by the coordinator admin console.
|
||||||
|
type adminWorkerKeyResponse struct {
|
||||||
|
workerKeyResponse
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
RevokedAt string `json:"revoked_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func toAdminWorkerKeyResponse(k *domain.WorkerKey) adminWorkerKeyResponse {
|
||||||
|
resp := adminWorkerKeyResponse{workerKeyResponse: toWorkerKeyResponse(k), UserID: k.UserID.String()}
|
||||||
|
if k.RevokedAt != nil {
|
||||||
|
resp.RevokedAt = k.RevokedAt.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// usersResponse is the admin list of accounts, password hashes excluded.
|
||||||
|
type usersResponse struct {
|
||||||
|
Users []userResponse `json:"users"`
|
||||||
|
}
|
||||||
|
|
||||||
func toWorkerKeyResponse(k *domain.WorkerKey) workerKeyResponse {
|
func toWorkerKeyResponse(k *domain.WorkerKey) workerKeyResponse {
|
||||||
resp := workerKeyResponse{
|
resp := workerKeyResponse{
|
||||||
ID: k.ID.String(),
|
ID: k.ID.String(),
|
||||||
|
|||||||
@@ -14,16 +14,19 @@ import (
|
|||||||
|
|
||||||
// Handlers holds the use cases each endpoint drives.
|
// Handlers holds the use cases each endpoint drives.
|
||||||
type Handlers struct {
|
type Handlers struct {
|
||||||
register *usecase.Register
|
register *usecase.Register
|
||||||
login *usecase.Login
|
login *usecase.Login
|
||||||
setVerified *usecase.SetVerified
|
setVerified *usecase.SetVerified
|
||||||
setRole *usecase.SetRole
|
setRole *usecase.SetRole
|
||||||
createWorkerKey *usecase.CreateWorkerKey
|
createWorkerKey *usecase.CreateWorkerKey
|
||||||
listWorkerKeys *usecase.ListWorkerKeys
|
listWorkerKeys *usecase.ListWorkerKeys
|
||||||
revokeWorkerKey *usecase.RevokeWorkerKey
|
listWorkerKeysAll *usecase.ListWorkerKeysAll
|
||||||
exchangeWorkerKey *usecase.ExchangeWorkerKey
|
revokeWorkerKey *usecase.RevokeWorkerKey
|
||||||
users usecase.UserRepository
|
revokeWorkerKeyAdmin *usecase.RevokeWorkerKeyAdmin
|
||||||
log *slog.Logger
|
exchangeWorkerKey *usecase.ExchangeWorkerKey
|
||||||
|
listUsers *usecase.ListUsers
|
||||||
|
users usecase.UserRepository
|
||||||
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleHealth is an unauthenticated liveness probe for the container and load
|
// handleHealth is an unauthenticated liveness probe for the container and load
|
||||||
@@ -173,15 +176,42 @@ func (h *Handlers) handleListWorkerKeys(w http.ResponseWriter, r *http.Request)
|
|||||||
writeJSON(w, http.StatusOK, workerKeysResponse{WorkerKeys: out})
|
writeJSON(w, http.StatusOK, workerKeysResponse{WorkerKeys: out})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleListWorkerKeysAll returns every key in the service — revoked included,
|
||||||
|
// with the owning user id — for the coordinator admin console. Admin-only.
|
||||||
|
func (h *Handlers) handleListWorkerKeysAll(w http.ResponseWriter, r *http.Request) {
|
||||||
|
keys, err := h.listWorkerKeysAll.Execute(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, h.log, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]adminWorkerKeyResponse, 0, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
out = append(out, toAdminWorkerKeyResponse(k))
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, struct {
|
||||||
|
WorkerKeys []adminWorkerKeyResponse `json:"worker_keys"`
|
||||||
|
}{WorkerKeys: out})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListUsers returns every account for the coordinator admin console.
|
||||||
|
// Password hashes never leave the service: only the public projection is sent.
|
||||||
|
func (h *Handlers) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
users, err := h.listUsers.Execute(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, h.log, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]userResponse, 0, len(users))
|
||||||
|
for _, u := range users {
|
||||||
|
out = append(out, toUserResponse(u))
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, usersResponse{Users: out})
|
||||||
|
}
|
||||||
|
|
||||||
// handleRevokeWorkerKey retires one of the caller's keys. The repository scopes
|
// handleRevokeWorkerKey retires one of the caller's keys. The repository scopes
|
||||||
// the delete to the owner, so a mismatched id is a clean 404, not another user's
|
// the delete to the owner, so a mismatched id is a clean 404, not another user's
|
||||||
// key.
|
// key.
|
||||||
func (h *Handlers) handleRevokeWorkerKey(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) handleRevokeWorkerKey(w http.ResponseWriter, r *http.Request) {
|
||||||
userID, ok := userIDFrom(r.Context())
|
|
||||||
if !ok {
|
|
||||||
unauthorized(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
keyID, err := uuid.Parse(r.PathValue("id"))
|
keyID, err := uuid.Parse(r.PathValue("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusBadRequest, errorResponse{
|
writeJSON(w, http.StatusBadRequest, errorResponse{
|
||||||
@@ -190,6 +220,20 @@ func (h *Handlers) handleRevokeWorkerKey(w http.ResponseWriter, r *http.Request)
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// An admin may revoke any key; a plain user only their own.
|
||||||
|
if role, ok := r.Context().Value(roleKey).(domain.Role); ok && role == domain.RoleAdmin {
|
||||||
|
if err := h.revokeWorkerKeyAdmin.Execute(r.Context(), keyID); err != nil {
|
||||||
|
writeError(w, r, h.log, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, ok := userIDFrom(r.Context())
|
||||||
|
if !ok {
|
||||||
|
unauthorized(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := h.revokeWorkerKey.Execute(r.Context(), userID, keyID); err != nil {
|
if err := h.revokeWorkerKey.Execute(r.Context(), userID, keyID); err != nil {
|
||||||
writeError(w, r, h.log, err)
|
writeError(w, r, h.log, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -14,31 +14,37 @@ import (
|
|||||||
|
|
||||||
// UseCases bundles the application services the handlers drive.
|
// UseCases bundles the application services the handlers drive.
|
||||||
type UseCases struct {
|
type UseCases struct {
|
||||||
Register *usecase.Register
|
Register *usecase.Register
|
||||||
Login *usecase.Login
|
Login *usecase.Login
|
||||||
SetVerified *usecase.SetVerified
|
SetVerified *usecase.SetVerified
|
||||||
SetRole *usecase.SetRole
|
SetRole *usecase.SetRole
|
||||||
CreateWorkerKey *usecase.CreateWorkerKey
|
CreateWorkerKey *usecase.CreateWorkerKey
|
||||||
ListWorkerKeys *usecase.ListWorkerKeys
|
ListWorkerKeys *usecase.ListWorkerKeys
|
||||||
RevokeWorkerKey *usecase.RevokeWorkerKey
|
ListWorkerKeysAll *usecase.ListWorkerKeysAll
|
||||||
ExchangeWorkerKey *usecase.ExchangeWorkerKey
|
RevokeWorkerKey *usecase.RevokeWorkerKey
|
||||||
Users usecase.UserRepository
|
RevokeWorkerKeyAdmin *usecase.RevokeWorkerKeyAdmin
|
||||||
|
ExchangeWorkerKey *usecase.ExchangeWorkerKey
|
||||||
|
ListUsers *usecase.ListUsers
|
||||||
|
Users usecase.UserRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer wires the routes and the middleware stack and returns the handler.
|
// NewServer wires the routes and the middleware stack and returns the handler.
|
||||||
// The issuer verifies tokens for the JWT-protected routes.
|
// The issuer verifies tokens for the JWT-protected routes.
|
||||||
func NewServer(log *slog.Logger, uc UseCases, issuer auth.Issuer) http.Handler {
|
func NewServer(log *slog.Logger, uc UseCases, issuer auth.Issuer) http.Handler {
|
||||||
h := &Handlers{
|
h := &Handlers{
|
||||||
register: uc.Register,
|
register: uc.Register,
|
||||||
login: uc.Login,
|
login: uc.Login,
|
||||||
setVerified: uc.SetVerified,
|
setVerified: uc.SetVerified,
|
||||||
setRole: uc.SetRole,
|
setRole: uc.SetRole,
|
||||||
createWorkerKey: uc.CreateWorkerKey,
|
createWorkerKey: uc.CreateWorkerKey,
|
||||||
listWorkerKeys: uc.ListWorkerKeys,
|
listWorkerKeys: uc.ListWorkerKeys,
|
||||||
revokeWorkerKey: uc.RevokeWorkerKey,
|
listWorkerKeysAll: uc.ListWorkerKeysAll,
|
||||||
exchangeWorkerKey: uc.ExchangeWorkerKey,
|
revokeWorkerKey: uc.RevokeWorkerKey,
|
||||||
users: uc.Users,
|
revokeWorkerKeyAdmin: uc.RevokeWorkerKeyAdmin,
|
||||||
log: log,
|
exchangeWorkerKey: uc.ExchangeWorkerKey,
|
||||||
|
listUsers: uc.ListUsers,
|
||||||
|
users: uc.Users,
|
||||||
|
log: log,
|
||||||
}
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
@@ -68,6 +74,12 @@ func NewServer(log *slog.Logger, uc UseCases, issuer auth.Issuer) http.Handler {
|
|||||||
mux.Handle("POST /users/{id}/demote",
|
mux.Handle("POST /users/{id}/demote",
|
||||||
chain(h.handleSetRole(domain.RoleUser), withJWT(issuer), withAdmin))
|
chain(h.handleSetRole(domain.RoleUser), withJWT(issuer), withAdmin))
|
||||||
|
|
||||||
|
// Admin console: lists of every account and every worker key, and the key
|
||||||
|
// revoke path the admin console calls (the same DELETE endpoint already
|
||||||
|
// lets an admin revoke any key).
|
||||||
|
mux.Handle("GET /users", chain(http.HandlerFunc(h.handleListUsers), withJWT(issuer), withAdmin))
|
||||||
|
mux.Handle("GET /worker-keys/all", chain(http.HandlerFunc(h.handleListWorkerKeysAll), withJWT(issuer), withAdmin))
|
||||||
|
|
||||||
// Outermost first: every request gets an ID and an access-log line.
|
// Outermost first: every request gets an ID and an access-log line.
|
||||||
return chain(mux, withRequestID, withAccessLog(log))
|
return chain(mux, withRequestID, withAccessLog(log))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,17 +25,25 @@ const secret = "server-test-secret-32-bytes-long!!!!"
|
|||||||
|
|
||||||
func newTestServer() http.Handler {
|
func newTestServer() http.Handler {
|
||||||
users := memstore.NewUserRepo()
|
users := memstore.NewUserRepo()
|
||||||
|
keys := memstore.NewWorkerKeyRepo()
|
||||||
hasher := auth.NewHasher(4)
|
hasher := auth.NewHasher(4)
|
||||||
clk := memstore.Clock{T: time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC)}
|
clk := memstore.Clock{T: time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC)}
|
||||||
// Real clock for the issuer so tokens are valid at verification time.
|
// Real clock for the issuer so tokens are valid at verification time.
|
||||||
issuer := auth.NewIssuer(secret, time.Hour, nil)
|
issuer := auth.NewIssuer(secret, time.Hour, nil)
|
||||||
|
|
||||||
uc := apihttp.UseCases{
|
uc := apihttp.UseCases{
|
||||||
Register: usecase.NewRegister(users, hasher, clk),
|
Register: usecase.NewRegister(users, hasher, clk),
|
||||||
Login: usecase.NewLogin(users, hasher, issuer),
|
Login: usecase.NewLogin(users, hasher, issuer),
|
||||||
SetVerified: usecase.NewSetVerified(users),
|
SetVerified: usecase.NewSetVerified(users),
|
||||||
SetRole: usecase.NewSetRole(users),
|
SetRole: usecase.NewSetRole(users),
|
||||||
Users: users,
|
CreateWorkerKey: usecase.NewCreateWorkerKey(keys, clk),
|
||||||
|
ListWorkerKeys: usecase.NewListWorkerKeys(keys),
|
||||||
|
ListWorkerKeysAll: usecase.NewListWorkerKeysAll(keys),
|
||||||
|
RevokeWorkerKey: usecase.NewRevokeWorkerKey(keys),
|
||||||
|
RevokeWorkerKeyAdmin: usecase.NewRevokeWorkerKeyAdmin(keys),
|
||||||
|
ExchangeWorkerKey: usecase.NewExchangeWorkerKey(keys, users, issuer, time.Hour),
|
||||||
|
ListUsers: usecase.NewListUsers(users),
|
||||||
|
Users: users,
|
||||||
}
|
}
|
||||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
return apihttp.NewServer(log, uc, issuer)
|
return apihttp.NewServer(log, uc, issuer)
|
||||||
@@ -216,10 +224,16 @@ func TestMeInternalError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// mintToken issues a token with the package secret for a synthetic caller of the
|
// mintToken issues a token with the package secret for a synthetic caller of the
|
||||||
// given role — enough to drive the admin-gated endpoints.
|
// given role — enough to drive the admin-gated endpoints. userID defaults to a
|
||||||
|
// fresh random id; pass one to act as an existing account.
|
||||||
func mintToken(t *testing.T, role domain.Role) string {
|
func mintToken(t *testing.T, role domain.Role) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
token, err := auth.NewIssuer(secret, time.Hour, nil).Issue(&domain.User{ID: uuid.New(), Role: role})
|
return mintTokenFor(t, role, uuid.New())
|
||||||
|
}
|
||||||
|
|
||||||
|
func mintTokenFor(t *testing.T, role domain.Role, userID uuid.UUID) string {
|
||||||
|
t.Helper()
|
||||||
|
token, err := auth.NewIssuer(secret, time.Hour, nil).Issue(&domain.User{ID: userID, Role: role})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -370,3 +384,98 @@ func TestUnverifyRevokes(t *testing.T) {
|
|||||||
t.Error("verified should be false after unverify")
|
t.Error("verified should be false after unverify")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAdminListsUsersAndKeys(t *testing.T) {
|
||||||
|
h := newTestServer()
|
||||||
|
userID, _ := uuid.Parse(registerUser(t, h, "listed@example.com"))
|
||||||
|
userToken := mintTokenFor(t, domain.RoleUser, userID)
|
||||||
|
// Mint a worker key as the plain user.
|
||||||
|
keyRec := do(t, h, http.MethodPost, "/worker-keys", userToken, map[string]string{"name": "lab-node"})
|
||||||
|
if keyRec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create key: got %d, body %s", keyRec.Code, keyRec.Body)
|
||||||
|
}
|
||||||
|
var created struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(keyRec.Body.Bytes(), &created)
|
||||||
|
|
||||||
|
// Admin lists users: emails present, password hashes absent.
|
||||||
|
rec := do(t, h, http.MethodGet, "/users", mintToken(t, domain.RoleAdmin), nil)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("list users: got %d, body %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var users struct {
|
||||||
|
Users []struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
PasswordHash string `json:"password_hash"`
|
||||||
|
} `json:"users"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &users); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, u := range users.Users {
|
||||||
|
if u.PasswordHash != "" {
|
||||||
|
t.Error("password hash leaked through the admin users list")
|
||||||
|
}
|
||||||
|
if u.Email == "listed@example.com" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("listed user missing from the admin list")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin lists all keys: the owner is attached, no secret.
|
||||||
|
rec = do(t, h, http.MethodGet, "/worker-keys/all", mintToken(t, domain.RoleAdmin), nil)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("list all keys: got %d", rec.Code)
|
||||||
|
}
|
||||||
|
var keys struct {
|
||||||
|
WorkerKeys []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"worker_keys"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &keys); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(keys.WorkerKeys) != 1 || keys.WorkerKeys[0].UserID != userID.String() {
|
||||||
|
t.Errorf("all keys = %+v, want the one key owned by %s", keys.WorkerKeys, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain users cannot see either list.
|
||||||
|
for _, path := range []string{"/users", "/worker-keys/all"} {
|
||||||
|
if rec := do(t, h, http.MethodGet, path, mintToken(t, domain.RoleUser), nil); rec.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("%s as user: got %d, want 403", path, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An admin revokes a key that belongs to another user; the plain owner of
|
||||||
|
// that key could not (it would be a 404, scoped to their own keys).
|
||||||
|
// The owner of the key revokes it themselves: 204.
|
||||||
|
if rec := do(t, h, http.MethodDelete, "/worker-keys/"+created.ID, userToken, nil); rec.Code != http.StatusNoContent {
|
||||||
|
t.Errorf("user revoke own key: got %d, want 204", rec.Code)
|
||||||
|
}
|
||||||
|
// Another plain user cannot revoke it: scoped to their own keys, so a
|
||||||
|
// mismatch reads as 404.
|
||||||
|
otherID, _ := uuid.Parse(registerUser(t, h, "other@example.com"))
|
||||||
|
if rec := do(t, h, http.MethodDelete, "/worker-keys/"+created.ID, mintTokenFor(t, domain.RoleUser, otherID), nil); rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("other user revoke: got %d, want 404", rec.Code)
|
||||||
|
}
|
||||||
|
// An admin revokes a key that belongs to someone else: 204.
|
||||||
|
keyRec = do(t, h, http.MethodPost, "/worker-keys", userToken, map[string]string{"name": "lab-node-2"})
|
||||||
|
var second struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(keyRec.Body.Bytes(), &second)
|
||||||
|
if rec := do(t, h, http.MethodDelete, "/worker-keys/"+second.ID, mintToken(t, domain.RoleAdmin), nil); rec.Code != http.StatusNoContent {
|
||||||
|
t.Errorf("admin revoke other's key: got %d, want 204", rec.Code)
|
||||||
|
}
|
||||||
|
// Admin cannot revoke an unknown key.
|
||||||
|
if rec := do(t, h, http.MethodDelete, "/worker-keys/"+uuid.NewString(), mintToken(t, domain.RoleAdmin), nil); rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("admin revoke unknown key: got %d, want 404", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ func (s stubRepo) SetVerified(context.Context, uuid.UUID, bool) error {
|
|||||||
func (s stubRepo) SetRole(context.Context, uuid.UUID, domain.Role) error {
|
func (s stubRepo) SetRole(context.Context, uuid.UUID, domain.Role) error {
|
||||||
return usecase.ErrUserNotFound
|
return usecase.ErrUserNotFound
|
||||||
}
|
}
|
||||||
|
func (s stubRepo) ListUsers(context.Context) ([]*domain.User, error) { return nil, nil }
|
||||||
|
|
||||||
type stubHasher struct {
|
type stubHasher struct {
|
||||||
hashErr error
|
hashErr error
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ type UserRepository interface {
|
|||||||
// SetRole changes a user's role, returning ErrUserNotFound if no such user
|
// SetRole changes a user's role, returning ErrUserNotFound if no such user
|
||||||
// exists.
|
// exists.
|
||||||
SetRole(ctx context.Context, id uuid.UUID, role domain.Role) error
|
SetRole(ctx context.Context, id uuid.UUID, role domain.Role) error
|
||||||
|
// ListUsers returns every account, oldest first. Admin-only: used by the
|
||||||
|
// coordinator admin console; the response must never carry password hashes
|
||||||
|
// (the caller projects the entity).
|
||||||
|
ListUsers(ctx context.Context) ([]*domain.User, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// WorkerKeyRepository persists and looks up the long-lived worker keys a user
|
// WorkerKeyRepository persists and looks up the long-lived worker keys a user
|
||||||
@@ -38,12 +42,18 @@ type WorkerKeyRepository interface {
|
|||||||
Insert(ctx context.Context, k *domain.WorkerKey) error
|
Insert(ctx context.Context, k *domain.WorkerKey) error
|
||||||
// ListByUser returns a user's live (non-revoked) keys, newest first.
|
// ListByUser returns a user's live (non-revoked) keys, newest first.
|
||||||
ListByUser(ctx context.Context, userID uuid.UUID) ([]*domain.WorkerKey, error)
|
ListByUser(ctx context.Context, userID uuid.UUID) ([]*domain.WorkerKey, error)
|
||||||
|
// ListAll returns every key (revoked included), newest first. Admin-only:
|
||||||
|
// backs the coordinator admin console's key table.
|
||||||
|
ListAll(ctx context.Context) ([]*domain.WorkerKey, error)
|
||||||
// GetActiveByHash returns the non-revoked key with the given hash, or
|
// GetActiveByHash returns the non-revoked key with the given hash, or
|
||||||
// ErrWorkerKeyNotFound.
|
// ErrWorkerKeyNotFound.
|
||||||
GetActiveByHash(ctx context.Context, tokenHash string) (*domain.WorkerKey, error)
|
GetActiveByHash(ctx context.Context, tokenHash string) (*domain.WorkerKey, error)
|
||||||
// Revoke retires a key the user owns, returning ErrWorkerKeyNotFound when no
|
// Revoke retires a key the user owns, returning ErrWorkerKeyNotFound when no
|
||||||
// live key with that id belongs to the user.
|
// live key with that id belongs to the user.
|
||||||
Revoke(ctx context.Context, id, userID uuid.UUID) error
|
Revoke(ctx context.Context, id, userID uuid.UUID) error
|
||||||
|
// RevokeAny retires a key by id regardless of its owner. Admin-only; the
|
||||||
|
// coordinator admin console uses it to cut a key immediately.
|
||||||
|
RevokeAny(ctx context.Context, id uuid.UUID) error
|
||||||
// TouchLastUsed records a successful exchange. Best-effort: a failure here
|
// TouchLastUsed records a successful exchange. Best-effort: a failure here
|
||||||
// must not fail the exchange itself.
|
// must not fail the exchange itself.
|
||||||
TouchLastUsed(ctx context.Context, id uuid.UUID) error
|
TouchLastUsed(ctx context.Context, id uuid.UUID) error
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/emil28092005/SciMesh/coordinator/internal/userservice/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListUsers returns every account for the coordinator admin console. The
|
||||||
|
// handler must project the entities so password hashes never leave the service.
|
||||||
|
type ListUsers struct {
|
||||||
|
users UserRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewListUsers(users UserRepository) *ListUsers {
|
||||||
|
return &ListUsers{users: users}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *ListUsers) Execute(ctx context.Context) ([]*domain.User, error) {
|
||||||
|
return uc.users.ListUsers(ctx)
|
||||||
|
}
|
||||||
@@ -47,6 +47,34 @@ func (uc *ListWorkerKeys) Execute(ctx context.Context, userID uuid.UUID) ([]*dom
|
|||||||
return uc.keys.ListByUser(ctx, userID)
|
return uc.keys.ListByUser(ctx, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListWorkerKeysAll returns every key in the service, revoked included, for
|
||||||
|
// the coordinator admin console. Admin-only.
|
||||||
|
type ListWorkerKeysAll struct {
|
||||||
|
keys WorkerKeyRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewListWorkerKeysAll(keys WorkerKeyRepository) *ListWorkerKeysAll {
|
||||||
|
return &ListWorkerKeysAll{keys: keys}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *ListWorkerKeysAll) Execute(ctx context.Context) ([]*domain.WorkerKey, error) {
|
||||||
|
return uc.keys.ListAll(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeWorkerKeyAdmin retires any key, regardless of owner. Admin-only; used
|
||||||
|
// by the coordinator admin console when a key must be cut immediately.
|
||||||
|
type RevokeWorkerKeyAdmin struct {
|
||||||
|
keys WorkerKeyRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRevokeWorkerKeyAdmin(keys WorkerKeyRepository) *RevokeWorkerKeyAdmin {
|
||||||
|
return &RevokeWorkerKeyAdmin{keys: keys}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *RevokeWorkerKeyAdmin) Execute(ctx context.Context, id uuid.UUID) error {
|
||||||
|
return uc.keys.RevokeAny(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
// RevokeWorkerKey retires one of the caller's keys.
|
// RevokeWorkerKey retires one of the caller's keys.
|
||||||
type RevokeWorkerKey struct {
|
type RevokeWorkerKey struct {
|
||||||
keys WorkerKeyRepository
|
keys WorkerKeyRepository
|
||||||
|
|||||||
@@ -64,6 +64,24 @@ func (r *fakeKeyRepo) TouchLastUsed(_ context.Context, id uuid.UUID) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *fakeKeyRepo) ListAll(_ context.Context) ([]*domain.WorkerKey, error) {
|
||||||
|
out := make([]*domain.WorkerKey, 0, len(r.byID))
|
||||||
|
for _, k := range r.byID {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeKeyRepo) RevokeAny(_ context.Context, id uuid.UUID) error {
|
||||||
|
k, ok := r.byID[id]
|
||||||
|
if !ok || k.Revoked() {
|
||||||
|
return usecase.ErrWorkerKeyNotFound
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
k.RevokedAt = &now
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func newKeyFixtures(t *testing.T) (*usecase.CreateWorkerKey, *usecase.ExchangeWorkerKey, *usecase.RevokeWorkerKey, *usecase.ListWorkerKeys, *fakeKeyRepo, *domain.User) {
|
func newKeyFixtures(t *testing.T) (*usecase.CreateWorkerKey, *usecase.ExchangeWorkerKey, *usecase.RevokeWorkerKey, *usecase.ListWorkerKeys, *fakeKeyRepo, *domain.User) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
users := memstore.NewUserRepo()
|
users := memstore.NewUserRepo()
|
||||||
|
|||||||
@@ -176,6 +176,12 @@ func (c *Catalog) Enabled() []*Workload {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Items returns every workload in the catalog, sorted by name (the same
|
||||||
|
// ordering as Enabled). The caller must not mutate the entries.
|
||||||
|
func (c *Catalog) Items() []*Workload {
|
||||||
|
return c.workloads
|
||||||
|
}
|
||||||
|
|
||||||
// ByName returns the workload with the given name, or nil.
|
// ByName returns the workload with the given name, or nil.
|
||||||
func (c *Catalog) ByName(name string) *Workload {
|
func (c *Catalog) ByName(name string) *Workload {
|
||||||
for _, workload := range c.workloads {
|
for _, workload := range c.workloads {
|
||||||
|
|||||||
Reference in New Issue
Block a user