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:
Dustin Persek
2026-07-02 13:12:23 +08:00
committed by GitHub
co-authored by SyntaxSawdust
parent a0d1c70172
commit 7e01d3637e
4 changed files with 123 additions and 2 deletions
+38 -1
View File
@@ -1,7 +1,8 @@
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
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";
/**
@@ -22,12 +23,17 @@ let aiVenv: string;
let aiVenvTmp: 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.
// Uses env vars for paths so we can point at temp directories.
const BOOTSTRAP_SCRIPT = `
#!/bin/sh
set -e
. "$TEST_ENTRYPOINT_LIB"
AI_VENV="$TEST_AI_VENV"
AI_VENV_TMP="$TEST_AI_VENV_TMP"
OPT_VENV="$TEST_OPT_VENV"
@@ -59,12 +65,14 @@ if [ -d "$OPT_VENV" ]; then
rm -rf "$AI_VENV"
cp -r "$OPT_VENV" "$AI_VENV_TMP"
mv "$AI_VENV_TMP" "$AI_VENV"
rewrite_venv_paths "$AI_VENV" "$OPT_VENV" "$AI_VENV"
if [ -f "$TEST_INSTALLED_JSON" ]; then
echo '{"bundles":{}}' > "$TEST_INSTALLED_JSON"
echo "BUNDLES_RESET"
fi
echo "VENV_READY"
else
rewrite_venv_paths "$AI_VENV" "$OPT_VENV" "$AI_VENV"
echo "SKIP"
fi
else
@@ -79,6 +87,7 @@ function runBootstrap(): string {
TEST_AI_VENV_TMP: aiVenvTmp,
TEST_OPT_VENV: optVenv,
TEST_INSTALLED_JSON: installedJson,
TEST_ENTRYPOINT_LIB: ENTRYPOINT_LIB,
PATH: process.env.PATH,
},
encoding: "utf-8",
@@ -95,8 +104,12 @@ beforeEach(() => {
// Simulate /opt/venv with a stamp and a marker file
mkdirSync(optVenv, { recursive: true });
mkdirSync(join(optVenv, "bin"), { recursive: true });
writeFileSync(join(optVenv, ".venv-version"), "abc123\n");
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 });
});
@@ -112,6 +125,10 @@ describe("venv upgrade stamp", () => {
expect(output).toContain("VENV_READY");
expect(existsSync(join(aiVenv, ".venv-version"))).toBe(true);
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", () => {
@@ -123,6 +140,26 @@ describe("venv upgrade stamp", () => {
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)", () => {
// Simulate stale venv with old stamp
mkdirSync(aiVenv, { recursive: true });