Archived
feat: unify Docker entrypoint
This commit is contained in:
@@ -0,0 +1,571 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { copyFile, chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { createServer } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { beforeEach, afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
const dockerEntrypoint = join(repoRoot, "docker", "pi-web-docker");
|
||||
|
||||
let tempDir = "";
|
||||
|
||||
interface CommandResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
interface FakeDocker {
|
||||
binDir: string;
|
||||
logPath: string;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "pi-web-docker-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("Docker command assets", () => {
|
||||
it("keeps shell entrypoints syntactically valid", async () => {
|
||||
await Promise.all([
|
||||
execUtf8("sh", ["-n", dockerEntrypoint], process.env),
|
||||
execUtf8("sh", ["-n", join(repoRoot, "docker", "install.sh")], process.env),
|
||||
execUtf8("sh", ["-n", join(repoRoot, "docker", "internal", "dev", "compose")], process.env),
|
||||
execUtf8("sh", ["-n", join(repoRoot, "docker", "internal", "host-profile.sh")], process.env),
|
||||
]);
|
||||
});
|
||||
|
||||
it("packages the canonical Docker command and internal support assets", async () => {
|
||||
const [dockerfile, devDockerfile, runtimeCompose, devCompose, installer, devWrapper, dockerignore] = await Promise.all([
|
||||
readRepoFile("docker/Dockerfile"),
|
||||
readRepoFile("docker/Dockerfile.dev"),
|
||||
readRepoFile("docker/compose.yml"),
|
||||
readRepoFile("docker/compose.dev.yml"),
|
||||
readRepoFile("docker/install.sh"),
|
||||
readRepoFile("docker/internal/dev/compose"),
|
||||
readRepoFile("docker/.dockerignore"),
|
||||
]);
|
||||
|
||||
expect(dockerfile).toContain("COPY pi-web-docker /usr/local/bin/pi-web-docker");
|
||||
expect(dockerfile).toContain("COPY internal/bin/hostexec /usr/local/bin/hostexec");
|
||||
expect(dockerfile).toContain("COPY internal/image/install-opensuse-base /usr/local/sbin/install-pi-web-opensuse-base");
|
||||
expect(devDockerfile).toContain("COPY docker/pi-web-docker /usr/local/bin/pi-web-docker");
|
||||
expect(devDockerfile).toContain("COPY docker/internal/bin/hostexec /usr/local/bin/hostexec");
|
||||
expect(dockerignore).toContain("!pi-web-docker");
|
||||
expect(dockerignore).toContain("!internal/bin/hostexec");
|
||||
expect(installer).toContain("write_asset pi-web-docker 0755");
|
||||
expect(installer).toContain("write_asset internal/host-profile.sh 0644");
|
||||
expect(installer).toContain("compose_cmd --project-name \"$compose_project_name\"");
|
||||
expect(installer).toContain("PI_WEB_DOCKER_INSTALL_DIR=$install_dir");
|
||||
expect(installer).toContain("PI_WEB_DOCKER_REF=$asset_ref");
|
||||
expect(installer).toContain("COMPOSE_PROJECT_NAME=$compose_project_name");
|
||||
expect(devWrapper).toContain("$repo_root/docker/internal/host-profile.sh");
|
||||
expect(devWrapper).toContain("--project-name \"$compose_project_name\"");
|
||||
expect(devWrapper).toContain("PI_WEB_DOCKER_DEV_REPO_ROOT=$repo_root");
|
||||
expect(devWrapper).toContain("COMPOSE_PROJECT_NAME=$compose_project_name");
|
||||
expect(runtimeCompose).toContain("PI_WEB_DOCKER_RUNTIME: \"1\"");
|
||||
expect(runtimeCompose).toContain("PI_WEB_DOCKER_MODE: runtime");
|
||||
expect(runtimeCompose).toContain("PI_WEB_DOCKER_INSTALL_DIR: ${PI_WEB_DOCKER_INSTALL_DIR:?set by docker/install.sh}");
|
||||
expect(runtimeCompose).toContain("PI_WEB_DOCKER_HELPER_IMAGE: ${PI_WEB_IMAGE:-pi-web:local}");
|
||||
expect(runtimeCompose).toContain("COMPOSE_PROJECT_NAME: ${COMPOSE_PROJECT_NAME:-pi-web}");
|
||||
expect(devCompose).toContain("PI_WEB_DOCKER_MODE: dev");
|
||||
expect(devCompose).toContain("PI_WEB_DOCKER_DEV_REPO_ROOT: ${PI_WEB_DOCKER_DEV_REPO_ROOT:?set by docker/pi-web-docker --dev}");
|
||||
expect(devCompose).toContain("PI_WEB_DOCKER_HELPER_IMAGE: ${PI_WEB_DEV_IMAGE:-pi-web:dev}");
|
||||
expect(devCompose).toContain("COMPOSE_PROJECT_NAME: ${COMPOSE_PROJECT_NAME:-pi-web-dev}");
|
||||
});
|
||||
|
||||
it("runs status through Docker Compose in the foreground", async () => {
|
||||
const installDir = await createRuntimeInstall();
|
||||
const fakeDocker = await installFakeDocker();
|
||||
|
||||
const result = await runDockerCommand(["status"], runtimeEnv(fakeDocker, installDir));
|
||||
|
||||
expect(result.stdout).toContain("fake docker compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml ps");
|
||||
const log = await readFile(fakeDocker.logPath, "utf8");
|
||||
expect(log).toContain("compose version");
|
||||
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml ps");
|
||||
expect(log).not.toContain("run -d");
|
||||
});
|
||||
|
||||
it("runs production host lifecycle commands through the generated runtime env", async () => {
|
||||
const installDir = await createRuntimeInstall();
|
||||
const fakeDocker = await installFakeDocker();
|
||||
const env = runtimeHostEnv(fakeDocker, installDir);
|
||||
|
||||
await runDockerCommand(["start"], env);
|
||||
await runDockerCommand(["stop"], env);
|
||||
await runDockerCommand(["restart-sessiond"], env);
|
||||
await runDockerCommand(["logs", "web"], env);
|
||||
await runDockerCommand(["shell", "sessiond"], env);
|
||||
await runDockerCommand(["cli", "config", "show"], env);
|
||||
|
||||
const log = await readFile(fakeDocker.logPath, "utf8");
|
||||
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml up -d");
|
||||
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml down");
|
||||
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml restart sessiond");
|
||||
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml logs -f web");
|
||||
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml exec sessiond bash");
|
||||
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml exec web pi-web config show");
|
||||
expect(log).not.toContain("run -d");
|
||||
});
|
||||
|
||||
it("ignores ambient Compose project names for runtime lifecycle commands", async () => {
|
||||
const installDir = await createRuntimeInstall();
|
||||
const fakeDocker = await installFakeDocker();
|
||||
|
||||
await runDockerCommand(["status"], {
|
||||
...runtimeHostEnv(fakeDocker, installDir),
|
||||
COMPOSE_PROJECT_NAME: "ambient-project",
|
||||
});
|
||||
|
||||
const log = await readFile(fakeDocker.logPath, "utf8");
|
||||
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml ps");
|
||||
expect(log).not.toContain("--project-name ambient-project");
|
||||
});
|
||||
|
||||
it("runs development commands through generated env while preserving host user ids", async () => {
|
||||
const devRoot = await createDevRepoFixture();
|
||||
const fakeDocker = await installFakeDocker();
|
||||
await installFakeUname(fakeDocker.binDir, "Darwin");
|
||||
await installFakeId(fakeDocker.binDir, 1234, 2345);
|
||||
const home = join(tempDir, "home");
|
||||
const runtimeDataDir = join(tempDir, "runtime-data");
|
||||
const runtimeEnvFile = join(tempDir, "runtime.env");
|
||||
const socketPath = join(home, ".docker", "run", "docker.sock");
|
||||
await writeFile(runtimeEnvFile, [
|
||||
"PI_WEB_UID=0",
|
||||
"PI_WEB_GID=0",
|
||||
`PI_WEB_DOCKER_DATA_DIR=${runtimeDataDir}`,
|
||||
"PI_WEB_BIND_ADDR=0.0.0.0",
|
||||
"COMPOSE_PROJECT_NAME=runtime-project",
|
||||
"",
|
||||
].join("\n"));
|
||||
|
||||
await withUnixSocket(socketPath, async () => {
|
||||
await runDockerCommand(["--dev", "status"], devHostEnv(fakeDocker, devRoot, home, { PI_WEB_DOCKER_RUNTIME_ENV_FILE: runtimeEnvFile }));
|
||||
});
|
||||
|
||||
const generatedEnvPath = join(devRoot, ".pi-web", "docker-compose-dev.generated.env");
|
||||
const generatedEnv = await readFile(generatedEnvPath, "utf8");
|
||||
expect(generatedEnv).toContain("PI_WEB_UID=1234\n");
|
||||
expect(generatedEnv).toContain("PI_WEB_GID=2345\n");
|
||||
expect(generatedEnv).toContain("DOCKER_GID=0\n");
|
||||
expect(generatedEnv).toContain(`PI_WEB_DOCKER_DATA_DIR=${runtimeDataDir}\n`);
|
||||
expect(generatedEnv).toContain(`PI_WEB_DOCKER_DEV_REPO_ROOT=${devRoot}\n`);
|
||||
expect(generatedEnv).toContain("COMPOSE_PROJECT_NAME=pi-web-dev\n");
|
||||
expect(generatedEnv).toContain("PI_WEB_DEV_API_BIND_ADDR=0.0.0.0\n");
|
||||
expect(generatedEnv).not.toContain("COMPOSE_PROJECT_NAME=runtime-project");
|
||||
|
||||
await withUnixSocket(socketPath, async () => {
|
||||
await runDockerCommand(["--dev", "status"], devHostEnv(fakeDocker, devRoot, home, {
|
||||
COMPOSE_PROJECT_NAME: "ambient-dev-project",
|
||||
PI_WEB_DOCKER_RUNTIME_ENV_FILE: runtimeEnvFile,
|
||||
}));
|
||||
});
|
||||
const regeneratedEnv = await readFile(generatedEnvPath, "utf8");
|
||||
expect(regeneratedEnv).toContain("COMPOSE_PROJECT_NAME=pi-web-dev\n");
|
||||
expect(regeneratedEnv).toContain(`PI_WEB_DOCKER_DATA_DIR=${runtimeDataDir}\n`);
|
||||
expect(regeneratedEnv).not.toContain("COMPOSE_PROJECT_NAME=ambient-dev-project");
|
||||
|
||||
const localConfig = await readFile(join(devRoot, ".pi-web", "docker-compose-dev.local.env"), "utf8");
|
||||
expect(localConfig).toContain("docker/pi-web-docker --dev creates this file once");
|
||||
expect(localConfig).toContain("PI_WEB_UID and PI_WEB_GID default to the current host user");
|
||||
const override = await readFile(join(devRoot, ".pi-web", "docker-compose-dev.host.generated.yml"), "utf8");
|
||||
expect(override).toContain(socketPath);
|
||||
expect(override).toContain(devRoot);
|
||||
const log = await readFile(fakeDocker.logPath, "utf8");
|
||||
expect(log).toContain(`compose --project-name pi-web-dev --env-file ${generatedEnvPath} -f ${devRoot}/docker/compose.dev.yml -f ${devRoot}/.pi-web/docker-compose-dev.host.generated.yml ps`);
|
||||
});
|
||||
|
||||
it("rejects development commands as root unless explicitly allowed", async () => {
|
||||
const fakeDocker = await installFakeDocker();
|
||||
await installFakeId(fakeDocker.binDir, 0, 0);
|
||||
|
||||
const result = await runDockerCommandAllowFailure(["--dev", "status"], {
|
||||
...cleanProcessEnv(),
|
||||
PATH: `${fakeDocker.binDir}:${process.env["PATH"] ?? ""}`,
|
||||
});
|
||||
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(result.stderr).toContain("refusing to run Docker development mode as root");
|
||||
});
|
||||
|
||||
it("passes the root override through to the development compose helper", async () => {
|
||||
const fakeDocker = await installFakeDocker();
|
||||
await installFakeId(fakeDocker.binDir, 0, 0);
|
||||
const helperLog = join(tempDir, "dev-helper.log");
|
||||
const devRoot = await createDevRepoFixtureWithFakeHelper(helperLog);
|
||||
|
||||
await runDockerCommand(["--dev", "--allow-root", "status"], {
|
||||
...cleanProcessEnv(),
|
||||
PATH: `${fakeDocker.binDir}:${process.env["PATH"] ?? ""}`,
|
||||
PI_WEB_DOCKER_DEV_REPO_ROOT: devRoot,
|
||||
});
|
||||
|
||||
expect(await readFile(helperLog, "utf8")).toBe("allow=1 args=ps\n");
|
||||
});
|
||||
|
||||
it("starts development detached helpers as the generated dev user", async () => {
|
||||
const devRoot = await createDevGeneratedEnv({ uid: 1234, gid: 2345, dockerGid: 3456 });
|
||||
const fakeDocker = await installFakeDocker();
|
||||
await installFakeId(fakeDocker.binDir, 1234, 2345);
|
||||
|
||||
await runDockerCommand(["--dev", "restart-sessiond"], devRuntimeEnv(fakeDocker, devRoot));
|
||||
|
||||
const log = await readFile(fakeDocker.logPath, "utf8");
|
||||
expect(log).toContain(`--env-file ${devRoot}/.pi-web/docker-compose-dev.generated.env`);
|
||||
expect(log).toContain("--group-add 3456");
|
||||
expect(log).toContain("--user 1234:2345");
|
||||
expect(log).toContain("PI_WEB_DOCKER_MODE=dev");
|
||||
expect(log).toContain("PI_WEB_DOCKER_ALLOW_ROOT=0");
|
||||
expect(log).toContain("PI_WEB_DOCKER_HELPER_IMAGE=pi-web:test");
|
||||
expect(log).toContain(`PI_WEB_DOCKER_DEV_REPO_ROOT=${devRoot}`);
|
||||
expect(log).toContain("COMPOSE_PROJECT_NAME=pi-web-dev-test");
|
||||
expect(log).toContain("pi-web.docker-helper.mode=dev");
|
||||
expect(log).toContain("pi-web:test pi-web-docker --dev __run-detached restart-sessiond");
|
||||
expect(log).not.toContain("--user 0:0");
|
||||
});
|
||||
|
||||
it("rejects inside-container commands whose explicit mode does not match the container mode", async () => {
|
||||
const result = await runDockerCommandAllowFailure(["restart-sessiond"], {
|
||||
...cleanProcessEnv(),
|
||||
PI_WEB_DOCKER_RUNTIME: "1",
|
||||
PI_WEB_DOCKER_MODE: "dev",
|
||||
});
|
||||
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(result.stderr).toContain("this PI WEB Docker container is in dev mode");
|
||||
});
|
||||
|
||||
it("routes production install to the bootstrap installer", async () => {
|
||||
const result = await runDockerCommand(["install", "--help"], cleanProcessEnv());
|
||||
|
||||
expect(result.stdout).toContain("Usage: docker/install.sh [options]");
|
||||
});
|
||||
|
||||
it("starts restart-sessiond in a detached Docker helper", async () => {
|
||||
const installDir = await createRuntimeInstall();
|
||||
const fakeDocker = await installFakeDocker();
|
||||
|
||||
const result = await runDockerCommand(["restart-sessiond"], runtimeEnv(fakeDocker, installDir));
|
||||
|
||||
expect(result.stdout).toContain("Started detached PI WEB Docker helper");
|
||||
expect(result.stdout).toContain("Follow progress with: docker logs -f pi-web-docker-restart-sessiond-");
|
||||
const log = await readFile(fakeDocker.logPath, "utf8");
|
||||
expect(log).toContain("container inspect");
|
||||
expect(log).toContain("run -d");
|
||||
expect(log).toContain("--env-file");
|
||||
expect(log).toContain(`${installDir}/.env`);
|
||||
expect(log).toContain("--volumes-from");
|
||||
expect(log).toContain("--group-add 3456");
|
||||
expect(log).toContain("--user 1234:2345");
|
||||
expect(log).toContain(`PI_WEB_DOCKER_INSTALL_DIR=${installDir}`);
|
||||
expect(log).toContain(`PI_WEB_DOCKER_DATA_DIR=${join(installDir, "data")}`);
|
||||
expect(log).toContain("PI_WEB_PORT=12345");
|
||||
expect(log).toContain("PI_WEB_DOCKER_EXTRA_HOST_PATHS=/srv/pi-web-extra /opt/pi-web-extra");
|
||||
expect(log).toContain("PI_WEB_EXTRA_ZYPPER_PACKAGES=git-lfs jq");
|
||||
expect(log).not.toContain('PI_WEB_EXTRA_ZYPPER_PACKAGES="git-lfs jq"');
|
||||
expect(log).toContain("PI_WEB_DOCKER_HELPER_IMAGE=pi-web:test");
|
||||
expect(log).toContain("COMPOSE_PROJECT_NAME=pi-web-test");
|
||||
expect(log).toContain("pi-web.docker-helper.mode=runtime");
|
||||
expect(log).toContain("pi-web.docker-helper.root=");
|
||||
expect(log).toContain("pi-web.docker-helper.project=pi-web-test");
|
||||
expect(log).toContain("pi-web:test pi-web-docker __run-detached restart-sessiond");
|
||||
expect(log).not.toContain("--user 0:0");
|
||||
expect(log).not.toContain("compose -f compose.yml -f compose.override.yml restart sessiond");
|
||||
});
|
||||
|
||||
it("executes the detached restart-sessiond action through Compose", async () => {
|
||||
const installDir = await createRuntimeInstall();
|
||||
const fakeDocker = await installFakeDocker();
|
||||
|
||||
await runDockerCommand(["__run-detached", "restart-sessiond"], runtimeEnv(fakeDocker, installDir));
|
||||
|
||||
const log = await readFile(fakeDocker.logPath, "utf8");
|
||||
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml restart sessiond");
|
||||
expect(log).not.toContain("run -d");
|
||||
});
|
||||
|
||||
it("executes the detached runtime update action through Compose without nesting helpers", async () => {
|
||||
const installDir = await createRuntimeInstall();
|
||||
const fakeDocker = await installFakeDocker();
|
||||
|
||||
await runDockerCommand(["__run-detached", "update"], runtimeEnv(fakeDocker, installDir));
|
||||
|
||||
const log = await readFile(fakeDocker.logPath, "utf8");
|
||||
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml build --pull --no-cache");
|
||||
expect(log).toContain("compose --project-name pi-web-test --env-file .env -f compose.yml -f compose.override.yml up -d --force-recreate --remove-orphans");
|
||||
expect(log).not.toContain("run -d");
|
||||
});
|
||||
});
|
||||
|
||||
async function readRepoFile(relativePath: string): Promise<string> {
|
||||
return await readFile(join(repoRoot, relativePath), "utf8");
|
||||
}
|
||||
|
||||
function runDockerCommand(args: string[], env: NodeJS.ProcessEnv): Promise<CommandResult> {
|
||||
return execUtf8("sh", [dockerEntrypoint, ...args], env);
|
||||
}
|
||||
|
||||
function runDockerCommandAllowFailure(args: string[], env: NodeJS.ProcessEnv): Promise<CommandResult & { exitCode: number }> {
|
||||
return execUtf8AllowFailure("sh", [dockerEntrypoint, ...args], env);
|
||||
}
|
||||
|
||||
function execUtf8(file: string, args: string[], env: NodeJS.ProcessEnv): Promise<CommandResult> {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
execFile(file, args, { encoding: "utf8", env }, (error, stdout, stderr) => {
|
||||
if (error !== null) {
|
||||
reject(error instanceof Error ? error : new Error("Process failed"));
|
||||
return;
|
||||
}
|
||||
resolvePromise({ stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function execUtf8AllowFailure(file: string, args: string[], env: NodeJS.ProcessEnv): Promise<CommandResult & { exitCode: number }> {
|
||||
return new Promise((resolvePromise) => {
|
||||
execFile(file, args, { encoding: "utf8", env }, (error, stdout, stderr) => {
|
||||
const exitCode = typeof error === "object" && error !== null && "code" in error && typeof error.code === "number" ? error.code : 0;
|
||||
resolvePromise({ stdout, stderr, exitCode });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function createRuntimeInstall(): Promise<string> {
|
||||
const installDir = join(tempDir, "runtime");
|
||||
await mkdir(installDir, { recursive: true });
|
||||
await writeFile(join(installDir, ".env"), [
|
||||
"PI_WEB_UID=1234",
|
||||
"PI_WEB_GID=2345",
|
||||
"DOCKER_GID=3456",
|
||||
`PI_WEB_DOCKER_DATA_DIR=${join(installDir, "data")}`,
|
||||
`PI_WEB_DOCKER_INSTALL_DIR=${installDir}`,
|
||||
"PI_WEB_DOCKER_EXTRA_HOST_PATHS=\"/srv/pi-web-extra /opt/pi-web-extra\"",
|
||||
"PI_WEB_BIND_ADDR=127.0.0.1",
|
||||
"PI_WEB_PORT=12345",
|
||||
"PI_WEB_EXTRA_ZYPPER_PACKAGES=\"git-lfs jq\"",
|
||||
"PI_WEB_IMAGE=pi-web:test",
|
||||
"COMPOSE_PROJECT_NAME=pi-web-test",
|
||||
"",
|
||||
].join("\n"), "utf8");
|
||||
await writeFile(join(installDir, "compose.yml"), "name: pi-web\nservices: {}\n", "utf8");
|
||||
await writeFile(join(installDir, "compose.override.yml"), "services: {}\n", "utf8");
|
||||
return installDir;
|
||||
}
|
||||
|
||||
async function createDevRepoFixture(): Promise<string> {
|
||||
const devRoot = join(tempDir, "dev-repo");
|
||||
await mkdir(join(devRoot, "docker", "internal", "dev"), { recursive: true });
|
||||
await copyFile(join(repoRoot, "docker", "internal", "dev", "compose"), join(devRoot, "docker", "internal", "dev", "compose"));
|
||||
await chmod(join(devRoot, "docker", "internal", "dev", "compose"), 0o755);
|
||||
await copyFile(join(repoRoot, "docker", "internal", "host-profile.sh"), join(devRoot, "docker", "internal", "host-profile.sh"));
|
||||
await writeFile(join(devRoot, "docker", "compose.dev.yml"), "name: pi-web-dev\nservices: {}\n", "utf8");
|
||||
return devRoot;
|
||||
}
|
||||
|
||||
async function createDevRepoFixtureWithFakeHelper(logPath: string): Promise<string> {
|
||||
const devRoot = join(tempDir, "dev-repo-fake-helper");
|
||||
const helperPath = join(devRoot, "docker", "internal", "dev", "compose");
|
||||
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)}
|
||||
`, "utf8");
|
||||
await chmod(helperPath, 0o755);
|
||||
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 });
|
||||
await writeFile(join(devRoot, ".pi-web", "docker-compose-dev.generated.env"), [
|
||||
`PI_WEB_UID=${String(ids.uid)}`,
|
||||
`PI_WEB_GID=${String(ids.gid)}`,
|
||||
`DOCKER_GID=${String(ids.dockerGid)}`,
|
||||
`PI_WEB_DOCKER_DATA_DIR=${join(tempDir, "dev-data")}`,
|
||||
`PI_WEB_DOCKER_DEV_REPO_ROOT=${devRoot}`,
|
||||
"PI_WEB_DEV_API_BIND_ADDR=127.0.0.1",
|
||||
"PI_WEB_DEV_BIND_ADDR=127.0.0.1",
|
||||
"PI_WEB_DEV_API_PORT=8504",
|
||||
"PI_WEB_DEV_PORT=8505",
|
||||
"PI_WEB_DEV_IMAGE=pi-web:test",
|
||||
"COMPOSE_PROJECT_NAME=pi-web-dev-test",
|
||||
"",
|
||||
].join("\n"), "utf8");
|
||||
return devRoot;
|
||||
}
|
||||
|
||||
async function installFakeDocker(): Promise<FakeDocker> {
|
||||
const binDir = join(tempDir, "bin");
|
||||
const logPath = join(tempDir, "docker.log");
|
||||
const dockerPath = join(binDir, "docker");
|
||||
await mkdir(binDir, { recursive: true });
|
||||
await writeFile(dockerPath, `#!/usr/bin/env sh
|
||||
set -eu
|
||||
: "\${FAKE_DOCKER_LOG:?}"
|
||||
printf '%s\n' "$*" >>"$FAKE_DOCKER_LOG"
|
||||
case "\${1:-}" in
|
||||
--version)
|
||||
printf 'Docker version 99.0.0, fake\n'
|
||||
exit 0
|
||||
;;
|
||||
context)
|
||||
case "\${2:-}" in
|
||||
show)
|
||||
printf 'default\n'
|
||||
exit 0
|
||||
;;
|
||||
inspect)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
info)
|
||||
if [ "\${2:-}" = --format ]; then
|
||||
printf 'Docker Desktop\n'
|
||||
else
|
||||
printf 'Fake Docker info\n'
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
compose)
|
||||
if [ "\${2:-}" = version ]; then
|
||||
exit 0
|
||||
fi
|
||||
printf 'fake docker'
|
||||
for arg in "$@"; do
|
||||
printf ' %s' "$arg"
|
||||
done
|
||||
printf '\n'
|
||||
exit 0
|
||||
;;
|
||||
container)
|
||||
if [ "\${2:-}" = inspect ]; then
|
||||
for arg in "$@"; do
|
||||
if [ "$arg" = --format ]; then
|
||||
printf 'pi-web:test\n'
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
printf '{}\n'
|
||||
exit 0
|
||||
fi
|
||||
;;
|
||||
ps|rm)
|
||||
exit 0
|
||||
;;
|
||||
run)
|
||||
printf 'fake-helper-container-id\n'
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
printf 'unexpected fake docker args: %s\n' "$*" >&2
|
||||
exit 9
|
||||
`, "utf8");
|
||||
await chmod(dockerPath, 0o755);
|
||||
return { binDir, logPath };
|
||||
}
|
||||
|
||||
async function installFakeUname(binDir: string, osName: string): Promise<void> {
|
||||
const unamePath = join(binDir, "uname");
|
||||
await writeFile(unamePath, `#!/usr/bin/env sh
|
||||
set -eu
|
||||
printf '%s\n' ${shellSingleQuote(osName)}
|
||||
`, "utf8");
|
||||
await chmod(unamePath, 0o755);
|
||||
}
|
||||
|
||||
async function installFakeId(binDir: string, uid: number, gid: number): Promise<void> {
|
||||
const idPath = join(binDir, "id");
|
||||
await writeFile(idPath, `#!/usr/bin/env sh
|
||||
set -eu
|
||||
case "\${1:-}" in
|
||||
-u) printf '%s\n' ${String(uid)} ;;
|
||||
-g) printf '%s\n' ${String(gid)} ;;
|
||||
*) printf '%s\n' ${String(uid)} ;;
|
||||
esac
|
||||
`, "utf8");
|
||||
await chmod(idPath, 0o755);
|
||||
}
|
||||
|
||||
async function withUnixSocket<T>(socketPath: string, callback: () => Promise<T>): Promise<T> {
|
||||
await mkdir(dirname(socketPath), { recursive: true });
|
||||
const server = createServer();
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(socketPath, resolvePromise);
|
||||
});
|
||||
try {
|
||||
return await callback();
|
||||
} finally {
|
||||
await new Promise<void>((resolvePromise) => {
|
||||
server.close(() => {
|
||||
resolvePromise();
|
||||
});
|
||||
});
|
||||
await rm(socketPath, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function cleanProcessEnv(): NodeJS.ProcessEnv {
|
||||
const env = { ...process.env };
|
||||
for (const key of Object.keys(env)) {
|
||||
if (key === "COMPOSE_PROJECT_NAME" || key === "DOCKER_GID" || key === "HOSTEXEC_IMAGE" || key.startsWith("PI_WEB_")) {
|
||||
Reflect.deleteProperty(env, key);
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function runtimeEnv(fakeDocker: FakeDocker, installDir: string): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...runtimeHostEnv(fakeDocker, installDir),
|
||||
PI_WEB_DOCKER_RUNTIME: "1",
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeHostEnv(fakeDocker: FakeDocker, installDir: string): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...cleanProcessEnv(),
|
||||
PATH: `${fakeDocker.binDir}:${process.env["PATH"] ?? ""}`,
|
||||
FAKE_DOCKER_LOG: fakeDocker.logPath,
|
||||
PI_WEB_DOCKER_RUNTIME: "0",
|
||||
PI_WEB_DOCKER_MODE: "runtime",
|
||||
PI_WEB_DOCKER_INSTALL_DIR: installDir,
|
||||
};
|
||||
}
|
||||
|
||||
function devHostEnv(fakeDocker: FakeDocker, devRoot: string, home: string, extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...cleanProcessEnv(),
|
||||
...extra,
|
||||
PATH: `${fakeDocker.binDir}:${process.env["PATH"] ?? ""}`,
|
||||
HOME: home,
|
||||
DOCKER_HOST: "",
|
||||
FAKE_DOCKER_LOG: fakeDocker.logPath,
|
||||
PI_WEB_DOCKER_RUNTIME: "0",
|
||||
PI_WEB_DOCKER_MODE: "dev",
|
||||
PI_WEB_DOCKER_DEV_REPO_ROOT: devRoot,
|
||||
};
|
||||
}
|
||||
|
||||
function devRuntimeEnv(fakeDocker: FakeDocker, devRoot: string): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...cleanProcessEnv(),
|
||||
PATH: `${fakeDocker.binDir}:${process.env["PATH"] ?? ""}`,
|
||||
FAKE_DOCKER_LOG: fakeDocker.logPath,
|
||||
PI_WEB_DOCKER_RUNTIME: "1",
|
||||
PI_WEB_DOCKER_MODE: "dev",
|
||||
PI_WEB_DOCKER_DEV_REPO_ROOT: devRoot,
|
||||
PI_WEB_DOCKER_CONTAINER_ID: "current-container",
|
||||
};
|
||||
}
|
||||
|
||||
function shellSingleQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -8,10 +8,20 @@ import type { PiWebComponentStatus } from "../shared/apiTypes.js";
|
||||
|
||||
const originalSkipVersionCheck = process.env["PI_WEB_SKIP_VERSION_CHECK"];
|
||||
const originalHome = process.env["HOME"];
|
||||
const originalPath = process.env["PATH"];
|
||||
const originalDockerRuntime = process.env["PI_WEB_DOCKER_RUNTIME"];
|
||||
const originalDockerMode = process.env["PI_WEB_DOCKER_MODE"];
|
||||
const originalDockerInstallDir = process.env["PI_WEB_DOCKER_INSTALL_DIR"];
|
||||
const originalDockerDevRepoRoot = process.env["PI_WEB_DOCKER_DEV_REPO_ROOT"];
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv("PI_WEB_SKIP_VERSION_CHECK", originalSkipVersionCheck);
|
||||
restoreEnv("HOME", originalHome);
|
||||
restoreEnv("PATH", originalPath);
|
||||
restoreEnv("PI_WEB_DOCKER_RUNTIME", originalDockerRuntime);
|
||||
restoreEnv("PI_WEB_DOCKER_MODE", originalDockerMode);
|
||||
restoreEnv("PI_WEB_DOCKER_INSTALL_DIR", originalDockerInstallDir);
|
||||
restoreEnv("PI_WEB_DOCKER_DEV_REPO_ROOT", originalDockerDevRepoRoot);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -42,6 +52,7 @@ describe("PI WEB status", () => {
|
||||
|
||||
it("reports stale session daemon versions as messages", async () => {
|
||||
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
|
||||
disableDockerRuntimeEnv();
|
||||
const daemon = daemonWithComponent({
|
||||
component: "sessiond",
|
||||
label: "Session daemon",
|
||||
@@ -63,9 +74,13 @@ describe("PI WEB status", () => {
|
||||
it("suggests native systemd commands for local development services", async () => {
|
||||
if (process.platform !== "linux") return;
|
||||
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
|
||||
disableDockerRuntimeEnv();
|
||||
const home = await tempHome();
|
||||
const binDir = await tempHome();
|
||||
try {
|
||||
process.env["HOME"] = home;
|
||||
await installExecutable(binDir, "systemctl");
|
||||
process.env["PATH"] = `${binDir}:${process.env["PATH"] ?? ""}`;
|
||||
await installSystemdServiceFiles(home, ["pi-web-sessiond.service", "pi-web-ui-dev.service"]);
|
||||
const daemon = daemonWithComponent(staleLocalSessiond());
|
||||
|
||||
@@ -76,12 +91,72 @@ describe("PI WEB status", () => {
|
||||
expect(status.commands.restartSessiond).toBe("systemd-run --user --collect --unit=pi-web-restart-sessiond -- systemctl --user restart pi-web-sessiond.service");
|
||||
expect(status.messages.find((message) => message.id === "sessiond-stale")?.command).toBe("systemd-run --user --collect --unit=pi-web-restart-sessiond -- systemctl --user restart pi-web-sessiond.service");
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
await Promise.all([
|
||||
rm(home, { recursive: true, force: true }),
|
||||
rm(binDir, { recursive: true, force: true }),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("suggests Docker commands when running inside the Docker runtime", async () => {
|
||||
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
|
||||
process.env["PI_WEB_DOCKER_RUNTIME"] = "1";
|
||||
process.env["PI_WEB_DOCKER_MODE"] = "runtime";
|
||||
process.env["PI_WEB_DOCKER_INSTALL_DIR"] = "/srv/pi-web-docker";
|
||||
process.env["PATH"] = "";
|
||||
const daemon = daemonWithComponent({ ...staleLocalSessiond(), installation: { kind: "docker", path: "/srv/pi-web-docker", dockerMode: "runtime" } });
|
||||
|
||||
const status = await getPiWebStatus(daemon);
|
||||
|
||||
expect(status.components.web.installation).toEqual({ kind: "docker", path: "/srv/pi-web-docker", dockerMode: "runtime" });
|
||||
expect(status.commands).toEqual({
|
||||
update: "pi-web-docker update",
|
||||
restart: "pi-web-docker restart",
|
||||
restartWeb: "pi-web-docker restart-web",
|
||||
restartSessiond: "pi-web-docker restart-sessiond",
|
||||
status: "pi-web-docker status",
|
||||
});
|
||||
expect(JSON.stringify(status)).not.toContain("npm install -g");
|
||||
expect(JSON.stringify(status)).not.toContain("pi-web restart");
|
||||
});
|
||||
|
||||
it("suggests explicit Docker development commands when running inside the Docker dev runtime", async () => {
|
||||
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
|
||||
process.env["PI_WEB_DOCKER_RUNTIME"] = "1";
|
||||
process.env["PI_WEB_DOCKER_MODE"] = "dev";
|
||||
process.env["PI_WEB_DOCKER_DEV_REPO_ROOT"] = "/workspace/pi-web";
|
||||
process.env["PATH"] = "";
|
||||
const daemon = daemonWithComponent({ ...staleLocalSessiond(), installation: { kind: "docker", path: "/workspace/pi-web", dockerMode: "dev" } });
|
||||
|
||||
const status = await getPiWebStatus(daemon);
|
||||
|
||||
expect(status.commands).toEqual({
|
||||
update: "pi-web-docker --dev update",
|
||||
restart: "pi-web-docker --dev restart",
|
||||
restartWeb: "pi-web-docker --dev restart-web",
|
||||
restartSessiond: "pi-web-docker --dev restart-sessiond",
|
||||
status: "pi-web-docker --dev status",
|
||||
});
|
||||
});
|
||||
|
||||
it("infers explicit Docker development commands from the generated dev root when mode is omitted", async () => {
|
||||
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
|
||||
process.env["PI_WEB_DOCKER_RUNTIME"] = "1";
|
||||
Reflect.deleteProperty(process.env, "PI_WEB_DOCKER_MODE");
|
||||
process.env["PI_WEB_DOCKER_DEV_REPO_ROOT"] = "/workspace/pi-web";
|
||||
process.env["PATH"] = "";
|
||||
const daemon = daemonWithComponent(staleLocalSessiond());
|
||||
|
||||
const status = await getPiWebStatus(daemon);
|
||||
|
||||
expect(status.components.web.installation).toEqual({ kind: "docker", path: "/workspace/pi-web", dockerMode: "dev" });
|
||||
expect(status.commands.update).toBe("pi-web-docker --dev update");
|
||||
expect(status.commands.status).toBe("pi-web-docker --dev status");
|
||||
});
|
||||
|
||||
it("omits local restart commands when no native service command is known", async () => {
|
||||
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
|
||||
disableDockerRuntimeEnv();
|
||||
const home = await tempHome();
|
||||
try {
|
||||
process.env["HOME"] = home;
|
||||
@@ -131,6 +206,19 @@ async function installSystemdServiceFiles(home: string, names: string[]): Promis
|
||||
await Promise.all(names.map((name) => writeFile(join(dir, name), "")));
|
||||
}
|
||||
|
||||
async function installExecutable(dir: string, name: string): Promise<void> {
|
||||
const path = join(dir, name);
|
||||
await writeFile(path, "#!/usr/bin/env sh\nexit 0\n");
|
||||
await chmod(path, 0o755);
|
||||
}
|
||||
|
||||
function disableDockerRuntimeEnv(): void {
|
||||
process.env["PI_WEB_DOCKER_RUNTIME"] = "0";
|
||||
Reflect.deleteProperty(process.env, "PI_WEB_DOCKER_MODE");
|
||||
Reflect.deleteProperty(process.env, "PI_WEB_DOCKER_INSTALL_DIR");
|
||||
Reflect.deleteProperty(process.env, "PI_WEB_DOCKER_DEV_REPO_ROOT");
|
||||
}
|
||||
|
||||
function restoreEnv(key: string, value: string | undefined): void {
|
||||
if (value === undefined) Reflect.deleteProperty(process.env, key);
|
||||
else process.env[key] = value;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
|
||||
import type { PiWebCapability, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebVersionResponse } from "../shared/apiTypes.js";
|
||||
import { effectivePiWebCapabilities, WEB_RUNTIME_CAPABILITIES } from "../shared/capabilities.js";
|
||||
import { piWebDockerCommand } from "../docker/piWebDockerCommandPlan.js";
|
||||
import { parsePiWebComponentStatus, parsePiWebRuntimeComponent } from "../shared/piWebStatusParsing.js";
|
||||
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
|
||||
|
||||
@@ -188,6 +189,8 @@ function parsePackageInfo(value: unknown, path: string): PackageInfo | undefined
|
||||
}
|
||||
|
||||
async function detectPiWebInstallation(): Promise<PiWebInstallationInfo> {
|
||||
const docker = detectDockerInstallation();
|
||||
if (docker !== undefined) return docker;
|
||||
const root = packageRootPath();
|
||||
const realRoot = await realPathOrSelf(root);
|
||||
const piPackage = await detectPiPackageInstallation(realRoot, root);
|
||||
@@ -197,6 +200,46 @@ async function detectPiWebInstallation(): Promise<PiWebInstallationInfo> {
|
||||
return { kind: "local", path: root };
|
||||
}
|
||||
|
||||
function detectDockerInstallation(): PiWebInstallationInfo | undefined {
|
||||
if (!isTruthyEnv("PI_WEB_DOCKER_RUNTIME")) return undefined;
|
||||
const dockerMode = dockerModeFromEnv(process.env["PI_WEB_DOCKER_MODE"]) ?? inferredDockerModeFromRoots();
|
||||
const path = dockerRootPathFromEnv(dockerMode);
|
||||
return {
|
||||
kind: "docker",
|
||||
...(path === undefined ? {} : { path }),
|
||||
...(dockerMode === undefined ? {} : { dockerMode }),
|
||||
};
|
||||
}
|
||||
|
||||
function dockerModeFromEnv(value: string | undefined): PiWebInstallationInfo["dockerMode"] | undefined {
|
||||
return value === "runtime" || value === "dev" ? value : undefined;
|
||||
}
|
||||
|
||||
function inferredDockerModeFromRoots(): PiWebInstallationInfo["dockerMode"] | undefined {
|
||||
if (firstNonEmptyEnv("PI_WEB_DOCKER_DEV_REPO_ROOT") !== undefined) return "dev";
|
||||
if (firstNonEmptyEnv("PI_WEB_DOCKER_INSTALL_DIR") !== undefined) return "runtime";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function dockerRootPathFromEnv(mode: PiWebInstallationInfo["dockerMode"] | undefined): string | undefined {
|
||||
if (mode === "dev") return firstNonEmptyEnv("PI_WEB_DOCKER_DEV_REPO_ROOT", "PI_WEB_DOCKER_INSTALL_DIR");
|
||||
if (mode === "runtime") return firstNonEmptyEnv("PI_WEB_DOCKER_INSTALL_DIR", "PI_WEB_DOCKER_DEV_REPO_ROOT");
|
||||
return firstNonEmptyEnv("PI_WEB_DOCKER_INSTALL_DIR", "PI_WEB_DOCKER_DEV_REPO_ROOT");
|
||||
}
|
||||
|
||||
function firstNonEmptyEnv(...keys: string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = process.env[key];
|
||||
if (value !== undefined && value !== "") return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isTruthyEnv(key: string): boolean {
|
||||
const value = process.env[key];
|
||||
return value !== undefined && value !== "" && value !== "0" && value.toLowerCase() !== "false";
|
||||
}
|
||||
|
||||
async function detectPiPackageInstallation(realRoot: string, displayPath: string): Promise<PiWebInstallationInfo | undefined> {
|
||||
try {
|
||||
const agentDir = getAgentDir();
|
||||
@@ -377,6 +420,8 @@ async function fetchLatestNpmVersion(currentVersion: string): Promise<string> {
|
||||
|
||||
async function commandsFor(components: PiWebStatusResponse["components"]): Promise<PiWebStatusResponse["commands"]> {
|
||||
const installation = preferredInstallation(components);
|
||||
if (installation?.kind === "docker") return dockerCommands(installation);
|
||||
|
||||
const [serviceCommands, cliCommands] = await Promise.all([
|
||||
nativeServiceCommands(),
|
||||
piWebCliCommands(installation),
|
||||
@@ -399,10 +444,21 @@ async function commandsFor(components: PiWebStatusResponse["components"]): Promi
|
||||
function preferredInstallation(components: PiWebStatusResponse["components"]): PiWebInstallationInfo | undefined {
|
||||
const web = components.web.installation;
|
||||
const sessiond = components.sessiond.installation;
|
||||
if (web?.kind === "docker" || sessiond?.kind === "docker") return web?.kind === "docker" ? web : sessiond;
|
||||
if (web?.kind === "local" || sessiond?.kind === "local") return web?.kind === "local" ? web : sessiond;
|
||||
return web ?? sessiond;
|
||||
}
|
||||
|
||||
function dockerCommands(installation: PiWebInstallationInfo): PiWebStatusResponse["commands"] {
|
||||
return {
|
||||
update: piWebDockerCommand(installation.dockerMode, "update"),
|
||||
restart: piWebDockerCommand(installation.dockerMode, "restart"),
|
||||
restartWeb: piWebDockerCommand(installation.dockerMode, "restart-web"),
|
||||
restartSessiond: piWebDockerCommand(installation.dockerMode, "restart-sessiond"),
|
||||
status: piWebDockerCommand(installation.dockerMode, "status"),
|
||||
};
|
||||
}
|
||||
|
||||
async function piWebCliCommands(installation: PiWebInstallationInfo | undefined): Promise<NativeServiceCommands> {
|
||||
if (installation?.kind !== "npm-global" || !(await hasCommand("pi-web"))) return {};
|
||||
return { restart: "pi-web restart", status: "pi-web status" };
|
||||
|
||||
Reference in New Issue
Block a user