From 1dc9f49e774144033eae06c62af9ec7bf083c580 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:17:38 -0400 Subject: [PATCH 1/2] Add a dev Compose file that syncs the working tree into the container --- .dockerignore | 1 + .gitignore | 3 ++ CONTRIBUTING.md | 13 +++++- docker-compose.dev.yml | 82 +++++++++++++++++++++++++++++++++++ test/repo/compose-dev.test.js | 72 ++++++++++++++++++++++++++++++ 5 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 docker-compose.dev.yml create mode 100644 test/repo/compose-dev.test.js diff --git a/.dockerignore b/.dockerignore index f2062304..a4707ecd 100644 --- a/.dockerignore +++ b/.dockerignore @@ -21,3 +21,4 @@ scripts/** docs .dockerignore Dockerfile +.dev diff --git a/.gitignore b/.gitignore index cbad47e7..72180c0a 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ coverage/ # Playwright output. playwright-report/ test-results/ + +# Data for docker-compose.dev.yml. Holds stored secrets. +/.dev/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a48efd4b..f7b20e0c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,18 @@ If a feature seems to need a dependency or a build step, open an issue first. ## Development -The frontend is static; edit files under `ui/` and reload. Unit tests use Node's +Run Stackyard from the working tree with Docker Compose 2.22 or later: + +``` +docker compose -f docker-compose.dev.yml watch +``` + +It serves `http://127.0.0.1:8701`. A saved file reaches the container within a +few seconds. A change under `api/src` or `ui/widgets`, or to a module the API +shares with the frontend, restarts the container. A change to the Dockerfile, +nginx or supervisord config rebuilds the image. Data is kept in `.dev/`. + +Unit tests use Node's built-in runner and need nothing installed; coverage and the browser tests use the dev tooling in the root `package.json`. diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 00000000..335990bf --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,82 @@ +# Development only. Run with: docker compose -f docker-compose.dev.yml watch +# Keep the port on 127.0.0.1. A fresh instance has no password, and any device +# that can reach it gets the admin. +name: stackyard-dev + +services: + stackyard: + build: . + image: stackyard:dev + cap_drop: + - ALL + cap_add: + - NET_BIND_SERVICE + - CHOWN + - SETUID + - SETGID + - DAC_OVERRIDE + - KILL + security_opt: + - no-new-privileges:true + privileged: false + mem_limit: 384m + mem_reservation: 96m + memswap_limit: 384m + cpus: "0.5" + pids_limit: 256 + init: true + ports: + - "127.0.0.1:8701:80" + volumes: + - ./.dev/data:/data + - ./.dev/icons:/icons + environment: + - NODE_OPTIONS=--max-old-space-size=192 + entrypoint: + - /bin/sh + - -c + - sed -i 's/public, max-age=31536000, immutable/no-cache/' /etc/nginx/http.d/dashboard.conf && exec /docker-entrypoint.sh "$$@" + - -- + command: ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/stackyard.conf"] + develop: + watch: + - path: ./ui + target: /usr/share/nginx/html + action: sync + ignore: + - test/ + - widgets/ + - path: ./ui/widgets + target: /usr/share/nginx/html/widgets + action: sync+restart + - path: ./api/src + target: /app/api/src + action: sync+restart + - path: ./ui/js/link-url.js + target: /app/ui/js/link-url.js + action: sync+restart + - path: ./ui/js/limits.js + target: /app/ui/js/limits.js + action: sync+restart + - path: ./ui/js/config-recovery-logic.js + target: /app/ui/js/config-recovery-logic.js + action: sync+restart + - path: ./ui/js/host-names.js + target: /app/ui/js/host-names.js + action: sync+restart + - path: ./ui/js/service-link.js + target: /app/ui/js/service-link.js + action: sync+restart + - path: ./ui/js/saved-row.js + target: /app/ui/js/saved-row.js + action: sync+restart + - path: ./Dockerfile + action: rebuild + - path: ./docker-entrypoint.sh + action: rebuild + - path: ./supervisord.conf + action: rebuild + - path: ./nginx + action: rebuild + - path: ./api/package.json + action: rebuild diff --git a/test/repo/compose-dev.test.js b/test/repo/compose-dev.test.js new file mode 100644 index 00000000..ddf5392c --- /dev/null +++ b/test/repo/compose-dev.test.js @@ -0,0 +1,72 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const yaml = require('js-yaml'); + +const root = path.join(__dirname, '..', '..'); +const read = f => fs.readFileSync(path.join(root, f), 'utf8'); + +const dev = yaml.load(read('docker-compose.dev.yml')).services.stackyard; +const prod = yaml.load(read('docker-compose.yml')).services.stackyard; + +/* A fresh instance has no password and answers any request by IP. */ +test('the dev instance listens on this computer only', () => { + assert.ok(dev.ports.length > 0); + for (const port of dev.ports) assert.match(port, /^127\.0\.0\.1:\d+:80$/, `${port} is reachable from other devices`); +}); + +test('the dev instance keeps the production limits', () => { + for (const key of [ + 'cap_drop', + 'cap_add', + 'security_opt', + 'privileged', + 'mem_limit', + 'mem_reservation', + 'memswap_limit', + 'cpus', + 'pids_limit', + 'init', + ]) { + assert.deepEqual(dev[key], prod[key], `${key} differs from docker-compose.yml`); + } +}); + +test('the dev instance turns no guard off and gets no Docker socket', () => { + const env = (dev.environment || []).join('\n'); + assert.doesNotMatch(env, /ALLOW_PRIVATE_IPS|SOCKET_PROXY_URL|TRUST_PROXY|DEMO_MODE/); + for (const v of dev.volumes) assert.doesNotMatch(v, /docker\.sock/); +}); + +/* The data folder holds stored secrets and corrupt-config backups. */ +test('dev data stays out of git and the build context', () => { + for (const v of dev.volumes) assert.match(v, /^\.\/\.dev\//, `${v} is outside .dev/`); + assert.match(read('.gitignore'), /^\/\.dev\/$/m); + assert.match(read('.dockerignore'), /^\.dev$/m); +}); + +/* Otherwise an edit to a shared module never reaches the API. */ +test('every module the API shares with the UI is watched', () => { + const rules = dev.develop.watch; + const shared = [...read('Dockerfile').matchAll(/^COPY (ui\/js\/\S+) (\/app\/\S+)$/gm)]; + assert.ok(shared.length > 0, 'the Dockerfile scan found no shared modules'); + for (const [, src, dst] of shared) { + const rule = rules.find(r => r.path === `./${src}`); + assert.ok(rule, `${src} has no watch rule`); + assert.equal(rule.target, dst); + assert.equal(rule.action, 'sync+restart'); + } +}); + +/* The rewrite fails silently. A changed cache line would serve stale files. */ +test('the dev cache rewrite matches every long-lived cache line in nginx', () => { + const script = dev.entrypoint.find(a => a.startsWith('sed ')); + const from = /s\/([^/]+)\//.exec(script)?.[1]; + assert.ok(from, 'no sed substitution in the dev entrypoint'); + const lines = read('nginx/dashboard.conf') + .split('\n') + .filter(l => /max-age=\d{5,}/.test(l)); + assert.ok(lines.length > 0); + for (const l of lines) assert.ok(l.includes(from), `not rewritten in dev: ${l.trim()}`); +}); From e7f8371f07ff4b118747b52ab494b2cddc1e90b9 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:19:07 -0400 Subject: [PATCH 2/2] Watch the Dockerfile and tighten the dev Compose tests --- .dockerignore | 1 - CONTRIBUTING.md | 2 +- test/repo/compose-dev.test.js | 30 +++++++++++++++++++++++++++++- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/.dockerignore b/.dockerignore index a4707ecd..99bb9bcf 100644 --- a/.dockerignore +++ b/.dockerignore @@ -20,5 +20,4 @@ scripts/** *.md docs .dockerignore -Dockerfile .dev diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f7b20e0c..506c23d1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,7 @@ If a feature seems to need a dependency or a build step, open an issue first. ## Development -Run Stackyard from the working tree with Docker Compose 2.22 or later: +Run Stackyard from the working tree with Docker Compose 2.23 or later: ``` docker compose -f docker-compose.dev.yml watch diff --git a/test/repo/compose-dev.test.js b/test/repo/compose-dev.test.js index ddf5392c..803e85a2 100644 --- a/test/repo/compose-dev.test.js +++ b/test/repo/compose-dev.test.js @@ -37,6 +37,8 @@ test('the dev instance turns no guard off and gets no Docker socket', () => { const env = (dev.environment || []).join('\n'); assert.doesNotMatch(env, /ALLOW_PRIVATE_IPS|SOCKET_PROXY_URL|TRUST_PROXY|DEMO_MODE/); for (const v of dev.volumes) assert.doesNotMatch(v, /docker\.sock/); + assert.equal(dev.network_mode, undefined); + assert.equal(dev.env_file, undefined); }); /* The data folder holds stored secrets and corrupt-config backups. */ @@ -62,11 +64,37 @@ test('every module the API shares with the UI is watched', () => { /* The rewrite fails silently. A changed cache line would serve stale files. */ test('the dev cache rewrite matches every long-lived cache line in nginx', () => { const script = dev.entrypoint.find(a => a.startsWith('sed ')); - const from = /s\/([^/]+)\//.exec(script)?.[1]; + const [, from, to] = /s\/([^/]+)\/([^/]*)\//.exec(script) || []; assert.ok(from, 'no sed substitution in the dev entrypoint'); + assert.equal(to, 'no-cache'); const lines = read('nginx/dashboard.conf') .split('\n') .filter(l => /max-age=\d{5,}/.test(l)); assert.ok(lines.length > 0); for (const l of lines) assert.ok(l.includes(from), `not rewritten in dev: ${l.trim()}`); }); + +/* Compose skips any watched path that .dockerignore excludes, with no warning. */ +test('no watched path is excluded by .dockerignore', () => { + const toRegex = glob => + new RegExp( + `^${glob + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*\*/g, '\0') + .replace(/\*/g, '[^/]*') + .replace(/\?/g, '[^/]') + .replace(/\0/g, '.*')}$`, + ); + const patterns = read('.dockerignore') + .split('\n') + .map(l => l.trim()) + .filter(l => l && !l.startsWith('#')) + .map(l => ({ keep: l.startsWith('!'), re: toRegex(l.replace(/^!/, '')) })); + for (const { path: watched } of dev.develop.watch) { + const parts = watched.replace(/^\.\//, '').split('/'); + const prefixes = parts.map((_, i) => parts.slice(0, i + 1).join('/')); + let excluded = false; + for (const { keep, re } of patterns) if (prefixes.some(p => re.test(p))) excluded = !keep; + assert.equal(excluded, false, `${watched} is excluded by .dockerignore`); + } +});