From aed1691697fca2ce3ff1d706ce582459cf049a51 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Tue, 23 Jun 2026 10:48:59 -0400 Subject: [PATCH 1/4] feat(create-a-container): run DB migrations at startup with advisory locks The server now applies all pending database migrations on startup before listening, so a deployment no longer depends on a one-shot init service to migrate. The run is serialized across processes by an engine-appropriate advisory lock (pg_advisory_lock on PostgreSQL, GET_LOCK on MySQL; SQLite needs none), so only one process migrates at a time. On failure the process exits non-zero (systemd Restart=on-failure retries) rather than serving an out-of-date schema. - Add utils/migrate.js: programmatic runner using umzug (the same v2 API sequelize-cli uses, so it shares the SequelizeMeta ledger) wrapped in the advisory lock. - Wire runMigrations() into server.js before app.listen(); fail fast on error. - Depend on umzug directly as a production dependency and move sequelize-cli to devDependencies (production runs migrations via the app, not the CLI). - Convert the four production seeders (groups, wazuh/sssd env vars) into idempotent migrations so required data is applied automatically and tracked in SequelizeMeta. They are timestamped to run after all schema migrations (preserving the old "seeders run last" ordering) and remain idempotent so databases that already ran them as seeders are not duplicated. - Drop the two obsolete push-notification seeders (their keys are removed by 20260604000002-remove-push-notification-settings). - Keep seeders/ for test/development data only. - container-creator-init.service no longer runs db:migrate/db:seed:all; it only provisions the PostgreSQL role/database and credentials. - Document the new behavior in the README. --- create-a-container/README.md | 28 ++- .../20260616000000-seed-groups.js} | 9 +- .../20260616000001-seed-wazuh-env-vars.js} | 5 +- .../20260616000002-seed-sssd-env-vars.js} | 8 +- ...seed-sssd-access-filter-and-user-attrs.js} | 4 - create-a-container/package-lock.json | 73 +++++++- create-a-container/package.json | 5 +- create-a-container/seeders/.gitkeep | 0 ...260120165612-push-notification-settings.js | 30 --- ...0260313000000-push-notification-api-key.js | 24 --- create-a-container/server.js | 12 +- create-a-container/utils/migrate.js | 177 ++++++++++++++++++ images/manager/container-creator-init.service | 4 +- 13 files changed, 300 insertions(+), 79 deletions(-) rename create-a-container/{seeders/20251121000000-seed-groups.js => migrations/20260616000000-seed-groups.js} (78%) rename create-a-container/{seeders/20260311000000-seed-wazuh-env-vars.js => migrations/20260616000001-seed-wazuh-env-vars.js} (93%) rename create-a-container/{seeders/20260604000000-seed-sssd-env-vars.js => migrations/20260616000002-seed-sssd-env-vars.js} (92%) rename create-a-container/{seeders/20260604000003-seed-sssd-access-filter-and-user-attrs.js => migrations/20260616000003-seed-sssd-access-filter-and-user-attrs.js} (94%) create mode 100644 create-a-container/seeders/.gitkeep delete mode 100644 create-a-container/seeders/20260120165612-push-notification-settings.js delete mode 100644 create-a-container/seeders/20260313000000-push-notification-api-key.js create mode 100644 create-a-container/utils/migrate.js diff --git a/create-a-container/README.md b/create-a-container/README.md index a9ba5832..f7a388a0 100644 --- a/create-a-container/README.md +++ b/create-a-container/README.md @@ -107,6 +107,12 @@ FLUSH PRIVILEGES; ``` #### Run Migrations + +Migrations run **automatically when the server starts** (`server.js` applies all +pending migrations before listening, serialized across processes by a database +advisory lock). For a fresh database you can simply start the server, or run +them explicitly: + ```bash npm run db:migrate ``` @@ -593,17 +599,36 @@ kill -9 ## Development ### Database Migrations + +Migrations are applied **automatically at server startup**. `server.js` calls the +runner in `utils/migrate.js`, which uses [umzug](https://github.com/sequelize/umzug) +(a runtime/production dependency) to run every pending migration in +`migrations/` before the HTTP server begins listening. Only one process migrates +at a time: the run is wrapped in an engine-appropriate advisory lock +(`pg_advisory_lock` on PostgreSQL, `GET_LOCK` on MySQL; SQLite needs none). If a +migration fails, the process exits non-zero and does not serve traffic. + +`sequelize-cli` is a **dev dependency** used for authoring and ad-hoc migration +management: + ```bash # Create new migration npx sequelize-cli migration:generate --name description-here -# Run migrations +# Run migrations + seeders manually (also used for first-time/dev setup) npm run db:migrate # Undo last migration npx sequelize-cli db:migrate:undo ``` +#### Seeders + +The `seeders/` directory is for **test/development data only**. Data that must +exist on every deployment lives in `migrations/` (so it is applied automatically +at startup). Run seeders on demand with `npx sequelize-cli db:seed:all` (also +invoked by `npm run db:migrate`). + ### Code Structure ``` create-a-container/ @@ -613,6 +638,7 @@ create-a-container/ ├── config/ # Sequelize configuration ├── models/ # Database models ├── migrations/ # Database migrations +├── seeders/ # Test/development data seeders ├── views/ # HTML templates ├── public/ # Static assets ├── data/ # JSON data files diff --git a/create-a-container/seeders/20251121000000-seed-groups.js b/create-a-container/migrations/20260616000000-seed-groups.js similarity index 78% rename from create-a-container/seeders/20251121000000-seed-groups.js rename to create-a-container/migrations/20260616000000-seed-groups.js index ad4978cc..c2815b7a 100644 --- a/create-a-container/seeders/20251121000000-seed-groups.js +++ b/create-a-container/migrations/20260616000000-seed-groups.js @@ -1,19 +1,18 @@ 'use strict'; +// Seeds the two built-in groups (sysadmins, ldapusers). const GID_MIN = 2000; /** @type {import('sequelize-cli').Migration} */ module.exports = { - async up(queryInterface, Sequelize) { + async up(queryInterface) { const now = new Date(); const groups = [ { gidNumber: GID_MIN, cn: 'sysadmins', isAdmin: true, createdAt: now, updatedAt: now }, { gidNumber: GID_MIN + 1, cn: 'ldapusers', isAdmin: false, createdAt: now, updatedAt: now }, ]; - // Idempotent: only insert groups that don't already exist, so re-running - // seeders (e.g. `make dev`) doesn't violate the unique constraints on - // gidNumber/cn. + // Idempotent: only insert groups that don't already exist. const [existing] = await queryInterface.sequelize.query( `SELECT "gidNumber" FROM "Groups" WHERE "gidNumber" IN (:gids)`, { replacements: { gids: groups.map((g) => g.gidNumber) } }, @@ -25,7 +24,7 @@ module.exports = { await queryInterface.bulkInsert('Groups', toInsert, {}); }, - async down(queryInterface, Sequelize) { + async down(queryInterface) { await queryInterface.bulkDelete('Groups', { gidNumber: [GID_MIN, GID_MIN + 1] }, {}); diff --git a/create-a-container/seeders/20260311000000-seed-wazuh-env-vars.js b/create-a-container/migrations/20260616000001-seed-wazuh-env-vars.js similarity index 93% rename from create-a-container/seeders/20260311000000-seed-wazuh-env-vars.js rename to create-a-container/migrations/20260616000001-seed-wazuh-env-vars.js index 7ef8fc13..0969f2ec 100644 --- a/create-a-container/seeders/20260311000000-seed-wazuh-env-vars.js +++ b/create-a-container/migrations/20260616000001-seed-wazuh-env-vars.js @@ -1,8 +1,7 @@ 'use strict'; -// Variables seeded into the default_container_env_vars setting. -// Add future cross-container variables here and create a new seeder -// that calls the same merge logic. +// Seeds the Wazuh agent enrollment variables into the +// default_container_env_vars setting. const WAZUH_DEFAULTS = [ { key: 'WAZUH_MANAGER', diff --git a/create-a-container/seeders/20260604000000-seed-sssd-env-vars.js b/create-a-container/migrations/20260616000002-seed-sssd-env-vars.js similarity index 92% rename from create-a-container/seeders/20260604000000-seed-sssd-env-vars.js rename to create-a-container/migrations/20260616000002-seed-sssd-env-vars.js index b317aea6..fb912933 100644 --- a/create-a-container/seeders/20260604000000-seed-sssd-env-vars.js +++ b/create-a-container/migrations/20260616000002-seed-sssd-env-vars.js @@ -1,9 +1,9 @@ 'use strict'; -// Variables seeded into the default_container_env_vars setting for the -// base/sssd.conf.template. Only SSSD_LDAP_URI and SSSD_LDAP_TLS_REQCERT -// carry default values; the remaining variables are intentionally left -// blank so that sssd falls back to its builtin defaults. +// Seeds the SSSD env vars for the base/sssd.conf.template into the +// default_container_env_vars setting. Only SSSD_LDAP_URI and +// SSSD_LDAP_TLS_REQCERT carry default values; the remaining variables are +// intentionally left blank so that sssd falls back to its builtin defaults. const SSSD_DEFAULTS = [ { key: 'SSSD_LDAP_URI', diff --git a/create-a-container/seeders/20260604000003-seed-sssd-access-filter-and-user-attrs.js b/create-a-container/migrations/20260616000003-seed-sssd-access-filter-and-user-attrs.js similarity index 94% rename from create-a-container/seeders/20260604000003-seed-sssd-access-filter-and-user-attrs.js rename to create-a-container/migrations/20260616000003-seed-sssd-access-filter-and-user-attrs.js index af443093..f828f685 100644 --- a/create-a-container/seeders/20260604000003-seed-sssd-access-filter-and-user-attrs.js +++ b/create-a-container/migrations/20260616000003-seed-sssd-access-filter-and-user-attrs.js @@ -5,10 +5,6 @@ // - SSSD_LDAP_USER_GECOS (full-name/gecos attribute; defaults to cn) // - SSSD_LDAP_ACCESS_FILTER (login access filter) // -// This is a separate seeder (rather than an edit to 20260604000000) because -// that seeder is already released and recorded as executed in existing -// databases, so editing it in place would not back-fill the new keys. -// // SSSD_LDAP_ACCESS_FILTER defaults to a permissive filter that every directory // entry matches, so out of the box all directory-authenticated users may log // in. This is deliberate: with access_provider=ldap and diff --git a/create-a-container/package-lock.json b/create-a-container/package-lock.json index 0f87e7bf..ca9a1c69 100644 --- a/create-a-container/package-lock.json +++ b/create-a-container/package-lock.json @@ -22,14 +22,15 @@ "patch-package": "^8.0.1", "pg": "^8.16.3", "sequelize": "^6.37.8", - "sequelize-cli": "^6.6.3", "swagger-ui-express": "^5.0.1", + "umzug": "^2.3.0", "yamljs": "^0.3.0" }, "devDependencies": { "@vscode/sqlite3": "5.1.12-vscode", "concurrently": "^9.2.3", - "nodemon": "^3.1.10" + "nodemon": "^3.1.10", + "sequelize-cli": "^6.6.3" } }, "node_modules/@epic-web/invariant": { @@ -41,6 +42,7 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, "license": "ISC", "dependencies": { "string-width": "^5.1.2", @@ -71,6 +73,7 @@ "version": "0.1.1", "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "dev": true, "license": "MIT" }, "node_modules/@phc/format": { @@ -85,6 +88,7 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, "license": "MIT", "optional": true, "engines": { @@ -150,6 +154,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, "license": "ISC", "engines": { "node": "^14.17.0 || ^16.13.0 || >=18.0.0" @@ -171,6 +176,7 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -183,6 +189,7 @@ "version": "6.2.3", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -245,6 +252,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, "license": "ISC", "engines": { "node": ">= 4.0.0" @@ -523,6 +531,7 @@ "version": "7.0.4", "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -534,6 +543,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -543,6 +553,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -558,12 +569,14 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, "license": "MIT" }, "node_modules/cliui/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -578,6 +591,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -590,6 +604,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -637,6 +652,7 @@ "version": "10.0.1", "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, "license": "MIT", "engines": { "node": ">=14" @@ -826,6 +842,7 @@ "version": "1.1.13", "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, "license": "MIT", "dependencies": { "ini": "^1.3.4", @@ -1009,12 +1026,14 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, "license": "MIT" }, "node_modules/editorconfig": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", + "dev": true, "license": "MIT", "dependencies": { "@one-ini/wasm": "0.1.1", @@ -1033,6 +1052,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -1042,6 +1062,7 @@ "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.2" @@ -1077,6 +1098,7 @@ "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, "license": "MIT" }, "node_modules/encodeurl": { @@ -1133,6 +1155,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -1349,6 +1372,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", @@ -1418,6 +1442,7 @@ "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, "license": "MIT", "dependencies": { "at-least-node": "^1.0.0", @@ -1462,6 +1487,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -1506,6 +1532,7 @@ "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "dev": true, "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", @@ -1538,6 +1565,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -1547,6 +1575,7 @@ "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.2" @@ -1690,6 +1719,7 @@ "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, "license": "ISC" }, "node_modules/ip-address": { @@ -1726,6 +1756,7 @@ "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -1766,6 +1797,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -1826,6 +1858,7 @@ "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" @@ -1867,6 +1900,7 @@ "version": "1.15.4", "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "dev": true, "license": "MIT", "dependencies": { "config-chain": "^1.1.13", @@ -1888,6 +1922,7 @@ "version": "3.0.8", "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", + "dev": true, "license": "MIT" }, "node_modules/json-stable-stringify": { @@ -2047,6 +2082,7 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, "license": "ISC", "engines": { "node": ">=16 || 14 >=14.17" @@ -2203,6 +2239,7 @@ "version": "7.2.1", "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, "license": "ISC", "dependencies": { "abbrev": "^2.0.0" @@ -2324,6 +2361,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, "license": "BlueOak-1.0.0" }, "node_modules/parseurl": { @@ -2399,12 +2437,14 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, "license": "MIT" }, "node_modules/path-scurry": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^10.2.0", @@ -2421,6 +2461,7 @@ "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, "license": "ISC" }, "node_modules/path-to-regexp": { @@ -2583,6 +2624,7 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, "license": "ISC" }, "node_modules/proxy-addr": { @@ -2690,6 +2732,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -2699,6 +2742,7 @@ "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, "license": "MIT", "dependencies": { "is-core-module": "^2.16.1", @@ -2869,6 +2913,7 @@ "version": "6.6.3", "resolved": "https://registry.npmjs.org/sequelize-cli/-/sequelize-cli-6.6.3.tgz", "integrity": "sha512-1YYPrcSRt/bpMDDSKM5ubY1mnJ2TEwIaGZcqITw4hLtGtE64nIqaBnLtMvH8VKHg6FbWpXTiFNc2mS/BtQCXZw==", + "dev": true, "license": "MIT", "dependencies": { "fs-extra": "^9.1.0", @@ -3038,6 +3083,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, "license": "ISC", "engines": { "node": ">=14" @@ -3095,6 +3141,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, "license": "MIT", "dependencies": { "eastasianwidth": "^0.2.0", @@ -3113,6 +3160,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -3127,6 +3175,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3136,12 +3185,14 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, "license": "MIT" }, "node_modules/string-width-cjs/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -3154,6 +3205,7 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -3170,6 +3222,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -3182,6 +3235,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3204,6 +3258,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3434,6 +3489,7 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.1.0", @@ -3452,6 +3508,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -3469,6 +3526,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3478,6 +3536,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -3493,12 +3552,14 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, "license": "MIT" }, "node_modules/wrap-ansi-cjs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -3513,6 +3574,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -3539,6 +3601,7 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -3608,6 +3671,7 @@ "version": "16.2.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, "license": "MIT", "dependencies": { "cliui": "^7.0.2", @@ -3626,6 +3690,7 @@ "version": "20.2.9", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -3635,6 +3700,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3644,12 +3710,14 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, "license": "MIT" }, "node_modules/yargs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -3664,6 +3732,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" diff --git a/create-a-container/package.json b/create-a-container/package.json index 8f7b4139..d69379e9 100644 --- a/create-a-container/package.json +++ b/create-a-container/package.json @@ -35,13 +35,14 @@ "patch-package": "^8.0.1", "pg": "^8.16.3", "sequelize": "^6.37.8", - "sequelize-cli": "^6.6.3", "swagger-ui-express": "^5.0.1", + "umzug": "^2.3.0", "yamljs": "^0.3.0" }, "devDependencies": { "@vscode/sqlite3": "5.1.12-vscode", "concurrently": "^9.2.3", - "nodemon": "^3.1.10" + "nodemon": "^3.1.10", + "sequelize-cli": "^6.6.3" } } diff --git a/create-a-container/seeders/.gitkeep b/create-a-container/seeders/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/create-a-container/seeders/20260120165612-push-notification-settings.js b/create-a-container/seeders/20260120165612-push-notification-settings.js deleted file mode 100644 index b797f94c..00000000 --- a/create-a-container/seeders/20260120165612-push-notification-settings.js +++ /dev/null @@ -1,30 +0,0 @@ -'use strict'; - -/** @type {import('sequelize-cli').Migration} */ -module.exports = { - async up(queryInterface, Sequelize) { - const now = new Date(); - const settings = [ - { key: 'push_notification_url', value: '', createdAt: now, updatedAt: now }, - { key: 'push_notification_enabled', value: 'false', createdAt: now, updatedAt: now }, - ]; - - // Idempotent: only insert keys that don't already exist, so re-running - // seeders doesn't violate the unique constraint on Settings.key. - const [existing] = await queryInterface.sequelize.query( - `SELECT "key" FROM "Settings" WHERE "key" IN (:keys)`, - { replacements: { keys: settings.map((s) => s.key) } }, - ); - const existingKeys = new Set(existing.map((r) => r.key)); - const toInsert = settings.filter((s) => !existingKeys.has(s.key)); - if (toInsert.length === 0) return; - - await queryInterface.bulkInsert('Settings', toInsert, {}); - }, - - async down(queryInterface, Sequelize) { - await queryInterface.bulkDelete('Settings', { - key: ['push_notification_url', 'push_notification_enabled'] - }, {}); - } -}; diff --git a/create-a-container/seeders/20260313000000-push-notification-api-key.js b/create-a-container/seeders/20260313000000-push-notification-api-key.js deleted file mode 100644 index ff50e358..00000000 --- a/create-a-container/seeders/20260313000000-push-notification-api-key.js +++ /dev/null @@ -1,24 +0,0 @@ -'use strict'; - -/** @type {import('sequelize-cli').Migration} */ -module.exports = { - async up(queryInterface, Sequelize) { - // Idempotent: skip if the key already exists, so re-running seeders doesn't - // violate the unique constraint on Settings.key. - const [existing] = await queryInterface.sequelize.query( - `SELECT "key" FROM "Settings" WHERE "key" = 'push_notification_api_key'`, - ); - if (existing.length > 0) return; - - const now = new Date(); - await queryInterface.bulkInsert('Settings', [ - { key: 'push_notification_api_key', value: '', createdAt: now, updatedAt: now }, - ], {}); - }, - - async down(queryInterface, Sequelize) { - await queryInterface.bulkDelete('Settings', { - key: 'push_notification_api_key' - }, {}); - } -}; diff --git a/create-a-container/server.js b/create-a-container/server.js index 650384b3..7d90be0c 100644 --- a/create-a-container/server.js +++ b/create-a-container/server.js @@ -12,6 +12,7 @@ const net = require('net'); const swaggerUi = require('swagger-ui-express'); const YAML = require('yamljs'); const { sequelize, SessionSecret } = require('./models'); +const { runMigrations } = require('./utils/migrate'); // Function to get or create session secrets @@ -33,6 +34,12 @@ async function getSessionSecrets() { } async function main() { + // Apply any pending database migrations before serving traffic. This is + // serialized across processes by an engine-appropriate advisory lock (see + // utils/migrate.js). If it fails we must not start the server against an + // out-of-date schema, so the rejection propagates and the process exits. + await runMigrations(sequelize); + const app = express(); // setup views (still used by templates router for nginx-conf / dnsmasq files) @@ -132,4 +139,7 @@ async function main() { app.listen(PORT, () => console.log(`Server running on http://localhost:${PORT}`)); } -main(); +main().catch(err => { + console.error('Fatal: server failed to start:', err); + process.exit(1); +}); diff --git a/create-a-container/utils/migrate.js b/create-a-container/utils/migrate.js new file mode 100644 index 00000000..f22df855 --- /dev/null +++ b/create-a-container/utils/migrate.js @@ -0,0 +1,177 @@ +'use strict'; + +/** + * utils/migrate.js + * + * Programmatic database migration runner executed at process startup. + * + * Migrations are applied with Umzug (the same library, and the same v2 API, + * that sequelize-cli uses internally) against the `SequelizeMeta` table, so the + * set of already-applied migrations is shared byte-for-byte with the + * `sequelize db:migrate` CLI command. Migrations that have already run are + * skipped. + * + * To guarantee that only one process applies migrations at a time (e.g. when a + * server is restarting while another instance boots, or alongside any legacy + * init tooling), the run is wrapped in an engine-appropriate ADVISORY LOCK: + * + * - PostgreSQL: pg_advisory_lock / pg_advisory_unlock (session-scoped). The + * lock is taken and released on a single dedicated connection that is held + * open for the entire migration batch. + * - MySQL/MariaDB: GET_LOCK / RELEASE_LOCK (session-scoped), likewise on a + * single dedicated connection. + * - SQLite: no advisory-lock primitive exists and concurrent processes + * sharing one SQLite file is not a supported production topology, so + * locking is skipped (SQLite already serializes writers via its file lock). + * + * On failure the error is propagated so the caller can exit non-zero and let + * the service manager (systemd Restart=on-failure) retry. + */ + +const path = require('path'); +const Umzug = require('umzug'); + +// A stable, application-specific identifier for the migration advisory lock. +// Postgres advisory locks are keyed by a signed 64-bit integer; we derive two +// 32-bit "classid"/"objid" halves from a hash of this string and use the +// two-argument form pg_advisory_lock(int4, int4) so the constant is readable +// and collision-resistant against other advisory-lock users. +const LOCK_NAMESPACE = 'create-a-container:migrations'; + +/** + * Deterministically derive a pair of signed 32-bit integers from a string, + * suitable for pg_advisory_lock(classid int4, objid int4). Uses a simple + * FNV-1a-style hash; the exact algorithm does not matter as long as it is + * stable across processes and versions. + * + * @param {string} str + * @returns {{ classId: number, objId: number }} + */ +function deriveLockKey(str) { + // Two independent 32-bit hashes (different offsets) for the two halves. + const hash32 = (seed) => { + let h = seed >>> 0; + for (let i = 0; i < str.length; i++) { + h ^= str.charCodeAt(i); + h = Math.imul(h, 0x01000193); // FNV prime + } + // Convert the unsigned 32-bit result to a signed int4 for Postgres. + return h | 0; + }; + return { classId: hash32(0x811c9dc5), objId: hash32(0x7ee3623b) }; +} + +/** + * Build the Umzug v2 instance bound to the project's migrations directory and + * the shared Sequelize connection. Each migration is invoked as + * `up(queryInterface, Sequelize)` / `down(queryInterface, Sequelize)`, matching + * sequelize-cli's calling convention. + * + * @param {import('sequelize').Sequelize} sequelize + * @returns {Umzug.Umzug} + */ +function buildUmzug(sequelize) { + return new Umzug({ + storage: 'sequelize', + storageOptions: { sequelize }, + logging: (...args) => console.log('[migrate]', ...args), + migrations: { + path: path.join(__dirname, '..', 'migrations'), + // Pass the same params sequelize-cli passes to migration up()/down(). + // `sequelize.constructor` is the Sequelize class (carries DataTypes etc.). + params: [sequelize.getQueryInterface(), sequelize.constructor], + pattern: /^\d.+\.js$/, + }, + }); +} + +/** + * Acquire the advisory lock for the given dialect on a dedicated connection, + * returning an async release function. For SQLite (or any dialect without an + * advisory-lock primitive) this is a no-op. + * + * The returned release function never throws; it logs and swallows errors so a + * failure to unlock cannot mask the real migration outcome. + * + * @param {import('sequelize').Sequelize} sequelize + * @returns {Promise<() => Promise>} + */ +async function acquireAdvisoryLock(sequelize) { + const dialect = sequelize.getDialect(); + + if (dialect === 'postgres') { + const { classId, objId } = deriveLockKey(LOCK_NAMESPACE); + // Hold a single connection for the lifetime of the lock; session-level + // advisory locks belong to the connection that acquired them. + const connection = await sequelize.connectionManager.getConnection(); + console.log(`[migrate] acquiring postgres advisory lock (${classId}, ${objId})...`); + await connection.query('SELECT pg_advisory_lock($1, $2)', [classId, objId]); + console.log('[migrate] postgres advisory lock acquired'); + return async () => { + try { + await connection.query('SELECT pg_advisory_unlock($1, $2)', [classId, objId]); + console.log('[migrate] postgres advisory lock released'); + } catch (err) { + console.error('[migrate] failed to release postgres advisory lock:', err); + } finally { + sequelize.connectionManager.releaseConnection(connection); + } + }; + } + + if (dialect === 'mysql' || dialect === 'mariadb') { + // MySQL GET_LOCK is keyed by a string name; -1 = wait indefinitely. + const connection = await sequelize.connectionManager.getConnection(); + console.log(`[migrate] acquiring mysql advisory lock "${LOCK_NAMESPACE}"...`); + const [rows] = await connection.query('SELECT GET_LOCK(?, ?) AS acquired', [LOCK_NAMESPACE, -1]); + const acquired = rows && rows[0] && Number(rows[0].acquired) === 1; + if (!acquired) { + sequelize.connectionManager.releaseConnection(connection); + throw new Error(`Could not acquire MySQL advisory lock "${LOCK_NAMESPACE}" for migrations`); + } + console.log('[migrate] mysql advisory lock acquired'); + return async () => { + try { + await connection.query('SELECT RELEASE_LOCK(?)', [LOCK_NAMESPACE]); + console.log('[migrate] mysql advisory lock released'); + } catch (err) { + console.error('[migrate] failed to release mysql advisory lock:', err); + } finally { + sequelize.connectionManager.releaseConnection(connection); + } + }; + } + + // sqlite (default) and anything else: no advisory lock available/needed. + console.log(`[migrate] dialect "${dialect}" has no advisory lock; relying on engine-level serialization`); + return async () => {}; +} + +/** + * Run all pending database migrations, serialized by an engine-appropriate + * advisory lock. Resolves once migrations are up to date; rejects (without + * starting the server) if any migration fails. + * + * @param {import('sequelize').Sequelize} sequelize - the shared connection from ../models + * @returns {Promise} names of migrations that were applied this run + */ +async function runMigrations(sequelize) { + const release = await acquireAdvisoryLock(sequelize); + try { + const umzug = buildUmzug(sequelize); + const pending = await umzug.pending(); + if (pending.length === 0) { + console.log('[migrate] database schema is up to date; no migrations to run'); + return []; + } + console.log(`[migrate] applying ${pending.length} pending migration(s)...`); + const applied = await umzug.up(); + const names = applied.map((m) => m.file); + console.log(`[migrate] applied ${names.length} migration(s): ${names.join(', ')}`); + return names; + } finally { + await release(); + } +} + +module.exports = { runMigrations }; diff --git a/images/manager/container-creator-init.service b/images/manager/container-creator-init.service index 8c9feac2..e898e2ee 100644 --- a/images/manager/container-creator-init.service +++ b/images/manager/container-creator-init.service @@ -22,9 +22,7 @@ ExecStart=/bin/bash -e -c '\ echo "POSTGRES_HOST=$${POSTGRES_HOST}" >> /etc/default/container-creator; \ echo "POSTGRES_DATABASE=$${POSTGRES_DATABASE}" >> /etc/default/container-creator; \ echo "POSTGRES_USER=$${POSTGRES_USER}" >> /etc/default/container-creator; \ - echo "POSTGRES_PASSWORD=$${POSTGRES_PASSWORD}" >> /etc/default/container-creator; \ - node_modules/.bin/sequelize db:migrate; \ - node_modules/.bin/sequelize db:seed:all;' + echo "POSTGRES_PASSWORD=$${POSTGRES_PASSWORD}" >> /etc/default/container-creator;' [Install] WantedBy=multi-user.target From ededa0cb12862e2e3fbdd861060b68b348b54693 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Tue, 23 Jun 2026 10:58:33 -0400 Subject: [PATCH 2/4] refactor(create-a-container): convert dev-bootstrap into a dev seeder Now that seeders are development-only (production data lives in migrations), move the local-dev provisioning out of bin/dev-bootstrap.js into a proper Sequelize seeder. It runs automatically via `make dev` (npm run db:migrate -> sequelize-cli db:seed:all) instead of a separate `node bin/dev-bootstrap.js` step. Behavior is preserved: it idempotently creates a localhost Site, a local-dummy Node (nodeType: dummy), and a localhost ExternalDomain, and is a NO-OP when any Site already exists. Seeders use the default `none` storage (they re-run on every db:seed:all), so the existing-site guard keeps it safe to re-run. It also no-ops when NODE_ENV=production so dev-only data never seeds production. - Add seeders/20260616000000-seed-dev-environment.js (queryInterface-based). - Remove bin/dev-bootstrap.js and its `make dev` invocation; update the Makefile comment to note seeding now happens via db:seed:all. --- create-a-container/Makefile | 8 +- create-a-container/bin/dev-bootstrap.js | 96 --------------- .../20260616000000-seed-dev-environment.js | 111 ++++++++++++++++++ 3 files changed, 115 insertions(+), 100 deletions(-) delete mode 100644 create-a-container/bin/dev-bootstrap.js create mode 100644 create-a-container/seeders/20260616000000-seed-dev-environment.js diff --git a/create-a-container/Makefile b/create-a-container/Makefile index 8888c403..c7523dc9 100644 --- a/create-a-container/Makefile +++ b/create-a-container/Makefile @@ -53,9 +53,10 @@ build: deps # --no-package-lock`. This is a fast, incremental install that keeps # devDependencies and runs the server's patch-package postinstall, but never # rewrites package-lock.json (so `make dev` leaves the lockfile untouched even -# when the local npm would otherwise renormalize it). Then run migrations, seed -# a localhost site + dummy node, build the client once, and run three processes -# together via concurrently: +# when the local npm would otherwise renormalize it). Then run migrations (which +# also runs the dev-only seeders, e.g. a localhost site + dummy node, via +# `db:seed:all`), build the client once, and run three processes together via +# concurrently: # - server (nodemon, restarts on change) # - job-runner (so container creation exercises the real POST /containers -> # Job -> job-runner -> bin/create-container.js -> DummyApi path) @@ -72,7 +73,6 @@ dev: npm install --no-package-lock npm --prefix client install --no-package-lock npm run db:migrate - node bin/dev-bootstrap.js npm --prefix client run build @echo "Starting Manager (server + job-runner + client watch) at http://localhost:3000 ..." $(LOG_LEVEL_PREFIX)npx concurrently -n server,jobs,client -c blue,magenta,green \ diff --git a/create-a-container/bin/dev-bootstrap.js b/create-a-container/bin/dev-bootstrap.js deleted file mode 100644 index f440322d..00000000 --- a/create-a-container/bin/dev-bootstrap.js +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env node -/** - * dev-bootstrap.js - * - * Provisions the minimum data needed to use the Manager locally WITHOUT a real - * Proxmox cluster. Intended to be run by `make dev` (and safe to run by hand). - * - * It creates, idempotently: - * - a `localhost` Site, - * - a `dummy` Node (nodeType: 'dummy') that acts as a mock hypervisor, and - * - a `localhost` ExternalDomain (so image templates that expose an HTTP port, - * which auto-add an HTTP service, have a domain to bind to — otherwise the - * new-container form is unsubmittable). - * - * IMPORTANT: This is deliberately NOT wired into the `/auth/dev` login route. - * Doing so previously clobbered the Docker Compose bootstrap, which calls - * `POST /auth/dev` before creating its own "Development" site — stealing site - * id 1 and importing real nodes/containers into the wrong site. - * - * To stay clear of that path entirely, this script is a NO-OP if ANY Site - * already exists. The Docker stack always creates its own site, so running this - * there does nothing. It only ever populates a fresh, empty local database. - * - * Refuses to run when NODE_ENV === 'production'. - * - * Exit code 0 = success (including the intentional no-op). - */ - -const path = require('path'); -const db = require(path.join(__dirname, '..', 'models')); -const { sequelize, Site, Node, ExternalDomain } = db; - -const DUMMY_NODE_NAME = 'local-dummy'; - -async function main() { - if (process.env.NODE_ENV === 'production') { - console.error('dev-bootstrap: refusing to run in production'); - process.exit(1); - } - - await sequelize.authenticate(); - - // No-op if any site exists — never interfere with an existing environment - // (e.g. the Docker Compose "Development" site). - const existingSiteCount = await Site.count(); - if (existingSiteCount > 0) { - console.log( - `dev-bootstrap: ${existingSiteCount} site(s) already present — nothing to do.`, - ); - return; - } - - console.log('dev-bootstrap: empty database detected, provisioning local dev environment...'); - - const site = await Site.create({ - name: 'localhost', - internalDomain: 'localhost', - dhcpRange: '10.0.0.100,10.0.0.250', - subnetMask: '255.255.255.0', - gateway: '10.0.0.1', - dnsForwarders: '1.1.1.1,8.8.8.8', - externalIp: '127.0.0.1', - }); - console.log(` • Site "localhost" created (id=${site.id})`); - - const node = await Node.create({ - name: DUMMY_NODE_NAME, - nodeType: 'dummy', - siteId: site.id, - ipv4Address: '127.0.0.1', - // Placeholder credentials: a dummy node never talks to a real hypervisor, - // but these must be non-null so code that gates on "has API credentials" - // (e.g. the live container-status resolver) routes through node.api() and - // gets the DummyApi instead of treating the node as unreachable. - apiUrl: 'local', - tokenId: 'local', - secret: 'local', - nvidiaAvailable: false, - }); - console.log(` • Dummy Node "${node.name}" created (id=${node.id}, nodeType=dummy)`); - - const domain = await ExternalDomain.create({ - name: 'localhost', - siteId: site.id, - }); - console.log(` • ExternalDomain "localhost" created (id=${domain.id})`); - - console.log('dev-bootstrap: done. The Manager can now create containers locally (simulated).'); -} - -main() - .then(() => process.exit(0)) - .catch((err) => { - console.error('dev-bootstrap failed:', err.message); - process.exit(1); - }); diff --git a/create-a-container/seeders/20260616000000-seed-dev-environment.js b/create-a-container/seeders/20260616000000-seed-dev-environment.js new file mode 100644 index 00000000..23201c08 --- /dev/null +++ b/create-a-container/seeders/20260616000000-seed-dev-environment.js @@ -0,0 +1,111 @@ +'use strict'; + +/** + * Local development environment seeder. + * + * Provisions the minimum data needed to use the Manager locally WITHOUT a real + * Proxmox cluster. Run automatically by `make dev` (via `npm run db:migrate`, + * which calls `sequelize-cli db:seed:all`) and safe to run by hand with + * `npx sequelize-cli db:seed:all`. + * + * It creates, idempotently: + * - a `localhost` Site, + * - a `local-dummy` Node (nodeType: 'dummy') that acts as a mock hypervisor, + * and + * - a `localhost` ExternalDomain (so image templates that expose an HTTP port, + * which auto-add an HTTP service, have a domain to bind to — otherwise the + * new-container form is unsubmittable). + * + * IMPORTANT: This is deliberately NOT wired into the `/auth/dev` login route. + * Doing so previously clobbered the Docker Compose bootstrap, which calls + * `POST /auth/dev` before creating its own "Development" site — stealing site + * id 1 and importing real nodes/containers into the wrong site. + * + * To stay clear of that path entirely, this seeder is a NO-OP if ANY Site + * already exists. The Docker stack always creates its own site, so running this + * there does nothing. It only ever populates a fresh, empty local database. + * + * It is also a NO-OP when NODE_ENV === 'production' — this is development-only + * data and must never seed a production database. + */ + +const DUMMY_NODE_NAME = 'local-dummy'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + if (process.env.NODE_ENV === 'production') { + console.log('seed dev-environment: NODE_ENV=production — skipping (dev-only data).'); + return; + } + + // No-op if any site exists — never interfere with an existing environment + // (e.g. the Docker Compose "Development" site). + const [siteRows] = await queryInterface.sequelize.query('SELECT COUNT(*) AS count FROM "Sites"'); + const existingSiteCount = Number(siteRows[0].count); + if (existingSiteCount > 0) { + console.log( + `seed dev-environment: ${existingSiteCount} site(s) already present — nothing to do.`, + ); + return; + } + + console.log('seed dev-environment: empty database detected, provisioning local dev environment...'); + + const now = new Date(); + + await queryInterface.bulkInsert('Sites', [{ + name: 'localhost', + internalDomain: 'localhost', + dhcpRange: '10.0.0.100,10.0.0.250', + subnetMask: '255.255.255.0', + gateway: '10.0.0.1', + dnsForwarders: '1.1.1.1,8.8.8.8', + externalIp: '127.0.0.1', + createdAt: now, + updatedAt: now, + }]); + + // Fetch the id of the site just created so the node/domain can reference it. + const [createdSiteRows] = await queryInterface.sequelize.query( + `SELECT id FROM "Sites" WHERE name = 'localhost' ORDER BY id ASC LIMIT 1`, + ); + const siteId = createdSiteRows[0].id; + console.log(` • Site "localhost" created (id=${siteId})`); + + await queryInterface.bulkInsert('Nodes', [{ + name: DUMMY_NODE_NAME, + nodeType: 'dummy', + siteId, + ipv4Address: '127.0.0.1', + // Placeholder credentials: a dummy node never talks to a real hypervisor, + // but these must be non-null so code that gates on "has API credentials" + // (e.g. the live container-status resolver) routes through node.api() and + // gets the DummyApi instead of treating the node as unreachable. + apiUrl: 'local', + tokenId: 'local', + secret: 'local', + nvidiaAvailable: false, + createdAt: now, + updatedAt: now, + }]); + console.log(` • Dummy Node "${DUMMY_NODE_NAME}" created (nodeType=dummy)`); + + await queryInterface.bulkInsert('ExternalDomains', [{ + name: 'localhost', + siteId, + createdAt: now, + updatedAt: now, + }]); + console.log(' • ExternalDomain "localhost" created'); + + console.log('seed dev-environment: done. The Manager can now create containers locally (simulated).'); + }, + + async down(queryInterface) { + // Remove only the specific local-dev records this seeder creates. + await queryInterface.bulkDelete('Nodes', { name: DUMMY_NODE_NAME }, {}); + await queryInterface.bulkDelete('ExternalDomains', { name: 'localhost' }, {}); + await queryInterface.bulkDelete('Sites', { name: 'localhost' }, {}); + } +}; From c6aec84a1ffd856002f387d145362b912e938469 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Tue, 23 Jun 2026 11:01:58 -0400 Subject: [PATCH 3/4] docs(create-a-container): simplify and correct the component README The README had grown to ~665 lines of largely inaccurate detail: manual clone/npm/systemd install steps that no longer match how the app is deployed (it ships as an OCI image and as deb/rpm/apk packages), MySQL/utf8mb4 and SESSION_SECRET instructions that don't apply (production is PostgreSQL; session secrets are auto-generated), plus stale API-route, schema, and job-runner testing dumps. Replace it with a concise component reference: - Development is just `make dev` (SQLite + dummy hypervisor, no .env/Proxmox). - Production merely notes the OCI image and the deb/rpm/apk package options, deferring to the Installation Guide. - Keep accurate, current notes on configuration (example.env), startup migrations/seeders, the OpenAPI/Swagger entry point, and the directory layout, with links to the canonical docs. --- create-a-container/README.md | 709 ++++------------------------------- 1 file changed, 83 insertions(+), 626 deletions(-) diff --git a/create-a-container/README.md b/create-a-container/README.md index f7a388a0..cedcdbb8 100644 --- a/create-a-container/README.md +++ b/create-a-container/README.md @@ -1,665 +1,122 @@ -# Create-a-Container +# Create-a-Container (Manager) -A web application for managing LXC container creation, configuration, and lifecycle on Proxmox VE infrastructure. Provides a user-friendly interface and REST API for container management with automated database tracking and nginx reverse proxy configuration generation. +The Manager web application for opensource-server: a Node.js + Express + Sequelize +app that manages LXC container creation, configuration, and lifecycle on Proxmox +VE, exposes a REST API, and generates the nginx/dnsmasq configuration consumed by +agents. -## Data Model +This README documents the component itself. For installing, operating, or +developing the wider system, start with the guides below. -```mermaid -erDiagram - Node ||--o{ Container : "hosts" - Container ||--o{ Service : "exposes" - - Node { - int id PK - string name UK "Proxmox node name" - string apiUrl "Proxmox API URL" - boolean tlsVerify "Verify TLS certificates" - datetime createdAt - datetime updatedAt - } - - Container { - int id PK - string hostname UK "FQDN hostname" - string username "Owner username" - string template "Template name" - int creationJobId FK "References Job" - int nodeId FK "References Node" - int containerId UK "Proxmox VMID" - string macAddress UK "MAC address (nullable)" - string ipv4Address UK "IPv4 address (nullable)" - string aiContainer "Node type flag" - datetime createdAt - datetime updatedAt - } - - Service { - int id PK - int containerId FK "References Container" - enum type "tcp, udp, or http" - int internalPort "Port inside container" - int externalPort "External port (tcp/udp only)" - boolean tls "TLS enabled (tcp only)" - string externalHostname UK "Public hostname (http only)" - datetime createdAt - datetime updatedAt - } -``` - -**Key Constraints:** -- `(Node.name)` - Unique -- `(Container.hostname)` - Unique -- `(Container.nodeId, Container.containerId)` - Unique (same VMID can exist on different nodes) -- `(Service.externalHostname)` - Unique when type='http' -- `(Service.type, Service.externalPort)` - Unique when type='tcp' or type='udp' - -## Features - -- **User Authentication** - Proxmox VE authentication integration -- **Container Management** - Create, list, and track LXC containers -- **Docker/OCI Support** - Pull and deploy containers from Docker Hub, GHCR, or any OCI registry -- **Service Registry** - Track HTTP/TCP/UDP services running on containers -- **Dynamic Nginx Config** - Generate nginx reverse proxy configurations on-demand -- **Real-time Progress** - SSE (Server-Sent Events) for container creation progress -- **User Registration** - Self-service account request system with email notifications -- **Rate Limiting** - Protection against abuse (100 requests per 15 minutes) - -## Prerequisites - -### System Requirements -- **Node.js** 18.x or higher -- **PostgreSQL** 16 or higher -- **Proxmox VE** cluster with API access -- **SMTP server** for email notifications (optional) - -### Services -```bash -# Install Node.js (Debian/Ubuntu) -curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - -sudo apt-get install -y nodejs - -# Install PostgreSQL -sudo apt-get install postgresql -y -``` - -## Installation - -### 1. Clone Repository -```bash -cd /opt -sudo git clone https://github.com/mieweb/opensource-server.git -cd opensource-server/create-a-container -``` - -### 2. Install Dependencies -```bash -npm install -``` - -### 3. Database Setup - -#### Create Database and User -```sql -CREATE DATABASE opensource_containers CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -CREATE USER 'container_manager'@'localhost' IDENTIFIED BY 'secure_password_here'; -GRANT ALL PRIVILEGES ON opensource_containers.* TO 'container_manager'@'localhost'; -FLUSH PRIVILEGES; -``` - -#### Run Migrations - -Migrations run **automatically when the server starts** (`server.js` applies all -pending migrations before listening, serialized across processes by a database -advisory lock). For a fresh database you can simply start the server, or run -them explicitly: - -```bash -npm run db:migrate -``` - -This creates the following tables: -- `Containers` - Container records (hostname, IP, MAC, OS, etc.) -- `Services` - Service mappings (ports, protocols, hostnames) - -### 4. Configuration - -Create a `.env` file in the `create-a-container` directory: - -```bash -# Database Configuration -POSTGRES_HOST=localhost -POSTGRES_USER=cluster_manager -POSTGRES_PASSWORD=secure_password_here -POSTGRES_DATABASE=cluster_manager -DATABASE_DIALECT=postgres - -# Session Configuration -SESSION_SECRET=generate_random_secret_here - -# Application -NODE_ENV=production -``` - -#### Generate Session Secret -```bash -node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" -``` - -### 5. Start Application - -#### Development Mode (with auto-reload) -```bash -npm run dev -``` - -#### Production Mode -```bash -node server.js -``` - -#### As a System Service -Create `/etc/systemd/system/create-a-container.service`: -```ini -[Unit] -Description=Create-a-Container Service -After=network.target mariadb.service - -[Service] -Type=simple -User=www-data -WorkingDirectory=/opt/opensource-server/create-a-container -Environment=NODE_ENV=production -ExecStart=/usr/bin/node server.js -Restart=always -RestartSec=10 - -[Install] -WantedBy=multi-user.target -``` - -Enable and start: -```bash -sudo systemctl daemon-reload -sudo systemctl enable create-a-container -sudo systemctl start create-a-container -sudo systemctl status create-a-container -``` - -## API Routes - -### Authentication Routes - -#### `GET /login` -Display login page - -#### `POST /login` -Authenticate user with Proxmox VE credentials -- **Body**: `{ username, password }` -- **Returns**: `{ success: true, redirect: "/" }` - -#### `POST /logout` -End user session - -### Container Management Routes - -#### `GET /` -Redirect to `/containers` - -#### `GET /containers` (Auth Required) -List all containers for authenticated user -- **Returns**: HTML page with container list - -#### `GET /containers/new` (Auth Required) -Display container creation form - -#### `POST /containers` -Create a container asynchronously via a background job -- **Body**: `{ hostname, template, customTemplate, services }` where: - - `hostname`: Container hostname - - `template`: Template selection in format "nodeName,vmid" OR "custom" for Docker images - - `customTemplate`: Docker image reference when template="custom" (e.g., `nginx`, `nginx:alpine`, `myorg/myapp:v1`, `ghcr.io/org/image:tag`) - - `services`: Object of service definitions -- **Returns**: Redirect to containers list with flash message -- **Process**: Creates pending container, services, and job in a single transaction. Docker image references are normalized to full format (`host/org/image:tag`). The job-runner executes the actual Proxmox operations. - -#### `DELETE /containers/:id` (Auth Required) -Delete a container from both Proxmox and the database -- **Path Parameter**: `id` - Container database ID -- **Authorization**: User can only delete their own containers -- **Process**: - 1. Verifies container ownership - 2. Deletes container from Proxmox via API - 3. On success, removes container record from database (cascades to services) -- **Returns**: `{ success: true, message: "Container deleted successfully" }` -- **Errors**: - - `404` - Container not found - - `403` - User doesn't own the container - - `500` - Proxmox API deletion failed or node not configured - -#### Container status (`status` field) -Every container returned by the list, show, and create endpoints includes a -**live** `status` field, computed on demand rather than read from a stored -column. It is resolved by combining the container's run-state in Proxmox (from a -single per-node cluster snapshot) with the state of its create job. Possible -values: -- `running` — online in Proxmox -- `offline` — exists in Proxmox but stopped -- `creating` — no Proxmox VM yet, active create job -- `failed` — no Proxmox VM, create job failed -- `missing` — no Proxmox VM, create succeeded or no create job found -- `unknown` — Proxmox unreachable / node has no API credentials - -The create endpoint (`POST /containers`) returns `creating` immediately, since a -create job is enqueued and there is no Proxmox VM yet. - -#### `GET /status/:jobId` (Auth Required) -View container creation progress page - -#### `GET /api/stream/:jobId` -SSE stream for real-time container creation progress -- **Returns**: Server-Sent Events stream - -### Job Runner & Jobs API Routes - -#### `POST /jobs` (Admin Auth Required) -Enqueue a job for background execution -- **Body**: `{ "command": "" }` -- **Response**: `201 { id, status }` -- **Authorization**: Admin only (prevents arbitrary command execution) -- **Behavior**: Admin's username is recorded in `createdBy` column for audit trail - -#### `GET /jobs/:id` (Auth Required) -Fetch job metadata (command, status, timestamps) -- **Response**: `{ id, command, status, createdAt, updatedAt, createdBy }` -- **Authorization**: Only the job owner or admins may view -- **Returns**: `404` if unauthorized (prevents information leakage) - -#### `GET /jobs/:id/status` (Auth Required) -Fetch job output rows with offset/limit pagination -- **Query Params**: - - `offset` (optional, default 0) - Skip first N rows - - `limit` (optional, max 1000) - Return up to N rows -- **Response**: Array of JobStatus objects `[{ id, jobId, output, createdAt, updatedAt }, ...]` -- **Authorization**: Only the job owner or admins may view -- **Returns**: `404` if unauthorized - -### Job Runner System - -#### Background Job Execution -The job runner (`job-runner.js`) is a background Node.js process that: -1. Polls the `Jobs` table for `pending` status records -2. Claims a job transactionally (sets status to `running` and acquires row lock) -3. Spawns the job command in a shell subprocess -4. Streams stdout/stderr into the `JobStatuses` table in real-time -5. Updates job status to `success` or `failure` on process exit -6. Gracefully cancels running jobs on shutdown (SIGTERM/SIGINT) and marks them `cancelled` - -#### Data Models - -**Job Model** (`models/job.js`) -``` -id INT PRIMARY KEY AUTO_INCREMENT -command VARCHAR(2000) NOT NULL - shell command to execute -createdBy VARCHAR(255) - username of admin who enqueued (nullable for legacy jobs) -status ENUM('pending', 'running', 'success', 'failure', 'cancelled') -createdAt DATETIME -updatedAt DATETIME -``` - -**JobStatus Model** (`models/jobstatus.js`) -``` -id INT PRIMARY KEY AUTO_INCREMENT -jobId INT NOT NULL (FK → Jobs.id, CASCADE delete) -output TEXT - chunk of stdout/stderr from the job -createdAt DATETIME -updatedAt DATETIME -``` - -**Migrations** -- `migrations/20251117120000-create-jobs.js` -- `migrations/20251117120001-create-jobstatuses.js` (includes `updatedAt`) -- `migrations/20251117120002-add-job-createdby.js` (adds nullable `createdBy` column + index) - -#### Running the Job Runner +| If you want to... | Read | +|---|---| +| Install and operate a production deployment | [Installation Guide](../mie-opensource-landing/docs/admins/installation.md) | +| Run the Manager locally to develop or contribute | [Development Workflow](../mie-opensource-landing/docs/developers/development-workflow.md) | +| Understand the system design | [System Architecture](../mie-opensource-landing/docs/developers/system-architecture.md) | -**Development (foreground, logs to stdout)** -```bash -cd create-a-container -npm run job-runner -``` - -**Production (systemd service)** -Copy `systemd/job-runner.service` to `/etc/systemd/system/job-runner.service`: -```bash -sudo cp systemd/job-runner.service /etc/systemd/system/ -sudo systemctl daemon-reload -sudo systemctl enable --now job-runner.service -sudo systemctl status job-runner.service -``` - -#### Configuration +## Running it -**Database** (via `.env`) -- `POSTGRES_HOST`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DATABASE`, `DATABASE_DIALECT` +### Development -**Runner Behavior** (environment variables) -- `JOB_RUNNER_POLL_MS` (default 2000) - Polling interval in milliseconds -- `JOB_RUNNER_CWD` (default cwd) - Working directory for spawned commands -- `NODE_ENV=production` - Recommended for production - -**Systemd Setup** (recommended for production) -Create `/etc/default/container-creator` with DB credentials: ```bash -POSTGRES_HOST=localhost -POSTGRES_USER=cluster_manager -POSTGRES_PASSWORD=secure_password_here -POSTGRES_DATABASE=cluster_manager -DATABASE_DIALECT=postgres -``` - -Update `job-runner.service` to include: -```ini -EnvironmentFile=/etc/default/container-creator +make dev ``` -#### Security Considerations +That's all you need. `make dev` installs dependencies, runs database migrations +and dev seeders, builds the client, and starts the server, the job-runner, and +the client build watcher together. It uses SQLite and a dummy (mock) hypervisor, +so **no `.env`, PostgreSQL, or Proxmox cluster is required** — the Manager comes +up at and can "create" containers locally (simulated). -1. **Command Injection Risk**: The runner spawns commands via shell. Only admins can enqueue jobs via the API. Do not expose `POST /jobs` to untrusted users. -2. **Job Ownership**: Jobs are scoped by `createdBy`. Only the admin who created the job (or other admins) can view its metadata and output. Non-owners receive `404` (not `403`) to prevent information leakage. -3. **Legacy Jobs**: Jobs created before the `createdBy` migration will have `createdBy = NULL` and are visible only to admins. -4. **Graceful Shutdown**: On SIGTERM/SIGINT, the runner kills all running child processes and marks their jobs as `cancelled`. +Pass `LOG_LEVEL=trace` to additionally log every SQL query: -#### Testing & Troubleshooting - -**Insert a test job (SQL)** -```sql -INSERT INTO Jobs (command, status, createdAt, updatedAt) -VALUES ('echo "Hello" && sleep 5 && echo "World"', 'pending', NOW(), NOW()); -``` - -**Inspect job status** -```sql -SELECT id, status, updatedAt FROM Jobs ORDER BY id DESC LIMIT 10; -``` - -**View job output** -```sql -SELECT id, output, createdAt FROM JobStatuses WHERE jobId = 1 ORDER BY id ASC; -``` - -**Long-running test (5 minutes)** -1. Stop runner to keep job pending ```bash -sudo systemctl stop job-runner.service -``` -2. Insert job -```bash -psql -c "INSERT INTO \"Jobs\" (command, status, \"createdAt\", \"updatedAt\") VALUES ('for i in \$(seq 1 300); do echo \"line \$i\"; sleep 1; done', 'pending', NOW(), NOW()) RETURNING id;" -``` -3. Start runner and monitor -```bash -node job-runner.js -# In another terminal: -while sleep 15; do - psql -c "SELECT id, output FROM \"JobStatuses\" WHERE \"jobId\"= ORDER BY id ASC;" -done -``` -4. Check final status -```sql -SELECT id, status FROM Jobs WHERE id = ; +make dev LOG_LEVEL=trace ``` -#### Deployment Checklist - -- [ ] Run migrations: `npm run db:migrate` -- [ ] Deploy `job-runner.js` to target host (e.g., `/opt/container-creator/`) -- [ ] Copy `systemd/job-runner.service` to `/etc/systemd/system/` -- [ ] Create `/etc/default/container-creator` with DB env vars -- [ ] Reload systemd: `sudo systemctl daemon-reload` -- [ ] Enable and start: `sudo systemctl enable --now job-runner.service` -- [ ] Verify runner is running: `sudo systemctl status job-runner.service` -- [ ] Test API by creating a job via `POST /jobs` (admin user) - -#### Future Enhancements - -- Replace raw `command` API with safe task names and parameter mapping -- Add SSE or WebSocket streaming endpoint (`/jobs/:id/stream`) to push log lines to frontend in real-time -- Add batching or file-based logs for high-volume output to reduce DB pressure -- Implement job timeout/deadline and automatic cancellation - -### Configuration Routes - -#### `GET /sites/:siteId/nginx` -Generate nginx configuration for all registered services -- **Returns**: `text/plain` - Complete nginx configuration with all server blocks - -### User Registration Routes - -#### `GET /register` -Display account request form - -#### `POST /register` -Submit account request (sends email to admins) -- **Body**: `{ name, email, username, reason }` -- **Returns**: Success message - -### Utility Routes - -#### `GET /send-test-email` (Dev Only) -Test email configuration (development/testing) - -## Database Schema - -### Containers Table -```sql -id INT PRIMARY KEY AUTO_INCREMENT -hostname VARCHAR(255) UNIQUE NOT NULL -username VARCHAR(255) NOT NULL -status VARCHAR(20) NOT NULL DEFAULT 'pending' -template VARCHAR(255) -creationJobId INT FOREIGN KEY REFERENCES Jobs(id) -nodeId INT FOREIGN KEY REFERENCES Nodes(id) -containerId INT UNSIGNED NOT NULL -macAddress VARCHAR(17) UNIQUE -ipv4Address VARCHAR(45) UNIQUE -aiContainer VARCHAR(50) DEFAULT 'N' -createdAt DATETIME -updatedAt DATETIME -``` - -### Services Table -```sql -id INT PRIMARY KEY AUTO_INCREMENT -containerId INT FOREIGN KEY REFERENCES Containers(id) -type ENUM('tcp', 'udp', 'http') NOT NULL -internalPort INT NOT NULL -externalPort INT -tls BOOLEAN DEFAULT FALSE -externalHostname VARCHAR(255) -createdAt DATETIME -updatedAt DATETIME -``` - -## Configuration Files - -### `config/config.js` -Sequelize database configuration (reads from `.env`) - -### `models/` -- `container.js` - Container model definition -- `service.js` - Service model definition -- `index.js` - Sequelize initialization - -### `data/services.json` -Service type definitions and port mappings - -### `views/` -- `login.html` - Login form -- `form.html` - Container creation form -- `request-account.html` - Account request form -- `status.html` - Container creation progress viewer -- `containers.ejs` - Container list (EJS template) -- `nginx-conf.ejs` - Nginx config generator (EJS template) +For the full Docker-based stack (real reverse proxy, DNS, Proxmox VE), see the +[Development Workflow](../mie-opensource-landing/docs/developers/development-workflow.md). -### `public/` -- `style.css` - Application styles +### Production -### `migrations/` -Database migration files for schema management +The Manager is not installed by hand in production. It ships as: -## Environment Variables +- an **OCI image** (`images/manager`) — the supported deployment, used by the + [Installation Guide](../mie-opensource-landing/docs/admins/installation.md); and +- distribution **packages** built from this directory with `make deb`, `make rpm`, + or `make apk` (via [fpm](https://fpm.readthedocs.io/)), which install the app + under `/opt/opensource-server/create-a-container` and register the + `container-creator` and `job-runner` systemd services. -### Required -- `POSTGRES_HOST` - Database host (default: localhost) -- `POSTGRES_USER` - Database username -- `POSTGRES_PASSWORD` - Database password -- `POSTGRES_DATABASE` - Database name -- `SESSION_SECRET` - Express session secret (cryptographically random string) +In both cases the app runs `server.js` (HTTP API + UI) and `job-runner.js` +(background worker). Database connection settings come from the environment (see +[Configuration](#configuration)); the manager image provisions PostgreSQL and +writes these to `/etc/default/container-creator` on first boot. -### Optional -- `NODE_ENV` - Environment (development/production, default: development) +## Configuration -## Security +Configuration is read from environment variables (a local `.env` file is +supported in development). See [`example.env`](example.env) for the full list, +including the database dialect/connection settings and the optional OIDC +single-sign-on variables. Session secrets are generated and stored automatically +— there is no secret to configure. -### Authentication -- Proxmox VE integration via API -- Session-based authentication with secure cookies -- Per-route authentication middleware +Production uses PostgreSQL (`DATABASE_DIALECT=postgres`); `make dev` uses SQLite. -### Rate Limiting -- 100 requests per 15-minute window per IP -- Protects against brute force and abuse - -### Session Security -- Session secret required for cookie signing -- Secure cookie flag enabled -- Session data server-side only - -### Input Validation -- URL encoding for all parameters -- Sequelize ORM prevents SQL injection -- Form data validation - -## Troubleshooting - -### Database Connection Issues -```bash -# Test database connection -psql -h localhost -U cluster_manager -d cluster_manager - -# Check if migrations ran -npm run db:migrate - -# Verify tables exist -psql -h localhost -U cluster_manager -d cluster_manager -c "\dt" -``` - -### Application Won't Start -```bash -# Check Node.js version -node --version # Should be 18.x or higher - -# Verify .env file exists and is readable -cat .env - -# Check for syntax errors -node -c server.js - -# Run with verbose logging -NODE_ENV=development node server.js -``` - -### Authentication Failing -```bash -# Verify Proxmox API is accessible -curl -k https://10.15.0.4:8006/api2/json/version - -# Check if certificate validation is working -# Edit server.js if using self-signed certs -``` - -### Email Not Sending -```bash -# Test SMTP connection -telnet mail.example.com 25 - -# Test route (development only) -curl http://localhost:3000/send-test-email -``` - -### Port Already in Use -```bash -# Find process using port 3000 -sudo lsof -i :3000 - -# Change port in .env or kill conflicting process -kill -9 -``` - -## Development - -### Database Migrations +## Database migrations & seeders Migrations are applied **automatically at server startup**. `server.js` calls the runner in `utils/migrate.js`, which uses [umzug](https://github.com/sequelize/umzug) -(a runtime/production dependency) to run every pending migration in -`migrations/` before the HTTP server begins listening. Only one process migrates -at a time: the run is wrapped in an engine-appropriate advisory lock -(`pg_advisory_lock` on PostgreSQL, `GET_LOCK` on MySQL; SQLite needs none). If a -migration fails, the process exits non-zero and does not serve traffic. +to run every pending migration in `migrations/` before the HTTP server begins +listening. Only one process migrates at a time: the run is wrapped in an +engine-appropriate advisory lock (`pg_advisory_lock` on PostgreSQL, `GET_LOCK` on +MySQL; SQLite needs none). If a migration fails the process exits non-zero and +does not serve traffic. -`sequelize-cli` is a **dev dependency** used for authoring and ad-hoc migration -management: +`sequelize-cli` is a dev dependency used for authoring and ad-hoc management: ```bash -# Create new migration +# Create a new migration npx sequelize-cli migration:generate --name description-here -# Run migrations + seeders manually (also used for first-time/dev setup) +# Apply migrations + seeders manually npm run db:migrate -# Undo last migration +# Undo the last migration npx sequelize-cli db:migrate:undo ``` -#### Seeders - -The `seeders/` directory is for **test/development data only**. Data that must -exist on every deployment lives in `migrations/` (so it is applied automatically -at startup). Run seeders on demand with `npx sequelize-cli db:seed:all` (also -invoked by `npm run db:migrate`). - -### Code Structure -``` -create-a-container/ -├── server.js # Main Express application -├── package.json # Dependencies and scripts -├── .env # Environment configuration (gitignored) -├── config/ # Sequelize configuration -├── models/ # Database models -├── migrations/ # Database migrations -├── seeders/ # Test/development data seeders -├── views/ # HTML templates -├── public/ # Static assets -├── data/ # JSON data files -└── bin/ # Utility scripts -``` - -## Integration with Nginx Reverse Proxy +The `seeders/` directory is for **development/test data only** (e.g. the local +`make dev` site and dummy node). Data that must exist on every deployment lives in +`migrations/`, so it is applied automatically at startup. -This application generates nginx configurations consumed by the `nginx-reverse-proxy` component: +## API -1. Containers register their services in the database -2. The `/sites/:siteId/nginx` endpoint generates complete nginx configs -3. The reverse proxy polls this endpoint via cron -4. Nginx automatically reloads with updated configurations +The REST API is versioned under `/api/v1` and documented by the OpenAPI spec in +[`openapi.v1.yaml`](openapi.v1.yaml), browsable via the built-in Swagger UI at +`/api` when the server is running. -See `../nginx-reverse-proxy/README.md` for reverse proxy setup. +## Layout -## License - -See the main repository LICENSE file. - -## Support - -For issues, questions, or contributions, see the main opensource-server repository. +``` +create-a-container/ +├── server.js # HTTP API + UI (Express) +├── job-runner.js # Background job worker +├── client/ # React/Vite single-page app (built to client/dist) +├── config/ # Sequelize configuration +├── models/ # Sequelize models +├── migrations/ # Database migrations (applied at startup) +├── seeders/ # Dev/test data seeders +├── routers/ # API and template routers +├── middlewares/ # Express middleware +├── utils/ # Shared helpers (incl. the migration runner) +├── views/ # EJS templates (nginx/dnsmasq config generation) +├── contrib/ # systemd units, logrotate, packaging hooks +└── Makefile # dev, build, and packaging targets +``` + +## License & support + +See the main repository [LICENSE](../LICENSE). For issues, questions, or +contributions, see the [opensource-server](https://github.com/mieweb/opensource-server) +repository. From ac83b6c9bbcf8e6e9615b08a4372ef4c46f592d8 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Tue, 23 Jun 2026 13:13:59 -0400 Subject: [PATCH 4/4] fix(create-a-container): address PR review on migrations & dev seeder - migrate.js: release the dedicated advisory-lock connection back to the pool if acquiring the lock throws (Postgres pg_advisory_lock / MySQL GET_LOCK), so a permission/network error during acquisition no longer leaks a connection across startup retries. - job-runner.js: run migrations on startup too. Both services are advisory-lock serialized and migrations are idempotent, so whichever starts first migrates; this removes the first-boot race where the runner could connect to an un-migrated schema and crash-loop (its systemd unit does not order after a migrated server). - seed-dev-environment.js: quote identifiers/literals via the dialect-aware queryGenerator instead of hard-coded Postgres double quotes, so the dev seeder also works under DATABASE_DIALECT=mysql. --- create-a-container/job-runner.js | 2 ++ .../20260616000000-seed-dev-environment.js | 14 +++++++++++-- create-a-container/server.js | 5 +---- create-a-container/utils/migrate.js | 21 ++++++++++++++++--- 4 files changed, 33 insertions(+), 9 deletions(-) diff --git a/create-a-container/job-runner.js b/create-a-container/job-runner.js index 644b7c97..83a4b850 100644 --- a/create-a-container/job-runner.js +++ b/create-a-container/job-runner.js @@ -10,6 +10,7 @@ const { spawn } = require('child_process'); const path = require('path'); const db = require('./models'); +const { runMigrations } = require('./utils/migrate'); const POLL_INTERVAL_MS = parseInt(process.env.JOB_RUNNER_POLL_MS || '2000', 10); const WORKDIR = process.env.JOB_RUNNER_CWD || process.cwd(); @@ -156,6 +157,7 @@ process.on('SIGTERM', () => { shutdownAndCancelJobs('SIGTERM').catch(err => { co async function start() { console.log('JobRunner starting, working dir:', WORKDIR); + await runMigrations(db.sequelize); await db.sequelize.authenticate(); console.log('DB connected'); loop(); diff --git a/create-a-container/seeders/20260616000000-seed-dev-environment.js b/create-a-container/seeders/20260616000000-seed-dev-environment.js index 23201c08..b1aaa947 100644 --- a/create-a-container/seeders/20260616000000-seed-dev-environment.js +++ b/create-a-container/seeders/20260616000000-seed-dev-environment.js @@ -39,9 +39,17 @@ module.exports = { return; } + // Use the dialect-aware query generator to quote identifiers/literals so the + // raw lookups below work regardless of DATABASE_DIALECT (Postgres uses + // double quotes, MySQL/MariaDB use backticks, etc.). + const qg = queryInterface.queryGenerator; + const sitesTable = qg.quoteTable('Sites'); + // No-op if any site exists — never interfere with an existing environment // (e.g. the Docker Compose "Development" site). - const [siteRows] = await queryInterface.sequelize.query('SELECT COUNT(*) AS count FROM "Sites"'); + const [siteRows] = await queryInterface.sequelize.query( + `SELECT COUNT(*) AS count FROM ${sitesTable}`, + ); const existingSiteCount = Number(siteRows[0].count); if (existingSiteCount > 0) { console.log( @@ -68,7 +76,9 @@ module.exports = { // Fetch the id of the site just created so the node/domain can reference it. const [createdSiteRows] = await queryInterface.sequelize.query( - `SELECT id FROM "Sites" WHERE name = 'localhost' ORDER BY id ASC LIMIT 1`, + `SELECT ${qg.quoteIdentifier('id')} FROM ${sitesTable} ` + + `WHERE ${qg.quoteIdentifier('name')} = ${qg.escape('localhost')} ` + + `ORDER BY ${qg.quoteIdentifier('id')} ASC LIMIT 1`, ); const siteId = createdSiteRows[0].id; console.log(` • Site "localhost" created (id=${siteId})`); diff --git a/create-a-container/server.js b/create-a-container/server.js index 7d90be0c..6ef567e6 100644 --- a/create-a-container/server.js +++ b/create-a-container/server.js @@ -34,10 +34,7 @@ async function getSessionSecrets() { } async function main() { - // Apply any pending database migrations before serving traffic. This is - // serialized across processes by an engine-appropriate advisory lock (see - // utils/migrate.js). If it fails we must not start the server against an - // out-of-date schema, so the rejection propagates and the process exits. + // Apply any pending database migrations before serving traffic await runMigrations(sequelize); const app = express(); diff --git a/create-a-container/utils/migrate.js b/create-a-container/utils/migrate.js index f22df855..05c88286 100644 --- a/create-a-container/utils/migrate.js +++ b/create-a-container/utils/migrate.js @@ -105,7 +105,14 @@ async function acquireAdvisoryLock(sequelize) { // advisory locks belong to the connection that acquired them. const connection = await sequelize.connectionManager.getConnection(); console.log(`[migrate] acquiring postgres advisory lock (${classId}, ${objId})...`); - await connection.query('SELECT pg_advisory_lock($1, $2)', [classId, objId]); + try { + await connection.query('SELECT pg_advisory_lock($1, $2)', [classId, objId]); + } catch (err) { + // Acquisition failed (permission/network error, etc.) — return the + // connection to the pool so it isn't leaked across startup retries. + sequelize.connectionManager.releaseConnection(connection); + throw err; + } console.log('[migrate] postgres advisory lock acquired'); return async () => { try { @@ -123,8 +130,16 @@ async function acquireAdvisoryLock(sequelize) { // MySQL GET_LOCK is keyed by a string name; -1 = wait indefinitely. const connection = await sequelize.connectionManager.getConnection(); console.log(`[migrate] acquiring mysql advisory lock "${LOCK_NAMESPACE}"...`); - const [rows] = await connection.query('SELECT GET_LOCK(?, ?) AS acquired', [LOCK_NAMESPACE, -1]); - const acquired = rows && rows[0] && Number(rows[0].acquired) === 1; + let acquired = false; + try { + const [rows] = await connection.query('SELECT GET_LOCK(?, ?) AS acquired', [LOCK_NAMESPACE, -1]); + acquired = rows && rows[0] && Number(rows[0].acquired) === 1; + } catch (err) { + // Query failed (e.g. connection dropped) before returning — release the + // connection so it isn't leaked across startup retries. + sequelize.connectionManager.releaseConnection(connection); + throw err; + } if (!acquired) { sequelize.connectionManager.releaseConnection(connection); throw new Error(`Could not acquire MySQL advisory lock "${LOCK_NAMESPACE}" for migrations`);