Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@ scripts/**
*.md
docs
.dockerignore
Dockerfile
.dev
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,6 @@ coverage/
# Playwright output.
playwright-report/
test-results/

# Data for docker-compose.dev.yml. Holds stored secrets.
/.dev/
13 changes: 12 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.23 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`.

Expand Down
82 changes: 82 additions & 0 deletions docker-compose.dev.yml
Original file line number Diff line number Diff line change
@@ -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
100 changes: 100 additions & 0 deletions test/repo/compose-dev.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
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/);
assert.equal(dev.network_mode, undefined);
assert.equal(dev.env_file, undefined);
});

/* 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, 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`);
}
});
Loading