Add the worker setup wizard (setup, --config, --check) to worker-agent
This commit is contained in:
@@ -1,14 +1,24 @@
|
||||
// Command worker-agent is the Go worker agent: a coordinator client that
|
||||
// executes SDK workloads in a Python subprocess per claimed task.
|
||||
// executes SDK workloads in a Python subprocess per claimed task. It also
|
||||
// carries the local setup wizard (`worker-agent setup`) so a machine that
|
||||
// installs only the worker can configure and start itself without a
|
||||
// coordinator on site.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/agent"
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/agent/setupui"
|
||||
)
|
||||
|
||||
// version is injected at build time (-ldflags "-X main.version=...") and
|
||||
@@ -16,13 +26,44 @@ import (
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
showVersion := flag.Bool("version", false, "print the build version and exit")
|
||||
flag.Parse()
|
||||
if len(os.Args) > 1 && os.Args[1] == "setup" {
|
||||
os.Exit(runSetup(os.Args[2:]))
|
||||
}
|
||||
|
||||
fs := flag.NewFlagSet("worker-agent", flag.ExitOnError)
|
||||
showVersion := fs.Bool("version", false, "print the build version and exit")
|
||||
configPath := fs.String("config", "", "path to a JSON config file (SCIMESH_WORKER_CONFIG overrides the default)")
|
||||
checkMode := fs.Bool("check", false, "run the preflight check (coordinator + local runtime) and exit 0/1")
|
||||
checkURL := fs.String("coordinator-url", "", "coordinator URL to probe in --check mode")
|
||||
_ = fs.Parse(os.Args[1:])
|
||||
|
||||
agent.Version = version
|
||||
|
||||
if *showVersion {
|
||||
fmt.Println("worker-agent " + version)
|
||||
return
|
||||
}
|
||||
config, err := agent.LoadConfig()
|
||||
|
||||
if *checkMode {
|
||||
url := *checkURL
|
||||
if url == "" {
|
||||
url = os.Getenv("COORDINATOR_URL")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if url == "" {
|
||||
fmt.Println("check: no coordinator URL (pass --coordinator-url or set COORDINATOR_URL)")
|
||||
os.Exit(1)
|
||||
}
|
||||
report := agent.RunCheck(ctx, url)
|
||||
printCheck(report)
|
||||
if !report.Coordinator.OK || !report.Python.OK || !report.Scimesh.OK {
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
config, err := loadConfig(*configPath)
|
||||
if err != nil {
|
||||
slog.Error("invalid configuration", "error", err)
|
||||
os.Exit(2)
|
||||
@@ -42,3 +83,96 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// loadConfig prefers a --config file; environment variables override the file
|
||||
// (see agent.ConfigFile.Config). Without a file, the plain environment path is
|
||||
// used exactly as before.
|
||||
func loadConfig(configPath string) (*agent.Config, error) {
|
||||
if configPath != "" {
|
||||
return agent.LoadConfigFile(configPath)
|
||||
}
|
||||
envPath := os.Getenv("SCIMESH_WORKER_CONFIG")
|
||||
if envPath != "" {
|
||||
if _, err := os.Stat(envPath); err == nil {
|
||||
return agent.LoadConfigFile(envPath)
|
||||
}
|
||||
}
|
||||
return agent.LoadConfig()
|
||||
}
|
||||
|
||||
func printCheck(report agent.CheckReport) {
|
||||
fmt.Printf("worker-agent %s\n", report.Agent)
|
||||
line := func(item agent.CheckItem) string {
|
||||
mark := "✗"
|
||||
if item.OK {
|
||||
mark = "✓"
|
||||
}
|
||||
detail := item.Detail
|
||||
if item.Latency > 0 {
|
||||
detail = fmt.Sprintf("%s (%d ms)", detail, item.Latency)
|
||||
}
|
||||
return fmt.Sprintf(" %s %s: %s", mark, item.Name, detail)
|
||||
}
|
||||
fmt.Println(line(report.Coordinator))
|
||||
fmt.Println(line(report.Auth))
|
||||
fmt.Println(line(report.Python))
|
||||
fmt.Println(line(report.Scimesh))
|
||||
}
|
||||
|
||||
// runSetup serves the local setup wizard until interrupted. It binds the
|
||||
// loopback interface only.
|
||||
func runSetup(args []string) int {
|
||||
fs := flag.NewFlagSet("worker-agent setup", flag.ContinueOnError)
|
||||
port := fs.Int("port", 0, "listen port (default 12700)")
|
||||
noOpen := fs.Bool("no-open", false, "do not open the browser automatically")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
server := setupui.New(logger, setupui.Options{
|
||||
Port: *port,
|
||||
OpenBrowser: func(url string) {
|
||||
if *noOpen {
|
||||
return
|
||||
}
|
||||
openBrowser(url)
|
||||
},
|
||||
})
|
||||
listener, err := server.Listen()
|
||||
if err != nil {
|
||||
logger.Error("setup wizard could not bind the loopback port", "err", err)
|
||||
return 1
|
||||
}
|
||||
url := "http://" + listener.Addr().String()
|
||||
logger.Info("SciMesh worker setup wizard", "url", url, "press-ctrl-c-to-stop", true)
|
||||
server.OpenBrowser(url)
|
||||
// Block until the signal arrives (never returns an error that matters: a
|
||||
// cancelled context is the normal exit path).
|
||||
err = server.Serve(ctx, listener)
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
logger.Error("setup wizard stopped", "err", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// openBrowser points the user's default browser at the wizard. Best-effort:
|
||||
// a missing browser must never fail the setup flow.
|
||||
func openBrowser(url string) {
|
||||
for _, candidate := range [][]string{
|
||||
{"xdg-open", url},
|
||||
{"open", url},
|
||||
{"cmd", "/c", "start", url},
|
||||
} {
|
||||
binary, err := exec.LookPath(candidate[0])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_ = exec.Command(binary, candidate[1:]...).Start()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CheckItem is one line of the preflight report the setup wizard shows.
|
||||
type CheckItem struct {
|
||||
Name string `json:"name"`
|
||||
OK bool `json:"ok"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Latency int64 `json:"latency_ms,omitempty"`
|
||||
}
|
||||
|
||||
// CheckReport is the full preflight result of `worker-agent --check` and of
|
||||
// the wizard's test step.
|
||||
type CheckReport struct {
|
||||
Coordinator CheckItem `json:"coordinator"`
|
||||
Auth CheckItem `json:"auth"`
|
||||
Python CheckItem `json:"python"`
|
||||
Scimesh CheckItem `json:"scimesh"`
|
||||
Agent string `json:"agent_version"`
|
||||
CoordinatorVersion string `json:"coordinator_version,omitempty"`
|
||||
}
|
||||
|
||||
// checkHTTP runs one GET and reports reachability + latency, with a fallback
|
||||
// detail message when the server answers without JSON.
|
||||
func checkHTTP(ctx context.Context, url string, timeout time.Duration) (CheckItem, string) {
|
||||
started := time.Now()
|
||||
reqCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return CheckItem{Name: "coordinator", OK: false, Detail: "invalid URL"}, ""
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
detail := err.Error()
|
||||
if strings.Contains(detail, "connection refused") {
|
||||
detail = "no coordinator answering at this address"
|
||||
}
|
||||
return CheckItem{Name: "coordinator", OK: false, Detail: detail}, ""
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
version := ""
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
var body struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err == nil && body.Status == "ok" {
|
||||
return CheckItem{Name: "coordinator", OK: true, Latency: time.Since(started).Milliseconds()}, version
|
||||
}
|
||||
}
|
||||
return CheckItem{Name: "coordinator", OK: false, Detail: fmt.Sprintf("HTTP %d", resp.StatusCode)}, version
|
||||
}
|
||||
|
||||
// CheckCoordinator probes the coordinator's /health endpoint.
|
||||
func CheckCoordinator(ctx context.Context, url string, timeout time.Duration) CheckReport {
|
||||
report := CheckReport{Agent: Version}
|
||||
item, _ := checkHTTP(ctx, strings.TrimRight(url, "/")+"/health", timeout)
|
||||
report.Coordinator = item
|
||||
report.Auth = CheckItem{Name: "auth", OK: true, Detail: "no token configured — will be checked at registration"}
|
||||
return report
|
||||
}
|
||||
|
||||
// CheckEnvironment verifies the local runtime: Python present and the scimesh
|
||||
// package importable.
|
||||
func CheckEnvironment(ctx context.Context) CheckReport {
|
||||
report := CheckReport{Agent: Version}
|
||||
python, err := exec.LookPath("python3")
|
||||
if err != nil {
|
||||
report.Python = CheckItem{Name: "python", OK: false, Detail: "python3 not found on PATH"}
|
||||
return report
|
||||
}
|
||||
report.Python = CheckItem{Name: "python", OK: true, Detail: python}
|
||||
cmd := exec.CommandContext(ctx, python, "-c", "import scimesh; print(scimesh.__version__ if hasattr(scimesh, '__version__') else 'installed')")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
report.Scimesh = CheckItem{Name: "scimesh", OK: false, Detail: "install with: pip install scimesh"}
|
||||
return report
|
||||
}
|
||||
report.Scimesh = CheckItem{Name: "scimesh", OK: true, Detail: strings.TrimSpace(string(out))}
|
||||
return report
|
||||
}
|
||||
|
||||
// RunCheck combines the coordinator probe and the local environment probe; it
|
||||
// is the body behind `worker-agent --check` and the wizard's test step.
|
||||
func RunCheck(ctx context.Context, coordinatorURL string) CheckReport {
|
||||
report := CheckCoordinator(ctx, coordinatorURL, 15*time.Second)
|
||||
env := CheckEnvironment(ctx)
|
||||
report.Python = env.Python
|
||||
report.Scimesh = env.Scimesh
|
||||
return report
|
||||
}
|
||||
|
||||
// Version is the agent build version; main injects it via -ldflags and the
|
||||
// setup wizard mirrors it into the report. "dev" marks a local build.
|
||||
var Version = "dev"
|
||||
|
||||
// Platform is the host platform string shown on the wizard.
|
||||
func Platform() string { return runtime.GOOS + "/" + runtime.GOARCH }
|
||||
@@ -0,0 +1,133 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ConfigFile is the persisted worker configuration written by the setup
|
||||
// wizard and read back by `worker-agent --config`. Environment variables
|
||||
// still win: the file fills in what the environment left unset.
|
||||
type ConfigFile struct {
|
||||
CoordinatorURL string `json:"coordinator_url"`
|
||||
Token string `json:"token,omitempty"`
|
||||
WorkerKey string `json:"worker_key,omitempty"`
|
||||
UserserviceURL string `json:"userservice_url,omitempty"`
|
||||
WorkDir string `json:"work_dir"`
|
||||
WorkerName string `json:"worker_name,omitempty"`
|
||||
CPUCount int `json:"cpu_count"`
|
||||
MemoryMB int `json:"memory_mb"`
|
||||
TaskRunner []string `json:"task_runner,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultConfigPath is where the setup wizard stores the worker's
|
||||
// configuration. SCIMESH_WORKER_CONFIG overrides it.
|
||||
func DefaultConfigPath() string {
|
||||
if raw := os.Getenv("SCIMESH_WORKER_CONFIG"); raw != "" {
|
||||
return raw
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
return filepath.Join(".", ".scimesh-worker", "config.json")
|
||||
}
|
||||
return filepath.Join(home, ".scimesh-worker", "config.json")
|
||||
}
|
||||
|
||||
// LoadConfigFile reads and validates a persisted configuration. The file is
|
||||
// created by the wizard with 0600 permissions, so no credential is exposed to
|
||||
// other local users.
|
||||
func LoadConfigFile(path string) (*Config, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config file: %w", err)
|
||||
}
|
||||
var file ConfigFile
|
||||
if err := json.Unmarshal(raw, &file); err != nil {
|
||||
return nil, fmt.Errorf("parse config file: %w", err)
|
||||
}
|
||||
return file.Config()
|
||||
}
|
||||
|
||||
// Config turns the file into the daemon configuration. Environment variables
|
||||
// take precedence so operators can still override any value per-process.
|
||||
func (f *ConfigFile) Config() (*Config, error) {
|
||||
config := &Config{}
|
||||
if env := os.Getenv("COORDINATOR_URL"); env != "" {
|
||||
config.CoordinatorURL = env
|
||||
} else {
|
||||
config.CoordinatorURL = strings.TrimSpace(f.CoordinatorURL)
|
||||
}
|
||||
if config.CoordinatorURL == "" {
|
||||
return nil, fmt.Errorf("coordinator_url is required")
|
||||
}
|
||||
if !strings.HasPrefix(config.CoordinatorURL, "http://") && !strings.HasPrefix(config.CoordinatorURL, "https://") {
|
||||
return nil, fmt.Errorf("coordinator_url must be an absolute HTTP(S) URL")
|
||||
}
|
||||
if env := os.Getenv("WORKER_AUTH_TOKEN"); env != "" {
|
||||
config.Token = env
|
||||
} else {
|
||||
config.Token = f.Token
|
||||
}
|
||||
if env := os.Getenv("WORKER_KEY"); env != "" {
|
||||
config.WorkerKey = env
|
||||
} else {
|
||||
config.WorkerKey = f.WorkerKey
|
||||
}
|
||||
if env := os.Getenv("USERSERVICE_URL"); env != "" {
|
||||
config.UserserviceURL = env
|
||||
} else {
|
||||
config.UserserviceURL = f.UserserviceURL
|
||||
}
|
||||
if env := os.Getenv("WORK_DIR"); env != "" {
|
||||
config.WorkDir = env
|
||||
} else if f.WorkDir != "" {
|
||||
config.WorkDir = f.WorkDir
|
||||
} else {
|
||||
config.WorkDir = "./scimesh-agent-data"
|
||||
}
|
||||
if env := os.Getenv("WORKER_NAME"); env != "" {
|
||||
config.WorkerName = env
|
||||
} else {
|
||||
config.WorkerName = f.WorkerName
|
||||
}
|
||||
config.CPUCount = f.CPUCount
|
||||
if config.CPUCount < 1 {
|
||||
config.CPUCount = 1
|
||||
}
|
||||
config.MemoryMB = f.MemoryMB
|
||||
if config.MemoryMB < 0 {
|
||||
config.MemoryMB = 0
|
||||
}
|
||||
if len(f.TaskRunner) > 0 {
|
||||
config.TaskRunner = f.TaskRunner
|
||||
}
|
||||
if len(config.TaskRunner) == 0 {
|
||||
config.TaskRunner = []string{"python", "-m", "scimesh.worker.task"}
|
||||
}
|
||||
config.PollInterval = 2 * time.Second
|
||||
config.RequestTimeout = 30 * time.Second
|
||||
config.Heartbeat = 15 * time.Second
|
||||
config.Capabilities = DefaultCapabilities()
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// Save writes the configuration file, creating the parent directory and
|
||||
// restricting permissions to the owner.
|
||||
func SaveConfigFile(path string, file ConfigFile) error {
|
||||
payload, err := json.MarshalIndent(file, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload = append(payload, '\n')
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return fmt.Errorf("create config directory: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(path, payload, 0o600); err != nil {
|
||||
return fmt.Errorf("write config file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
// Package setupui serves the local worker setup wizard: a small HTTP server
|
||||
// bound to 127.0.0.1 that writes the worker's config file, runs preflight
|
||||
// checks, and starts/stops the worker as a background process. It is part of
|
||||
// the worker-agent binary so a machine that installs only a worker never needs
|
||||
// a coordinator.
|
||||
package setupui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/agent"
|
||||
)
|
||||
|
||||
//go:embed template.html
|
||||
var templateFS embed.FS
|
||||
|
||||
const (
|
||||
defaultPort = 12700
|
||||
pidFileName = "worker.pid"
|
||||
logFileName = "worker.log"
|
||||
)
|
||||
|
||||
// Supervisor starts and stops the worker process and tracks its pid. It is an
|
||||
// interface so tests can substitute a fake.
|
||||
type Supervisor interface {
|
||||
// Start launches `worker-agent --config <path>` detached, writing output
|
||||
// into the log file. Returns the child pid.
|
||||
Start(configPath, logPath string) (int, error)
|
||||
// Stop terminates the process recorded in the pid file.
|
||||
Stop() error
|
||||
// Pid returns the recorded child pid, or 0 when none is recorded.
|
||||
Pid() int
|
||||
// Alive reports whether the recorded child is still running.
|
||||
Alive() bool
|
||||
}
|
||||
|
||||
// PIDSupervisor is the real Supervisor: it spawns the running binary with
|
||||
// --config and manages its pid file.
|
||||
type PIDSupervisor struct {
|
||||
mu sync.Mutex
|
||||
pidPath string
|
||||
}
|
||||
|
||||
func NewPIDSupervisor(pidPath string) *PIDSupervisor { return &PIDSupervisor{pidPath: pidPath} }
|
||||
|
||||
func (s *PIDSupervisor) Pid() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.readPid()
|
||||
}
|
||||
|
||||
func (s *PIDSupervisor) readPid() int {
|
||||
raw, err := os.ReadFile(s.pidPath)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(string(raw)))
|
||||
if err != nil || pid < 1 {
|
||||
return 0
|
||||
}
|
||||
return pid
|
||||
}
|
||||
|
||||
func (s *PIDSupervisor) Alive() bool {
|
||||
pid := s.Pid()
|
||||
if pid == 0 {
|
||||
return false
|
||||
}
|
||||
// Signal 0 probes liveness without sending anything.
|
||||
return syscall.Kill(pid, 0) == nil
|
||||
}
|
||||
|
||||
func (s *PIDSupervisor) processAlive(pid int) bool {
|
||||
return syscall.Kill(pid, 0) == nil
|
||||
}
|
||||
|
||||
func (s *PIDSupervisor) Start(configPath, logPath string) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if pid := s.readPid(); pid > 0 {
|
||||
if s.processAlive(pid) {
|
||||
return pid, fmt.Errorf("worker is already running (pid %d)", pid)
|
||||
}
|
||||
}
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("resolve worker binary: %w", err)
|
||||
}
|
||||
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("open worker log: %w", err)
|
||||
}
|
||||
defer func() { _ = logFile.Close() }()
|
||||
cmd := exec.Command(exe, "--config", configPath)
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
cmd.Stdin = nil
|
||||
if err := cmd.Start(); err != nil {
|
||||
return 0, fmt.Errorf("start worker: %w", err)
|
||||
}
|
||||
// The child inherits our stdout/stderr descriptors pointing at the log
|
||||
// file, so we can close our copy; the child keeps it open.
|
||||
_ = logFile.Close()
|
||||
if err := os.WriteFile(s.pidPath, []byte(strconv.Itoa(cmd.Process.Pid)+"\n"), 0o600); err != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
return 0, fmt.Errorf("write pid file: %w", err)
|
||||
}
|
||||
return cmd.Process.Pid, nil
|
||||
}
|
||||
|
||||
func (s *PIDSupervisor) Stop() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
pid := s.readPid()
|
||||
if pid == 0 {
|
||||
return nil
|
||||
}
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
_ = os.Remove(s.pidPath)
|
||||
return nil
|
||||
}
|
||||
if err := proc.Signal(os.Interrupt); err != nil {
|
||||
_ = os.Remove(s.pidPath)
|
||||
return nil
|
||||
}
|
||||
// Give the agent a moment to exit cleanly, then reap.
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if !s.processAlive(pid) {
|
||||
break
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
_ = os.Remove(s.pidPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Server is the wizard HTTP server, bound to the loopback interface only.
|
||||
type Server struct {
|
||||
log *slog.Logger
|
||||
cfgPath string
|
||||
logPath string
|
||||
dir string
|
||||
sup Supervisor
|
||||
openBrowser func(string)
|
||||
port int
|
||||
}
|
||||
|
||||
// Options customises the wizard for tests and embedding.
|
||||
type Options struct {
|
||||
Port int
|
||||
ConfigPath string
|
||||
OpenBrowser func(url string)
|
||||
Supervisor Supervisor
|
||||
Dir string // directory for pid/log files; defaults to the config dir
|
||||
}
|
||||
|
||||
func New(log *slog.Logger, opts Options) *Server {
|
||||
cfgPath := opts.ConfigPath
|
||||
if cfgPath == "" {
|
||||
cfgPath = agent.DefaultConfigPath()
|
||||
}
|
||||
dir := opts.Dir
|
||||
if dir == "" {
|
||||
dir = filepath.Dir(cfgPath)
|
||||
}
|
||||
sup := opts.Supervisor
|
||||
if sup == nil {
|
||||
sup = NewPIDSupervisor(filepath.Join(dir, pidFileName))
|
||||
}
|
||||
open := opts.OpenBrowser
|
||||
if open == nil {
|
||||
open = func(string) {}
|
||||
}
|
||||
port := opts.Port
|
||||
if port == 0 {
|
||||
port = defaultPort
|
||||
}
|
||||
return &Server{log: log, cfgPath: cfgPath, logPath: filepath.Join(dir, logFileName), dir: dir, sup: sup, openBrowser: open, port: port}
|
||||
}
|
||||
|
||||
// Listen binds the loopback listener and returns it; Serve runs the server on
|
||||
// it. Split so tests can inspect the actual ephemeral port.
|
||||
func (s *Server) Listen() (net.Listener, error) {
|
||||
return net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", s.port))
|
||||
}
|
||||
|
||||
// OpenBrowser hands the wizard URL to the configured opener (default: no-op).
|
||||
func (s *Server) OpenBrowser(url string) { s.openBrowser(url) }
|
||||
|
||||
// Serve runs the wizard until ctx is cancelled.
|
||||
func (s *Server) Serve(ctx context.Context, listener net.Listener) error {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /", s.handleIndex)
|
||||
mux.HandleFunc("GET /api/status", s.handleStatus)
|
||||
mux.HandleFunc("POST /api/config", s.handleSaveConfig)
|
||||
mux.HandleFunc("POST /api/test", s.handleTest)
|
||||
mux.HandleFunc("POST /api/start", s.handleStart)
|
||||
mux.HandleFunc("POST /api/stop", s.handleStop)
|
||||
mux.HandleFunc("GET /api/logs", s.handleLogs)
|
||||
server := &http.Server{
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 3*time.Second)
|
||||
defer cancel()
|
||||
_ = server.Shutdown(shutdownCtx)
|
||||
}()
|
||||
return server.Serve(listener)
|
||||
}
|
||||
|
||||
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
html, err := templateFS.ReadFile("template.html")
|
||||
if err != nil {
|
||||
http.Error(w, "template unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write(html)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// statusView is what the wizard needs to paint the running/stopped state.
|
||||
type statusView struct {
|
||||
ConfigPresent bool `json:"config_present"`
|
||||
ConfigPath string `json:"config_path"`
|
||||
LogPath string `json:"log_path"`
|
||||
Running bool `json:"running"`
|
||||
Pid int `json:"pid"`
|
||||
WorkerName string `json:"worker_name,omitempty"`
|
||||
Coordinator string `json:"coordinator,omitempty"`
|
||||
WorkDir string `json:"work_dir,omitempty"`
|
||||
TokenSet bool `json:"token_set"`
|
||||
}
|
||||
|
||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
view := statusView{ConfigPath: s.cfgPath, LogPath: s.logPath, Running: s.sup.Alive(), Pid: s.sup.Pid()}
|
||||
if raw, err := os.ReadFile(s.cfgPath); err == nil {
|
||||
var file agent.ConfigFile
|
||||
if json.Unmarshal(raw, &file) == nil {
|
||||
view.ConfigPresent = true
|
||||
view.WorkerName = file.WorkerName
|
||||
view.Coordinator = file.CoordinatorURL
|
||||
view.WorkDir = file.WorkDir
|
||||
view.TokenSet = file.Token != "" || file.WorkerKey != ""
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
type saveConfigRequest struct {
|
||||
CoordinatorURL string `json:"coordinator_url"`
|
||||
Token string `json:"token"`
|
||||
WorkerKey string `json:"worker_key"`
|
||||
UserserviceURL string `json:"userservice_url"`
|
||||
WorkDir string `json:"work_dir"`
|
||||
WorkerName string `json:"worker_name"`
|
||||
CPUCount int `json:"cpu_count"`
|
||||
MemoryMB int `json:"memory_mb"`
|
||||
TaskRunner []string `json:"task_runner"`
|
||||
}
|
||||
|
||||
func (s *Server) handleSaveConfig(w http.ResponseWriter, r *http.Request) {
|
||||
var req saveConfigRequest
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
file := agent.ConfigFile{
|
||||
CoordinatorURL: strings.TrimSpace(req.CoordinatorURL),
|
||||
Token: req.Token,
|
||||
WorkerKey: req.WorkerKey,
|
||||
UserserviceURL: strings.TrimSpace(req.UserserviceURL),
|
||||
WorkDir: strings.TrimSpace(req.WorkDir),
|
||||
WorkerName: strings.TrimSpace(req.WorkerName),
|
||||
CPUCount: req.CPUCount,
|
||||
MemoryMB: req.MemoryMB,
|
||||
TaskRunner: req.TaskRunner,
|
||||
}
|
||||
if file.CoordinatorURL == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "coordinator_url is required"})
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(file.CoordinatorURL, "http://") && !strings.HasPrefix(file.CoordinatorURL, "https://") {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "coordinator_url must be an absolute HTTP(S) URL"})
|
||||
return
|
||||
}
|
||||
if file.WorkDir == "" {
|
||||
file.WorkDir = "./scimesh-agent-data"
|
||||
}
|
||||
if file.WorkerName == "" {
|
||||
if host, err := os.Hostname(); err == nil {
|
||||
file.WorkerName = host
|
||||
} else {
|
||||
file.WorkerName = "worker"
|
||||
}
|
||||
}
|
||||
if file.CPUCount < 1 {
|
||||
file.CPUCount = 1
|
||||
}
|
||||
if err := agent.SaveConfigFile(s.cfgPath, file); err != nil {
|
||||
s.log.Error("save wizard config", "err", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not write the config file"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"saved": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleTest(w http.ResponseWriter, r *http.Request) {
|
||||
var req saveConfigRequest
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
url := strings.TrimSpace(req.CoordinatorURL)
|
||||
if url == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "coordinator_url is required"})
|
||||
return
|
||||
}
|
||||
report := agent.RunCheck(r.Context(), url)
|
||||
writeJSON(w, http.StatusOK, report)
|
||||
}
|
||||
|
||||
func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) {
|
||||
if _, err := os.Stat(s.cfgPath); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "no configuration saved yet"})
|
||||
return
|
||||
}
|
||||
pid, err := s.sup.Start(s.cfgPath, s.logPath)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]int{"pid": pid})
|
||||
}
|
||||
|
||||
func (s *Server) handleStop(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.sup.Stop(); err != nil {
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"stopped": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) {
|
||||
raw, err := os.ReadFile(s.logPath)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"log": ""})
|
||||
return
|
||||
}
|
||||
lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n")
|
||||
tail := 200
|
||||
if n, err := strconv.Atoi(r.URL.Query().Get("tail")); err == nil && n > 0 && n < 5000 {
|
||||
tail = n
|
||||
}
|
||||
if len(lines) > tail {
|
||||
lines = lines[len(lines)-tail:]
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"log": strings.Join(lines, "\n")})
|
||||
}
|
||||
|
||||
// ErrCanceled mirrors context.Canceled for callers that treat a cancelled
|
||||
// wizard as a clean exit.
|
||||
var ErrCanceled = errors.New("setup wizard cancelled")
|
||||
@@ -0,0 +1,272 @@
|
||||
package setupui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/agent"
|
||||
)
|
||||
|
||||
func testLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T, sup Supervisor) (*Server, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
server := New(testLogger(), Options{
|
||||
ConfigPath: filepath.Join(dir, "config.json"),
|
||||
Dir: dir,
|
||||
Supervisor: sup,
|
||||
OpenBrowser: func(string) {},
|
||||
})
|
||||
listener, err := server.Listen()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = listener.Close() })
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
go func() { _ = server.Serve(ctx, listener) }()
|
||||
return server, "http://" + listener.Addr().String()
|
||||
}
|
||||
|
||||
type fakeSup struct {
|
||||
mu sync.Mutex
|
||||
started bool
|
||||
stopped bool
|
||||
pid int
|
||||
alive bool
|
||||
}
|
||||
|
||||
func (f *fakeSup) Start(configPath, logPath string) (int, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.started = true
|
||||
f.alive = true
|
||||
f.pid = 4242
|
||||
return f.pid, nil
|
||||
}
|
||||
|
||||
func (f *fakeSup) Stop() error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.stopped = true
|
||||
f.alive = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeSup) Pid() int { f.mu.Lock(); defer f.mu.Unlock(); return f.pid }
|
||||
func (f *fakeSup) Alive() bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.alive
|
||||
}
|
||||
|
||||
func postJSON(t *testing.T, base, path string, body any) (*httptest.ResponseRecorder, map[string]any) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodPost, base+path, strings.NewReader(mustJSON(t, body)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
client := http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
rec := httptest.NewRecorder()
|
||||
rec.Code = resp.StatusCode
|
||||
data := map[string]any{}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&data)
|
||||
return rec, data
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, v any) string {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func TestWizardSavesConfigWithStrictPermissions(t *testing.T) {
|
||||
sup := &fakeSup{}
|
||||
server, base := newTestServer(t, sup)
|
||||
|
||||
rec, _ := postJSON(t, base, "/api/config", map[string]any{
|
||||
"coordinator_url": "http://192.168.1.10:8080",
|
||||
"token": "sm_live_secret",
|
||||
"work_dir": "/home/emil/scimesh-worker",
|
||||
"worker_name": "emil-laptop",
|
||||
"cpu_count": 8,
|
||||
"memory_mb": 16384,
|
||||
})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("save config: got %d, want 200", rec.Code)
|
||||
}
|
||||
info, err := os.Stat(server.cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0o600 {
|
||||
t.Errorf("config perms = %o, want 600", perm)
|
||||
}
|
||||
config, err := agent.LoadConfigFile(server.cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.CoordinatorURL != "http://192.168.1.10:8080" || config.Token != "sm_live_secret" || config.WorkDir != "/home/emil/scimesh-worker" || config.WorkerName != "emil-laptop" {
|
||||
t.Errorf("config = %+v", config)
|
||||
}
|
||||
if config.CPUCount != 8 || config.MemoryMB != 16384 {
|
||||
t.Errorf("resources: cpu=%d mem=%d", config.CPUCount, config.MemoryMB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWizardRejectsInvalidConfig(t *testing.T) {
|
||||
sup := &fakeSup{}
|
||||
_, base := newTestServer(t, sup)
|
||||
|
||||
for _, body := range []map[string]any{
|
||||
{"coordinator_url": "", "token": "x"},
|
||||
{"coordinator_url": "not-a-url", "token": "x"},
|
||||
} {
|
||||
rec, _ := postJSON(t, base, "/api/config", body)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("body %v: got %d, want 400", body, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWizardStartStopLifecycle(t *testing.T) {
|
||||
sup := &fakeSup{}
|
||||
_, base := newTestServer(t, sup)
|
||||
|
||||
// Starting without a saved config is rejected.
|
||||
rec, _ := postJSON(t, base, "/api/start", map[string]any{})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("start without config: got %d, want 400", rec.Code)
|
||||
}
|
||||
|
||||
postJSON(t, base, "/api/config", map[string]any{
|
||||
"coordinator_url": "http://127.0.0.1:8080", "token": "t", "work_dir": ".",
|
||||
})
|
||||
rec, data := postJSON(t, base, "/api/start", map[string]any{})
|
||||
if rec.Code != http.StatusOK || int(data["pid"].(float64)) != 4242 {
|
||||
t.Errorf("start: got %d %v, want 200 pid 4242", rec.Code, data)
|
||||
}
|
||||
if !sup.started {
|
||||
t.Error("supervisor never started the worker")
|
||||
}
|
||||
|
||||
// Status reflects the running state.
|
||||
req, _ := http.NewRequest(http.MethodGet, base+"/api/status", nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
var status map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&status)
|
||||
if status["running"] != true || status["pid"] != float64(4242) {
|
||||
t.Errorf("status = %v, want running pid 4242", status)
|
||||
}
|
||||
|
||||
rec, _ = postJSON(t, base, "/api/stop", map[string]any{})
|
||||
if rec.Code != http.StatusOK || !sup.stopped {
|
||||
t.Errorf("stop: got %d stopped=%v, want 200/true", rec.Code, sup.stopped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWizardStatusPrefillsSavedConfig(t *testing.T) {
|
||||
sup := &fakeSup{}
|
||||
_, base := newTestServer(t, sup)
|
||||
postJSON(t, base, "/api/config", map[string]any{
|
||||
"coordinator_url": "http://10.0.0.5:8080", "worker_key": "smk_abc", "work_dir": "/w", "worker_name": "n1",
|
||||
})
|
||||
req, _ := http.NewRequest(http.MethodGet, base+"/api/status", nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
var status struct {
|
||||
ConfigPresent bool `json:"config_present"`
|
||||
WorkerName string `json:"worker_name"`
|
||||
Coordinator string `json:"coordinator"`
|
||||
TokenSet bool `json:"token_set"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&status)
|
||||
if !status.ConfigPresent || status.WorkerName != "n1" || status.Coordinator != "http://10.0.0.5:8080" || !status.TokenSet {
|
||||
t.Errorf("status = %+v", status)
|
||||
}
|
||||
// The secret must never appear in the status projection.
|
||||
if strings.Contains(strings.ToLower(mustJSON(t, status)), "smk_abc") {
|
||||
t.Error("status leaks the worker key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckCoordinatorReachable(t *testing.T) {
|
||||
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/health" {
|
||||
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer stub.Close()
|
||||
|
||||
report := agent.CheckCoordinator(context.Background(), stub.URL, 5*time.Second)
|
||||
if !report.Coordinator.OK {
|
||||
t.Errorf("coordinator check = %+v, want ok", report.Coordinator)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckCoordinatorUnreachable(t *testing.T) {
|
||||
report := agent.CheckCoordinator(context.Background(), "http://127.0.0.1:1", 2*time.Second)
|
||||
if report.Coordinator.OK {
|
||||
t.Error("unreachable coordinator reported ok")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigFileDefaultsAndEnvOverride(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.json")
|
||||
file := agent.ConfigFile{
|
||||
CoordinatorURL: "http://coord:8080",
|
||||
Token: "file-token",
|
||||
WorkDir: "/w",
|
||||
CPUCount: 4,
|
||||
}
|
||||
if err := agent.SaveConfigFile(path, file); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("COORDINATOR_URL", "http://env:9090")
|
||||
t.Setenv("WORKER_AUTH_TOKEN", "")
|
||||
config, err := agent.LoadConfigFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.CoordinatorURL != "http://env:9090" {
|
||||
t.Errorf("env must win: %s", config.CoordinatorURL)
|
||||
}
|
||||
if config.Token != "file-token" {
|
||||
t.Errorf("token = %q, want the file value", config.Token)
|
||||
}
|
||||
if config.CPUCount != 4 {
|
||||
t.Errorf("cpu = %d", config.CPUCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SciMesh Worker · Setup</title>
|
||||
<style>
|
||||
:root{--bg:#0b0e13;--panel:#11151d;--panel-2:#161c27;--border:#1f2634;--border-soft:#181f2b;--text:#e9ecf3;--text-2:#98a2b5;--text-3:#5d6879;--accent:#5b8cff;--accent-soft:#5b8cff1f;--accent-strong:#3f6fe0;--green:#3fce8a;--green-soft:#3fce8a1a;--amber:#e5b64f;--amber-soft:#e5b64f1a;--red:#f2647c;--red-soft:#f2647c1a;--mono:ui-monospace,"SF Mono",SFMono-Regular,Menlo,monospace;color-scheme:dark}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:radial-gradient(900px 500px at 50% -180px,#16233d66,transparent),var(--bg);color:var(--text);font:14px/1.55 Inter,-apple-system,"Segoe UI",Roboto,sans-serif;-webkit-font-smoothing:antialiased;min-height:100vh}
|
||||
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
|
||||
input{font:inherit;color:var(--text);background:var(--panel-2);border:1px solid var(--border);border-radius:9px;padding:10px 13px;width:100%;outline:none;transition:border-color .12s,box-shadow .12s}
|
||||
input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
|
||||
input::placeholder{color:var(--text-3)}
|
||||
code{font-family:var(--mono);font-size:.86em}
|
||||
.shell{max-width:660px;margin:0 auto;padding:44px 22px 70px}
|
||||
.brand{display:flex;align-items:center;justify-content:center;gap:11px;margin-bottom:8px}
|
||||
.brand-mark{display:grid;place-items:center;width:34px;height:34px;border-radius:10px;background:linear-gradient(135deg,#5b8cff,#7c5cff);box-shadow:0 4px 16px #5b8cff40}
|
||||
.brand-mark svg{width:18px;height:18px;stroke:#fff}
|
||||
.brand-name{font-weight:700;font-size:16px;letter-spacing:-.01em}
|
||||
.brand-name span{color:var(--text-3);font-weight:500}
|
||||
.tagline{text-align:center;color:var(--text-3);font-size:12.5px;margin-bottom:34px}
|
||||
.tagline code{color:var(--text-2)}
|
||||
.steps{display:flex;align-items:center;justify-content:center;gap:0;margin-bottom:30px}
|
||||
.step{display:flex;flex-direction:column;align-items:center;gap:7px;width:96px}
|
||||
.step-dot{display:grid;place-items:center;width:30px;height:30px;border-radius:50%;border:1.5px solid var(--border);background:var(--panel);color:var(--text-3);font-size:12.5px;font-weight:700;transition:all .2s}
|
||||
.step-label{font-size:11px;font-weight:600;color:var(--text-3);letter-spacing:.02em}
|
||||
.step.active .step-dot{border-color:var(--accent);background:var(--accent-soft);color:var(--accent);box-shadow:0 0 0 4px #5b8cff14}
|
||||
.step.active .step-label{color:var(--text)}
|
||||
.step.done .step-dot{border-color:var(--green);background:var(--green-soft);color:var(--green)}
|
||||
.step.done .step-label{color:var(--text-2)}
|
||||
.step-line{flex:1;max-width:44px;height:1.5px;background:var(--border);margin:0 6px 22px;position:relative;overflow:hidden}
|
||||
.step-line.done:after{content:"";position:absolute;inset:0;background:var(--green)}
|
||||
.card{background:var(--panel);border:1px solid var(--border-soft);border-radius:15px;padding:26px 28px;box-shadow:0 24px 60px #0000004d}
|
||||
.card h1{font-size:18px;font-weight:700;letter-spacing:-.02em;margin-bottom:4px}
|
||||
.card .sub{color:var(--text-2);font-size:13px;margin-bottom:22px}
|
||||
.field{margin-bottom:16px}
|
||||
.field label{display:block;font-size:12px;font-weight:650;letter-spacing:.04em;text-transform:uppercase;color:var(--text-3);margin-bottom:7px}
|
||||
.field .hint{margin-top:6px;font-size:12px;color:var(--text-3)}
|
||||
.field .hint code{color:var(--text-2)}
|
||||
.radio-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
||||
.radio-card{border:1px solid var(--border);border-radius:11px;padding:13px 14px;cursor:pointer;transition:all .13s;background:var(--panel-2)}
|
||||
.radio-card:hover{border-color:#2a3446}
|
||||
.radio-card.sel{border-color:var(--accent);background:var(--accent-soft);box-shadow:0 0 0 3px #5b8cff14}
|
||||
.radio-card b{display:flex;align-items:center;gap:8px;font-size:13.5px}
|
||||
.radio-card b svg{width:15px;height:15px;stroke:var(--accent)}
|
||||
.radio-card p{margin-top:4px;font-size:12px;color:var(--text-2)}
|
||||
.check-row{display:flex;align-items:center;gap:12px;padding:11px 14px;border:1px solid var(--border-soft);border-radius:10px;margin-bottom:9px;background:var(--panel-2)}
|
||||
.check-ic{display:grid;place-items:center;width:24px;height:24px;border-radius:50%;flex:none}
|
||||
.check-ic svg{width:13px;height:13px;stroke-width:2.6}
|
||||
.check-ok{background:var(--green-soft)}.check-ok svg{stroke:var(--green)}
|
||||
.check-bad{background:var(--red-soft)}.check-bad svg{stroke:var(--red)}
|
||||
.check-wait{background:#ffffff10}.check-wait svg{stroke:var(--text-3)}
|
||||
.check-row b{font-size:13.5px;font-weight:600}
|
||||
.check-row span{display:block;font-size:12px;color:var(--text-3)}
|
||||
.check-row .ms{margin-left:auto;font:11.5px var(--mono);color:var(--text-3)}
|
||||
.actions{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:24px}
|
||||
.btn{display:inline-flex;align-items:center;gap:8px;border-radius:9px;padding:10px 18px;font-weight:650;font-size:13.5px;border:1px solid transparent;transition:all .13s}
|
||||
.btn svg{width:15px;height:15px;stroke:currentColor}
|
||||
.btn-primary{background:var(--accent);color:#0a1222}
|
||||
.btn-primary:hover{background:var(--accent-strong);color:#fff}
|
||||
.btn-ghost{border-color:var(--border);color:var(--text-2);background:var(--panel-2)}
|
||||
.btn-ghost:hover{color:var(--text);border-color:#2a3446}
|
||||
.btn-danger{background:var(--red-soft);color:var(--red)}
|
||||
.btn-lg{padding:12px 24px;font-size:14.5px;border-radius:10px}
|
||||
.link{color:var(--text-3);font-size:13px}
|
||||
.link:hover{color:var(--text)}
|
||||
.status-head{display:flex;align-items:center;gap:14px;margin-bottom:22px}
|
||||
.pulse{position:relative;width:12px;height:12px;border-radius:50%;background:var(--green);flex:none}
|
||||
.pulse:after{content:"";position:absolute;inset:-5px;border-radius:50%;border:2px solid var(--green);opacity:.5;animation:ping 1.6s ease-out infinite}
|
||||
@keyframes ping{from{transform:scale(.6);opacity:.7}to{transform:scale(1.4);opacity:0}}
|
||||
.status-head h1{font-size:19px}
|
||||
.status-head .sub{font-size:12.5px;color:var(--text-3)}
|
||||
.stat-row{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:18px}
|
||||
.stat{background:var(--panel-2);border:1px solid var(--border-soft);border-radius:11px;padding:12px 14px}
|
||||
.stat b{display:block;font-size:20px;font-weight:700;letter-spacing:-.02em}
|
||||
.stat span{font-size:11px;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:var(--text-3)}
|
||||
.stat.bad b{color:var(--red)}
|
||||
.logbox{background:#0a0d12;border:1px solid var(--border-soft);border-radius:11px;padding:14px 16px;font:12px/1.7 var(--mono);color:#8fa3bf;max-height:210px;overflow-y:auto;white-space:pre-wrap;word-break:break-word}
|
||||
.meta-line{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}
|
||||
.chip{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--border);border-radius:99px;padding:4px 11px;font-size:12px;color:var(--text-2);background:var(--panel-2)}
|
||||
.chip svg{width:12px;height:12px;stroke:var(--text-3)}
|
||||
.wizard-page{display:none}.wizard-page.active{display:block;animation:fade .18s ease}
|
||||
@keyframes fade{from{opacity:0;transform:translateY(5px)}to{opacity:1}}
|
||||
.error-strip{background:var(--red-soft);border:1px solid #f2647c33;border-radius:9px;padding:9px 12px;font-size:12.5px;color:#ffb3c0;margin-bottom:14px}
|
||||
.spinner{display:inline-block;width:13px;height:13px;border:2px solid var(--text-3);border-top-color:transparent;border-radius:50%;animation:spin .7s linear infinite;vertical-align:-2px;margin-right:7px}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
.checks{padding-bottom:6px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<div class="brand">
|
||||
<div class="brand-mark"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round"><circle cx="6" cy="6" r="2.4"/><circle cx="18" cy="7" r="2.4"/><circle cx="12" cy="17" r="2.4"/><path d="M8 7.5l7.6-1M7 8.3l3.7 6.5M16.6 9.2l-3.2 5.6"/></svg></div>
|
||||
<div class="brand-name">SciMesh <span>· Worker setup</span></div>
|
||||
</div>
|
||||
<p class="tagline">Local wizard served by <code>worker-agent setup</code> · <code>127.0.0.1</code></p>
|
||||
|
||||
<!-- ═══ WIZARD VIEW ═══ -->
|
||||
<div id="view-wizard">
|
||||
<div class="steps" id="steps">
|
||||
<div class="step active" id="st1"><div class="step-dot">1</div><div class="step-label">Connect</div></div>
|
||||
<div class="step-line" id="sl1"></div>
|
||||
<div class="step" id="st2"><div class="step-dot">2</div><div class="step-label">Machine</div></div>
|
||||
<div class="step-line" id="sl2"></div>
|
||||
<div class="step" id="st3"><div class="step-dot">3</div><div class="step-label">Check</div></div>
|
||||
<div class="step-line" id="sl3"></div>
|
||||
<div class="step" id="st4"><div class="step-dot">4</div><div class="step-label">Run</div></div>
|
||||
</div>
|
||||
<div id="error-box"></div>
|
||||
|
||||
<!-- step 1: connect -->
|
||||
<div class="wizard-page active" id="wp1">
|
||||
<div class="card">
|
||||
<h1>Connect to a coordinator</h1>
|
||||
<p class="sub">The coordinator hands out work and collects results. Ask your cluster admin for its address.</p>
|
||||
<div class="field"><label>Coordinator URL</label><input id="in-url" placeholder="http://192.168.1.10:8080"><p class="hint">For a served instance this is the address printed by <code>coordinator serve</code>.</p></div>
|
||||
<div class="field"><label>Authentication</label>
|
||||
<div class="radio-grid" id="auth-grid">
|
||||
<div class="radio-card sel" data-mode="token"><b><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>Cluster token</b><p>Serve instances: one token for every worker.</p></div>
|
||||
<div class="radio-card" data-mode="key"><b><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><circle cx="8" cy="14" r="4"/><path d="M10.8 11.2L20 2M15 4l3 3"/></svg>Worker key</b><p>Shared clusters: a key tied to your account.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field" id="token-field"><label>Token</label><input id="in-token" type="password" placeholder="paste the token from coordinator token"><p class="hint">The admin can copy it from <code>coordinator token</code> on the server.</p></div>
|
||||
<div class="field" id="key-field" style="display:none"><label>Worker key + userservice URL</label><input id="in-key" type="password" placeholder="smk_…"><input id="in-users" placeholder="http://userservice-host:8081" style="margin-top:8px"><p class="hint">Create a key in the coordinator UI: Users → worker keys.</p></div>
|
||||
<div class="actions"><span class="link" id="l1">Step 1 of 4</span><button class="btn btn-primary" id="b1">Continue →</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- step 2: machine -->
|
||||
<div class="wizard-page" id="wp2">
|
||||
<div class="card">
|
||||
<h1>This machine</h1>
|
||||
<p class="sub">Where tasks run and how the machine appears in the cluster.</p>
|
||||
<div class="field"><label>Worker name</label><input id="in-name" placeholder="auto-detected"><p class="hint">Shown in the coordinator’s worker list.</p></div>
|
||||
<div class="field"><label>Work directory</label><input id="in-dir" placeholder="./scimesh-agent-data"><p class="hint">Datasets and shard results live here. ~1 GB free space recommended.</p></div>
|
||||
<div class="field"><label>Compute resources advertised</label>
|
||||
<div class="radio-grid">
|
||||
<div class="radio-card sel" data-cpu="auto"><b>Auto</b><p>Detect the machine’s CPU count.</p></div>
|
||||
<div class="radio-card" data-cpu="custom"><b>Custom…</b><p>Limit what this machine advertises.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field" id="cpu-field" style="display:none"><label>CPU count</label><input id="in-cpu" type="number" min="1" value="1"></div>
|
||||
<div class="actions"><button class="btn btn-ghost" id="b2b">← Back</button><button class="btn btn-primary" id="b2">Continue →</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- step 3: preflight -->
|
||||
<div class="wizard-page" id="wp3">
|
||||
<div class="card">
|
||||
<h1>Preflight check</h1>
|
||||
<p class="sub">Making sure this machine can reach the coordinator and run SciMesh workloads.</p>
|
||||
<div class="checks" id="checks"></div>
|
||||
<div class="actions"><button class="btn btn-ghost" id="b3b">← Back</button><button class="btn btn-primary" id="b3" disabled>Continue anyway →</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- step 4: run -->
|
||||
<div class="wizard-page" id="wp4">
|
||||
<div class="card" style="text-align:center;padding:40px 28px">
|
||||
<div class="brand-mark" style="margin:0 auto 18px;width:46px;height:46px;border-radius:13px"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" style="width:22px;height:22px"><path d="M6 4l14 8-14 8V4z"/></svg></div>
|
||||
<h1 style="font-size:20px">Ready to join the cluster</h1>
|
||||
<p class="sub" style="max-width:380px;margin:8px auto 26px">Configuration will be saved and the worker started as a background process.</p>
|
||||
<div class="actions" style="justify-content:space-between;margin-top:30px"><button class="btn btn-ghost" id="b4b">← Back</button><button class="btn btn-primary btn-lg" id="b4">Start worker</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ STATUS VIEW ═══ -->
|
||||
<div id="view-status" style="display:none">
|
||||
<div class="card">
|
||||
<div class="status-head">
|
||||
<div class="pulse" id="st-pulse"></div>
|
||||
<div><h1 id="st-title">Worker is working</h1><div class="sub" id="st-sub">—</div></div>
|
||||
<button class="btn btn-danger" id="st-stop" style="margin-left:auto"><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>Stop</button>
|
||||
</div>
|
||||
<div class="meta-line" id="st-meta"></div>
|
||||
<div class="logbox" id="st-log"></div>
|
||||
<div class="actions"><span class="link" id="st-cfg">—</span><button class="btn btn-ghost" id="st-reconfig">Reconfigure…</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $=id=>document.getElementById(id);
|
||||
let state={mode:'token',cpu:'auto'};
|
||||
let checksOk=false;
|
||||
|
||||
function err(msg){$('error-box').innerHTML=msg?'<div class="error-strip">'+msg+'</div>':''}
|
||||
function goto(n){
|
||||
['wp1','wp2','wp3','wp4'].forEach((id,i)=>$(id).classList.toggle('active',i===n-1));
|
||||
for(let i=1;i<=4;i++){
|
||||
const st=$('st'+i);
|
||||
st.classList.toggle('done',i<n);st.classList.toggle('active',i===n);
|
||||
st.querySelector('.step-dot').textContent=i<n?'✓':i;
|
||||
if(i<4)$('sl'+i).classList.toggle('done',i<n);
|
||||
}
|
||||
err('');
|
||||
}
|
||||
document.querySelectorAll('#auth-grid .radio-card').forEach(c=>c.addEventListener('click',()=>{
|
||||
document.querySelectorAll('#auth-grid .radio-card').forEach(x=>x.classList.remove('sel'));
|
||||
c.classList.add('sel');state.mode=c.dataset.mode;
|
||||
$('token-field').style.display=state.mode==='token'?'':'none';
|
||||
$('key-field').style.display=state.mode==='key'?'':'none';
|
||||
}));
|
||||
document.querySelectorAll('#wp2 .radio-card').forEach(c=>c.addEventListener('click',()=>{
|
||||
c.parentElement.querySelectorAll('.radio-card').forEach(x=>x.classList.remove('sel'));
|
||||
c.classList.add('sel');state.cpu=c.dataset.cpu;
|
||||
$('cpu-field').style.display=state.cpu==='custom'?'':'none';
|
||||
}));
|
||||
|
||||
async function postJSON(path,body){
|
||||
const r=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
||||
const data=await r.json().catch(()=>({}));
|
||||
return {status:r.status,data};
|
||||
}
|
||||
function draftConfig(){
|
||||
return {
|
||||
coordinator_url:$('in-url').value.trim(),
|
||||
token:state.mode==='token'?$('in-token').value.trim():'',
|
||||
worker_key:state.mode==='key'?$('in-key').value.trim():'',
|
||||
userservice_url:state.mode==='key'?$('in-users').value.trim():'',
|
||||
work_dir:$('in-dir').value.trim(),
|
||||
worker_name:$('in-name').value.trim(),
|
||||
cpu_count:state.cpu==='custom'?parseInt($('in-cpu').value||'1',10):0
|
||||
};
|
||||
}
|
||||
|
||||
$('b1').onclick=()=>{
|
||||
const c=draftConfig();
|
||||
if(!c.coordinator_url){err('Enter the coordinator URL.');return}
|
||||
if(state.mode==='token'&&!c.token){err('Enter the cluster token.');return}
|
||||
if(state.mode==='key'&&!c.worker_key){err('Enter the worker key.');return}
|
||||
goto(2);
|
||||
};
|
||||
$('b2b').onclick=()=>goto(1);
|
||||
$('b2').onclick=()=>{goto(3);runChecks()};
|
||||
$('b3b').onclick=()=>goto(2);
|
||||
$('b3').onclick=()=>goto(4);
|
||||
$('b4b').onclick=()=>goto(3);
|
||||
$('b4').onclick=async()=>{
|
||||
const c=draftConfig();
|
||||
const r=await postJSON('/api/config',c);
|
||||
if(r.status!==200){err('Could not save the configuration: '+(r.data.error||'unknown error'));return}
|
||||
const s=await postJSON('/api/start',{});
|
||||
if(s.status!==200){err('Could not start the worker: '+(s.data.error||'unknown error'));return}
|
||||
showStatus();
|
||||
};
|
||||
|
||||
const checkRow=(name,ok,detail,ms)=>
|
||||
'<div class="check-row"><div class="check-ic '+(ok===null?'check-wait':ok?'check-ok':'check-bad')+'"><svg viewBox="0 0 24 24" fill="none" stroke-linecap="round">'+(ok===null?'<path d="M12 7v5l3 3"/>':ok?'<path d="M4 12l5 5L20 6"/>':'<path d="M6 6l12 12M18 6L6 18"/>')+'</svg></div><div><b>'+name+'</b><span>'+(detail||'')+'</span></div>'+(ms?'<span class="ms">'+ms+' ms</span>':'')+'</div>';
|
||||
async function runChecks(){
|
||||
const box=$('checks');
|
||||
box.innerHTML=checkRow('Coordinator reachable','',null,null)+checkRow('Python 3','',null,null)+checkRow('scimesh package','',null,null);
|
||||
const r=await postJSON('/api/test',draftConfig());
|
||||
box.innerHTML='';
|
||||
checksOk=true;
|
||||
const items=[r.data.coordinator,r.data.python,r.data.scimesh];
|
||||
for(const item of items){
|
||||
if(item&&!item.ok)checksOk=false;
|
||||
box.insertAdjacentHTML('beforeend',checkRow(item?item.name:'?',item?item.ok:null,item?item.detail:'',item?item.latency_ms:null));
|
||||
}
|
||||
$('b3').disabled=!checksOk;
|
||||
}
|
||||
|
||||
async function showStatus(){
|
||||
$('view-wizard').style.display='none';
|
||||
$('view-status').style.display='block';
|
||||
await refreshStatus();
|
||||
setInterval(refreshStatus,2000);
|
||||
}
|
||||
async function refreshStatus(){
|
||||
const r=await fetch('/api/status');
|
||||
if(!r.ok)return;
|
||||
const v=await r.json();
|
||||
$('st-pulse').style.background=v.running?'var(--green)':'var(--text-3)';
|
||||
$('st-pulse').style.animation=v.running?'':'none';
|
||||
$('st-title').textContent=v.running?(v.worker_name||'Worker')+' is working':(v.worker_name||'Worker')+' is stopped';
|
||||
$('st-sub').textContent='pid '+(v.pid||'—')+' · config '+(v.config_present?v.config_path:'not saved yet');
|
||||
$('st-cfg').textContent='Configuration: '+(v.config_present?v.config_path:'—');
|
||||
const meta=$('st-meta');
|
||||
meta.innerHTML='';
|
||||
if(v.coordinator){const c=document.createElement('span');c.className='chip';c.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke-width="2"><circle cx="12" cy="12" r="9"/></svg>'+v.coordinator;meta.append(c)}
|
||||
if(v.work_dir){const d=document.createElement('span');d.className='chip';d.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke-width="2"><path d="M4 6h16M4 12h16M4 18h16"/></svg>'+v.work_dir;meta.append(d)}
|
||||
if(!v.token_set){const t=document.createElement('span');t.className='chip';t.style.color='var(--amber)';t.textContent='no credential set';meta.append(t)}
|
||||
const logs=await fetch('/api/logs?tail=200');
|
||||
const lv=await logs.json();
|
||||
$('st-log').textContent=lv.log||'(no log yet — the worker writes here once started)';
|
||||
}
|
||||
$('st-stop').onclick=async()=>{await postJSON('/api/stop',{});refreshStatus()};
|
||||
$('st-reconfig').onclick=()=>{
|
||||
$('view-status').style.display='none';
|
||||
$('view-wizard').style.display='block';
|
||||
goto(1);
|
||||
};
|
||||
|
||||
// Prefill from a saved configuration, then decide which view to show.
|
||||
(async()=>{
|
||||
const r=await fetch('/api/status');
|
||||
const v=await r.json();
|
||||
if(v.config_present){
|
||||
$('in-url').value=v.coordinator||'';
|
||||
$('in-dir').value=v.work_dir||'';
|
||||
$('in-name').value=v.worker_name||'';
|
||||
}
|
||||
if(v.running)showStatus();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user