@sigrun/sigrun 0.0.0-stage → 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 +214 -0
- package/CONTEXT.md +163 -0
- package/LICENSE +21 -0
- package/README.md +691 -2
- package/THIRD_PARTY_NOTICES.md +28 -0
- package/dist/backup/marker.js +80 -0
- package/dist/backup/run-backup.js +339 -0
- package/dist/cli.js +952 -0
- package/dist/commands/expand.js +245 -0
- package/dist/commands/templates.js +272 -0
- package/dist/config.js +1146 -0
- package/dist/extension.d.ts +431 -0
- package/dist/harness/activity.js +42 -0
- package/dist/harness/ambient.js +261 -0
- package/dist/harness/auth.js +130 -0
- package/dist/harness/boot.js +865 -0
- package/dist/harness/builtins.js +191 -0
- package/dist/harness/event-bus.js +1203 -0
- package/dist/harness/health.js +30 -0
- package/dist/harness/identity/index.js +37 -0
- package/dist/harness/identity/invites.js +94 -0
- package/dist/harness/identity/json-store.js +108 -0
- package/dist/harness/identity/setup-code.js +81 -0
- package/dist/harness/identity/tokens.js +101 -0
- package/dist/harness/identity/users.js +93 -0
- package/dist/harness/interactions.js +283 -0
- package/dist/harness/loader.js +1236 -0
- package/dist/harness/memory-default.js +35 -0
- package/dist/harness/naming.js +193 -0
- package/dist/harness/packages.js +1002 -0
- package/dist/harness/providers/auth-bridge.js +60 -0
- package/dist/harness/providers/index.js +277 -0
- package/dist/harness/providers/models-store.js +105 -0
- package/dist/harness/providers/refresh.js +161 -0
- package/dist/harness/providers/scheduler.js +89 -0
- package/dist/harness/registry.js +9 -0
- package/dist/harness/reserved-sections.js +24 -0
- package/dist/harness/seam-host.js +239 -0
- package/dist/harness/sections/context-files.js +78 -0
- package/dist/harness/sections/model-guidance.js +77 -0
- package/dist/harness/settings.js +1053 -0
- package/dist/harness/stores.js +450 -0
- package/dist/harness/tools.js +500 -0
- package/dist/harness/workspace.js +73 -0
- package/dist/index.js +39 -0
- package/dist/jobs/docs.js +257 -0
- package/dist/jobs/fire.js +342 -0
- package/dist/jobs/index.js +228 -0
- package/dist/jobs/observe.js +165 -0
- package/dist/jobs/reconcile.js +245 -0
- package/dist/jobs/schedule.js +77 -0
- package/dist/jobs/spec.js +248 -0
- package/dist/jobs/task.js +190 -0
- package/dist/jobs/transfer.js +53 -0
- package/dist/serve.js +164 -0
- package/dist/server/auth/claim-lock.js +28 -0
- package/dist/server/auth/ip.js +51 -0
- package/dist/server/auth/login-limiter.js +52 -0
- package/dist/server/auth/loopback.js +47 -0
- package/dist/server/auth/pipeline.js +330 -0
- package/dist/server/auth/principal.js +26 -0
- package/dist/server/commands.js +198 -0
- package/dist/server/content-parts.js +142 -0
- package/dist/server/context.js +13 -0
- package/dist/server/conversation-handler.js +61 -0
- package/dist/server/conversation-ops.js +310 -0
- package/dist/server/events/hub.js +39 -0
- package/dist/server/events/types.js +8 -0
- package/dist/server/helpers.js +351 -0
- package/dist/server/host-bridge.js +131 -0
- package/dist/server/index.js +232 -0
- package/dist/server/notifications.js +185 -0
- package/dist/server/ownership.js +67 -0
- package/dist/server/readiness.js +36 -0
- package/dist/server/routes/auth.js +172 -0
- package/dist/server/routes/conversations.js +341 -0
- package/dist/server/routes/events.js +161 -0
- package/dist/server/routes/extension-routes.js +127 -0
- package/dist/server/routes/extensions.js +168 -0
- package/dist/server/routes/files.js +308 -0
- package/dist/server/routes/interactions.js +260 -0
- package/dist/server/routes/jobs.js +271 -0
- package/dist/server/routes/meta.js +146 -0
- package/dist/server/routes/metrics.js +219 -0
- package/dist/server/routes/onboarding.js +268 -0
- package/dist/server/routes/probes.js +53 -0
- package/dist/server/routes/providers.js +198 -0
- package/dist/server/routes/settings.js +48 -0
- package/dist/server/routes/stream.js +131 -0
- package/dist/server/routes/submissions.js +317 -0
- package/dist/server/routes/users.js +169 -0
- package/dist/server/schema-fixture.js +950 -0
- package/dist/server/session-meta.js +247 -0
- package/dist/server/static.js +169 -0
- package/dist/server/views.js +71 -0
- package/dist/skills/index.js +418 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
- package/node_modules/@sigrun/shared/LICENSE +21 -0
- package/node_modules/@sigrun/shared/README.md +10 -0
- package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
- package/node_modules/@sigrun/shared/dist/access.js +72 -0
- package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
- package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
- package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
- package/node_modules/@sigrun/shared/dist/commands.js +25 -0
- package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
- package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
- package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
- package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
- package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
- package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
- package/node_modules/@sigrun/shared/dist/json.js +13 -0
- package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
- package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
- package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
- package/node_modules/@sigrun/shared/dist/settings.js +19 -0
- package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
- package/node_modules/@sigrun/shared/dist/themes.js +156 -0
- package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
- package/node_modules/@sigrun/shared/dist/turns.js +166 -0
- package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
- package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
- package/node_modules/@sigrun/shared/dist/views.js +38 -0
- package/node_modules/@sigrun/shared/package.json +83 -0
- package/package.json +89 -5
- package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
- package/webui/dist/assets/index-rOiARZLF.js +50 -0
- package/webui/dist/index.html +37 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Third-party notices
|
|
2
|
+
|
|
3
|
+
## hermes-webui
|
|
4
|
+
|
|
5
|
+
- <https://github.com/nesquena/hermes-webui>
|
|
6
|
+
- sigrun's web UI visual design is adapted from hermes-webui.
|
|
7
|
+
|
|
8
|
+
MIT License
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2025 Hermes Web UI Contributors
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
in the Software without restriction, including without limitation the rights
|
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
furnished to do so, subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
28
|
+
SOFTWARE.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The backup last-success marker (core-hardening §6.8, R15 — "backup
|
|
3
|
+
* honesty"): `run-backup.ts` stamps `<stateDir>/backup/last-success.json`
|
|
4
|
+
* after a successful run, and `GET /v1/meta` reads it PER REQUEST to
|
|
5
|
+
* derive `health.backup` — before this, a configured-but-silently-failing
|
|
6
|
+
* CronJob still reported "configured" (the badge lied).
|
|
7
|
+
*
|
|
8
|
+
* Read posture: the marker is observability, never a gate. An absent,
|
|
9
|
+
* unreadable, unparseable, or wrong-shape marker reads as `undefined`
|
|
10
|
+
* — which DERIDES health ("degraded") but never fails the request. A
|
|
11
|
+
* corrupt marker must cost the badge, not the endpoint.
|
|
12
|
+
*/
|
|
13
|
+
import { mkdir, readFile } from "node:fs/promises";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { writeFileAtomic } from "../harness/stores.js";
|
|
16
|
+
/** The marker's directory + filename under the state root. */
|
|
17
|
+
export const BACKUP_MARKER_DIR = "backup";
|
|
18
|
+
export const BACKUP_MARKER_NAME = "last-success.json";
|
|
19
|
+
/** The marker's path under a state root. */
|
|
20
|
+
export function backupMarkerPath(stateDir) {
|
|
21
|
+
return join(stateDir, BACKUP_MARKER_DIR, BACKUP_MARKER_NAME);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Write the marker: mkdir the `backup/` dir, then atomic-replace with
|
|
25
|
+
* mode 0644 (an operator file — unlike secrets, nothing in it is
|
|
26
|
+
* sensitive; 0644 keeps `kubectl exec … cat` and restores simple).
|
|
27
|
+
*/
|
|
28
|
+
export async function writeBackupMarker(stateDir, marker) {
|
|
29
|
+
await mkdir(join(stateDir, BACKUP_MARKER_DIR), { recursive: true });
|
|
30
|
+
await writeFileAtomic(backupMarkerPath(stateDir), `${JSON.stringify(marker, null, "\t")}\n`, 0o644);
|
|
31
|
+
}
|
|
32
|
+
/** Structural check: every field present and correctly typed — `at`
|
|
33
|
+
* must PARSE as a timestamp too (a marker whose `at` is garbage reads as
|
|
34
|
+
* no marker: surfacing an unparseable `lastSuccessAt` on the wire would
|
|
35
|
+
* trade a honest degraded badge for a confusing one). */
|
|
36
|
+
function isBackupMarker(value) {
|
|
37
|
+
if (typeof value !== "object" || value === null)
|
|
38
|
+
return false;
|
|
39
|
+
const record = value;
|
|
40
|
+
return (typeof record.at === "string" &&
|
|
41
|
+
Number.isFinite(Date.parse(record.at)) &&
|
|
42
|
+
(record.target === "path" || record.target === "s3") &&
|
|
43
|
+
typeof record.bytes === "number" &&
|
|
44
|
+
Number.isFinite(record.bytes));
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Read the marker from a state root. `undefined` when absent (ENOENT) —
|
|
48
|
+
* and when anything else is wrong with it (unreadable, unparseable,
|
|
49
|
+
* wrong shape): a marker that cannot be trusted must not read as a
|
|
50
|
+
* success.
|
|
51
|
+
*/
|
|
52
|
+
export async function readBackupMarker(stateDir) {
|
|
53
|
+
let raw;
|
|
54
|
+
try {
|
|
55
|
+
raw = await readFile(backupMarkerPath(stateDir), "utf8");
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
let parsed;
|
|
61
|
+
try {
|
|
62
|
+
parsed = JSON.parse(raw);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
return isBackupMarker(parsed) ? parsed : undefined;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Whether a marker is stale: `now - at` strictly beyond
|
|
71
|
+
* `staleAfterHours` hours. A marker whose `at` does not parse as a
|
|
72
|
+
* timestamp is stale by definition (the derivation never surfaces it,
|
|
73
|
+
* but the pure rule stays total).
|
|
74
|
+
*/
|
|
75
|
+
export function isBackupMarkerStale(marker, staleAfterHours, now = Date.now()) {
|
|
76
|
+
const at = Date.parse(marker.at);
|
|
77
|
+
if (!Number.isFinite(at))
|
|
78
|
+
return true;
|
|
79
|
+
return now - at > staleAfterHours * 60 * 60 * 1000;
|
|
80
|
+
}
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The optional backup script (spec §4.3, Task 11) — the CronJob's payload.
|
|
3
|
+
*
|
|
4
|
+
* Invoked (tsx / node) with `SIGRUN_STORAGE_PATH` pointing at the live
|
|
5
|
+
* harness sqlite database plus the backup target env (see
|
|
6
|
+
* `parseBackupConfig`). It takes a `sqlite3 .backup` — sqlite3's online
|
|
7
|
+
* backup API (`.backup`): ONLINE-SAFE, it locks the source only per
|
|
8
|
+
* page, so the copy is consistent even while the harness process holds
|
|
9
|
+
* the database in WAL mode (a second process touching the file is
|
|
10
|
+
* exactly the class of assumption spec §4.3's App C ledger rule covers;
|
|
11
|
+
* the restore drill in test/backup.test.ts verifies it live).
|
|
12
|
+
*
|
|
13
|
+
* Flow, fail-loud at every step:
|
|
14
|
+
* 1. `sqlite3 <db> ".backup '<tmpfile>'"` into a sibling temp file (same
|
|
15
|
+
* filesystem as the target for the atomic rename; never a half-written
|
|
16
|
+
* target). A sqlite3 failure deletes the temp file and exits non-zero
|
|
17
|
+
* (sqlite3 leaves a 0-byte file on failure — it must not survive).
|
|
18
|
+
* 2. path target: rename into `<dir>/sigrun-<UTC timestamp>.db`, then
|
|
19
|
+
* 30-day retention (`applyRetention`).
|
|
20
|
+
* 2'. s3 target: `PutObject` the temp file to
|
|
21
|
+
* `s3://<bucket>[/<prefix>]sigrun-<UTC timestamp>.db`. Endpoint and
|
|
22
|
+
* credentials come from the environment (`AWS_*`,
|
|
23
|
+
* `SIGRUN_BACKUP_S3_ENDPOINT`) — core secrets per spec §4.3, injected
|
|
24
|
+
* into the CronJob from a Secret. A failed upload exits non-zero.
|
|
25
|
+
* 3. No backup target env → exit 1 with a config error (the CronJob must
|
|
26
|
+
* not silently do nothing).
|
|
27
|
+
*
|
|
28
|
+
* Last-success marker (§6.8 backup honesty): after a run succeeds — the
|
|
29
|
+
* db rename/upload AND every enabled section — the script writes
|
|
30
|
+
* `<SIGRUN_STATE_DIR>/backup/last-success.json` (`{at, target, bytes}`, 0644,
|
|
31
|
+
* atomic; see `marker.ts`), which `GET /v1/meta` reads per request to
|
|
32
|
+
* derive `health.backup` (absent or older than `server.backup.staleAfterHours`,
|
|
33
|
+
* default 36 h → "degraded"). With `SIGRUN_STATE_DIR` unset the script WARNS
|
|
34
|
+
* and skips the marker — the backup itself still succeeds; refusing to
|
|
35
|
+
* guess the state root matters because a marker written where the server
|
|
36
|
+
* does not read it would read as fresh backups that exist nowhere.
|
|
37
|
+
*
|
|
38
|
+
* The script deliberately touches NOTHING but the db file and the target —
|
|
39
|
+
* no pi-durable internals, no lock negotiation; that is the whole point of
|
|
40
|
+
* `sqlite3 .backup` over an application-level snapshot.
|
|
41
|
+
*/
|
|
42
|
+
import { spawn } from "node:child_process";
|
|
43
|
+
import { chmod, mkdtemp, readdir, rename, rm, stat, unlink } from "node:fs/promises";
|
|
44
|
+
import { realpathSync } from "node:fs";
|
|
45
|
+
import { tmpdir } from "node:os";
|
|
46
|
+
import { dirname, join } from "node:path";
|
|
47
|
+
import { fileURLToPath } from "node:url";
|
|
48
|
+
import { PutObjectCommand, S3Client, } from "@aws-sdk/client-s3";
|
|
49
|
+
import { readFile } from "node:fs/promises";
|
|
50
|
+
import { parseBackupConfig } from "../config.js";
|
|
51
|
+
import { resolveStoreRoots } from "../harness/stores.js";
|
|
52
|
+
import { writeBackupMarker } from "./marker.js";
|
|
53
|
+
/** Retention floor (spec §4.3: "retention ≥ 30 days"). */
|
|
54
|
+
export const BACKUP_RETENTION_DAYS = 30;
|
|
55
|
+
/** The db path under backup — `SIGRUN_STORAGE_PATH` in deployment. */
|
|
56
|
+
const DB_PATH = process.env.SIGRUN_STORAGE_PATH ?? "";
|
|
57
|
+
/** Promise-wrapped spawn: a step's exit code and stderr. */
|
|
58
|
+
function run(command, args) {
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
const child = spawn(command, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
61
|
+
let stderr = "";
|
|
62
|
+
child.stderr.on("data", (chunk) => {
|
|
63
|
+
stderr += chunk.toString("utf8");
|
|
64
|
+
});
|
|
65
|
+
child.on("error", reject);
|
|
66
|
+
child.on("close", (code) => resolve({ code: code ?? -1, stderr }));
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Timestamp segment of a backup filename: `2026-10-07T03-15-02Z` — UTC,
|
|
71
|
+
* sortable, colon-free (safe on every filesystem / object-store key).
|
|
72
|
+
*/
|
|
73
|
+
function timestampSegment(date = new Date()) {
|
|
74
|
+
return date.toISOString().replace(/:/g, "-").slice(0, 19) + "Z";
|
|
75
|
+
}
|
|
76
|
+
/** Run `sqlite3 <db> ".backup '<dest>'"`; fail loud on a non-zero exit. */
|
|
77
|
+
async function sqliteBackup(dbPath, dest) {
|
|
78
|
+
// The quoting inside the dot-command is sqlite3's own (the dot-command
|
|
79
|
+
// parser treats the quoted string as one filename; an unquoted path with
|
|
80
|
+
// spaces would split).
|
|
81
|
+
const result = await run("sqlite3", [dbPath, `.backup '${dest}'`]);
|
|
82
|
+
if (result.code !== 0) {
|
|
83
|
+
// sqlite3 leaves a 0-byte dest on failure — a later restore would
|
|
84
|
+
// open a valid EMPTY database and "resume" to nothing. Delete it.
|
|
85
|
+
await rm(dest, { force: true });
|
|
86
|
+
throw new Error(`sqlite3 .backup failed with exit ${result.code}: ${result.stderr.trim()}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Path-target retention: delete `sigrun-*.db` files in `dir` whose mtime is
|
|
91
|
+
* older than {@link BACKUP_RETENTION_DAYS} days. Non-matching names are
|
|
92
|
+
* never touched (the directory is the deployment's backup dir, but it is
|
|
93
|
+
* still conservative to prune only what this script produced).
|
|
94
|
+
*/
|
|
95
|
+
export async function applyRetention(dir) {
|
|
96
|
+
const cutoff = Date.now() - BACKUP_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
|
97
|
+
for (const name of await readdir(dir)) {
|
|
98
|
+
if (!name.startsWith("sigrun-") || !name.endsWith(".db"))
|
|
99
|
+
continue;
|
|
100
|
+
const path = join(dir, name);
|
|
101
|
+
const info = await stat(path);
|
|
102
|
+
if (info.mtimeMs < cutoff)
|
|
103
|
+
await unlink(path);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The S3 client the uploads go through. A module-level factory with the
|
|
108
|
+
* real client as its default: the test swaps in a recording fake, and the
|
|
109
|
+
* deployment path is untouched (no public API surface beyond this seam).
|
|
110
|
+
*
|
|
111
|
+
* Credentials come from the SDK's own default provider chain — the
|
|
112
|
+
* standard `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` /
|
|
113
|
+
* `AWS_REGION` env, or an AWS config file. sigrun adds no credential
|
|
114
|
+
* settings of its own.
|
|
115
|
+
*/
|
|
116
|
+
/** The production client factory — what a restore puts back. */
|
|
117
|
+
const defaultS3ClientFactory = (config) => new S3Client(config);
|
|
118
|
+
let s3ClientFactory = defaultS3ClientFactory;
|
|
119
|
+
/** Replace the S3 client factory; `undefined` restores the production one. */
|
|
120
|
+
export function setS3ClientFactory(factory) {
|
|
121
|
+
s3ClientFactory = factory ?? defaultS3ClientFactory;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The client config for a backup target: an endpoint means an
|
|
125
|
+
* S3-COMPATIBLE store (MinIO, RustFS, …), which needs path-style
|
|
126
|
+
* addressing — `bucket.host` virtual-host addressing does not resolve
|
|
127
|
+
* there. No endpoint = AWS's own endpoints, virtual-host style.
|
|
128
|
+
*/
|
|
129
|
+
function s3ClientConfig() {
|
|
130
|
+
const endpoint = process.env.SIGRUN_BACKUP_S3_ENDPOINT;
|
|
131
|
+
return endpoint === undefined || endpoint === ""
|
|
132
|
+
? {}
|
|
133
|
+
: { endpoint, forcePathStyle: true };
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Put one local file at `key` in the target bucket. The bytes are read
|
|
137
|
+
* once and sent as a Buffer: the SDK would otherwise want a stream, and
|
|
138
|
+
* the files here are a sqlite snapshot and small tarballs — reading them
|
|
139
|
+
* is the honest, testable shape. Failures reject with the SDK's error.
|
|
140
|
+
*/
|
|
141
|
+
async function s3Put(backup, key, file) {
|
|
142
|
+
const client = s3ClientFactory(s3ClientConfig());
|
|
143
|
+
try {
|
|
144
|
+
await client.send(new PutObjectCommand({
|
|
145
|
+
Bucket: backup.bucket,
|
|
146
|
+
Key: key,
|
|
147
|
+
Body: await readFile(file),
|
|
148
|
+
}));
|
|
149
|
+
}
|
|
150
|
+
catch (cause) {
|
|
151
|
+
throw new Error(`s3 upload of ${key} to ${backup.bucket} failed: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
client.destroy();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** Upload the backup to the s3 target; fail loud when the upload fails. */
|
|
158
|
+
async function s3Upload(backup, file) {
|
|
159
|
+
await s3Put(backup, `${backup.prefix ?? ""}sigrun-${timestampSegment()}.db`, file);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* One backup run: take the sqlite backup, then ship it to the target.
|
|
163
|
+
* Exported for the test's direct (in-process) invocation; the script's
|
|
164
|
+
* main() wraps it with env parsing and process exit codes.
|
|
165
|
+
*
|
|
166
|
+
* `stateDir` (§6.8): where the last-success marker is written after the
|
|
167
|
+
* WHOLE run settles (db + sections) — a run whose section leg failed must
|
|
168
|
+
* not refresh the marker, or the badge would hide the failure the exit
|
|
169
|
+
* code records. `undefined` skips the marker with a warning (the run
|
|
170
|
+
* itself still succeeds).
|
|
171
|
+
*/
|
|
172
|
+
export async function runBackup(dbPath, backup, stateDir) {
|
|
173
|
+
if (!dbPath) {
|
|
174
|
+
throw new Error("SIGRUN_STORAGE_PATH is required (the db to back up)");
|
|
175
|
+
}
|
|
176
|
+
// sqlite3 opens its source with create flags: a MISSING db file backs up
|
|
177
|
+
// "successfully" as an empty database (a SIGRUN_STORAGE_PATH typo or a
|
|
178
|
+
// CronJob firing before first boot would then produce nightly empty
|
|
179
|
+
// backups indistinguishable from real ones). Fail loud instead.
|
|
180
|
+
const dbStat = await stat(dbPath).catch(() => undefined);
|
|
181
|
+
if (dbStat === undefined || dbStat.size === 0) {
|
|
182
|
+
throw new Error(`database ${dbPath} does not exist or is empty — refusing to back up nothing`);
|
|
183
|
+
}
|
|
184
|
+
// Temp file in the TARGET's filesystem (path target) so the final move
|
|
185
|
+
// is an atomic rename, not a copy; for s3 the temp dir is irrelevant to
|
|
186
|
+
// atomicity (the upload is the move) so the OS temp dir serves.
|
|
187
|
+
const tempDir = backup.target === "path" ? backup.path : tmpdir();
|
|
188
|
+
const tempFile = join(await mkdtemp(join(tempDir, ".sigrun-backup-")), `backup-${timestampSegment()}.db`);
|
|
189
|
+
// The shipped size for the marker — captured inside the try after the
|
|
190
|
+
// sqlite step, read by the marker write below (rename/upload/cleanup all
|
|
191
|
+
// consume the temp file, so the size must be taken while it exists).
|
|
192
|
+
let bytes = 0;
|
|
193
|
+
try {
|
|
194
|
+
await sqliteBackup(dbPath, tempFile);
|
|
195
|
+
bytes = (await stat(tempFile)).size;
|
|
196
|
+
if (backup.target === "path") {
|
|
197
|
+
const dest = join(backup.path, `sigrun-${timestampSegment()}.db`);
|
|
198
|
+
await rename(tempFile, dest);
|
|
199
|
+
await applyRetention(backup.path);
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
await s3Upload(backup, tempFile);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
// The temp file survives only on the failure paths between sqlite
|
|
207
|
+
// success and the rename/upload — always clean it up.
|
|
208
|
+
await rm(tempFile, { force: true });
|
|
209
|
+
await rm(dirname(tempFile), { force: true, recursive: true });
|
|
210
|
+
}
|
|
211
|
+
// Sections fold in AFTER the DB steps (Task 7): with no sections
|
|
212
|
+
// enabled the loop is a no-op and the run is byte-identical to the
|
|
213
|
+
// DB-only contract.
|
|
214
|
+
await backupSections(backup);
|
|
215
|
+
// §6.8: the marker — after EVERYTHING above succeeded (see the
|
|
216
|
+
// docblock for why a sections failure must leave it stale). A marker
|
|
217
|
+
// WRITE failure warns and is swallowed (fix round 1): the marker is
|
|
218
|
+
// observability, never a gate — a read-only state dir or a full disk
|
|
219
|
+
// must not turn a completed backup into a failed one. The consequence
|
|
220
|
+
// is honest: the server reads no marker and reports degraded.
|
|
221
|
+
if (stateDir === undefined) {
|
|
222
|
+
console.warn("SIGRUN_STATE_DIR is not set — skipping the backup last-success marker " +
|
|
223
|
+
"(the backup itself succeeded; set SIGRUN_STATE_DIR on the CronJob and " +
|
|
224
|
+
"the server to the same root so health.backup can see it)");
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
await writeBackupMarker(stateDir, {
|
|
229
|
+
at: new Date().toISOString(),
|
|
230
|
+
target: backup.target,
|
|
231
|
+
bytes,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
console.warn(`backup succeeded but writing the last-success marker under ${stateDir} failed: ${String(error)} ` +
|
|
236
|
+
"(health.backup will report degraded until this is fixed)");
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/** The store root each backup section tars. */
|
|
240
|
+
const SECTION_ROOTS = {
|
|
241
|
+
// config and secrets both live under the config root (settings.json /
|
|
242
|
+
// auth.json); secrets differs by its 0600 tarball mode.
|
|
243
|
+
config: "configDir",
|
|
244
|
+
secrets: "configDir",
|
|
245
|
+
data: "dataDir",
|
|
246
|
+
state: "stateDir",
|
|
247
|
+
};
|
|
248
|
+
/**
|
|
249
|
+
* Sections whose content can carry secret files → tarball mode 0600
|
|
250
|
+
* (fix round 1). `config` tars the same root as `secrets`, so its
|
|
251
|
+
* archive carries auth.json too and must share the mode; `data` and
|
|
252
|
+
* `state` carry no key material and stay at the umask default.
|
|
253
|
+
*/
|
|
254
|
+
const SECRET_SECTIONS = new Set([
|
|
255
|
+
"config",
|
|
256
|
+
"secrets",
|
|
257
|
+
]);
|
|
258
|
+
/** Tar one section's root directory into `tarball`; fail loud on error. */
|
|
259
|
+
async function tarSection(sourceDir, tarball) {
|
|
260
|
+
// `-C source .` keeps the entries relative (settings.json, not
|
|
261
|
+
// /data/config/settings.json) and sidesteps tar's leading-`/` strip.
|
|
262
|
+
const result = await run("tar", ["-czf", tarball, "-C", sourceDir, "."]);
|
|
263
|
+
if (result.code !== 0) {
|
|
264
|
+
await rm(tarball, { force: true });
|
|
265
|
+
throw new Error(`tar of ${sourceDir} failed with exit ${result.code}: ${result.stderr.trim()}`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/** Upload one section tarball to the s3 target; fail loud when it fails. */
|
|
269
|
+
async function s3UploadSection(backup, file, name) {
|
|
270
|
+
await s3Put(backup, `${backup.prefix ?? ""}${name}`, file);
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* The per-section opt-in (Task 7): for each enabled section, tar its
|
|
274
|
+
* store root (`resolveStoreRoots(process.env)` — the same chain boot
|
|
275
|
+
* uses) into `<section>-<UTC timestamp>.tar.gz` and ship it to the
|
|
276
|
+
* target. Sections whose content can carry secret files get mode 0600
|
|
277
|
+
* (fix round 1: config tars the same root as secrets — auth.json rides
|
|
278
|
+
* BOTH tarballs, and in-archive modes don't gate extraction, so the
|
|
279
|
+
* config ARCHIVE itself must be 0600 too or enabling `config` would
|
|
280
|
+
* defeat the secrets tarball's mode). No enabled sections → no-op.
|
|
281
|
+
*/
|
|
282
|
+
async function backupSections(backup) {
|
|
283
|
+
const enabled = Object.keys(SECTION_ROOTS).filter((name) => backup.sections?.[name] === true);
|
|
284
|
+
if (enabled.length === 0)
|
|
285
|
+
return;
|
|
286
|
+
const roots = resolveStoreRoots(process.env);
|
|
287
|
+
for (const name of enabled) {
|
|
288
|
+
const tarballName = `${name}-${timestampSegment()}.tar.gz`;
|
|
289
|
+
if (backup.target === "path") {
|
|
290
|
+
const dest = join(backup.path, tarballName);
|
|
291
|
+
await tarSection(roots[SECTION_ROOTS[name]], dest);
|
|
292
|
+
if (SECRET_SECTIONS.has(name))
|
|
293
|
+
await chmod(dest, 0o600);
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
// Tar to the OS temp dir, upload, clean up — same shape as the
|
|
297
|
+
// DB's s3 leg (the upload is the move).
|
|
298
|
+
const tempFile = join(await mkdtemp(join(tmpdir(), ".sigrun-backup-section-")), tarballName);
|
|
299
|
+
try {
|
|
300
|
+
await tarSection(roots[SECTION_ROOTS[name]], tempFile);
|
|
301
|
+
await s3UploadSection(backup, tempFile, tarballName);
|
|
302
|
+
}
|
|
303
|
+
finally {
|
|
304
|
+
await rm(tempFile, { force: true });
|
|
305
|
+
await rm(dirname(tempFile), { force: true, recursive: true });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Script entry — runs only when executed directly (tsx run-backup.ts / a
|
|
312
|
+
* built node dist), not when the test imports `runBackup` in-process.
|
|
313
|
+
* Compares the realpath of argv[1] with this module (tsx and ts-node both
|
|
314
|
+
* keep argv[1] as the script path).
|
|
315
|
+
*/
|
|
316
|
+
const invokedAsScript = (() => {
|
|
317
|
+
if (process.argv[1] === undefined)
|
|
318
|
+
return false;
|
|
319
|
+
try {
|
|
320
|
+
return fileURLToPath(import.meta.url) === realpathSync(process.argv[1]);
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
})();
|
|
326
|
+
if (invokedAsScript) {
|
|
327
|
+
const backup = parseBackupConfig(process.env);
|
|
328
|
+
if (backup === undefined) {
|
|
329
|
+
console.error("No backup target configured: set SIGRUN_BACKUP_PATH or SIGRUN_BACKUP_S3_BUCKET");
|
|
330
|
+
process.exit(1);
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
await runBackup(DB_PATH, backup, process.env.SIGRUN_STATE_DIR?.trim() || undefined);
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
console.error(`sigrun backup failed: ${String(error)}`);
|
|
337
|
+
process.exit(1);
|
|
338
|
+
}
|
|
339
|
+
}
|