The installer, run on a clean machine, found two bugs in itself
Ran behavision-setup in a fresh Linux container: Python 3.12, nothing else, the release contents mounted read-only the way Program Files or a shared drive would be. It failed, and then it failed differently, and both failures would have been the client's first experience. 1. `pip install <folder>` makes setuptools write behavision.egg-info INTO the folder. The folder is read-only wherever a release is sensibly unzipped, so: "could not create 'behavision.egg-info': Read-only file system". The release now ships a wheel - pure Python, buildable anywhere, nothing to build on the shop PC, and pip never touches the unzipped folder. Source stays as a fallback and is copied somewhere writable first. 2. The engine's paths.py knows two worlds - frozen (ProgramData) and a checkout (the repo root) - and a pip-installed engine is neither. It resolved its state root to site-packages: database there, camera list there, and its generated API credential in a folder the app never reads, while the app looked in ProgramData. Every call would be 401 on a stock install, with nothing in either log saying why. The same disease as the Mac checkout two days ago, now in production shape. engine.ChildEnv is the one place the engine's environment is built, used by the desktop app, the headless agent and the installer's own smoke test. It passes BEHAVISION_DATA_DIR = this process's state root, which paths.py honours ahead of every other rule, so the two halves agree by construction however the engine was installed. It also seeds config/default.yaml into the state root: a package in site-packages has no config beside it to seed from. Re-run on the same clean container: seven steps, all pass, models downloaded, engine started and answered, and its data/ landed beside agent.json - not in site-packages. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
This commit is contained in:
@@ -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 <state>/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 <folder>` 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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
41
agent/pkg/engine/env.go
Normal file
41
agent/pkg/engine/env.go
Normal file
@@ -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
|
||||
}
|
||||
44
agent/pkg/engine/env_test.go
Normal file
44
agent/pkg/engine/env_test.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user