|
| 1 | +// docker/: the container's web profile (#352) and the files the image and compose rely on. |
| 2 | +import { test } from 'node:test' |
| 3 | +import assert from 'node:assert/strict' |
| 4 | +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs' |
| 5 | +import { tmpdir } from 'node:os' |
| 6 | +import { join } from 'node:path' |
| 7 | +import { imagePackages, linkPackages, prepareProfile } from '../../docker/profile.mjs' |
| 8 | + |
| 9 | +const root = new URL('../../', import.meta.url) |
| 10 | +const read = (path) => readFileSync(new URL(path, root), 'utf8') |
| 11 | + |
| 12 | +function packagesDir() { |
| 13 | + const dir = mkdtempSync(join(tmpdir(), 'flybot-pkgs-')) |
| 14 | + for (const [folder, name] of [['ds-bot-0.2.1', 'ds-bot'], ['flybot-tree-0.1.0', '@flybot/tree'], ['flybot-social-0.1.0', '@flybot/social']]) { |
| 15 | + mkdirSync(join(dir, folder)) |
| 16 | + writeFileSync(join(dir, folder, 'package.json'), JSON.stringify({ name })) |
| 17 | + } |
| 18 | + mkdirSync(join(dir, 'not-a-package')) |
| 19 | + return dir |
| 20 | +} |
| 21 | + |
| 22 | +test('imagePackages lists the unpacked packages by name', () => { |
| 23 | + const dir = packagesDir() |
| 24 | + try { |
| 25 | + assert.deepEqual(imagePackages(dir).map((p) => p.name), ['@flybot/social', '@flybot/tree', 'ds-bot']) |
| 26 | + assert.equal(imagePackages(dir).find((p) => p.name === 'ds-bot').dir, join(dir, 'ds-bot-0.2.1')) |
| 27 | + } finally { rmSync(dir, { recursive: true, force: true }) } |
| 28 | +}) |
| 29 | + |
| 30 | +test('a new profile gets the web bundles plus every image package, linked', () => { |
| 31 | + const next = linkPackages(undefined, [{ name: 'ds-bot', dir: '/opt/p/core' }, { name: '@flybot/tree', dir: '/opt/p/tree' }]) |
| 32 | + assert.deepEqual(next.dependencies, { 'ds-bot': 'link:/opt/p/core', '@flybot/tree': 'link:/opt/p/tree' }) |
| 33 | + assert.deepEqual(next.dsh.profile.bundles, ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', 'ds-bot', '@flybot/tree']) |
| 34 | +}) |
| 35 | + |
| 36 | +test('an existing profile keeps your own plugins and bundle order; old FlyBot entries are relinked', () => { |
| 37 | + const current = { |
| 38 | + name: 'dsh-profile-web', |
| 39 | + private: true, |
| 40 | + dependencies: { 'ds-bot': 'file:/old/ds-bot-0.2.0.tgz', 'my-plugin': '^1.0.0' }, |
| 41 | + dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', 'my-plugin', 'ds-bot'] }, other: true }, |
| 42 | + } |
| 43 | + const before = structuredClone(current) |
| 44 | + const next = linkPackages(current, [{ name: 'ds-bot', dir: '/opt/p/core' }, { name: '@flybot/social', dir: '/opt/p/social' }]) |
| 45 | + assert.deepEqual(current, before, 'the input is not changed') |
| 46 | + assert.deepEqual(next.dependencies, { 'ds-bot': 'link:/opt/p/core', 'my-plugin': '^1.0.0', '@flybot/social': 'link:/opt/p/social' }) |
| 47 | + assert.deepEqual(next.dsh.profile.bundles, ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', 'my-plugin', 'ds-bot', '@flybot/social']) |
| 48 | + assert.equal(next.dsh.other, true) |
| 49 | +}) |
| 50 | + |
| 51 | +test('prepareProfile writes package.json and pnpm-workspace.yaml and keeps your settings', () => { |
| 52 | + const pkgs = packagesDir() |
| 53 | + const home = mkdtempSync(join(tmpdir(), 'flybot-home-')) |
| 54 | + try { |
| 55 | + const profile = join(home, 'profiles', 'web') |
| 56 | + mkdirSync(profile, { recursive: true }) |
| 57 | + writeFileSync(join(profile, 'cordis.patch.yml'), '- id: flybot-social\n') |
| 58 | + writeFileSync(join(profile, 'pnpm-workspace.yaml'), 'packages:\n - .\n# mine\n') |
| 59 | + assert.equal(prepareProfile(home, pkgs), profile) |
| 60 | + const manifest = JSON.parse(readFileSync(join(profile, 'package.json'), 'utf8')) |
| 61 | + assert.equal(manifest.dependencies['ds-bot'], `link:${join(pkgs, 'ds-bot-0.2.1')}`) |
| 62 | + assert.equal(readFileSync(join(profile, 'cordis.patch.yml'), 'utf8'), '- id: flybot-social\n') |
| 63 | + assert.equal(readFileSync(join(profile, 'pnpm-workspace.yaml'), 'utf8'), 'packages:\n - .\n# mine\n') |
| 64 | + prepareProfile(home, pkgs) |
| 65 | + assert.deepEqual(JSON.parse(readFileSync(join(profile, 'package.json'), 'utf8')), manifest, 'a second start changes nothing') |
| 66 | + rmSync(join(profile, 'pnpm-workspace.yaml')) |
| 67 | + prepareProfile(home, pkgs) |
| 68 | + assert.match(readFileSync(join(profile, 'pnpm-workspace.yaml'), 'utf8'), /nodeLinker: hoisted\nautoInstallPeers: false/) |
| 69 | + } finally { |
| 70 | + rmSync(pkgs, { recursive: true, force: true }) |
| 71 | + rmSync(home, { recursive: true, force: true }) |
| 72 | + } |
| 73 | +}) |
| 74 | + |
| 75 | +test('secure defaults: loopback port, non-root user, no secret in the image, no registry push', () => { |
| 76 | + const compose = read('compose.yaml') |
| 77 | + assert.match(compose, /^ {6}- "127\.0\.0\.1:\$\{FLYBOT_PORT:-3080\}:3080"$/m, 'the port is published on the host loopback only') |
| 78 | + assert.match(compose, /^ {6}- data:\/data$/m, 'one data volume') |
| 79 | + assert.match(compose, /^ {6}- DEEPSEEK_API_KEY$/m, 'the key is passed through, never written into the file') |
| 80 | + const dockerfile = read('Dockerfile') |
| 81 | + assert.match(dockerfile, /^USER node$/m) |
| 82 | + assert.match(dockerfile, /^VOLUME \["\/data"\]$/m) |
| 83 | + assert.match(dockerfile, /^HEALTHCHECK /m) |
| 84 | + assert.doesNotMatch(dockerfile, /^(ENV|ARG)\s.*(KEY|TOKEN|SECRET)/im, 'no key in the image') |
| 85 | + const pinned = JSON.parse(read('core/dev/harness/package.json')).dependencies['@deepseek-ai/dsh'] |
| 86 | + assert.match(dockerfile, new RegExp(`^ARG DSH_VERSION=${pinned.replace(/\./g, '\\.')}$`, 'm'), 'DSH is pinned like the tests and the README') |
| 87 | + const ignore = read('.dockerignore') |
| 88 | + for (const entry of ['**/.env', '**/.credentials.yaml', '**/.dsh*', '.git']) assert.ok(ignore.split('\n').includes(entry), `.dockerignore lists ${entry}`) |
| 89 | + const workflow = read('.github/workflows/docker.yml') |
| 90 | + assert.doesNotMatch(workflow, /docker (push|login)|docker\/(login|build-push)-action|packages: write/, 'CI builds and checks only') |
| 91 | + assert.match(read('docker/web.patch.yml'), /^ {4}host: 0\.0\.0\.0$/m) |
| 92 | +}) |
0 commit comments