Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40ff688416 | ||
|
|
1ec4b2e60b | ||
|
|
d309145290 | ||
|
|
15dd9651a8 | ||
|
|
30c441a7e9 |
@@ -54,8 +54,39 @@ jobs:
|
||||
path: coordinator/dist/*
|
||||
if-no-files-found: error
|
||||
|
||||
wheel:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: build the scimesh wheel
|
||||
env:
|
||||
VERSION: ${{ github.ref_name }}
|
||||
run: |
|
||||
# The tag (v1.1.0-alpha.10) becomes the package version in its
|
||||
# PEP 440 form (1.1.0a10); the wheel is then version-locked to the
|
||||
# binaries of the same release.
|
||||
WHEEL_VERSION="${VERSION#v}"
|
||||
WHEEL_VERSION="${WHEEL_VERSION/-alpha./a}"
|
||||
WHEEL_VERSION="${WHEEL_VERSION/-beta./b}"
|
||||
WHEEL_VERSION="${WHEEL_VERSION/-rc./rc}"
|
||||
sed -i "s/^version = .*/version = \"${WHEEL_VERSION}\"/" pyproject.toml
|
||||
python -m pip install --quiet build
|
||||
python -m build --wheel --outdir dist
|
||||
ls -la dist/
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: wheel
|
||||
path: dist/*.whl
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
needs: binaries
|
||||
needs: [binaries, wheel]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -68,6 +99,11 @@ jobs:
|
||||
pattern: binaries-*
|
||||
merge-multiple: true
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: wheel
|
||||
path: artifacts
|
||||
|
||||
- name: checksums
|
||||
working-directory: artifacts
|
||||
run: sha256sum * > SHA256SUMS.txt
|
||||
|
||||
@@ -225,6 +225,10 @@ The coordinator and worker agent are Go modules under `coordinator/` and `users/
|
||||
cd coordinator && make coordinator agent && go test ./...
|
||||
```
|
||||
|
||||
On headless servers (no desktop environment), RDKit needs a few X11
|
||||
libraries that desktops already ship: `sudo apt-get install -y libxrender1
|
||||
libxext6 libxcursor1 libxfixes3 libxi6 libxrandr2`.
|
||||
|
||||
`make check` runs the full gate: vet, lint, race tests, the PostgreSQL
|
||||
integration suite, and the two-worker end-to-end smoke script.
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/agent"
|
||||
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"flag"
|
||||
@@ -259,13 +262,25 @@ func ensureRuntime(log *slog.Logger, dataDir, venvPython string) {
|
||||
}
|
||||
pip := filepath.Join(venvDir, binName("bin/pip"))
|
||||
// The scimesh package is installed from an explicit source only: the PyPI
|
||||
// name is not ours yet, so `pip install scimesh` would fetch a stranger's
|
||||
// package. Operators publish a wheel or index via SCIMESH_PIP_PACKAGE.
|
||||
// name belongs to an unrelated project, so `pip install scimesh` would
|
||||
// fetch a stranger's package. Default: download the wheel attached to our
|
||||
// own GitHub release for this binary version; SCIMESH_PIP_PACKAGE
|
||||
// overrides with a custom wheel, checkout or index.
|
||||
source := os.Getenv("SCIMESH_PIP_PACKAGE")
|
||||
if source == "" {
|
||||
log.Warn("scientific runtime venv created, but scimesh is not installed",
|
||||
"hint", pip+" install <your scimesh wheel or index> (or set SCIMESH_PIP_PACKAGE)")
|
||||
return
|
||||
url, _, err := agent.ReleaseWheelURL(version)
|
||||
if err != nil {
|
||||
log.Warn("scientific runtime venv created, but scimesh is not installed",
|
||||
"hint", "set SCIMESH_PIP_PACKAGE to your wheel or index")
|
||||
return
|
||||
}
|
||||
downloaded, err := agent.DownloadWheel(context.Background(), url, venvDir)
|
||||
if err != nil {
|
||||
log.Warn("could not download the scimesh wheel for this release",
|
||||
"err", err, "hint", "set SCIMESH_PIP_PACKAGE to your wheel or index")
|
||||
return
|
||||
}
|
||||
source = downloaded
|
||||
}
|
||||
// #nosec G204,G702 -- pip and source are operator-configured paths.
|
||||
install := exec.CommandContext(context.Background(), pip, "install", source)
|
||||
|
||||
@@ -27,6 +27,10 @@ import (
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
// The wizard and --check need the injected build version too (they resolve
|
||||
// the release wheel matching this binary), so it is set before dispatch.
|
||||
agent.Version = version
|
||||
|
||||
if len(os.Args) > 1 && os.Args[1] == "setup" {
|
||||
os.Exit(runSetup(os.Args[2:]))
|
||||
}
|
||||
@@ -38,8 +42,6 @@ func main() {
|
||||
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
|
||||
@@ -56,7 +58,7 @@ func main() {
|
||||
fmt.Println("check: no coordinator URL (pass --coordinator-url or set COORDINATOR_URL)")
|
||||
os.Exit(1)
|
||||
}
|
||||
report := agent.RunCheck(ctx, url)
|
||||
report := agent.RunCheck(ctx, url, "")
|
||||
printCheck(report)
|
||||
if !report.Coordinator.OK || !report.Python.OK || !report.Scimesh.OK {
|
||||
os.Exit(1)
|
||||
|
||||
@@ -70,23 +70,31 @@ func CheckCoordinator(ctx context.Context, url string, timeout time.Duration) Ch
|
||||
return report
|
||||
}
|
||||
|
||||
// CheckEnvironment verifies the local runtime: Python present and the scimesh
|
||||
// package importable.
|
||||
// CheckEnvironment verifies the local runtime against the python3 found on
|
||||
// PATH.
|
||||
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
|
||||
return CheckReport{Agent: Version, Python: CheckItem{Name: "python", OK: false, Detail: "python3 not found on PATH"}}
|
||||
}
|
||||
report.Python = CheckItem{Name: "python", OK: true, Detail: python}
|
||||
//nolint:gosec // G204: python comes from LookPath, the argument list is constant
|
||||
return CheckEnvironmentWithPython(ctx, python)
|
||||
}
|
||||
|
||||
// CheckEnvironmentWithPython verifies the local runtime against a specific
|
||||
// interpreter — the wizard's managed venv python when the runtime installer
|
||||
// has created one, so the preflight reflects what the worker will actually
|
||||
// execute with.
|
||||
func CheckEnvironmentWithPython(ctx context.Context, python string) CheckReport {
|
||||
report := CheckReport{Agent: Version, Python: CheckItem{Name: "python", OK: true, Detail: python}}
|
||||
//nolint:gosec // G204: python is a resolved interpreter path, 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 {
|
||||
// The worker executes workloads by spawning scimesh's task runner, so
|
||||
// the package is a hard requirement, not an optimisation.
|
||||
report.Scimesh = CheckItem{Name: "scimesh", OK: false, Detail: "the worker runs workloads through scimesh — install with: pip install scimesh"}
|
||||
// the package is a hard requirement, not an optimisation. The PyPI
|
||||
// name belongs to a different project, so the wizard installs from
|
||||
// SCIMESH_PIP_PACKAGE instead of suggesting a bare pip install.
|
||||
report.Scimesh = CheckItem{Name: "scimesh", OK: false, Detail: "the worker runs workloads through scimesh — install it from your wheel or index (SCIMESH_PIP_PACKAGE)"}
|
||||
return report
|
||||
}
|
||||
report.Scimesh = CheckItem{Name: "scimesh", OK: true, Detail: strings.TrimSpace(string(out))}
|
||||
@@ -94,10 +102,17 @@ func CheckEnvironment(ctx context.Context) CheckReport {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// is the body behind `worker-agent --check` and the wizard's test step. A
|
||||
// non-empty python overrides the interpreter probed for the scimesh package
|
||||
// (the managed venv after a runtime install).
|
||||
func RunCheck(ctx context.Context, coordinatorURL, python string) CheckReport {
|
||||
report := CheckCoordinator(ctx, coordinatorURL, 15*time.Second)
|
||||
env := CheckEnvironment(ctx)
|
||||
var env CheckReport
|
||||
if python != "" {
|
||||
env = CheckEnvironmentWithPython(ctx, python)
|
||||
} else {
|
||||
env = CheckEnvironment(ctx)
|
||||
}
|
||||
report.Python = env.Python
|
||||
report.Scimesh = env.Scimesh
|
||||
return report
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ReleaseWheelURL returns the download URL of the scimesh wheel attached to
|
||||
// the GitHub release that matches the given binary version (for example
|
||||
// "1.1.0-alpha.10"), plus the wheel file name. The wheel is version-locked to
|
||||
// the binary so a worker's catalog always matches its task runner.
|
||||
func ReleaseWheelURL(version string) (string, string, error) {
|
||||
if version == "" || version == "dev" {
|
||||
return "", "", fmt.Errorf("no release wheel for build %q", version)
|
||||
}
|
||||
filename := fmt.Sprintf("scimesh-%s-py3-none-any.whl", NormalizePEP440(version))
|
||||
return fmt.Sprintf("https://github.com/emil28092005/SciMesh/releases/download/v%s/%s", version, filename), filename, nil
|
||||
}
|
||||
|
||||
// NormalizePEP440 turns our release tag suffixes into the PEP 440 form
|
||||
// setuptools uses for wheel names: 1.1.0-alpha.10 -> 1.1.0a10,
|
||||
// 1.1.0-beta.2 -> 1.1.0b2, 1.1.0-rc.1 -> 1.1.0rc1. Stable tags pass through.
|
||||
func NormalizePEP440(version string) string {
|
||||
for from, to := range map[string]string{"-alpha.": "a", "-beta.": "b", "-rc.": "rc"} {
|
||||
version = strings.ReplaceAll(version, from, to)
|
||||
}
|
||||
return version
|
||||
}
|
||||
|
||||
// DownloadWheel fetches the release wheel into dir (config directory of the
|
||||
// wizard / serve data dir) and returns the local path. Best-effort download
|
||||
// with a generous timeout: wheels can be several MB.
|
||||
func DownloadWheel(ctx context.Context, url, dir string) (string, error) {
|
||||
target := filepath.Join(dir, wheelNameFromURL(url))
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("download wheel: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("download wheel: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
//nolint:gosec // G304: target is our own config dir + a fixed wheel name
|
||||
out, err := os.Create(target)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("download wheel: %w", err)
|
||||
}
|
||||
defer func() { _ = out.Close() }()
|
||||
if _, err := io.Copy(out, resp.Body); err != nil {
|
||||
return "", fmt.Errorf("download wheel: %w", err)
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// wheelNameFromURL extracts the trailing file name of a wheel URL.
|
||||
func wheelNameFromURL(url string) string {
|
||||
return url[strings.LastIndex(url, "/")+1:]
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizePEP440(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"1.1.0": "1.1.0",
|
||||
"1.1.0-alpha.10": "1.1.0a10",
|
||||
"1.1.0-beta.2": "1.1.0b2",
|
||||
"1.1.0-rc.1": "1.1.0rc1",
|
||||
"1.0.0": "1.0.0",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := NormalizePEP440(in); got != want {
|
||||
t.Errorf("NormalizePEP440(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseWheelURL(t *testing.T) {
|
||||
url, name, err := ReleaseWheelURL("1.1.0-alpha.10")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantURL := "https://github.com/emil28092005/SciMesh/releases/download/v1.1.0-alpha.10/scimesh-1.1.0a10-py3-none-any.whl"
|
||||
if url != wantURL {
|
||||
t.Errorf("url = %q, want %q", url, wantURL)
|
||||
}
|
||||
if name != "scimesh-1.1.0a10-py3-none-any.whl" {
|
||||
t.Errorf("name = %q", name)
|
||||
}
|
||||
|
||||
// A dev build has no release wheel.
|
||||
if _, _, err := ReleaseWheelURL("dev"); err == nil {
|
||||
t.Error("dev build must not resolve a release wheel")
|
||||
}
|
||||
if _, _, err := ReleaseWheelURL(""); err == nil {
|
||||
t.Error("empty version must not resolve a release wheel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadWheel(t *testing.T) {
|
||||
payload := []byte("fake wheel bytes")
|
||||
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write(payload)
|
||||
}))
|
||||
defer stub.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
path, err := DownloadWheel(context.Background(), stub.URL+"/scimesh-1.1.0a10-py3-none-any.whl", dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasSuffix(path, "scimesh-1.1.0a10-py3-none-any.whl") {
|
||||
t.Errorf("path = %q", path)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != string(payload) {
|
||||
t.Error("wheel bytes mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadWheelReportsHTTPErrors(t *testing.T) {
|
||||
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer stub.Close()
|
||||
if _, err := DownloadWheel(context.Background(), stub.URL+"/missing.whl", t.TempDir()); err == nil {
|
||||
t.Error("404 must fail the download")
|
||||
}
|
||||
}
|
||||
@@ -176,14 +176,15 @@ func (s *PIDSupervisor) Stop() error {
|
||||
|
||||
// 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
|
||||
install func(ctx context.Context, venvPython, pkg string) error
|
||||
log *slog.Logger
|
||||
cfgPath string
|
||||
logPath string
|
||||
dir string
|
||||
sup Supervisor
|
||||
openBrowser func(string)
|
||||
port int
|
||||
install func(ctx context.Context, venvPython, pkg string) error
|
||||
downloadWheel func(ctx context.Context, url, dir string) (string, error)
|
||||
}
|
||||
|
||||
// Options customises the wizard for tests and embedding.
|
||||
@@ -196,6 +197,9 @@ type Options struct {
|
||||
// InstallScimesh overrides the pip step of the runtime installer (tests
|
||||
// substitute a fake); nil uses the real pip inside the managed venv.
|
||||
InstallScimesh func(ctx context.Context, venvPython, pkg string) error
|
||||
// DownloadWheel overrides the release-wheel download (tests substitute a
|
||||
// fake); nil downloads from the GitHub release matching the agent version.
|
||||
DownloadWheel func(ctx context.Context, url, dir string) (string, error)
|
||||
}
|
||||
|
||||
func New(log *slog.Logger, opts Options) *Server {
|
||||
@@ -223,7 +227,11 @@ func New(log *slog.Logger, opts Options) *Server {
|
||||
if install == nil {
|
||||
install = installScimeshWithPip
|
||||
}
|
||||
return &Server{log: log, cfgPath: cfgPath, logPath: filepath.Join(dir, logFileName), dir: dir, sup: sup, openBrowser: open, port: port, install: install}
|
||||
downloadWheel := opts.DownloadWheel
|
||||
if downloadWheel == nil {
|
||||
downloadWheel = agent.DownloadWheel
|
||||
}
|
||||
return &Server{log: log, cfgPath: cfgPath, logPath: filepath.Join(dir, logFileName), dir: dir, sup: sup, openBrowser: open, port: port, install: install, downloadWheel: downloadWheel}
|
||||
}
|
||||
|
||||
// Listen binds the loopback listener and returns it; Serve runs the server on
|
||||
@@ -292,6 +300,25 @@ type statusView struct {
|
||||
TokenSet bool `json:"token_set"`
|
||||
}
|
||||
|
||||
// ensureVenvTaskRunner rewrites the saved config so its task runner uses the
|
||||
// managed venv python when one exists and the config does not already pin one.
|
||||
func (s *Server) ensureVenvTaskRunner() {
|
||||
raw, err := os.ReadFile(s.cfgPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var file agent.ConfigFile
|
||||
if json.Unmarshal(raw, &file) != nil || len(file.TaskRunner) > 0 {
|
||||
return
|
||||
}
|
||||
if venv := s.venvPython(); venv != "" {
|
||||
file.TaskRunner = []string{venv, "-m", "scimesh.worker.task"}
|
||||
if payload, err := json.MarshalIndent(file, "", " "); err == nil {
|
||||
_ = os.WriteFile(s.cfgPath, append(payload, '\n'), 0o600)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -357,6 +384,14 @@ func (s *Server) handleSaveConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if file.CPUCount < 1 {
|
||||
file.CPUCount = 1
|
||||
}
|
||||
// The wizard UI bakes the venv python into the runner after an install;
|
||||
// an API-driven or scripted flow may not, so the server guarantees it:
|
||||
// workloads execute through scimesh's task runner, which lives in the venv.
|
||||
if len(file.TaskRunner) == 0 {
|
||||
if venv := s.venvPython(); venv != "" {
|
||||
file.TaskRunner = []string{venv, "-m", "scimesh.worker.task"}
|
||||
}
|
||||
}
|
||||
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"})
|
||||
@@ -376,7 +411,10 @@ func (s *Server) handleTest(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "coordinator_url is required"})
|
||||
return
|
||||
}
|
||||
report := agent.RunCheck(r.Context(), url)
|
||||
// After the runtime installer created the venv, probe that interpreter:
|
||||
// checking the bare system python3 would keep reporting scimesh as
|
||||
// missing even though the worker would run with the venv.
|
||||
report := agent.RunCheck(r.Context(), url, s.venvPython())
|
||||
writeJSON(w, http.StatusOK, report)
|
||||
}
|
||||
|
||||
@@ -385,6 +423,7 @@ func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "no configuration saved yet"})
|
||||
return
|
||||
}
|
||||
s.ensureVenvTaskRunner()
|
||||
pid, err := s.sup.Start(s.cfgPath, s.logPath)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
|
||||
@@ -450,7 +489,25 @@ func (s *Server) handleInstallRuntime(w http.ResponseWriter, r *http.Request) {
|
||||
pkg = os.Getenv("SCIMESH_PIP_PACKAGE")
|
||||
}
|
||||
if pkg == "" {
|
||||
pkg = "scimesh"
|
||||
// No PyPI default on purpose: the PyPI name "scimesh" belongs to an
|
||||
// unrelated project. Instead we ship the wheel in our own GitHub
|
||||
// release, version-locked to this binary, and download it from there.
|
||||
url, _, err := agent.ReleaseWheelURL(agent.Version)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusConflict, map[string]string{
|
||||
"error": "no scimesh source configured: set SCIMESH_PIP_PACKAGE to your wheel, checkout or index, then retry",
|
||||
})
|
||||
return
|
||||
}
|
||||
local, err := s.downloadWheel(r.Context(), url, s.dir)
|
||||
if err != nil {
|
||||
s.log.Error("download release wheel", "err", err, "url", url)
|
||||
writeJSON(w, http.StatusConflict, map[string]string{
|
||||
"error": "could not download the scimesh wheel for this release: " + err.Error() + ". Set SCIMESH_PIP_PACKAGE to your wheel, checkout or index and retry.",
|
||||
})
|
||||
return
|
||||
}
|
||||
pkg = local
|
||||
}
|
||||
|
||||
python3, err := exec.LookPath("python3")
|
||||
@@ -488,6 +545,20 @@ func (s *Server) handleInstallRuntime(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, installRuntimeResponse{OK: true, Python: venvPython, Installed: true})
|
||||
}
|
||||
|
||||
// venvPython returns the managed venv python when the runtime installer has
|
||||
// created one, so the task runner can be pointed at it automatically.
|
||||
func (s *Server) venvPython() string {
|
||||
for _, candidate := range []string{
|
||||
filepath.Join(s.dir, "venv", "bin", "python"),
|
||||
filepath.Join(s.dir, "venv", "Scripts", "python.exe"),
|
||||
} {
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// installScimeshWithPip installs the package with the venv's own pip,
|
||||
// streaming into the agent log so a long build is not silent.
|
||||
func installScimeshWithPip(ctx context.Context, venvPython, pkg string) error {
|
||||
|
||||
@@ -29,6 +29,11 @@ func newTestServer(t *testing.T, sup Supervisor) (*Server, string) {
|
||||
}
|
||||
|
||||
func newTestServerWithInstall(t *testing.T, sup Supervisor, install func(ctx context.Context, venvPython, pkg string) error) (*Server, string) {
|
||||
t.Helper()
|
||||
return newTestServerWithInstallAndWheel(t, sup, install, nil)
|
||||
}
|
||||
|
||||
func newTestServerWithInstallAndWheel(t *testing.T, sup Supervisor, install func(ctx context.Context, venvPython, pkg string) error, wheel func(ctx context.Context, url, dir string) (string, error)) (*Server, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
server := New(testLogger(), Options{
|
||||
@@ -41,6 +46,7 @@ func newTestServerWithInstall(t *testing.T, sup Supervisor, install func(ctx con
|
||||
Supervisor: sup,
|
||||
OpenBrowser: func(string) {},
|
||||
InstallScimesh: install,
|
||||
DownloadWheel: wheel,
|
||||
})
|
||||
listener, err := server.Listen()
|
||||
if err != nil {
|
||||
@@ -311,18 +317,35 @@ func TestInstallRuntimeCreatesVenvAndReportsPython(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
|
||||
rec, data := postJSON(t, base, "/api/runtime/install", map[string]any{})
|
||||
rec, data := postJSON(t, base, "/api/runtime/install", map[string]any{"scimesh_package": "/wheels/scimesh.whl"})
|
||||
if rec.Code != http.StatusOK || data["ok"] != true {
|
||||
t.Fatalf("install: got %d %v, want 200 ok", rec.Code, data)
|
||||
}
|
||||
if installedPkg != "scimesh" {
|
||||
t.Errorf("package = %q, want the default scimesh", installedPkg)
|
||||
if installedPkg != "/wheels/scimesh.whl" {
|
||||
t.Errorf("package = %q, want the requested wheel", installedPkg)
|
||||
}
|
||||
if !strings.HasSuffix(data["python"].(string), "venv/bin/python") {
|
||||
t.Errorf("python = %v, want the venv python", data["python"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallRuntimeRequiresASource(t *testing.T) {
|
||||
sup := &fakeSup{}
|
||||
_, base := newTestServerWithInstall(t, sup, func(ctx context.Context, venvPython, pkg string) error {
|
||||
t.Fatal("install must not run without a package source")
|
||||
return nil
|
||||
})
|
||||
// No source anywhere (SCIMESH_PIP_PACKAGE unset, request empty): 409 with
|
||||
// guidance. The PyPI name is another project, so no silent fallback.
|
||||
rec, data := postJSON(t, base, "/api/runtime/install", map[string]any{})
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("install without source: got %d, want 409", rec.Code)
|
||||
}
|
||||
if !strings.Contains(data["error"].(string), "SCIMESH_PIP_PACKAGE") {
|
||||
t.Errorf("error = %v, want a hint about SCIMESH_PIP_PACKAGE", data["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallRuntimeFailureIsExplained(t *testing.T) {
|
||||
sup := &fakeSup{}
|
||||
_, base := newTestServerWithInstall(t, sup, func(ctx context.Context, venvPython, pkg string) error {
|
||||
@@ -336,3 +359,123 @@ func TestInstallRuntimeFailureIsExplained(t *testing.T) {
|
||||
t.Errorf("error = %v, want a hint about SCIMESH_PIP_PACKAGE", data["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallRuntimeDownloadsReleaseWheelWhenNoSource(t *testing.T) {
|
||||
oldVersion := agent.Version
|
||||
agent.Version = "1.1.0-alpha.10"
|
||||
t.Cleanup(func() { agent.Version = oldVersion })
|
||||
|
||||
var downloadedURL, installedPkg string
|
||||
sup := &fakeSup{}
|
||||
_, base := newTestServerWithInstallAndWheel(t, sup,
|
||||
func(ctx context.Context, venvPython, pkg string) error {
|
||||
installedPkg = pkg
|
||||
return nil
|
||||
},
|
||||
func(ctx context.Context, url, dir string) (string, error) {
|
||||
downloadedURL = url
|
||||
return filepath.Join(dir, "scimesh-1.1.0a10-py3-none-any.whl"), nil
|
||||
})
|
||||
|
||||
rec, data := postJSON(t, base, "/api/runtime/install", map[string]any{})
|
||||
if rec.Code != http.StatusOK || data["ok"] != true {
|
||||
t.Fatalf("install: got %d %v, want 200 ok", rec.Code, data)
|
||||
}
|
||||
if !strings.Contains(downloadedURL, "releases/download/v1.1.0-alpha.10/scimesh-1.1.0a10-py3-none-any.whl") {
|
||||
t.Errorf("download url = %q, want the release wheel of this version", downloadedURL)
|
||||
}
|
||||
if !strings.HasSuffix(installedPkg, "scimesh-1.1.0a10-py3-none-any.whl") {
|
||||
t.Errorf("pip received %q, want the downloaded wheel", installedPkg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallRuntimeWheelDownloadFailureIsExplained(t *testing.T) {
|
||||
oldVersion := agent.Version
|
||||
agent.Version = "1.1.0-alpha.10"
|
||||
t.Cleanup(func() { agent.Version = oldVersion })
|
||||
|
||||
sup := &fakeSup{}
|
||||
_, base := newTestServerWithInstallAndWheel(t, sup,
|
||||
func(ctx context.Context, venvPython, pkg string) error { t.Fatal("pip must not run"); return nil },
|
||||
func(ctx context.Context, url, dir string) (string, error) {
|
||||
return "", errors.New("HTTP 404")
|
||||
})
|
||||
|
||||
rec, data := postJSON(t, base, "/api/runtime/install", map[string]any{})
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("got %d, want 409", rec.Code)
|
||||
}
|
||||
if !strings.Contains(data["error"].(string), "SCIMESH_PIP_PACKAGE") {
|
||||
t.Errorf("error = %v, want a SCIMESH_PIP_PACKAGE hint", data["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartPinsTheVenvTaskRunner(t *testing.T) {
|
||||
sup := &fakeSup{}
|
||||
server, base := newTestServer(t, sup)
|
||||
postJSON(t, base, "/api/config", map[string]any{
|
||||
"coordinator_url": "http://coord:8080", "token": "t", "work_dir": ".",
|
||||
})
|
||||
// Simulate the runtime installer: create the venv python marker.
|
||||
venvPython := filepath.Join(server.dir, "venv", "bin", "python")
|
||||
_ = os.MkdirAll(filepath.Dir(venvPython), 0o755)
|
||||
_ = os.WriteFile(venvPython, []byte("#!/bin/sh\nexit 0\n"), 0o755)
|
||||
|
||||
rec, _ := postJSON(t, base, "/api/start", map[string]any{})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("start: got %d, want 200", rec.Code)
|
||||
}
|
||||
config, err := agent.LoadConfigFile(server.cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(config.TaskRunner) != 3 || config.TaskRunner[0] != venvPython || config.TaskRunner[1] != "-m" || config.TaskRunner[2] != "scimesh.worker.task" {
|
||||
t.Errorf("task runner = %v, want the venv python runner", config.TaskRunner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveConfigPinsVenvRunnerWhenPresent(t *testing.T) {
|
||||
server, base := newTestServer(t, &fakeSup{})
|
||||
venvPython := filepath.Join(server.dir, "venv", "bin", "python")
|
||||
_ = os.MkdirAll(filepath.Dir(venvPython), 0o755)
|
||||
_ = os.WriteFile(venvPython, []byte("#!/bin/sh\nexit 0\n"), 0o755)
|
||||
|
||||
rec, _ := postJSON(t, base, "/api/config", map[string]any{
|
||||
"coordinator_url": "http://coord:8080", "token": "t", "work_dir": ".",
|
||||
})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("config: got %d", rec.Code)
|
||||
}
|
||||
config, err := agent.LoadConfigFile(server.cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(config.TaskRunner) != 3 || config.TaskRunner[0] != venvPython {
|
||||
t.Errorf("task runner = %v, want the venv python", config.TaskRunner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestProbesTheVenvPythonAfterInstall(t *testing.T) {
|
||||
sup := &fakeSup{}
|
||||
server, base := newTestServer(t, sup)
|
||||
// The runtime installer leaves a venv python; make it a stub that reports
|
||||
// a fake scimesh version so the preflight goes green through the venv.
|
||||
venvPython := filepath.Join(server.dir, "venv", "bin", "python")
|
||||
_ = os.MkdirAll(filepath.Dir(venvPython), 0o755)
|
||||
_ = os.WriteFile(venvPython, []byte("#!/bin/sh\nif [ \"$1\" = \"-c\" ]; then echo 9.9.9-test; exit 0; fi\nexit 0\n"), 0o755)
|
||||
|
||||
req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, base+"/api/test", strings.NewReader(`{"coordinator_url":"http://127.0.0.1:1"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
var report agent.CheckReport
|
||||
if err := json.NewDecoder(resp.Body).Decode(&report); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !report.Scimesh.OK || report.Scimesh.Detail != "9.9.9-test" {
|
||||
t.Errorf("scimesh check = %+v, want the venv interpreter reporting 9.9.9-test", report.Scimesh)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -97,6 +97,7 @@ if ($Component -eq "coordinator") {
|
||||
Write-Host "For a coordinator started with 'coordinator serve', the worker token is"
|
||||
Write-Host "in ~\.scimesh\worker.token on that machine. Set SCIMESH_PIP_PACKAGE to"
|
||||
Write-Host "install scimesh into a managed venv, or install it yourself:"
|
||||
Write-Host " pip install scimesh"
|
||||
Write-Host " set SCIMESH_PIP_PACKAGE=<your wheel or index>"
|
||||
Write-Host " $Target setup"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -112,5 +112,5 @@ else
|
||||
echo "For a coordinator started with 'coordinator serve', the worker token is"
|
||||
echo "in ~/.scimesh/worker.token on that machine. Set SCIMESH_PIP_PACKAGE to"
|
||||
echo "install scimesh into a managed venv, or install it yourself:"
|
||||
echo " pip install scimesh"
|
||||
echo " SCIMESH_PIP_PACKAGE=<your wheel or index> worker-agent setup"
|
||||
fi
|
||||
|
||||
+6
-4
@@ -113,10 +113,12 @@ chmod +x coordinator
|
||||
```
|
||||
|
||||
It spawns `python -m scimesh.worker.task`, so the machine needs Python 3
|
||||
with the `scimesh` package (`pip install scimesh`, or let the managed venv
|
||||
do it via `SCIMESH_PIP_PACKAGE`). For a `coordinator serve` instance, the
|
||||
worker token is in `~/.scimesh/worker.token`. On Windows set
|
||||
`SCIMESH_COMPONENT=worker` for `install.ps1`.
|
||||
with the `scimesh` package. The wizard installs it into its own venv; the
|
||||
package must come from your wheel, checkout or index — point
|
||||
`SCIMESH_PIP_PACKAGE` at it (the PyPI name `scimesh` belongs to an unrelated
|
||||
project). For a `coordinator serve` instance, the worker token is in
|
||||
`~/.scimesh/worker.token`. On Windows set `SCIMESH_COMPONENT=worker` for
|
||||
`install.ps1`.
|
||||
- **coordinator** needs no external services at all in its default mode:
|
||||
`coordinator serve` embeds SQLite (both databases), the userservice, and
|
||||
local workers. The `SCIMESH_DB=postgres` engine remains for cluster
|
||||
|
||||
@@ -59,6 +59,13 @@ curl -fsSL https://raw.githubusercontent.com/emil28092005/SciMesh/main/install.s
|
||||
# the installer opens the local wizard at http://127.0.0.1:12700 automatically
|
||||
```
|
||||
|
||||
On a headless server (no desktop environment), RDKit needs a few X11
|
||||
libraries that desktops already ship — install them once with apt:
|
||||
|
||||
```bash
|
||||
sudo apt-get install -y libxrender1 libxext6 libxcursor1 libxfixes3 libxi6 libxrandr2
|
||||
```
|
||||
|
||||
Or configure by hand:
|
||||
|
||||
```bash
|
||||
|
||||
Reference in New Issue
Block a user