mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 15:04:52 +09:00
Hosts join the cluster by running the installer, which shows a code and waits until the request is approved under Hosts > Add host in the web interface or with clusterioctl host approve. An admin can pass their user token to the installer to approve the host right away. The token is only used for that and is not saved. The controller only accepts hosts that have a record, and only approving a host creates one. Revoking a host deletes its record, so its token can no longer be used to reconnect. Hosts that have connected before keep working. Removes host generate-token, revoke-token and create-config from clusterioctl, bootstrap generate-host-token, the host token messages and the core.host.generate_token, revoke_token and create_config permissions, which are also stripped from stored roles. Adds clusterioctl host pending, approve, reject and revoke, and the core.host.approve and core.host.revoke permissions. Standalone installs add the host to the controller database directly, since the controller is not running during the install.
416 lines
16 KiB
JavaScript
416 lines
16 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Version compatibility smoke test
|
|
*
|
|
* Starts a controller and a host where one side runs from this repository
|
|
* and the other from a release published to npm, then verifies the host
|
|
* connects to the controller and can be assigned an instance.
|
|
*
|
|
* Before starting the cluster it also diffs the link messages registered by
|
|
* the local and published versions of the lib. A message whose serialization
|
|
* (src, dst, data schema or response schema) changed fails the test, while
|
|
* added and removed messages are only warned about since breaking
|
|
* compatibility is unavoidable there. Optional properties that only one
|
|
* version has are left out of the comparison, as the other version ignores
|
|
* them when received and does not need them when sent.
|
|
*
|
|
* Intended breaks are listed in scripts/version-compat-breaks.json, see
|
|
* scripts/version-compat-breaks.js for the format.
|
|
*
|
|
* Usage:
|
|
* node scripts/version-compat-test.js --controller local --host published
|
|
* node scripts/version-compat-test.js --controller published --host local --published-version 2.0.0-alpha.27
|
|
*
|
|
* The repository must be built (pnpm run build) before running with a local side.
|
|
*/
|
|
"use strict";
|
|
import child_process from "node:child_process";
|
|
import events from "node:events";
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import readline from "node:readline";
|
|
import util from "node:util";
|
|
|
|
import { loadBreaks, matchBreaks, selectBreaks } from "./version-compat-breaks.js";
|
|
// Only uses Node builtins, so it does not load a second copy of the lib.
|
|
import { pairHost, PairingError } from "../packages/create/pair.js";
|
|
|
|
const execFile = util.promisify(child_process.execFile);
|
|
|
|
const repoRoot = path.join(import.meta.dirname, "..");
|
|
const workDir = path.join(repoRoot, "temp", "compat-test");
|
|
const controllerDir = path.join(workDir, "controller");
|
|
const hostDir = path.join(workDir, "host");
|
|
const publishedDir = path.join(workDir, "published");
|
|
const httpPort = 8899;
|
|
|
|
const daemons = [];
|
|
|
|
function parseArgs() {
|
|
const options = { controller: undefined, host: undefined, publishedVersion: "latest" };
|
|
const argv = process.argv.slice(2);
|
|
for (let i = 0; i < argv.length; i++) {
|
|
if (argv[i] === "--controller") { options.controller = argv[++i]; continue; }
|
|
if (argv[i] === "--host") { options.host = argv[++i]; continue; }
|
|
if (argv[i] === "--published-version") { options.publishedVersion = argv[++i]; continue; }
|
|
console.error(`Unknown argument ${argv[i]}`);
|
|
process.exit(1);
|
|
}
|
|
for (const side of ["controller", "host"]) {
|
|
if (!["local", "published"].includes(options[side])) {
|
|
console.error(`--${side} must be either local or published`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
return options;
|
|
}
|
|
|
|
function packageDir(pkg, source) {
|
|
if (source === "local") {
|
|
return path.join(repoRoot, "packages", pkg);
|
|
}
|
|
return path.join(publishedDir, "node_modules", "@clusterio", pkg);
|
|
}
|
|
|
|
async function packageVersion(pkg, source) {
|
|
const json = JSON.parse(await fs.readFile(path.join(packageDir(pkg, source), "package.json"), "utf8"));
|
|
return `${json.version} (${source})`;
|
|
}
|
|
|
|
async function run(name, pkgDir, cliArgs, cwd) {
|
|
console.log(`| ${name} ${cliArgs.join(" ")}`);
|
|
try {
|
|
const result = await execFile(process.execPath, [pkgDir, ...cliArgs], { cwd });
|
|
for (const line of `${result.stdout}${result.stderr}`.split("\n")) {
|
|
if (line.trim()) { console.log(`${name}: ${line}`); }
|
|
}
|
|
return result;
|
|
} catch (err) {
|
|
console.error(`${name}: command failed\n${err.stdout ?? ""}${err.stderr ?? ""}`);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
function spawnDaemon(name, pkgDir, cliArgs, cwd, readyRegex, timeoutMs = 120e3) {
|
|
console.log(`| ${name} ${cliArgs.join(" ")}`);
|
|
return new Promise((resolve, reject) => {
|
|
const proc = child_process.spawn(process.execPath, [pkgDir, ...cliArgs], { cwd });
|
|
daemons.push(proc);
|
|
let ready = false;
|
|
const timeout = setTimeout(() => {
|
|
reject(new Error(`Timed out waiting for ${name} to log ${readyRegex}`));
|
|
}, timeoutMs);
|
|
function watch(stream) {
|
|
readline.createInterface({ input: stream }).on("line", line => {
|
|
console.log(`${name}: ${line}`);
|
|
if (!ready && readyRegex.test(line)) {
|
|
ready = true;
|
|
clearTimeout(timeout);
|
|
resolve(proc);
|
|
}
|
|
});
|
|
}
|
|
watch(proc.stdout);
|
|
watch(proc.stderr);
|
|
proc.on("exit", (code, signal) => {
|
|
if (!ready) {
|
|
clearTimeout(timeout);
|
|
reject(new Error(`${name} exited with ${signal ?? code} before logging ${readyRegex}`));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
async function shutdown(name, proc) {
|
|
if (!proc || proc.exitCode !== null) {
|
|
return;
|
|
}
|
|
console.log(`| stopping ${name}`);
|
|
proc.kill("SIGINT");
|
|
const [code, signal] = await events.once(proc, "exit");
|
|
console.log(`${name}: exited with ${signal ?? code}`);
|
|
}
|
|
|
|
function annotate(message) {
|
|
console.log(message);
|
|
if (process.env.GITHUB_ACTIONS) {
|
|
console.log(`::warning::${message}`);
|
|
}
|
|
}
|
|
|
|
// JSON.stringify with object keys sorted so logically equal schemas compare equal.
|
|
function stableStringify(value) {
|
|
return JSON.stringify(value, (key, item) => (
|
|
item && typeof item === "object" && !Array.isArray(item)
|
|
? Object.fromEntries(Object.keys(item).sort().map(k => [k, item[k]])) : item
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Copy of a schema without the optional properties that the other schema doesn't have. Objects with
|
|
* additionalProperties false on either side keep them, as those reject unknown properties.
|
|
*/
|
|
function withoutOneSidedOptionals(schema, other) {
|
|
if (Array.isArray(schema)) {
|
|
return schema.map((item, index) => withoutOneSidedOptionals(item, other?.[index]));
|
|
}
|
|
if (!schema || typeof schema !== "object") {
|
|
return schema;
|
|
}
|
|
const result = {};
|
|
for (const [key, value] of Object.entries(schema)) {
|
|
if (key !== "properties") {
|
|
result[key] = withoutOneSidedOptionals(value, other?.[key]);
|
|
continue;
|
|
}
|
|
const closed = schema.additionalProperties === false || other?.additionalProperties === false;
|
|
result.properties = {};
|
|
for (const [name, property] of Object.entries(value)) {
|
|
const otherProperty = other?.properties?.[name];
|
|
if (otherProperty || closed || schema.required?.includes(name)) {
|
|
result.properties[name] = withoutOneSidedOptionals(property, otherProperty);
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Runs in a child process because only one copy of the lib may be loaded per process.
|
|
const dumpMessageSpecs = `
|
|
const { Link } = require(process.argv[1]);
|
|
const addresses = types => [types].flat().sort();
|
|
const specs = {};
|
|
for (const [name, entry] of Link._requestsByName) {
|
|
specs["request " + name] = {
|
|
src: addresses(entry.Request.src),
|
|
dst: addresses(entry.Request.dst),
|
|
data: entry.Request.jsonSchema ?? null,
|
|
response: entry.Request.Response ? entry.Request.Response.jsonSchema : null,
|
|
};
|
|
}
|
|
for (const [name, entry] of Link._eventsByName) {
|
|
specs["event " + name] = {
|
|
src: addresses(entry.Event.src),
|
|
dst: addresses(entry.Event.dst),
|
|
data: entry.Event.jsonSchema ?? null,
|
|
};
|
|
}
|
|
console.log(JSON.stringify(specs));
|
|
`;
|
|
|
|
async function messageSpecs(libPath) {
|
|
const result = await execFile(process.execPath, ["-e", dumpMessageSpecs, libPath]);
|
|
return new Map(Object.entries(JSON.parse(result.stdout)));
|
|
}
|
|
|
|
function printChange(print, name, fields, localSpec, publishedSpec, publishedVersion) {
|
|
print(`Link ${name} changed ${fields.join(", ")} since ${publishedVersion}:`);
|
|
for (const field of fields) {
|
|
print(` ${field} in ${publishedVersion}: ${stableStringify(publishedSpec[field])}`);
|
|
print(` ${field} in local: ${stableStringify(localSpec[field])}`);
|
|
}
|
|
}
|
|
|
|
async function checkMessageCompat(publishedVersion, knownBreaks) {
|
|
const localSpecs = await messageSpecs(path.join(repoRoot, "packages", "lib"));
|
|
const publishedSpecs = await messageSpecs(path.join(publishedDir, "node_modules", "@clusterio", "lib"));
|
|
console.log(`| comparing ${localSpecs.size} local with ${publishedSpecs.size} published link messages`);
|
|
|
|
const changed = [];
|
|
for (const [name, localSpec] of localSpecs) {
|
|
const publishedSpec = publishedSpecs.get(name);
|
|
if (!publishedSpec) {
|
|
annotate(`Link ${name} does not exist in ${publishedVersion} which cannot receive it`);
|
|
continue;
|
|
}
|
|
const differs = Object.keys(localSpec).filter(
|
|
field => stableStringify(localSpec[field]) !== stableStringify(publishedSpec[field])
|
|
);
|
|
const fields = differs.filter(field => (
|
|
stableStringify(withoutOneSidedOptionals(localSpec[field], publishedSpec[field])) !==
|
|
stableStringify(withoutOneSidedOptionals(publishedSpec[field], localSpec[field]))
|
|
));
|
|
const optionalOnly = differs.filter(field => !fields.includes(field));
|
|
if (optionalOnly.length) {
|
|
console.log(
|
|
`| Link ${name} changed optional properties in ${optionalOnly.join(", ")} since ${publishedVersion}`
|
|
);
|
|
}
|
|
if (fields.length) {
|
|
changed.push({ name, fields, localSpec, publishedSpec });
|
|
}
|
|
}
|
|
for (const name of publishedSpecs.keys()) {
|
|
if (!localSpecs.has(name)) {
|
|
annotate(`Link ${name} was removed after ${publishedVersion} which may still send it`);
|
|
}
|
|
}
|
|
|
|
const { known, unknown, stale } = matchBreaks(knownBreaks, changed.map(({ name }) => name));
|
|
for (const { name, fields, localSpec, publishedSpec } of changed) {
|
|
if (known.includes(name)) {
|
|
const { pr, reason, impact } = knownBreaks.get(name);
|
|
annotate(`Link ${name} is a known break since ${publishedVersion} from #${pr}: ${reason} Impact: ${impact}`);
|
|
printChange(console.log, name, fields, localSpec, publishedSpec, publishedVersion);
|
|
} else {
|
|
printChange(console.error, name, fields, localSpec, publishedSpec, publishedVersion);
|
|
}
|
|
}
|
|
for (const name of stale) {
|
|
console.error(`Link ${name} is listed as a known break but did not change since ${publishedVersion}`);
|
|
}
|
|
const problems = [];
|
|
if (unknown.length) { problems.push(`${unknown.length} link message(s) changed serialization`); }
|
|
if (stale.length) { problems.push(`${stale.length} known break(s) did not change`); }
|
|
if (problems.length) {
|
|
throw new Error(`${problems.join(" and ")} since ${publishedVersion}`);
|
|
}
|
|
}
|
|
|
|
async function pollCtl(ctl, cliArgs, expectRegex, description, timeoutMs = 30e3) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
for (;;) {
|
|
const result = await run("ctl", ctl, cliArgs, controllerDir);
|
|
if (expectRegex.test(result.stdout)) {
|
|
return;
|
|
}
|
|
if (Date.now() > deadline) {
|
|
throw new Error(`Timed out waiting for ${description}`);
|
|
}
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pair the host with the running controller and write its config
|
|
*
|
|
* The pairing API is probed instead of checking which side is local, as releases that support
|
|
* pairing no longer have ctl host create-config to fall back on.
|
|
*
|
|
* @returns true if the host was paired, false if the controller predates host pairing.
|
|
*/
|
|
async function pairHostWithController() {
|
|
// Written by bootstrap create-ctl-config, holds the admin token which approves the host directly.
|
|
const ctlConfig = JSON.parse(await fs.readFile(path.join(controllerDir, "config-ctl.json"), "utf8"));
|
|
const controllerUrl = ctlConfig["ctl.controller_url"];
|
|
console.log(`| pairing host with ${controllerUrl}`);
|
|
let pairing;
|
|
try {
|
|
pairing = await pairHost({ controllerUrl, name: "host", token: ctlConfig["ctl.controller_token"] });
|
|
} catch (err) {
|
|
if (err instanceof PairingError && err.status === 404) {
|
|
console.log("| controller does not support host pairing");
|
|
return false;
|
|
}
|
|
throw err;
|
|
}
|
|
console.log(`| paired host ${pairing.name} with id ${pairing.hostId}`);
|
|
|
|
// The same fields the installer sets, which every host version reads.
|
|
const hostConfig = {
|
|
"host.id": pairing.hostId,
|
|
"host.name": pairing.name,
|
|
"host.controller_url": controllerUrl,
|
|
"host.controller_token": pairing.token,
|
|
};
|
|
await fs.writeFile(
|
|
path.join(hostDir, "config-host.json"), JSON.stringify(hostConfig, null, "\t"), { flag: "wx", mode: 0o600 }
|
|
);
|
|
return true;
|
|
}
|
|
|
|
async function main() {
|
|
const options = parseArgs();
|
|
const breaks = await loadBreaks();
|
|
|
|
await fs.rm(workDir, { force: true, recursive: true, maxRetries: 10 });
|
|
await fs.mkdir(controllerDir, { recursive: true });
|
|
await fs.mkdir(hostDir, { recursive: true });
|
|
await fs.mkdir(path.join(hostDir, "factorio"), { recursive: true });
|
|
|
|
const published = [];
|
|
if (options.controller === "published") { published.push("controller", "ctl"); }
|
|
if (options.host === "published") { published.push("host"); }
|
|
if (published.length) {
|
|
await fs.mkdir(publishedDir, { recursive: true });
|
|
await fs.writeFile(path.join(publishedDir, "package.json"), JSON.stringify({ private: true }));
|
|
const specs = published.map(pkg => `@clusterio/${pkg}@${options.publishedVersion}`);
|
|
console.log(`| npm install ${specs.join(" ")}`);
|
|
await execFile("npm", ["install", "--no-audit", "--no-fund", ...specs], { cwd: publishedDir });
|
|
}
|
|
|
|
const publishedLib = JSON.parse(await fs.readFile(
|
|
path.join(publishedDir, "node_modules", "@clusterio", "lib", "package.json"), "utf8"
|
|
));
|
|
const knownBreaks = selectBreaks(breaks, publishedLib.version);
|
|
for (const version of knownBreaks.ignored) {
|
|
annotate(`Known breaks for ${version} do not apply to ${publishedLib.version} and can be removed`);
|
|
}
|
|
await checkMessageCompat(publishedLib.version, knownBreaks.messages);
|
|
if (knownBreaks.connect) {
|
|
const { pr, reason, impact } = knownBreaks.connect;
|
|
annotate(
|
|
`Skipping cluster test, known break since ${publishedLib.version} from #${pr}: ${reason} Impact: ${impact}`
|
|
);
|
|
console.log(`PASS: link messages match ${publishedLib.version} apart from known breaks`);
|
|
return;
|
|
}
|
|
|
|
const controller = packageDir("controller", options.controller);
|
|
const host = packageDir("host", options.host);
|
|
// Use the ctl matching the controller version as it connects to the controller.
|
|
const ctl = packageDir("ctl", options.controller);
|
|
|
|
console.log(`Testing controller ${await packageVersion("controller", options.controller)} `
|
|
+ `with host ${await packageVersion("host", options.host)}`);
|
|
|
|
try {
|
|
await run("controller", controller, ["config", "create"], controllerDir);
|
|
} catch {
|
|
// Ignore failure on previous version where config create does not exist and is not needed.
|
|
}
|
|
await run("controller", controller, ["config", "set", "controller.http_port", String(httpPort)], controllerDir);
|
|
await run("controller", controller, ["bootstrap", "create-admin", "test"], controllerDir);
|
|
await run("controller", controller, ["bootstrap", "create-ctl-config", "test"], controllerDir);
|
|
const controllerProc = await spawnDaemon(
|
|
"controller", controller, ["run"], controllerDir, /Started controller/
|
|
);
|
|
|
|
try {
|
|
if (!await pairHostWithController()) {
|
|
await run("ctl", ctl, [
|
|
"host", "create-config", "--id", "1", "--name", "host", "--generate-token",
|
|
"--output", path.join(hostDir, "config-host.json"),
|
|
], controllerDir);
|
|
}
|
|
|
|
// Started host is only logged after the connection to the controller is established.
|
|
const hostProc = await spawnDaemon("host", host, ["run"], hostDir, /Started host/);
|
|
await pollCtl(ctl, ["host", "list"], /\bhost\b.*\btrue\b/, "host to show as connected");
|
|
|
|
await run("ctl", ctl, ["instance", "create", "compat", "--id", "10"], controllerDir);
|
|
await run("ctl", ctl, ["instance", "assign", "compat", "host"], controllerDir);
|
|
await pollCtl(ctl, ["instance", "list"], /\bcompat\b.*\bstopped\b/, "instance to show as stopped");
|
|
|
|
await shutdown("host", hostProc);
|
|
} finally {
|
|
await shutdown("controller", controllerProc);
|
|
}
|
|
|
|
console.log(`PASS: ${options.controller} controller accepted connection from ${options.host} host`);
|
|
}
|
|
|
|
const watchdog = setTimeout(() => {
|
|
console.error("FAIL: test timed out");
|
|
process.exit(1);
|
|
}, 600e3);
|
|
|
|
main().then(() => {
|
|
clearTimeout(watchdog);
|
|
for (const proc of daemons) { proc.kill(); }
|
|
}).catch(err => {
|
|
console.error(`FAIL: ${err.message}`);
|
|
for (const proc of daemons) { proc.kill(); }
|
|
process.exit(1);
|
|
});
|