From c88a38f88b8e006fff2adfb65f5e0ff5f79cf59b Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Tue, 20 Oct 2026 00:37:34 +0300 Subject: [PATCH] V21: Let the Docker image start on a fresh named volume Create and chown /app/data before USER node and declare it a VOLUME. Co-Authored-By: Claude Opus 5.5 --- docs/deployment.md | 14 +++++++- llms-full.txt | 14 +++++++- src/deploy/adapters/docker.docker.test.ts | 40 +++++++++++++++++++++++ src/deploy/adapters/docker.test.ts | 8 +++++ src/deploy/adapters/docker.ts | 7 ++++ 5 files changed, 81 insertions(+), 2 deletions(-) diff --git a/docs/deployment.md b/docs/deployment.md index 488be1af..c879f09b 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -214,7 +214,7 @@ directory's `auth.ts`, with `LOUSHO_API_TOKEN` appended to it: see | Value | Store | | ----- | ----- | | `memory` (default) | `memoryStore()`: gone when the process restarts, and not shared between instances. | -| `sqlite:` | A [`SqliteStore`](./sessions.md) file (created with its directory): sessions survive restarts. Needs Node 22 (`node:sqlite`); the `docker` image uses `node:22-slim`. Mount the file's directory as a volume. | +| `sqlite:` | A [`SqliteStore`](./sessions.md) file (created with its directory): sessions survive restarts. Needs Node 22 (`node:sqlite`); the `docker` image uses `node:22-slim`. Mount the file's directory as a volume (the `docker` image owns `/app/data` for this). | SQLite is one file on one disk, so run a single instance per database file. @@ -312,6 +312,18 @@ docker build -t lousho-agent . docker run -p 3000:3000 -e OPENAI_API_KEY=... lousho-agent ``` +The image creates `/app/data`, owned by `node`, and declares it a `VOLUME`. Docker +copies that ownership into a fresh named volume, so the unprivileged server can write +there without a manual `chown`. Keep sessions across restarts by mounting a volume and +pointing the store at it: + +```bash +docker run -p 3000:3000 -e OPENAI_API_KEY=... -v lousho-data:/app/data -e LOUSHO_STORE=sqlite:/app/data/lousho.db lousho-agent +``` + +A bind mount (`-v ./data:/app/data`) keeps the host directory's own ownership, so +`chown` it to uid 1000 first. + ## `cloudflare-worker` Generates a module Worker (`export default { fetch }`), a `wrangler.toml` and `dist/worker.js`, bundled as a diff --git a/llms-full.txt b/llms-full.txt index a5376a5a..3b98e71f 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -7265,7 +7265,7 @@ directory's `auth.ts`, with `LOUSHO_API_TOKEN` appended to it: see | Value | Store | | ----- | ----- | | `memory` (default) | `memoryStore()`: gone when the process restarts, and not shared between instances. | -| `sqlite:` | A [`SqliteStore`](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/sessions.md) file (created with its directory): sessions survive restarts. Needs Node 22 (`node:sqlite`); the `docker` image uses `node:22-slim`. Mount the file's directory as a volume. | +| `sqlite:` | A [`SqliteStore`](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/sessions.md) file (created with its directory): sessions survive restarts. Needs Node 22 (`node:sqlite`); the `docker` image uses `node:22-slim`. Mount the file's directory as a volume (the `docker` image owns `/app/data` for this). | SQLite is one file on one disk, so run a single instance per database file. @@ -7363,6 +7363,18 @@ docker build -t lousho-agent . docker run -p 3000:3000 -e OPENAI_API_KEY=... lousho-agent ``` +The image creates `/app/data`, owned by `node`, and declares it a `VOLUME`. Docker +copies that ownership into a fresh named volume, so the unprivileged server can write +there without a manual `chown`. Keep sessions across restarts by mounting a volume and +pointing the store at it: + +```bash +docker run -p 3000:3000 -e OPENAI_API_KEY=... -v lousho-data:/app/data -e LOUSHO_STORE=sqlite:/app/data/lousho.db lousho-agent +``` + +A bind mount (`-v ./data:/app/data`) keeps the host directory's own ownership, so +`chown` it to uid 1000 first. + ## `cloudflare-worker` Generates a module Worker (`export default { fetch }`), a `wrangler.toml` and `dist/worker.js`, bundled as a diff --git a/src/deploy/adapters/docker.docker.test.ts b/src/deploy/adapters/docker.docker.test.ts index 8d42e6d9..eed81e8e 100644 --- a/src/deploy/adapters/docker.docker.test.ts +++ b/src/deploy/adapters/docker.docker.test.ts @@ -86,4 +86,44 @@ describe.skipIf(!hasDocker)('DockerAdapter image (real Docker daemon)', () => { dockerOutput(['rmi', '-f', tag]); } }, 300_000); + + it('starts on a fresh named volume with LOUSHO_STORE=sqlite: and persists a session', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lousho-docker-vol-')); + const outDir = path.join(dir, 'out'); + await DockerAdapter.scaffold(writeSpec(dir), outDir); + await withBuildLock(() => DockerAdapter.build(outDir)); + + const stamp = Date.now(); + const tag = `lousho-agent-vol-test-${stamp}`; + const volume = `lousho-vol-test-${stamp}`; + execFileSync('docker', ['build', '-t', tag, '.'], { cwd: outDir, stdio: 'inherit' }); + execFileSync('docker', ['volume', 'create', volume]); + const containerId = execFileSync( + 'docker', + ['run', '-d', '-p', '127.0.0.1::3000', '-v', `${volume}:/app/data`, '-e', 'LOUSHO_STORE=sqlite:/app/data/lousho.db', tag], + { encoding: 'utf8' } + ).trim(); + try { + const mapping = execFileSync('docker', ['port', containerId, '3000'], { encoding: 'utf8' }); + const base = `http://127.0.0.1:${Number(mapping.trim().split('\n')[0].split(':').pop())}`; + const health = await poll(`${base}/health`, 60); + if (health?.status !== 200) console.error(`docker logs: +${dockerOutput(['logs', containerId])}`); + expect(health?.status).toBe(200); + + const chat = await fetch(`${base}/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: 'hello volume', sessionId: 'vol-1' }), + }); + expect(chat.status).toBe(200); + // The database file landed on the volume, written by the unprivileged user. + const ls = execFileSync('docker', ['exec', containerId, 'ls', '-l', '/app/data'], { encoding: 'utf8' }); + expect(ls).toMatch(/lousho\.db/); + } finally { + dockerOutput(['rm', '-f', containerId]); + dockerOutput(['rmi', '-f', tag]); + dockerOutput(['volume', 'rm', '-f', volume]); + } + }, 300_000); }); diff --git a/src/deploy/adapters/docker.test.ts b/src/deploy/adapters/docker.test.ts index 3fbebd56..cb5dab5a 100644 --- a/src/deploy/adapters/docker.test.ts +++ b/src/deploy/adapters/docker.test.ts @@ -75,6 +75,14 @@ describe('DockerAdapter', () => { expect(lines).toContain('ENV HOST=0.0.0.0'); }); + it('creates and chowns /app/data before dropping to the node user, so a fresh named volume is writable', () => { + const lines = DOCKERFILE.split('\n'); + const mkdir = lines.findIndex((l) => /^RUN mkdir -p \/app\/data && chown node:node \/app\/data$/.test(l)); + expect(mkdir).toBeGreaterThan(-1); + expect(mkdir).toBeLessThan(lines.indexOf('USER node')); + expect(lines).toContain('VOLUME /app/data'); + }); + it('writes a package.json shipping the SDK and the peers the spec needs', async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lousho-docker-')); const specPath = path.join(dir, 'agent.yaml'); diff --git a/src/deploy/adapters/docker.ts b/src/deploy/adapters/docker.ts index 194b50b4..15a29389 100644 --- a/src/deploy/adapters/docker.ts +++ b/src/deploy/adapters/docker.ts @@ -6,6 +6,7 @@ * dist/server.js), and scaffold() additionally writes a Dockerfile (plus a * .dockerignore) that packages dist/ into a node:22-slim image (22 has the node:sqlite that LOUSHO_STORE=sqlite: needs). * describe(): 'docker build -t lousho-agent . && docker run -p 3000:3000 lousho-agent'. + * The image also owns /app/data (declared a VOLUME) for LOUSHO_STORE=sqlite:/app/data/. * * The image sets HOST=0.0.0.0: inside a container, the server's default * 127.0.0.1 binding would be unreachable through `docker run -p`, so the @@ -26,6 +27,12 @@ COPY package.json package-lock.json* ./ RUN if [ -f package-lock.json ]; then npm ci --omit=dev; else npm install --omit=dev; fi && npm cache clean --force COPY dist/ ./dist/ ENV NODE_ENV=production +# Docker copies an image directory's ownership into a fresh named volume, so the +# data directory must exist and belong to node before USER node and VOLUME, or +# \`-v lousho-data:/app/data\` mounts a root-owned dir the server cannot write to. +# Point LOUSHO_STORE at it, e.g. sqlite:/app/data/lousho.db. +RUN mkdir -p /app/data && chown node:node /app/data +VOLUME /app/data # Run as the image's unprivileged node user, not root. USER node # Listen on all interfaces inside the container so \`docker run -p\` can reach it.