mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix(docker): repair copied AI venv paths (#390)
AI feature installs now keep copied Python venv metadata (bin/pip shebang, bin/activate, pyvenv.cfg) pointed at /data/ai/venv, so scripts no longer silently fall back to the baked, read-only /opt/venv after the venv is bootstrapped into /data. Fixes #127 (AI tools incompatible with PUID/PGID). The entrypoint repairs both fresh bootstraps and already-stamped runtime venvs (self-heals existing deployments on next restart, no reinstall needed), with regression coverage for literal path replacement and binary file safety. Independently reviewed and verified: traced chown/gosu ordering in entrypoint.sh to confirm no permission regression, reproduced the exact issue #127 scenario (custom PUID + manual venv activation) in a live container both before and after the fix, and ran the PR's own test suite locally (16/16 passing). Co-authored-by: SyntaxSawdust
This commit is contained in:
co-authored by
SyntaxSawdust
parent
a0d1c70172
commit
7e01d3637e
@@ -55,3 +55,55 @@ ensure_writable() {
|
|||||||
done
|
done
|
||||||
return "$_ew_failed"
|
return "$_ew_failed"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# rewrite_venv_paths <venv> <from> <to>
|
||||||
|
# A Python venv is not fully relocatable after a raw copy: console scripts and
|
||||||
|
# activation files keep the source venv path. Patch only text files that still
|
||||||
|
# contain that path so feature installs do not fall back to the baked /opt/venv.
|
||||||
|
rewrite_venv_paths() {
|
||||||
|
_rv_venv="$1"
|
||||||
|
_rv_from="$2"
|
||||||
|
_rv_to="$3"
|
||||||
|
|
||||||
|
if [ ! -d "$_rv_venv" ] || [ -z "$_rv_from" ] || [ -z "$_rv_to" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
grep -Il -- "$_rv_from" "$_rv_venv"/bin/* "$_rv_venv/pyvenv.cfg" 2>/dev/null |
|
||||||
|
while IFS= read -r _rv_file; do
|
||||||
|
[ -f "$_rv_file" ] || continue
|
||||||
|
[ -L "$_rv_file" ] && continue
|
||||||
|
python3 - "$_rv_file" "$_rv_from" "$_rv_to" <<'PY'
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
path, old, new = sys.argv[1:]
|
||||||
|
old_bytes = old.encode()
|
||||||
|
new_bytes = new.encode()
|
||||||
|
|
||||||
|
with open(path, "rb") as source:
|
||||||
|
data = source.read()
|
||||||
|
|
||||||
|
if old_bytes not in data:
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
stat = os.stat(path)
|
||||||
|
directory = os.path.dirname(path) or "."
|
||||||
|
prefix = f".{os.path.basename(path)}.snapotter-rewrite."
|
||||||
|
fd, tmp_path = tempfile.mkstemp(prefix=prefix, dir=directory)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "wb") as target:
|
||||||
|
target.write(data.replace(old_bytes, new_bytes))
|
||||||
|
os.chmod(tmp_path, stat.st_mode & 0o7777)
|
||||||
|
os.replace(tmp_path, path)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
PY
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ if [ -d "/opt/venv" ]; then
|
|||||||
rm -rf "$AI_VENV"
|
rm -rf "$AI_VENV"
|
||||||
cp -r /opt/venv "$AI_VENV_TMP"
|
cp -r /opt/venv "$AI_VENV_TMP"
|
||||||
mv "$AI_VENV_TMP" "$AI_VENV"
|
mv "$AI_VENV_TMP" "$AI_VENV"
|
||||||
|
rewrite_venv_paths "$AI_VENV" "/opt/venv" "$AI_VENV"
|
||||||
# Reset installed-bundle state: their packages lived in the old venv.
|
# Reset installed-bundle state: their packages lived in the old venv.
|
||||||
# Models in /data/ai/models survive, so reinstalling a bundle only
|
# Models in /data/ai/models survive, so reinstalling a bundle only
|
||||||
# reruns pip (model downloads are idempotent and skip existing files).
|
# reruns pip (model downloads are idempotent and skip existing files).
|
||||||
@@ -129,6 +130,8 @@ if [ -d "/opt/venv" ]; then
|
|||||||
echo "WARNING: Installed AI feature bundles were reset after base venv upgrade. Reinstall them from the Settings page."
|
echo "WARNING: Installed AI feature bundles were reset after base venv upgrade. Reinstall them from the Settings page."
|
||||||
fi
|
fi
|
||||||
echo "AI venv ready at $AI_VENV"
|
echo "AI venv ready at $AI_VENV"
|
||||||
|
elif [ -d "$AI_VENV" ]; then
|
||||||
|
rewrite_venv_paths "$AI_VENV" "/opt/venv" "$AI_VENV"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,12 +23,17 @@ let aiVenv: string;
|
|||||||
let aiVenvTmp: string;
|
let aiVenvTmp: string;
|
||||||
let installedJson: string;
|
let installedJson: string;
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const ENTRYPOINT_LIB = resolve(here, "../../../docker/entrypoint-lib.sh");
|
||||||
|
|
||||||
// Self-contained shell script mirroring the entrypoint bootstrap block.
|
// Self-contained shell script mirroring the entrypoint bootstrap block.
|
||||||
// Uses env vars for paths so we can point at temp directories.
|
// Uses env vars for paths so we can point at temp directories.
|
||||||
const BOOTSTRAP_SCRIPT = `
|
const BOOTSTRAP_SCRIPT = `
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
. "$TEST_ENTRYPOINT_LIB"
|
||||||
|
|
||||||
AI_VENV="$TEST_AI_VENV"
|
AI_VENV="$TEST_AI_VENV"
|
||||||
AI_VENV_TMP="$TEST_AI_VENV_TMP"
|
AI_VENV_TMP="$TEST_AI_VENV_TMP"
|
||||||
OPT_VENV="$TEST_OPT_VENV"
|
OPT_VENV="$TEST_OPT_VENV"
|
||||||
@@ -59,12 +65,14 @@ if [ -d "$OPT_VENV" ]; then
|
|||||||
rm -rf "$AI_VENV"
|
rm -rf "$AI_VENV"
|
||||||
cp -r "$OPT_VENV" "$AI_VENV_TMP"
|
cp -r "$OPT_VENV" "$AI_VENV_TMP"
|
||||||
mv "$AI_VENV_TMP" "$AI_VENV"
|
mv "$AI_VENV_TMP" "$AI_VENV"
|
||||||
|
rewrite_venv_paths "$AI_VENV" "$OPT_VENV" "$AI_VENV"
|
||||||
if [ -f "$TEST_INSTALLED_JSON" ]; then
|
if [ -f "$TEST_INSTALLED_JSON" ]; then
|
||||||
echo '{"bundles":{}}' > "$TEST_INSTALLED_JSON"
|
echo '{"bundles":{}}' > "$TEST_INSTALLED_JSON"
|
||||||
echo "BUNDLES_RESET"
|
echo "BUNDLES_RESET"
|
||||||
fi
|
fi
|
||||||
echo "VENV_READY"
|
echo "VENV_READY"
|
||||||
else
|
else
|
||||||
|
rewrite_venv_paths "$AI_VENV" "$OPT_VENV" "$AI_VENV"
|
||||||
echo "SKIP"
|
echo "SKIP"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
@@ -79,6 +87,7 @@ function runBootstrap(): string {
|
|||||||
TEST_AI_VENV_TMP: aiVenvTmp,
|
TEST_AI_VENV_TMP: aiVenvTmp,
|
||||||
TEST_OPT_VENV: optVenv,
|
TEST_OPT_VENV: optVenv,
|
||||||
TEST_INSTALLED_JSON: installedJson,
|
TEST_INSTALLED_JSON: installedJson,
|
||||||
|
TEST_ENTRYPOINT_LIB: ENTRYPOINT_LIB,
|
||||||
PATH: process.env.PATH,
|
PATH: process.env.PATH,
|
||||||
},
|
},
|
||||||
encoding: "utf-8",
|
encoding: "utf-8",
|
||||||
@@ -95,8 +104,12 @@ beforeEach(() => {
|
|||||||
|
|
||||||
// Simulate /opt/venv with a stamp and a marker file
|
// Simulate /opt/venv with a stamp and a marker file
|
||||||
mkdirSync(optVenv, { recursive: true });
|
mkdirSync(optVenv, { recursive: true });
|
||||||
|
mkdirSync(join(optVenv, "bin"), { recursive: true });
|
||||||
writeFileSync(join(optVenv, ".venv-version"), "abc123\n");
|
writeFileSync(join(optVenv, ".venv-version"), "abc123\n");
|
||||||
writeFileSync(join(optVenv, "marker.txt"), "base-package-content");
|
writeFileSync(join(optVenv, "marker.txt"), "base-package-content");
|
||||||
|
writeFileSync(join(optVenv, "bin", "pip"), `#!${optVenv}/bin/python3\n`);
|
||||||
|
writeFileSync(join(optVenv, "bin", "activate"), `VIRTUAL_ENV=${optVenv}\n`);
|
||||||
|
writeFileSync(join(optVenv, "pyvenv.cfg"), `command = python3 -m venv ${optVenv}\n`);
|
||||||
|
|
||||||
mkdirSync(dataAi, { recursive: true });
|
mkdirSync(dataAi, { recursive: true });
|
||||||
});
|
});
|
||||||
@@ -112,6 +125,10 @@ describe("venv upgrade stamp", () => {
|
|||||||
expect(output).toContain("VENV_READY");
|
expect(output).toContain("VENV_READY");
|
||||||
expect(existsSync(join(aiVenv, ".venv-version"))).toBe(true);
|
expect(existsSync(join(aiVenv, ".venv-version"))).toBe(true);
|
||||||
expect(readFileSync(join(aiVenv, "marker.txt"), "utf-8")).toBe("base-package-content");
|
expect(readFileSync(join(aiVenv, "marker.txt"), "utf-8")).toBe("base-package-content");
|
||||||
|
expect(readFileSync(join(aiVenv, "bin", "pip"), "utf-8")).toContain(aiVenv);
|
||||||
|
expect(readFileSync(join(aiVenv, "bin", "activate"), "utf-8")).toContain(aiVenv);
|
||||||
|
expect(readFileSync(join(aiVenv, "pyvenv.cfg"), "utf-8")).toContain(aiVenv);
|
||||||
|
expect(readFileSync(join(aiVenv, "bin", "pip"), "utf-8")).not.toContain(optVenv);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips when stamps match", () => {
|
it("skips when stamps match", () => {
|
||||||
@@ -123,6 +140,26 @@ describe("venv upgrade stamp", () => {
|
|||||||
expect(output).toBe("SKIP");
|
expect(output).toBe("SKIP");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("repairs copied venv paths even when stamps already match", () => {
|
||||||
|
mkdirSync(join(aiVenv, "bin"), { recursive: true });
|
||||||
|
writeFileSync(join(aiVenv, ".venv-version"), "abc123\n");
|
||||||
|
writeFileSync(join(aiVenv, "bin", "pip"), `#!${optVenv}/bin/python3\n`);
|
||||||
|
writeFileSync(join(aiVenv, "bin", "activate"), `VIRTUAL_ENV=${optVenv}\n`);
|
||||||
|
writeFileSync(join(aiVenv, "pyvenv.cfg"), `command = python3 -m venv ${optVenv}\n`);
|
||||||
|
|
||||||
|
const output = runBootstrap();
|
||||||
|
expect(output).toBe("SKIP");
|
||||||
|
for (const file of [
|
||||||
|
join(aiVenv, "bin", "pip"),
|
||||||
|
join(aiVenv, "bin", "activate"),
|
||||||
|
join(aiVenv, "pyvenv.cfg"),
|
||||||
|
]) {
|
||||||
|
const content = readFileSync(file, "utf-8");
|
||||||
|
expect(content).toContain(aiVenv);
|
||||||
|
expect(content).not.toContain(optVenv);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("refreshes when stamps differ (upgrade)", () => {
|
it("refreshes when stamps differ (upgrade)", () => {
|
||||||
// Simulate stale venv with old stamp
|
// Simulate stale venv with old stamp
|
||||||
mkdirSync(aiVenv, { recursive: true });
|
mkdirSync(aiVenv, { recursive: true });
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { dirname, join, resolve } from "node:path";
|
import { dirname, join, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
@@ -72,3 +72,32 @@ describe("entrypoint-lib.sh ensure_writable", () => {
|
|||||||
expect(stderr).toContain("chown");
|
expect(stderr).toContain("chown");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("entrypoint-lib.sh rewrite_venv_paths", () => {
|
||||||
|
it("rewrites copied venv text entrypoints literally without touching binary files", () => {
|
||||||
|
const optVenv = join(root, "opt&venv");
|
||||||
|
const aiVenv = join(root, "data|ai", "venv&runtime");
|
||||||
|
const binDir = join(aiVenv, "bin");
|
||||||
|
mkdirSync(binDir, { recursive: true });
|
||||||
|
|
||||||
|
const pip = join(binDir, "pip");
|
||||||
|
const activate = join(binDir, "activate");
|
||||||
|
const pyvenv = join(aiVenv, "pyvenv.cfg");
|
||||||
|
const binary = join(binDir, "python3");
|
||||||
|
|
||||||
|
writeFileSync(pip, `#!${optVenv}/bin/python3\nprint('pip')\n`);
|
||||||
|
writeFileSync(activate, `VIRTUAL_ENV=${optVenv}\nexport VIRTUAL_ENV\n`);
|
||||||
|
writeFileSync(pyvenv, `command = python3 -m venv ${optVenv}\n`);
|
||||||
|
writeFileSync(binary, Buffer.from([0x00, ...Buffer.from(optVenv), 0x00]));
|
||||||
|
|
||||||
|
const result = runLib(`rewrite_venv_paths '${aiVenv}' '${optVenv}' '${aiVenv}'`);
|
||||||
|
expect(result.status, result.stderr).toBe(0);
|
||||||
|
|
||||||
|
for (const file of [pip, activate, pyvenv]) {
|
||||||
|
const content = readFileSync(file, "utf-8");
|
||||||
|
expect(content).toContain(aiVenv);
|
||||||
|
expect(content).not.toContain(optVenv);
|
||||||
|
}
|
||||||
|
expect(readFileSync(binary)).toEqual(Buffer.from([0x00, ...Buffer.from(optVenv), 0x00]));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user