{{range .Capabilities}}{{.}}{{end}}
Last signal · {{time .LastHeartbeatAt}}
diff --git a/coordinator/cmd/coordinator/main.go b/coordinator/cmd/coordinator/main.go index dd6e33d..af83019 100644 --- a/coordinator/cmd/coordinator/main.go +++ b/coordinator/cmd/coordinator/main.go @@ -121,7 +121,7 @@ func run() error { // pool.Ping backs /health: readiness means the database answers, not just // that the process is alive. - api := httptransport.NewServer(useCases, log, cfg.RequestTimeout, cfg.HeartbeatInterval, cfg.MaxUploadBytes, cfg.JWTSecret, cfg.UserserviceURL, m, pool.Ping) + api := httptransport.NewServer(useCases, log, cfg.RequestTimeout, cfg.HeartbeatInterval, cfg.MaxUploadBytes, cfg.JWTSecret, cfg.UserserviceURL, m, pool.Ping, cfg.PublicCoordinatorURL, cfg.PublicUserserviceURL) err = infra.RunServer(ctx, log, cfg.Addr, api.Handler(cfg.Token, cfg.UIToken)) // Shutdown order matters, and defers alone cannot express it (they run diff --git a/coordinator/docker-compose.users.yml b/coordinator/docker-compose.users.yml index 30e5483..2b27262 100644 --- a/coordinator/docker-compose.users.yml +++ b/coordinator/docker-compose.users.yml @@ -64,3 +64,8 @@ services: environment: JWT_SECRET: ${JWT_SECRET} USERSERVICE_URL: http://userservice:8081 + # Browser/host-facing URLs for the "add your machine" command. A user's + # worker runs on the host, so it reaches the published ports on localhost, + # not the in-cluster service names. + PUBLIC_COORDINATOR_URL: http://localhost:${COORDINATOR_PORT:-8080} + PUBLIC_USERSERVICE_URL: http://localhost:${USERSERVICE_PORT:-8081} diff --git a/coordinator/internal/infra/config.go b/coordinator/internal/infra/config.go index 5a414bd..94ca185 100644 --- a/coordinator/internal/infra/config.go +++ b/coordinator/internal/infra/config.go @@ -37,6 +37,13 @@ type Config struct { // login/registration (cookie session) instead of the static UI_AUTH_TOKEN // basic auth. Empty keeps the basic-auth UI. UserserviceURL string + // Browser-facing base URLs used to render the "add your machine" command on + // the UI. They must be reachable from a user's own machine, which is not + // necessarily the in-cluster address the coordinator uses for UserserviceURL. + // PublicCoordinatorURL empty lets the page fall back to its own origin; + // PublicUserserviceURL empty falls back to UserserviceURL. + PublicCoordinatorURL string + PublicUserserviceURL string // Minimum log level: debug, info, warn, error. LogLevel string @@ -92,23 +99,25 @@ func LoadConfig() (Config, error) { DatabaseURL: os.Getenv("DATABASE_URL"), // COORDINATOR_TOKEN is the contract name; WORKER_AUTH_TOKEN is the // former name, still honoured so existing .env files keep working. - Token: getEnv("COORDINATOR_TOKEN", os.Getenv("WORKER_AUTH_TOKEN")), - UIToken: os.Getenv("UI_AUTH_TOKEN"), - JWTSecret: os.Getenv("JWT_SECRET"), - UserserviceURL: os.Getenv("USERSERVICE_URL"), - LogLevel: getEnv("LOG_LEVEL", "info"), - LogFile: os.Getenv("LOG_FILE"), - StorageDir: getEnv("COORDINATOR_STORAGE_DIR", "./data"), - MaxUploadBytes: 1 << 30, // 1 GiB - DBMaxConns: 10, - DBConnectTimeout: 30 * time.Second, - RequestTimeout: 15 * time.Second, - HeartbeatInterval: 15 * time.Second, - LeaseDuration: 2 * time.Minute, - DefaultMaxAttempts: 3, - QuorumSize: 2, - ReaperInterval: 30 * time.Second, - WorkerOfflineAfter: 1 * time.Minute, + Token: getEnv("COORDINATOR_TOKEN", os.Getenv("WORKER_AUTH_TOKEN")), + UIToken: os.Getenv("UI_AUTH_TOKEN"), + JWTSecret: os.Getenv("JWT_SECRET"), + UserserviceURL: os.Getenv("USERSERVICE_URL"), + PublicCoordinatorURL: os.Getenv("PUBLIC_COORDINATOR_URL"), + PublicUserserviceURL: getEnv("PUBLIC_USERSERVICE_URL", os.Getenv("USERSERVICE_URL")), + LogLevel: getEnv("LOG_LEVEL", "info"), + LogFile: os.Getenv("LOG_FILE"), + StorageDir: getEnv("COORDINATOR_STORAGE_DIR", "./data"), + MaxUploadBytes: 1 << 30, // 1 GiB + DBMaxConns: 10, + DBConnectTimeout: 30 * time.Second, + RequestTimeout: 15 * time.Second, + HeartbeatInterval: 15 * time.Second, + LeaseDuration: 2 * time.Minute, + DefaultMaxAttempts: 3, + QuorumSize: 2, + ReaperInterval: 30 * time.Second, + WorkerOfflineAfter: 1 * time.Minute, } if cfg.DatabaseURL == "" { diff --git a/coordinator/internal/memstore/ui_read.go b/coordinator/internal/memstore/ui_read.go index cc2c681..8f3f191 100644 --- a/coordinator/internal/memstore/ui_read.go +++ b/coordinator/internal/memstore/ui_read.go @@ -87,16 +87,44 @@ func (r *UIReadRepo) ListWorkers(_ context.Context, limit int) ([]domain.Worker, copy.Capabilities = append([]string(nil), worker.Capabilities...) out = append(out, copy) } + sortWorkers(out) + if len(out) > limit { + out = out[:limit] + } + return out, nil +} + +func (r *UIReadRepo) ListWorkersByOwner(_ context.Context, owner uuid.UUID, limit int) ([]domain.Worker, error) { + if limit < 1 || limit > 100 { + return nil, domain.ErrInvalidInput + } + r.workers.mu.Lock() + defer r.workers.mu.Unlock() + out := []domain.Worker{} + for _, worker := range r.workers.workers { + if worker.OwnerID == nil || *worker.OwnerID != owner { + continue + } + copy := *worker + copy.Capabilities = append([]string(nil), worker.Capabilities...) + out = append(out, copy) + } + sortWorkers(out) + if len(out) > limit { + out = out[:limit] + } + return out, nil +} + +// sortWorkers orders workers most-recently-seen first, breaking ties on id so +// the order is deterministic across calls. +func sortWorkers(out []domain.Worker) { sort.Slice(out, func(i, j int) bool { if out[i].LastHeartbeatAt.Equal(out[j].LastHeartbeatAt) { return out[i].ID.String() > out[j].ID.String() } return out[i].LastHeartbeatAt.After(out[j].LastHeartbeatAt) }) - if len(out) > limit { - out = out[:limit] - } - return out, nil } func (r *UIReadRepo) ListArtifactsByJob(_ context.Context, jobID uuid.UUID) ([]domain.Artifact, error) { r.artifacts.mu.Lock() diff --git a/coordinator/internal/storage/postgres/ui_read_repo.go b/coordinator/internal/storage/postgres/ui_read_repo.go index 5c8a6a6..1382ee4 100644 --- a/coordinator/internal/storage/postgres/ui_read_repo.go +++ b/coordinator/internal/storage/postgres/ui_read_repo.go @@ -128,6 +128,32 @@ func (r *UIReadRepo) ListWorkers(ctx context.Context, limit int) ([]domain.Worke return workers, rows.Err() } +func (r *UIReadRepo) ListWorkersByOwner(ctx context.Context, owner uuid.UUID, limit int) ([]domain.Worker, error) { + if limit < 1 || limit > 100 { + return nil, domain.ErrInvalidInput + } + sql, args, err := psql.Select(workerColumns...).From("workers"). + Where(sq.Eq{"owner_id": owner}). + OrderBy("last_heartbeat_at DESC", "id DESC").Limit(uint64(limit)).ToSql() + if err != nil { + return nil, err + } + rows, err := conn(ctx, r.pool).Query(ctx, sql, args...) + if err != nil { + return nil, fmt.Errorf("list workers by owner: %w", err) + } + defer rows.Close() + workers := make([]domain.Worker, 0) + for rows.Next() { + worker, err := scanWorker(rows) + if err != nil { + return nil, err + } + workers = append(workers, *worker) + } + return workers, rows.Err() +} + func (r *UIReadRepo) ListArtifactsByJob(ctx context.Context, jobID uuid.UUID) ([]domain.Artifact, error) { sql, args, err := psql.Select(artifactColumns...).From("artifacts").Where(sq.Eq{"job_id": jobID}).OrderBy("created_at ASC", "id ASC").ToSql() if err != nil { diff --git a/coordinator/internal/transport/http/server.go b/coordinator/internal/transport/http/server.go index 6439da1..a2967e7 100644 --- a/coordinator/internal/transport/http/server.go +++ b/coordinator/internal/transport/http/server.go @@ -49,6 +49,11 @@ type Server struct { // userserviceURL is the base URL the UI proxies login/registration to. Empty // keeps the static basic-auth UI. userserviceURL string + // publicCoordinatorURL / publicUserserviceURL are the browser-facing URLs + // rendered into the worker-enrollment command. Either may be empty; the + // template falls back (own origin / userserviceURL respectively). + publicCoordinatorURL string + publicUserserviceURL string // httpClient makes the login/register calls to the userservice. httpClient *http.Client // metrics holds the Prometheus registry and HTTP instrumentation. @@ -59,21 +64,33 @@ type Server struct { } func NewServer(uc UseCases, log *slog.Logger, requestTimeout, heartbeatInterval time.Duration, - maxUploadBytes int64, jwtSecret, userserviceURL string, m *metrics.Metrics, ready func(context.Context) error) *Server { + maxUploadBytes int64, jwtSecret, userserviceURL string, m *metrics.Metrics, ready func(context.Context) error, + publicURLs ...string) *Server { if m == nil { m = metrics.New() } + // publicURLs is variadic so existing callers/tests need no change: [0] is the + // public coordinator URL, [1] the public userservice URL; both optional. + var publicCoordinatorURL, publicUserserviceURL string + if len(publicURLs) > 0 { + publicCoordinatorURL = strings.TrimRight(publicURLs[0], "/") + } + if len(publicURLs) > 1 { + publicUserserviceURL = strings.TrimRight(publicURLs[1], "/") + } return &Server{ - uc: uc, - log: log, - requestTimeout: requestTimeout, - heartbeatInterval: heartbeatInterval, - maxUploadBytes: maxUploadBytes, - verifier: tokenpkg.NewVerifier(jwtSecret), - userserviceURL: strings.TrimRight(userserviceURL, "/"), - httpClient: &http.Client{Timeout: 10 * time.Second}, - metrics: m, - ready: ready, + uc: uc, + log: log, + requestTimeout: requestTimeout, + heartbeatInterval: heartbeatInterval, + maxUploadBytes: maxUploadBytes, + verifier: tokenpkg.NewVerifier(jwtSecret), + userserviceURL: strings.TrimRight(userserviceURL, "/"), + publicCoordinatorURL: publicCoordinatorURL, + publicUserserviceURL: publicUserserviceURL, + httpClient: &http.Client{Timeout: 10 * time.Second}, + metrics: m, + ready: ready, } } @@ -139,6 +156,14 @@ func (s *Server) Handler(token string, uiToken ...string) http.Handler { ui.Handle(rt.pattern, gate(rt.handler)) } ui.Handle("GET /ui/profile", gate(http.HandlerFunc(s.handleUIProfile))) + // Worker enrollment: a user creates/lists/revokes their own worker keys + // and copies a ready-to-run command. Session-only — it proxies to the + // userservice with the caller's token, so it has no meaning under basic + // auth (which has no userservice). + ui.Handle("GET /ui/workers/new", gate(http.HandlerFunc(s.handleUIAddWorker))) + ui.Handle("GET /ui/api/worker-keys", gate(http.HandlerFunc(s.handleUIWorkerKeysList))) + ui.Handle("POST /ui/api/worker-keys", gate(http.HandlerFunc(s.handleUIWorkerKeyCreate))) + ui.Handle("POST /ui/api/worker-keys/{id}/revoke", gate(http.HandlerFunc(s.handleUIWorkerKeyRevoke))) // Admin panel: session + admin role. ui.Handle("GET /ui/admin", chain(http.HandlerFunc(s.handleUIAdmin), gate, requireAdmin)) ui.Handle("POST /ui/admin/user-action", chain(http.HandlerFunc(s.handleUIAdminUserAction), gate, requireAdmin)) diff --git a/coordinator/internal/transport/http/templates/add-worker.html b/coordinator/internal/transport/http/templates/add-worker.html new file mode 100644 index 0000000..e0de231 --- /dev/null +++ b/coordinator/internal/transport/http/templates/add-worker.html @@ -0,0 +1,55 @@ +{{define "add-worker.html"}} + + +
+ + +Contribute compute
Create a key, install the worker, and run one command. The worker binds to your account and pulls tasks whenever it is online.
+A key is long-lived and does not expire like a login. The worker trades it for short-lived tokens automatically. Revoke a key to stop its machines.
+ + + +Local scientific compute
Follow the real path from a molecular TSV to a globally reduced similarity result—without reading coordinator logs.
{{len .Jobs}} shown · newest first
Workers you registered. Add your machine →
{{range .Capabilities}}{{.}}{{end}}
Last signal · {{time .LastHeartbeatAt}}
Workers register themselves; this page never controls their processes.
{{range .Capabilities}}{{.}}{{end}}
Last signal · {{time .LastHeartbeatAt}}
scimesh-worker in another terminal, then return here.