menu-admin was the odd one out: it built from the working directory with a date tag and a multi-arch manifest, deployed with `-e tag=`, and kept the Docker-era container name. The other two apps build from `git archive HEAD` and tag with the commit, so what runs on the host is exactly what git knows about; build only for the architecture emily actually runs; and use one slug for the container, its network and its data directory. The build is now their three lines: `podman build --platform linux/arm64` from `git archive HEAD`, tagged `:<commit>` and `:latest`, pushed with `podman push` rather than a manifest. `deploy.sh` pushes the commit first, then refuses a dirty tracked tree or a HEAD that is not origin/main — which is what makes the commit tag trustworthy — takes the same `--force` and `--no-push`, and ends in the same `-u tanshu ... -e image_tag=<commit>`. Its own lint/typecheck/test gate stays, but behind `--checks`, so a plain deploy behaves as theirs does. The registry credential moves into vars/default.yml and the role logs in, pulls and logs out around it, as their roles do. The image is only pulled at deploy time, so the unit keeps podman's default `Pull=missing` and a reboot never needs the registry. This is the same plaintext credential the other two repos carry. One real bug goes with it: the healthcheck gate ran before the handler that restarts a changed unit, so a run that only changed the unit checked the stale container. `meta: flush_handlers` before the gate is the fix the sibling repos already made. The container, network and data directory are all menu-<site> now. The dotted Docker-era name and the NetworkAlias it needed are gone, so `podman logs menu-<site>` and `systemctl --user status menu-<site>` are the same two commands across all three apps. The Dockerfile keeps its name for the same reason: podman reads it as readily as a Containerfile, and the siblings use that name too.
67 lines
2.7 KiB
TypeScript
67 lines
2.7 KiB
TypeScript
import { strict as assert } from 'node:assert';
|
|
import { readdir, readFile } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { describe, it } from 'node:test';
|
|
import { parseSiteConfig } from '../src/config.ts';
|
|
|
|
const root = path.join(import.meta.dirname, '..');
|
|
|
|
/** The inventory decides which Sites exist; the playbook names the vars file. */
|
|
async function inventory(): Promise<{ name: string; varFile: string }[]> {
|
|
const text = await readFile(path.join(root, 'ansible', 'hosts'), 'utf8');
|
|
const inMenus = text.split(/^\[/m).find((block) => block.startsWith('menus]')) ?? '';
|
|
return inMenus
|
|
.split('\n')
|
|
.slice(1)
|
|
.map((line) => line.trim())
|
|
.filter((line) => line !== '' && !line.startsWith('#'))
|
|
.map((line) => {
|
|
const [name, ...vars] = line.split(/\s+/);
|
|
const varFile = vars.find((entry) => entry.startsWith('var_file=')) ?? '';
|
|
return { name: name!, varFile: varFile.replace('var_file=', '') };
|
|
});
|
|
}
|
|
|
|
describe('every Site in the inventory is complete', () => {
|
|
it('has a config file, a vars file, and a config that parses', async () => {
|
|
const sites = await inventory();
|
|
assert.ok(sites.length >= 3, `expected the three Sites, found ${sites.length}`);
|
|
|
|
for (const { name, varFile } of sites) {
|
|
assert.notEqual(varFile, '', `${name} names no var_file`);
|
|
|
|
const configPath = path.join(root, 'config', `${name}.site.json`);
|
|
const raw = JSON.parse(await readFile(configPath, 'utf8'));
|
|
const config = parseSiteConfig(raw);
|
|
|
|
assert.equal(
|
|
config.site.startsWith('menu.'),
|
|
true,
|
|
`${name}: site should be a menu hostname`,
|
|
);
|
|
assert.ok(config.sections.length > 0, `${name} seeds no categories`);
|
|
assert.match(
|
|
config.theme.steel,
|
|
/^#[0-9a-f]{6}$/i,
|
|
`${name}: the theme should carry real colours`,
|
|
);
|
|
|
|
const varsRaw = await readFile(path.join(root, 'ansible', varFile), 'utf8');
|
|
// Each vars file only needs to say where the Site runs; the templating is
|
|
// quoted, which is what a first deploy got wrong.
|
|
assert.match(varsRaw, /^title: /m, `${name}: ${varFile} has no title`);
|
|
assert.match(varsRaw, /^host: 'menu\.\{\{ title \}\}'/m, `${name}: host is not quoted`);
|
|
assert.match(varsRaw, /^host_directory: /m, `${name}: no host_directory`);
|
|
}
|
|
});
|
|
|
|
it('leaves no Site config without an inventory entry', async () => {
|
|
const sites = new Set((await inventory()).map((site) => site.name));
|
|
const files = await readdir(path.join(root, 'config'));
|
|
for (const file of files.filter((entry) => entry.endsWith('.site.json'))) {
|
|
const name = file.replace('.site.json', '');
|
|
assert.ok(sites.has(name), `config/${file} belongs to no Site in the inventory`);
|
|
}
|
|
});
|
|
});
|