underpost 3.2.11 → 3.2.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ghpkg.ci.yml +1 -0
- package/.github/workflows/npmpkg.ci.yml +9 -5
- package/CHANGELOG.md +76 -1
- package/CLI-HELP.md +906 -1130
- package/README.md +50 -48
- package/bin/build.js +88 -137
- package/bin/build.template.js +23 -179
- package/bin/deploy.js +4 -1
- package/bin/index.js +2 -2
- package/conf.js +11 -37
- package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +2 -2
- package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
- package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
- package/package.json +9 -14
- package/src/cli/deploy.js +0 -228
- package/src/cli/image.js +58 -4
- package/src/cli/monitor.js +190 -6
- package/src/cli/release.js +5 -5
- package/src/cli/repository.js +80 -3
- package/src/cli/run.js +115 -69
- package/src/index.js +1 -1
- package/src/runtime/wp/Dockerfile +3 -3
- package/src/server/catalog-underpost.js +61 -0
- package/src/server/catalog.js +77 -0
- package/src/server/conf.js +336 -50
- package/src/server/start.js +9 -5
- package/test/deploy-monitor.test.js +188 -0
- package/manifests/deployment/dd-test-development/deployment.yaml +0 -256
- package/manifests/deployment/dd-test-development/proxy.yaml +0 -102
package/src/server/conf.js
CHANGED
|
@@ -10,6 +10,7 @@ import dotenv from 'dotenv';
|
|
|
10
10
|
import {
|
|
11
11
|
capFirst,
|
|
12
12
|
getCapVariableName,
|
|
13
|
+
getDirname,
|
|
13
14
|
newInstance,
|
|
14
15
|
orderAbc,
|
|
15
16
|
orderArrayFromAttrInt,
|
|
@@ -1295,16 +1296,19 @@ const validateTemplatePath = (absolutePath = '') => {
|
|
|
1295
1296
|
/**
|
|
1296
1297
|
* @method awaitDeployMonitor
|
|
1297
1298
|
* @description Waits for the deploy monitor.
|
|
1298
|
-
* @param {boolean} [
|
|
1299
|
+
* @param {boolean} [isFinal=false] - If true, logs when the final (non-replica) deployment completes.
|
|
1299
1300
|
* @param {number} [deltaMs=1000] - The delta ms.
|
|
1300
|
-
* @
|
|
1301
|
+
* @param {boolean} [callback=false] - The callback.
|
|
1302
|
+
* @returns {Promise<boolean>} - `false` if `container-status=error` was detected, `true` on clean completion.
|
|
1301
1303
|
* @memberof ServerConfBuilder
|
|
1302
1304
|
*/
|
|
1303
|
-
const awaitDeployMonitor = async (
|
|
1304
|
-
if (
|
|
1305
|
+
const awaitDeployMonitor = async (isFinal = false, deltaMs = 1000, callback = false) => {
|
|
1306
|
+
if (!callback) Underpost.env.set('await-deploy', new Date().toISOString());
|
|
1307
|
+
if (isFinal) logger.info('Final deployment running (no replica)');
|
|
1305
1308
|
await timer(deltaMs);
|
|
1306
|
-
if (Underpost.env.get('container-status') === 'error')
|
|
1307
|
-
if (Underpost.env.get('await-deploy')) return await awaitDeployMonitor();
|
|
1309
|
+
if (Underpost.env.get('container-status') === 'error') return false;
|
|
1310
|
+
if (Underpost.env.get('await-deploy')) return await awaitDeployMonitor(false, deltaMs, true);
|
|
1311
|
+
return true;
|
|
1308
1312
|
};
|
|
1309
1313
|
|
|
1310
1314
|
/**
|
|
@@ -1420,65 +1424,130 @@ const writeEnv = (envPath, envObj) =>
|
|
|
1420
1424
|
|
|
1421
1425
|
/**
|
|
1422
1426
|
* @method buildCliDoc
|
|
1423
|
-
* @description
|
|
1424
|
-
*
|
|
1425
|
-
*
|
|
1426
|
-
*
|
|
1427
|
+
* @description Scrapes `node bin help` (and `node bin help <command>` for every
|
|
1428
|
+
* registered command) and renders a structured Markdown reference: a command
|
|
1429
|
+
* index with anchor links, plus a per-command section with its description,
|
|
1430
|
+
* usage, and Arguments/Options rendered as tables. Writes
|
|
1431
|
+
* `CLI-HELP.md` + the served reference doc, and refreshes the README CLI index.
|
|
1432
|
+
* @param {object} program - The commander program.
|
|
1433
|
+
* @param {string} oldVersion - The old version string to replace.
|
|
1434
|
+
* @param {string} newVersion - The new version string.
|
|
1427
1435
|
* @memberof ServerConfBuilder
|
|
1428
1436
|
*/
|
|
1429
1437
|
const buildCliDoc = (program, oldVersion, newVersion) => {
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
'
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1438
|
+
const help = (args = '') => shellExec(`node bin help${args ? ` ${args}` : ''}`, { silent: true, stdout: true });
|
|
1439
|
+
// Escape table-breaking pipes and collapse wrapped whitespace for a Markdown cell.
|
|
1440
|
+
const cell = (s) => String(s).replace(/\s+/g, ' ').replaceAll('|', '\\|').trim();
|
|
1441
|
+
const anchor = (name) => `underpost-${name}`.toLowerCase().replace(/[^a-z0-9-]/g, '');
|
|
1442
|
+
|
|
1443
|
+
// Parse a commander help block into { usage, description, sections: { Options, Arguments, Commands } }.
|
|
1444
|
+
const parseHelp = (text) => {
|
|
1445
|
+
const lines = text.split('\n');
|
|
1446
|
+
const usageMatch = lines[0].match(/^Usage:\s*(.*)$/);
|
|
1447
|
+
const usage = usageMatch ? usageMatch[1].trim() : '';
|
|
1448
|
+
const sections = {};
|
|
1449
|
+
const descLines = [];
|
|
1450
|
+
let current = null;
|
|
1451
|
+
let buf = [];
|
|
1452
|
+
const flush = () => {
|
|
1453
|
+
if (current) sections[current] = buf.join('\n');
|
|
1454
|
+
buf = [];
|
|
1455
|
+
};
|
|
1456
|
+
for (let i = 1; i < lines.length; i++) {
|
|
1457
|
+
const line = lines[i];
|
|
1458
|
+
const head = line.match(/^([A-Za-z][\w ]*):\s*$/); // top-level "Options:", "Arguments:", "Commands:"
|
|
1459
|
+
if (head) {
|
|
1460
|
+
flush();
|
|
1461
|
+
current = head[1].trim();
|
|
1462
|
+
} else if (current !== null) {
|
|
1463
|
+
buf.push(line);
|
|
1464
|
+
} else {
|
|
1465
|
+
descLines.push(line);
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
flush();
|
|
1469
|
+
return { usage, description: descLines.join('\n').trim(), sections };
|
|
1470
|
+
};
|
|
1471
|
+
|
|
1472
|
+
// Parse a columnar " <term> <description>" section (descriptions may wrap onto
|
|
1473
|
+
// indented continuation lines) into [{ term, desc }].
|
|
1474
|
+
const parseEntries = (text = '') => {
|
|
1475
|
+
const entries = [];
|
|
1476
|
+
for (const line of text.split('\n')) {
|
|
1477
|
+
if (!line.trim()) continue;
|
|
1478
|
+
const leading = line.length - line.trimStart().length;
|
|
1479
|
+
if (leading <= 2) {
|
|
1480
|
+
const rest = line.trim();
|
|
1481
|
+
const gap = rest.search(/\s{2,}/);
|
|
1482
|
+
entries.push(gap === -1 ? { term: rest, desc: '' } : { term: rest.slice(0, gap), desc: rest.slice(gap) });
|
|
1483
|
+
} else if (entries.length) {
|
|
1484
|
+
entries[entries.length - 1].desc += ` ${line.trim()}`;
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
return entries;
|
|
1488
|
+
};
|
|
1489
|
+
|
|
1490
|
+
const table = (head, entries) =>
|
|
1491
|
+
!entries.length
|
|
1492
|
+
? ''
|
|
1493
|
+
: `| ${head[0]} | ${head[1]} |\n| --- | --- |\n` +
|
|
1494
|
+
entries.map(({ term, desc }) => `| \`${cell(term)}\` | ${cell(desc)} |`).join('\n') +
|
|
1495
|
+
'\n';
|
|
1496
|
+
|
|
1497
|
+
const detailSection = (sections, name, head) => {
|
|
1498
|
+
const t = table(head, parseEntries(sections[name]));
|
|
1499
|
+
return t ? `\n#### ${name}\n\n${t}` : '';
|
|
1500
|
+
};
|
|
1501
|
+
|
|
1502
|
+
// ── Top-level index ──
|
|
1503
|
+
const root = parseHelp(help());
|
|
1504
|
+
const commandEntries = parseEntries(root.sections['Commands']).filter((e) => e.term.split(' ')[0] !== 'help');
|
|
1505
|
+
|
|
1506
|
+
const index =
|
|
1507
|
+
`## Underpost CLI\n\n` +
|
|
1508
|
+
(root.description ? `> ${root.description.replace(/\s+/g, ' ')}\n\n` : '') +
|
|
1509
|
+
`**Usage:** \`${root.usage}\`\n\n` +
|
|
1510
|
+
`### Global options\n\n${table(['Option', 'Description'], parseEntries(root.sections['Options']))}\n` +
|
|
1511
|
+
`### Commands\n\n| Command | Description |\n| --- | --- |\n` +
|
|
1512
|
+
commandEntries
|
|
1513
|
+
.map((e) => {
|
|
1514
|
+
const name = e.term.split(' ')[0];
|
|
1515
|
+
return `| [\`${name}\`](#${anchor(name)}) | ${cell(e.desc)} |`;
|
|
1516
|
+
})
|
|
1517
|
+
.join('\n') +
|
|
1518
|
+
'\n';
|
|
1519
|
+
|
|
1520
|
+
// ── Per-command detail ──
|
|
1521
|
+
let details = `\n## Command reference\n`;
|
|
1522
|
+
for (const cmd of program.commands) {
|
|
1523
|
+
const name = cmd._name;
|
|
1524
|
+
if (name === 'help') continue;
|
|
1525
|
+
const cmdHelp = parseHelp(help(name));
|
|
1526
|
+
details +=
|
|
1527
|
+
`\n### \`underpost ${name}\`\n\n` +
|
|
1528
|
+
(cmdHelp.description ? `${cmdHelp.description.replace(/\s+/g, ' ')}\n\n` : '') +
|
|
1529
|
+
`**Usage:** \`${cmdHelp.usage}\`\n` +
|
|
1530
|
+
detailSection(cmdHelp.sections, 'Arguments', ['Argument', 'Description']) +
|
|
1531
|
+
detailSection(cmdHelp.sections, 'Options', ['Option', 'Description']);
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
const md = `${index}${details}`.replaceAll(oldVersion, newVersion);
|
|
1466
1535
|
fs.writeFileSync(`./src/client/public/nexodev/docs/references/Command Line Interface.md`, md, 'utf8');
|
|
1467
1536
|
fs.writeFileSync(`./CLI-HELP.md`, md, 'utf8');
|
|
1468
1537
|
|
|
1469
|
-
// Update README.md:
|
|
1470
|
-
let readme = fs.readFileSync(`./README.md`, 'utf8');
|
|
1471
|
-
readme = readme.replaceAll(oldVersion, newVersion);
|
|
1538
|
+
// Update README.md: bump version and refresh the CLI index between the comment tags.
|
|
1539
|
+
let readme = fs.readFileSync(`./README.md`, 'utf8').replaceAll(oldVersion, newVersion);
|
|
1472
1540
|
const cliStartTag = '<!-- cli-index-start -->';
|
|
1473
1541
|
const cliEndTag = '<!-- cli-index-end -->';
|
|
1474
1542
|
const startIdx = readme.indexOf(cliStartTag);
|
|
1475
1543
|
const endIdx = readme.indexOf(cliEndTag);
|
|
1476
1544
|
if (startIdx !== -1 && endIdx !== -1) {
|
|
1545
|
+
const readmeIndex = index.replace(/\(#(underpost-[a-z0-9-]+)\)/g, '(CLI-HELP.md#$1)');
|
|
1477
1546
|
readme =
|
|
1478
1547
|
readme.substring(0, startIdx) +
|
|
1479
1548
|
cliStartTag +
|
|
1480
1549
|
'\n' +
|
|
1481
|
-
|
|
1550
|
+
readmeIndex.replaceAll(oldVersion, newVersion) +
|
|
1482
1551
|
'\n' +
|
|
1483
1552
|
cliEndTag +
|
|
1484
1553
|
readme.substring(endIdx + cliEndTag.length);
|
|
@@ -1747,6 +1816,219 @@ ${renderHosts}`,
|
|
|
1747
1816
|
return { renderHosts };
|
|
1748
1817
|
};
|
|
1749
1818
|
|
|
1819
|
+
/**
|
|
1820
|
+
* Resolves the concrete deploy ids a build or conf-sync run should iterate over.
|
|
1821
|
+
*
|
|
1822
|
+
* The meta deploy id `dd` fans out to the comma separated ids declared in
|
|
1823
|
+
* `engine-private/deploy/dd.router`; any other value is parsed as a comma separated list.
|
|
1824
|
+
* Entries are trimmed and empties dropped.
|
|
1825
|
+
*
|
|
1826
|
+
* @method resolveDeployList
|
|
1827
|
+
* @param {string} deployId - A deploy id, a comma separated list, or the `dd` meta id.
|
|
1828
|
+
* @returns {string[]} Ordered list of concrete deploy ids.
|
|
1829
|
+
* @memberof ServerConfBuilder
|
|
1830
|
+
*/
|
|
1831
|
+
const resolveDeployList = (deployId) =>
|
|
1832
|
+
(deployId === 'dd' ? fs.readFileSync('./engine-private/deploy/dd.router', 'utf8') : deployId)
|
|
1833
|
+
.split(',')
|
|
1834
|
+
.map((id) => id.trim())
|
|
1835
|
+
.filter(Boolean);
|
|
1836
|
+
|
|
1837
|
+
/**
|
|
1838
|
+
* Syncs a single deploy id's private configuration into its dedicated
|
|
1839
|
+
* `engine-<suffix>-private` repository and pushes the result.
|
|
1840
|
+
*
|
|
1841
|
+
* Idempotent and safe to rerun: the private repo is cloned when missing or reset to a clean
|
|
1842
|
+
* checkout when present, then the deploy id's `conf` folder, matching `replica` and
|
|
1843
|
+
* `itc-scripts` entries, and any caller-supplied `extraPaths` payloads are mirrored. The
|
|
1844
|
+
* commit/push step is a no-op when nothing changed (`silentOnError`).
|
|
1845
|
+
*
|
|
1846
|
+
* @method syncPrivateConf
|
|
1847
|
+
* @param {string} deployId - A concrete deploy id (e.g. `dd-cyberia`), not the `dd` meta id.
|
|
1848
|
+
* @param {string[]} [extraPaths=[]] - Extra `./engine-private` payload paths to mirror (from the
|
|
1849
|
+
* deploy's product catalog), kept out of this module so it stays product-agnostic.
|
|
1850
|
+
* @returns {void}
|
|
1851
|
+
* @memberof ServerConfBuilder
|
|
1852
|
+
*/
|
|
1853
|
+
const syncPrivateConf = (deployId, extraPaths = []) => {
|
|
1854
|
+
const suffix = deployId.split('dd-')[1];
|
|
1855
|
+
const privateRepoName = `engine-${suffix}-private`;
|
|
1856
|
+
const privateGitUri = `${process.env.GITHUB_USERNAME}/${privateRepoName}`;
|
|
1857
|
+
const privateRepoPath = `../${privateRepoName}`;
|
|
1858
|
+
|
|
1859
|
+
if (!fs.existsSync(privateRepoPath)) {
|
|
1860
|
+
shellExec(`cd .. && underpost clone ${privateGitUri}`, { silent: true });
|
|
1861
|
+
} else {
|
|
1862
|
+
shellExec(`cd ${privateRepoPath} && git checkout . && git clean -f -d && underpost pull . ${privateGitUri}`, {
|
|
1863
|
+
silent: true,
|
|
1864
|
+
});
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
const confDest = `${privateRepoPath}/conf/${deployId}`;
|
|
1868
|
+
fs.removeSync(confDest);
|
|
1869
|
+
fs.mkdirSync(confDest, { recursive: true });
|
|
1870
|
+
fs.copySync(`./engine-private/conf/${deployId}`, confDest);
|
|
1871
|
+
|
|
1872
|
+
fs.removeSync(`${privateRepoPath}/replica`);
|
|
1873
|
+
for (const payloadDir of ['replica', 'itc-scripts']) {
|
|
1874
|
+
const srcDir = `./engine-private/${payloadDir}`;
|
|
1875
|
+
if (!fs.existsSync(srcDir)) continue;
|
|
1876
|
+
for (const entry of fs.readdirSync(srcDir))
|
|
1877
|
+
if (entry.match(deployId)) fs.copySync(`${srcDir}/${entry}`, `${privateRepoPath}/${payloadDir}/${entry}`);
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
for (const extraPath of extraPaths) fs.copySync(`./engine-private/${extraPath}`, `${privateRepoPath}/${extraPath}`);
|
|
1881
|
+
|
|
1882
|
+
shellExec(
|
|
1883
|
+
`cd ${privateRepoPath}` +
|
|
1884
|
+
` && git add .` +
|
|
1885
|
+
` && underpost cmt . ci engine-core-conf 'Update ${deployId} conf'` +
|
|
1886
|
+
` && underpost push . ${privateGitUri}`,
|
|
1887
|
+
{ silent: true, silentOnError: true },
|
|
1888
|
+
);
|
|
1889
|
+
};
|
|
1890
|
+
|
|
1891
|
+
/**
|
|
1892
|
+
* Moves a deploy's public template sources into the engine working tree ahead of
|
|
1893
|
+
* the build copy step. Idempotent and safe to rerun: each move is guarded by
|
|
1894
|
+
* `existsSync`, so already-moved or absent sources are skipped rather than throwing.
|
|
1895
|
+
* The `[src, dest]` pairs come from the deploy's product catalog (passed in), so
|
|
1896
|
+
* this module stays product-agnostic.
|
|
1897
|
+
*
|
|
1898
|
+
* @method syncDeployIdSources
|
|
1899
|
+
* @param {Array<[string, string]>} [sourceMoves=[]] - Public `[src, dest]` move pairs.
|
|
1900
|
+
* @returns {boolean} `true` when any sources were declared, else `false`.
|
|
1901
|
+
* @memberof ServerConfBuilder
|
|
1902
|
+
*/
|
|
1903
|
+
const syncDeployIdSources = (sourceMoves = []) => {
|
|
1904
|
+
if (!sourceMoves.length) return false;
|
|
1905
|
+
for (const dir of ['src/api', 'src/client/components', 'src/client/public', 'src/client/services'])
|
|
1906
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1907
|
+
for (const [src, dest] of sourceMoves) if (fs.existsSync(src)) fs.moveSync(src, dest, { overwrite: true });
|
|
1908
|
+
return true;
|
|
1909
|
+
};
|
|
1910
|
+
|
|
1911
|
+
/**
|
|
1912
|
+
* Rebuilds the standalone `pwa-microservices-template` from scratch out of the current
|
|
1913
|
+
* engine source tree.
|
|
1914
|
+
*
|
|
1915
|
+
* Clones the template repo next to the engine when missing, otherwise resets it to a clean
|
|
1916
|
+
* pristine checkout, then syncs every engine-tracked file the template is allowed to carry
|
|
1917
|
+
* ({@link validateTemplatePath}), strips engine-only + product modules, restores the template's
|
|
1918
|
+
* own CI workflows + guest services, and rewrites `package.json` / `package-lock.json` / `README`
|
|
1919
|
+
* so the result is a standalone, installable project. Throws on failure; callers own exit codes.
|
|
1920
|
+
*
|
|
1921
|
+
* Product catalogs are read dynamically ({@link module:src/server/catalog} `loadProductCatalogs`),
|
|
1922
|
+
* so this stays decoupled from — and survives removal of — any product module.
|
|
1923
|
+
*
|
|
1924
|
+
* @method buildTemplate
|
|
1925
|
+
* @param {object} [options]
|
|
1926
|
+
* @param {string} [options.srcPath='./'] - Engine source root to sync from.
|
|
1927
|
+
* @param {string} [options.toPath='../pwa-microservices-template'] - Template output path.
|
|
1928
|
+
* @returns {Promise<void>}
|
|
1929
|
+
* @memberof ServerConfBuilder
|
|
1930
|
+
*/
|
|
1931
|
+
const buildTemplate = async ({ srcPath = './', toPath = '../pwa-microservices-template' } = {}) => {
|
|
1932
|
+
const walk = (await import('ignore-walk')).default;
|
|
1933
|
+
const { TEMPLATE_RESTORE_PATHS, TEMPLATE_KEYWORDS, TEMPLATE_DESCRIPTION } = await import('./catalog-underpost.js');
|
|
1934
|
+
const { loadProductCatalogs } = await import('./catalog.js');
|
|
1935
|
+
const githubUsername = process.env.GITHUB_USERNAME;
|
|
1936
|
+
|
|
1937
|
+
logger.info('Build template', { srcPath, toPath });
|
|
1938
|
+
|
|
1939
|
+
const sourceFiles = (
|
|
1940
|
+
await new Promise((resolve) =>
|
|
1941
|
+
walk({ path: srcPath, ignoreFiles: [`.gitignore`], includeEmpty: false, follow: false }, (...args) =>
|
|
1942
|
+
resolve(args[1]),
|
|
1943
|
+
),
|
|
1944
|
+
)
|
|
1945
|
+
).filter((p) => !p.startsWith('.git'));
|
|
1946
|
+
|
|
1947
|
+
// Clone the template from 0 if missing; otherwise reset it to a clean pristine checkout.
|
|
1948
|
+
if (!fs.existsSync(toPath)) {
|
|
1949
|
+
shellExec(`cd .. && node engine/bin clone ${githubUsername}/pwa-microservices-template`);
|
|
1950
|
+
} else {
|
|
1951
|
+
shellExec(`cd ${toPath} && git reset && git checkout . && git clean -f -d`);
|
|
1952
|
+
shellExec(`node bin pull ${toPath} ${githubUsername}/pwa-microservices-template`);
|
|
1953
|
+
shellExec(`sudo rm -rf ${toPath}/engine-private`);
|
|
1954
|
+
shellExec(`sudo rm -rf ${toPath}/logs`);
|
|
1955
|
+
}
|
|
1956
|
+
shellExec(`cd ${toPath} && git config core.filemode false`);
|
|
1957
|
+
|
|
1958
|
+
for (const copyPath of sourceFiles) {
|
|
1959
|
+
if (copyPath === 'NaN') continue;
|
|
1960
|
+
const absolutePath = `${srcPath}/${copyPath}`;
|
|
1961
|
+
if (!validateTemplatePath(absolutePath)) continue;
|
|
1962
|
+
|
|
1963
|
+
const folder = getDirname(`${toPath}/${copyPath}`);
|
|
1964
|
+
if (!fs.existsSync(folder)) fs.mkdirSync(folder, { recursive: true });
|
|
1965
|
+
|
|
1966
|
+
logger.info('build', `${toPath}/${copyPath}`);
|
|
1967
|
+
fs.copyFileSync(absolutePath, `${toPath}/${copyPath}`);
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
fs.copySync(`./.vscode`, `${toPath}/.vscode`);
|
|
1971
|
+
fs.copySync(`./src/client/public/default`, `${toPath}/src/client/public/default`);
|
|
1972
|
+
|
|
1973
|
+
// Preserve the template's own README + package.json identity before merging engine metadata.
|
|
1974
|
+
for (const checkoutPath of ['README.md', 'package.json']) shellExec(`cd ${toPath} && git checkout ${checkoutPath}`);
|
|
1975
|
+
|
|
1976
|
+
// Strip each product catalog's `stripPaths` (aggregated dynamically) plus the engine-only
|
|
1977
|
+
// workflows, deploy manifests, and product catalog modules.
|
|
1978
|
+
const productStripPaths = (await loadProductCatalogs()).flatMap((c) => c.stripPaths);
|
|
1979
|
+
for (const deletePath of productStripPaths) {
|
|
1980
|
+
const target = `${toPath}/${deletePath}`;
|
|
1981
|
+
if (fs.existsSync(target)) fs.removeSync(target);
|
|
1982
|
+
}
|
|
1983
|
+
shellExec(`rm -rf ${toPath}/.github`);
|
|
1984
|
+
shellExec(`rm -rf ${toPath}/manifests/deployment/dd-*`);
|
|
1985
|
+
shellExec(`rm -rf ${toPath}/src/server/catalog-*`);
|
|
1986
|
+
|
|
1987
|
+
fs.mkdirSync(`${toPath}/.github/workflows`, { recursive: true });
|
|
1988
|
+
for (const restorePath of TEMPLATE_RESTORE_PATHS) {
|
|
1989
|
+
const dest = `${toPath}/${restorePath}`;
|
|
1990
|
+
if (fs.statSync(restorePath).isDirectory()) fs.copySync(restorePath, dest, { overwrite: true });
|
|
1991
|
+
else fs.copyFileSync(restorePath, dest);
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
// ── package.json: take engine deps/scripts/version, keep template identity. ──
|
|
1995
|
+
const originPackageJson = JSON.parse(fs.readFileSync('./package.json', 'utf8'));
|
|
1996
|
+
const templatePackageJson = JSON.parse(fs.readFileSync(`${toPath}/package.json`, 'utf8'));
|
|
1997
|
+
const templateName = templatePackageJson.name;
|
|
1998
|
+
|
|
1999
|
+
templatePackageJson.dependencies = originPackageJson.dependencies;
|
|
2000
|
+
templatePackageJson.devDependencies = originPackageJson.devDependencies;
|
|
2001
|
+
templatePackageJson.version = originPackageJson.version;
|
|
2002
|
+
templatePackageJson.scripts = originPackageJson.scripts;
|
|
2003
|
+
templatePackageJson.overrides = originPackageJson.overrides;
|
|
2004
|
+
templatePackageJson.name = templateName;
|
|
2005
|
+
templatePackageJson.description = TEMPLATE_DESCRIPTION;
|
|
2006
|
+
templatePackageJson.keywords = TEMPLATE_KEYWORDS;
|
|
2007
|
+
delete templatePackageJson.scripts['build:template'];
|
|
2008
|
+
fs.writeFileSync(`${toPath}/package.json`, JSON.stringify(templatePackageJson, null, 4), 'utf8');
|
|
2009
|
+
|
|
2010
|
+
// ── package-lock.json: mirror engine packages, keep template name/version on the root entry. ──
|
|
2011
|
+
const originPackageLockJson = JSON.parse(fs.readFileSync('./package-lock.json', 'utf8'));
|
|
2012
|
+
const templatePackageLockJson = JSON.parse(fs.readFileSync(`${toPath}/package-lock.json`, 'utf8'));
|
|
2013
|
+
const originBasePackageLock = newInstance(templatePackageLockJson.packages['']);
|
|
2014
|
+
templatePackageLockJson.name = templateName;
|
|
2015
|
+
templatePackageLockJson.version = originPackageLockJson.version;
|
|
2016
|
+
templatePackageLockJson.packages = originPackageLockJson.packages;
|
|
2017
|
+
templatePackageLockJson.packages[''].name = templateName;
|
|
2018
|
+
templatePackageLockJson.packages[''].version = originPackageLockJson.version;
|
|
2019
|
+
templatePackageLockJson.packages[''].hasInstallScript = originBasePackageLock.hasInstallScript;
|
|
2020
|
+
templatePackageLockJson.packages[''].license = originBasePackageLock.license;
|
|
2021
|
+
fs.writeFileSync(`${toPath}/package-lock.json`, JSON.stringify(templatePackageLockJson, null, 4), 'utf8');
|
|
2022
|
+
|
|
2023
|
+
fs.writeFileSync(
|
|
2024
|
+
`${toPath}/README.md`,
|
|
2025
|
+
fs
|
|
2026
|
+
.readFileSync('./README.md', 'utf8')
|
|
2027
|
+
.replace('<!-- template-title -->', '#### Base template for pwa/api-rest projects.'),
|
|
2028
|
+
'utf8',
|
|
2029
|
+
);
|
|
2030
|
+
};
|
|
2031
|
+
|
|
1750
2032
|
export {
|
|
1751
2033
|
Config,
|
|
1752
2034
|
loadConf,
|
|
@@ -1794,4 +2076,8 @@ export {
|
|
|
1794
2076
|
loadCronDeployEnv,
|
|
1795
2077
|
cronDeployIdResolve,
|
|
1796
2078
|
etcHostFactory,
|
|
2079
|
+
resolveDeployList,
|
|
2080
|
+
syncPrivateConf,
|
|
2081
|
+
syncDeployIdSources,
|
|
2082
|
+
buildTemplate,
|
|
1797
2083
|
};
|
package/src/server/start.js
CHANGED
|
@@ -211,19 +211,23 @@ class UnderpostStartUp {
|
|
|
211
211
|
shellExec(`node bin env ${replica} ${env}`);
|
|
212
212
|
const replicaCmd = `npm ${runCmd} ${replica}`;
|
|
213
213
|
shellExec(replicaCmd, { async: true, callback: makeDeployCallback(replicaCmd) });
|
|
214
|
-
await awaitDeployMonitor(
|
|
214
|
+
const result = await awaitDeployMonitor();
|
|
215
|
+
if (result !== true) {
|
|
216
|
+
Underpost.env.set('container-status', 'error');
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
215
219
|
}
|
|
216
220
|
}
|
|
217
221
|
shellExec(`node bin env ${deployId} ${env}`);
|
|
218
222
|
const deployCmd = `npm ${runCmd} ${deployId}`;
|
|
219
223
|
shellExec(deployCmd, { async: true, callback: makeDeployCallback(deployCmd) });
|
|
220
|
-
await awaitDeployMonitor(true);
|
|
221
|
-
if (
|
|
224
|
+
const result = await awaitDeployMonitor(true);
|
|
225
|
+
if (result === true) {
|
|
222
226
|
if (env === 'production' && Underpost.env.isInsideContainer()) Underpost.secret.globalSecretClean();
|
|
223
227
|
Underpost.env.set('container-status', `${deployId}-${env}-running-deployment`);
|
|
228
|
+
} else {
|
|
229
|
+
Underpost.env.set('container-status', 'error');
|
|
224
230
|
}
|
|
225
|
-
Underpost.env.set('container-status', 'error');
|
|
226
|
-
throw new Error('Deployment process exited unexpectedly');
|
|
227
231
|
},
|
|
228
232
|
};
|
|
229
233
|
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module deploy-monitor.test
|
|
3
|
+
* @description End-to-end test of the deploy readiness/failure contract between
|
|
4
|
+
* the in-pod runtime (start.js) and the CD-runner monitor
|
|
5
|
+
* (`Underpost.monitor.monitorReadyRunner`), exercised as two real OS processes
|
|
6
|
+
* running in parallel and coordinating through the shared underpost env file.
|
|
7
|
+
*
|
|
8
|
+
* Contract under test:
|
|
9
|
+
*
|
|
10
|
+
* - start.js (in-pod) only writes `container-status`; it never propagates an
|
|
11
|
+
* exit code. On a failed deploy child it sets `container-status=error`; on a
|
|
12
|
+
* healthy one it sets `container-status=<deploy>-<env>-running-deployment`.
|
|
13
|
+
*
|
|
14
|
+
* - monitorReadyRunner (CD runner) reads that value per pod (via
|
|
15
|
+
* `kubectl exec … underpost config get container-status`) and is the side
|
|
16
|
+
* that produces the real process exit: it `throw`s (→ exit 1) on `error`,
|
|
17
|
+
* and returns (→ exit 0) once the pod is K8S-Ready AND reports
|
|
18
|
+
* `running-deployment`.
|
|
19
|
+
*
|
|
20
|
+
* The cluster surface monitorReadyRunner depends on (`sudo`, `kubectl get`,
|
|
21
|
+
* `kubectl exec`) is supplied by tiny fake binaries on the child's PATH: one
|
|
22
|
+
* always-Ready pod whose container-status is read straight from the same env
|
|
23
|
+
* file start.js writes. The env file is redirected under a temp
|
|
24
|
+
* `npm_config_prefix`, so the test needs no cluster, no root, and never touches
|
|
25
|
+
* the machine's global install.
|
|
26
|
+
*
|
|
27
|
+
* Uses 'chai' for assertions.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { expect } from 'chai';
|
|
31
|
+
import fs from 'fs-extra';
|
|
32
|
+
import os from 'node:os';
|
|
33
|
+
import path from 'node:path';
|
|
34
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
35
|
+
import Underpost from '../src/index.js';
|
|
36
|
+
import { shellExec } from '../src/server/process.js';
|
|
37
|
+
|
|
38
|
+
const node = process.execPath;
|
|
39
|
+
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
40
|
+
|
|
41
|
+
const DEPLOY_ID = 'dd-test';
|
|
42
|
+
const ENV = 'production';
|
|
43
|
+
const TRAFFIC = 'green';
|
|
44
|
+
const POD_NAME = `${DEPLOY_ID}-${ENV}-${TRAFFIC}-pod`;
|
|
45
|
+
const RUNNING_STATUS = `${DEPLOY_ID}-${ENV}-running-deployment`;
|
|
46
|
+
|
|
47
|
+
describe('Deploy monitor — start.js ↔ monitorReadyRunner (e2e, parallel processes)', function () {
|
|
48
|
+
this.timeout(40000);
|
|
49
|
+
|
|
50
|
+
let prevPrefix;
|
|
51
|
+
let tmpPrefix;
|
|
52
|
+
let fakeBinDir;
|
|
53
|
+
let envFile;
|
|
54
|
+
let monitorScriptPath;
|
|
55
|
+
|
|
56
|
+
before(() => {
|
|
57
|
+
prevPrefix = process.env.npm_config_prefix;
|
|
58
|
+
tmpPrefix = fs.mkdtempSync(path.join(os.tmpdir(), 'underpost-e2e-'));
|
|
59
|
+
process.env.npm_config_prefix = tmpPrefix;
|
|
60
|
+
|
|
61
|
+
// Materialize the underpost env file and resolve its absolute path (the
|
|
62
|
+
// fake `kubectl exec` reads container-status straight from it).
|
|
63
|
+
Underpost.env.set('container-status', 'init');
|
|
64
|
+
const npmRoot = shellExec('npm root -g', { stdout: true, silent: true, disableLog: true }).trim();
|
|
65
|
+
envFile = path.join(npmRoot, 'underpost', '.env');
|
|
66
|
+
|
|
67
|
+
// Fake cluster surface for monitorReadyRunner: a `sudo` that just drops its
|
|
68
|
+
// flags and execs, and a `kubectl` that reports one always-Ready pod whose
|
|
69
|
+
// container-status comes from the shared env file.
|
|
70
|
+
fakeBinDir = path.join(tmpPrefix, 'fakebin');
|
|
71
|
+
fs.ensureDirSync(fakeBinDir);
|
|
72
|
+
|
|
73
|
+
const sudoPath = path.join(fakeBinDir, 'sudo');
|
|
74
|
+
fs.writeFileSync(
|
|
75
|
+
sudoPath,
|
|
76
|
+
`#!/usr/bin/env bash
|
|
77
|
+
while [[ "$1" == -* || "$1" == "--" ]]; do shift; done
|
|
78
|
+
exec "$@"
|
|
79
|
+
`,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const kubectlPath = path.join(fakeBinDir, 'kubectl');
|
|
83
|
+
fs.writeFileSync(
|
|
84
|
+
kubectlPath,
|
|
85
|
+
`#!/usr/bin/env bash
|
|
86
|
+
verb="$1"; kind="$2"
|
|
87
|
+
if [[ "$verb" == "get" && "$kind" == "pods" ]]; then
|
|
88
|
+
printf 'NAME READY STATUS RESTARTS AGE\\n'
|
|
89
|
+
printf '%s 1/1 Running 0 1m\\n' "$POD_NAME"
|
|
90
|
+
exit 0
|
|
91
|
+
fi
|
|
92
|
+
if [[ "$verb" == "get" && "$kind" == "pod" ]]; then
|
|
93
|
+
printf '{"status":{"conditions":[{"type":"Ready","status":"True"}]}}\\n'
|
|
94
|
+
exit 0
|
|
95
|
+
fi
|
|
96
|
+
if [[ "$verb" == "exec" ]]; then
|
|
97
|
+
grep -E '^container-status=' "$UNDERPOST_ENV_FILE" 2>/dev/null | tail -n1 | sed -E 's/^container-status=//'
|
|
98
|
+
exit 0
|
|
99
|
+
fi
|
|
100
|
+
exit 0
|
|
101
|
+
`,
|
|
102
|
+
);
|
|
103
|
+
fs.chmodSync(sudoPath, 0o755);
|
|
104
|
+
fs.chmodSync(kubectlPath, 0o755);
|
|
105
|
+
|
|
106
|
+
// Real monitorReadyRunner in its own process: exit 0 when it returns (ready),
|
|
107
|
+
// exit 1 when it throws (container-status=error). This is the signal `set -e`
|
|
108
|
+
// turns into a passed/failed GitHub Actions job.
|
|
109
|
+
monitorScriptPath = path.join(tmpPrefix, 'monitor-ready.mjs');
|
|
110
|
+
fs.writeFileSync(
|
|
111
|
+
monitorScriptPath,
|
|
112
|
+
`import Underpost from ${JSON.stringify(pathToFileURL(path.join(repoRoot, 'src/index.js')).href)};
|
|
113
|
+
try {
|
|
114
|
+
await Underpost.monitor.monitorReadyRunner(${JSON.stringify(DEPLOY_ID)}, ${JSON.stringify(ENV)}, ${JSON.stringify(
|
|
115
|
+
TRAFFIC,
|
|
116
|
+
)}, [], 'default');
|
|
117
|
+
process.exit(0);
|
|
118
|
+
} catch (_) {
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
`,
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
after(() => {
|
|
126
|
+
if (prevPrefix === undefined) delete process.env.npm_config_prefix;
|
|
127
|
+
else process.env.npm_config_prefix = prevPrefix;
|
|
128
|
+
fs.removeSync(tmpPrefix);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
beforeEach(() => {
|
|
132
|
+
// Deploy in flight: K8S not-yet-running app phase before start.js reports.
|
|
133
|
+
Underpost.env.set('container-status', `${DEPLOY_ID}-${ENV}-initializing-deployment`);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Spawns the real monitorReadyRunner process with the fake cluster on PATH and
|
|
137
|
+
// resolves with its exit code.
|
|
138
|
+
const spawnMonitor = () =>
|
|
139
|
+
new Promise((resolve) => {
|
|
140
|
+
const prefix =
|
|
141
|
+
`PATH="${fakeBinDir}:$PATH" ` +
|
|
142
|
+
`UNDERPOST_ENV_FILE="${envFile}" ` +
|
|
143
|
+
`POD_NAME="${POD_NAME}" ` +
|
|
144
|
+
`npm_config_prefix="${tmpPrefix}"`;
|
|
145
|
+
shellExec(`${prefix} ${node} ${monitorScriptPath}`, {
|
|
146
|
+
async: true,
|
|
147
|
+
silent: true,
|
|
148
|
+
disableLog: true,
|
|
149
|
+
callback: (code) => resolve(code),
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Models start.js: runs the deploy as an async child and, mirroring its
|
|
154
|
+
// `makeDeployCallback`, writes container-status from the child's exit code.
|
|
155
|
+
// start.js never propagates the failure — it only sets the flag.
|
|
156
|
+
const runStartJs = (shouldFail) =>
|
|
157
|
+
new Promise((resolve) => {
|
|
158
|
+
const deployCmd = `${node} -e "process.exit(${shouldFail ? 1 : 0})"`;
|
|
159
|
+
shellExec(deployCmd, {
|
|
160
|
+
async: true,
|
|
161
|
+
silent: true,
|
|
162
|
+
disableLog: true,
|
|
163
|
+
callback: (code) => {
|
|
164
|
+
if (code !== 0) Underpost.env.set('container-status', 'error');
|
|
165
|
+
else Underpost.env.set('container-status', RUNNING_STATUS);
|
|
166
|
+
resolve(code);
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('error: start.js sets container-status=error and monitorReadyRunner exits 1', async () => {
|
|
172
|
+
const monitorExit = spawnMonitor();
|
|
173
|
+
const deployCode = await runStartJs(true);
|
|
174
|
+
|
|
175
|
+
expect(deployCode).to.not.equal(0);
|
|
176
|
+
expect(Underpost.env.get('container-status', undefined, { disableLog: true })).to.equal('error');
|
|
177
|
+
expect(await monitorExit).to.equal(1);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('success: start.js sets running-deployment and monitorReadyRunner exits 0', async () => {
|
|
181
|
+
const monitorExit = spawnMonitor();
|
|
182
|
+
const deployCode = await runStartJs(false);
|
|
183
|
+
|
|
184
|
+
expect(deployCode).to.equal(0);
|
|
185
|
+
expect(Underpost.env.get('container-status', undefined, { disableLog: true })).to.equal(RUNNING_STATUS);
|
|
186
|
+
expect(await monitorExit).to.equal(0);
|
|
187
|
+
});
|
|
188
|
+
});
|