diff --git a/agent/cmd/behavision-setup/main.go b/agent/cmd/behavision-setup/main.go index 9ec7453..78a053e 100644 --- a/agent/cmd/behavision-setup/main.go +++ b/agent/cmd/behavision-setup/main.go @@ -33,6 +33,7 @@ import ( "time" "github.com/loyaly/behavision-agent/pkg/config" + "github.com/loyaly/behavision-agent/pkg/engine" "github.com/loyaly/behavision-agent/pkg/paths" ) @@ -81,6 +82,17 @@ func run() error { vpy := venvPython(venv) step("Virtual environment", venv) + // The engine reads its settings from /config/default.yaml and will + // seed that from beside its own code on first run - which works when its + // code is a checkout or a frozen folder and not when it is a package in + // site-packages, where there is no config beside it. Seeded here, from the + // copy the release ships. Never overwritten: an upgrade must not revert an + // operator's thresholds. + if err := seedConfig(src, state); err != nil { + return err + } + step("Settings", filepath.Join(state, "config", "default.yaml")) + // --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 { @@ -237,13 +249,81 @@ func pipInstall(vpy, src string) error { "pip", "setuptools", "wheel"), "updating pip"); err != nil { return err } - return stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade", src), + + // A wheel if the release ships one - nothing to build on the shop PC, and + // pip never has to touch the folder the release was unzipped into. + // + // That matters more than it sounds: `pip install ` makes setuptools + // write behavision.egg-info INTO that folder, and the folder is read-only + // whenever the release was unzipped somewhere sensible - Program Files, or + // the shared drive INSTALL.txt says is fine. Found by running this in a + // container with the source mounted read-only: "could not create + // 'behavision.egg-info': Read-only file system". Falling back to source + // copies it somewhere writable first, for the same reason. + if wheels, _ := filepath.Glob(filepath.Join(src, "behavision-*.whl")); len(wheels) > 0 { + return stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade", wheels[0]), + "installing the engine") + } + tmp, err := os.MkdirTemp("", "behavision-src-") + if err != nil { + return err + } + defer os.RemoveAll(tmp) + if err := copyTree(src, tmp); err != nil { + return fmt.Errorf("staging the engine source: %w", err) + } + return stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade", tmp), "installing the engine") } +// seedConfig puts the shipped default.yaml where the engine will look for it, +// and leaves an existing one alone. +func seedConfig(src, state string) error { + dst := filepath.Join(state, "config", "default.yaml") + if _, err := os.Stat(dst); err == nil { + return nil + } + from := filepath.Join(src, "config", "default.yaml") + b, err := os.ReadFile(from) + if err != nil { + return fmt.Errorf("the release is missing config/default.yaml: %w", err) + } + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + return os.WriteFile(dst, b, 0o644) +} + +// copyTree copies a source tree, skipping the caches a checkout accumulates. +func copyTree(from, to string) error { + return filepath.WalkDir(from, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel(from, path) + if d.IsDir() { + if d.Name() == "__pycache__" || strings.HasSuffix(d.Name(), ".egg-info") { + return filepath.SkipDir + } + return os.MkdirAll(filepath.Join(to, rel), 0o755) + } + b, err := os.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(filepath.Join(to, rel), b, 0o644) + }) +} + +// runEngine runs the engine exactly as the app will later: same interpreter, +// same environment. In particular ChildEnv sets BEHAVISION_DATA_DIR, without +// which a pip-installed engine decides its state lives in site-packages and +// downloads the models to a place the app never looks. func runEngine(vpy string, args ...string) error { full := append([]string{"-m", "behavision"}, args...) - return stream(exec.Command(vpy, full...), "running the engine") + cmd := exec.Command(vpy, full...) + cmd.Env = engine.ChildEnv("") + return stream(cmd, "running the engine") } // writeConfig records how to start the engine, in the same file and through @@ -272,6 +352,7 @@ func smokeTest(vpy string) error { defer cancel() cmd := exec.CommandContext(ctx, vpy, "-m", "behavision", "run") + cmd.Env = engine.ChildEnv("") var log strings.Builder cmd.Stdout, cmd.Stderr = &log, &log if err := cmd.Start(); err != nil { diff --git a/agent/main.go b/agent/main.go index aab0d3c..d1575c1 100644 --- a/agent/main.go +++ b/agent/main.go @@ -232,7 +232,7 @@ func cmdRun() error { // nothing - the URL was returned, logged and even exposed on the // desktop's status object, and never actually given to the engine. // A claimed shop PC published heartbeats and zero visits. - cmd.Env = append(os.Environ(), "BEHAVISION_WEBHOOK_URL="+hookURL) + cmd.Env = engine.ChildEnv(hookURL) return cmd }, LogWriter: logFile, diff --git a/agent/pkg/engine/env.go b/agent/pkg/engine/env.go new file mode 100644 index 0000000..064d526 --- /dev/null +++ b/agent/pkg/engine/env.go @@ -0,0 +1,41 @@ +package engine + +import ( + "os" + + "github.com/loyaly/behavision-agent/pkg/paths" +) + +// ChildEnv is the environment the engine is launched with, wherever it is +// launched from - the desktop app and the headless agent both go through +// here, so a third caller cannot get it half right. +// +// The line that matters is BEHAVISION_DATA_DIR. +// +// The engine's paths.py knows two worlds: frozen with PyInstaller, where state +// lives under ProgramData, and a checkout, where everything sits in the repo +// root. An engine installed from source into a virtual environment is neither. +// Left to itself it resolves its state root to site-packages - writes its +// database and camera list there, and generates its API credential into a +// folder this process never reads - while this process resolves the same +// state root to ProgramData. The two halves then disagree about where +// everything lives, and every call to the engine is 401 on a stock install, +// with nothing in either log saying why. Seen twice: once on a Mac checkout +// (the app in ~/Library, the engine in the repo) and once in a clean Linux +// container running the installer. +// +// Telling the engine where THIS process keeps state makes the two agree by +// construction, however the engine was installed. paths.py honours the +// override ahead of every other rule it has. +// +// hookURL is where the engine posts detections; empty is allowed and means +// the bridge has not started, which the engine treats as "no webhook". +func ChildEnv(hookURL string) []string { + env := append(os.Environ(), + "BEHAVISION_DATA_DIR="+paths.StateRoot(), + ) + if hookURL != "" { + env = append(env, "BEHAVISION_WEBHOOK_URL="+hookURL) + } + return env +} diff --git a/agent/pkg/engine/env_test.go b/agent/pkg/engine/env_test.go new file mode 100644 index 0000000..d717699 --- /dev/null +++ b/agent/pkg/engine/env_test.go @@ -0,0 +1,44 @@ +package engine + +import ( + "strings" + "testing" + + "github.com/loyaly/behavision-agent/pkg/paths" +) + +// The engine must be told where THIS process keeps state, or a pip-installed +// engine decides on site-packages and the two halves never find each other. +func TestTheEngineIsToldWhereStateLives(t *testing.T) { + t.Setenv("BEHAVISION_DATA_DIR", t.TempDir()) + + env := ChildEnv("http://127.0.0.1:5555/events") + + want := "BEHAVISION_DATA_DIR=" + paths.StateRoot() + if !contains(env, want) { + t.Fatalf("engine env lacks %q - a source-installed engine would put its "+ + "database and credential somewhere this process never looks", want) + } + if !contains(env, "BEHAVISION_WEBHOOK_URL=http://127.0.0.1:5555/events") { + t.Fatal("webhook url not passed to the engine") + } +} + +// Before the bridge has a port there is no webhook. An empty variable would be +// read by the engine as a webhook at "", which is not the same as none. +func TestNoWebhookMeansNoVariable(t *testing.T) { + for _, v := range ChildEnv("") { + if strings.HasPrefix(v, "BEHAVISION_WEBHOOK_URL=") { + t.Fatalf("empty hook still exported: %q", v) + } + } +} + +func contains(env []string, want string) bool { + for _, v := range env { + if v == want { + return true + } + } + return false +} diff --git a/desktop/app.go b/desktop/app.go index 8c0cb9c..6369d09 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -114,7 +114,7 @@ func (a *App) startup(ctx context.Context) { // be told again. Without it the engine recognised people and the // bridge received nothing: a claimed shop PC published heartbeats // and zero visits. - cmd.Env = append(os.Environ(), "BEHAVISION_WEBHOOK_URL="+a.webhookURL()) + cmd.Env = agentengine.ChildEnv(a.webhookURL()) return cmd }, LogWriter: logFile,