deploy-workshop-board-series.mjs 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
  1. import fs from 'node:fs';
  2. import path from 'node:path';
  3. import crypto from 'node:crypto';
  4. import { fileURLToPath } from 'node:url';
  5. const sha = text => crypto.createHash('sha256').update(text).digest('hex');
  6. const fail = code => { throw Object.assign(Error(code), { code }); };
  7. const canonical = value => JSON.stringify(value && typeof value === 'object'
  8. ? Array.isArray(value) ? value.map(v => JSON.parse(canonical(v))) : Object.fromEntries(Object.keys(value).sort().map(k => [k, JSON.parse(canonical(value[k]))]))
  9. : value);
  10. /** Preserve the actual deployed function, including identity/account wrappers. Only its artifact whitelist changes. */
  11. export function patchPublicBoardCode(code) {
  12. if (typeof code !== 'string' || !code.includes('ksfGetPublicBoard')) fail('UNRECOGNIZED_PUBLIC_BOARD');
  13. const pattern = /(\bartifact\s*:\s*['"])(objectId\s+[^'"\r\n]+)(['"])/g;
  14. const matches = [...code.matchAll(pattern)];
  15. if (matches.length !== 1) fail('PUBLIC_ARTIFACT_WHITELIST_AMBIGUOUS');
  16. const fields = matches[0][2].split(/\s+/);
  17. if (!['rawText', 'report', 'ownerName', 'version', 'dataMode', 'dashboardState', 'workshopKey'].every(k => fields.includes(k))) fail('PUBLIC_ARTIFACT_CONTRACT_CHANGED');
  18. if (fields.includes('reportSeriesKey')) return code;
  19. const index = fields.indexOf('version');fields.splice(index + 1, 0, 'reportSeriesKey');
  20. const next = code.replace(pattern, (_all, prefix, _fields, suffix) => prefix + fields.join(' ') + suffix);
  21. new Function(next);
  22. return next;
  23. }
  24. export async function deployBoardSeries({ request, manifest = null, apply = false, backupDir }) {
  25. const rows = (await request('GET', '/classes/Function?' + new URLSearchParams({ where: JSON.stringify({ name: 'ksfGetPublicBoard' }), limit: '2' }))).results;
  26. if (!Array.isArray(rows) || rows.length !== 1 || rows[0].name !== 'ksfGetPublicBoard' || rows[0].path !== '/ksfGetPublicBoard') fail('PUBLIC_BOARD_IDENTITY_MISMATCH');
  27. const before = rows[0], next = patchPublicBoardCode(before.code);
  28. const plan = { schema: 'ksf.workshop-board-series.v1', functionName: before.name, objectId: before.objectId,
  29. beforeSha256: sha(before.code), afterSha256: sha(next), changed: next !== before.code,
  30. fieldAdded: 'reportSeriesKey', fullFunctionRebuild: false, businessRowsChanged: 0 };
  31. if (!apply) return { ok: true, readOnly: true, ...plan };
  32. if (!manifest || manifest.objectId !== before.objectId || manifest.afterSha256 !== plan.afterSha256
  33. || ![manifest.beforeSha256, manifest.afterSha256].includes(plan.beforeSha256)) fail('DEPLOYMENT_BASELINE_CHANGED');
  34. if (!plan.changed) return { ok: true, readOnly: true, ...plan, applied: false, alreadyDeployed: true };
  35. if (!path.isAbsolute(backupDir || '')) fail('PRIVATE_BACKUP_REQUIRED');
  36. const schemas = await request('GET', '/schemas');
  37. fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
  38. const backup = path.join(backupDir, 'public-board-before-' + crypto.randomUUID() + '.json');
  39. fs.writeFileSync(backup, JSON.stringify(before), { flag: 'wx', mode: 0o600 });
  40. const route = '/classes/Function/' + before.objectId;
  41. if (sha((await request('GET', route)).code) !== plan.beforeSha256) fail('CONCURRENT_FUNCTION_CHANGE');
  42. let attempted = false;
  43. try {
  44. attempted = true;await request('PUT', route, { code: next });
  45. const after = await request('GET', route);
  46. if (after.code !== next) fail('FUNCTION_READBACK_MISMATCH');
  47. for (const key of ['name', 'path', 'ACL']) if (canonical(after[key] ?? null) !== canonical(before[key] ?? null)) fail('FUNCTION_IDENTITY_OR_ACL_CHANGED');
  48. if (canonical(await request('GET', '/schemas')) !== canonical(schemas)) fail('SCHEMA_OR_PERMISSIONS_CHANGED');
  49. return { ok: true, readOnly: false, ...plan, applied: true, backup,
  50. readbackVerified: true, schemaAndPermissionsPreserved: true, otherFunctionBodiesModified: false };
  51. } catch (error) {
  52. let rollbackComplete = !attempted;
  53. if (attempted) try {
  54. const actual = await request('GET', route);
  55. if (actual.code === before.code) rollbackComplete = true;
  56. else if (actual.code === next) {
  57. await request('PUT', route, { code: before.code });
  58. rollbackComplete = (await request('GET', route)).code === before.code;
  59. }
  60. } catch { rollbackComplete = false; }
  61. error.receipt = { ok: false, ...plan, backup, rollbackComplete, errorCode: error.code || 'DEPLOYMENT_UNCERTAIN' };
  62. throw error;
  63. }
  64. }
  65. if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
  66. try {
  67. const args = process.argv.slice(2), apply = args.includes('--apply');
  68. const root = path.resolve('work/ksf-workshop-board-20261110-r1');
  69. const planPath = path.join(root, 'series-plan.json');
  70. const { request } = await import('../server/scripts/platform-client.mjs');
  71. const result = await deployBoardSeries({ request, apply, manifest: apply ? JSON.parse(fs.readFileSync(planPath, 'utf8')) : null,
  72. backupDir: path.join(root, 'private') });
  73. fs.mkdirSync(root, { recursive: true });
  74. fs.writeFileSync(apply ? path.join(root, 'series-deployment.json') : planPath, JSON.stringify(result, null, 2) + '\n');
  75. console.log(JSON.stringify(result));
  76. } catch (e) {
  77. console.error(JSON.stringify(e.receipt || { ok: false, errorCode: e.code || 'PUBLIC_BOARD_DEPLOYMENT_STOPPED' }));
  78. process.exitCode = 1;
  79. }
  80. }