Compare commits

...
Author SHA1 Message Date
Emil c4d88c7ffc Satisfy the linter gate: errors.Is, context-aware exec/listen, gosec nolints
coordinator / test (push) Waiting to run
python / test (push) Waiting to run
release / binaries (amd64, darwin) (push) Waiting to run
release / binaries (amd64, linux) (push) Waiting to run
release / binaries (amd64, windows) (push) Waiting to run
release / binaries (arm64, darwin) (push) Waiting to run
release / binaries (arm64, linux) (push) Waiting to run
release / binaries (arm64, windows) (push) Waiting to run
release / release (push) Blocked by required conditions
release / image (push) Waiting to run
users / test (push) Waiting to run
2026-08-03 01:47:53 +03:00
Emil 44247bd94e Give every wizard test its own port and disable keep-alive pooling
coordinator / test (push) Waiting to run
python / test (push) Waiting to run
release / binaries (amd64, darwin) (push) Waiting to run
release / binaries (amd64, linux) (push) Waiting to run
release / binaries (amd64, windows) (push) Waiting to run
release / binaries (arm64, darwin) (push) Waiting to run
release / binaries (arm64, linux) (push) Waiting to run
release / binaries (arm64, windows) (push) Waiting to run
release / release (push) Blocked by required conditions
release / image (push) Waiting to run
users / test (push) Waiting to run
2026-08-03 01:43:48 +03:00
Emil 7c1d0dc568 Tell non-admins why the console is off-limits instead of bouncing them to the dashboard
coordinator / test (push) Waiting to run
python / test (push) Waiting to run
release / binaries (amd64, darwin) (push) Waiting to run
release / binaries (amd64, linux) (push) Waiting to run
release / binaries (amd64, windows) (push) Waiting to run
release / binaries (arm64, darwin) (push) Waiting to run
release / binaries (arm64, linux) (push) Waiting to run
release / binaries (arm64, windows) (push) Waiting to run
release / release (push) Blocked by required conditions
release / image (push) Waiting to run
users / test (push) Waiting to run
2026-08-03 01:34:22 +03:00
11 changed files with 50 additions and 19 deletions
+5 -3
View File
@@ -7,6 +7,7 @@ package main
import (
"context"
"errors"
"flag"
"fmt"
"log/slog"
@@ -93,7 +94,7 @@ func loadConfig(configPath string) (*agent.Config, error) {
}
envPath := os.Getenv("SCIMESH_WORKER_CONFIG")
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)
}
}
@@ -153,7 +154,7 @@ func runSetup(args []string) int {
// 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 {
if err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("setup wizard stopped", "err", err)
return 1
}
@@ -172,7 +173,8 @@ func openBrowser(url string) {
if err != nil {
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
}
}
+1
View File
@@ -80,6 +80,7 @@ func CheckEnvironment(ctx context.Context) CheckReport {
return report
}
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')")
out, err := cmd.Output()
if err != nil {
+1
View File
@@ -41,6 +41,7 @@ func DefaultConfigPath() string {
// created by the wizard with 0600 permissions, so no credential is exposed to
// other local users.
func LoadConfigFile(path string) (*Config, error) {
//nolint:gosec // G304: path is --config or SCIMESH_WORKER_CONFIG, operator-supplied
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config file: %w", err)
+5 -2
View File
@@ -107,12 +107,15 @@ func (s *PIDSupervisor) Start(configPath, logPath string) (int, error) {
if err != nil {
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)
if err != nil {
return 0, fmt.Errorf("open worker log: %w", err)
}
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.Stderr = logFile
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
// 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))
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).
@@ -5,6 +5,7 @@ import (
"encoding/json"
"io"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"os"
@@ -25,7 +26,10 @@ func newTestServer(t *testing.T, sup Supervisor) (*Server, string) {
t.Helper()
dir := t.TempDir()
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"),
Dir: dir,
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) {
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 {
t.Fatal(err)
}
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)
if err != nil {
t.Fatal(err)
@@ -94,6 +100,20 @@ func postJSON(t *testing.T, base, path string, body any) (*httptest.ResponseReco
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 {
t.Helper()
raw, err := json.Marshal(v)
@@ -174,7 +194,7 @@ func TestWizardStartStopLifecycle(t *testing.T) {
}
// 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)
if err != nil {
t.Fatal(err)
@@ -198,7 +218,7 @@ func TestWizardStatusPrefillsSavedConfig(t *testing.T) {
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)
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, base+"/api/status", nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
@@ -2,6 +2,7 @@ package sqlite
import (
"context"
"errors"
"testing"
"time"
@@ -83,7 +84,7 @@ func TestWorkerSetTrust(t *testing.T) {
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 {
if err := repo.SetTrust(ctx, uuid.New(), domain.WorkerTrusted); !errors.Is(err, domain.ErrWorkerNotFound) {
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
// inside withUISession, which has already stamped the requester. A non-admin is
// sent back to the dashboard rather than shown the panel.
// inside withUISession, which has already stamped the requester. A signed-in
// 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 {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if req, ok := authctx.From(r.Context()); !ok || !req.IsAdmin() {
http.Redirect(w, r, "/ui", http.StatusSeeOther)
http.Redirect(w, r, "/ui/login?error=admin+role+required", http.StatusSeeOther)
return
}
next.ServeHTTP(w, r)
@@ -30,15 +30,15 @@ func TestRequireAdminAllowsAdminOnly(t *testing.T) {
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
rec := httptest.NewRecorder()
h.ServeHTTP(rec, adminReq(t, "user"))
if reached {
t.Error("non-admin must not reach the handler")
}
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/ui" {
t.Errorf("non-admin got %d -> %q, want 303 -> /ui", rec.Code, rec.Header().Get("Location"))
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/ui/login?error=admin+role+required" {
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/") {
next = "/ui"
}
//nolint:gosec // G710: next is validated to start with /ui/ just above
http.Redirect(w, r, next, http.StatusSeeOther)
}
+1 -1
View File
@@ -65,7 +65,7 @@ Write-Host "SciMesh $Component installed: $Target"
Write-Host ""
if ($Component -eq "coordinator") {
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 ""
& $Target serve --open
+1 -1
View File
@@ -78,7 +78,7 @@ fi
if [ "$COMPONENT" = "coordinator" ]; then
if [ "$AUTO_START" = "1" ]; then
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
exec "$TARGET" serve --open