fix(docker): refuse unsafe development updates

This commit is contained in:
Pi Web Agent
2026-07-13 15:08:39 +00:00
parent 8b0452d545
commit c217c4a417
3 changed files with 164 additions and 2 deletions
+98 -1
View File
@@ -259,6 +259,84 @@ describe("Docker command assets", () => {
expect(await readFile(helperLog, "utf8")).toBe("allow=1 args=ps\n");
});
dockerCommandIt("refuses development updates when the checkout has uncommitted files", async () => {
const helperLog = join(tempDir, "dev-helper.log");
const devRoot = await createCleanDevGitRepoWithFakeHelper(helperLog);
const fakeDocker = await installFakeDocker();
await installFakeId(fakeDocker.binDir, 1234, 2345);
await writeFile(join(devRoot, "staged.txt"), "changed\n", "utf8");
await execUtf8("git", ["-C", devRoot, "add", "staged.txt"], cleanProcessEnv());
await writeFile(join(devRoot, "modified.txt"), "changed\n", "utf8");
await writeFile(join(devRoot, "untracked.txt"), "untracked\n", "utf8");
const result = await runDockerCommandAllowFailure(
["--dev", "update"],
devHostEnv(fakeDocker, devRoot, join(tempDir, "home")),
);
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toContain("refusing to update the Docker development stack because the checkout has uncommitted changes");
expect(result.stderr).toContain("staged.txt");
expect(result.stderr).toContain("modified.txt");
expect(result.stderr).toContain("?? untracked.txt");
expect(result.stderr).toContain("commit, stash, or remove these changes");
await expect(readFile(helperLog, "utf8")).rejects.toThrow();
});
dockerCommandIt("refuses dirty development updates before scheduling a detached helper", async () => {
const helperLog = join(tempDir, "dev-helper.log");
const devRoot = await createCleanDevGitRepoWithFakeHelper(helperLog);
const fakeDocker = await installFakeDocker();
await installFakeId(fakeDocker.binDir, 1234, 2345);
await writeFile(join(devRoot, "untracked.txt"), "untracked\n", "utf8");
const result = await runDockerCommandAllowFailure(["--dev", "update"], devRuntimeEnv(fakeDocker, devRoot));
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toContain("checkout has uncommitted changes");
expect(result.stdout).not.toContain("Started detached PI WEB Docker helper");
await expect(readFile(fakeDocker.logPath, "utf8")).rejects.toThrow();
await expect(readFile(helperLog, "utf8")).rejects.toThrow();
});
dockerCommandIt("refuses development updates while a Git operation is in progress", async () => {
const helperLog = join(tempDir, "dev-helper.log");
const devRoot = await createCleanDevGitRepoWithFakeHelper(helperLog);
const fakeDocker = await installFakeDocker();
await installFakeId(fakeDocker.binDir, 1234, 2345);
const head = (await execUtf8("git", ["-C", devRoot, "rev-parse", "HEAD"], cleanProcessEnv())).stdout.trim();
await writeFile(join(devRoot, ".git", "MERGE_HEAD"), `${head}\n`, "utf8");
const result = await runDockerCommandAllowFailure(
["--dev", "update"],
devHostEnv(fakeDocker, devRoot, join(tempDir, "home")),
);
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toContain("while a Git merge is in progress");
expect(result.stderr).toContain("resolve or abort the Git merge");
await expect(readFile(helperLog, "utf8")).rejects.toThrow();
});
dockerCommandIt("allows clean development updates and dirty development starts", async () => {
const helperLog = join(tempDir, "dev-helper.log");
const devRoot = await createCleanDevGitRepoWithFakeHelper(helperLog);
const fakeDocker = await installFakeDocker();
await installFakeId(fakeDocker.binDir, 1234, 2345);
const env = devHostEnv(fakeDocker, devRoot, join(tempDir, "home"));
await runDockerCommand(["--dev", "update"], env);
await writeFile(join(devRoot, "in-progress-work.txt"), "dirty by design\n", "utf8");
await runDockerCommand(["--dev", "start"], env);
expect(await readFile(helperLog, "utf8")).toBe([
"allow=0 args=build --pull",
"allow=0 args=up -d --force-recreate --remove-orphans",
"allow=0 args=up -d --build",
"",
].join("\n"));
});
dockerCommandIt("starts development detached helpers as the generated dev user", async () => {
const devRoot = await createDevGeneratedEnv({ uid: 1234, gid: 2345, dockerGid: 3456 });
const fakeDocker = await installFakeDocker();
@@ -443,12 +521,31 @@ async function createDevRepoFixtureWithFakeHelper(logPath: string): Promise<stri
await mkdir(dirname(helperPath), { recursive: true });
await writeFile(helperPath, `#!/usr/bin/env sh
set -eu
printf 'allow=%s args=%s\n' "\${PI_WEB_DOCKER_ALLOW_ROOT:-}" "$*" >${shellSingleQuote(logPath)}
printf 'allow=%s args=%s\n' "\${PI_WEB_DOCKER_ALLOW_ROOT:-}" "$*" >>${shellSingleQuote(logPath)}
`, "utf8");
await chmod(helperPath, 0o755);
return devRoot;
}
async function createCleanDevGitRepoWithFakeHelper(logPath: string): Promise<string> {
const devRoot = await createDevRepoFixtureWithFakeHelper(logPath);
await Promise.all([
writeFile(join(devRoot, "staged.txt"), "clean\n", "utf8"),
writeFile(join(devRoot, "modified.txt"), "clean\n", "utf8"),
]);
const env = cleanProcessEnv();
await execUtf8("git", ["init", "--quiet", devRoot], env);
await execUtf8("git", ["-C", devRoot, "add", "."], env);
await execUtf8("git", [
"-C", devRoot,
"-c", "user.name=PI WEB Test",
"-c", "[email protected]",
"-c", "core.hooksPath=/dev/null",
"commit", "--quiet", "--no-gpg-sign", "-m", "test fixture",
], env);
return devRoot;
}
async function createDevGeneratedEnv(ids: { uid: number; gid: number; dockerGid: number }): Promise<string> {
const devRoot = join(tempDir, "dev-runtime");
await mkdir(join(devRoot, ".pi-web"), { recursive: true });