diff --git a/agent/cmd/behavision-setup/main.go b/agent/cmd/behavision-setup/main.go new file mode 100644 index 0000000..9ec7453 --- /dev/null +++ b/agent/cmd/behavision-setup/main.go @@ -0,0 +1,331 @@ +// Command behavision-setup prepares a shop PC to run the recognition engine. +// +// It exists because the engine is Python and the rest of the product is Go. +// The Go halves cross-compile to Windows from any machine; the engine, frozen +// with PyInstaller, does not - PyInstaller bundles the interpreter and native +// wheels of the machine it runs on, so a frozen engine can only be built on +// Windows. That single fact was the whole reason a release could not be cut. +// +// So this installs the engine from source instead of shipping it frozen: find +// a Python, build a private virtual environment beside the database, install +// the engine into it, fetch the models, and record how to start it. Everything +// in the release can then be built anywhere. +// +// The trade, stated plainly because whoever runs this is standing in a shop: +// it needs Python and a working internet connection at install time, and it +// takes minutes rather than seconds. A frozen build needs neither. What it +// buys is a release that exists. +package main + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + "github.com/loyaly/behavision-agent/pkg/config" + "github.com/loyaly/behavision-agent/pkg/paths" +) + +// The engine needs 3.10; nothing here works below it and the failure would +// otherwise arrive as a syntax error deep inside a dependency. +const minMinor = 10 + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "\n Setup did not finish: %v\n\n", err) + pause() + os.Exit(1) + } + pause() +} + +func run() error { + fmt.Println() + fmt.Println(" Behavision setup") + fmt.Println(" ----------------") + fmt.Println() + + state := paths.StateRoot() + src, err := engineSource() + if err != nil { + return err + } + fmt.Printf(" engine source %s\n", src) + fmt.Printf(" install into %s\n", state) + fmt.Println() + + if err := paths.EnsureState(); err != nil { + return fmt.Errorf("could not create %s: %w", state, err) + } + + py, ver, err := findPython() + if err != nil { + return err + } + step("Python", fmt.Sprintf("%s (%s)", ver, py)) + + venv := filepath.Join(state, "runtime") + if err := makeVenv(py, venv); err != nil { + return err + } + vpy := venvPython(venv) + step("Virtual environment", venv) + + // --upgrade so re-running after a new release replaces the engine rather + // than leaving the old one in place and reporting success. + if err := pipInstall(vpy, src); err != nil { + return err + } + step("Engine and dependencies", "installed") + + if err := runEngine(vpy, "setup-models"); err != nil { + return fmt.Errorf("downloading the recognition models: %w", err) + } + step("Recognition models", "downloaded") + + if err := writeConfig(vpy); err != nil { + return err + } + step("Startup settings", filepath.Join(state, "agent.json")) + + // Proving it starts is the point. An installer that reports success and + // leaves a shop with an engine that will not run has done worse than + // failing: the failure surfaces later, to someone who did not install it. + if err := smokeTest(vpy); err != nil { + return fmt.Errorf("the engine installed but would not start: %w", err) + } + step("Engine starts and answers", "verified") + + fmt.Println() + fmt.Println(" Done. Start Behavision from the Start menu or the desktop icon.") + fmt.Println(" It appears in the system tray; right-click there to stop it.") + fmt.Println() + return nil +} + +func step(label, detail string) { + fmt.Printf(" [ok] %-24s %s\n", label, detail) +} + +// engineSource finds the Python source shipped beside this executable. Beside, +// not downloaded: the engine and the app must be the same release, and a +// version skew between them is the class of bug nobody can reproduce. +func engineSource() (string, error) { + candidates := []string{ + filepath.Join(paths.InstallRoot(), "engine-src"), + filepath.Join(paths.InstallRoot(), "..", "engine-src"), + } + if wd, err := os.Getwd(); err == nil { + candidates = append(candidates, filepath.Join(wd, "engine-src"), wd) + } + for _, c := range candidates { + if _, err := os.Stat(filepath.Join(c, "pyproject.toml")); err == nil { + abs, _ := filepath.Abs(c) + return abs, nil + } + } + return "", errors.New("could not find the engine source (expected an " + + "engine-src folder with pyproject.toml beside this program). " + + "Unzip the whole release together rather than moving this file out of it") +} + +// findPython returns the first interpreter that is new enough. +// +// `py -3` first on Windows: the launcher is what the official installer puts +// on PATH, and `python` there is often the Microsoft Store stub that prints an +// advert and exits 9009 instead of running anything. +func findPython() (string, string, error) { + type cand struct { + exe string + args []string + } + var cands []cand + if runtime.GOOS == "windows" { + cands = append(cands, cand{"py", []string{"-3"}}) + } + cands = append(cands, cand{"python3", nil}, cand{"python", nil}) + + var tried []string + for _, c := range cands { + exe, err := exec.LookPath(c.exe) + if err != nil { + continue + } + args := append(append([]string{}, c.args...), "-c", + "import sys;print('%d.%d'%sys.version_info[:2])") + out, err := exec.Command(exe, args...).Output() + if err != nil { + continue + } + ver := strings.TrimSpace(string(out)) + tried = append(tried, c.exe+" "+ver) + if major, minor, ok := parseVer(ver); ok && (major > 3 || (major == 3 && minor >= minMinor)) { + full := exe + if len(c.args) > 0 { + full = exe + " " + strings.Join(c.args, " ") + } + return full, "Python " + ver, nil + } + } + + msg := "no Python 3.10 or newer was found on this PC.\n\n" + + " Install it from https://www.python.org/downloads/windows/\n" + + " and tick \"Add python.exe to PATH\" on the first screen,\n" + + " then run this again." + if len(tried) > 0 { + msg += "\n\n Found, but too old: " + strings.Join(tried, ", ") + } + return "", "", errors.New(msg) +} + +func parseVer(s string) (int, int, bool) { + parts := strings.Split(s, ".") + if len(parts) < 2 { + return 0, 0, false + } + major, err1 := strconv.Atoi(parts[0]) + minor, err2 := strconv.Atoi(parts[1]) + return major, minor, err1 == nil && err2 == nil +} + +// splitLauncher turns `py -3` back into a command and its arguments. +func splitLauncher(s string) (string, []string) { + f := strings.Fields(s) + if len(f) == 0 { + return s, nil + } + return f[0], f[1:] +} + +func venvPython(venv string) string { + if runtime.GOOS == "windows" { + return filepath.Join(venv, "Scripts", "python.exe") + } + return filepath.Join(venv, "bin", "python") +} + +// makeVenv builds the engine's own interpreter under the writable state root. +// +// A virtual environment rather than the system Python: a shop PC may have +// Python there for something else, and pinning numpy below 2.0 - which the +// engine requires - inside a shared interpreter is how you break the other +// thing months later, silently. +func makeVenv(py, venv string) error { + if _, err := os.Stat(venvPython(venv)); err == nil { + return nil // already built; pip below brings it up to date + } + exe, args := splitLauncher(py) + args = append(args, "-m", "venv", venv) + return stream(exec.Command(exe, args...), "creating the virtual environment") +} + +func pipInstall(vpy, src string) error { + fmt.Println(" Installing the engine and its libraries. This downloads a few") + fmt.Println(" hundred megabytes and takes a while on a slow connection.") + fmt.Println() + if err := stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade", + "pip", "setuptools", "wheel"), "updating pip"); err != nil { + return err + } + return stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade", src), + "installing the engine") +} + +func runEngine(vpy string, args ...string) error { + full := append([]string{"-m", "behavision"}, args...) + return stream(exec.Command(vpy, full...), "running the engine") +} + +// writeConfig records how to start the engine, in the same file and through +// the same type the app reads, so the two cannot disagree about it. +func writeConfig(vpy string) error { + path := paths.AgentConfig() + cfg, err := config.Load(path) + if err != nil { + return fmt.Errorf("reading %s: %w", path, err) + } + // An absolute path: the app resolves a relative EngineExe against its own + // install root under Program Files, and the interpreter is not there. + cfg.EngineExe = vpy + cfg.EngineArgs = []string{"-m", "behavision", "run"} + if cfg.APIBase == "" { + cfg.APIBase = "http://127.0.0.1:8010" + } + return cfg.Save(path) +} + +// smokeTest starts the engine exactly as the app will and waits for its API to +// answer. Any reply counts, including 401: the engine invents its own +// credential when none is configured, and a refusal proves it is serving. +func smokeTest(vpy string) error { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, vpy, "-m", "behavision", "run") + var log strings.Builder + cmd.Stdout, cmd.Stderr = &log, &log + if err := cmd.Start(); err != nil { + return err + } + defer func() { + _ = cmd.Process.Kill() + _, _ = cmd.Process.Wait() + }() + + client := &http.Client{Timeout: 3 * time.Second} + deadline := time.Now().Add(75 * time.Second) + for time.Now().Before(deadline) { + resp, err := client.Get("http://127.0.0.1:8010/api/health") + if err == nil { + _, _ = io.Copy(io.Discard, resp.Body) + resp.Body.Close() + return nil + } + if cmd.ProcessState != nil && cmd.ProcessState.Exited() { + break + } + time.Sleep(2 * time.Second) + } + return fmt.Errorf("it did not answer within 75 seconds.\n\n%s", + tail(log.String(), 15)) +} + +func tail(s string, n int) string { + lines := strings.Split(strings.TrimRight(s, "\n"), "\n") + if len(lines) > n { + lines = lines[len(lines)-n:] + } + return " " + strings.Join(lines, "\n ") +} + +// stream runs a command and shows its output. Shown, not swallowed: pip failing +// on a missing build tool prints exactly what is wrong, and hiding that leaves +// the operator with "setup failed" and nothing to act on. +func stream(cmd *exec.Cmd, what string) error { + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("%s failed: %w", what, err) + } + return nil +} + +// pause keeps the window open. Double-clicked from Explorer, a console program +// that finishes closes instantly and the operator sees nothing at all - +// success and failure look identical. +func pause() { + if runtime.GOOS != "windows" { + return + } + fmt.Print(" Press Enter to close. ") + _, _ = bufio.NewReader(os.Stdin).ReadString('\n') +}