sc-site-sync/test/livePull.js
2026-10-19 01:25:16 -05:00

176 lines
9.3 KiB
JavaScript

// Live pull over REAL HTTP. Modes (each run against its own instance's DB, set by
// runLivePull.sh):
// init-target register the plugin (real onLoad) on a fresh TARGET DB; print env_id
// init-source register + seed distinctive design state on the SOURCE DB, and pair
// the target into it; print env_id (needs TARGET_ENV, SHARED)
// pair-target pair the source into the TARGET DB (needs SOURCE_ENV, SOURCE_URL, SHARED)
// drive (TARGET, in-process) pull the SOURCE over HTTP, plan, apply, verify
// (needs SOURCE_ENV, SOURCE_URL)
//
// The source runs as a real `saltcorn serve` process; the target is driven
// in-process. So the pull crosses a real network boundary: the target's outbound
// signed request -> HTTP -> the source's real inbound route + HMAC verify ->
// /api/state -> a signed response the target verifies before using it.
const Table = require("@saltcorn/data/models/table");
const Field = require("@saltcorn/data/models/field");
const File = require("@saltcorn/data/models/file");
const Page = require("@saltcorn/data/models/page");
const { getState } = require("@saltcorn/data/db/state");
const { peerBytes, API_PATHS } = require("../lib/transport");
const FILE_BYTES = Buffer.from("SOURCE FILE BYTES over HTTP");
// layout/logo images must be real images (content-sniffed); vary trailing bytes.
const PNG = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64");
const LOGO_BYTES = Buffer.concat([PNG, Buffer.from("logo")]);
const HERO_BYTES = Buffer.concat([PNG, Buffer.from("hero")]);
const { bootstrapPlugin, ok, summary } = require("./harness");
const peers = require("../lib/peers");
const env = require("../lib/env");
const capture = require("../lib/capture");
const rows = require("../lib/rows");
const ops = require("../lib/operations");
const planLib = require("../lib/plan");
const applyLib = require("../lib/apply");
const SHARED = process.env.SHARED ? Buffer.from(process.env.SHARED, "hex") : null;
const initTarget = async () => {
await bootstrapPlugin();
const e = await env.getEnv();
console.log("ENV_ID=" + e.env_id);
};
const initSource = async () => {
await bootstrapPlugin();
// distinctive design state the target does NOT have
const planets = await Table.create("planets", { min_role_read: 1, min_role_write: 1 });
await Field.create({ table: planets, name: "pname", label: "pname", type: "String" });
await Field.create({ table: planets, name: "diameter", label: "diameter", type: "Integer" });
await getState().setConfig("site_name", "SourceSite");
// a managed reference table with rows (its manifest rides /api/state)
const status = await Table.create("ref_status", { min_role_read: 1, min_role_write: 1 });
await Field.create({ table: status, name: "label", label: "label", type: "String" });
const fresh = Table.findOne({ name: "ref_status" });
await fresh.insertRow({ label: "active" });
await fresh.insertRow({ label: "archived" });
await rows.setDataMode("ref_status", "managed");
// a managed table with a FILE field (its bytes ride GET /api/file/:hash)
const docs = await Table.create("docs", { min_role_read: 1, min_role_write: 1 });
await Field.create({ table: docs, name: "title", label: "title", type: "String" });
await Field.create({ table: docs, name: "attachment", label: "attachment", type: "File" });
const f = await File.from_contents("readme.txt", "text/plain", FILE_BYTES, 1, 1, "/");
await Table.findOne({ name: "docs" }).insertRow({ title: "doc1", attachment: f.field_value });
await rows.setDataMode("docs", "managed");
// a site logo + a page with a layout image (always-on file sync, not row data)
const logo = await File.from_contents("logo.png", "image/png", LOGO_BYTES, 1, 100, "/");
await getState().setConfig("site_logo_id", logo.field_value);
const hero = await File.from_contents("hero.png", "image/png", HERO_BYTES, 1, 100, "/");
await Page.create({ name: "landing", title: "Landing", description: "", min_role: 1,
layout: { above: [{ type: "image", fileid: hero.field_value, srcType: "File" }] }, fixed_states: {} });
// pair the target INTO the source so the source can verify the target's pulls
await peers.addPeer({ envId: process.env.TARGET_ENV, label: "target", baseUrl: "http://127.0.0.1:1/", existingSecret: SHARED });
const e = await env.getEnv();
console.log("ENV_ID=" + e.env_id);
};
const pairTarget = async () => {
await bootstrapPlugin();
await peers.addPeer({ envId: process.env.SOURCE_ENV, label: "source", baseUrl: process.env.SOURCE_URL, existingSecret: SHARED });
};
const drive = async () => {
await bootstrapPlugin();
const e = await env.getEnv();
const peerRow = await peers.findPeerByEnvId(process.env.SOURCE_ENV);
ok("source peer is paired", !!peerRow);
console.log("\n# real HTTP: plugin-parity health check");
const warnings = await ops.diffPluginsWithPeer(peerRow, e, await require("../lib/common/pluginParity").localPluginList());
ok("signed GET /api/health round-tripped over HTTP", Array.isArray(warnings) && !warnings.some((w) => /could not|returned/.test(w)), JSON.stringify(warnings));
console.log("\n# real HTTP: pull desired state");
const state = await ops.fetchPeerState(peerRow, e);
ok("signed GET /api/state returned a pack over HTTP", !!(state && state.pack && Array.isArray(state.pack.tables)));
ok("source's planets table present in pulled pack", !!state.pack.tables.find((t) => t.name === "planets"));
ok("source's managed ref rows rode /api/state", !!(state.rows && state.rows.ref_status && state.rows.ref_status.rows.length === 2));
console.log("\n# plan + apply (in-process) the pulled state");
const { plan_id, plan } = await planLib.buildPlan({
peerRow, env: e, desired: state.pack, desiredHash: state.hash,
sourceEnvId: state.env_id, direction: "pull", sourceRows: state.rows
});
ok("plan creates planets", plan.entities.tables.create.some((t) => t.name === "planets"));
ok("plan sets site_name config", plan.config.sets.site_name === "SourceSite");
const res = await applyLib.applyPlan(plan_id, { applyDeletes: true });
ok("apply succeeded", res.applied === true);
console.log("\n# verify the TARGET converged");
const p = Table.findOne({ name: "planets" });
ok("planets table now exists on target", !!p);
ok("planets has diameter field", !!(p && p.getField("diameter")));
ok("ref_status table created on target", !!Table.findOne({ name: "ref_status" }));
ok("site_name config converged", getState().getConfig("site_name") === "SourceSite", getState().getConfig("site_name"));
console.log("\n# always-on layout/logo file sync over HTTP (materialized during apply)");
const landing = Page.findOne({ name: "landing" });
ok("landing page synced", !!landing);
const heroFid = landing && landing.layout.above[0].fileid;
ok("hero layout image resolved to a local file", !!heroFid && !heroFid.startsWith("__ss_file_ref::"));
const heroBytes = heroFid && (await (await File.findOne(heroFid)).get_contents());
ok("hero image content matches over HTTP", !!heroBytes && Buffer.compare(heroBytes, HERO_BYTES) === 0);
const logoVal = getState().getConfig("site_logo_id");
ok("site logo resolved to a local file", !!logoVal && !logoVal.startsWith("__ss_file_ref::"));
const logoBytes = logoVal && (await (await File.findOne(logoVal)).get_contents());
ok("logo content matches over HTTP", !!logoBytes && Buffer.compare(logoBytes, LOGO_BYTES) === 0);
console.log("\n# real HTTP: fetch a file by content hash");
const docRow = state.rows.docs && state.rows.docs.rows[0];
const fileHash = docRow && docRow.attachment__file && docRow.attachment__file.hash;
ok("docs manifest carried a file placeholder", !!fileHash);
const fileBytes = await peerBytes(peerRow, e.env_id, API_PATHS.file(fileHash));
ok("signed GET /api/file/:hash returned the verified bytes over HTTP", Buffer.compare(fileBytes, FILE_BYTES) === 0);
console.log("\n# full file materialization over HTTP (target opts in, second pull)");
await rows.setDataMode("docs", "managed");
const state2 = await ops.fetchPeerState(peerRow, e);
const built = await planLib.buildPlan({ peerRow, env: e, desired: state2.pack, desiredHash: state2.hash, sourceEnvId: state2.env_id, direction: "pull", sourceRows: state2.rows });
await applyLib.applyPlan(built.plan_id, { applyDeletes: true });
const doc = (await Table.findOne({ name: "docs" }).getRows({}, {}))[0];
ok("doc row synced to target", !!doc);
const localFile = doc && (await File.findOne(doc.attachment));
const bytes = localFile && (await localFile.get_contents());
ok("attachment materialized over HTTP with correct bytes", !!bytes && Buffer.compare(bytes, FILE_BYTES) === 0);
};
const MODES = {
"init-target": initTarget,
"init-source": initSource,
"pair-target": pairTarget,
"drive": drive
};
const EXIT_USAGE = 2;
const main = async () => {
const run = MODES[process.argv[2]];
if (!run) {
console.error("unknown mode: " + process.argv[2]);
process.exit(EXIT_USAGE);
}
await run();
process.exit(summary());
};
main().catch((err) => {
console.error(err && err.stack ? err.stack : err);
process.exit(1);
});