Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4d88c7ffc | ||
|
|
44247bd94e | ||
|
|
7c1d0dc568 |
@@ -7,6 +7,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -93,7 +94,7 @@ func loadConfig(configPath string) (*agent.Config, error) {
|
|||||||
}
|
}
|
||||||
envPath := os.Getenv("SCIMESH_WORKER_CONFIG")
|
envPath := os.Getenv("SCIMESH_WORKER_CONFIG")
|
||||||
if envPath != "" {
|
if envPath != "" {
|
||||||
if _, err := os.Stat(envPath); err == nil {
|
if _, err := os.Stat(envPath); err == nil { //nolint:gosec // G703: path is the operator's own env var
|
||||||
return agent.LoadConfigFile(envPath)
|
return agent.LoadConfigFile(envPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,7 +154,7 @@ func runSetup(args []string) int {
|
|||||||
// Block until the signal arrives (never returns an error that matters: a
|
// Block until the signal arrives (never returns an error that matters: a
|
||||||
// cancelled context is the normal exit path).
|
// cancelled context is the normal exit path).
|
||||||
err = server.Serve(ctx, listener)
|
err = server.Serve(ctx, listener)
|
||||||
if err != nil && err != http.ErrServerClosed {
|
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
logger.Error("setup wizard stopped", "err", err)
|
logger.Error("setup wizard stopped", "err", err)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
@@ -172,7 +173,8 @@ func openBrowser(url string) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
_ = exec.Command(binary, candidate[1:]...).Start()
|
//nolint:gosec // G204: candidates are our own fixed list; the url is a loopback literal
|
||||||
|
_ = exec.CommandContext(context.Background(), binary, candidate[1:]...).Start()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ func CheckEnvironment(ctx context.Context) CheckReport {
|
|||||||
return report
|
return report
|
||||||
}
|
}
|
||||||
report.Python = CheckItem{Name: "python", OK: true, Detail: python}
|
report.Python = CheckItem{Name: "python", OK: true, Detail: python}
|
||||||
|
//nolint:gosec // G204: python comes from LookPath, the argument list is constant
|
||||||
cmd := exec.CommandContext(ctx, python, "-c", "import scimesh; print(scimesh.__version__ if hasattr(scimesh, '__version__') else 'installed')")
|
cmd := exec.CommandContext(ctx, python, "-c", "import scimesh; print(scimesh.__version__ if hasattr(scimesh, '__version__') else 'installed')")
|
||||||
out, err := cmd.Output()
|
out, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ func DefaultConfigPath() string {
|
|||||||
// created by the wizard with 0600 permissions, so no credential is exposed to
|
// created by the wizard with 0600 permissions, so no credential is exposed to
|
||||||
// other local users.
|
// other local users.
|
||||||
func LoadConfigFile(path string) (*Config, error) {
|
func LoadConfigFile(path string) (*Config, error) {
|
||||||
|
//nolint:gosec // G304: path is --config or SCIMESH_WORKER_CONFIG, operator-supplied
|
||||||
raw, err := os.ReadFile(path)
|
raw, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read config file: %w", err)
|
return nil, fmt.Errorf("read config file: %w", err)
|
||||||
|
|||||||
@@ -107,12 +107,15 @@ func (s *PIDSupervisor) Start(configPath, logPath string) (int, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("resolve worker binary: %w", err)
|
return 0, fmt.Errorf("resolve worker binary: %w", err)
|
||||||
}
|
}
|
||||||
|
//nolint:gosec // G304: logPath lives in the wizard's own config directory
|
||||||
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("open worker log: %w", err)
|
return 0, fmt.Errorf("open worker log: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = logFile.Close() }()
|
defer func() { _ = logFile.Close() }()
|
||||||
cmd := exec.Command(exe, "--config", configPath)
|
//nolint:gosec // G204: exe is os.Executable, configPath is the wizard's own file;
|
||||||
|
// Background ctx: the child's lifecycle is managed by the supervisor, not the context
|
||||||
|
cmd := exec.CommandContext(context.Background(), exe, "--config", configPath)
|
||||||
cmd.Stdout = logFile
|
cmd.Stdout = logFile
|
||||||
cmd.Stderr = logFile
|
cmd.Stderr = logFile
|
||||||
cmd.Stdin = nil
|
cmd.Stdin = nil
|
||||||
@@ -218,7 +221,7 @@ func New(log *slog.Logger, opts Options) *Server {
|
|||||||
// Listen binds the loopback listener and returns it; Serve runs the server on
|
// Listen binds the loopback listener and returns it; Serve runs the server on
|
||||||
// it. Split so tests can inspect the actual ephemeral port.
|
// it. Split so tests can inspect the actual ephemeral port.
|
||||||
func (s *Server) Listen() (net.Listener, error) {
|
func (s *Server) Listen() (net.Listener, error) {
|
||||||
return net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", s.port))
|
return (&net.ListenConfig{}).Listen(context.Background(), "tcp", fmt.Sprintf("127.0.0.1:%d", s.port))
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenBrowser hands the wizard URL to the configured opener (default: no-op).
|
// OpenBrowser hands the wizard URL to the configured opener (default: no-op).
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@@ -25,7 +26,10 @@ func newTestServer(t *testing.T, sup Supervisor) (*Server, string) {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
server := New(testLogger(), Options{
|
server := New(testLogger(), Options{
|
||||||
Port: 0, // ephemeral: tests must never collide on the default 12700
|
// A distinct random port per test: Port 0 means "the default 12700" in
|
||||||
|
// the server, which would let the shared http.Client pool reuse a stale
|
||||||
|
// keep-alive connection across tests (EOF after a Shutdown).
|
||||||
|
Port: freePort(t),
|
||||||
ConfigPath: filepath.Join(dir, "config.json"),
|
ConfigPath: filepath.Join(dir, "config.json"),
|
||||||
Dir: dir,
|
Dir: dir,
|
||||||
Supervisor: sup,
|
Supervisor: sup,
|
||||||
@@ -76,12 +80,14 @@ func (f *fakeSup) Alive() bool {
|
|||||||
|
|
||||||
func postJSON(t *testing.T, base, path string, body any) (*httptest.ResponseRecorder, map[string]any) {
|
func postJSON(t *testing.T, base, path string, body any) (*httptest.ResponseRecorder, map[string]any) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
req, err := http.NewRequest(http.MethodPost, base+path, strings.NewReader(mustJSON(t, body)))
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, base+path, strings.NewReader(mustJSON(t, body)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
client := http.Client{}
|
// No keep-alive pooling: a pooled connection to a shut-down test server
|
||||||
|
// would surface as an EOF instead of a fresh dial.
|
||||||
|
client := http.Client{Transport: &http.Transport{DisableKeepAlives: true}}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -94,6 +100,20 @@ func postJSON(t *testing.T, base, path string, body any) (*httptest.ResponseReco
|
|||||||
return rec, data
|
return rec, data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// freePort reserves an ephemeral port and returns it. The listener is closed
|
||||||
|
// immediately; the tiny reuse window is acceptable for tests and each test
|
||||||
|
// gets a different port, so nothing can collide or share pooled connections.
|
||||||
|
func freePort(t *testing.T) int {
|
||||||
|
t.Helper()
|
||||||
|
listener, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
port := listener.Addr().(*net.TCPAddr).Port
|
||||||
|
_ = listener.Close()
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
|
||||||
func mustJSON(t *testing.T, v any) string {
|
func mustJSON(t *testing.T, v any) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
raw, err := json.Marshal(v)
|
raw, err := json.Marshal(v)
|
||||||
@@ -174,7 +194,7 @@ func TestWizardStartStopLifecycle(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Status reflects the running state.
|
// Status reflects the running state.
|
||||||
req, _ := http.NewRequest(http.MethodGet, base+"/api/status", nil)
|
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, base+"/api/status", nil)
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -198,7 +218,7 @@ func TestWizardStatusPrefillsSavedConfig(t *testing.T) {
|
|||||||
postJSON(t, base, "/api/config", map[string]any{
|
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",
|
"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)
|
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, base+"/api/status", nil)
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package sqlite
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -83,7 +84,7 @@ func TestWorkerSetTrust(t *testing.T) {
|
|||||||
if err := repo.SetTrust(ctx, worker.ID, domain.WorkerTrusted); err != nil {
|
if err := repo.SetTrust(ctx, worker.ID, domain.WorkerTrusted); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := repo.SetTrust(ctx, uuid.New(), domain.WorkerTrusted); err != domain.ErrWorkerNotFound {
|
if err := repo.SetTrust(ctx, uuid.New(), domain.WorkerTrusted); !errors.Is(err, domain.ErrWorkerNotFound) {
|
||||||
t.Errorf("unknown worker trust err = %v, want ErrWorkerNotFound", err)
|
t.Errorf("unknown worker trust err = %v, want ErrWorkerNotFound", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,12 +26,14 @@ var adminUserActions = map[string]bool{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// requireAdmin gates a route on the session caller being an admin. It runs
|
// requireAdmin gates a route on the session caller being an admin. It runs
|
||||||
// inside withUISession, which has already stamped the requester. A non-admin is
|
// inside withUISession, which has already stamped the requester. A signed-in
|
||||||
// sent back to the dashboard rather than shown the panel.
|
// non-admin is told why (and bounced to the login with the message); an
|
||||||
|
// unauthenticated caller never gets here — the gate has already sent them to
|
||||||
|
// the login page with the intended destination.
|
||||||
func requireAdmin(next http.Handler) http.Handler {
|
func requireAdmin(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if req, ok := authctx.From(r.Context()); !ok || !req.IsAdmin() {
|
if req, ok := authctx.From(r.Context()); !ok || !req.IsAdmin() {
|
||||||
http.Redirect(w, r, "/ui", http.StatusSeeOther)
|
http.Redirect(w, r, "/ui/login?error=admin+role+required", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
|
|||||||
@@ -30,15 +30,15 @@ func TestRequireAdminAllowsAdminOnly(t *testing.T) {
|
|||||||
t.Error("admin must reach the handler")
|
t.Error("admin must reach the handler")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plain user is redirected to the dashboard.
|
// Plain user is redirected to the login with the reason.
|
||||||
reached = false
|
reached = false
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
h.ServeHTTP(rec, adminReq(t, "user"))
|
h.ServeHTTP(rec, adminReq(t, "user"))
|
||||||
if reached {
|
if reached {
|
||||||
t.Error("non-admin must not reach the handler")
|
t.Error("non-admin must not reach the handler")
|
||||||
}
|
}
|
||||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/ui" {
|
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/ui/login?error=admin+role+required" {
|
||||||
t.Errorf("non-admin got %d -> %q, want 303 -> /ui", rec.Code, rec.Header().Get("Location"))
|
t.Errorf("non-admin got %d -> %q, want 303 -> login with the admin-required error", rec.Code, rec.Header().Get("Location"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ func (s *Server) handleUILogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
if next == "" || !strings.HasPrefix(next, "/ui/") {
|
if next == "" || !strings.HasPrefix(next, "/ui/") {
|
||||||
next = "/ui"
|
next = "/ui"
|
||||||
}
|
}
|
||||||
|
//nolint:gosec // G710: next is validated to start with /ui/ just above
|
||||||
http.Redirect(w, r, next, http.StatusSeeOther)
|
http.Redirect(w, r, next, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -65,7 +65,7 @@ Write-Host "SciMesh $Component installed: $Target"
|
|||||||
Write-Host ""
|
Write-Host ""
|
||||||
if ($Component -eq "coordinator") {
|
if ($Component -eq "coordinator") {
|
||||||
if ($AutoStart -eq "1") {
|
if ($AutoStart -eq "1") {
|
||||||
Write-Host "Starting the platform and opening the control room in your browser..."
|
Write-Host "Starting the platform and opening the admin console in your browser..."
|
||||||
Write-Host "(stop it with Ctrl-C; it keeps your data in ~\.scimesh)"
|
Write-Host "(stop it with Ctrl-C; it keeps your data in ~\.scimesh)"
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
& $Target serve --open
|
& $Target serve --open
|
||||||
|
|||||||
+1
-1
@@ -78,7 +78,7 @@ fi
|
|||||||
if [ "$COMPONENT" = "coordinator" ]; then
|
if [ "$COMPONENT" = "coordinator" ]; then
|
||||||
if [ "$AUTO_START" = "1" ]; then
|
if [ "$AUTO_START" = "1" ]; then
|
||||||
echo
|
echo
|
||||||
echo "Starting the platform and opening the control room in your browser..."
|
echo "Starting the platform and opening the admin console in your browser..."
|
||||||
echo "(stop it with Ctrl-C; it keeps your data in ~/.scimesh)"
|
echo "(stop it with Ctrl-C; it keeps your data in ~/.scimesh)"
|
||||||
echo
|
echo
|
||||||
exec "$TARGET" serve --open
|
exec "$TARGET" serve --open
|
||||||
|
|||||||
Reference in New Issue
Block a user