276 lines
12 KiB
JavaScript
276 lines
12 KiB
JavaScript
// Apply a persisted plan so this instance converges to the source's desired state.
|
|
//
|
|
// The upsert (create/update) half is delegated to Saltcorn's own install_pack:
|
|
// its two-pass FK-aware ordering (bare tables -> refresh -> fields incl. FKs ->
|
|
// constraints -> views -> triggers -> pages -> page_groups) is exactly what we
|
|
// want. install_pack is ADDITIVE-only; the surgical delete half lives in
|
|
// deletes.js.
|
|
//
|
|
// Mandatory pre-processing before handing a pack to install_pack (install_pack
|
|
// mutates its argument and has non-idempotent appenders):
|
|
// * structuredClone the pack (install_pack sorts pack.tables, rewrites min_role,
|
|
// reassigns role.id in place -- must not corrupt the persisted plan).
|
|
// * name = undefined (skip the non-idempotent installed_packs append).
|
|
// * plugins = [] (site-sync never installs plugin config; parity is a pre-flight
|
|
// warning only, and plugin_pack carries the secret deploy_private_key).
|
|
// * event_logs / model_instances = [] (EventLog.create is append-only).
|
|
// * config re-intersected with SYNCABLE_CONFIG_KEYS (never trust the sender;
|
|
// install_pack's setConfig clobbers unconditionally).
|
|
// * tags are installed in a SECOND, tags-only install_pack call, and only the
|
|
// plan's created/updated tags: an updated tag is deleted just before it is
|
|
// re-created (install_pack's existing-tag path appends entries without
|
|
// dedup), and only after the main install succeeded, so a failure there can
|
|
// never leave the target without its tags; unchanged tags are not touched.
|
|
//
|
|
// Safety flow: take the site-wide apply lock (lib/siteLock.js: one apply or
|
|
// rollback per site at a time, across workers and servers), claim the plan
|
|
// atomically, re-check the precondition hash (refuse if the target drifted since
|
|
// preview -- optimistic concurrency), take a metadata snapshot + restore point,
|
|
// apply (checking before every mutating phase that the lock is still held), then
|
|
// one non-silent getState().refresh() so sibling cluster workers converge. On
|
|
// failure the plan's status is restored and the error carries `planId`, so the
|
|
// caller can offer the rollback of a partially applied plan.
|
|
|
|
const Table = require("@saltcorn/data/models/table");
|
|
|
|
const capture = require("./capture");
|
|
const planLib = require("./plan");
|
|
const siteLock = require("./siteLock");
|
|
const deletes = require("./deletes");
|
|
const rowsLib = require("./rows");
|
|
const layoutFiles = require("./layoutFiles");
|
|
const { getEnv } = require("./env");
|
|
const { PLAN_STATUS } = require("./constants");
|
|
|
|
// The statuses a plan can be applied from (an applied plan only with opts.force).
|
|
const APPLY_FROM = [PLAN_STATUS.PENDING, PLAN_STATUS.ROLLED_BACK];
|
|
|
|
|
|
// Build the install_pack input: a deep clone reduced to the upsert-safe surface.
|
|
// Tags are left out (see installTags).
|
|
const buildUpsertPack = (desired) => {
|
|
const pack = structuredClone(desired);
|
|
pack.plugins = [];
|
|
pack.event_logs = [];
|
|
pack.model_instances = [];
|
|
pack.tags = [];
|
|
pack.config = capture.pickSyncableConfig(pack.config);
|
|
return pack;
|
|
};
|
|
|
|
|
|
// Install the plan's created/updated tags with a tags-only install_pack call.
|
|
const installTags = async (plan) => {
|
|
const tags = plan.entities.tags.create.concat(plan.entities.tags.update.map((u) => u.to));
|
|
if (tags.length === 0) {
|
|
return;
|
|
}
|
|
const Tag = require("@saltcorn/data/models/tag");
|
|
for (const u of plan.entities.tags.update) {
|
|
const existing = await Tag.findOne({ name: u.key });
|
|
if (existing) {
|
|
await existing.delete();
|
|
}
|
|
}
|
|
const tagPack = { plugins: [], event_logs: [], model_instances: [] };
|
|
for (const k of capture.PACK_KINDS.filter((kind) => kind !== "code_pages")) {
|
|
tagPack[k] = [];
|
|
}
|
|
tagPack.tags = structuredClone(tags);
|
|
await require("@saltcorn/admin-models/models/pack").install_pack(tagPack, undefined, async () => {}, false);
|
|
};
|
|
|
|
|
|
// install_pack's View.update never changes a view's table binding, so re-point
|
|
// every updated view whose desired table differs from its current one.
|
|
const fixViewTables = async (plan) => {
|
|
const View = require("@saltcorn/data/models/view");
|
|
for (const u of plan.entities.views.update) {
|
|
const cur = View.findOne({ name: u.key });
|
|
const tbl = u.to.table ? Table.findOne({ name: u.to.table }) : null;
|
|
if (!cur || (u.to.table && !tbl)) {
|
|
continue;
|
|
}
|
|
const wantId = tbl ? tbl.id : null;
|
|
if ((cur.table_id || null) !== wantId) {
|
|
await View.update({ table_id: wantId }, cur.id);
|
|
}
|
|
}
|
|
};
|
|
|
|
|
|
// Build a content-hash file fetcher for the row/layout file layers: a signed,
|
|
// response-verified GET of the file by hash from the plan's source peer.
|
|
// `opts.fileFetcher` (a test override, e.g. an in-process resolver) wins. Returns
|
|
// null when there is no peer to fetch from (files are then skipped with a warning).
|
|
const buildFileFetcher = async (loaded, env, opts) => {
|
|
if (typeof opts.fileFetcher === "function") {
|
|
return opts.fileFetcher;
|
|
}
|
|
if (loaded.peer_id === null || loaded.peer_id === undefined) {
|
|
return null;
|
|
}
|
|
const peerRow = await require("./peers").findPeer(loaded.peer_id);
|
|
if (!peerRow) {
|
|
return null;
|
|
}
|
|
const { peerBytes, API_PATHS } = require("./transport");
|
|
return async (hash) => peerBytes(peerRow, env.env_id, API_PATHS.file(hash));
|
|
};
|
|
|
|
|
|
// Flatten every non-fatal problem of an apply into human-readable warnings.
|
|
const collectApplyWarnings = (parts) => {
|
|
const out = parts.notes.slice();
|
|
for (const f of parts.renames.failed) {
|
|
out.push(`rename "${f.from}" -> "${f.to}" not applied (${f.error}); handled as create + gated drop`);
|
|
}
|
|
for (const s of parts.deletes.skipped || []) {
|
|
out.push(`${s.kind} "${s.name}" not deleted: ${s.reason}`);
|
|
}
|
|
return out.concat(parts.fileWarnings, parts.rows.warnings || []);
|
|
};
|
|
|
|
|
|
// The apply proper, run while this caller holds the site lock (lease) and the
|
|
// plan's APPLYING claim.
|
|
const runApply = async (planId, loaded, opts, lease) => {
|
|
const env = await getEnv();
|
|
const notes = [];
|
|
|
|
// Optimistic concurrency: refuse if the target changed since the preview.
|
|
const before = await capture.buildDesiredState();
|
|
if (!opts.force && before.hash !== loaded.precondition_hash) {
|
|
throw new Error("target state changed since this plan was previewed; re-preview the plan before applying");
|
|
}
|
|
|
|
// Defense in depth: re-sanitize the desired pack on the mutating side.
|
|
const desired = capture.sanitizeDesired(loaded.desired);
|
|
const gateOpts = {
|
|
onDelete: env.on_delete,
|
|
prunable: opts.prunable || [],
|
|
confirmDataDrops: !!opts.confirmDataDrops
|
|
};
|
|
|
|
// Saltcorn's own metadata snapshot (best-effort; reported when unavailable).
|
|
let snapshotTaken = false;
|
|
try {
|
|
await require("@saltcorn/admin-models/models/snapshot").take_if_changed(`site-sync pre-apply ${planId}`);
|
|
snapshotTaken = true;
|
|
} catch (e) {
|
|
notes.push(`no Saltcorn snapshot taken: ${e.message}`);
|
|
}
|
|
|
|
// site-sync restore point: pre-apply metadata pack + the rows this apply can
|
|
// change or destroy, so it can be one-click reverted. Skipped when we are
|
|
// ourselves executing a rollback. A failure only disables rollback (reported).
|
|
if (!opts.skipRestorePoint) {
|
|
try {
|
|
await require("./rollback").saveRestorePoint(planId, before.pack, loaded.plan, gateOpts);
|
|
} catch (e) {
|
|
notes.push(`no restore point saved (rollback unavailable for this apply): ${e.message}`);
|
|
}
|
|
}
|
|
|
|
const doStructural = opts.applyDeletes !== false;
|
|
|
|
// ---- rename pre-pass (BEFORE install_pack) ----
|
|
await lease.assertHeld();
|
|
// Data-preserving Table.rename (confirmed renames only) so install_pack then
|
|
// UPDATES the renamed table instead of creating a fresh empty one.
|
|
let renameResult = { renamed: [], failed: [] };
|
|
if (doStructural) {
|
|
renameResult = await deletes.applyRenames(loaded.plan, { confirmRenames: opts.confirmRenames });
|
|
}
|
|
|
|
// A content-hash file fetcher for the source peer, reused by both the layout-file
|
|
// resolution below and the reference-row sync later.
|
|
const fileFetcher = await buildFileFetcher(loaded, env, opts);
|
|
const fileWarnings = [];
|
|
|
|
// ---- upsert half (create + update) ----
|
|
const upsertPack = buildUpsertPack(desired);
|
|
// Resolve files referenced inside page layouts / view configs / the site logo to
|
|
// LOCAL file values BEFORE install_pack writes those layouts (otherwise the
|
|
// placeholders would be persisted verbatim). resolvePlaceholders dedups against
|
|
// the target's own layout files, and fileIndexSeed (rollback) lets pre-apply
|
|
// files (on disk but no longer referenced) resolve without a fetch.
|
|
try {
|
|
await layoutFiles.resolvePlaceholders(upsertPack, {
|
|
fileFetcher,
|
|
fileIndexSeed: opts.fileIndexSeed,
|
|
warnings: fileWarnings
|
|
});
|
|
} catch (e) {
|
|
fileWarnings.push(`layout-file resolution failed: ${e.message}`);
|
|
}
|
|
await lease.assertHeld();
|
|
await require("@saltcorn/admin-models/models/pack").install_pack(upsertPack, undefined, async () => {}, false);
|
|
await fixViewTables(loaded.plan);
|
|
await installTags(loaded.plan);
|
|
|
|
// ---- delete half (mirror pruning) ----
|
|
let deleteResult = { applied: false, deleted: 0, skipped: [] };
|
|
if (doStructural) {
|
|
await lease.assertHeld();
|
|
deleteResult = await deletes.applyDeletes(loaded.plan, Object.assign({ renamed: renameResult.renamed }, gateOpts));
|
|
}
|
|
|
|
// ---- reference-row sync (AFTER install_pack so managed schema exists) ----
|
|
let rowResult = { applied: false, perTable: {}, warnings: [] };
|
|
const manifest = loaded.plan.sourceRows;
|
|
if (manifest && Object.keys(manifest).length > 0 && opts.applyRows !== false) {
|
|
await lease.assertHeld();
|
|
rowResult = await rowsLib.applyRowSync(manifest, { fileFetcher });
|
|
rowResult.applied = true;
|
|
}
|
|
|
|
// Cluster convergence: one non-silent refresh so sibling workers pick up the
|
|
// new catalog (install_pack refreshes with noSignal=true internally).
|
|
try {
|
|
const { getState } = require("@saltcorn/data/db/state");
|
|
await getState().refresh();
|
|
} catch (e) {
|
|
notes.push(`cluster refresh failed (other workers may serve stale metadata until restart): ${e.message}`);
|
|
}
|
|
|
|
return {
|
|
applied: true,
|
|
plan_id: planId,
|
|
snapshotTaken,
|
|
summary: loaded.plan.summary,
|
|
renames: renameResult,
|
|
deletes: deleteResult,
|
|
rows: rowResult,
|
|
warnings: collectApplyWarnings({ notes, renames: renameResult, deletes: deleteResult, fileWarnings, rows: rowResult })
|
|
};
|
|
};
|
|
|
|
|
|
// Apply a persisted plan under the site lock (siteLock.LockBusyError while another
|
|
// apply or rollback runs; opts.lease = run under a lock the caller holds already).
|
|
// The plan is claimed atomically first (pending or rolled_back -> applying; also
|
|
// applied with opts.force). On success the plan is `applied` and the warnings
|
|
// first name any abandoned plan acquiring the lock recovered; on failure its prior
|
|
// status is restored and the error is rethrown with `planId` set.
|
|
const applyPlan = async (planId, opts = {}) => {
|
|
return await siteLock.withSiteLock({ operation: "apply", planId, lease: opts.lease }, async (lease) => {
|
|
const loaded = await planLib.loadPlan(planId);
|
|
if (!loaded) {
|
|
throw new Error(`unknown plan ${planId}`);
|
|
}
|
|
const startable = APPLY_FROM.concat(opts.force ? [PLAN_STATUS.APPLIED] : []);
|
|
if (!(await planLib.claimPlanStatus(planId, startable, PLAN_STATUS.APPLYING))) {
|
|
throw new Error(`plan ${planId} cannot be applied: it is ${loaded.status} (re-preview to apply the source again)`);
|
|
}
|
|
const result = await siteLock.finishClaimed(lease, planId, { prior: loaded.status, done: PLAN_STATUS.APPLIED }, () => runApply(planId, loaded, opts, lease));
|
|
result.warnings = siteLock.recoveryWarnings(lease).concat(result.warnings);
|
|
return result;
|
|
});
|
|
};
|
|
|
|
|
|
module.exports = {
|
|
APPLY_FROM,
|
|
applyPlan
|
|
};
|