dsh-foreman 0.1.0 → 0.1.1
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/CHANGELOG.md +22 -0
- package/lib/index.js +20 -2
- package/package.json +5 -2
- package/scripts/add-device.mjs +122 -0
- package/scripts/dsh-foreman +250 -0
- package/scripts/migrate.mjs +188 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,28 @@
|
|
|
6
6
|
|
|
7
7
|
本项目自己的版本从 `0.1.0` 开始。
|
|
8
8
|
|
|
9
|
+
## 0.1.1 — 2026-11-07
|
|
10
|
+
|
|
11
|
+
修的是 `0.1.0` 发布时才暴露的两个问题,都是真机验证(上海服务器 srv-02)发现的。
|
|
12
|
+
|
|
13
|
+
### 修复:运维脚本没有进包
|
|
14
|
+
|
|
15
|
+
`0.1.0` 的 `files` 漏了 `scripts/`,于是装完 `dsh-foreman@0.1.0` 之后
|
|
16
|
+
`node scripts/add-device.mjs` **不存在** —— 而那是加设备的唯一入口,
|
|
17
|
+
文档里却写着让人这么跑。现在随包发三个脚本:
|
|
18
|
+
|
|
19
|
+
- `scripts/add-device.mjs`(加设备 / 换令牌 / 吊销)
|
|
20
|
+
- `scripts/migrate.mjs`(迁机打包与自检)
|
|
21
|
+
- `scripts/dsh-foreman`(服务脚本)
|
|
22
|
+
|
|
23
|
+
`scripts/sync-wsl.sh`、`scripts/deploy-preview.sh` 是开发用的,**不发布**。
|
|
24
|
+
|
|
25
|
+
### 修复:一个不带正文的帧会崩掉握手
|
|
26
|
+
|
|
27
|
+
`ws.ts` 把收到的任何消息一律 `.toString()`,于是 payload 缺失时会得到字符串 `"undefined"`,
|
|
28
|
+
再被当成帧去解析。现在只接受真的带文本的帧(字符串 / Buffer / Buffer[]),
|
|
29
|
+
其余直接忽略,并在握手阶段给出可读原因。
|
|
30
|
+
|
|
9
31
|
## 0.1.0 — 2026-11-07
|
|
10
32
|
|
|
11
33
|
首个公开发布。**一个常驻主 loop,把工单派给多台在线设备上各自完整运行的 DSH,账本只有一份。**
|
package/lib/index.js
CHANGED
|
@@ -9962,7 +9962,8 @@ var SocketChannel = class {
|
|
|
9962
9962
|
constructor(socket) {
|
|
9963
9963
|
this.socket = socket;
|
|
9964
9964
|
socket.on("message", (data) => {
|
|
9965
|
-
const text =
|
|
9965
|
+
const text = textOf(data);
|
|
9966
|
+
if (text === void 0) return;
|
|
9966
9967
|
if (this.messages.length === 0) {
|
|
9967
9968
|
this.pending.push(text);
|
|
9968
9969
|
return;
|
|
@@ -10030,6 +10031,18 @@ async function serve(input) {
|
|
|
10030
10031
|
};
|
|
10031
10032
|
}
|
|
10032
10033
|
/**
|
|
10034
|
+
* The text of one frame, or undefined when the frame carries none.
|
|
10035
|
+
*
|
|
10036
|
+
* `ws` hands over a string, a Buffer, an array of Buffers, or — for a binary frame this protocol never sends — a raw
|
|
10037
|
+
* Buffer. Converting blindly would turn an absent payload into the literal string "undefined", which then reaches a
|
|
10038
|
+
* parser as a malformed frame.
|
|
10039
|
+
*/
|
|
10040
|
+
function textOf(data) {
|
|
10041
|
+
if (typeof data === "string") return data;
|
|
10042
|
+
if (Buffer.isBuffer(data)) return data.toString("utf8");
|
|
10043
|
+
if (Array.isArray(data)) return Buffer.concat(data).toString("utf8");
|
|
10044
|
+
}
|
|
10045
|
+
/**
|
|
10033
10046
|
* The device id the first frame claims, and why it is missing when it is.
|
|
10034
10047
|
*
|
|
10035
10048
|
* Reading it here rather than in the hub keeps `Hub` free of `ws` types without weakening the handshake: the hub
|
|
@@ -10047,7 +10060,12 @@ function firstFrame(socket) {
|
|
|
10047
10060
|
const timer = setTimeout(() => settle({ reason: "the connection did not open with a hello within 10 seconds" }), 1e4);
|
|
10048
10061
|
const closed = () => settle({ reason: "the connection closed before it said hello" });
|
|
10049
10062
|
socket.once("message", (data) => {
|
|
10050
|
-
const
|
|
10063
|
+
const text = textOf(data);
|
|
10064
|
+
if (text === void 0) {
|
|
10065
|
+
settle({ reason: "the first frame carried no text" });
|
|
10066
|
+
return;
|
|
10067
|
+
}
|
|
10068
|
+
const deviceId = deviceIdOf(text);
|
|
10051
10069
|
settle(deviceId === void 0 ? { reason: "the first frame must be a hello naming a device" } : {
|
|
10052
10070
|
deviceId,
|
|
10053
10071
|
reason: ""
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "dsh-foreman",
|
|
3
3
|
"author": "niechao136",
|
|
4
4
|
"license": "MIT",
|
|
5
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.1",
|
|
6
6
|
"description": "dsh-foreman:主 loop 在多设备上派单的 DSH bundle:需求来自本机操作者的主会话而不是 GitHub/飞书,每个工作区各有一条主会话,主会话只负责路由,真正的活儿派给 Worker 在该工作区目录里执行。",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"main": "lib/index.js",
|
|
@@ -27,7 +27,10 @@
|
|
|
27
27
|
"CHANGELOG.md",
|
|
28
28
|
"README.md",
|
|
29
29
|
"prompts",
|
|
30
|
-
"skills"
|
|
30
|
+
"skills",
|
|
31
|
+
"scripts/add-device.mjs",
|
|
32
|
+
"scripts/migrate.mjs",
|
|
33
|
+
"scripts/dsh-foreman"
|
|
31
34
|
],
|
|
32
35
|
"dsh": {
|
|
33
36
|
"bundle": {
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Register a device on the hub, or manage the ones already registered.
|
|
4
|
+
*
|
|
5
|
+
* Adding a device must not mean editing plugin configuration and restarting the hub — that is an anti-goal of the
|
|
6
|
+
* design — so this writes the registry row directly and the running hub picks it up on the device's next hello.
|
|
7
|
+
*
|
|
8
|
+
* Plain ESM on `node:sqlite`, no dependencies: it has to run on a server where only the hub is installed.
|
|
9
|
+
*
|
|
10
|
+
* node scripts/add-device.mjs add --home <dir> --id laptop --name "公司笔记本" \
|
|
11
|
+
* --root shop=/home/me/work/shop --label intranet
|
|
12
|
+
* node scripts/add-device.mjs list --home <dir>
|
|
13
|
+
* node scripts/add-device.mjs rotate --home <dir> --id laptop
|
|
14
|
+
* node scripts/add-device.mjs revoke --home <dir> --id laptop
|
|
15
|
+
*/
|
|
16
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
17
|
+
import { existsSync } from 'node:fs'
|
|
18
|
+
import { join, resolve } from 'node:path'
|
|
19
|
+
import { DatabaseSync } from 'node:sqlite'
|
|
20
|
+
|
|
21
|
+
const DATABASE = 'state/superagent/orchestrator.sqlite'
|
|
22
|
+
const PREFIX = 'foreman-device:'
|
|
23
|
+
|
|
24
|
+
const usage = `Usage:
|
|
25
|
+
add --id <id> --name <name> [--label <label>]... [--root <project>=<path>]...
|
|
26
|
+
list
|
|
27
|
+
rotate --id <id>
|
|
28
|
+
revoke --id <id>
|
|
29
|
+
All commands take --home <dir> (default: $DSH_HOME, else ~/.dsh) and --db <path> to override the database.`
|
|
30
|
+
|
|
31
|
+
const hashToken = token => createHash('sha256').update(token, 'utf8').digest('hex')
|
|
32
|
+
|
|
33
|
+
/** Reads repeating and single `--name value` options into a list of pairs. */
|
|
34
|
+
function options(argv) {
|
|
35
|
+
const single = new Map()
|
|
36
|
+
const many = { label: [], root: [] }
|
|
37
|
+
for (let index = 0; index < argv.length; index++) {
|
|
38
|
+
const name = argv[index]
|
|
39
|
+
if (!name.startsWith('--')) throw new Error(`unexpected argument ${name}\n\n${usage}`)
|
|
40
|
+
const key = name.slice(2)
|
|
41
|
+
const value = argv[index + 1]
|
|
42
|
+
if (value === undefined || value.startsWith('--')) throw new Error(`${name} needs a value\n\n${usage}`)
|
|
43
|
+
if (key in many) many[key].push(value)
|
|
44
|
+
else single.set(key, value)
|
|
45
|
+
index++
|
|
46
|
+
}
|
|
47
|
+
return { single, many }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Opens the hub's database, refusing a path that is not one so a typo cannot write anywhere. */
|
|
51
|
+
function open(single) {
|
|
52
|
+
const home = resolve(single.get('home') ?? process.env.DSH_HOME ?? join(process.env.HOME ?? process.env.USERPROFILE ?? '.', '.dsh'))
|
|
53
|
+
const path = single.get('db') ? resolve(single.get('db')) : join(home, DATABASE)
|
|
54
|
+
if (!existsSync(path)) throw new Error(`no hub database at ${path}; is --home the DSH home of the loop instance?`)
|
|
55
|
+
const db = new DatabaseSync(path)
|
|
56
|
+
// The registry is a kv family, so this works without importing the hub's schema: the key format is the contract.
|
|
57
|
+
db.exec('CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT NOT NULL)')
|
|
58
|
+
return { db, path }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const read = (db, id) => {
|
|
62
|
+
const row = db.prepare('SELECT value FROM kv WHERE key=?').get(PREFIX + id)
|
|
63
|
+
return row === undefined ? undefined : JSON.parse(String(row.value))
|
|
64
|
+
}
|
|
65
|
+
const write = (db, record) => {
|
|
66
|
+
db.prepare('INSERT INTO kv VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').run(PREFIX + record.id, JSON.stringify(record))
|
|
67
|
+
}
|
|
68
|
+
const all = db => db.prepare('SELECT value FROM kv WHERE key LIKE ? ORDER BY key').all(`${PREFIX}%`).map(row => JSON.parse(String(row.value)))
|
|
69
|
+
|
|
70
|
+
/** `project=path`, split on the first `=` so a Windows path's own `=` survives. */
|
|
71
|
+
function rootsOf(values) {
|
|
72
|
+
return values.map(value => {
|
|
73
|
+
const at = value.indexOf('=')
|
|
74
|
+
if (at <= 0) throw new Error(`--root needs <project>=<path>, not ${value}`)
|
|
75
|
+
return { project: value.slice(0, at), path: value.slice(at + 1) }
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const [command, ...rest] = process.argv.slice(2)
|
|
80
|
+
if (command === undefined || ['-h', '--help', 'help'].includes(command)) {
|
|
81
|
+
console.log(usage)
|
|
82
|
+
process.exit(0)
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
const { single, many } = options(rest)
|
|
86
|
+
const { db, path } = open(single)
|
|
87
|
+
let output
|
|
88
|
+
try {
|
|
89
|
+
if (command === 'add') {
|
|
90
|
+
const id = single.get('id'), name = single.get('name')
|
|
91
|
+
if (!id) throw new Error('add needs --id')
|
|
92
|
+
if (read(db, id) !== undefined) throw new Error(`a device with id ${id} is already registered; use rotate to replace its token`)
|
|
93
|
+
const token = randomBytes(32).toString('hex')
|
|
94
|
+
write(db, { id, name: name ?? id, labels: many.label, roots: rootsOf(many.root), tokenHash: hashToken(token) })
|
|
95
|
+
// The token is printed once and never stored in readable form: there is nothing to look it up from later.
|
|
96
|
+
output = JSON.stringify({ device: id, token, note: 'this token is shown once; only its hash is stored' }, null, 2)
|
|
97
|
+
} else if (command === 'list') {
|
|
98
|
+
output = all(db).map(({ tokenHash: _hidden, ...record }) => record)
|
|
99
|
+
.map(record => JSON.stringify(record)).join('\n') || '(no device is registered)'
|
|
100
|
+
} else if (command === 'rotate') {
|
|
101
|
+
const id = single.get('id')
|
|
102
|
+
const device = id === undefined ? undefined : read(db, id)
|
|
103
|
+
if (device === undefined) throw new Error(`no device with id ${String(id)}`)
|
|
104
|
+
const token = randomBytes(32).toString('hex')
|
|
105
|
+
write(db, { ...device, tokenHash: hashToken(token) })
|
|
106
|
+
output = JSON.stringify({ device: device.id, token }, null, 2)
|
|
107
|
+
} else if (command === 'revoke') {
|
|
108
|
+
const id = single.get('id')
|
|
109
|
+
const device = id === undefined ? undefined : read(db, id)
|
|
110
|
+
if (device === undefined) throw new Error(`no device with id ${String(id)}`)
|
|
111
|
+
write(db, { ...device, revokedAt: new Date().toISOString() })
|
|
112
|
+
output = `revoked ${device.id}: its next frame is refused and its connection is dropped`
|
|
113
|
+
} else throw new Error(`unknown command ${command}\n\n${usage}`)
|
|
114
|
+
} finally {
|
|
115
|
+
db.close()
|
|
116
|
+
}
|
|
117
|
+
console.log(output)
|
|
118
|
+
if (command !== 'list') console.log(`\ndatabase: ${path}\nthe running hub needs no restart; the device picks this up when it next connects.`)
|
|
119
|
+
} catch (error) {
|
|
120
|
+
console.error(`add-device: ${error instanceof Error ? error.message : String(error)}`)
|
|
121
|
+
process.exit(1)
|
|
122
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Manage a local dsh-foreman service (one DSH profile, default `superagent`) without affecting other tmux sessions.
|
|
3
|
+
|
|
4
|
+
`status`, `start`, and `restart` exit 0 while the service runs normally; 1 when it is stopped, duplicated, unreachable, or
|
|
5
|
+
reporting a runtime error, which `restart` may fix; 2 when it runs while a component such as a Source reports an error;
|
|
6
|
+
and 3 when it runs and waits for setup. A restart fixes neither 2 nor 3: they need a person.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import fcntl
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
import re
|
|
15
|
+
import shlex
|
|
16
|
+
import shutil
|
|
17
|
+
import signal
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
import urllib.error
|
|
22
|
+
import urllib.request
|
|
23
|
+
|
|
24
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
25
|
+
ARTIFACTS = ROOT / '.artifacts'
|
|
26
|
+
CONTROL = ARTIFACTS / 'control'
|
|
27
|
+
KEEP_LOGS = 20
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def control(profile):
|
|
31
|
+
"""Return the control directory for `profile`, creating it if needed."""
|
|
32
|
+
directory = CONTROL / 'profiles' / profile
|
|
33
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
return directory
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def stopped(profile):
|
|
38
|
+
"""Return whether `profile` was intentionally stopped."""
|
|
39
|
+
return (control(profile) / 'manually-stopped').exists()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def serves(args, profile):
|
|
43
|
+
"""Return whether command-line arguments belong to the main DSH service process for `profile`."""
|
|
44
|
+
words, matched, value = [], False, False
|
|
45
|
+
for word in args[1:]:
|
|
46
|
+
if value:
|
|
47
|
+
matched, value = matched or word == profile, False
|
|
48
|
+
elif word == '--profile':
|
|
49
|
+
value = True
|
|
50
|
+
elif word == '--profile=' + profile:
|
|
51
|
+
matched = True
|
|
52
|
+
elif not word.startswith('-'):
|
|
53
|
+
words.append(word)
|
|
54
|
+
return matched and 'plugin' not in words
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def ps(*options, check=False):
|
|
58
|
+
"""Yield `(pid, comm, args)` tuples from `ps`; arguments are split on whitespace, so paths with spaces become several words and only `--profile` and subcommands are inspected."""
|
|
59
|
+
result = subprocess.run(['ps', '-ww', *options, '-o', 'pid=,ucomm=,args='], check=check, capture_output=True, text=True)
|
|
60
|
+
for line in result.stdout.splitlines():
|
|
61
|
+
parts = line.split(None, 2)
|
|
62
|
+
if len(parts) == 3 and parts[0].isdigit():
|
|
63
|
+
yield int(parts[0]), parts[1], parts[2].split()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def running(pid, profile):
|
|
67
|
+
"""Return whether `pid` is currently running the DSH service for `profile` (`ps -p` returns nothing once exited)."""
|
|
68
|
+
return any(found == pid and name in ('node', 'dsh') and serves(args, profile) for found, name, args in ps('-p', str(pid)))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def processes(profile):
|
|
72
|
+
"""Return PIDs of all running DSH service processes for `profile`, including instances started outside this script and the recorded `service.pid` while still running."""
|
|
73
|
+
found = [pid for pid, name, args in ps('-ax', check=True) if name in ('node', 'dsh') and serves(args, profile)]
|
|
74
|
+
try:
|
|
75
|
+
pid = int((control(profile) / 'service.pid').read_text())
|
|
76
|
+
except (OSError, ValueError):
|
|
77
|
+
return found
|
|
78
|
+
return found + [pid] if pid not in found and running(pid, profile) else found
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def state(url):
|
|
82
|
+
"""Fetch health and setup status from the management API, or return None if unreachable."""
|
|
83
|
+
request = urllib.request.Request(url.rstrip('/') + '/orchestrator/api/state',
|
|
84
|
+
headers={'x-orchestrator-request': '1'})
|
|
85
|
+
try:
|
|
86
|
+
with urllib.request.urlopen(request, timeout=2) as response:
|
|
87
|
+
value = json.load(response)
|
|
88
|
+
# Keys omitted by another version read as empty so the service is not mistaken for unreachable.
|
|
89
|
+
health, setup = value.get('health') or {}, value.get('setup') or {}
|
|
90
|
+
entries = {name: entry if isinstance(entry, dict) else {} for name, entry in health.items()}
|
|
91
|
+
return {'health': {name: entry.get('state') for name, entry in entries.items()},
|
|
92
|
+
'errors': {name: str(entry.get('detail', '')) for name, entry in entries.items() if entry.get('state') == 'error'},
|
|
93
|
+
'setup': setup.get('problem'), 'feishu': setup.get('feishuProblem')}
|
|
94
|
+
except (OSError, ValueError, AttributeError):
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
PART_ERROR = 2
|
|
99
|
+
SETUP_WAITS = 3
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def runtime_up(current):
|
|
103
|
+
"""Return whether the runtime is ready or paused awaiting operator action; a component still starting (such as history recovery) does not hold this up."""
|
|
104
|
+
return current is not None and current['health'].get('runtime') in ('ready', 'paused')
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def part_errors(current):
|
|
108
|
+
"""Return non-runtime components that are in an error state."""
|
|
109
|
+
return {name: detail for name, detail in sorted(current['errors'].items()) if name != 'runtime'}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def outcome(pids, current):
|
|
113
|
+
"""Compute the CLI exit code from running PIDs and management API state."""
|
|
114
|
+
if len(pids) != 1 or not runtime_up(current):
|
|
115
|
+
return 1
|
|
116
|
+
if current['setup']:
|
|
117
|
+
return SETUP_WAITS
|
|
118
|
+
# A component's lasting error, unlike a Feishu bot check that failed during setup, requires operator action.
|
|
119
|
+
return PART_ERROR if part_errors(current) else 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def status(args):
|
|
123
|
+
pids = processes(args.profile)
|
|
124
|
+
current = state(args.url) if pids else None
|
|
125
|
+
print(json.dumps({'pids': pids, 'cwd': str(ROOT), 'url': args.url,
|
|
126
|
+
'manually_stopped': stopped(args.profile), 'health': current and current['health'],
|
|
127
|
+
'errors': current and current['errors'], 'setup': current and current['setup'],
|
|
128
|
+
'feishu': current and current['feishu']}, ensure_ascii=False, indent=2))
|
|
129
|
+
return outcome(pids, current)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def stop(args, intentional=True):
|
|
133
|
+
"""Stop the service gracefully, recording a manual stop marker when `intentional` is True."""
|
|
134
|
+
pids = processes(args.profile)
|
|
135
|
+
if len(pids) > 1:
|
|
136
|
+
raise RuntimeError('Multiple dsh-foreman processes found; refusing to choose one.')
|
|
137
|
+
# Write the stop marker before waiting so monitors respect the intentional stop even if graceful shutdown times out.
|
|
138
|
+
if intentional:
|
|
139
|
+
(control(args.profile) / 'manually-stopped').write_text(time.strftime('%Y-%m-%dT%H:%M:%S%z') + '\n')
|
|
140
|
+
for pid in pids:
|
|
141
|
+
os.kill(pid, signal.SIGINT)
|
|
142
|
+
deadline = time.monotonic() + args.stop_timeout
|
|
143
|
+
while any(running(pid, args.profile) for pid in pids):
|
|
144
|
+
if time.monotonic() >= deadline:
|
|
145
|
+
raise RuntimeError('Graceful stop timed out; no forced kill was sent. Check status.')
|
|
146
|
+
time.sleep(0.5)
|
|
147
|
+
(control(args.profile) / 'service.pid').unlink(missing_ok=True)
|
|
148
|
+
print('dsh-foreman stopped. Automatic recovery is paused until start/restart.' if intentional else 'dsh-foreman stopped.')
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def prune_logs(profile):
|
|
152
|
+
"""Retain only the newest `KEEP_LOGS` log files for `profile`."""
|
|
153
|
+
name = re.compile(re.escape(f'dsh-foreman-{profile}-') + r'\d{8}-\d{6}-\d+\.log')
|
|
154
|
+
logs = sorted((path for path in ARTIFACTS.iterdir() if name.fullmatch(path.name)), key=lambda path: path.stat().st_mtime)
|
|
155
|
+
for path in logs[:-KEEP_LOGS]:
|
|
156
|
+
path.unlink(missing_ok=True)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def start(args):
|
|
160
|
+
session = 'dsh-' + args.profile
|
|
161
|
+
pids = processes(args.profile)
|
|
162
|
+
if len(pids) > 1:
|
|
163
|
+
raise RuntimeError('Multiple dsh-foreman processes found; refusing to start another.')
|
|
164
|
+
if not pids:
|
|
165
|
+
dsh = shutil.which('dsh')
|
|
166
|
+
if not dsh or not shutil.which('tmux'):
|
|
167
|
+
raise RuntimeError('Both dsh and tmux must be on PATH.')
|
|
168
|
+
exists = subprocess.run(['tmux', 'has-session', '-t', '=' + session],
|
|
169
|
+
capture_output=True).returncode == 0
|
|
170
|
+
if state(args.url) is not None:
|
|
171
|
+
raise RuntimeError('An existing orchestrator answers at this URL; refusing duplicate startup.')
|
|
172
|
+
stamp = time.strftime('%Y%m%d-%H%M%S')
|
|
173
|
+
log = ARTIFACTS / f'dsh-foreman-{args.profile}-{stamp}-{os.getpid()}.log'
|
|
174
|
+
log.touch()
|
|
175
|
+
prune_logs(args.profile)
|
|
176
|
+
# `exec` replaces the shell so the pane PID printed by tmux is the DSH service process itself.
|
|
177
|
+
command = ('umask 077; exec ' + shlex.join([dsh, '--profile', args.profile, '--no-open'])
|
|
178
|
+
+ ' >> ' + shlex.quote(str(log)) + ' 2>&1')
|
|
179
|
+
# Reuse an existing tmux session without disturbing other windows.
|
|
180
|
+
target = ['new-window', '-t', '=' + session + ':'] if exists else ['new-session', '-s', session]
|
|
181
|
+
pane = subprocess.run(['tmux', *target, '-d', '-P', '-F', '#{pane_pid}', '-n', 'server', '-c', str(ROOT), command],
|
|
182
|
+
check=True, capture_output=True, text=True).stdout.strip()
|
|
183
|
+
if pane.isdigit():
|
|
184
|
+
(control(args.profile) / 'service.pid').write_text(pane + '\n')
|
|
185
|
+
print('Log: ' + str(log), flush=True)
|
|
186
|
+
else:
|
|
187
|
+
(control(args.profile) / 'service.pid').write_text(f'{pids[0]}\n')
|
|
188
|
+
print(f'dsh-foreman already running (PID {pids[0]}); no duplicate started.', flush=True)
|
|
189
|
+
(control(args.profile) / 'manually-stopped').unlink(missing_ok=True)
|
|
190
|
+
deadline = time.monotonic() + args.timeout
|
|
191
|
+
while time.monotonic() < deadline:
|
|
192
|
+
current = state(args.url)
|
|
193
|
+
if processes(args.profile) and runtime_up(current):
|
|
194
|
+
code = status(args)
|
|
195
|
+
if current['setup']:
|
|
196
|
+
print('dsh-foreman is waiting for setup: ' + str(current['setup'].get('detail', '')))
|
|
197
|
+
print('Open the URL DSH printed in the log, then the dsh-foreman overview.')
|
|
198
|
+
if current['feishu']:
|
|
199
|
+
print('dsh-foreman runs without Feishu: ' + str(current['feishu'].get('detail', '')))
|
|
200
|
+
print('Fix it on the Feishu card of the Source settings.')
|
|
201
|
+
for name, detail in part_errors(current).items():
|
|
202
|
+
print(f'{name} reports an error, which a restart does not fix: {detail}')
|
|
203
|
+
return code
|
|
204
|
+
time.sleep(1)
|
|
205
|
+
status(args)
|
|
206
|
+
raise RuntimeError('Startup health check timed out; inspect the log. Any running process was left intact.')
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def main():
|
|
210
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
211
|
+
parser.add_argument('action', choices=['start', 'stop', 'restart', 'status'])
|
|
212
|
+
parser.add_argument('--profile', default=os.environ.get('DSH_SUPERAGENT_PROFILE') or 'superagent',
|
|
213
|
+
help='DSH profile that loads dsh-foreman; its tmux session is dsh-<profile>.')
|
|
214
|
+
parser.add_argument('--url', default=os.environ.get('DSH_SUPERAGENT_URL') or 'http://127.0.0.1:3082',
|
|
215
|
+
help='Health-check URL; override after changing the profile port.')
|
|
216
|
+
parser.add_argument('--timeout', type=float, default=60, help='Startup timeout in seconds.')
|
|
217
|
+
parser.add_argument('--stop-timeout', type=float, default=300,
|
|
218
|
+
help='Graceful shutdown timeout in seconds; running Executor Sessions finish their step first.')
|
|
219
|
+
parser.add_argument('--respect-stop', action='store_true',
|
|
220
|
+
help='For monitoring: never start/restart after an intentional stop.')
|
|
221
|
+
args = parser.parse_args()
|
|
222
|
+
if args.timeout <= 0 or args.stop_timeout <= 0:
|
|
223
|
+
parser.error('--timeout and --stop-timeout must be positive')
|
|
224
|
+
if not re.fullmatch(r'[A-Za-z0-9_][A-Za-z0-9_.-]*', args.profile):
|
|
225
|
+
parser.error('--profile must be a DSH profile name')
|
|
226
|
+
os.umask(0o077)
|
|
227
|
+
with (control(args.profile) / 'operation.lock').open('a') as lock:
|
|
228
|
+
try:
|
|
229
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
230
|
+
except BlockingIOError:
|
|
231
|
+
raise RuntimeError('Another dsh-foreman control operation is in progress.') from None
|
|
232
|
+
if args.respect_stop and args.action in ('start', 'restart') and stopped(args.profile):
|
|
233
|
+
print('Intentional stop is active; automatic recovery skipped.')
|
|
234
|
+
return 0
|
|
235
|
+
if args.action == 'status':
|
|
236
|
+
return status(args)
|
|
237
|
+
if args.action in ('stop', 'restart'):
|
|
238
|
+
# Only a stop keeps automatic recovery off; a restart whose stop timed out leaves it on.
|
|
239
|
+
stop(args, intentional=args.action == 'stop')
|
|
240
|
+
if args.action in ('start', 'restart'):
|
|
241
|
+
return start(args)
|
|
242
|
+
return 0
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
if __name__ == '__main__':
|
|
246
|
+
try:
|
|
247
|
+
sys.exit(main())
|
|
248
|
+
except (OSError, RuntimeError, subprocess.CalledProcessError) as error:
|
|
249
|
+
print(str(error), file=sys.stderr)
|
|
250
|
+
sys.exit(1)
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Pack and verify a hub migration.
|
|
4
|
+
*
|
|
5
|
+
* Moving the hub is "stop, pack, copy, unpack at the same absolute paths, start". The one step that cannot be
|
|
6
|
+
* skipped is the SQLite checkpoint: the database runs in WAL mode, so the newest writes live in a `-wal` file, and
|
|
7
|
+
* copying only the `.sqlite` silently loses them. srv-01 has no `sqlite3` command, so the checkpoint goes through
|
|
8
|
+
* `node:sqlite` here.
|
|
9
|
+
*
|
|
10
|
+
* The script is plain ESM with no dependencies, because it has to run on a machine where the hub may not be
|
|
11
|
+
* installed. On Windows it only checkpoints and prints what to copy: `tar` is not guaranteed there, and a silent
|
|
12
|
+
* half-copy is worse than a printed list.
|
|
13
|
+
*/
|
|
14
|
+
import { spawnSync } from 'node:child_process'
|
|
15
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync } from 'node:fs'
|
|
16
|
+
import { dirname, join, resolve } from 'node:path'
|
|
17
|
+
import { DatabaseSync } from 'node:sqlite'
|
|
18
|
+
|
|
19
|
+
/** The directories a move must carry. `storages` is not optional: it holds the workspace UUIDs the main Sessions are keyed by. */
|
|
20
|
+
const DIRECTORIES = ['state/superagent', 'storages', 'sessions', 'attachments', 'profiles']
|
|
21
|
+
/** The database inside the state directory. */
|
|
22
|
+
const DATABASE = 'state/superagent/orchestrator.sqlite'
|
|
23
|
+
|
|
24
|
+
const usage = `Usage:
|
|
25
|
+
node scripts/migrate.mjs plan --home <dir>
|
|
26
|
+
node scripts/migrate.mjs checkpoint --home <dir>
|
|
27
|
+
node scripts/migrate.mjs pack --home <dir> --out <tar>
|
|
28
|
+
node scripts/migrate.mjs verify --home <dir>
|
|
29
|
+
|
|
30
|
+
--home is the DSH_HOME of the instance being moved (default: $DSH_HOME, else ~/.dsh).`
|
|
31
|
+
|
|
32
|
+
/** Reads `--name value` pairs; a missing value is an error rather than an empty string. */
|
|
33
|
+
function options(argv) {
|
|
34
|
+
const values = new Map()
|
|
35
|
+
for (let index = 0; index < argv.length; index++) {
|
|
36
|
+
const name = argv[index]
|
|
37
|
+
if (!name.startsWith('--')) throw new Error(`unexpected argument ${name}\n\n${usage}`)
|
|
38
|
+
const value = argv[index + 1]
|
|
39
|
+
if (value === undefined || value.startsWith('--')) throw new Error(`${name} needs a value\n\n${usage}`)
|
|
40
|
+
values.set(name.slice(2), value)
|
|
41
|
+
index++
|
|
42
|
+
}
|
|
43
|
+
return values
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The DSH home to act on: `--home`, else `$DSH_HOME`, else `~/.dsh`. */
|
|
47
|
+
function homeOf(values) {
|
|
48
|
+
const given = values.get('home') ?? process.env.DSH_HOME ?? join(process.env.HOME ?? process.env.USERPROFILE ?? '.', '.dsh')
|
|
49
|
+
return resolve(given)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Refuses a directory that is not a DSH home, so a typo cannot checkpoint or pack the wrong database. */
|
|
53
|
+
function requireHome(home) {
|
|
54
|
+
const database = join(home, DATABASE)
|
|
55
|
+
if (!existsSync(database)) throw new Error(`${home} is not a DSH home: ${database} does not exist`)
|
|
56
|
+
return database
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Checkpoints the WAL into the database file and closes it.
|
|
61
|
+
*
|
|
62
|
+
* `TRUNCATE` writes the frames back and empties the `-wal`, which is what makes a plain file copy complete. A
|
|
63
|
+
* checkpoint that reports it could not finish throws: packing a database that still has unmerged writes is exactly
|
|
64
|
+
* the silent data loss this step exists to prevent.
|
|
65
|
+
*/
|
|
66
|
+
function checkpoint(home) {
|
|
67
|
+
const database = requireHome(home)
|
|
68
|
+
const db = new DatabaseSync(database)
|
|
69
|
+
try {
|
|
70
|
+
const row = db.prepare('PRAGMA wal_checkpoint(TRUNCATE)').get()
|
|
71
|
+
// The pragma answers { busy, log, checkpointed }: `busy` non-zero means another connection still holds the WAL.
|
|
72
|
+
if (row && Number(row.busy) !== 0) throw new Error(`the database is still in use by another process (busy=${row.busy}); stop the hub first`)
|
|
73
|
+
} finally {
|
|
74
|
+
db.close()
|
|
75
|
+
}
|
|
76
|
+
return database
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Prints what a move carries, with each directory's size, so a partial copy is visible before it happens. */
|
|
80
|
+
function plan(home) {
|
|
81
|
+
requireHome(home)
|
|
82
|
+
const lines = [`home: ${home}`, `database: ${DATABASE}`, '']
|
|
83
|
+
let total = 0
|
|
84
|
+
for (const directory of DIRECTORIES) {
|
|
85
|
+
const path = join(home, directory)
|
|
86
|
+
if (!existsSync(path)) { lines.push(` ${directory}: absent`); continue }
|
|
87
|
+
const bytes = sizeOf(path)
|
|
88
|
+
total += bytes
|
|
89
|
+
lines.push(` ${directory}: ${bytes} bytes`)
|
|
90
|
+
}
|
|
91
|
+
lines.push('', `total: ${total} bytes`, '', 'Copy these to the same absolute paths on the new machine; the database must keep its path or the ledger\'s absolute paths break.')
|
|
92
|
+
return lines.join('\n')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Total bytes of a file or directory tree, without following links. */
|
|
96
|
+
/**
|
|
97
|
+
* Total bytes under a path.
|
|
98
|
+
*
|
|
99
|
+
* Walks the tree itself rather than shelling out to `du`: `du -sb` is GNU-specific, and on a platform whose `du`
|
|
100
|
+
* lacks `-b` (macOS/BSD) the command fails and the old fallback reported only the directory inode's size — a number
|
|
101
|
+
* small enough to look like the tree was empty, on the one page an operator reads before deciding the copy is small.
|
|
102
|
+
* Links are counted as their own size and not followed, so a symlink loop cannot hang the walk.
|
|
103
|
+
*/
|
|
104
|
+
function sizeOf(path) {
|
|
105
|
+
let info
|
|
106
|
+
try { info = lstatSync(path) }
|
|
107
|
+
catch { return 0 }
|
|
108
|
+
if (!info.isDirectory()) return info.size
|
|
109
|
+
let total = info.size
|
|
110
|
+
let entries
|
|
111
|
+
try { entries = readdirSync(path) } catch { return total }
|
|
112
|
+
for (const name of entries) total += sizeOf(join(path, name))
|
|
113
|
+
return total
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Packs the directories into a tar, after the checkpoint. Windows has no guaranteed `tar`, so it prints instead. */
|
|
117
|
+
function pack(home, out) {
|
|
118
|
+
const database = checkpoint(home)
|
|
119
|
+
if (process.platform === 'win32') {
|
|
120
|
+
throw new Error(`on Windows this script only checkpoints (${database}); copy ${DIRECTORIES.join(', ')} to the new machine with the same absolute paths, then run verify there`)
|
|
121
|
+
}
|
|
122
|
+
mkdirSync(dirname(out), { recursive: true })
|
|
123
|
+
const existing = DIRECTORIES.filter(directory => existsSync(join(home, directory)))
|
|
124
|
+
const result = spawnSync('tar', ['-czf', out, '-C', home, ...existing], { stdio: 'inherit' })
|
|
125
|
+
if (result.status !== 0) throw new Error(`tar failed with status ${result.status}`)
|
|
126
|
+
return out
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Checks that what a finished move produced is usable: the database opens, the checkpoint took, and the workspace
|
|
131
|
+
* copy is present. It prints counts rather than asserting a specific number, because the number depends on the
|
|
132
|
+
* instance — the operator compares it with the old machine's.
|
|
133
|
+
*/
|
|
134
|
+
function verify(home) {
|
|
135
|
+
const database = requireHome(home)
|
|
136
|
+
// Re-checkpointing is safe and tells us whether the WAL came back after the copy.
|
|
137
|
+
const db = new DatabaseSync(database)
|
|
138
|
+
let checkpointed
|
|
139
|
+
try {
|
|
140
|
+
checkpointed = db.prepare('PRAGMA wal_checkpoint(TRUNCATE)').get()
|
|
141
|
+
const busy = checkpointed ? Number(checkpointed.busy) : 0
|
|
142
|
+
if (busy !== 0) throw new Error('the database is in use; stop the hub before verifying a move')
|
|
143
|
+
} finally {
|
|
144
|
+
db.close()
|
|
145
|
+
}
|
|
146
|
+
const counts = {}
|
|
147
|
+
const counting = new DatabaseSync(database)
|
|
148
|
+
try {
|
|
149
|
+
for (const table of ['kv', 'trigger', 'ticket']) {
|
|
150
|
+
// A fresh or partially built database may lack a table; report that rather than failing the whole verify.
|
|
151
|
+
try { counts[table] = String(counting.prepare(`SELECT count(*) AS n FROM ${table}`).get()?.n ?? 0) } catch { counts[table] = 'absent' }
|
|
152
|
+
}
|
|
153
|
+
} finally {
|
|
154
|
+
counting.close()
|
|
155
|
+
}
|
|
156
|
+
const lines = [
|
|
157
|
+
`home: ${home}`,
|
|
158
|
+
'checkpoint ok',
|
|
159
|
+
`workspace.json: ${existsSync(join(home, 'storages/workspace.json')) ? 'present' : 'MISSING'}`,
|
|
160
|
+
`tickets: ${counts.ticket}`,
|
|
161
|
+
`rows: kv=${counts.kv} trigger=${counts.trigger} ticket=${counts.ticket}`,
|
|
162
|
+
]
|
|
163
|
+
for (const directory of DIRECTORIES) {
|
|
164
|
+
if (!existsSync(join(home, directory))) lines.push(`missing directory: ${directory}`)
|
|
165
|
+
}
|
|
166
|
+
return lines.join('\n')
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const [command, ...rest] = process.argv.slice(2)
|
|
170
|
+
if (command === undefined || ['-h', '--help', 'help'].includes(command)) {
|
|
171
|
+
console.log(usage)
|
|
172
|
+
process.exit(0)
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
const values = options(rest)
|
|
176
|
+
const home = homeOf(values)
|
|
177
|
+
if (command === 'plan') console.log(plan(home))
|
|
178
|
+
else if (command === 'checkpoint') console.log(`checkpoint ok: ${checkpoint(home)}`)
|
|
179
|
+
else if (command === 'pack') {
|
|
180
|
+
const out = values.get('out')
|
|
181
|
+
if (out === undefined) throw new Error(`pack needs --out <tar>\n\n${usage}`)
|
|
182
|
+
console.log(`packed: ${pack(home, resolve(out))}`)
|
|
183
|
+
} else if (command === 'verify') console.log(verify(home))
|
|
184
|
+
else throw new Error(`unknown command ${command}\n\n${usage}`)
|
|
185
|
+
} catch (error) {
|
|
186
|
+
console.error(`migrate: ${error instanceof Error ? error.message : String(error)}`)
|
|
187
|
+
process.exit(1)
|
|
188
|
+
}
|