total-recall-brain 3.43.2 → 3.44.0
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/bin/total-recall-server-release.mjs +4 -0
- package/docs/reference/cli-reference.md +14 -0
- package/package.json +5 -4
- package/scaffold/.agent/skills/plugin/SKILL.md +7 -2
- package/scaffold/.agent/skills/plugin/evals/README.md +5 -0
- package/scaffold/.agent/skills/plugin/evals/cases/01-fabricated-seed.mjs +1 -0
- package/scaffold/.agent/skills/plugin/evals/cases/02-reassuring-default.mjs +1 -0
- package/scaffold/.agent/skills/plugin/evals/cases/03-declared-template.mjs +1 -0
- package/scaffold/.agent/skills/plugin/evals/cases/04-card-collects-credential.mjs +1 -0
- package/scaffold/.agent/skills/plugin/evals/cases/05-host-leak-in-scaffold.mjs +1 -0
- package/scaffold/.agent/skills/plugin/evals/cases/06-correct-plugin.mjs +1 -0
- package/scaffold/.agent/skills/plugin/evals/cases/07-missing-reuse.mjs +11 -0
- package/scaffold/.agent/skills/plugin/evals/cases/08-unresolved-owner-command.mjs +12 -0
- package/scaffold/.agent/skills/plugin/evals/cases/09-missing-brick.mjs +12 -0
- package/scaffold/.agent/skills/plugin/prompts/01-function.md +4 -3
- package/scaffold/.agent/skills/plugin/prompts/08-verify.md +1 -0
- package/scaffold/.agent/skills/plugin/references/surfaces.md +12 -2
- package/scaffold/.agent/skills/plugin/scripts/record.mjs +59 -2
- package/scaffold/.agent/skills/total-recall/plugins/auth/README.md +8 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/package-lock.json +14 -13
- package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +3 -3
- package/scaffold/.agent/skills/total-recall/plugins/auth/plugin.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-gh-login.mjs +102 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-secret.mjs +18 -1
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/login-projections.mjs +157 -13
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-sync.mjs +161 -17
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/README.md +2 -2
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/core/check.mjs +39 -6
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/core/config.schema.json +2 -1
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +14 -13
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/package.json +3 -3
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/plugin.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +14 -13
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package.json +3 -3
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/plugin.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +14 -13
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/package.json +3 -3
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/plugin.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +14 -13
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package.json +3 -3
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/plugin.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/mesh/package-lock.json +14 -13
- package/scaffold/.agent/skills/total-recall/plugins/mesh/package.json +3 -3
- package/scaffold/.agent/skills/total-recall/plugins/mesh/plugin.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/README.md +38 -5
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/cli.mjs +38 -7
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package-lock.json +14 -13
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package.json +3 -3
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/plugin.json +85 -3
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/skills/meta-harness/SKILL.md +2 -1
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/adoption.mjs +104 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/cascade.mjs +12 -1
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/coordinator.mjs +15 -3
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/dispatch-request.mjs +38 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/dispatch.mjs +42 -11
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/executor.mjs +40 -13
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/included-usage.mjs +249 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/learner.mjs +3 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/pool.mjs +16 -8
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/process-manager.mjs +1 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/reasoning.mjs +5 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/remote-operation.mjs +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/router.mjs +27 -11
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/runtime.mjs +28 -6
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/usage-queue.mjs +110 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/worker.mjs +322 -40
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +14 -13
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +3 -3
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +14 -13
- package/scaffold/.agent/skills/total-recall/plugins/security/package.json +3 -3
- package/scaffold/.agent/skills/total-recall/plugins/security/plugin.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/ssss/assets/scaffold.json +5 -5
- package/scaffold/.agent/skills/total-recall/plugins/ssss/package-lock.json +6 -6
- package/scaffold/.agent/skills/total-recall/plugins/ssss/package.json +2 -2
- package/scaffold/.agent/skills/total-recall/plugins/ssss/plugin.json +1 -1
- package/src/cli/connect.mjs +2 -29
- package/src/cli/context.mjs +1 -0
- package/src/cli/ingest-okf.mjs +4 -4
- package/src/cli/release.mjs +27 -2
- package/src/cli/startup.mjs +1 -0
- package/src/cli/update.mjs +14 -3
- package/src/core/automatic-brain-updates.mjs +13 -3
- package/src/core/autopull-install.mjs +14 -0
- package/src/core/brain-updates.mjs +12 -2
- package/src/core/context-compactor.mjs +7 -2
- package/src/core/context-compiler.mjs +6 -1
- package/src/core/context-policy.mjs +7 -3
- package/src/core/daemon-control.mjs +22 -0
- package/src/core/global-brain-updates.mjs +3 -3
- package/src/core/mesh-brain-updates.mjs +20 -5
- package/src/core/running-server.mjs +76 -0
- package/src/core/server-release.mjs +487 -0
- package/src/core/server-restart.mjs +128 -7
- package/src/core/source-brain-updates.mjs +35 -6
- package/src/core/ssss-kernel-bridge.mjs +1 -2
- package/src/core/startup-health.mjs +13 -2
- package/src/core/surface.mjs +3 -3
- package/src/core/task-context.mjs +9 -8
- package/src/core/validated-write.mjs +4 -0
- package/src/core/vault-files.mjs +8 -2
- package/src/core/vault.mjs +1 -1
- package/src/server/index.mjs +17 -1
|
@@ -41,8 +41,8 @@ export function updateGlobalBrainPackages({versions,exec=spawnSync,sourceUpdate=
|
|
|
41
41
|
if(installed && comparePackageVersions(installed,versions[name])===1){row.status='failed';row.error='Newer local/preview global package; refusing implicit downgrade';continue;}
|
|
42
42
|
if(fs.existsSync(path.join(directory,'.git'))){
|
|
43
43
|
const result=sourceUpdate({directory:fs.realpathSync(directory),name,version:versions[name],exec});
|
|
44
|
-
row.status=result.ok?'updated':'failed';row.source=true;
|
|
45
|
-
if(result.ok){row.installed=result.installed;row.commit=result.commit;}else row.error=`Source checkout update failed: ${result.error}`;
|
|
44
|
+
row.status=result.ok?'updated':result.serving?'serving':'failed';row.source=true;
|
|
45
|
+
if(result.ok){row.installed=result.installed;row.commit=result.commit;}else if(result.serving)row.note=result.error;else row.error=`Source checkout update failed: ${result.error}`;
|
|
46
46
|
continue;
|
|
47
47
|
}
|
|
48
48
|
row.status='pending';install.push(`${name}@${versions[name]}`);
|
|
@@ -55,5 +55,5 @@ export function updateGlobalBrainPackages({versions,exec=spawnSync,sourceUpdate=
|
|
|
55
55
|
if(row.status==='failed')row.error=result.error?.message || result.stderr?.slice(-1000) || 'Global version verification failed';
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
|
-
return {failed:results.filter(row=>row.status==='failed').length,results};
|
|
58
|
+
return {failed:results.filter(row=>row.status==='failed').length,results,root};
|
|
59
59
|
}
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
/** The daemon dispatches maintenance through the mesh owner; remote brains stay separate. */
|
|
2
|
+
// Mesh clients on these systems cannot run Total Recall, so they have nothing to update.
|
|
3
|
+
const NO_RUNTIME_OS=new Set(['ios','android','tvos']);
|
|
4
|
+
// Rows that are not machines this release can reach. They stay in the report and never count as failed.
|
|
5
|
+
const NOT_APPLICABLE=new Set(['not-enrolled','unsupported']);
|
|
6
|
+
|
|
2
7
|
export async function updateMeshBrainPackages({version,versions,listNodes,exec,now=Date.now}={}) {
|
|
3
8
|
if(!/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/.test(version || ''))throw new Error('A verified published TR version is required');
|
|
4
9
|
if(!listNodes || !exec){
|
|
@@ -7,17 +12,27 @@ export async function updateMeshBrainPackages({version,versions,listNodes,exec,n
|
|
|
7
12
|
exec ||= mesh.execMeshCommand;
|
|
8
13
|
}
|
|
9
14
|
const results=[];
|
|
10
|
-
|
|
15
|
+
const nodes=await listNodes();
|
|
16
|
+
// A stored record with no live counterpart is a machine that left the mesh, but when every
|
|
17
|
+
// row is stored the mesh itself did not answer, and that must not read as a clean run.
|
|
18
|
+
const meshAnswered=nodes.some(node=>!node.vault_only);
|
|
19
|
+
for(const node of nodes){
|
|
11
20
|
if(node.self)continue;
|
|
12
|
-
const
|
|
21
|
+
const name=node.ip || node.hostname || 'unidentified peer';
|
|
22
|
+
if(node.vault_only && meshAnswered){results.push({node:name,status:'not-enrolled'});continue;}
|
|
23
|
+
if(NO_RUNTIME_OS.has(String(node.os).toLowerCase())){results.push({node:name,status:'unsupported',os:node.os});continue;}
|
|
24
|
+
const row={node:name,status:'pending'};results.push(row);
|
|
25
|
+
if(!node.online){row.status='failed';row.error='Mesh node is offline; retry pending';continue;}
|
|
13
26
|
if(!node.ip || !node.access?.ssh_user){row.status='failed';row.error='Mesh peer has no configured update transport; verification pending';continue;}
|
|
14
|
-
if(!node.online){row.status='failed';row.error='Configured mesh node is offline; retry pending';continue;}
|
|
15
27
|
try{
|
|
16
28
|
// Read the durable report first, then launch only when stale or failed.
|
|
17
29
|
const observed=await exec(node.ip,'cd "$HOME" && total-recall update --status --json',{timeoutMs:20000});
|
|
18
|
-
let report;try{
|
|
30
|
+
let report,serving;try{const status=JSON.parse(observed.stdout);report=status.last;serving=status.server;}catch{}
|
|
19
31
|
const localFailed=report?(report.local_failed ?? Math.max(0,(report.failed || 0)-(report.mesh?.failed || 0))):null;
|
|
20
32
|
const current=observed.success && report && localFailed===0 && now()>=report.checkedAt && now()-report.checkedAt<2*60*60*1000 && report.versions?.['total-recall-brain']===version && (!versions?.['@gregiteen/ssss-cli'] || report.versions?.['@gregiteen/ssss-cli']===versions['@gregiteen/ssss-cli']);
|
|
33
|
+
// Maintenance could not fix this server, or it would have reported a local failure above and
|
|
34
|
+
// been dispatched again, so this is reported. One that lags since the last run waits for that run's report to age.
|
|
35
|
+
if(current && serving?.status==='stale'){row.status='failed';row.report=report;row.server=serving;row.error=serving.reason;continue;}
|
|
21
36
|
if(current){row.status='verified';row.report=report;continue;}
|
|
22
37
|
// npm exec pins the verified release so an old installed CLI can bootstrap
|
|
23
38
|
// this capability. The remote command owns its lock, verification and report.
|
|
@@ -30,5 +45,5 @@ export async function updateMeshBrainPackages({version,versions,listNodes,exec,n
|
|
|
30
45
|
}catch(error){row.status='failed';row.error=error.message;}
|
|
31
46
|
}
|
|
32
47
|
// A dispatched process is not a verified update. Keep it visible for retries.
|
|
33
|
-
return {failed:results.filter(row=>row.status!=='verified').length,results};
|
|
48
|
+
return {failed:results.filter(row=>row.status!=='verified' && !NOT_APPLICABLE.has(row.status)).length,results};
|
|
34
49
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the server on this host actually runs, against what was released.
|
|
3
|
+
*
|
|
4
|
+
* A moved checkout proves nothing until the process serving it has been replaced.
|
|
5
|
+
* Update reports used to read package.json on disk, so a server left on an old
|
|
6
|
+
* release read as `up_to_date` for as long as nobody looked at /health. This module
|
|
7
|
+
* only reads: replacing a production server is a deploy, and a deploy is a decision.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { comparePackageVersions } from './package-version.mjs';
|
|
12
|
+
|
|
13
|
+
const LOOPBACK = new Set(['127.0.0.1', 'localhost', '[::1]']);
|
|
14
|
+
|
|
15
|
+
/** Identity a deploy compares to tell one release from another. */
|
|
16
|
+
export function deploymentHealth({ name, version, pid = process.pid, port = null }) {
|
|
17
|
+
return { status: 'ok', version, deploymentId: `${name}@${version}`, pid, port };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The address of the server this host runs, or null when its brain is served
|
|
22
|
+
* elsewhere. The brain's own configuration decides; TR_PORT covers a host with none.
|
|
23
|
+
*/
|
|
24
|
+
export function localServerUrl({ brainDir, env = process.env } = {}) {
|
|
25
|
+
try {
|
|
26
|
+
const { url } = JSON.parse(fs.readFileSync(path.join(brainDir, 'config', 'brain.json'), 'utf8'));
|
|
27
|
+
if (url) {
|
|
28
|
+
const parsed = new URL(url);
|
|
29
|
+
return LOOPBACK.has(parsed.hostname) ? parsed.origin : null;
|
|
30
|
+
}
|
|
31
|
+
} catch { /* no configuration: fall through to the port */ }
|
|
32
|
+
const port = Number(env.TR_PORT);
|
|
33
|
+
return `http://127.0.0.1:${Number.isInteger(port) && port > 0 ? port : 3000}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Read /health once. A server that does not answer in time is `unknown`, never
|
|
38
|
+
* `down`: under load /health can take seconds, and a slow answer is not an outage.
|
|
39
|
+
*/
|
|
40
|
+
export async function observeRunningServer({ brainDir, url, fetchImpl = fetch, timeoutMs = 10000, exists = fs.existsSync, env = process.env } = {}) {
|
|
41
|
+
const target = url === undefined ? localServerUrl({ brainDir, env }) : url;
|
|
42
|
+
if (!target) return { status: 'none', reason: 'This brain is served from another host.' };
|
|
43
|
+
try {
|
|
44
|
+
const response = await fetchImpl(new URL('/health', target), { redirect: 'error', signal: AbortSignal.timeout(timeoutMs) });
|
|
45
|
+
if (!response.ok) return { status: 'unknown', url: target, reason: `/health answered HTTP ${response.status}` };
|
|
46
|
+
const body = await response.json();
|
|
47
|
+
if (typeof body?.version !== 'string') return { status: 'unknown', url: target, reason: '/health did not report a version' };
|
|
48
|
+
return { status: 'running', url: target, version: body.version };
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (error?.name === 'TimeoutError') return { status: 'unknown', url: target, reason: `/health gave no answer within ${timeoutMs} ms` };
|
|
51
|
+
if (error?.cause?.code !== 'ECONNREFUSED') return { status: 'unknown', url: target, reason: error?.cause?.code || error?.message || 'unreadable /health' };
|
|
52
|
+
return exists(path.join(brainDir || '', 'server.pid'))
|
|
53
|
+
? { status: 'down', url: target, reason: 'A server PID file exists and nothing answers.' }
|
|
54
|
+
: { status: 'none', url: target, reason: 'No server runs on this host.' };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Compare an observation with a released version. `stale` is the only state that needs a replacement. */
|
|
59
|
+
export function compareRunningServer(observed, latest) {
|
|
60
|
+
if (observed.status !== 'running') return { ...observed, latest: latest ?? null };
|
|
61
|
+
const { url, version: running } = observed;
|
|
62
|
+
const order = comparePackageVersions(running, latest);
|
|
63
|
+
if (order === null) return { status: 'unknown', url, running, latest: latest ?? null, reason: 'The versions could not be compared.' };
|
|
64
|
+
const status = order === 0 ? 'current' : order < 0 ? 'stale' : 'ahead';
|
|
65
|
+
return {
|
|
66
|
+
status,
|
|
67
|
+
url,
|
|
68
|
+
running,
|
|
69
|
+
latest,
|
|
70
|
+
...(status === 'stale' ? { reason: `The server on this host runs ${running} and the release is ${latest}. Files moving does not replace a running process.` } : {}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function runningServerStatus({ brainDir, latest, observe = observeRunningServer, ...options } = {}) {
|
|
75
|
+
return compareRunningServer(await observe({ brainDir, ...options }), latest);
|
|
76
|
+
}
|
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replace the brain server on this host with no gap.
|
|
3
|
+
*
|
|
4
|
+
* Runs on the node and is called by `total-recall deploy` through the mesh. The server is
|
|
5
|
+
* one process on a fixed port and the old one cannot cooperate, so traffic moves in the
|
|
6
|
+
* kernel: a nat rule rewrites the port of new connections, connections already open stay
|
|
7
|
+
* where they are, and the client's address is unchanged, so loopback and mesh callers are
|
|
8
|
+
* still recognized for what they are.
|
|
9
|
+
*
|
|
10
|
+
* stage Start the release on the next port from its verified install, and check it.
|
|
11
|
+
* switch Rewrite new connections to it, in one transaction.
|
|
12
|
+
* retire Replace the home unit behind the rule, take the rule away, stop the staged one.
|
|
13
|
+
*
|
|
14
|
+
* Nothing stays behind: the host ends with the same unit on the same port and no rule. The
|
|
15
|
+
* rule and this file's state are gone after a reboot, and the unit then starts normally.
|
|
16
|
+
* If the new home instance does not verify, traffic stays on the staged one.
|
|
17
|
+
*/
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import http from 'node:http';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { randomInt } from 'node:crypto';
|
|
22
|
+
import { spawnSync } from 'node:child_process';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
|
|
25
|
+
const moduleRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
|
26
|
+
|
|
27
|
+
export const HOME_UNIT = 'total-recall-server.service';
|
|
28
|
+
export const BRIDGE_UNIT = 'total-recall-server-bridge.service';
|
|
29
|
+
export const CONVERGE_UNIT = 'total-recall-server-converge.service';
|
|
30
|
+
export const SWAP_CHAIN = 'TR_SERVER_SWAP';
|
|
31
|
+
/** Below the default ephemeral range, so no ordinary client can land here by chance. */
|
|
32
|
+
export const PROBE_SOURCE_PORTS = [30000, 30999];
|
|
33
|
+
|
|
34
|
+
const IPV4 = /^\d{1,3}(?:\.\d{1,3}){3}$/;
|
|
35
|
+
|
|
36
|
+
// ─── Rules ──────────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Applied with `iptables-restore --noflush`, so both directions flip in one transaction.
|
|
40
|
+
* The nat table is consulted only for a packet creating a new connection, and DNAT with no
|
|
41
|
+
* address changes only the port. Local probes return early so they can reach the home port
|
|
42
|
+
* behind the rule.
|
|
43
|
+
*/
|
|
44
|
+
export function swapOnRules({ addresses, homePort, bridgePort }) {
|
|
45
|
+
if (!addresses.length) throw new Error('No listening address to move.');
|
|
46
|
+
return [
|
|
47
|
+
'*nat',
|
|
48
|
+
`:${SWAP_CHAIN} - [0:0]`,
|
|
49
|
+
`-A ${SWAP_CHAIN} -p tcp -s 127.0.0.1 --sport ${PROBE_SOURCE_PORTS[0]}:${PROBE_SOURCE_PORTS[1]} -j RETURN`,
|
|
50
|
+
...addresses.map((address) => `-A ${SWAP_CHAIN} -p tcp -d ${address} --dport ${homePort} -j DNAT --to-destination :${bridgePort}`),
|
|
51
|
+
`-I PREROUTING 1 -j ${SWAP_CHAIN}`,
|
|
52
|
+
`-I OUTPUT 1 -j ${SWAP_CHAIN}`,
|
|
53
|
+
'COMMIT',
|
|
54
|
+
'',
|
|
55
|
+
].join('\n');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function swapOffRules() {
|
|
59
|
+
return ['*nat', `-D PREROUTING -j ${SWAP_CHAIN}`, `-D OUTPUT -j ${SWAP_CHAIN}`, `-F ${SWAP_CHAIN}`, `-X ${SWAP_CHAIN}`, 'COMMIT', ''].join('\n');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── Reading the host ───────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/** `Environment=` as `systemctl show` prints it: space separated, quoted when a value has spaces. */
|
|
65
|
+
export function parseEnvironment(value = '') {
|
|
66
|
+
const environment = {};
|
|
67
|
+
for (const match of value.matchAll(/"([^"]*)"|(\S+)/g)) {
|
|
68
|
+
const pair = match[1] ?? match[2];
|
|
69
|
+
const at = pair.indexOf('=');
|
|
70
|
+
if (at > 0) environment[pair.slice(0, at)] = pair.slice(at + 1);
|
|
71
|
+
}
|
|
72
|
+
return environment;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Local addresses from `ss -ltnH`. */
|
|
76
|
+
export function parseListeners(text = '') {
|
|
77
|
+
return text.split('\n').map((line) => line.trim().split(/\s+/)[3]).filter(Boolean)
|
|
78
|
+
.map((local) => local.slice(0, local.lastIndexOf(':')));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function run(ctx, binary, args, { input, timeout = 30000 } = {}) {
|
|
82
|
+
const result = ctx.exec(binary, args, { encoding: 'utf8', timeout, input, maxBuffer: 4 * 1024 * 1024 });
|
|
83
|
+
return { ok: result.status === 0, stdout: String(result.stdout || ''), stderr: String(result.stderr || ''), error: result.error };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function must(ctx, binary, args, options) {
|
|
87
|
+
const result = run(ctx, binary, args, options);
|
|
88
|
+
if (!result.ok) throw new Error(`${binary} ${args[0] || ''} failed: ${(result.stderr || result.error?.message || 'no output').trim().slice(-400)}`);
|
|
89
|
+
return result.stdout;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function readHomeUnit(ctx) {
|
|
93
|
+
const shown = {};
|
|
94
|
+
for (const line of must(ctx, 'systemctl', ['show', HOME_UNIT, '-p', 'LoadState', '-p', 'ActiveState', '-p', 'MainPID', '-p', 'WorkingDirectory', '-p', 'Environment']).split('\n')) {
|
|
95
|
+
const at = line.indexOf('=');
|
|
96
|
+
if (at > 0) shown[line.slice(0, at)] = line.slice(at + 1);
|
|
97
|
+
}
|
|
98
|
+
if (shown.LoadState !== 'loaded') throw new Error(`${HOME_UNIT} is not installed on this host, and this runner replaces only that unit.`);
|
|
99
|
+
const environment = parseEnvironment(shown.Environment);
|
|
100
|
+
const port = Number(environment.PORT);
|
|
101
|
+
if (!Number.isInteger(port) || port < 1) throw new Error(`${HOME_UNIT} sets no PORT.`);
|
|
102
|
+
return { active: shown.ActiveState === 'active', mainPid: Number(shown.MainPID), workingDirectory: shown.WorkingDirectory, environment, port };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const unitActive = (ctx, unit) => run(ctx, 'systemctl', ['is-active', '--quiet', unit]).ok;
|
|
106
|
+
const chainExists = (ctx) => run(ctx, 'iptables', ['-t', 'nat', '-S', SWAP_CHAIN]).ok;
|
|
107
|
+
const applyRules = (ctx, rules) => must(ctx, 'iptables-restore', ['--noflush'], { input: rules });
|
|
108
|
+
const listeners = (ctx, port) => parseListeners(must(ctx, 'ss', ['-ltnH', `sport = :${port}`]));
|
|
109
|
+
const established = (ctx, port) => must(ctx, 'ss', ['-tnH', 'state', 'established', `sport = :${port}`]).split('\n').filter(Boolean).length;
|
|
110
|
+
|
|
111
|
+
/** Ask an instance over HTTP. A local probe stays on a loopback source so `requireAuthOrLocal` accepts it. */
|
|
112
|
+
export function httpProbe({ host = '127.0.0.1', port, path: target, exempt = false, timeoutMs = 5000 }) {
|
|
113
|
+
return new Promise((resolve, reject) => {
|
|
114
|
+
const source = exempt ? { localAddress: '127.0.0.1', localPort: randomInt(PROBE_SOURCE_PORTS[0], PROBE_SOURCE_PORTS[1] + 1) } : {};
|
|
115
|
+
const request = http.get({ host, port, path: target, agent: false, timeout: timeoutMs, ...source }, (response) => {
|
|
116
|
+
let text = '';
|
|
117
|
+
response.setEncoding('utf8');
|
|
118
|
+
response.on('data', (chunk) => { text += chunk; });
|
|
119
|
+
response.on('end', () => {
|
|
120
|
+
try { resolve({ status: response.statusCode, body: JSON.parse(text) }); } catch { resolve({ status: response.statusCode, body: null }); }
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
request.on('timeout', () => request.destroy(new Error(`No answer from ${host}:${port} within ${timeoutMs} ms`)));
|
|
124
|
+
request.on('error', reject);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function deployment(ctx, target) {
|
|
129
|
+
const { status, body } = await ctx.probe({ path: '/health/deployment', ...target });
|
|
130
|
+
return status === 200 && body?.status === 'ok' ? body : null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function waitFor(ctx, description, ms, check) {
|
|
134
|
+
const deadline = ctx.now() + ms;
|
|
135
|
+
let last;
|
|
136
|
+
for (;;) {
|
|
137
|
+
try {
|
|
138
|
+
const value = await check();
|
|
139
|
+
if (value) return value;
|
|
140
|
+
last = undefined;
|
|
141
|
+
} catch (error) { last = error; }
|
|
142
|
+
if (ctx.now() >= deadline) throw new Error(`${description} did not happen within ${Math.round(ms / 1000)} s${last ? `: ${last.message}` : ''}`);
|
|
143
|
+
await ctx.sleep(ctx.pollMs);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Wait for open connections to finish. Streams that never do are closed at the deadline and reconnect. */
|
|
148
|
+
async function drain(ctx, port) {
|
|
149
|
+
const deadline = ctx.now() + ctx.drainMs;
|
|
150
|
+
for (;;) {
|
|
151
|
+
const open = established(ctx, port);
|
|
152
|
+
if (open === 0 || ctx.now() >= deadline) return open;
|
|
153
|
+
await ctx.sleep(ctx.pollMs);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function readState(ctx) {
|
|
158
|
+
try { return JSON.parse(ctx.fs.readFileSync(ctx.stateFile, 'utf8')); } catch { return null; }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function writeState(ctx, state) {
|
|
162
|
+
const temporary = `${ctx.stateFile}.${process.pid}.tmp`;
|
|
163
|
+
ctx.fs.writeFileSync(temporary, JSON.stringify(state, null, 2));
|
|
164
|
+
ctx.fs.renameSync(temporary, ctx.stateFile);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function stopBridge(ctx) {
|
|
168
|
+
run(ctx, 'systemctl', ['stop', BRIDGE_UNIT], { timeout: 60000 });
|
|
169
|
+
run(ctx, 'systemctl', ['reset-failed', BRIDGE_UNIT]);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ─── Stage ──────────────────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
function assertCheckoutCanMove(ctx, unit, version) {
|
|
175
|
+
const directory = unit.workingDirectory;
|
|
176
|
+
const real = (file) => { try { return ctx.fs.realpathSync(file); } catch { return file; } };
|
|
177
|
+
if (real(directory) === real(ctx.packageRoot)) throw new Error('The replacement must be an install separate from the checkout the server runs from.');
|
|
178
|
+
if (!ctx.fs.existsSync(path.join(directory, '.git'))) throw new Error(`${directory} is not a git checkout, so it cannot follow a release.`);
|
|
179
|
+
const current = JSON.parse(ctx.fs.readFileSync(path.join(directory, 'package.json'), 'utf8'));
|
|
180
|
+
if (current.name !== ctx.pkg.name) throw new Error(`${directory} holds ${current.name}, not ${ctx.pkg.name}.`);
|
|
181
|
+
if (current.version !== version && must(ctx, 'git', ['-C', directory, 'status', '--porcelain']).trim()) {
|
|
182
|
+
throw new Error(`${directory} has uncommitted changes, so it could not move to ${version}. Nothing was changed.`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The rule only leaves open connections alone because conntrack already knows them. Proved on a
|
|
188
|
+
* real kernel: with tracking off, the first packet of a connection opened before the rule counts
|
|
189
|
+
* as new, gets rewritten to the staged port, and the client receives a reset.
|
|
190
|
+
*/
|
|
191
|
+
function assertConnectionsTracked(ctx) {
|
|
192
|
+
let tracked = 0;
|
|
193
|
+
try { tracked = Number(ctx.fs.readFileSync(ctx.conntrackCountFile, 'utf8')); } catch { /* not loaded */ }
|
|
194
|
+
if (!(tracked > 0)) throw new Error('Connection tracking is not active on this host, so moving traffic would reset every open connection. Nothing was changed.');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function assertMaintenanceIdle(ctx, brainDir) {
|
|
198
|
+
const lock = path.join(brainDir, 'memory-derived', 'automatic-brain-updates.json.lock');
|
|
199
|
+
if (!ctx.fs.existsSync(lock)) return;
|
|
200
|
+
const pid = Number(ctx.fs.readFileSync(lock, 'utf8'));
|
|
201
|
+
if (pid && ctx.isAlive(pid)) throw new Error(`Maintenance is running (PID ${pid}). Stage again when it finishes.`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function stage(input, ctx) {
|
|
205
|
+
const { version, slot, operationKey } = input;
|
|
206
|
+
const deploymentId = `${ctx.pkg.name}@${version}`;
|
|
207
|
+
const receipt = (state) => ({ operationKey, verified: true, isolated: true, slot, version, deploymentId, port: state.bridgePort, healthUrl: state.healthUrl });
|
|
208
|
+
if (ctx.pkg.version !== version) throw new Error(`This install is ${ctx.pkg.version}. Install ${ctx.pkg.name}@${version} on this host, then stage again.`);
|
|
209
|
+
if (chainExists(ctx)) throw new Error('A swap is in progress: traffic is on the staged server. Finish it with deploy retire.');
|
|
210
|
+
|
|
211
|
+
const previous = readState(ctx);
|
|
212
|
+
if (unitActive(ctx, BRIDGE_UNIT)) {
|
|
213
|
+
const staged = previous && await deployment(ctx, { port: previous.bridgePort }).catch(() => null);
|
|
214
|
+
if (staged?.deploymentId === deploymentId && previous.deploymentId === deploymentId) return receipt(previous);
|
|
215
|
+
throw new Error(`${BRIDGE_UNIT} is already running something else. Stop it before staging ${version}.`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const unit = readHomeUnit(ctx);
|
|
219
|
+
const homePort = unit.port;
|
|
220
|
+
const bridgePort = homePort + 1;
|
|
221
|
+
const home = await ctx.probe({ port: homePort, path: '/health', timeoutMs: 15000 }).catch(() => null);
|
|
222
|
+
const agentDir = home?.body?.vfs?.path;
|
|
223
|
+
if (!agentDir) throw new Error(`Nothing answers on port ${homePort}, so there is no server to replace. Start ${HOME_UNIT} first.`);
|
|
224
|
+
assertMaintenanceIdle(ctx, path.join(agentDir, 'skills', 'total-recall'));
|
|
225
|
+
assertCheckoutCanMove(ctx, unit, version);
|
|
226
|
+
assertConnectionsTracked(ctx);
|
|
227
|
+
const addresses = listeners(ctx, homePort);
|
|
228
|
+
const unsupported = addresses.filter((address) => !IPV4.test(address) || address === '0.0.0.0');
|
|
229
|
+
if (!addresses.length || unsupported.length) throw new Error(`Cannot move port ${homePort}: it listens on ${addresses.join(', ') || 'nothing'}, and only specific IPv4 addresses are handled.`);
|
|
230
|
+
if (listeners(ctx, bridgePort).length) throw new Error(`Port ${bridgePort} is in use, and the staged server needs it.`);
|
|
231
|
+
|
|
232
|
+
const environment = {
|
|
233
|
+
...unit.environment,
|
|
234
|
+
NODE_ENV: 'production',
|
|
235
|
+
PORT: String(bridgePort),
|
|
236
|
+
AGENT_DIR: agentDir,
|
|
237
|
+
DISABLE_DAEMON: 'true',
|
|
238
|
+
TR_MONITOR_PORT: '0',
|
|
239
|
+
TR_PACKAGE_ROOT: ctx.packageRoot,
|
|
240
|
+
TR_SERVER_PID_FILE: `${path.dirname(ctx.stateFile)}/total-recall-server-bridge.pid`,
|
|
241
|
+
};
|
|
242
|
+
run(ctx, 'systemctl', ['reset-failed', BRIDGE_UNIT]);
|
|
243
|
+
must(ctx, 'systemd-run', [
|
|
244
|
+
'--unit', BRIDGE_UNIT,
|
|
245
|
+
'--collect',
|
|
246
|
+
'--description=Total Recall brain server, staged for a swap',
|
|
247
|
+
`--working-directory=${unit.workingDirectory}`,
|
|
248
|
+
'-p', 'Restart=on-failure',
|
|
249
|
+
'-p', 'RestartSec=2',
|
|
250
|
+
...Object.entries(environment).map(([key, value]) => `--setenv=${key}=${value}`),
|
|
251
|
+
ctx.nodePath,
|
|
252
|
+
path.join(ctx.packageRoot, 'src', 'server', 'index.mjs'),
|
|
253
|
+
]);
|
|
254
|
+
try {
|
|
255
|
+
await waitFor(ctx, `Version ${version} answering on port ${bridgePort}`, ctx.bootMs, async () => {
|
|
256
|
+
const staged = await deployment(ctx, { port: bridgePort });
|
|
257
|
+
return staged?.deploymentId === deploymentId && addresses.every((address) => listeners(ctx, bridgePort).includes(address));
|
|
258
|
+
});
|
|
259
|
+
const stagedHealth = await ctx.probe({ port: bridgePort, path: '/health', timeoutMs: 15000 });
|
|
260
|
+
if (stagedHealth.body?.vfs?.path !== agentDir) throw new Error(`The staged server opened ${stagedHealth.body?.vfs?.path}, not ${agentDir}.`);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
stopBridge(ctx);
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
265
|
+
const state = {
|
|
266
|
+
version,
|
|
267
|
+
deploymentId,
|
|
268
|
+
name: ctx.pkg.name,
|
|
269
|
+
homePort,
|
|
270
|
+
bridgePort,
|
|
271
|
+
addresses,
|
|
272
|
+
healthUrl: `http://${addresses.find((address) => address !== '127.0.0.1') || '127.0.0.1'}:${bridgePort}/health/deployment`,
|
|
273
|
+
liveUrl: `http://${addresses.find((address) => address !== '127.0.0.1') || '127.0.0.1'}:${homePort}/health/deployment`,
|
|
274
|
+
phase: 'staged',
|
|
275
|
+
};
|
|
276
|
+
writeState(ctx, state);
|
|
277
|
+
return receipt(state);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ─── Switch ─────────────────────────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
export async function switchTraffic(input, ctx) {
|
|
283
|
+
const { slot, deploymentId, previousDeploymentId, operationKey } = input;
|
|
284
|
+
const state = readState(ctx);
|
|
285
|
+
if (!state || state.deploymentId !== deploymentId) throw new Error('No staged release matches this switch. Stage it first.');
|
|
286
|
+
const staged = await deployment(ctx, { port: state.bridgePort }).catch(() => null);
|
|
287
|
+
if (staged?.deploymentId !== deploymentId) throw new Error(`The staged server is not answering on port ${state.bridgePort}. Nothing was switched.`);
|
|
288
|
+
|
|
289
|
+
if (!chainExists(ctx)) {
|
|
290
|
+
if (!previousDeploymentId) {
|
|
291
|
+
// The first managed release has no previous slot. Name what is really serving so it retires too.
|
|
292
|
+
const home = await ctx.probe({ port: state.homePort, path: '/health', timeoutMs: 15000 });
|
|
293
|
+
if (!home.body?.version) throw new Error(`Port ${state.homePort} did not report what it serves. Nothing was switched.`);
|
|
294
|
+
state.displaced = { version: home.body.version, deploymentId: `${state.name}@${home.body.version}`, port: state.homePort };
|
|
295
|
+
}
|
|
296
|
+
applyRules(ctx, swapOnRules(state));
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
for (const host of state.addresses) {
|
|
300
|
+
const seen = await deployment(ctx, { host, port: state.homePort });
|
|
301
|
+
if (seen?.pid !== staged.pid) throw new Error(`${host}:${state.homePort} is served by PID ${seen?.pid}, not the staged PID ${staged.pid}.`);
|
|
302
|
+
}
|
|
303
|
+
} catch (error) {
|
|
304
|
+
applyRules(ctx, swapOffRules());
|
|
305
|
+
throw new Error(`Traffic did not reach the staged server, so the rule was removed. ${error.message}`);
|
|
306
|
+
}
|
|
307
|
+
state.phase = 'switched';
|
|
308
|
+
writeState(ctx, state);
|
|
309
|
+
return { operationKey, verified: true, graceful: true, activeSlot: slot, deploymentId, ...(state.displaced ? { displaced: state.displaced } : {}) };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ─── Retire ─────────────────────────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
/** Long work runs in its own unit: it must outlive a mesh call and the server being replaced. */
|
|
315
|
+
export async function retire(input, ctx) {
|
|
316
|
+
const { deploymentId, operationKey } = input;
|
|
317
|
+
const state = readState(ctx);
|
|
318
|
+
if (!state || !['switched', 'converged'].includes(state.phase)) throw new Error('No switched release to retire behind.');
|
|
319
|
+
const job = state.converge;
|
|
320
|
+
if (job?.status === 'done') {
|
|
321
|
+
return { operationKey, verified: true, deploymentId, stopped: true, filesRemoved: true, drained: true, closedConnections: job.closedConnections ?? 0, activePort: state.homePort, activeHealthUrl: state.liveUrl };
|
|
322
|
+
}
|
|
323
|
+
if (unitActive(ctx, CONVERGE_UNIT)) return { operationKey, pending: true, phase: job?.phase || 'starting' };
|
|
324
|
+
if (job?.status === 'failed' && !job.reported) {
|
|
325
|
+
state.converge = { ...job, reported: true };
|
|
326
|
+
writeState(ctx, state);
|
|
327
|
+
throw new Error(`${job.phase}: ${job.error}. Traffic stays on the staged server. Retire again to retry.`);
|
|
328
|
+
}
|
|
329
|
+
state.converge = { status: 'running', phase: 'starting' };
|
|
330
|
+
writeState(ctx, state);
|
|
331
|
+
run(ctx, 'systemctl', ['reset-failed', CONVERGE_UNIT]);
|
|
332
|
+
must(ctx, 'systemd-run', ['--unit', CONVERGE_UNIT, '--collect', '--description=Total Recall brain server swap', ctx.nodePath, ctx.entry, 'converge']);
|
|
333
|
+
return { operationKey, pending: true, phase: 'starting' };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Replace the home unit behind the rule. Traffic leaves the staged server only after the new home verifies. */
|
|
337
|
+
export async function converge(ctx) {
|
|
338
|
+
const state = readState(ctx);
|
|
339
|
+
if (!state) throw new Error('No swap state on this host.');
|
|
340
|
+
const update = (fields) => { state.converge = { ...state.converge, ...fields }; writeState(ctx, state); };
|
|
341
|
+
update({ status: 'running', phase: 'starting', error: null, reported: false });
|
|
342
|
+
try {
|
|
343
|
+
const unit = readHomeUnit(ctx);
|
|
344
|
+
const already = await deployment(ctx, { port: state.homePort, exempt: true }).catch(() => null);
|
|
345
|
+
if (already?.deploymentId !== state.deploymentId) {
|
|
346
|
+
update({ phase: 'draining the old server' });
|
|
347
|
+
const closedConnections = await drain(ctx, state.homePort);
|
|
348
|
+
update({ phase: 'stopping the old server', closedConnections });
|
|
349
|
+
must(ctx, 'systemctl', ['stop', HOME_UNIT], { timeout: 180000 });
|
|
350
|
+
update({ phase: 'moving the checkout' });
|
|
351
|
+
await ctx.moveCheckout({ directory: unit.workingDirectory, name: state.name, version: state.version, artifactRoot: ctx.packageRoot, exec: ctx.exec });
|
|
352
|
+
update({ phase: 'starting the new server' });
|
|
353
|
+
must(ctx, 'systemctl', ['start', HOME_UNIT], { timeout: 60000 });
|
|
354
|
+
}
|
|
355
|
+
const home = await waitFor(ctx, `${HOME_UNIT} reporting ${state.version}`, ctx.homeBootMs, async () => {
|
|
356
|
+
const seen = await deployment(ctx, { port: state.homePort, exempt: true });
|
|
357
|
+
return seen?.deploymentId === state.deploymentId ? seen : null;
|
|
358
|
+
});
|
|
359
|
+
update({ phase: 'waiting for the daemon' });
|
|
360
|
+
await waitFor(ctx, 'The daemon running under the new server', ctx.daemonMs, async () => {
|
|
361
|
+
const health = await ctx.probe({ port: state.homePort, path: '/health', exempt: true, timeoutMs: 15000 });
|
|
362
|
+
return health.body?.daemon === 'running';
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
update({ phase: 'moving traffic home' });
|
|
366
|
+
const mainPid = readHomeUnit(ctx).mainPid;
|
|
367
|
+
if (home.pid !== mainPid) throw new Error(`Port ${state.homePort} is served by PID ${home.pid}, not ${HOME_UNIT}'s PID ${mainPid}.`);
|
|
368
|
+
if (chainExists(ctx)) applyRules(ctx, swapOffRules());
|
|
369
|
+
try {
|
|
370
|
+
for (const host of state.addresses) {
|
|
371
|
+
const seen = await deployment(ctx, { host, port: state.homePort });
|
|
372
|
+
if (seen?.pid !== home.pid) throw new Error(`${host}:${state.homePort} is served by PID ${seen?.pid}, not ${home.pid}.`);
|
|
373
|
+
}
|
|
374
|
+
} catch (error) {
|
|
375
|
+
applyRules(ctx, swapOnRules(state));
|
|
376
|
+
throw error;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
update({ phase: 'draining the staged server' });
|
|
380
|
+
const stagedOpen = await drain(ctx, state.bridgePort);
|
|
381
|
+
update({ phase: 'stopping the staged server' });
|
|
382
|
+
stopBridge(ctx);
|
|
383
|
+
if (unitActive(ctx, BRIDGE_UNIT)) throw new Error(`${BRIDGE_UNIT} did not stop.`);
|
|
384
|
+
state.phase = 'converged';
|
|
385
|
+
update({ status: 'done', phase: 'done', closedConnections: (state.converge.closedConnections ?? 0) + stagedOpen });
|
|
386
|
+
} catch (error) {
|
|
387
|
+
update({ status: 'failed', error: error.message });
|
|
388
|
+
throw error;
|
|
389
|
+
}
|
|
390
|
+
return state.converge;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Move the checkout while nothing runs from it. The dashboard is not in git, so it comes from the verified install. */
|
|
394
|
+
export async function moveCheckout({ directory, name, version, artifactRoot, exec }) {
|
|
395
|
+
const current = JSON.parse(fs.readFileSync(path.join(directory, 'package.json'), 'utf8')).version;
|
|
396
|
+
if (current !== version) {
|
|
397
|
+
const { updateSourceBrainPackage } = await import('./source-brain-updates.mjs');
|
|
398
|
+
const moved = updateSourceBrainPackage({ directory, name, version, exec });
|
|
399
|
+
if (!moved.ok) throw new Error(`The checkout could not move to ${version}: ${moved.error}`);
|
|
400
|
+
}
|
|
401
|
+
const dist = path.join(artifactRoot, 'frontend', 'dist');
|
|
402
|
+
if (!fs.existsSync(dist)) return;
|
|
403
|
+
const target = path.join(directory, 'frontend', 'dist');
|
|
404
|
+
const temporary = `${target}.${process.pid}`;
|
|
405
|
+
fs.cpSync(dist, temporary, { recursive: true });
|
|
406
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
407
|
+
fs.renameSync(temporary, target);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ─── Status and entry point ─────────────────────────────────────────────────────
|
|
411
|
+
|
|
412
|
+
export async function status(ctx) {
|
|
413
|
+
const unit = readHomeUnit(ctx);
|
|
414
|
+
const state = readState(ctx);
|
|
415
|
+
const home = await ctx.probe({ port: unit.port, path: '/health', exempt: true, timeoutMs: 15000 }).catch(() => null);
|
|
416
|
+
return {
|
|
417
|
+
install: { name: ctx.pkg.name, version: ctx.pkg.version, root: ctx.packageRoot },
|
|
418
|
+
home: { unit: HOME_UNIT, active: unit.active, mainPid: unit.mainPid, port: unit.port, directory: unit.workingDirectory, version: home?.body?.version ?? null, daemon: home?.body?.daemon ?? null, listening: listeners(ctx, unit.port) },
|
|
419
|
+
rule: chainExists(ctx),
|
|
420
|
+
staged: unitActive(ctx, BRIDGE_UNIT),
|
|
421
|
+
converging: unitActive(ctx, CONVERGE_UNIT),
|
|
422
|
+
swap: state,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function hostContext(overrides = {}) {
|
|
427
|
+
return {
|
|
428
|
+
exec: spawnSync,
|
|
429
|
+
probe: httpProbe,
|
|
430
|
+
fs,
|
|
431
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
432
|
+
now: Date.now,
|
|
433
|
+
isAlive: (pid) => { try { process.kill(pid, 0); return true; } catch { return false; } },
|
|
434
|
+
moveCheckout,
|
|
435
|
+
stateFile: '/run/total-recall-server-release.json',
|
|
436
|
+
conntrackCountFile: '/proc/sys/net/netfilter/nf_conntrack_count',
|
|
437
|
+
packageRoot: moduleRoot,
|
|
438
|
+
pkg: JSON.parse(fs.readFileSync(path.join(moduleRoot, 'package.json'), 'utf8')),
|
|
439
|
+
nodePath: process.execPath,
|
|
440
|
+
entry: path.join(moduleRoot, 'bin', 'total-recall-server-release.mjs'),
|
|
441
|
+
pollMs: 1000,
|
|
442
|
+
bootMs: 25000,
|
|
443
|
+
homeBootMs: 180000,
|
|
444
|
+
daemonMs: 120000,
|
|
445
|
+
drainMs: 30000,
|
|
446
|
+
...overrides,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const HELP = `
|
|
451
|
+
total-recall-server-release — replace the brain server on this host with no gap
|
|
452
|
+
|
|
453
|
+
Called on the node by 'total-recall deploy' through the mesh. Linux with systemd only.
|
|
454
|
+
Run it through 'total-recall deploy release <version> --adapter-secret <reference>'.
|
|
455
|
+
|
|
456
|
+
Usage:
|
|
457
|
+
total-recall-server-release status Read the swap state; changes nothing
|
|
458
|
+
total-recall-server-release stage <payload> Start the release on the next port and verify it
|
|
459
|
+
total-recall-server-release switch <payload> Move new connections to it
|
|
460
|
+
total-recall-server-release retire <payload> Replace the home unit, then move traffic home
|
|
461
|
+
total-recall-server-release converge The detached work behind retire
|
|
462
|
+
|
|
463
|
+
A payload is base64url JSON from the deploy plugin.
|
|
464
|
+
`;
|
|
465
|
+
|
|
466
|
+
/** Prints one JSON document on stdout, which is what the deploy plugin reads through the mesh. */
|
|
467
|
+
export async function main(argv, { write = (text) => process.stdout.write(text), makeContext = hostContext, platform = process.platform } = {}) {
|
|
468
|
+
const [operation, payload, ...extra] = argv;
|
|
469
|
+
if (!operation || operation === '--help' || operation === '-h') { write(HELP); return 0; }
|
|
470
|
+
const handlers = { status: (_input, ctx) => status(ctx), converge: (_input, ctx) => converge(ctx), stage, switch: switchTraffic, retire };
|
|
471
|
+
const answer = (document) => write(`${JSON.stringify(document)}\n`);
|
|
472
|
+
if (!Object.hasOwn(handlers, operation)) { answer({ verified: false, error: `Unknown operation: ${operation}` }); return 1; }
|
|
473
|
+
const takesPayload = ['stage', 'switch', 'retire'].includes(operation);
|
|
474
|
+
if (extra.length || (!takesPayload && payload)) { answer({ verified: false, error: `Unexpected argument: ${extra[0] || payload}` }); return 1; }
|
|
475
|
+
if (platform !== 'linux') { answer({ verified: false, error: 'This runner needs Linux with systemd and iptables.' }); return 1; }
|
|
476
|
+
let input = {};
|
|
477
|
+
if (takesPayload) {
|
|
478
|
+
try { input = JSON.parse(Buffer.from(String(payload), 'base64url').toString('utf8')); } catch { answer({ verified: false, error: 'The payload is not base64url JSON.' }); return 1; }
|
|
479
|
+
}
|
|
480
|
+
try {
|
|
481
|
+
answer(await handlers[operation](input, makeContext()));
|
|
482
|
+
return 0;
|
|
483
|
+
} catch (error) {
|
|
484
|
+
answer({ operationKey: input.operationKey, verified: false, error: error.message });
|
|
485
|
+
return 1;
|
|
486
|
+
}
|
|
487
|
+
}
|