From 33a8d56e8b5aeed1c455645eba5d64bf9e614575 Mon Sep 17 00:00:00 2001 From: Amritanshu Date: Wed, 23 Sep 2026 00:09:36 +0530 Subject: [PATCH] Add the Great Bear and Hops n Grains Mohali Sites One image serves every Site, so a new Site is three small files: an inventory entry, a vars file naming its web address and data root, and a config that seeds its theme and first category list (baked into the image, which is why a new Site needs a new tag). Both reuse the directory their old static menus were served from, and the caddy role drops the snippet the retired nginx-menu playbook wrote for each hostname, so a deploy repoints each address at its own container with nothing else to do. The seeded category lists are a starting point, not a decision: categories, names and icons are all editable in each Site's own admin. A test now checks the inventory, config and vars agree for every Site and that no config is orphaned, because a half-added Site was the obvious way to get this wrong. --- ansible/hosts | 2 ++ ansible/vars/greatbear.yml | 13 ++++++++ ansible/vars/hngmohali.yml | 13 ++++++++ config/greatbear.site.json | 57 ++++++++++++++++++++++++++++++++ config/hngmohali.site.json | 57 ++++++++++++++++++++++++++++++++ test/site-configs.test.ts | 66 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 208 insertions(+) create mode 100644 ansible/vars/greatbear.yml create mode 100644 ansible/vars/hngmohali.yml create mode 100644 config/greatbear.site.json create mode 100644 config/hngmohali.site.json create mode 100644 test/site-configs.test.ts diff --git a/ansible/hosts b/ansible/hosts index 5f66e8c..59bf344 100644 --- a/ansible/hosts +++ b/ansible/hosts @@ -10,6 +10,8 @@ [menus] hopsngrains ansible_host=monoco.tanshu.com var_file=vars/hopsngrains.yml +greatbear ansible_host=monoco.tanshu.com var_file=vars/greatbear.yml +hngmohali ansible_host=monoco.tanshu.com var_file=vars/hngmohali.yml [all:vars] ansible_python_interpreter=/usr/bin/python3 diff --git a/ansible/vars/greatbear.yml b/ansible/vars/greatbear.yml new file mode 100644 index 0000000..96aa27c --- /dev/null +++ b/ansible/vars/greatbear.yml @@ -0,0 +1,13 @@ +--- +# The Great Bear. Its name, tagline, icon, categories and menus live on the server under +# app_root and are edited in the admin; config/greatbear.site.json only seeds the +# theme and the first category list. +title: greatbear.in +host: 'menu.{{ title }}' + +docker_container: 'menu-app.{{ title }}' +docker_network: '{{ docker_container }}_net' + +# Everything the app writes — data/ and the untracked site.env. Deliberately the +# directory the old static menus were served from. +app_root: '/var/lib/{{ host }}' diff --git a/ansible/vars/hngmohali.yml b/ansible/vars/hngmohali.yml new file mode 100644 index 0000000..986b0d3 --- /dev/null +++ b/ansible/vars/hngmohali.yml @@ -0,0 +1,13 @@ +--- +# Hops n Grains Mohali. Its name, tagline, icon, categories and menus live on the server under +# app_root and are edited in the admin; config/hngmohali.site.json only seeds the +# theme and the first category list. +title: hngmohali.com +host: 'menu.{{ title }}' + +docker_container: 'menu-app.{{ title }}' +docker_network: '{{ docker_container }}_net' + +# Everything the app writes — data/ and the untracked site.env. Deliberately the +# directory the old static menus were served from. +app_root: '/var/lib/{{ host }}' diff --git a/config/greatbear.site.json b/config/greatbear.site.json new file mode 100644 index 0000000..1dc0e21 --- /dev/null +++ b/config/greatbear.site.json @@ -0,0 +1,57 @@ +{ + "site": "menu.greatbear.in", + "brand": { + "name": "The Great Bear", + "tagline": "Craft Microbrewery", + "logo": "/assets/favicon.svg" + }, + "theme": { + "steel": "#faf8f5", + "rail": "#f4f1ea", + "paper": "#ffffff", + "accent": "#c59b58", + "copper": "#b08544" + }, + "sections": [ + { + "slug": "food", + "label": "Food", + "shortLabel": "Food", + "order": 1 + }, + { + "slug": "bar", + "label": "Bar", + "shortLabel": "Bar", + "order": 2 + }, + { + "slug": "wine", + "label": "Wine", + "shortLabel": "Wine", + "order": 3 + }, + { + "slug": "straight-drinks", + "label": "Straight Drinks", + "shortLabel": "Drinks", + "order": 4 + }, + { + "slug": "delivery", + "label": "Delivery", + "shortLabel": "Delivery", + "order": 5 + }, + { + "slug": "whats-on", + "label": "What's On", + "shortLabel": "What's On", + "order": 6 + } + ], + "limits": { + "maxUploadBytes": 31457280, + "maxPagesPerSet": 40 + } +} diff --git a/config/hngmohali.site.json b/config/hngmohali.site.json new file mode 100644 index 0000000..b5b8af1 --- /dev/null +++ b/config/hngmohali.site.json @@ -0,0 +1,57 @@ +{ + "site": "menu.hngmohali.com", + "brand": { + "name": "Hops n Grains Mohali", + "tagline": "Handcrafted Microbrewery", + "logo": "/assets/favicon.svg" + }, + "theme": { + "steel": "#faf8f5", + "rail": "#f4f1ea", + "paper": "#ffffff", + "accent": "#c59b58", + "copper": "#b08544" + }, + "sections": [ + { + "slug": "food", + "label": "Food", + "shortLabel": "Food", + "order": 1 + }, + { + "slug": "bar", + "label": "Bar", + "shortLabel": "Bar", + "order": 2 + }, + { + "slug": "wine", + "label": "Wine", + "shortLabel": "Wine", + "order": 3 + }, + { + "slug": "straight-drinks", + "label": "Straight Drinks", + "shortLabel": "Drinks", + "order": 4 + }, + { + "slug": "delivery", + "label": "Delivery", + "shortLabel": "Delivery", + "order": 5 + }, + { + "slug": "whats-on", + "label": "What's On", + "shortLabel": "What's On", + "order": 6 + } + ], + "limits": { + "maxUploadBytes": 31457280, + "maxPagesPerSet": 40 + } +} diff --git a/test/site-configs.test.ts b/test/site-configs.test.ts new file mode 100644 index 0000000..cd703cd --- /dev/null +++ b/test/site-configs.test.ts @@ -0,0 +1,66 @@ +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, /^app_root: /m, `${name}: no app_root`); + } + }); + + 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`); + } + }); +});