Compare commits

...
13 Commits
99 changed files with 1750 additions and 373 deletions
+4
View File
@@ -32,3 +32,7 @@ PLUTAINER_IMAGE=ghcr.io/ayymoss/plutainer:latest
# Optional request/report timings and event-loop delay diagnostics. # Optional request/report timings and event-loop delay diagnostics.
EC_PERF_LOG=false EC_PERF_LOG=false
# Log statements taking at least this many milliseconds; 0 disables logging.
EC_SLOW_SQL_MS=0
# For a local SQLite disk, set to 256 for a 256 MiB memory map. Leave 0 for network mounts.
EC_SQLITE_MMAP_MB=0
+18
View File
@@ -129,6 +129,13 @@ stopping games; activation uses the same occupancy and concurrency gates as
lifecycle operations. Agent before images preserve exact rollback bytes and lifecycle operations. Agent before images preserve exact rollback bytes and
operation history. operation history.
Artifact bodies are stored once by SHA-256 in SQLite. Versions, staging plans,
rollback snapshots and launch commands reference those shared bytes. The first
startup after this upgrade backfills existing rows transactionally; preserve the
data-directory backup when upgrading. Current agents fetch assigned artifacts
from an authenticated endpoint and verify the hash. Older agents continue to
receive inline content until their automatic update completes.
T5 Zombies uses `t5sp` and `dedicated_sp.cfg`; T6 uses `t6zm` and T5 Zombies uses `t5sp` and `dedicated_sp.cfg`; T6 uses `t6zm` and
`dedicated_zm.cfg`. Map selection generates `electric_cherry_map.cfg`, loaded `dedicated_zm.cfg`. Map selection generates `electric_cherry_map.cfg`, loaded
after the main config. T6 variants retain their distinct catalog selections. after the main config. T6 variants retain their distinct catalog selections.
@@ -184,6 +191,17 @@ record an `ec:navigation` performance measure. `npm run benchmark:leaderboard`
uses an isolated synthetic database; pass `-- --scenario all --baseline-ref <commit>` uses an isolated synthetic database; pass `-- --scenario all --baseline-ref <commit>`
to compare full response parity and warm query timings with an earlier revision. to compare full response parity and warm query timings with an earlier revision.
Set `EC_SLOW_SQL_MS=20` to log runtime statements taking at least 20 ms; bound
values and string literals are omitted. It is disabled by default. Before and
after database changes, capture these timings under the same fleet load and run
`npm run diagnostics:database -- /data/cherry.db` inside the controller to report
the largest tables/indexes, database and WAL sizes, and free pages. This command
opens an existing database read-only and does not run app migrations.
The controller uses a 128 MiB page cache and each reader worker uses 32 MiB.
For a local data disk, set `EC_SQLITE_MMAP_MB=256` to enable memory-mapped reads.
Query statistics are optimized at startup and hourly; WAL reuse is capped at
64 MiB after checkpoints (active transactions can temporarily grow it larger).
Local controller Local controller
metadata defaults to `./data/cherry.db`; use a separate test database when metadata defaults to `./data/cherry.db`; use a separate test database when
exercising enrollment. Production agent transport requires HTTPS. exercising enrollment. Production agent transport requires HTTPS.
+15 -3
View File
@@ -33,7 +33,7 @@ import urllib.parse
import urllib.request import urllib.request
import uuid import uuid
VERSION = "1.5.3" VERSION = "1.6.0"
LABEL = "io.electric-cherry" LABEL = "io.electric-cherry"
MAX_FILE = 32 * 1024 * 1024 MAX_FILE = 32 * 1024 * 1024
MANIFEST_MAX_ENTRIES = 10000 MANIFEST_MAX_ENTRIES = 10000
@@ -1356,7 +1356,7 @@ class Agent:
if line.startswith("PRETTY_NAME="): if line.startswith("PRETTY_NAME="):
os_name = line.split("=", 1)[1].strip('"') os_name = line.split("=", 1)[1].strip('"')
update = self.update_inventory() update = self.update_inventory()
capabilities = ["library.remove", "instance.cancel-create", "instance.safety-override", "operations.read-lane", "operations.health-lane", "rcon.status-batch", "instance.heartbeat-state", "files.manifest"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else []) capabilities = ["library.remove", "instance.cancel-create", "instance.safety-override", "operations.read-lane", "operations.health-lane", "rcon.status-batch", "instance.heartbeat-state", "files.manifest", "files.blobs"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else [])
return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": capabilities, "agentUpdate": update, "agentLanes": self.lane_inventory(), "os": os_name, "osVersion": platform.release(), "architecture": platform.machine(), "cpu": platform.processor() or platform.machine(), "cpuCount": os.cpu_count() or 0, **resources, **({"cpuUsagePercent": cpu_usage} if cpu_usage is not None else {}), "memoryTotal": memory.get("MemTotal", 0), "memoryAvailable": memory.get("MemAvailable", 0), "storageTotal": usage.total, "storageAvailable": usage.free, "dockerVersion": docker_version, "dockerRunning": docker_running, "directoriesWritable": os.access(self.root, os.W_OK), "runtimeImages": sorted(set(images)), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})} return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": capabilities, "agentUpdate": update, "agentLanes": self.lane_inventory(), "os": os_name, "osVersion": platform.release(), "architecture": platform.machine(), "cpu": platform.processor() or platform.machine(), "cpuCount": os.cpu_count() or 0, **resources, **({"cpuUsagePercent": cpu_usage} if cpu_usage is not None else {}), "memoryTotal": memory.get("MemTotal", 0), "memoryAvailable": memory.get("MemAvailable", 0), "storageTotal": usage.total, "storageAvailable": usage.free, "dockerVersion": docker_version, "dockerRunning": docker_running, "directoriesWritable": os.access(self.root, os.W_OK), "runtimeImages": sorted(set(images)), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})}
def safe_to_disrupt(self, instance_id, generation): def safe_to_disrupt(self, instance_id, generation):
@@ -1559,6 +1559,18 @@ class Agent:
managed_write(root, item["path"], content, int(self.config.get("runtimeUid", 1000)), int(self.config.get("runtimeGid", 1000))) managed_write(root, item["path"], content, int(self.config.get("runtimeUid", 1000)), int(self.config.get("runtimeGid", 1000)))
def decode_file(self, item): def decode_file(self, item):
if "contentSha256" in item:
sha = item["contentSha256"]
if not isinstance(sha, str) or not re.fullmatch(r"[a-f0-9]{64}", sha):
raise AgentError("Invalid file content hash")
url = self.config["controller"].rstrip("/") + "/api/agent/blobs/" + sha
with self.artifact_open("GET", url) as response:
content = response.read(MAX_FILE + 1)
if len(content) > MAX_FILE:
raise AgentError("File exceeds staging size limit")
if digest(content) != sha or item.get("sha256", sha) != sha:
raise AgentError("Staged file checksum mismatch")
return content
try: try:
content = base64.b64decode(item["contentBase64"], validate=True) content = base64.b64decode(item["contentBase64"], validate=True)
except (ValueError, KeyError, TypeError): except (ValueError, KeyError, TypeError):
@@ -1677,7 +1689,7 @@ class Agent:
if item["path"] in paths: if item["path"] in paths:
raise AgentError("Duplicate staged file path") raise AgentError("Duplicate staged file path")
paths.add(item["path"]) paths.add(item["path"])
deleting = item.get("delete") is True or item.get("contentBase64") is None deleting = item.get("delete") is True or (item.get("contentBase64") is None and "contentSha256" not in item)
content = None if deleting else self.decode_file(item) content = None if deleting else self.decode_file(item)
total_size += len(content) if content is not None else 0 total_size += len(content) if content is not None else 0
if total_size > 64 * 1024 * 1024: if total_size > 64 * 1024 * 1024:
+20
View File
@@ -832,6 +832,26 @@ class AgentTests(unittest.TestCase):
self.assertEqual(result["state"], "completed", result) self.assertEqual(result["state"], "completed", result)
return payload return payload
def test_hash_file_downloads_are_verified_and_staged_as_writes(self):
self.create()
content = b"downloaded plugin"
sha = module.digest(content)
file = {"id": 1, "path": "configs/server.cfg", "contentSha256": sha, "sha256": sha}
with patch.object(self.agent, "artifact_open", return_value=Response(content)) as fetch:
result = self.agent.stage(INSTANCE, 1, {"stageId": "hash-stage", "files": [file]}, "hash-operation")
self.assertEqual(result["state"], "staged")
self.assertTrue(fetch.call_args.args[1].endswith("/api/agent/blobs/" + sha))
manifest = json.loads((self.agent.instance_dir(INSTANCE, 1) / "stages/hash-stage/manifest.json").read_text())
self.assertFalse(manifest["files"][0]["delete"])
self.assertEqual(base64.b64decode(manifest["files"][0]["contentBase64"]), content)
with patch.object(self.agent, "artifact_open", return_value=Response(b"wrong bytes")):
with self.assertRaisesRegex(AgentError, "checksum mismatch"):
self.agent.decode_file(file)
with patch.object(self.agent, "artifact_open") as fetch:
with self.assertRaisesRegex(AgentError, "Invalid file content hash"):
self.agent.decode_file({"contentSha256": "../../secret"})
fetch.assert_not_called()
def test_same_id_reports_saved_result_without_executing_again(self): def test_same_id_reports_saved_result_without_executing_again(self):
self.create() self.create()
operation = self.operation("instance.restart") operation = self.operation("instance.restart")
+2 -2
View File
@@ -52,8 +52,8 @@ An already available Docker installation (including Docker CE) is reused. If the
daemon is unavailable, the installer reports the Docker error and stops before daemon is unavailable, the installer reports the Docker error and stops before
enrollment; inspect `systemctl status docker` and `journalctl -u docker`. enrollment; inspect `systemctl status docker` and `journalctl -u docker`.
The controller reconciles automatic setup on agent heartbeats and operation The controller reconciles automatic setup on agent heartbeats. Command polls only
polls. It first pulls `PLUTAINER_IMAGE` (default deliver work already queued. Setup first pulls `PLUTAINER_IMAGE` (default
`ghcr.io/ayymoss/plutainer:latest`), then prepares the latest verified upload for `ghcr.io/ayymoss/plutainer:latest`), then prepares the latest verified upload for
T5 and for T6 when available, one setup operation at a time. Uploading new files T5 and for T6 when available, one setup operation at a time. Uploading new files
or enrolling another host needs no manual propagation. Setup waits for an active, connected, or enrolling another host needs no manual propagation. Setup waits for an active, connected,
+14 -1
View File
@@ -47,7 +47,8 @@ minimum count as well as versions younger than the age threshold.
| Telemetry diagnostics | 7 days; 200 per server; 10,000 overall | | Telemetry diagnostics | 7 days; 200 per server; 10,000 overall |
| Completed/interrupted matches | Indefinite | | Completed/interrupted matches | Indefinite |
| Closed betas and test evidence | Indefinite | | Closed betas and test evidence | Indefinite |
| Campaign file bodies and closed beta snapshots | No separate expiry | | Campaign file bodies and closed beta snapshots | 30 days |
| Completed launch and command file bodies | 30 days |
| Unused artifact versions | No automatic expiry; no minimum count | | Unused artifact versions | No automatic expiry; no minimum count |
| Unused game files | No grace period; keep latest 1 version per game | | Unused game files | No grace period; keep latest 1 version per game |
| Incomplete browser uploads | Indefinite | | Incomplete browser uploads | Indefinite |
@@ -83,6 +84,18 @@ state and rollback requests fail explicitly. Active beta rollback images stay
protected. Expiring matches removes their player participation rows and changes protected. Expiring matches removes their player participation rows and changes
leaderboard/player totals; lifetime aggregates are not retained separately. leaderboard/player totals; lifetime aggregates are not retained separately.
Completed launch recipes and command receipts also retain their metadata when
their file bodies expire. Unfinished workflows, unreconciled commands, unresolved
failures and explicit holds preserve the bytes they need. Shared file bytes are
deleted only after their final reference expires; library versions can keep a file
available longer. Previously saved retention choices are preserved on upgrade.
The database upgrade normalizes retention clocks to UTC and enables incremental
vacuum. An existing database without a vacuum page map is rebuilt once at startup;
allow time for this rebuild and free disk space up to twice the database size.
Normal cleanup releases unused file blobs in bounded batches and incrementally
returns free pages to disk after larger prunes.
Game-file minimums apply per game. Grace starts when a version is superseded. Game-file minimums apply per game. Grace starts when a version is superseded.
Live, stopped, rebuilding and provisioning instances pin their files; unresolved Live, stopped, rebuilding and provisioning instances pin their files; unresolved
operations and offline host copies also prevent controller deletion. Controller operations and offline host copies also prevent controller deletion. Controller
+1
View File
@@ -12,6 +12,7 @@
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"benchmark:leaderboard": "node scripts/benchmark-leaderboard.mjs", "benchmark:leaderboard": "node scripts/benchmark-leaderboard.mjs",
"diagnostics:database": "node scripts/database-diagnostics.mjs",
"seed:fixture": "tsx scripts/seed-fixture.ts", "seed:fixture": "tsx scripts/seed-fixture.ts",
"agent:package": "python3 agent/package_release.py", "agent:package": "python3 agent/package_release.py",
"prebuild": "npm run agent:package", "prebuild": "npm run agent:package",
+16
View File
@@ -0,0 +1,16 @@
import Database from 'better-sqlite3';
import { statSync } from 'node:fs';
const path = process.argv[2] ?? process.env.DB_PATH ?? './data/cherry.db';
const db = new Database(path, { readonly: true, fileMustExist: true });
try {
console.table(['', '-wal', '-shm'].map(suffix => {
try { return { file: path + suffix, bytes: statSync(path + suffix).size }; }
catch (error) { if (error.code !== 'ENOENT') throw error; return { file: path + suffix, bytes: 0 }; }
}));
console.table(db.prepare(`SELECT name, ROUND(SUM(pgsize)/1048576.0, 2) AS mib
FROM dbstat GROUP BY name ORDER BY SUM(pgsize) DESC LIMIT 20`).all());
console.table(['page_size', 'page_count', 'freelist_count', 'auto_vacuum'].map(pragma => ({
pragma, value: db.pragma(pragma, { simple: true })
})));
} finally { db.close(); }
+6 -2
View File
@@ -13,6 +13,7 @@ export interface FleetSettings {
matchKeepDays: number; matchKeepDays: number;
betaKeepDays: number; betaKeepDays: number;
fileContentKeepDays: number; fileContentKeepDays: number;
payloadKeepDays: number;
versionKeepDays: number; versionKeepDays: number;
versionKeepCount: number; versionKeepCount: number;
gameFileGraceDays: number; gameFileGraceDays: number;
@@ -49,7 +50,8 @@ export const defaultFleetSettings: FleetSettings = {
telemetryMaxTotal: 10000, telemetryMaxTotal: 10000,
matchKeepDays: 0, matchKeepDays: 0,
betaKeepDays: 0, betaKeepDays: 0,
fileContentKeepDays: 0, fileContentKeepDays: 30,
payloadKeepDays: 30,
versionKeepDays: 0, versionKeepDays: 0,
versionKeepCount: 0, versionKeepCount: 0,
gameFileGraceDays: 0, gameFileGraceDays: 0,
@@ -86,6 +88,7 @@ export const fleetSettingLabels: Record<keyof FleetSettings, string> = {
matchKeepDays: 'Match history retention', matchKeepDays: 'Match history retention',
betaKeepDays: 'Completed beta history retention', betaKeepDays: 'Completed beta history retention',
fileContentKeepDays: 'File diffs and rollback snapshot retention', fileContentKeepDays: 'File diffs and rollback snapshot retention',
payloadKeepDays: 'Launch and command file retention',
versionKeepDays: 'Unused artifact version retention', versionKeepDays: 'Unused artifact version retention',
versionKeepCount: 'Minimum artifact versions to keep', versionKeepCount: 'Minimum artifact versions to keep',
gameFileGraceDays: 'Unused game-file grace period', gameFileGraceDays: 'Unused game-file grace period',
@@ -129,7 +132,7 @@ export function validateFleetSettings(input: unknown): FleetSettings {
export const additionalConcurrencyKeys: (keyof FleetSettings)[] = ["restartConcurrencyPerHost", "createConcurrencyPerHost", "destroyConcurrencyPerHost", "lifecycleConcurrency", "rebuildConcurrency", "preparationConcurrency"]; export const additionalConcurrencyKeys: (keyof FleetSettings)[] = ["restartConcurrencyPerHost", "createConcurrencyPerHost", "destroyConcurrencyPerHost", "lifecycleConcurrency", "rebuildConcurrency", "preparationConcurrency"];
export const concurrencyKeys: (keyof FleetSettings)[] = ['restartConcurrency', 'createConcurrency', 'destroyConcurrency', ...additionalConcurrencyKeys]; export const concurrencyKeys: (keyof FleetSettings)[] = ['restartConcurrency', 'createConcurrency', 'destroyConcurrency', ...additionalConcurrencyKeys];
export const retentionKeys: (keyof FleetSettings)[] = ['deployKeepDays', 'observationKeepDays', 'provisioningKeepDays', 'actionKeepDays', 'failureKeepDays', 'maintenanceKeepDays', 'recoveryKeepDays', 'hostResourceKeepDays', 'instanceResourceKeepDays', 'metricsKeepDays', 'telemetryKeepDays', 'telemetryMaxPerServer', 'telemetryMaxTotal', 'matchKeepDays', 'betaKeepDays', 'fileContentKeepDays', 'versionKeepDays', 'versionKeepCount', 'gameFileGraceDays', 'gameFileKeepCount', 'uploadKeepDays', 'auditKeepDays', 'historyKeepCount']; export const retentionKeys: (keyof FleetSettings)[] = ['deployKeepDays', 'observationKeepDays', 'provisioningKeepDays', 'actionKeepDays', 'failureKeepDays', 'maintenanceKeepDays', 'recoveryKeepDays', 'hostResourceKeepDays', 'instanceResourceKeepDays', 'metricsKeepDays', 'telemetryKeepDays', 'telemetryMaxPerServer', 'telemetryMaxTotal', 'matchKeepDays', 'betaKeepDays', 'fileContentKeepDays', 'payloadKeepDays', 'versionKeepDays', 'versionKeepCount', 'gameFileGraceDays', 'gameFileKeepCount', 'uploadKeepDays', 'auditKeepDays', 'historyKeepCount'];
export function settingMinimum(key: keyof FleetSettings): number { export function settingMinimum(key: keyof FleetSettings): number {
return ['restartConcurrency','createConcurrency','destroyConcurrency','gameFileKeepCount'].includes(key) ? 1 : 0; return ['restartConcurrency','createConcurrency','destroyConcurrency','gameFileKeepCount'].includes(key) ? 1 : 0;
} }
@@ -149,6 +152,7 @@ export const settingHelp: Partial<Record<keyof FleetSettings, string>> = {
matchKeepDays: 'Completed and interrupted matches and player details. Expiry changes leaderboards and player totals; no lifetime aggregates are preserved.', matchKeepDays: 'Completed and interrupted matches and player details. Expiry changes leaderboards and player totals; no lifetime aggregates are preserved.',
betaKeepDays: 'Closed betas, revisions and test evidence, after linked campaigns and recovery work no longer need them.', betaKeepDays: 'Closed betas, revisions and test evidence, after linked campaigns and recovery work no longer need them.',
fileContentKeepDays: 'Expired campaign file bodies are removed while summaries remain. Rollback becomes unavailable for those campaigns. Active beta snapshots remain protected.', fileContentKeepDays: 'Expired campaign file bodies are removed while summaries remain. Rollback becomes unavailable for those campaigns. Active beta snapshots remain protected.',
payloadKeepDays: 'Expire file bodies in completed launch recipes, commands and receipts while keeping their history. Unfinished work and protected records keep their files. 0 keeps files indefinitely.',
versionKeepDays: 'Unused script, plugin, configuration and baseline versions. Current and referenced versions are always protected.', versionKeepDays: 'Unused script, plugin, configuration and baseline versions. Current and referenced versions are always protected.',
versionKeepCount: 'Keep at least this many recent versions per artifact. With no age expiry, a positive count removes older unused versions.', versionKeepCount: 'Keep at least this many recent versions per artifact. With no age expiry, a positive count removes older unused versions.',
gameFileGraceDays: 'Delay removal after a version becomes superseded. Latest files, referenced versions and offline host copies remain protected. 0 adds no delay.', gameFileGraceDays: 'Delay removal after a version becomes superseded. Latest files, referenced versions and offline host copies remain protected. 0 adds no delay.',
+1
View File
@@ -22,6 +22,7 @@ export function ensureAuthSchema(handle: Database.Database): void {
id INTEGER PRIMARY KEY AUTOINCREMENT, actor_id TEXT, action TEXT NOT NULL, id INTEGER PRIMARY KEY AUTOINCREMENT, actor_id TEXT, action TEXT NOT NULL,
target_id TEXT, details TEXT NOT NULL, created_at INTEGER NOT NULL target_id TEXT, details TEXT NOT NULL, created_at INTEGER NOT NULL
); );
CREATE INDEX IF NOT EXISTS auth_audit_created_at ON auth_audit(created_at);
`); `);
initialized.add(handle); initialized.add(handle);
} }
+9
View File
@@ -41,7 +41,16 @@ export function migrateNativeInstances(handle: Database.Database): void {
creation_id TEXT PRIMARY KEY REFERENCES server_deployments(id), creation_id TEXT PRIMARY KEY REFERENCES server_deployments(id),
cleanup_id TEXT NOT NULL UNIQUE REFERENCES server_deployments(id) cleanup_id TEXT NOT NULL UNIQUE REFERENCES server_deployments(id)
); );
CREATE INDEX IF NOT EXISTS idx_ec_host_migrations_instance ON ec_host_migrations(instance_id);
`); `);
if (!handle.prepare("SELECT 1 FROM settings WHERE key='native_file_plans_v1'").get()) {
handle.transaction(() => {
handle.exec(`UPDATE native_provisioning SET files_json=(SELECT json_group_array(json_remove(value,'$.contentBase64'))
FROM json_each(native_provisioning.files_json)) WHERE files_json IS NOT NULL;
INSERT INTO settings(key,value) VALUES('native_file_plans_v1','1');`);
})();
}
} }
/** Accepted native creations receive their host port only when provisioning places them. */ /** Accepted native creations receive their host port only when provisioning places them. */
+12
View File
@@ -0,0 +1,12 @@
import type Database from 'better-sqlite3';
export function tuneConnection(handle: Database.Database, reader = false): void {
handle.pragma(`cache_size = -${reader ? 32768 : 131072}`);
handle.pragma('temp_store = MEMORY');
// mmap is opt-in because the controller data directory can be a network mount.
const mmapMiB = Number(process.env.EC_SQLITE_MMAP_MB ?? 0);
if (Number.isSafeInteger(mmapMiB) && mmapMiB >= 0 && mmapMiB <= 32768) {
handle.pragma(`mmap_size = ${mmapMiB * 1024 * 1024}`);
}
if (!reader) handle.pragma('journal_size_limit = 67108864');
}
+123
View File
@@ -0,0 +1,123 @@
import { createHash } from 'node:crypto';
import type Database from 'better-sqlite3';
import { db } from './index.js';
export function ensureFileBlobSchema(handle: Database.Database): void {
handle.exec(`CREATE TABLE IF NOT EXISTS file_blobs(sha256 TEXT PRIMARY KEY,size INTEGER NOT NULL,content BLOB NOT NULL);
CREATE TABLE IF NOT EXISTS file_blob_references(owner_type TEXT NOT NULL,owner_id TEXT NOT NULL,
sha256 TEXT NOT NULL REFERENCES file_blobs(sha256),PRIMARY KEY(owner_type,owner_id,sha256));
CREATE INDEX IF NOT EXISTS idx_file_blob_references_hash ON file_blob_references(sha256);`);
}
export function putFileBlob(content: Uint8Array | null, handle: Database.Database = db()): string | null {
if (content === null) return null;
const sha256 = createHash('sha256').update(content).digest('hex');
handle.prepare('INSERT OR IGNORE INTO file_blobs(sha256,size,content) VALUES(?,?,?)').run(sha256, content.byteLength, content);
return sha256;
}
export function getFileBlob(sha256: string, handle: Database.Database = db()): Buffer {
const row = handle.prepare('SELECT content FROM file_blobs WHERE sha256=?').get(sha256) as { content: Buffer } | undefined;
if (!row) throw new Error('File content is unavailable or has expired.');
return row.content;
}
/** SQL fragments are for fixed schema identifiers, never user input. */
export const blobContent = (column: string) => `(SELECT content FROM file_blobs WHERE sha256=${column})`;
export const blobSize = (column: string) => `(SELECT size FROM file_blobs WHERE sha256=${column})`;
export function referenceBlobs(ownerType: string, ownerId: string, hashes: Iterable<string>, handle: Database.Database = db()): void {
handle.prepare('DELETE FROM file_blob_references WHERE owner_type=? AND owner_id=?').run(ownerType, ownerId);
const insert = handle.prepare('INSERT OR IGNORE INTO file_blob_references(owner_type,owner_id,sha256) VALUES(?,?,?)');
for (const hash of hashes) insert.run(ownerType, ownerId, hash);
}
/** Immutable file bytes in JSON are replaced with hash references, including before images. */
export function packFileJson(value: unknown, handle: Database.Database = db(), hashes = new Set<string>()): { value: unknown; hashes: Set<string> } {
if (Array.isArray(value)) return { value: value.map(item => packFileJson(item, handle, hashes).value), hashes };
if (!value || typeof value !== 'object') return { value, hashes };
const result: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value)) {
if ((key === 'contentBase64' || key === 'content' && 'path' in value && 'sha256' in value) && typeof item === 'string') {
const hash = putFileBlob(Buffer.from(item, 'base64'), handle)!;
result[key === 'content' ? 'contentHash' : 'contentSha256'] = hash;
hashes.add(hash);
} else {
result[key] = packFileJson(item, handle, hashes).value;
if ((key === 'contentHash' || key === 'contentSha256') && typeof item === 'string') hashes.add(item);
}
}
return { value: result, hashes };
}
export function unpackFileJson(value: unknown, handle: Database.Database = db()): any {
if (Array.isArray(value)) return value.map(item => unpackFileJson(item, handle));
if (!value || typeof value !== 'object') return value;
const result: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value)) {
if ((key === 'contentHash' || key === 'contentSha256') && typeof item === 'string') {
result[key === 'contentHash' ? 'content' : 'contentBase64'] = getFileBlob(item, handle).toString('base64');
} else result[key] = unpackFileJson(item, handle);
}
return result;
}
export const blobColumns = [
['script_versions', 'content'], ['staged_file_changes', 'content'],
['deployment_files', 'content_before'], ['deployment_files', 'content_after'],
['beta_file_snapshots', 'prior_content'], ['beta_revision_files', 'content']
] as const;
/** Finish each SELECT before writing; better-sqlite3 iterators hold an active cursor. */
export function* migrationRows<T>(handle: Database.Database, sql: string): Generator<T & { rid: number }> {
const select = handle.prepare(sql);
let cursor = -Number.MAX_SAFE_INTEGER;
for (;;) {
const row = select.get(cursor) as (T & { rid: number }) | undefined;
if (!row) return;
cursor = row.rid;
yield row;
}
}
/** Backfill one row at a time so large libraries do not exhaust startup memory. */
export function migrateFileBlobs(handle: Database.Database): void {
for (const [table, column] of blobColumns) {
if (!(handle.pragma(`table_info(${table})`) as { name: string }[]).some(item => item.name === column)) continue;
handle.transaction(() => {
handle.exec(`ALTER TABLE ${table} ADD COLUMN ${column}_hash TEXT REFERENCES file_blobs(sha256)`);
const update = handle.prepare(`UPDATE ${table} SET ${column}_hash=? WHERE rowid=?`);
for (const row of migrationRows<{ content: Buffer | null }>(handle, `SELECT rowid AS rid,${column} AS content FROM ${table} WHERE rowid>? ORDER BY rowid LIMIT 1`)) {
update.run(putFileBlob(row.content, handle), row.rid);
}
handle.exec(`ALTER TABLE ${table} DROP COLUMN ${column}`);
})();
}
for (const [table, column] of blobColumns) handle.exec(`CREATE INDEX IF NOT EXISTS idx_${table}_${column}_hash ON ${table}(${column}_hash)`);
if (!handle.prepare("SELECT 1 FROM settings WHERE key='recipe_blobs_v1'").get()) {
handle.transaction(() => {
for (const row of migrationRows<{ deployment_id: string; recipe: string }>(handle,
'SELECT rowid AS rid,deployment_id,recipe FROM server_launch_recipes WHERE rowid>? ORDER BY rowid LIMIT 1')) {
const packed = packFileJson(JSON.parse(row.recipe), handle);
handle.prepare('UPDATE server_launch_recipes SET recipe=? WHERE deployment_id=?').run(JSON.stringify(packed.value), row.deployment_id);
referenceBlobs('recipe', row.deployment_id, packed.hashes, handle);
}
handle.exec("INSERT INTO settings(key,value) VALUES('recipe_blobs_v1','1')");
})();
}
handle.exec(`CREATE TRIGGER IF NOT EXISTS release_recipe_blobs AFTER DELETE ON server_launch_recipes BEGIN
DELETE FROM file_blob_references WHERE owner_type='recipe' AND owner_id=OLD.deployment_id; END;`);
for (const action of ['INSERT', 'UPDATE OF recipe']) handle.exec(`CREATE TRIGGER IF NOT EXISTS reference_recipe_blobs_${action.startsWith('UPDATE') ? 'update' : 'insert'}
AFTER ${action} ON server_launch_recipes BEGIN
DELETE FROM file_blob_references WHERE owner_type='recipe' AND owner_id=NEW.deployment_id;
INSERT OR IGNORE INTO file_blob_references(owner_type,owner_id,sha256)
SELECT 'recipe',NEW.deployment_id,json_extract(value,'$.contentHash') FROM json_each(NEW.recipe,'$.files')
WHERE json_extract(value,'$.contentHash') IS NOT NULL; END;`);
}
export function pruneFileBlobs(limit = 100): number {
return db().prepare(`DELETE FROM file_blobs WHERE sha256 IN (SELECT b.sha256 FROM file_blobs b
WHERE NOT EXISTS (SELECT 1 FROM file_blob_references r WHERE r.sha256=b.sha256)
${blobColumns.map(([table, column]) => `AND NOT EXISTS (SELECT 1 FROM ${table} WHERE ${column}_hash=b.sha256)`).join('\n')}
LIMIT ?)`).run(limit).changes;
}
+30 -1
View File
@@ -10,8 +10,15 @@ import { backfillLeaderboard } from '../fleet/leaderboard-ingest.js';
import { initializeFleetSettings } from './fleet-settings.js'; import { initializeFleetSettings } from './fleet-settings.js';
import { migrateServerHistory } from './server-history.js'; import { migrateServerHistory } from './server-history.js';
import { migrateNativeInstances } from '../backends/schema.js'; import { migrateNativeInstances } from '../backends/schema.js';
import { installPerformanceIndexes } from './performance-indexes.js';
import { migrateVersionLayout } from './version-layout.js';
import { migrateFileBlobs } from './file-blobs.js';
import { enableIncrementalVacuum, retentionClocks } from './retention-clocks.js';
import { tuneConnection } from './connection.js';
import { timeStatement } from './slow-statements.js';
let _db: Database.Database | null = null; let _db: Database.Database | null = null;
let optimizeTimer: NodeJS.Timeout | undefined;
// Desired shape of the `servers` table. Every column here is added to the // Desired shape of the `servers` table. Every column here is added to the
// table on boot if missing — introspection via PRAGMA table_info handles // table on boot if missing — introspection via PRAGMA table_info handles
@@ -312,7 +319,9 @@ export function db(): Database.Database {
// silently breaks every subsequent query without a chance to retry. // silently breaks every subsequent query without a chance to retry.
try { try {
handle.pragma('journal_mode = WAL'); handle.pragma('journal_mode = WAL');
handle.pragma('auto_vacuum = INCREMENTAL');
handle.pragma('foreign_keys = ON'); handle.pragma('foreign_keys = ON');
tuneConnection(handle);
dropLegacyBetaTables(handle); dropLegacyBetaTables(handle);
handle.exec(schemaSql); handle.exec(schemaSql);
migrateProvisioningActions(handle); migrateProvisioningActions(handle);
@@ -386,6 +395,8 @@ export function db(): Database.Database {
backfillLeaderboard(handle); backfillLeaderboard(handle);
initializeFleetSettings(handle); initializeFleetSettings(handle);
migrateNativeInstances(handle); migrateNativeInstances(handle);
migrateVersionLayout(handle);
migrateFileBlobs(handle);
// Lifecycle history is retained indefinitely. Admission and status reads // Lifecycle history is retained indefinitely. Admission and status reads
// must narrow by server or active state instead of scanning that history. // must narrow by server or active state instead of scanning that history.
// Install after migrations that rebuild this table or add server_deleted_at. // Install after migrations that rebuild this table or add server_deleted_at.
@@ -403,7 +414,17 @@ export function db(): Database.Database {
ON server_deployments(finished_at) WHERE server_deleted_at IS NULL; ON server_deployments(finished_at) WHERE server_deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_server_deployments_unsettled CREATE INDEX IF NOT EXISTS idx_server_deployments_unsettled
ON server_deployments(id) WHERE state NOT IN ('done','warning','cancelled');`); ON server_deployments(id) WHERE state NOT IN ('done','warning','cancelled');`);
installPerformanceIndexes(handle);
installReportRevisions(handle); installReportRevisions(handle);
retentionClocks(handle, {
deployments: ['started_at', 'finished_at'], server_deployments: ['started_at', 'finished_at'],
maintenance_jobs: ['requested_at', 'finished_at'], watchdog_incidents: ['first_seen_at', 'finished_at'],
leaderboard_matches: ['started_at', 'last_received_at', 'ended_at'],
betas: ['opened_at', 'closed_at'], script_versions: ['uploaded_at'], retention_controls: ['resolved_at']
});
// Existing databases require one rebuild to gain the incremental-vacuum page map.
enableIncrementalVacuum(handle);
handle.pragma('optimize = 0x10002');
} catch (err) { } catch (err) {
handle.close(); handle.close();
throw err; throw err;
@@ -414,21 +435,29 @@ export function db(): Database.Database {
// Callers share statement options (e.g. raw()); use consistent options for // Callers share statement options (e.g. raw()); use consistent options for
// identical SQL and pass values to run/get/all rather than binding permanently. // identical SQL and pass values to run/get/all rather than binding permanently.
const statements = new Map<string, Database.Statement>(); const statements = new Map<string, Database.Statement>();
const slowMs = Number(process.env.EC_SLOW_SQL_MS ?? 0);
const prepare = handle.prepare.bind(handle); const prepare = handle.prepare.bind(handle);
handle.prepare = ((sql: string) => { handle.prepare = ((sql: string) => {
let statement = statements.get(sql); let statement = statements.get(sql);
if (!statement) { if (!statement) {
statement = prepare(sql); statement = timeStatement(prepare(sql), sql, slowMs);
statements.set(sql, statement); statements.set(sql, statement);
} }
return statement; return statement;
}) as typeof handle.prepare; }) as typeof handle.prepare;
_db = handle; _db = handle;
optimizeTimer = setInterval(() => {
try { handle.pragma('optimize'); }
catch (error) { console.error('[database] optimize failed:', error); }
}, 60 * 60_000);
optimizeTimer.unref();
return _db; return _db;
} }
export function closeDb(): void { export function closeDb(): void {
clearInterval(optimizeTimer);
optimizeTimer = undefined;
if (_db) { if (_db) {
_db.close(); _db.close();
_db = null; _db = null;
+27
View File
@@ -0,0 +1,27 @@
import type Database from 'better-sqlite3';
/** Install after additive column and history migrations, on new and existing databases. */
export function installPerformanceIndexes(handle: Database.Database): void {
handle.exec(`
CREATE INDEX IF NOT EXISTS idx_staged_files_deployment_file ON staged_file_changes(deployment_file_id);
CREATE INDEX IF NOT EXISTS idx_beta_test_runs_beta_revision ON beta_test_runs(beta_id, revision);
CREATE INDEX IF NOT EXISTS idx_deployments_beta ON deployments(beta_id);
CREATE INDEX IF NOT EXISTS idx_deployments_script ON deployments(script_id);
CREATE INDEX IF NOT EXISTS idx_deployments_version ON deployments(script_version_id);
CREATE INDEX IF NOT EXISTS idx_deployments_prior ON deployments(prior_version_id);
CREATE INDEX IF NOT EXISTS idx_deployment_scripts_script ON deployment_scripts(script_id);
CREATE INDEX IF NOT EXISTS idx_deployment_scripts_version ON deployment_scripts(script_version_id);
CREATE INDEX IF NOT EXISTS idx_deployment_scripts_prior ON deployment_scripts(prior_version_id);
CREATE INDEX IF NOT EXISTS idx_server_scripts_version ON server_scripts(script_version_id);
CREATE INDEX IF NOT EXISTS idx_betas_version ON betas(script_version_id);
CREATE INDEX IF NOT EXISTS idx_beta_scripts_version ON beta_scripts(script_version_id);
CREATE INDEX IF NOT EXISTS idx_beta_servers_prior ON beta_servers(prior_version_id);
CREATE INDEX IF NOT EXISTS idx_beta_server_scripts_script ON beta_server_scripts(script_id);
CREATE INDEX IF NOT EXISTS idx_beta_server_scripts_prior ON beta_server_scripts(prior_version_id);
CREATE INDEX IF NOT EXISTS idx_beta_file_snapshots_server ON beta_file_snapshots(server);
CREATE INDEX IF NOT EXISTS idx_server_launch_previews_deployment ON server_launch_previews(deployment_id);
CREATE INDEX IF NOT EXISTS idx_server_collection_previews_collection ON server_collection_previews(collection_id);
CREATE INDEX IF NOT EXISTS idx_server_collection_removals_deployment ON server_collection_removals(deployment_id);
CREATE INDEX IF NOT EXISTS idx_server_creation_dependencies_removal ON server_creation_dependencies(removal_id);
`);
}
+45
View File
@@ -0,0 +1,45 @@
import type Database from 'better-sqlite3';
/** Normalize age-policy clocks once, then compare indexed ISO UTC strings directly. */
export function retentionClocks(handle: Database.Database, clocks: Record<string, string[]>): void {
handle.exec('CREATE TABLE IF NOT EXISTS database_maintenance_migrations(name TEXT PRIMARY KEY)');
for (const [table, columns] of Object.entries(clocks)) {
const marker = `iso-clocks:${table}:v1`;
if (!handle.prepare('SELECT 1 FROM database_maintenance_migrations WHERE name=?').get(marker)) {
handle.transaction(() => {
for (const column of columns) handle.exec(`UPDATE ${table} SET ${column}=strftime('%Y-%m-%dT%H:%M:%fZ',${column})
WHERE julianday(${column}) IS NOT NULL AND ${column}!=strftime('%Y-%m-%dT%H:%M:%fZ',${column});`);
handle.prepare('INSERT INTO database_maintenance_migrations(name) VALUES(?)').run(marker);
})();
}
const indexes = handle.prepare(`PRAGMA index_list(${table})`).all() as { name: string; partial: number }[];
const indexed = new Set(indexes.filter(index => !index.partial).map(index =>
(handle.prepare(`PRAGMA index_info("${index.name.replaceAll('"', '""')}")`).get() as { name: string }).name));
for (const column of columns) if (!indexed.has(column)) {
handle.exec(`CREATE INDEX IF NOT EXISTS idx_retention_${table}_${column} ON ${table}(${column})`);
}
}
}
/** VACUUM can renumber implicit rowids. Keep host enable cutoffs tied to command IDs across crashes. */
export function enableIncrementalVacuum(handle: Database.Database): void {
if (handle.pragma('auto_vacuum', { simple: true }) !== 2) {
handle.exec(`CREATE TABLE IF NOT EXISTS database_vacuum_boundaries(host_id TEXT PRIMARY KEY, operation_id TEXT)`);
if (handle.prepare("SELECT 1 FROM sqlite_master WHERE name='ec_host_enable_requests'").get()) {
handle.exec(`INSERT OR IGNORE INTO database_vacuum_boundaries
SELECT e.host_id,(SELECT o.id FROM ec_host_operations o WHERE o.host_id=e.host_id
AND o.rowid<=e.operation_floor ORDER BY o.rowid DESC LIMIT 1) FROM ec_host_enable_requests e`);
}
handle.pragma('auto_vacuum = INCREMENTAL');
handle.exec('VACUUM');
}
if (handle.prepare("SELECT 1 FROM sqlite_master WHERE name='database_vacuum_boundaries'").get()) {
handle.transaction(() => {
if (handle.prepare("SELECT 1 FROM sqlite_master WHERE name='ec_host_enable_requests'").get()) handle.exec(`
UPDATE ec_host_enable_requests AS e SET operation_floor=COALESCE((
SELECT o.rowid FROM database_vacuum_boundaries b JOIN ec_host_operations o ON o.id=b.operation_id
WHERE b.host_id=e.host_id),0) WHERE e.host_id IN (SELECT host_id FROM database_vacuum_boundaries)`);
handle.exec('DROP TABLE database_vacuum_boundaries');
})();
}
}
+16 -6
View File
@@ -1,3 +1,13 @@
-- Immutable file bodies are shared across versions, campaigns, betas and host commands.
CREATE TABLE IF NOT EXISTS file_blobs (
sha256 TEXT PRIMARY KEY, size INTEGER NOT NULL, content BLOB NOT NULL
);
CREATE TABLE IF NOT EXISTS file_blob_references (
owner_type TEXT NOT NULL, owner_id TEXT NOT NULL, sha256 TEXT NOT NULL REFERENCES file_blobs(sha256),
PRIMARY KEY(owner_type,owner_id,sha256)
);
CREATE INDEX IF NOT EXISTS idx_file_blob_references_hash ON file_blob_references(sha256);
-- Electric Cherry schema. Idempotent — safe to run at every boot via migrate.ts. -- Electric Cherry schema. Idempotent — safe to run at every boot via migrate.ts.
-- The old betas/beta_scripts/beta_deploys tables are dropped in-code before this -- The old betas/beta_scripts/beta_deploys tables are dropped in-code before this
-- runs (see dropLegacyBetaTables in db/index.ts); the new script-centric model -- runs (see dropLegacyBetaTables in db/index.ts); the new script-centric model
@@ -130,11 +140,11 @@ CREATE TABLE IF NOT EXISTS script_versions (
release_version TEXT, -- publisher's version/tag, independent of upload order release_version TEXT, -- publisher's version/tag, independent of upload order
sha256 TEXT NOT NULL, sha256 TEXT NOT NULL,
size INTEGER NOT NULL, size INTEGER NOT NULL,
content BLOB NOT NULL,
notes TEXT, notes TEXT,
state TEXT NOT NULL DEFAULT 'untested', -- 'untested' | 'tested' | 'test-failed' state TEXT NOT NULL DEFAULT 'untested', -- 'untested' | 'tested' | 'test-failed'
kind TEXT NOT NULL DEFAULT 'script', -- denormalized from scripts.kind for join-free filtering kind TEXT NOT NULL DEFAULT 'script', -- denormalized from scripts.kind for join-free filtering
uploaded_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), uploaded_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
content_hash TEXT REFERENCES file_blobs(sha256),
UNIQUE(script_id, version) UNIQUE(script_id, version)
); );
CREATE INDEX IF NOT EXISTS idx_script_versions_script ON script_versions(script_id); CREATE INDEX IF NOT EXISTS idx_script_versions_script ON script_versions(script_id);
@@ -201,7 +211,7 @@ CREATE TABLE IF NOT EXISTS beta_revision_files (
version INTEGER NOT NULL, version INTEGER NOT NULL,
release_version TEXT, release_version TEXT,
sha256 TEXT NOT NULL, sha256 TEXT NOT NULL,
content BLOB NOT NULL, content_hash TEXT REFERENCES file_blobs(sha256),
PRIMARY KEY (beta_id, revision, script_id), PRIMARY KEY (beta_id, revision, script_id),
FOREIGN KEY (beta_id, revision) REFERENCES beta_revisions(beta_id, revision) ON DELETE CASCADE FOREIGN KEY (beta_id, revision) REFERENCES beta_revisions(beta_id, revision) ON DELETE CASCADE
); );
@@ -306,7 +316,7 @@ CREATE TABLE IF NOT EXISTS beta_file_snapshots (
beta_id INTEGER NOT NULL REFERENCES betas(id) ON DELETE CASCADE, beta_id INTEGER NOT NULL REFERENCES betas(id) ON DELETE CASCADE,
server TEXT NOT NULL REFERENCES servers(name) ON DELETE CASCADE, server TEXT NOT NULL REFERENCES servers(name) ON DELETE CASCADE,
rel_path TEXT NOT NULL, rel_path TEXT NOT NULL,
prior_content BLOB NOT NULL, prior_content_hash TEXT REFERENCES file_blobs(sha256),
prior_sha256 TEXT NOT NULL, prior_sha256 TEXT NOT NULL,
prior_size INTEGER NOT NULL, prior_size INTEGER NOT NULL,
PRIMARY KEY (beta_id, server, rel_path) PRIMARY KEY (beta_id, server, rel_path)
@@ -364,8 +374,8 @@ CREATE TABLE IF NOT EXISTS deployment_files (
rel_path TEXT NOT NULL, rel_path TEXT NOT NULL,
sha256_before TEXT, sha256_before TEXT,
sha256_after TEXT, sha256_after TEXT,
content_before BLOB, content_before_hash TEXT REFERENCES file_blobs(sha256),
content_after BLOB, content_after_hash TEXT REFERENCES file_blobs(sha256),
-- An unknown before image is distinct from a confirmed absent file. -- An unknown before image is distinct from a confirmed absent file.
snapshot_pending INTEGER NOT NULL DEFAULT 0 snapshot_pending INTEGER NOT NULL DEFAULT 0
); );
@@ -382,7 +392,7 @@ CREATE TABLE IF NOT EXISTS staged_file_changes (
server TEXT NOT NULL, server TEXT NOT NULL,
kind TEXT NOT NULL, kind TEXT NOT NULL,
rel_path TEXT NOT NULL, rel_path TEXT NOT NULL,
content BLOB, content_hash TEXT REFERENCES file_blobs(sha256),
script_version_id INTEGER, script_version_id INTEGER,
inventory_source TEXT NOT NULL, inventory_source TEXT NOT NULL,
deployment_file_id INTEGER REFERENCES deployment_files(id) ON DELETE CASCADE, deployment_file_id INTEGER REFERENCES deployment_files(id) ON DELETE CASCADE,
+19
View File
@@ -0,0 +1,19 @@
import type Database from 'better-sqlite3';
/** Log query shapes, never bound values or SQL string literals. Disabled by default. */
export function timeStatement(statement: Database.Statement, sql: string, thresholdMs: number): Database.Statement {
if (!Number.isFinite(thresholdMs) || thresholdMs <= 0) return statement;
const shape = sql.replace(/'(?:''|[^'])*'/g, '?').replace(/\s+/g, ' ').trim().slice(0, 200);
for (const method of ['run', 'get', 'all'] as const) {
const original = statement[method].bind(statement) as (...args: unknown[]) => unknown;
Object.defineProperty(statement, method, { value: (...args: unknown[]) => {
const started = performance.now();
try { return original(...args); }
finally {
const ms = performance.now() - started;
if (ms >= thresholdMs) console.warn('[sql]', { ms: Math.round(ms), method, sql: shape });
}
} });
}
return statement;
}
+27
View File
@@ -0,0 +1,27 @@
import type Database from 'better-sqlite3';
/** Keep metadata ahead of overflow pages, preserving IDs and all references. */
export function migrateVersionLayout(handle: Database.Database): void {
const columns = handle.pragma('table_info(script_versions)') as { name: string }[];
if (!columns.some(column => column.name === 'content') || columns.at(-1)?.name === 'content') return;
const { sql } = handle.prepare("SELECT sql FROM sqlite_master WHERE name='script_versions'").get() as { sql: string };
const content = /\bcontent\s+BLOB\s+NOT\s+NULL\s*,/i;
if (!content.test(sql)) throw new Error('Unrecognized script version content layout');
const replacement = sql.replace(content, '').replace(/,\s*UNIQUE\s*\(/i, ', content BLOB NOT NULL, UNIQUE(')
.replace('script_versions', 'script_versions_metadata');
const dependents = handle.prepare("SELECT sql FROM sqlite_master WHERE tbl_name='script_versions' AND type IN ('index','trigger') AND sql IS NOT NULL")
.all() as { sql: string }[];
const sequence = handle.prepare("SELECT seq FROM sqlite_sequence WHERE name='script_versions'").get() as { seq: number } | undefined;
handle.pragma('foreign_keys = OFF');
try {
handle.transaction(() => {
handle.exec(replacement);
const names = columns.map(column => `"${column.name}"`).join(',');
handle.exec(`INSERT INTO script_versions_metadata (${names}) SELECT ${names} FROM script_versions;
DROP TABLE script_versions; ALTER TABLE script_versions_metadata RENAME TO script_versions;`);
if (sequence) handle.prepare("UPDATE sqlite_sequence SET seq=MAX(seq,?) WHERE name='script_versions'").run(sequence.seq);
for (const dependent of dependents) handle.exec(dependent.sql);
if ((handle.pragma('foreign_key_check') as unknown[]).length) throw new Error('Version layout migration found broken references');
})();
} finally { handle.pragma('foreign_keys = ON'); }
}
+40
View File
@@ -0,0 +1,40 @@
import { db } from '../db/index.js';
import type { BetaRow } from './beta.js';
const positive = (value: string | null, fallback: number) => {
const number = Number(value);
return Number.isSafeInteger(number) && number > 0 ? number : fallback;
};
export function betaListPage(params: URLSearchParams) {
const state = params.get('state'), view = params.get('view'), search = (params.get('q') ?? '').slice(0, 200);
const predicates: string[] = [], args: (string | number)[] = [];
if (state) { predicates.push('b.state=?'); args.push(state); }
else if (view === 'active') predicates.push("b.state IN ('running','ready-promote')");
else if (view === 'history') predicates.push("b.state IN ('promoted','rolled-back')");
if (search) {
predicates.push(`(instr(lower('#'||b.id||' '||COALESCE(b.notes,'')),lower(?))>0 OR EXISTS (
SELECT 1 FROM beta_scripts bs JOIN scripts s ON s.id=bs.script_id WHERE bs.beta_id=b.id AND instr(lower(s.name),lower(?))>0))`);
args.push(search, search);
}
const where = predicates.length ? ' WHERE ' + predicates.join(' AND ') : '';
const total = (db().prepare('SELECT COUNT(*) n FROM betas b' + where).get(...args) as { n: number }).n;
const pageSize = Math.min(100, positive(params.get('limit'), 25));
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const page = Math.min(totalPages, positive(params.get('page'), 1));
const betas = db().prepare('SELECT b.* FROM betas b' + where + ' ORDER BY b.id DESC LIMIT ? OFFSET ?')
.all(...args, pageSize, (page - 1) * pageSize) as BetaRow[];
const counts = db().prepare(`SELECT COUNT(CASE WHEN state IN ('running','ready-promote') THEN 1 END) active,
COUNT(CASE WHEN state IN ('promoted','rolled-back') THEN 1 END) history FROM betas`).get() as { active: number; history: number };
return { betas, page, pageSize, total, totalPages, counts };
}
export function betaActivityPage(id: number, params = new URLSearchParams()) {
const before = positive(params.get('before'), Number.MAX_SAFE_INTEGER);
const limit = Math.min(100, positive(params.get('limit'), 50));
const rows = db().prepare('SELECT id,created_at,message FROM beta_events WHERE beta_id=? AND id<? ORDER BY id DESC LIMIT ?')
.all(id, before, limit + 1) as { id: number; created_at: string; message: string }[];
const more = rows.length > limit;
const events = rows.slice(0, limit).reverse();
return { events, nextBefore: more ? events[0].id : null };
}
+5 -4
View File
@@ -12,8 +12,8 @@ export function archiveBetaRevision(id: number, revision: number, startedAt: str
db().prepare('INSERT OR IGNORE INTO beta_revisions (beta_id, revision, started_at, notes) VALUES (?, ?, ?, ?)') db().prepare('INSERT OR IGNORE INTO beta_revisions (beta_id, revision, started_at, notes) VALUES (?, ?, ?, ?)')
.run(id, revision, startedAt, notes); .run(id, revision, startedAt, notes);
db().prepare(`INSERT OR IGNORE INTO beta_revision_files db().prepare(`INSERT OR IGNORE INTO beta_revision_files
(beta_id, revision, script_id, name, kind, path, version_id, version, release_version, sha256, content) (beta_id, revision, script_id, name, kind, path, version_id, version, release_version, sha256, content_hash)
SELECT bs.beta_id, ?, s.id, s.name, s.kind, s.target_rel_path, v.id, v.version, v.release_version, v.sha256, v.content SELECT bs.beta_id, ?, s.id, s.name, s.kind, s.target_rel_path, v.id, v.version, v.release_version, v.sha256, v.content_hash
FROM beta_scripts bs JOIN scripts s ON s.id = bs.script_id JOIN script_versions v ON v.id = bs.script_version_id FROM beta_scripts bs JOIN scripts s ON s.id = bs.script_id JOIN script_versions v ON v.id = bs.script_version_id
WHERE bs.beta_id = ?`).run(revision, id); WHERE bs.beta_id = ?`).run(revision, id);
} }
@@ -21,9 +21,10 @@ export function archiveBetaRevision(id: number, revision: number, startedAt: str
export function betaRevisionHistory(id: number): BetaRevision[] { export function betaRevisionHistory(id: number): BetaRevision[] {
const rows = db().prepare('SELECT revision, started_at, notes, results_json FROM beta_revisions WHERE beta_id = ? ORDER BY revision DESC') const rows = db().prepare('SELECT revision, started_at, notes, results_json FROM beta_revisions WHERE beta_id = ? ORDER BY revision DESC')
.all(id) as Array<{ revision: number; started_at: string; notes: string | null; results_json: string | null }>; .all(id) as Array<{ revision: number; started_at: string; notes: string | null; results_json: string | null }>;
const files = db().prepare(`SELECT revision, script_id, name, kind, path, version_id, version, release_version, sha256
FROM beta_revision_files WHERE beta_id = ? ORDER BY script_id`).all(id) as Array<BetaRevision['files'][number] & { revision: number }>;
return rows.map(({ results_json, ...row }) => ({ ...row, return rows.map(({ results_json, ...row }) => ({ ...row,
results: results_json ? JSON.parse(results_json) : null, results: results_json ? JSON.parse(results_json) : null,
files: db().prepare(`SELECT script_id, name, kind, path, version_id, version, release_version, sha256 files: files.filter(file => file.revision === row.revision).map(({ revision, ...file }) => file)
FROM beta_revision_files WHERE beta_id = ? AND revision = ? ORDER BY script_id`).all(id, row.revision) as BetaRevision['files']
})); }));
} }
+3 -2
View File
@@ -7,6 +7,7 @@ import type { GameTelemetry } from '../../telemetry.js';
import { applySample, applyGameEvent, emptyMetrics, evaluateRequirements, type TestMetrics } from './beta-evaluation.js'; import { applySample, applyGameEvent, emptyMetrics, evaluateRequirements, type TestMetrics } from './beta-evaluation.js';
import { ONLINE_MAX_MS } from '../../server-freshness.js'; import { ONLINE_MAX_MS } from '../../server-freshness.js';
import { currentBetaRevision } from './beta-revisions.js'; import { currentBetaRevision } from './beta-revisions.js';
import { invalidateBeta } from '../pipeline/bus.js';
export interface TestRun { id: string; beta_id: number; server: string; phase: number; created_at: string; observing_at: string | null; metrics: string } export interface TestRun { id: string; beta_id: number; server: string; phase: number; created_at: string; observing_at: string | null; metrics: string }
export interface StoredPlan { beta_id: number; plan: string; outcome: string; reason: string | null } export interface StoredPlan { beta_id: number; plan: string; outcome: string; reason: string | null }
@@ -15,7 +16,7 @@ const activeParticipant = (id: number, server: string) => !!db().prepare(`SELECT
WHERE beta_id = ? AND server = ? AND server_deleted_at IS NULL`).get(id, server); WHERE beta_id = ? AND server = ? AND server_deleted_at IS NULL`).get(id, server);
export const storedPlan = (id: number) => db().prepare('SELECT * FROM beta_test_plans WHERE beta_id = ?').get(id) as StoredPlan | undefined; export const storedPlan = (id: number) => db().prepare('SELECT * FROM beta_test_plans WHERE beta_id = ?').get(id) as StoredPlan | undefined;
export const currentRun = (id: number, server: string) => db().prepare('SELECT * FROM beta_test_runs WHERE beta_id = ? AND server = ? AND retired_at IS NULL').get(id, server) as TestRun | undefined; export const currentRun = (id: number, server: string) => db().prepare('SELECT * FROM beta_test_runs WHERE beta_id = ? AND server = ? AND retired_at IS NULL').get(id, server) as TestRun | undefined;
export function activity(id: number, message: string) { db().prepare('INSERT INTO beta_events (beta_id, message) VALUES (?, ?)').run(id, message); } export function activity(id: number, message: string) { db().prepare('INSERT INTO beta_events (beta_id, message) VALUES (?, ?)').run(id, message); invalidateBeta(id); }
export function setOutcome(id: number, outcome: string, reason: string | null): void { export function setOutcome(id: number, outcome: string, reason: string | null): void {
const previous = storedPlan(id); const previous = storedPlan(id);
if (!previous || (previous.outcome === outcome && previous.reason === reason)) return; if (!previous || (previous.outcome === outcome && previous.reason === reason)) return;
@@ -23,7 +24,7 @@ export function setOutcome(id: number, outcome: string, reason: string | null):
activity(id, `Test plan ${outcome}${reason ? `: ${reason}` : ''}`); activity(id, `Test plan ${outcome}${reason ? `: ${reason}` : ''}`);
} }
export function readMetrics(run: TestRun): TestMetrics { return { ...emptyMetrics(), ...JSON.parse(run.metrics) }; } export function readMetrics(run: TestRun): TestMetrics { return { ...emptyMetrics(), ...JSON.parse(run.metrics) }; }
export function saveMetrics(run: TestRun, metrics: TestMetrics): void { db().prepare('UPDATE beta_test_runs SET metrics = ? WHERE id = ?').run(JSON.stringify(metrics), run.id); } export function saveMetrics(run: TestRun, metrics: TestMetrics): void { db().prepare('UPDATE beta_test_runs SET metrics = ? WHERE id = ?').run(JSON.stringify(metrics), run.id); invalidateBeta(run.beta_id); }
export function evidence(run: TestRun, eventId: string, kind: string, payload: unknown): boolean { export function evidence(run: TestRun, eventId: string, kind: string, payload: unknown): boolean {
return db().prepare('INSERT OR IGNORE INTO beta_test_evidence (run_id, event_id, received_at, kind, payload) VALUES (?, ?, ?, ?, ?)') return db().prepare('INSERT OR IGNORE INTO beta_test_evidence (run_id, event_id, received_at, kind, payload) VALUES (?, ?, ?, ?, ?)')
.run(run.id, eventId, nowIso(), kind, JSON.stringify(payload)).changes > 0; .run(run.id, eventId, nowIso(), kind, JSON.stringify(payload)).changes > 0;
+20 -18
View File
@@ -1,3 +1,5 @@
import { putFileBlob, blobContent } from '../db/file-blobs.js';
import { getVersionContent } from './scripts.js';
import { parseBetaPlan, type BetaPlan } from '../../beta-plan.js'; import { parseBetaPlan, type BetaPlan } from '../../beta-plan.js';
import { storedPlan, testView, activity, setOutcome } from './beta-tests.js'; import { storedPlan, testView, activity, setOutcome } from './beta-tests.js';
import { archiveBetaRevision, currentBetaRevision } from './beta-revisions.js'; import { archiveBetaRevision, currentBetaRevision } from './beta-revisions.js';
@@ -89,7 +91,7 @@ function composeCfgForServer(
if (inputs.inBetaBaseline) { if (inputs.inBetaBaseline) {
const s = getScript(inputs.inBetaBaseline.script_id); const s = getScript(inputs.inBetaBaseline.script_id);
if (s && scopeAppliesToServer(s, { game: serverGame, map: serverMap })) { if (s && scopeAppliesToServer(s, { game: serverGame, map: serverMap })) {
baselineText = inputs.inBetaBaseline.content.toString('utf8'); baselineText = getVersionContent(inputs.inBetaBaseline.id).toString('utf8');
baselineScriptId = s.id; baselineScriptId = s.id;
} }
} }
@@ -99,7 +101,7 @@ function composeCfgForServer(
if (promoted?.current_version_id) { if (promoted?.current_version_id) {
const ver = getVersion(promoted.current_version_id); const ver = getVersion(promoted.current_version_id);
if (ver) { if (ver) {
baselineText = ver.content.toString('utf8'); baselineText = getVersionContent(ver.id).toString('utf8');
baselineScriptId = promoted.id; baselineScriptId = promoted.id;
} }
} }
@@ -122,7 +124,7 @@ function composeCfgForServer(
if (!s.current_version_id) continue; if (!s.current_version_id) continue;
const ver = getVersion(s.current_version_id); const ver = getVersion(s.current_version_id);
if (!ver) continue; if (!ver) continue;
promotedOverlays.push({ id: s.id, text: ver.content.toString('utf8') }); promotedOverlays.push({ id: s.id, text: getVersionContent(ver.id).toString('utf8') });
promotedIdsSeen.add(s.id); promotedIdsSeen.add(s.id);
} }
}; };
@@ -132,7 +134,7 @@ function composeCfgForServer(
// In-beta overlays scoped to this server. // In-beta overlays scoped to this server.
const inBetaScopedOverlays = inputs.inBetaOverlays const inBetaScopedOverlays = inputs.inBetaOverlays
.filter(({ script }) => scopeAppliesToServer(script, { game: serverGame, map: serverMap })) .filter(({ script }) => scopeAppliesToServer(script, { game: serverGame, map: serverMap }))
.map(({ script, version }) => ({ id: script.id, text: version.content.toString('utf8') })); .map(({ script, version }) => ({ id: script.id, text: getVersionContent(version.id).toString('utf8') }));
const composed = composeServerCfg(baselineText, [...promotedOverlays, ...inBetaScopedOverlays], serverGame, serverMap, getServer(serverName)?.map_slug); const composed = composeServerCfg(baselineText, [...promotedOverlays, ...inBetaScopedOverlays], serverGame, serverMap, getServer(serverName)?.map_slug);
return { text: composed, baselineScriptId }; return { text: composed, baselineScriptId };
@@ -604,7 +606,7 @@ function revisionFileChanges(betaId: number, name: string): Parameters<typeof qu
const server = getServer(name)!; const server = getServer(name)!;
const changes: Parameters<typeof queueFileChange>[0][] = resolved.filter(row => !isCollapsingKind(row.script.kind)) const changes: Parameters<typeof queueFileChange>[0][] = resolved.filter(row => !isCollapsingKind(row.script.kind))
.map(({ script, version }) => ({ sourceKind: 'beta', sourceId: String(betaId), server: name, kind: script.kind, .map(({ script, version }) => ({ sourceKind: 'beta', sourceId: String(betaId), server: name, kind: script.kind,
relPath: script.target_rel_path, content: version.content, scriptVersionId: version.id, inventorySource: `beta:${betaId}` })); relPath: script.target_rel_path, content: getVersionContent(version.id), scriptVersionId: version.id, inventorySource: `beta:${betaId}` }));
if (resolved.some(row => isCollapsingKind(row.script.kind))) { if (resolved.some(row => isCollapsingKind(row.script.kind))) {
const composed = composeCfgForServer(name, server.game, server.map, { const composed = composeCfgForServer(name, server.game, server.map, {
inBetaBaseline: resolved.find(row => row.script.kind === 'baseline')?.version, inBetaBaseline: resolved.find(row => row.script.kind === 'baseline')?.version,
@@ -639,7 +641,7 @@ async function deployBetaServersInternal(betaId: number, names: string[]): Promi
await mkdir(archive, { recursive: true }); await mkdir(archive, { recursive: true });
for (const r of nonCollapsing) { for (const r of nonCollapsing) {
const base = posix.basename(r.script.target_rel_path); const base = posix.basename(r.script.target_rel_path);
await writeFile(join(archive, base), r.version.content); await writeFile(join(archive, base), getVersionContent(r.version.id));
} }
// Persist the requested bytes without touching any live destination. Provisioning // Persist the requested bytes without touching any live destination. Provisioning
@@ -650,7 +652,7 @@ async function deployBetaServersInternal(betaId: number, names: string[]): Promi
try { try {
const changes: Parameters<typeof queueFileChange>[0][] = nonCollapsing.map(({ script, version }) => ({ const changes: Parameters<typeof queueFileChange>[0][] = nonCollapsing.map(({ script, version }) => ({
sourceKind: 'beta' as const, sourceId: String(betaId), server: name, sourceKind: 'beta' as const, sourceId: String(betaId), server: name,
kind: script.kind, relPath: script.target_rel_path, content: version.content, kind: script.kind, relPath: script.target_rel_path, content: getVersionContent(version.id),
scriptVersionId: version.id, inventorySource: `beta:${betaId}` scriptVersionId: version.id, inventorySource: `beta:${betaId}`
})); }));
if (collapsingBaseline || collapsingOverlays.length > 0) { if (collapsingBaseline || collapsingOverlays.length > 0) {
@@ -733,7 +735,7 @@ async function rollbackBetaInternal(betaId: number, options: { restart?: 'manual
const hasCollapsing = bundle.some((b) => isCollapsingKind(b.script.kind)); const hasCollapsing = bundle.some((b) => isCollapsingKind(b.script.kind));
const getSnapshot = db().prepare( const getSnapshot = db().prepare(
`SELECT prior_content, prior_sha256, prior_size FROM beta_file_snapshots `SELECT ${blobContent('prior_content_hash')} AS prior_content, prior_sha256, prior_size FROM beta_file_snapshots
WHERE beta_id = ? AND server = ? AND rel_path = ?` WHERE beta_id = ? AND server = ? AND rel_path = ?`
); );
@@ -796,10 +798,10 @@ async function rollbackBetaInternal(betaId: number, options: { restart?: 'manual
} }
for (const change of restorations) { for (const change of restorations) {
const file = db().prepare(`INSERT INTO deployment_files const file = db().prepare(`INSERT INTO deployment_files
(deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before, content_after) (deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before_hash, content_after_hash)
VALUES (?, ?, ?, ?, NULL, ?, NULL, ?)`).run(deploymentId, change.server, VALUES (?, ?, ?, ?, NULL, ?, NULL, ?)`).run(deploymentId, change.server,
change.content === null ? 'removed' : 'updated', change.relPath, change.content === null ? 'removed' : 'updated', change.relPath,
change.content === null ? null : sha256Buffer(change.content), change.content); change.content === null ? null : sha256Buffer(change.content), putFileBlob(change.content));
queueFileChange({ sourceKind: 'campaign', sourceId: deploymentId, server: change.server, queueFileChange({ sourceKind: 'campaign', sourceId: deploymentId, server: change.server,
kind: change.kind, relPath: change.relPath, content: change.content, kind: change.kind, relPath: change.relPath, content: change.content,
scriptVersionId: change.versionId, inventorySource: `rollback:${betaId}`, scriptVersionId: change.versionId, inventorySource: `rollback:${betaId}`,
@@ -968,7 +970,7 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
const plans: Plan[] = []; const plans: Plan[] = [];
const getSnapshotForPromote = db().prepare( const getSnapshotForPromote = db().prepare(
`SELECT prior_content, prior_sha256 FROM beta_file_snapshots `SELECT ${blobContent('prior_content_hash')} AS prior_content, prior_sha256 FROM beta_file_snapshots
WHERE beta_id = ? AND server = ? AND rel_path = ?` WHERE beta_id = ? AND server = ? AND rel_path = ?`
); );
@@ -990,7 +992,7 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
priorSha = snap.prior_sha256; priorSha = snap.prior_sha256;
} else if (bss.prior_version_id) { } else if (bss.prior_version_id) {
const pv = getVersion(bss.prior_version_id); const pv = getVersion(bss.prior_version_id);
if (pv) { priorContent = pv.content; priorSha = pv.sha256; } if (pv) { priorContent = getVersionContent(pv.id); priorSha = pv.sha256; }
} }
plan = { plan = {
script, version, server: name, script, version, server: name,
@@ -1095,7 +1097,7 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
); );
const insertDepFile = db().prepare( const insertDepFile = db().prepare(
`INSERT INTO deployment_files `INSERT INTO deployment_files
(deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before, content_after, snapshot_pending) (deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before_hash, content_after_hash, snapshot_pending)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
); );
@@ -1120,13 +1122,13 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
p.script.target_rel_path, p.script.target_rel_path,
p.priorSha, p.priorSha,
p.version.sha256, p.version.sha256,
p.priorContent, putFileBlob(p.priorContent),
p.version.content, putFileBlob(getVersionContent(p.version.id)),
betaServerSet.has(p.server) ? 0 : 1 betaServerSet.has(p.server) ? 0 : 1
); );
if (plansToActivate.has(p)) { if (plansToActivate.has(p)) {
queueFileChange({ sourceKind: 'campaign', sourceId: depId, server: p.server, queueFileChange({ sourceKind: 'campaign', sourceId: depId, server: p.server,
kind: p.script.kind, relPath: p.script.target_rel_path, content: p.version.content, kind: p.script.kind, relPath: p.script.target_rel_path, content: getVersionContent(p.version.id),
scriptVersionId: p.version.id, inventorySource: `promote:${depId}`, scriptVersionId: p.version.id, inventorySource: `promote:${depId}`,
deploymentFileId: Number(file.lastInsertRowid), preserveBeforeImage: betaServerSet.has(p.server) }); deploymentFileId: Number(file.lastInsertRowid), preserveBeforeImage: betaServerSet.has(p.server) });
} }
@@ -1140,8 +1142,8 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
configFileOf(cp.server), configFileOf(cp.server),
cp.priorSha, cp.priorSha,
cp.composedSha, cp.composedSha,
cp.priorContent, putFileBlob(cp.priorContent),
cp.composedContent, putFileBlob(cp.composedContent),
betaServerSet.has(cp.server) ? 0 : 1 betaServerSet.has(cp.server) ? 0 : 1
); );
if (cp.requiresActivation) { if (cp.requiresActivation) {
+8 -6
View File
@@ -1,3 +1,5 @@
import { putFileBlob } from '../db/file-blobs.js';
import { getVersionContent } from './scripts.js';
import { readdir, readFile, stat, nativeFileManifest, type FileFingerprint } from '../backends/files.js'; import { readdir, readFile, stat, nativeFileManifest, type FileFingerprint } from '../backends/files.js';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type { DriftPlan, DriftPlanFile } from '$lib/drift-plan.js'; import type { DriftPlan, DriftPlanFile } from '$lib/drift-plan.js';
@@ -275,7 +277,7 @@ async function resolveManagedEntry(server: string, entry: DriftEntry): Promise<B
} }
if (!entry.scriptVersionId) return null; if (!entry.scriptVersionId) return null;
const ver = getVersion(entry.scriptVersionId); const ver = getVersion(entry.scriptVersionId);
return ver?.content ?? null; return ver ? getVersionContent(ver.id) : null;
} }
function composeExpectedCfg( function composeExpectedCfg(
@@ -400,11 +402,11 @@ export function createDriftRebuildCampaign(
db().prepare("INSERT INTO deployments (id, action, started_at, triggered_by) VALUES (?, 'drift', ?, 'watchdog')") db().prepare("INSERT INTO deployments (id, action, started_at, triggered_by) VALUES (?, 'drift', ?, 'watchdog')")
.run(id, new Date().toISOString()); .run(id, new Date().toISOString());
const insertFile = db().prepare(`INSERT INTO deployment_files const insertFile = db().prepare(`INSERT INTO deployment_files
(deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before, content_after) (deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before_hash, content_after_hash)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`); VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
for (const { file, before, after } of prepared.files) { for (const { file, before, after } of prepared.files) {
insertFile.run(id, server, after === null ? 'removed' : before === null ? 'added' : 'updated', insertFile.run(id, server, after === null ? 'removed' : before === null ? 'added' : 'updated',
file.relPath, file.beforeSha256, file.afterSha256, before, after); file.relPath, file.beforeSha256, file.afterSha256, putFileBlob(before), putFileBlob(after));
} }
db().prepare(`INSERT INTO deployment_servers (deployment_id, server, files_changed, restart_state, provisioning_id) db().prepare(`INSERT INTO deployment_servers (deployment_id, server, files_changed, restart_state, provisioning_id)
VALUES (?, ?, ?, 'pending', ?)`).run(id, server, prepared.files.length, operationId); VALUES (?, ?, ?, 'pending', ?)`).run(id, server, prepared.files.length, operationId);
@@ -479,7 +481,7 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise<Run
); );
const insertFile = db().prepare( const insertFile = db().prepare(
`INSERT INTO deployment_files `INSERT INTO deployment_files
(deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before, content_after) (deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before_hash, content_after_hash)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)` VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
); );
@@ -512,7 +514,7 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise<Run
const content = await readFile(abs); const content = await readFile(abs);
if (hasActiveBeta(s.server)) throw new Error('active beta owns this server; reconciliation deferred'); if (hasActiveBeta(s.server)) throw new Error('active beta owns this server; reconciliation deferred');
db().transaction(() => { db().transaction(() => {
const file = insertFile.run(depId, s.server, 'removed', entry.relPath, sha256Buffer(content), null, content, null); const file = insertFile.run(depId, s.server, 'removed', entry.relPath, sha256Buffer(content), null, putFileBlob(content), null);
queueFileChange({ sourceKind: 'campaign', sourceId: depId, server: s.server, queueFileChange({ sourceKind: 'campaign', sourceId: depId, server: s.server,
kind: entry.kind, relPath: entry.relPath, content: null, kind: entry.kind, relPath: entry.relPath, content: null,
inventorySource: `drift:${depId}`, deploymentFileId: Number(file.lastInsertRowid), inventorySource: `drift:${depId}`, deploymentFileId: Number(file.lastInsertRowid),
@@ -537,7 +539,7 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise<Run
if (hasActiveBeta(s.server)) throw new Error('active beta owns this server; reconciliation deferred'); if (hasActiveBeta(s.server)) throw new Error('active beta owns this server; reconciliation deferred');
db().transaction(() => { db().transaction(() => {
const file = insertFile.run(depId, s.server, before ? 'updated' : 'added', entry.relPath, const file = insertFile.run(depId, s.server, before ? 'updated' : 'added', entry.relPath,
before ? sha256Buffer(before) : null, sha256Buffer(written), before, written); before ? sha256Buffer(before) : null, sha256Buffer(written), putFileBlob(before), putFileBlob(written));
queueFileChange({ sourceKind: 'campaign', sourceId: depId, server: s.server, queueFileChange({ sourceKind: 'campaign', sourceId: depId, server: s.server,
kind: entry.kind, relPath: entry.relPath, content: written, kind: entry.kind, relPath: entry.relPath, content: written,
scriptVersionId: entry.kind === 'config' ? null : entry.scriptVersionId ?? null, scriptVersionId: entry.kind === 'config' ? null : entry.scriptVersionId ?? null,
+41 -37
View File
@@ -8,6 +8,7 @@ import { rconStatusResult, agentRconStatusResult, type AgentRconReply } from '..
import { withOperationSource } from '../hosts/operation-context.js'; import { withOperationSource } from '../hosts/operation-context.js';
import { getServer } from './servers.js'; import { getServer } from './servers.js';
import { latestGameSubmission } from './lifecycle-evidence.js'; import { latestGameSubmission } from './lifecycle-evidence.js';
import type { MonitoringSnapshot } from './monitoring-snapshot.js';
import { ONLINE_MAX_MS } from '../../server-freshness.js'; import { ONLINE_MAX_MS } from '../../server-freshness.js';
const MAX_AGE_MS = 30_000; const MAX_AGE_MS = 30_000;
@@ -25,19 +26,20 @@ interface Entry<T> {
export class MonitoringChangedError extends Error {} export class MonitoringChangedError extends Error {}
export class MonitoringSkippedError extends Error {} export class MonitoringSkippedError extends Error {}
function identity(server: string, probe: Probe): Identity { function identity(server: string, probe: Probe, snapshot?: MonitoringSnapshot): Identity {
const row = getServer(server); const row = snapshot ? snapshot.get(server)?.server : getServer(server);
if (!row || row.destroyed_at) throw new MonitoringChangedError('Server is no longer registered; monitoring evidence is unavailable.'); if (!row || row.destroyed_at) throw new MonitoringChangedError('Server is no longer registered; monitoring evidence is unavailable.');
const native = row.native_instance_id ? nativeInstance(server) : null; const native = snapshot ? snapshot.get(server)?.native : row.native_instance_id ? nativeInstance(server) : null;
if (row.native_instance_id && (!native || getHost(native.host_id)?.connectionState !== 'online')) { if (row.native_instance_id && (!native || (snapshot ? snapshot.get(server)?.host : getHost(native.host_id))?.connectionState !== 'online')) {
throw new Error('Host agent is offline; monitoring evidence is unavailable.'); throw new Error('Host agent is offline; monitoring evidence is unavailable.');
} }
// Stop requests also invalidate observations, even though they do not // Stop requests also invalidate observations, even though they do not
// establish a new game-telemetry epoch in latestGameSubmission(). // establish a new game-telemetry epoch in latestGameSubmission().
const lifecycle = db().prepare(`SELECT id, action, submitted_at FROM server_deployments const savedLifecycle = snapshot?.get(server)?.lifecycle;
const lifecycle = snapshot ? savedLifecycle ? { id: savedLifecycle.id, action: savedLifecycle.action, submitted_at: savedLifecycle.submitted_at } : null : db().prepare(`SELECT id, action, submitted_at FROM server_deployments
WHERE server=? AND server_deleted_at IS NULL ORDER BY rowid DESC LIMIT 1`).get(server) ?? null; WHERE server=? AND server_deleted_at IS NULL ORDER BY rowid DESC LIMIT 1`).get(server) ?? null;
return { return {
key: JSON.stringify([row.created_at, row.desired_state, latestGameSubmission(server), lifecycle, key: JSON.stringify([row.created_at, row.desired_state, snapshot ? snapshot.get(server)?.submission : latestGameSubmission(server), lifecycle,
native ? [native.host_id, native.id, native.generation] : null, native ? [native.host_id, native.id, native.generation] : null,
probe === 'rcon' ? [row.rcon_host, config.rcon] : null]), probe === 'rcon' ? [row.rcon_host, config.rcon] : null]),
port: probe === 'rcon' ? native?.port ?? row.game_port : null, port: probe === 'rcon' ? native?.port ?? row.game_port : null,
@@ -57,9 +59,9 @@ function reusable(startedAt: number): boolean {
} }
async function observe<T extends { ok: boolean }>(server: string, fresh: boolean, probe: Probe, async function observe<T extends { ok: boolean }>(server: string, fresh: boolean, probe: Probe,
entries: Map<string, Entry<T>>, request: (server: string) => Promise<T>): Promise<Observation<T>> { entries: Map<string, Entry<T>>, request: (server: string) => Promise<T>, snapshot?: MonitoringSnapshot, batch = false): Promise<Observation<T>> {
let current: Identity; let current: Identity;
try { current = identity(server, probe); } try { current = identity(server, probe, snapshot); }
catch (error) { entries.delete(server); throw error; } catch (error) { entries.delete(server); throw error; }
const saved = entries.get(server); const saved = entries.get(server);
const savedAt = saved?.observation ? Date.parse(saved.observation.observedAt) : saved?.startedAt ?? 0; const savedAt = saved?.observation ? Date.parse(saved.observation.observedAt) : saved?.startedAt ?? 0;
@@ -75,7 +77,8 @@ async function observe<T extends { ok: boolean }>(server: string, fresh: boolean
entries.set(server, entry); entries.set(server, entry);
while (entries.size > MAX_SERVERS) entries.delete(entries.keys().next().value!); while (entries.size > MAX_SERVERS) entries.delete(entries.keys().next().value!);
const pending = Promise.resolve().then(() => request(server)).then(result => { const pending = Promise.resolve().then(() => request(server)).then(result => {
const latest = identity(server, probe); if (!batch) snapshot?.refresh(server);
const latest = identity(server, probe, snapshot);
if (!sameIdentity(current, latest) || newerEvidence(latest, entry.startedAt)) { if (!sameIdentity(current, latest) || newerEvidence(latest, entry.startedAt)) {
throw new MonitoringChangedError('Server changed during monitoring; the previous observation was discarded.'); throw new MonitoringChangedError('Server changed during monitoring; the previous observation was discarded.');
} }
@@ -105,17 +108,17 @@ interface MonitoringStatus {
// this snapshot must never dispatch a request or renew an observation's age. // this snapshot must never dispatch a request or renew an observation's age.
const statuses = new Map<string, { snapshot?: MonitoringStatus }>(); const statuses = new Map<string, { snapshot?: MonitoringStatus }>();
export function monitoringIdentity(server: string): string { export function monitoringIdentity(server: string, snapshot?: MonitoringSnapshot): string {
const container = identity(server, 'inspect'); const container = identity(server, 'inspect', snapshot);
const game = identity(server, 'rcon'); const game = identity(server, 'rcon', snapshot);
// Identity includes connection configuration; persist only its fingerprint. // Identity includes connection configuration; persist only its fingerprint.
return createHash('sha256').update(JSON.stringify([container.key, game.key, game.port])).digest('hex'); return createHash('sha256').update(JSON.stringify([container.key, game.key, game.port])).digest('hex');
} }
/** Capture ownership before a status pass; publish only evidence for that instance. */ /** Capture ownership before a status pass; publish only evidence for that instance. */
export function monitoringStatusWriter(server: string) { export function monitoringStatusWriter(server: string, snapshot?: MonitoringSnapshot) {
let before: string; let before: string;
try { before = identity(server, 'inspect').key; } try { before = identity(server, 'inspect', snapshot).key; }
catch { return () => {}; } catch { return () => {}; }
const slot = { snapshot: statuses.get(server)?.snapshot }; const slot = { snapshot: statuses.get(server)?.snapshot };
statuses.delete(server); statuses.delete(server);
@@ -126,16 +129,16 @@ export function monitoringStatusWriter(server: string) {
// network requests are still in flight. // network requests are still in flight.
if (statuses.get(server) !== slot) return; if (statuses.get(server) !== slot) return;
try { try {
if (identity(server, 'inspect').key !== before) return; if (identity(server, 'inspect', snapshot).key !== before) return;
const current = slot.snapshot; const current = slot.snapshot;
if (current && current.container.observedAt > container.observedAt) return; if (current && current.container.observedAt > container.observedAt) return;
slot.snapshot = { identity: monitoringIdentity(server), container, game }; slot.snapshot = { identity: monitoringIdentity(server, snapshot), container, game };
} catch { statuses.delete(server); } } catch { statuses.delete(server); }
}; };
} }
/** Latest completed status-monitor pass, with stale or superseded evidence excluded. */ /** Latest completed status-monitor pass, with stale or superseded evidence excluded. */
export function monitoringStatus(server: string): MonitoringStatus | null { export function monitoringStatus(server: string, snapshot?: MonitoringSnapshot): MonitoringStatus | null {
const saved = statuses.get(server)?.snapshot; const saved = statuses.get(server)?.snapshot;
if (!saved) return null; if (!saved) return null;
const fresh = (at: string) => { const fresh = (at: string) => {
@@ -143,28 +146,28 @@ export function monitoringStatus(server: string): MonitoringStatus | null {
return age >= 0 && age <= ONLINE_MAX_MS; return age >= 0 && age <= ONLINE_MAX_MS;
}; };
try { try {
if (saved.identity !== monitoringIdentity(server) if (saved.identity !== monitoringIdentity(server, snapshot)
|| !fresh(saved.container.observedAt) || !fresh(saved.container.observedAt)
|| newerEvidence(identity(server, 'inspect'), Date.parse(saved.container.observedAt))) return null; || newerEvidence(identity(server, 'inspect', snapshot), Date.parse(saved.container.observedAt))) return null;
const game = saved.game && fresh(saved.game.observedAt) const game = saved.game && fresh(saved.game.observedAt)
&& !newerEvidence(identity(server, 'rcon'), Date.parse(saved.game.observedAt)) ? saved.game : undefined; && !newerEvidence(identity(server, 'rcon', snapshot), Date.parse(saved.game.observedAt)) ? saved.game : undefined;
return { ...saved, game }; return { ...saved, game };
} catch { return null; } } catch { return null; }
} }
/** Keep the actual sample time: a later heartbeat must not renew old evidence. */ /** Keep the actual sample time: a later heartbeat must not renew old evidence. */
export function heartbeatInspection(server: string): Observation<Awaited<ReturnType<typeof nativeHealth>>> | null { export function heartbeatInspection(server: string, snapshot?: MonitoringSnapshot): Observation<Awaited<ReturnType<typeof nativeHealth>>> | null {
const row = getServer(server); const row = snapshot ? snapshot.get(server)?.server : getServer(server);
if (!row?.native_instance_id || !row.host_id) return null; if (!row?.native_instance_id || !row.host_id) return null;
const host = getHost(row.host_id); const host = snapshot ? snapshot.get(server)?.host : getHost(row.host_id);
if (!host?.connected || !host.inventory.capabilities?.includes('instance.heartbeat-state')) return null; if (!host?.connected || !host.inventory.capabilities?.includes('instance.heartbeat-state')) return null;
const state = host.inventory.instanceStates?.find(state => state.instanceId === row.native_instance_id && state.generation === row.instance_generation); const state = host.inventory.instanceStates?.find(state => state.instanceId === row.native_instance_id && state.generation === row.instance_generation);
if (!state) return null; if (!state) return null;
const at = Date.parse(state.observedAt); const at = Date.parse(state.observedAt);
const lifecycle = db().prepare(`SELECT MAX(MAX(submitted_at,COALESCE(finished_at,submitted_at))) AS at const lifecycle = snapshot ? { at: snapshot.get(server)?.lifecycleAt } : db().prepare(`SELECT MAX(MAX(submitted_at,COALESCE(finished_at,submitted_at))) AS at
FROM server_deployments WHERE server=? AND server_deleted_at IS NULL`).get(server) as { at: string | null }; FROM server_deployments WHERE server=? AND server_deleted_at IS NULL`).get(server) as { at: string | null };
if (!reusable(at) || at < Date.parse(row.created_at) || at < Date.parse(lifecycle.at ?? '') if (!reusable(at) || at < Date.parse(row.created_at) || at < Date.parse(lifecycle.at ?? '')
|| at < Date.parse(latestGameSubmission(server) ?? '') || at < Date.parse(row.container_checked_at ?? '')) return null; || at < Date.parse((snapshot ? snapshot.get(server)?.submission : latestGameSubmission(server)) ?? '') || at < Date.parse(row.container_checked_at ?? '')) return null;
return { observedAt: new Date(at).toISOString(), result: state.healthy return { observedAt: new Date(at).toISOString(), result: state.healthy
? { ok: true, state: state.state } : { ok: false, state: state.state, reason: `Container ${state.state}` } }; ? { ok: true, state: state.state } : { ok: false, state: state.state, reason: `Container ${state.state}` } };
} }
@@ -172,17 +175,17 @@ export function heartbeatInspection(server: string): Observation<Awaited<ReturnT
type RconObservation = Observation<Awaited<ReturnType<typeof rconStatusResult>>>; type RconObservation = Observation<Awaited<ReturnType<typeof rconStatusResult>>>;
/** Prime a whole host before the per-host queue admits just its first two servers. */ /** Prime a whole host before the per-host queue admits just its first two servers. */
export function prepareMonitoringRcon(servers: string[]): Map<string, () => Promise<RconObservation>> { export function prepareMonitoringRcon(servers: string[], snapshot?: MonitoringSnapshot): Map<string, () => Promise<RconObservation>> {
const probes = new Map<string, () => Promise<RconObservation>>(); const probes = new Map<string, () => Promise<RconObservation>>();
const batches = new Map<string, { instances: NativeInstance[]; result: Promise<{ results: Array<{ instanceId: string; generation: number; reply: AgentRconReply }> }> }>(); const batches = new Map<string, { instances: NativeInstance[]; result: Promise<{ results: Array<{ instanceId: string; generation: number; reply: AgentRconReply }> }> }>();
for (const server of servers) { for (const server of servers) {
const instance = nativeInstance(server); const instance = snapshot ? snapshot.get(server)?.native : nativeInstance(server);
if (!instance) continue; if (!instance) continue;
const host = getHost(instance.host_id); const host = snapshot ? snapshot.get(server)?.host : getHost(instance.host_id);
if (!host?.connected || !host.inventory.capabilities?.includes('rcon.status-batch')) continue; if (!host?.connected || !host.inventory.capabilities?.includes('rcon.status-batch')) continue;
const container = heartbeatInspection(server); const container = heartbeatInspection(server, snapshot);
if (container && !container.result.ok && container.result.state !== 'unknown') continue; if (container && !container.result.ok && container.result.state !== 'unknown') continue;
const before = identity(server, 'rcon'); const before = identity(server, 'rcon', snapshot);
const request = async () => { const request = async () => {
let batch = batches.get(instance.host_id); let batch = batches.get(instance.host_id);
if (!batch) { if (!batch) {
@@ -195,6 +198,7 @@ export function prepareMonitoringRcon(servers: string[]): Map<string, () => Prom
})); }));
// Allow delivery and receipt overhead beyond the agent's 20-second budget. // Allow delivery and receipt overhead beyond the agent's 20-second budget.
const receipt = await waitHostOperationStatus(operation.id, 25_000); const receipt = await waitHostOperationStatus(operation.id, 25_000);
snapshot?.refresh();
if (receipt.state !== 'completed') throw new Error(receipt.error ?? 'Host status batch did not complete.'); if (receipt.state !== 'completed') throw new Error(receipt.error ?? 'Host status batch did not complete.');
return receipt.result as { results: Array<{ instanceId: string; generation: number; reply: AgentRconReply }> }; return receipt.result as { results: Array<{ instanceId: string; generation: number; reply: AgentRconReply }> };
}); });
@@ -216,11 +220,11 @@ export function prepareMonitoringRcon(servers: string[]): Map<string, () => Prom
// Observe before dispatch, including servers still waiting for their // Observe before dispatch, including servers still waiting for their
// queue slot. Consume failures too, so a failed batch is never retried // queue slot. Consume failures too, so a failed batch is never retried
// once per server or left as an unhandled rejected promise. // once per server or left as an unhandled rejected promise.
const pending = observe(server, false, 'rcon', rcon, request).then(value => ({ value }), error => ({ error })); const pending = observe(server, false, 'rcon', rcon, request, snapshot, true).then(value => ({ value }), error => ({ error }));
probes.set(server, async () => { probes.set(server, async () => {
const result = await pending; const result = await pending;
if ('error' in result) throw result.error; if ('error' in result) throw result.error;
const current = identity(server, 'rcon'); const current = identity(server, 'rcon', snapshot);
if (!sameIdentity(before, current) || newerEvidence(current, Date.parse(result.value.observedAt))) { if (!sameIdentity(before, current) || newerEvidence(current, Date.parse(result.value.observedAt))) {
throw new MonitoringChangedError('Server changed before consuming the host status batch.'); throw new MonitoringChangedError('Server changed before consuming the host status batch.');
} }
@@ -231,13 +235,13 @@ export function prepareMonitoringRcon(servers: string[]): Map<string, () => Prom
} }
/** Routine monitoring only. Manual diagnostics and disruption gates use fresh probes directly. */ /** Routine monitoring only. Manual diagnostics and disruption gates use fresh probes directly. */
export function inspectForMonitoring(server: string, fresh = false) { export function inspectForMonitoring(server: string, fresh = false, snapshot?: MonitoringSnapshot) {
if (fresh) statuses.delete(server); if (fresh) statuses.delete(server);
const heartbeat = !fresh && heartbeatInspection(server); const heartbeat = !fresh && heartbeatInspection(server, snapshot);
if (heartbeat) return Promise.resolve(heartbeat); if (heartbeat) return Promise.resolve(heartbeat);
return observe(server, fresh, 'inspect', inspections, nativeHealth); return observe(server, fresh, 'inspect', inspections, nativeHealth, snapshot);
} }
export function rconForMonitoring(server: string, fresh = false) { export function rconForMonitoring(server: string, fresh = false, snapshot?: MonitoringSnapshot) {
if (fresh) statuses.delete(server); if (fresh) statuses.delete(server);
return observe(server, fresh, 'rcon', rcon, rconStatusResult); return observe(server, fresh, 'rcon', rcon, rconStatusResult, snapshot);
} }
+3 -2
View File
@@ -1,3 +1,4 @@
import { packFileJson, unpackFileJson } from '../db/file-blobs.js';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type { BlueprintSummary, LaunchRecipeSummary, LaunchFileSummary } from '$lib/deployment-plan.js'; import type { BlueprintSummary, LaunchRecipeSummary, LaunchFileSummary } from '$lib/deployment-plan.js';
import { config, configFileForGame } from '../config.js'; import { config, configFileForGame } from '../config.js';
@@ -118,7 +119,7 @@ export function summarizeRecipe(recipe: LaunchRecipePlan, fingerprint = sha256Bu
/** Keep the small detail response beside the immutable attempted file bytes. */ /** Keep the small detail response beside the immutable attempted file bytes. */
export function recipeStorage(recipe: LaunchRecipePlan): { recipe: string; recipeSummary: string } { export function recipeStorage(recipe: LaunchRecipePlan): { recipe: string; recipeSummary: string } {
const encoded = JSON.stringify(recipe); const encoded = JSON.stringify(recipe);
return { recipe: encoded, recipeSummary: JSON.stringify(summarizeRecipe(recipe, sha256Buffer(Buffer.from(encoded)))) }; return { recipe: JSON.stringify(packFileJson(recipe).value), recipeSummary: JSON.stringify(summarizeRecipe(recipe, sha256Buffer(Buffer.from(encoded)))) };
} }
export function recipeSummaryForDeployment(id: string): { recipe: LaunchRecipeSummary; blueprintName: string | null } | null { export function recipeSummaryForDeployment(id: string): { recipe: LaunchRecipeSummary; blueprintName: string | null } | null {
@@ -137,7 +138,7 @@ export function recipeSummaryForDeployment(id: string): { recipe: LaunchRecipeSu
export function recipeForDeployment(id: string): { recipe: LaunchRecipePlan; blueprintName: string | null } | null { export function recipeForDeployment(id: string): { recipe: LaunchRecipePlan; blueprintName: string | null } | null {
const row = db().prepare('SELECT recipe, blueprint_name FROM server_launch_recipes WHERE deployment_id = ?') const row = db().prepare('SELECT recipe, blueprint_name FROM server_launch_recipes WHERE deployment_id = ?')
.get(id) as { recipe: string; blueprint_name: string | null } | undefined; .get(id) as { recipe: string; blueprint_name: string | null } | undefined;
return row ? { recipe: JSON.parse(row.recipe), blueprintName: row.blueprint_name } : null; return row ? { recipe: unpackFileJson(JSON.parse(row.recipe)), blueprintName: row.blueprint_name } : null;
} }
export function blueprintRecipe(id: string): { recipe: LaunchRecipePlan; blueprintName: string } { export function blueprintRecipe(id: string): { recipe: LaunchRecipePlan; blueprintName: string } {
+46
View File
@@ -0,0 +1,46 @@
import { db } from '../db/index.js';
import type { ArtifactKind, ScriptRow, ScriptVersionRow } from './scripts.js';
/** Fixed query count regardless of the number of artifacts or retained versions. */
export function scriptCatalog(kind?: ArtifactKind) {
const version = (alias: string) => `CASE WHEN ${alias}.id IS NOT NULL THEN json_object(
'id',${alias}.id,'version',${alias}.version,'release_version',${alias}.release_version,
'sha256',${alias}.sha256,'size',${alias}.size,'state',${alias}.state,'uploaded_at',${alias}.uploaded_at) END`;
const rows = db().prepare(`SELECT s.*, ${version('c')} current_json, ${version('v')} latest_json,
COALESCE(counts.n,0) versionCount, active.beta_id runningBetaId
FROM scripts s
LEFT JOIN (SELECT script_id, COUNT(*) n, MAX(version) latest FROM script_versions GROUP BY script_id) counts ON counts.script_id=s.id
LEFT JOIN script_versions v ON v.script_id=s.id AND v.version=counts.latest
LEFT JOIN script_versions c ON c.id=s.current_version_id
LEFT JOIN (SELECT bs.script_id, MIN(b.id) beta_id FROM beta_scripts bs JOIN betas b ON b.id=bs.beta_id
WHERE b.state IN ('running','ready-promote') GROUP BY bs.script_id) active ON active.script_id=s.id
${kind ? 'WHERE s.kind=?' : ''} ORDER BY s.name`).all(...(kind ? [kind] : [])) as Array<ScriptRow & {
current_json: string | null; latest_json: string | null; versionCount: number; runningBetaId: number | null;
}>;
const servers = db().prepare('SELECT name, game, map FROM servers ORDER BY name').all() as { name: string; game: string | null; map: string | null }[];
return rows.map(({ current_json, latest_json, ...row }) => ({ ...row,
currentVersion: current_json ? JSON.parse(current_json) as ScriptVersionRow : null,
latestVersion: latest_json ? JSON.parse(latest_json) as ScriptVersionRow : null,
servers: row.scope_type && row.scope_value ? servers.filter(server => server[row.scope_type!] === row.scope_value).map(server => server.name) : []
}));
}
export function betaScriptMetadata(ids: number[]) {
const rows = db().prepare(`SELECT bs.beta_id, bs.script_id id, COALESCE(s.name,'(deleted)') name,
COALESCE(s.target_rel_path,'') target_rel_path, COALESCE(s.kind,bs.kind,'script') kind,
v.id version_id, v.version, v.release_version, v.sha256
FROM beta_scripts bs LEFT JOIN scripts s ON s.id=bs.script_id
LEFT JOIN script_versions v ON v.id=bs.script_version_id
WHERE bs.beta_id IN (SELECT value FROM json_each(?)) ORDER BY bs.script_id`).all(JSON.stringify(ids)) as Array<{
beta_id: number; id: number; name: string; target_rel_path: string; kind: ArtifactKind;
version_id: number | null; version: number; release_version: string | null; sha256: string;
}>;
const grouped = new Map<number, Array<{ id: number; name: string; target_rel_path: string; kind: ArtifactKind;
version: { id: number; version: number; release_version: string | null; sha256: string } | null }>>();
for (const { beta_id, version_id, version, release_version, sha256, ...script } of rows) {
const group = grouped.get(beta_id) ?? [];
group.push({ ...script, version: version_id === null ? null : { id: version_id, version, release_version, sha256 } });
grouped.set(beta_id, group);
}
return grouped;
}
@@ -0,0 +1,91 @@
import { db } from '../db/index.js';
import { ensureHostSchema } from '../hosts/schema.js';
import { HEARTBEAT_MAX_AGE_MS } from '../hosts/index.js';
import { parseHostInventory } from '../hosts/request-context.js';
import type { Host } from '../../hosts.js';
import type { NativeInstance } from '../backends/native.js';
import type { ServerRow } from './servers.js';
import { parseGameEvent, type GameTelemetry } from '../../telemetry.js';
import { ONLINE_MAX_MS } from '../../server-freshness.js';
import { serverObservation } from './status.js';
const nativeFields = ['id','server','host_id','generation','library_id','image','bind_address','port','public_address','public_port','created_at','retired_at'];
const sql = `SELECT s.*, n.id AS native_instance_id,n.host_id,n.generation AS instance_generation,n.library_id AS game_library_id,
CASE WHEN n.id IS NOT NULL THEN json_object(${nativeFields.map(key => `'${key}',n.${key}`).join(',')}) END AS instance_json,
h.inventory AS host_inventory,h.last_heartbeat AS host_heartbeat,
EXISTS(SELECT 1 FROM ec_host_credentials c WHERE c.host_id=h.id AND c.revoked_at IS NULL) AS credential_active,
(SELECT json_object('id',d.id,'action',d.action,'state',d.state,'submitted_at',d.submitted_at)
FROM server_deployments d WHERE d.server=s.name AND d.server_deleted_at IS NULL ORDER BY d.rowid DESC LIMIT 1) AS lifecycle_json,
(SELECT MAX(MAX(d.submitted_at,COALESCE(d.finished_at,d.submitted_at))) FROM server_deployments d
WHERE d.server=s.name AND d.server_deleted_at IS NULL) AS lifecycle_at,
(SELECT MAX(at) FROM (SELECT MAX(d.submitted_at) AS at FROM server_deployments d
WHERE d.server=s.name AND d.server_deleted_at IS NULL AND d.action IN ('create','start','restart','rebuild')
UNION ALL SELECT r.requested_at)) AS submission,
r.requested_at AS restart_at,r.healthy_at AS restart_healthy,t.received_at AS telemetry_at,t.payload AS telemetry_json
FROM servers s LEFT JOIN native_instances n ON n.server=s.name AND n.retired_at IS NULL
LEFT JOIN ec_hosts h ON h.id=n.host_id AND h.deleted_at IS NULL
LEFT JOIN restart_rollout r ON r.server=s.name LEFT JOIN server_telemetry t ON t.server=s.name`;
interface Row extends ServerRow {
instance_json: string | null; host_inventory: string | null; host_heartbeat: string | null; credential_active: number;
lifecycle_json: string | null; lifecycle_at: string | null; submission: string | null;
restart_at: string | null; restart_healthy: string | null; telemetry_at: string | null; telemetry_json: string | null;
}
interface Record {
server: ServerRow; native: NativeInstance | null;
host: Pick<Host, 'connected' | 'connectionState' | 'inventory'> | null;
lifecycle: { id: string; action: string; state: string; submitted_at: string | null } | null;
lifecycleAt: string | null; submission: string | null; telemetry: GameTelemetry | null;
restartAt: string | null; restartHealthy: string | null;
}
/** A pass shares its reads; each asynchronous evidence boundary refreshes in one joined query. */
export class MonitoringSnapshot {
private records = new Map<string, Record>();
constructor() { ensureHostSchema(db()); this.refresh(); }
refresh(server?: string): void {
const rows = server === undefined ? db().prepare(sql + ' WHERE s.destroyed_at IS NULL ORDER BY s.name').all() as Row[]
: db().prepare(sql + ' WHERE s.name=? AND s.destroyed_at IS NULL').all(server) as Row[];
if (server === undefined) this.records.clear(); else this.records.delete(server);
for (const row of rows) {
const { instance_json,host_inventory,host_heartbeat,credential_active,lifecycle_json,lifecycle_at,submission,
restart_at,restart_healthy,telemetry_at,telemetry_json,...fields } = row;
const connected = !!credential_active && !!host_heartbeat && Date.now() - Date.parse(host_heartbeat) <= HEARTBEAT_MAX_AGE_MS;
let telemetry: GameTelemetry | null = null;
if (telemetry_json) try {
const saved = JSON.parse(telemetry_json) as GameTelemetry;
const event = parseGameEvent(saved);
if (event.server === row.name) telemetry = { ...event, receivedAt: telemetry_at!, timing: saved.timing };
} catch { /* Invalid telemetry cannot establish health. */ }
this.records.set(row.name, { server: fields, native: instance_json ? JSON.parse(instance_json) : null,
host: host_inventory ? { connected, connectionState: connected ? 'online' : 'offline',
inventory: parseHostInventory(row.host_id!, host_inventory) } : null,
lifecycle: lifecycle_json ? JSON.parse(lifecycle_json) : null, lifecycleAt: lifecycle_at, submission, telemetry,
restartAt: restart_at, restartHealthy: restart_healthy });
}
}
get(server: string) { return this.records.get(server); }
servers() { return [...this.records.values()].map(record => record.server); }
provisioning(server: string) {
const operation = this.get(server)?.lifecycle;
return !!operation && ['create','rebuild','start'].includes(operation.action) && ['queued','running','awaiting-health'].includes(operation.state);
}
settling(server: string) {
const record = this.get(server);
return !!record?.restartAt && !record.restartHealthy && Date.now() - Date.parse(record.restartAt) < 15_000;
}
telemetryEvidence(server: string) {
const record = this.get(server), report = record?.telemetry;
if (!report) return null;
const received = Date.parse(report.receivedAt), age = Date.now() - received;
return Number.isFinite(age) && age >= 0 && age <= ONLINE_MAX_MS
&& (!record?.submission || received > Date.parse(record.submission)) ? report : null;
}
observe = (server: ServerRow) => serverObservation(server, {
provisioning: this.provisioning(server.name), settling: this.settling(server.name),
submission: this.get(server.name)?.submission ?? null, telemetry: this.get(server.name)?.telemetry ?? null
});
patch(server: string, fields: Partial<ServerRow>) {
const record = this.get(server);
if (record) record.server = { ...record.server, ...fields };
}
}
+5 -2
View File
@@ -1,4 +1,4 @@
import { getVersion, getVersionMetadata, listScripts } from './scripts.js'; import { getVersion, getVersionContent, getVersionMetadata, listScripts } from './scripts.js';
function selectedArtifacts<Version>(game: string | null, map: string | null, readVersion: (id: number) => Version | undefined) { function selectedArtifacts<Version>(game: string | null, map: string | null, readVersion: (id: number) => Version | undefined) {
return (['script', 'plugin'] as const).flatMap((kind) => return (['script', 'plugin'] as const).flatMap((kind) =>
@@ -14,7 +14,10 @@ function selectedArtifacts<Version>(game: string | null, map: string | null, rea
/** Published file artifacts shared by provisioning and drift reconciliation. */ /** Published file artifacts shared by provisioning and drift reconciliation. */
export function promotedArtifacts(game: string | null, map: string | null) { export function promotedArtifacts(game: string | null, map: string | null) {
return selectedArtifacts(game, map, getVersion); return selectedArtifacts(game, map, id => {
const version = getVersion(id);
return version ? { ...version, content: getVersionContent(id) } : undefined;
});
} }
/** Metadata uses the same promotion and scope rules without reading file content. */ /** Metadata uses the same promotion and scope rules without reading file content. */
+5 -4
View File
@@ -1,3 +1,4 @@
import { putFileBlob, blobContent } from '../db/file-blobs.js';
import type { ArtifactKind } from '../config.js'; import type { ArtifactKind } from '../config.js';
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import { emit } from '../pipeline/bus.js'; import { emit } from '../pipeline/bus.js';
@@ -101,7 +102,7 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
const fileRows = db() const fileRows = db()
.prepare( .prepare(
`SELECT f.id, f.server, f.verb, f.rel_path, f.sha256_before, f.sha256_after, f.content_before, f.content_after, f.snapshot_pending `SELECT f.id, f.server, f.verb, f.rel_path, f.sha256_before, f.sha256_after, ${blobContent('f.content_before_hash')} AS content_before, ${blobContent('f.content_after_hash')} AS content_after, f.snapshot_pending
FROM deployment_files f JOIN deployment_servers ds ON ds.deployment_id = f.deployment_id AND ds.server = f.server FROM deployment_files f JOIN deployment_servers ds ON ds.deployment_id = f.deployment_id AND ds.server = f.server
JOIN servers s ON s.name = f.server JOIN servers s ON s.name = f.server
WHERE f.deployment_id = ? AND ds.server_deleted_at IS NULL AND s.destroyed_at IS NULL ORDER BY f.server, f.rel_path` WHERE f.deployment_id = ? AND ds.server_deleted_at IS NULL AND s.destroyed_at IS NULL ORDER BY f.server, f.rel_path`
@@ -184,7 +185,7 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
); );
const insertDepFile = db().prepare( const insertDepFile = db().prepare(
`INSERT INTO deployment_files `INSERT INTO deployment_files
(deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before, content_after) (deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before_hash, content_after_hash)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)` VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
); );
@@ -226,8 +227,8 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
f.rel_path, f.rel_path,
f.sha256_after, f.sha256_after,
f.sha256_before, f.sha256_before,
f.content_after, putFileBlob(f.content_after),
f.content_before putFileBlob(f.content_before)
); );
const owningScript = scriptByTarget.get(f.rel_path); const owningScript = scriptByTarget.get(f.rel_path);
const priorVersion = owningScript?.prior_version_id ? getVersion(owningScript.prior_version_id) : null; const priorVersion = owningScript?.prior_version_id ? getVersion(owningScript.prior_version_id) : null;
+16 -8
View File
@@ -1,3 +1,4 @@
import { putFileBlob, blobContent } from '../db/file-blobs.js';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import { configFileForGame, type ArtifactKind } from '../config.js'; import { configFileForGame, type ArtifactKind } from '../config.js';
@@ -32,7 +33,6 @@ export interface ScriptVersionRow {
release_version: string | null; release_version: string | null;
sha256: string; sha256: string;
size: number; size: number;
content: Buffer;
notes: string | null; notes: string | null;
state: ScriptVersionState; state: ScriptVersionState;
kind: ArtifactKind; kind: ArtifactKind;
@@ -137,14 +137,22 @@ export function listPromotedOverlaysForScope(
return rows.filter((r) => !skip.has(r.id)); return rows.filter((r) => !skip.has(r.id));
} }
export const VERSION_COLUMNS = 'id, script_id, version, release_version, sha256, size, notes, state, kind, uploaded_at';
export function getVersionContent(id: number): Buffer {
const row = db().prepare(`SELECT ${blobContent('content_hash')} AS content FROM script_versions WHERE id = ?`).get(id) as { content: Buffer } | undefined;
if (!row?.content) throw new ScriptError(`version content is unavailable (id=${id})`, 'no-such-version');
return row.content;
}
export function listVersions(scriptId: number): ScriptVersionRow[] { export function listVersions(scriptId: number): ScriptVersionRow[] {
return db() return db()
.prepare('SELECT * FROM script_versions WHERE script_id = ? ORDER BY version DESC') .prepare(`SELECT ${VERSION_COLUMNS} FROM script_versions WHERE script_id = ? ORDER BY version DESC`)
.all(scriptId) as ScriptVersionRow[]; .all(scriptId) as ScriptVersionRow[];
} }
export function getVersion(id: number): ScriptVersionRow | undefined { export function getVersion(id: number): ScriptVersionRow | undefined {
return db().prepare('SELECT * FROM script_versions WHERE id = ?').get(id) as return db().prepare(`SELECT ${VERSION_COLUMNS} FROM script_versions WHERE id = ?`).get(id) as
| ScriptVersionRow | ScriptVersionRow
| undefined; | undefined;
} }
@@ -162,7 +170,7 @@ export function getVersionMetadata(id: number): ScriptVersionMetadata | undefine
// upload, whether or not it has been promoted (current_version_id). // upload, whether or not it has been promoted (current_version_id).
export function getLatestVersion(scriptId: number): ScriptVersionRow | undefined { export function getLatestVersion(scriptId: number): ScriptVersionRow | undefined {
return db() return db()
.prepare('SELECT * FROM script_versions WHERE script_id = ? ORDER BY version DESC LIMIT 1') .prepare(`SELECT ${VERSION_COLUMNS} FROM script_versions WHERE script_id = ? ORDER BY version DESC LIMIT 1`)
.get(scriptId) as ScriptVersionRow | undefined; .get(scriptId) as ScriptVersionRow | undefined;
} }
@@ -234,7 +242,7 @@ export function createScript(input: CreateScriptInput): { script: ScriptRow; ver
VALUES (?, ?, ?, ?, ?)` VALUES (?, ?, ?, ?, ?)`
); );
const insertVersion = db().prepare( const insertVersion = db().prepare(
`INSERT INTO script_versions (script_id, version, sha256, size, content, notes, kind, release_version) `INSERT INTO script_versions (script_id, version, sha256, size, content_hash, notes, kind, release_version)
VALUES (?, 1, ?, ?, ?, ?, ?, ?)` VALUES (?, 1, ?, ?, ?, ?, ?, ?)`
); );
@@ -243,7 +251,7 @@ export function createScript(input: CreateScriptInput): { script: ScriptRow; ver
db().transaction(() => { db().transaction(() => {
const s = insertScript.run(input.name, targetRelPath, input.scopeType ?? null, input.scopeValue ?? null, kind); const s = insertScript.run(input.name, targetRelPath, input.scopeType ?? null, input.scopeValue ?? null, kind);
scriptId = Number(s.lastInsertRowid); scriptId = Number(s.lastInsertRowid);
const v = insertVersion.run(scriptId, sha, size, input.content, input.notes ?? null, kind, releaseVersion); const v = insertVersion.run(scriptId, sha, size, putFileBlob(input.content), input.notes ?? null, kind, releaseVersion);
versionId = Number(v.lastInsertRowid); versionId = Number(v.lastInsertRowid);
})(); })();
@@ -274,10 +282,10 @@ export function uploadNewVersion(scriptId: number, content: Buffer, notes?: stri
const res = db() const res = db()
.prepare( .prepare(
`INSERT INTO script_versions (script_id, version, sha256, size, content, notes, kind, release_version) `INSERT INTO script_versions (script_id, version, sha256, size, content_hash, notes, kind, release_version)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)` VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
) )
.run(scriptId, nextVersion, sha, content.byteLength, content, notes ?? null, script.kind, release); .run(scriptId, nextVersion, sha, content.byteLength, putFileBlob(content), notes ?? null, script.kind, release);
return getVersion(Number(res.lastInsertRowid))!; return getVersion(Number(res.lastInsertRowid))!;
} }
+3 -2
View File
@@ -1,3 +1,4 @@
import { getVersionContent } from './scripts.js';
import { composeServerCfg } from './config-compose.js'; import { composeServerCfg } from './config-compose.js';
import { getBaselineForScope, getVersion, listPromotedOverlaysForScope } from './scripts.js'; import { getBaselineForScope, getVersion, listPromotedOverlaysForScope } from './scripts.js';
import { selectedZombieMap, mapRotation } from './map-aliases.js'; import { selectedZombieMap, mapRotation } from './map-aliases.js';
@@ -23,7 +24,7 @@ export function getPromotedServerConfig(game: string | null, map: string | null,
...(map ? listPromotedOverlaysForScope('map', map) : []) ...(map ? listPromotedOverlaysForScope('map', map) : [])
].flatMap((overlay) => { ].flatMap((overlay) => {
const current = overlay.current_version_id ? getVersion(overlay.current_version_id) : undefined; const current = overlay.current_version_id ? getVersion(overlay.current_version_id) : undefined;
return current ? [{ id: overlay.id, text: current.content.toString('utf8') }] : []; return current ? [{ id: overlay.id, text: getVersionContent(current.id).toString('utf8') }] : [];
}); });
return Buffer.from(composeServerCfg(version.content.toString('utf8'), overlays, game, map, mapSlug), 'utf8'); return Buffer.from(composeServerCfg(getVersionContent(version.id).toString('utf8'), overlays, game, map, mapSlug), 'utf8');
} }
+11 -10
View File
@@ -1,3 +1,4 @@
import { putFileBlob, blobContent } from '../db/file-blobs.js';
import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { dirname, relative, resolve, isAbsolute } from 'node:path'; import { dirname, relative, resolve, isAbsolute } from 'node:path';
import { destOfKind, serverDir, type ArtifactKind } from '../config.js'; import { destOfKind, serverDir, type ArtifactKind } from '../config.js';
@@ -39,22 +40,22 @@ function targetPath(server: string, kind: ArtifactKind, relPath: string): string
/** Capture immutable desired bytes without changing the server or its inventory. */ /** Capture immutable desired bytes without changing the server or its inventory. */
export function queueFileChange(change: FileChange): void { export function queueFileChange(change: FileChange): void {
targetPath(change.server, change.kind, change.relPath); targetPath(change.server, change.kind, change.relPath);
db().prepare(`INSERT INTO staged_file_changes (source_kind, source_id, server, kind, rel_path, content, db().prepare(`INSERT INTO staged_file_changes (source_kind, source_id, server, kind, rel_path, content_hash,
script_version_id, inventory_source, deployment_file_id, preserve_before_image, check_before, expected_before_sha) script_version_id, inventory_source, deployment_file_id, preserve_before_image, check_before, expected_before_sha)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(source_kind, source_id, server, kind, rel_path) DO UPDATE SET ON CONFLICT(source_kind, source_id, server, kind, rel_path) DO UPDATE SET
content = excluded.content, script_version_id = excluded.script_version_id, content_hash = excluded.content_hash, script_version_id = excluded.script_version_id,
inventory_source = excluded.inventory_source, deployment_file_id = excluded.deployment_file_id, inventory_source = excluded.inventory_source, deployment_file_id = excluded.deployment_file_id,
preserve_before_image = excluded.preserve_before_image, check_before = excluded.check_before, preserve_before_image = excluded.preserve_before_image, check_before = excluded.check_before,
expected_before_sha = excluded.expected_before_sha expected_before_sha = excluded.expected_before_sha
WHERE staged_file_changes.state = 'pending'`) WHERE staged_file_changes.state = 'pending'`)
.run(change.sourceKind, change.sourceId, change.server, change.kind, change.relPath, change.content, .run(change.sourceKind, change.sourceId, change.server, change.kind, change.relPath, putFileBlob(change.content),
change.scriptVersionId ?? null, change.inventorySource, change.deploymentFileId ?? null, change.scriptVersionId ?? null, change.inventorySource, change.deploymentFileId ?? null,
change.preserveBeforeImage ? 1 : 0, change.expectedBeforeSha !== undefined ? 1 : 0, change.expectedBeforeSha ?? null); change.preserveBeforeImage ? 1 : 0, change.expectedBeforeSha !== undefined ? 1 : 0, change.expectedBeforeSha ?? null);
} }
export function stagedFileChanges(sourceKind: SourceKind, sourceId: string, server?: string): StagedFileChange[] { export function stagedFileChanges(sourceKind: SourceKind, sourceId: string, server?: string): StagedFileChange[] {
return db().prepare(`SELECT * FROM staged_file_changes WHERE source_kind = ? AND source_id = ? return db().prepare(`SELECT *, ${blobContent('content_hash')} AS content FROM staged_file_changes WHERE source_kind = ? AND source_id = ?
${server === undefined ? '' : 'AND server = ?'} ORDER BY id`) ${server === undefined ? '' : 'AND server = ?'} ORDER BY id`)
.all(sourceKind, sourceId, ...(server === undefined ? [] : [server])) as StagedFileChange[]; .all(sourceKind, sourceId, ...(server === undefined ? [] : [server])) as StagedFileChange[];
} }
@@ -81,13 +82,13 @@ function readExisting(path: string): Buffer | null {
} }
/** The first activation owns the rollback image, including an absent file. */ /** The first activation owns the rollback image, including an absent file. */
export function captureBetaBeforeImage(row: StagedFileChange, before: Buffer | null): void { export function captureBetaBeforeImage(row: Pick<StagedFileChange, 'source_id' | 'server' | 'rel_path' | 'kind'>, before: Buffer | null): void {
if (db().prepare('SELECT 1 FROM beta_file_changes WHERE beta_id = ? AND server = ? AND rel_path = ?') if (db().prepare('SELECT 1 FROM beta_file_changes WHERE beta_id = ? AND server = ? AND rel_path = ?')
.get(row.source_id, row.server, row.rel_path)) return; .get(row.source_id, row.server, row.rel_path)) return;
const prior = getServerScript(row.server, row.rel_path); const prior = getServerScript(row.server, row.rel_path);
if (before !== null) db().prepare(`INSERT OR IGNORE INTO beta_file_snapshots if (before !== null) db().prepare(`INSERT OR IGNORE INTO beta_file_snapshots
(beta_id, server, rel_path, prior_content, prior_sha256, prior_size) VALUES (?, ?, ?, ?, ?, ?)`) (beta_id, server, rel_path, prior_content_hash, prior_sha256, prior_size) VALUES (?, ?, ?, ?, ?, ?)`)
.run(row.source_id, row.server, row.rel_path, before, sha256Buffer(before), before.length); .run(row.source_id, row.server, row.rel_path, putFileBlob(before), sha256Buffer(before), before.length);
db().prepare(`UPDATE beta_server_scripts SET had_original = ?, prior_version_id = ? WHERE beta_id = ? AND server = ? db().prepare(`UPDATE beta_server_scripts SET had_original = ?, prior_version_id = ? WHERE beta_id = ? AND server = ?
AND script_id IN (SELECT id FROM scripts WHERE target_rel_path = ? AND kind = ?)`) AND script_id IN (SELECT id FROM scripts WHERE target_rel_path = ? AND kind = ?)`)
.run(before === null ? 0 : 1, prior?.script_version_id ?? null, row.source_id, row.server, row.rel_path, row.kind); .run(before === null ? 0 : 1, prior?.script_version_id ?? null, row.source_id, row.server, row.rel_path, row.kind);
@@ -100,7 +101,7 @@ export function captureBetaBeforeImage(row: StagedFileChange, before: Buffer | n
* from interleaving between the final gates and dispatching the restart. * from interleaving between the final gates and dispatching the restart.
*/ */
export function applyStagedFilesForOperation(operationId: string, server: string): void { export function applyStagedFilesForOperation(operationId: string, server: string): void {
const rows = db().prepare(`SELECT f.* FROM staged_file_changes f JOIN provisioning_sources s const rows = db().prepare(`SELECT f.*, ${blobContent('f.content_hash')} AS content FROM staged_file_changes f JOIN provisioning_sources s
ON s.kind = f.source_kind AND s.source_id = f.source_id AND s.server = f.server ON s.kind = f.source_kind AND s.source_id = f.source_id AND s.server = f.server
WHERE s.operation_id = ? AND f.server = ? AND f.state IN ('pending','applying') ORDER BY f.id`) WHERE s.operation_id = ? AND f.server = ? AND f.state IN ('pending','applying') ORDER BY f.id`)
.all(operationId, server) as StagedFileChange[]; .all(operationId, server) as StagedFileChange[];
@@ -132,8 +133,8 @@ export function applyStagedFilesForOperation(operationId: string, server: string
} }
if (row.deployment_file_id && !row.preserve_before_image) { if (row.deployment_file_id && !row.preserve_before_image) {
db().prepare(`UPDATE deployment_files SET db().prepare(`UPDATE deployment_files SET
verb = ?, content_before = ?, sha256_before = ?, snapshot_pending = 0 WHERE id = ?`) verb = ?, content_before_hash = ?, sha256_before = ?, snapshot_pending = 0 WHERE id = ?`)
.run(row.content === null ? 'removed' : before === null ? 'added' : 'updated', before, .run(row.content === null ? 'removed' : before === null ? 'added' : 'updated', putFileBlob(before),
before === null ? null : sha256Buffer(before), row.deployment_file_id); before === null ? null : sha256Buffer(before), row.deployment_file_id);
if (row.source_kind === 'campaign') db().prepare('INSERT INTO campaign_events (deployment_id, message) VALUES (?, ?)') if (row.source_kind === 'campaign') db().prepare('INSERT INTO campaign_events (deployment_id, message) VALUES (?, ?)')
.run(row.source_id, `${server}: captured rollback snapshot for ${row.rel_path}`); .run(row.source_id, `${server}: captured rollback snapshot for ${row.rel_path}`);
+1
View File
@@ -48,6 +48,7 @@ export function ensureGameFileSchema(handle: Database.Database): void {
FROM game_file_versions v,json_each(v.manifest) f; FROM game_file_versions v,json_each(v.manifest) f;
CREATE TRIGGER IF NOT EXISTS game_file_versions_immutable BEFORE UPDATE ON game_file_versions CREATE TRIGGER IF NOT EXISTS game_file_versions_immutable BEFORE UPDATE ON game_file_versions
WHEN OLD.state = 'ready' BEGIN SELECT RAISE(ABORT, 'Published game-file versions are immutable'); END; WHEN OLD.state = 'ready' BEGIN SELECT RAISE(ABORT, 'Published game-file versions are immutable'); END;
CREATE INDEX IF NOT EXISTS idx_host_library_caches_version ON host_library_caches(version_id);
`); `);
const versionColumns = handle.prepare('PRAGMA table_info(game_file_versions)').all() as { name: string }[]; const versionColumns = handle.prepare('PRAGMA table_info(game_file_versions)').all() as { name: string }[];
if (!versionColumns.some(column => column.name === 'last_activity_at')) handle.exec('ALTER TABLE game_file_versions ADD COLUMN last_activity_at TEXT'); if (!versionColumns.some(column => column.name === 'last_activity_at')) handle.exec('ALTER TABLE game_file_versions ADD COLUMN last_activity_at TEXT');
+42
View File
@@ -0,0 +1,42 @@
import type Database from 'better-sqlite3';
import { importantOperationSql } from './observations.js';
const important = (alias: string) => importantOperationSql.replaceAll('o.', `${alias}.`);
const visible = (alias: string) => `${alias}.history_expired_at IS NULL`;
/** Durable totals for global, host, server, and host/server filters; receipts update only changed membership. */
export function installHistoryCounts(handle: Database.Database): void {
if (handle.prepare("SELECT 1 FROM ec_host_schema_migrations WHERE name='history-counts-v1'").get()) return;
handle.transaction(() => {
handle.exec(`CREATE TABLE ec_host_operation_history_counts (
host_id TEXT NOT NULL, server_name TEXT NOT NULL, total INTEGER NOT NULL,
routine INTEGER NOT NULL, important INTEGER NOT NULL, PRIMARY KEY(host_id,server_name)
) WITHOUT ROWID;
DROP INDEX ec_host_operations_important_history;
CREATE INDEX ec_host_operations_important_history ON ec_host_operations(created_at)
WHERE history_expired_at IS NULL AND ${importantOperationSql.replaceAll('o.', '')};`);
for (const [host, server] of [['', ''], ['o.host_id', ''], ['', 'o.server_name'], ['o.host_id', 'o.server_name']]) {
const groups = [host, server].filter(Boolean);
handle.exec(`INSERT INTO ec_host_operation_history_counts
SELECT ${host || "''"},${server || "''"},COUNT(*),COALESCE(SUM(o.routine=1),0),COALESCE(SUM(${important('o')}),0)
FROM ec_host_operations o WHERE ${visible('o')} ${server ? 'AND o.server_name IS NOT NULL' : ''}
${groups.length ? 'GROUP BY ' + groups.join(',') : ''}`);
}
const adjust = (alias: string, direction: 1 | -1) => `
INSERT INTO ec_host_operation_history_counts(host_id,server_name,total,routine,important)
SELECT host_id,server_name,${direction},${direction}*(${alias}.routine=1),${direction}*${important(alias)} FROM (
SELECT '' host_id,'' server_name UNION ALL SELECT ${alias}.host_id,''
UNION ALL SELECT '',${alias}.server_name WHERE ${alias}.server_name IS NOT NULL
UNION ALL SELECT ${alias}.host_id,${alias}.server_name WHERE ${alias}.server_name IS NOT NULL
) WHERE ${visible(alias)}
ON CONFLICT(host_id,server_name) DO UPDATE SET total=total+excluded.total,
routine=routine+excluded.routine,important=important+excluded.important;`;
handle.exec(`CREATE TRIGGER operation_history_count_insert AFTER INSERT ON ec_host_operations BEGIN ${adjust('NEW', 1)} END;
CREATE TRIGGER operation_history_count_delete AFTER DELETE ON ec_host_operations BEGIN ${adjust('OLD', -1)} END;
CREATE TRIGGER operation_history_count_update AFTER UPDATE OF host_id,server_name,routine,state,observation_success,error,history_expired_at ON ec_host_operations
WHEN OLD.host_id IS NOT NEW.host_id OR OLD.server_name IS NOT NEW.server_name OR OLD.routine IS NOT NEW.routine
OR (${visible('OLD')}) IS NOT (${visible('NEW')}) OR ${important('OLD')} IS NOT ${important('NEW')}
BEGIN ${adjust('OLD', -1)} ${adjust('NEW', 1)} END;`);
handle.prepare("INSERT INTO ec_host_schema_migrations(name) VALUES('history-counts-v1')").run();
})();
}
+21 -8
View File
@@ -1,3 +1,4 @@
import { packFileJson, unpackFileJson, referenceBlobs } from '../db/file-blobs.js';
import { pruneHistory } from '../retention.js'; import { pruneHistory } from '../retention.js';
import { recordAgentReceipt } from '../provisioning/agent-events.js'; import { recordAgentReceipt } from '../provisioning/agent-events.js';
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
@@ -396,7 +397,7 @@ export type HostOperationStatus = Omit<HostOperation, 'payload'>;
const operationStatusColumns = 'o.id,o.host_id,o.kind,o.instance_id,o.generation,o.state,o.result,o.error,o.created_at,o.updated_at'; const operationStatusColumns = 'o.id,o.host_id,o.kind,o.instance_id,o.generation,o.state,o.result,o.error,o.created_at,o.updated_at';
const operationPayloadSql = 'COALESCE((SELECT p.payload FROM ec_host_operation_payloads p WHERE p.operation_id=o.id),o.payload)'; const operationPayloadSql = 'COALESCE((SELECT p.payload FROM ec_host_operation_payloads p WHERE p.operation_id=o.id),o.payload)';
function operationStatusFromRow(row: Omit<OperationRow, 'payload'>): HostOperationStatus { return { id: row.id, hostId: row.host_id, kind: row.kind, instanceId: row.instance_id, generation: row.generation, state: row.state, result: row.result === null ? null : JSON.parse(row.result), error: row.error, createdAt: row.created_at, updatedAt: row.updated_at }; } function operationStatusFromRow(row: Omit<OperationRow, 'payload'>): HostOperationStatus { return { id: row.id, hostId: row.host_id, kind: row.kind, instanceId: row.instance_id, generation: row.generation, state: row.state, result: row.result === null ? null : JSON.parse(row.result), error: row.error, createdAt: row.created_at, updatedAt: row.updated_at }; }
function operationFromRow(row: OperationRow): HostOperation { return { ...operationStatusFromRow(row), payload: JSON.parse(row.payload) }; } function operationFromRow(row: OperationRow): HostOperation { const status = operationStatusFromRow(row); return { ...status, result: unpackFileJson(status.result), payload: unpackFileJson(JSON.parse(row.payload)) }; }
export function getHostOperation(id: string): HostOperation | null { export function getHostOperation(id: string): HostOperation | null {
const row = database().prepare(`SELECT ${operationStatusColumns},${operationPayloadSql} AS payload FROM ec_host_operations o WHERE o.id=?`) const row = database().prepare(`SELECT ${operationStatusColumns},${operationPayloadSql} AS payload FROM ec_host_operations o WHERE o.id=?`)
.get(id) as OperationRow | undefined; .get(id) as OperationRow | undefined;
@@ -425,7 +426,9 @@ export function listHostOperationHistory(page = 1, filter: HostOperationFilter =
if (scope.server) { conditions.push('o.server_name=?'); parameters.push(scope.server); } if (scope.server) { conditions.push('o.server_name=?'); parameters.push(scope.server); }
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
return handle.transaction((): HostOperationHistoryPage => { return handle.transaction((): HostOperationHistoryPage => {
const { total } = handle.prepare(`SELECT COUNT(*) AS total FROM ec_host_operations o ${where}`).get(...parameters) as { total: number }; const countColumn = filter === 'all' ? 'total' : filter;
const total = (handle.prepare(`SELECT ${countColumn} AS total FROM ec_host_operation_history_counts WHERE host_id=? AND server_name=?`)
.get(scope.host ?? '', scope.server ?? '') as { total: number } | undefined)?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / pageSize)); const totalPages = Math.max(1, Math.ceil(total / pageSize));
const currentPage = Math.min(totalPages, Number.isSafeInteger(page) && page > 0 ? page : 1); const currentPage = Math.min(totalPages, Number.isSafeInteger(page) && page > 0 ? page : 1);
const operations = handle.prepare(`SELECT o.id,o.host_id AS hostId,h.name AS hostName,o.kind, const operations = handle.prepare(`SELECT o.id,o.host_id AS hostId,h.name AS hostName,o.kind,
@@ -474,20 +477,24 @@ function enqueueHostOperation(hostId: string, kind: string, payload: string | Bu
FROM ec_host_operations o WHERE o.host_id=? AND o.idempotency_key=?`).get(payload, hostId, options.idempotencyKey) as FROM ec_host_operations o WHERE o.host_id=? AND o.idempotency_key=?`).get(payload, hostId, options.idempotencyKey) as
{ id: string; kind: string; instance_id: string | null; generation: number | null; history_expired_at: string | null; request_hash: string | null; same_payload: number } | undefined; { id: string; kind: string; instance_id: string | null; generation: number | null; history_expired_at: string | null; request_hash: string | null; same_payload: number } | undefined;
if (existing) { if (existing) {
const samePayload = existing.history_expired_at ? existing.request_hash === createHash('sha256').update(payload).digest('hex') : !!existing.same_payload; const samePayload = existing.request_hash ? existing.request_hash === createHash('sha256').update(payload).digest('hex') : !!existing.same_payload;
if (existing.kind !== kind || existing.instance_id !== (options.instanceId ?? null) || existing.generation !== (options.generation ?? null) || !samePayload) throw new Error('Operation idempotency key is already associated with a different request.'); if (existing.kind !== kind || existing.instance_id !== (options.instanceId ?? null) || existing.generation !== (options.generation ?? null) || !samePayload) throw new Error('Operation idempotency key is already associated with a different request.');
return getHostOperationStatus(existing.id)!; return getHostOperationStatus(existing.id)!;
} }
} }
if (['instance.create','create'].includes(kind)) requireReadyHost(hostId); if (['instance.create','create'].includes(kind)) requireReadyHost(hostId);
if (host.mode === 'disabled' && kind !== 'instance.cancel-create' && !(host.enablingSince && ['runtime.pull','library.download','host.inspect'].includes(kind))) throw new Error('Host is disabled. Enable it before requesting operations.'); if (host.mode === 'disabled' && kind !== 'instance.cancel-create' && !(host.enablingSince && ['runtime.pull','library.download','host.inspect'].includes(kind))) throw new Error('Host is disabled. Enable it before requesting operations.');
const requestHash = createHash('sha256').update(payload).digest('hex');
const packed = packFileJson(JSON.parse(payload.toString()));
payload = JSON.stringify(packed.value);
const id = options.id ?? randomUUID(); const timestamp = now(); const id = options.id ?? randomUUID(); const timestamp = now();
const serverName = options.instanceId ? (database().prepare('SELECT server FROM native_instances WHERE id=?') const serverName = options.instanceId ? (database().prepare('SELECT server FROM native_instances WHERE id=?')
.get(options.instanceId) as { server: string } | undefined)?.server ?? null : null; .get(options.instanceId) as { server: string } | undefined)?.server ?? null : null;
database().prepare(`INSERT INTO ec_host_operations(id,host_id,kind,instance_id,generation,idempotency_key,payload,created_at,updated_at,source,server_name) database().prepare(`INSERT INTO ec_host_operations(id,host_id,kind,instance_id,generation,idempotency_key,payload,created_at,updated_at,source,server_name,request_hash)
VALUES(?,?,?,?,?,?,CAST(? AS TEXT),?,?,?,?)`) VALUES(?,?,?,?,?,?,CAST(? AS TEXT),?,?,?,?,?)`)
.run(id, hostId, kind, options.instanceId ?? null, options.generation ?? null, options.idempotencyKey ?? null, .run(id, hostId, kind, options.instanceId ?? null, options.generation ?? null, options.idempotencyKey ?? null,
routingPayload ?? payload, timestamp, timestamp, (currentOperationSource() ?? 'Controller').slice(0, 200), serverName); routingPayload ?? payload, timestamp, timestamp, (currentOperationSource() ?? 'Controller').slice(0, 200), serverName, requestHash);
referenceBlobs('operation', id, packed.hashes);
if (routingPayload !== undefined) database().prepare('INSERT INTO ec_host_operation_payloads(operation_id,payload) VALUES(?,CAST(? AS TEXT))').run(id, payload); if (routingPayload !== undefined) database().prepare('INSERT INTO ec_host_operation_payloads(operation_id,payload) VALUES(?,CAST(? AS TEXT))').run(id, payload);
database().prepare(`UPDATE ec_host_operations AS o SET routine=CASE WHEN ${routineObservationSql} THEN 1 ELSE 0 END, database().prepare(`UPDATE ec_host_operations AS o SET routine=CASE WHEN ${routineObservationSql} THEN 1 ELSE 0 END,
target_path=${operationTargetSql} WHERE o.id=?`).run(id); target_path=${operationTargetSql} WHERE o.id=?`).run(id);
@@ -562,7 +569,8 @@ export function getHostOperationPayload(hostId: string, id: string): Buffer {
const row = database().prepare(`SELECT CAST(${operationPayloadSql} AS BLOB) AS payload FROM ec_host_operations o WHERE o.id=? AND o.host_id=?`) const row = database().prepare(`SELECT CAST(${operationPayloadSql} AS BLOB) AS payload FROM ec_host_operations o WHERE o.id=? AND o.host_id=?`)
.get(id, hostId) as { payload: Buffer } | undefined; .get(id, hostId) as { payload: Buffer } | undefined;
if (!row) throw new Error('Host operation was not found for this host.'); if (!row) throw new Error('Host operation was not found for this host.');
return row.payload; if (!row.payload.includes('"contentSha256"') || getHost(hostId)?.inventory.capabilities?.includes('files.blobs')) return row.payload;
return Buffer.from(JSON.stringify(unpackFileJson(JSON.parse(row.payload.toString()))));
} }
export function reportHostOperationStatus(hostId: string, id: string, input: { state: unknown; result?: unknown; error?: unknown }): HostOperationStatus { export function reportHostOperationStatus(hostId: string, id: string, input: { state: unknown; result?: unknown; error?: unknown }): HostOperationStatus {
const operation = database().transaction(() => { const operation = database().transaction(() => {
@@ -575,8 +583,13 @@ export function reportHostOperationStatus(hostId: string, id: string, input: { s
const state = input.state as HostOperationState; const state = input.state as HostOperationState;
if (operation.state === 'running' && state === 'accepted') return operation; if (operation.state === 'running' && state === 'accepted') return operation;
const error = input.error === undefined || input.error === null ? null : String(input.error).slice(0, 4000); const error = input.error === undefined || input.error === null ? null : String(input.error).slice(0, 4000);
const result = input.result === undefined ? null : JSON.stringify(input.result); let result = input.result === undefined ? null : JSON.stringify(input.result);
if (result !== null && Buffer.byteLength(result) > 128 * 1024 * 1024) throw new Error('Operation result exceeds the limit.'); if (result !== null && Buffer.byteLength(result) > 128 * 1024 * 1024) throw new Error('Operation result exceeds the limit.');
if (result !== null) {
const packed = packFileJson(input.result);
result = JSON.stringify(packed.value);
referenceBlobs('operation-result', id, packed.hashes);
}
const observation = state === 'completed' && !error && successfulObservation(operation.kind, const observation = state === 'completed' && !error && successfulObservation(operation.kind,
operation.kind === 'rcon.execute' ? JSON.parse(getHostOperationPayload(hostId, id).toString('utf8')) : {}, input.result); operation.kind === 'rcon.execute' ? JSON.parse(getHostOperationPayload(hostId, id).toString('utf8')) : {}, input.result);
database().prepare('UPDATE ec_host_operations SET state=?,result=?,error=?,updated_at=?,observation_success=? WHERE id=?') database().prepare('UPDATE ec_host_operations SET state=?,result=?,error=?,updated_at=?,observation_success=? WHERE id=?')
+2 -2
View File
@@ -173,7 +173,7 @@ function supersedeUndeliveredDownloads(hostId: string): void {
} }
} }
/** Agent heartbeats and polls drive durable preparation; no open UI or in-memory worker is needed. */ /** Heartbeats drive durable preparation; idle checks do not reserve the SQLite writer. */
export function reconcileHostPreparation(hostId: string): HostPreparation { export function reconcileHostPreparation(hostId: string): HostPreparation {
return db().transaction(() => { return db().transaction(() => {
const host = getHost(hostId); const host = getHost(hostId);
@@ -203,5 +203,5 @@ export function reconcileHostPreparation(hostId: string): HostPreparation {
scheduleAvailabilityRefresh(); scheduleAvailabilityRefresh();
} }
return status; return status;
}).immediate(); })();
} }
+50
View File
@@ -1,3 +1,7 @@
import { createHash } from 'node:crypto';
import { ensureFileBlobSchema, migrationRows, packFileJson, referenceBlobs } from '../db/file-blobs.js';
import { retentionClocks } from '../db/retention-clocks.js';
import { installHistoryCounts } from './history-counts.js';
import type Database from 'better-sqlite3'; import type Database from 'better-sqlite3';
import { successfulObservationSql, routineObservationSql, operationTargetSql, importantOperationSql } from './observations.js'; import { successfulObservationSql, routineObservationSql, operationTargetSql, importantOperationSql } from './observations.js';
@@ -6,6 +10,7 @@ export function ensureHostSchema(handle: Database.Database): void {
if (initialized.has(handle)) return; if (initialized.has(handle)) return;
// Background readers open only after controller migrations have completed. // Background readers open only after controller migrations have completed.
if (handle.readonly) { initialized.add(handle); return; } if (handle.readonly) { initialized.add(handle); return; }
ensureFileBlobSchema(handle);
handle.exec(` handle.exec(`
CREATE TABLE IF NOT EXISTS ec_hosts ( CREATE TABLE IF NOT EXISTS ec_hosts (
id TEXT PRIMARY KEY, name TEXT NOT NULL, hostname TEXT NOT NULL DEFAULT '', id TEXT PRIMARY KEY, name TEXT NOT NULL, hostname TEXT NOT NULL DEFAULT '',
@@ -89,6 +94,8 @@ export function ensureHostSchema(handle: Database.Database): void {
CHECK(stage IN ('queued','running','cleaned','failed','completed')), CHECK(stage IN ('queued','running','cleaned','failed','completed')),
requested_at TEXT NOT NULL, updated_at TEXT NOT NULL, cleaned_at TEXT, error TEXT requested_at TEXT NOT NULL, updated_at TEXT NOT NULL, cleaned_at TEXT, error TEXT
); );
CREATE INDEX IF NOT EXISTS idx_ec_host_credentials_host ON ec_host_credentials(host_id);
CREATE INDEX IF NOT EXISTS idx_ec_host_enrollments_host ON ec_host_enrollments(host_id);
`); `);
const columns = handle.prepare('PRAGMA table_info(ec_host_operations)').all() as { name: string }[]; const columns = handle.prepare('PRAGMA table_info(ec_host_operations)').all() as { name: string }[];
if (!columns.some(column => column.name === 'history_expired_at')) handle.exec('ALTER TABLE ec_host_operations ADD COLUMN history_expired_at TEXT'); if (!columns.some(column => column.name === 'history_expired_at')) handle.exec('ALTER TABLE ec_host_operations ADD COLUMN history_expired_at TEXT');
@@ -120,6 +127,15 @@ export function ensureHostSchema(handle: Database.Database): void {
CREATE INDEX IF NOT EXISTS ec_host_operations_important_history ON ec_host_operations(created_at) CREATE INDEX IF NOT EXISTS ec_host_operations_important_history ON ec_host_operations(created_at)
WHERE ${importantOperationSql.replaceAll('o.', '')}`); WHERE ${importantOperationSql.replaceAll('o.', '')}`);
handle.exec('CREATE TABLE IF NOT EXISTS ec_host_schema_migrations (name TEXT PRIMARY KEY)'); handle.exec('CREATE TABLE IF NOT EXISTS ec_host_schema_migrations (name TEXT PRIMARY KEY)');
if (!handle.prepare('SELECT 1 FROM ec_host_schema_migrations WHERE name=?').get('staged-operation-payloads')) {
handle.transaction(() => {
handle.exec(`INSERT OR IGNORE INTO ec_host_operation_payloads(operation_id,payload)
SELECT id,payload FROM ec_host_operations WHERE kind='files.stage' AND json_type(payload,'$.files')='array';
UPDATE ec_host_operations SET payload=json_object('stageId',json_extract(payload,'$.stageId'))
WHERE kind='files.stage' AND json_type(payload,'$.files')='array';`);
handle.prepare('INSERT INTO ec_host_schema_migrations(name) VALUES(?)').run('staged-operation-payloads');
})();
}
if (!handle.prepare('SELECT 1 FROM ec_host_schema_migrations WHERE name=?').get('completed-status-batches')) { if (!handle.prepare('SELECT 1 FROM ec_host_schema_migrations WHERE name=?').get('completed-status-batches')) {
handle.transaction(() => { handle.transaction(() => {
// Reclassify existing completed batches without discarding their per-server replies. // Reclassify existing completed batches without discarding their per-server replies.
@@ -139,5 +155,39 @@ export function ensureHostSchema(handle: Database.Database): void {
handle.exec('UPDATE ec_resource_allowances SET cpu_cores=cpu_cores/1.25, memory_bytes=CAST(memory_bytes/1.25 AS INTEGER)'); handle.exec('UPDATE ec_resource_allowances SET cpu_cores=cpu_cores/1.25, memory_bytes=CAST(memory_bytes/1.25 AS INTEGER)');
})(); })();
} }
if (!handle.prepare('SELECT 1 FROM ec_host_schema_migrations WHERE name=?').get('operation-file-blobs')) {
handle.transaction(() => {
for (const row of migrationRows<{ id: string; payload: string; result: string | null }>(handle,
`SELECT o.rowid AS rid,o.id,COALESCE(p.payload,o.payload) payload,o.result
FROM ec_host_operations o LEFT JOIN ec_host_operation_payloads p ON p.operation_id=o.id
WHERE o.rowid>? AND o.kind IN ('files.stage','instance.create','instance.rebuild','instance.restart','instance.start','files.read')
ORDER BY o.rowid LIMIT 1`)) {
const packed = packFileJson(JSON.parse(row.payload), handle);
handle.prepare('UPDATE ec_host_operations SET request_hash=COALESCE(request_hash,?) WHERE id=?')
.run(createHash('sha256').update(row.payload).digest('hex'), row.id);
if (packed.hashes.size) {
handle.prepare(`INSERT INTO ec_host_operation_payloads(operation_id,payload) VALUES(?,?)
ON CONFLICT(operation_id) DO UPDATE SET payload=excluded.payload`).run(row.id, JSON.stringify(packed.value));
handle.prepare("UPDATE ec_host_operations SET payload=json_remove(payload,'$.files') WHERE id=?").run(row.id);
referenceBlobs('operation', row.id, packed.hashes, handle);
}
if (row.result) {
const result = packFileJson(JSON.parse(row.result), handle);
if (result.hashes.size) {
handle.prepare('UPDATE ec_host_operations SET result=? WHERE id=?').run(JSON.stringify(result.value), row.id);
referenceBlobs('operation-result', row.id, result.hashes, handle);
}
}
}
handle.prepare('INSERT INTO ec_host_schema_migrations(name) VALUES(?)').run('operation-file-blobs');
})();
}
handle.exec(`CREATE TRIGGER IF NOT EXISTS release_operation_blobs AFTER DELETE ON ec_host_operations BEGIN
DELETE FROM file_blob_references WHERE owner_type IN ('operation','operation-result') AND owner_id=OLD.id; END;
CREATE TRIGGER IF NOT EXISTS expire_operation_blobs AFTER UPDATE OF history_expired_at ON ec_host_operations
WHEN NEW.history_expired_at IS NOT NULL BEGIN
DELETE FROM file_blob_references WHERE owner_type IN ('operation','operation-result') AND owner_id=NEW.id; END;`);
retentionClocks(handle, { ec_host_operations: ['created_at', 'updated_at'] });
installHistoryCounts(handle);
initialized.add(handle); initialized.add(handle);
} }
+11
View File
@@ -46,6 +46,7 @@ export type BetaStage =
export type BetaState = 'running' | 'ready-promote' | 'promoted' | 'rolled-back'; export type BetaState = 'running' | 'ready-promote' | 'promoted' | 'rolled-back';
export type PipelineEvent = export type PipelineEvent =
| { type: 'beta-changed'; betaId: number }
| { type: 'fleet-invalidated' } | { type: 'fleet-invalidated' }
| { type: 'server-telemetry'; entries: GameTelemetry[] } | { type: 'server-telemetry'; entries: GameTelemetry[] }
| { type: 'deployment-opened'; deploymentId: string; action: string; servers: string[] } | { type: 'deployment-opened'; deploymentId: string; action: string; servers: string[] }
@@ -79,6 +80,15 @@ class Bus extends EventEmitter {
} }
const bus = new Bus(); const bus = new Bus();
const changedBetas = new Set<number>();
export function invalidateBeta(betaId: number): void {
const scheduled = changedBetas.size > 0;
changedBetas.add(betaId);
if (!scheduled) queueMicrotask(() => {
const ids = [...changedBetas]; changedBetas.clear();
for (const betaId of ids) bus.emit('event', { type: 'beta-changed', betaId });
});
}
let invalidationPending = false; let invalidationPending = false;
export function invalidateFleet(): void { export function invalidateFleet(): void {
if (invalidationPending) return; if (invalidationPending) return;
@@ -90,6 +100,7 @@ export function invalidateFleet(): void {
export function emit(event: PipelineEvent): void { export function emit(event: PipelineEvent): void {
bus.emit('event', event); bus.emit('event', event);
if (event.type === 'beta-opened' || event.type === 'beta-state' || event.type === 'beta-server-stage') invalidateFleet(); if (event.type === 'beta-opened' || event.type === 'beta-state' || event.type === 'beta-server-stage') invalidateFleet();
if ('betaId' in event && event.type !== 'beta-changed') invalidateBeta(event.betaId);
} }
export function subscribe(listener: (event: PipelineEvent) => void): () => void { export function subscribe(listener: (event: PipelineEvent) => void): () => void {
+45 -35
View File
@@ -6,18 +6,17 @@ import { betaTick } from './beta-runner.js';
import { emit } from './bus.js'; import { emit } from './bus.js';
import type { ServerStatusEntry } from './bus.js'; import type { ServerStatusEntry } from './bus.js';
import type { RconFailure } from '../integrations/rcon.js'; import type { RconFailure } from '../integrations/rcon.js';
import { getServer, listServers, setServerFields, type ServerRow } from '../fleet/servers.js'; import { setServerFields, type ServerRow } from '../fleet/servers.js';
import { inspectForMonitoring, rconForMonitoring, prepareMonitoringRcon, monitoringStatus, monitoringStatusWriter, MonitoringChangedError, MonitoringSkippedError } from '../fleet/health-observations.js'; import { inspectForMonitoring, rconForMonitoring, prepareMonitoringRcon, monitoringStatus, monitoringStatusWriter, monitoringIdentity, MonitoringChangedError, MonitoringSkippedError } from '../fleet/health-observations.js';
import { restartSettling } from '../provisioning/index.js'; import { serverObservation } from '../fleet/status.js';
import { serverObservation, observationReader } from '../fleet/status.js';
import { telemetryEvidence, latestGameSubmission } from '../fleet/status-evidence.js';
import { isProvisioning } from '../fleet/provisioning.js';
import { STATUS_REFRESH_MS } from '../../server-freshness.js'; import { STATUS_REFRESH_MS } from '../../server-freshness.js';
import { recordMetrics } from '../fleet/metrics.js'; import { recordMetrics } from '../fleet/metrics.js';
import { selectedZombieMap } from '../../map-aliases.js'; import { selectedZombieMap } from '../../map-aliases.js';
import { runMaintenance } from '../maintenance/index.js'; import { runMaintenance } from '../maintenance/index.js';
import { cleanupControllerLibraries } from '../game-files/index.js'; import { cleanupControllerLibraries } from '../game-files/index.js';
import { trackActivity } from './activity.js'; import { trackActivity } from './activity.js';
import { MonitoringSnapshot } from '../fleet/monitoring-snapshot.js';
import { withHostRequestContext } from '../hosts/request-context.js';
import { withOperationSource } from '../hosts/operation-context.js'; import { withOperationSource } from '../hosts/operation-context.js';
const TICK_MS = 15_000; const TICK_MS = 15_000;
@@ -104,7 +103,7 @@ function drainStatusQueue(host: string, queue: StatusQueue): void {
if (!queue.active && !queue.queued.length) statusQueues.delete(host); if (!queue.active && !queue.queued.length) statusQueues.delete(host);
} }
function queueStatus(srv: ServerRow, probe?: () => ReturnType<typeof rconForMonitoring>): Promise<number | null> { function queueStatus(srv: ServerRow, snapshot: MonitoringSnapshot, probe?: () => ReturnType<typeof rconForMonitoring>): Promise<number | null> {
const server = srv.name; const server = srv.name;
const pending = pendingStatus.get(server); const pending = pendingStatus.get(server);
if (pending) return pending; if (pending) return pending;
@@ -118,10 +117,10 @@ function queueStatus(srv: ServerRow, probe?: () => ReturnType<typeof rconForMoni
queue.queued.push(async () => { queue.queued.push(async () => {
let capacity: number | null = null; let capacity: number | null = null;
try { try {
capacity = await withOperationSource('Status monitor', () => refreshServerStatus(server, probe)); capacity = await withOperationSource('Status monitor', () => refreshServerStatus(server, snapshot, probe));
// Publish this server's latest evidence without waiting for the fleet. // Publish this server's latest evidence without waiting for the fleet.
const current = getServer(server); const current = snapshot.get(server)?.server;
if (current) publishStatus(statusEntry(current)); if (current) publishStatus(statusEntry(current, snapshot.observe));
} catch (err) { console.error(`[worker] status error for ${server}:`, err); } } catch (err) { console.error(`[worker] status error for ${server}:`, err); }
finally { pendingStatus.delete(server); finish(capacity); } finally { pendingStatus.delete(server); finish(capacity); }
}); });
@@ -129,19 +128,24 @@ function queueStatus(srv: ServerRow, probe?: () => ReturnType<typeof rconForMoni
return work; return work;
} }
export async function fanoutStatus(): Promise<void> { export function fanoutStatus(): Promise<void> {
const servers = listServers(); return withHostRequestContext(statusPass);
}
async function statusPass(): Promise<void> {
const snapshot = new MonitoringSnapshot();
const servers = snapshot.servers();
if (!servers.length) return; if (!servers.length) return;
const probes = prepareMonitoringRcon(servers.filter(server => !pendingStatus.has(server.name) const probes = prepareMonitoringRcon(servers.filter(server => !pendingStatus.has(server.name)
&& !isProvisioning(server.name) && !restartSettling(server.name) && !telemetryEvidence(server.name)).map(server => server.name)); && !snapshot.provisioning(server.name) && !snapshot.settling(server.name) && !snapshot.telemetryEvidence(server.name)).map(server => server.name), snapshot);
const results = await Promise.all(servers.map(server => queueStatus(server, probes.get(server.name)))); const results = await Promise.all(servers.map(server => queueStatus(server, snapshot, probes.get(server.name))));
const capacities = new Map(servers.map((server, i) => [server.name, results[i]])); const capacities = new Map(servers.map((server, i) => [server.name, results[i]]));
try { try {
// Metrics remain complete fleet collections, using freshness at capture // Metrics remain complete fleet collections, using freshness at capture
// time rather than the status saved at the beginning of a long scan. // time rather than the status saved at the beginning of a long scan.
const observe = observationReader(); snapshot.refresh();
recordMetrics(listServers().map(srv => { recordMetrics(snapshot.servers().map(srv => {
const current = observe(srv); const current = snapshot.observe(srv);
return { server: srv.name, game: srv.game ?? 'unknown', return { server: srv.name, game: srv.game ?? 'unknown',
map: selectedZombieMap(srv.game, current.last_map ?? srv.map, srv.map_slug)?.friendly ?? current.last_map ?? srv.map ?? 'Unknown map', map: selectedZombieMap(srv.game, current.last_map ?? srv.map, srv.map_slug)?.friendly ?? current.last_map ?? srv.map ?? 'Unknown map',
players: current.health === 'online' ? current.last_players : null, players: current.health === 'online' ? current.last_players : null,
@@ -150,33 +154,38 @@ export async function fanoutStatus(): Promise<void> {
} catch (err) { console.error('[worker] metrics error:', err); } } catch (err) { console.error('[worker] metrics error:', err); }
} }
async function refreshServerStatus(server: string, probeStatus?: () => ReturnType<typeof rconForMonitoring>): Promise<number | null> { async function refreshServerStatus(server: string, snapshot: MonitoringSnapshot, probeStatus?: () => ReturnType<typeof rconForMonitoring>): Promise<number | null> {
const srv = getServer(server); snapshot.refresh(server);
const srv = snapshot.get(server)?.server;
if (!srv) return null; if (!srv) return null;
let capacity: number | null = null; let capacity: number | null = null;
let updates: Partial<Record<keyof ServerRow, string | number | null>> = {}; let updates: Partial<Record<keyof ServerRow, string | number | null>> = {};
const flush = () => { setServerFields(srv.name, updates); updates = {}; }; const flush = () => { setServerFields(srv.name, updates); snapshot.patch(srv.name, updates as Partial<ServerRow>); updates = {}; };
if (!isProvisioning(srv.name)) { if (!snapshot.provisioning(srv.name)) {
const previousGame = monitoringStatus(srv.name)?.game; const previousGame = monitoringStatus(srv.name, snapshot)?.game;
const publishObservation = monitoringStatusWriter(srv.name); const publishObservation = monitoringStatusWriter(srv.name, snapshot);
let gameObservation: Awaited<ReturnType<typeof rconForMonitoring>> | undefined; let gameObservation: Awaited<ReturnType<typeof rconForMonitoring>> | undefined;
const settling = restartSettling(srv.name); const settling = snapshot.settling(srv.name);
const epoch = latestGameSubmission(srv.name); const epoch = snapshot.get(srv.name)?.submission;
let identity: string | undefined;
try { identity = monitoringIdentity(srv.name, snapshot); } catch { /* Offline hosts still need an unavailable sample. */ }
const unchanged = () => { const unchanged = () => {
const current = getServer(srv.name); const current = snapshot.get(srv.name)?.server;
return epoch === latestGameSubmission(srv.name) && !isProvisioning(srv.name) if (identity) try { if (identity !== monitoringIdentity(srv.name, snapshot)) return false; } catch { return false; }
return epoch === snapshot.get(srv.name)?.submission && !snapshot.provisioning(srv.name)
&& current?.created_at === srv.created_at && current.desired_state === srv.desired_state && current?.created_at === srv.created_at && current.desired_state === srv.desired_state
&& current.native_instance_id === srv.native_instance_id && current.instance_generation === srv.instance_generation; && current.native_instance_id === srv.native_instance_id && current.instance_generation === srv.instance_generation;
}; };
const inspectionStartedAt = new Date().toISOString(); const inspectionStartedAt = new Date().toISOString();
const inspected = settling const inspected = settling
? { result: { ok: false as const, state: 'restarting', reason: 'Restart requested' }, observedAt: inspectionStartedAt } ? { result: { ok: false as const, state: 'restarting', reason: 'Restart requested' }, observedAt: inspectionStartedAt }
: await inspectForMonitoring(srv.name).catch(err => err instanceof MonitoringChangedError ? null : await inspectForMonitoring(srv.name, false, snapshot).catch(err => err instanceof MonitoringChangedError ? null
: { result: { ok: false as const, state: 'unknown', reason: (err as Error).message }, observedAt: inspectionStartedAt }); : { result: { ok: false as const, state: 'unknown', reason: (err as Error).message }, observedAt: inspectionStartedAt });
snapshot.refresh(srv.name);
if (!inspected) return null; if (!inspected) return null;
const container = inspected.result; const container = inspected.result;
// A lifecycle request may be submitted while an inspection is in flight. // A lifecycle request may be submitted while an inspection is in flight.
if (unchanged() && (getServer(srv.name)?.container_checked_at ?? '') <= inspected.observedAt) { if (unchanged() && (snapshot.get(srv.name)?.server?.container_checked_at ?? '') <= inspected.observedAt) {
updates.container_state = container.ok ? 'running' : container.state?.toLowerCase() ?? 'unknown'; updates.container_state = container.ok ? 'running' : container.state?.toLowerCase() ?? 'unknown';
updates.container_checked_at = inspected.observedAt; updates.container_checked_at = inspected.observedAt;
// An unavailable inspection is not evidence that the game stopped. // An unavailable inspection is not evidence that the game stopped.
@@ -187,10 +196,11 @@ async function refreshServerStatus(server: string, probeStatus?: () => ReturnTyp
updates.last_error = null; updates.last_error = null;
updates.rcon_failures = 0; updates.rcon_failures = 0;
updates.last_reply_snippet = null; updates.last_reply_snippet = null;
} else if (unavailable || !telemetryEvidence(srv.name)) { } else if (unavailable || !snapshot.telemetryEvidence(srv.name)) {
flush(); flush();
const probeStartedAt = new Date().toISOString(); const probeStartedAt = new Date().toISOString();
const probe = await (probeStatus ? probeStatus() : rconForMonitoring(srv.name)).catch(err => { const probe = await (probeStatus ? probeStatus() : rconForMonitoring(srv.name, false, snapshot)).catch(err => {
snapshot.refresh(srv.name);
if (err instanceof MonitoringSkippedError) { if (err instanceof MonitoringSkippedError) {
// The container was checked, but game evidence retains its original age. // The container was checked, but game evidence retains its original age.
if (unchanged()) publishObservation(inspected, previousGame); if (unchanged()) publishObservation(inspected, previousGame);
@@ -199,9 +209,10 @@ async function refreshServerStatus(server: string, probeStatus?: () => ReturnTyp
if (err instanceof MonitoringChangedError) return null; if (err instanceof MonitoringChangedError) return null;
return { result: { ok: false as const, endpoint: null, reason: 'send-error' as RconFailure, detail: (err as Error).message, rawReply: undefined }, observedAt: probeStartedAt }; return { result: { ok: false as const, endpoint: null, reason: 'send-error' as RconFailure, detail: (err as Error).message, rawReply: undefined }, observedAt: probeStartedAt };
}); });
snapshot.refresh(srv.name);
if (!probe) return null; if (!probe) return null;
const result = probe.result; const result = probe.result;
const previous = getServer(srv.name); const previous = snapshot.get(srv.name)?.server;
if (unchanged() && previous && (previous.last_checked_at ?? '') <= probe.observedAt) { if (unchanged() && previous && (previous.last_checked_at ?? '') <= probe.observedAt) {
gameObservation = probe; gameObservation = probe;
const now = probe.observedAt; const now = probe.observedAt;
@@ -234,9 +245,8 @@ async function refreshServerStatus(server: string, probeStatus?: () => ReturnTyp
updates.rcon_failures = 0; updates.rcon_failures = 0;
updates.last_reply_snippet = null; updates.last_reply_snippet = null;
} }
flush(); const current = { ...(snapshot.get(srv.name)?.server ?? srv), ...updates } as ServerRow;
const current = getServer(srv.name) ?? srv; const status = snapshot.observe(current);
const status = serverObservation(current);
// Occupancy timers and match clearing follow the same source as the UI. // Occupancy timers and match clearing follow the same source as the UI.
if (status.health === 'online' && status.last_players !== null) { if (status.health === 'online' && status.last_players !== null) {
if (status.last_players > 0) { if (status.last_players > 0) {
@@ -20,6 +20,7 @@ export interface NativeLaunchInput {
export interface PreparedNativeLaunch { export interface PreparedNativeLaunch {
recipe: LaunchRecipePlan; recipe: LaunchRecipePlan;
recipeSummary: string; recipeSummary: string;
blobs?: Array<{ sha256: string; content: Uint8Array }>;
recipeJson: Uint8Array; recipeJson: Uint8Array;
payloadJson: Uint8Array; payloadJson: Uint8Array;
} }
+8 -5
View File
@@ -29,17 +29,20 @@ parentPort!.on('message', (message: { id: number; kind: 'launch'; input: NativeL
PLUTAINER_EXTRA_ARGS: `+exec electric_cherry_map.cfg +set tws_server_id ${instance.server} +map_rotate`, PLUTAINER_EXTRA_ARGS: `+exec electric_cherry_map.cfg +set tws_server_id ${instance.server} +map_rotate`,
PLUTO_SERVER_KEY: input.serverKey }, PLUTO_SERVER_KEY: input.serverKey },
files: recipe.files.map(file => ({ path: posix.join(relDestForKind(file.kind, recipe.game), file.path), files: recipe.files.map(file => ({ path: posix.join(relDestForKind(file.kind, recipe.game), file.path),
contentBase64: file.content, sha256: file.sha256 })), contentSha256: file.sha256, sha256: file.sha256 })),
...(input.rebuild ? { nextGeneration: instance.generation + 1, ...(input.safetyOverride ? { safetyOverride: true } : {}) } : {}) ...(input.rebuild ? { nextGeneration: instance.generation + 1, ...(input.safetyOverride ? { safetyOverride: true } : {}) } : {})
}; };
const encode = (value: unknown) => new TextEncoder().encode(JSON.stringify(value)); const encode = (value: unknown) => new TextEncoder().encode(JSON.stringify(value));
const recipeJson = encode(recipe); const blobs = [...new Map(recipe.files.map(file => [file.sha256,
{ sha256: file.sha256, content: new Uint8Array(Buffer.from(file.content, 'base64')) }])).values()];
const storedRecipe = { ...recipe, files: recipe.files.map(({ content, ...file }) => ({ ...file, contentHash: file.sha256 })) };
const recipeJson = encode(storedRecipe);
const payloadJson = encode(payload); const payloadJson = encode(payload);
const plan = { ...recipe, files: recipe.files.map(({ content: _content, ...file }) => file) }; const plan = { ...recipe, files: recipe.files.map(({ content: _content, ...file }) => file) };
const recipeSummary = JSON.stringify(summarizeRecipe(plan, sha256Buffer(Buffer.from(recipeJson.buffer)))); const recipeSummary = JSON.stringify(summarizeRecipe(plan, sha256Buffer(Buffer.from(JSON.stringify(recipe)))));
// Transfer ownership of the encoded bytes; do not clone giant strings or objects. // Transfer ownership of the encoded bytes; do not clone giant strings or objects.
parentPort!.postMessage({ id, value: { recipe: plan, recipeSummary, recipeJson, payloadJson } }, parentPort!.postMessage({ id, value: { recipe: plan, recipeSummary, recipeJson, payloadJson, blobs } },
[recipeJson.buffer, payloadJson.buffer]); [recipeJson.buffer, payloadJson.buffer, ...blobs.map(blob => blob.content.buffer)]);
} catch (error) { } catch (error) {
parentPort!.postMessage({ id, error: error instanceof Error ? error.message : 'Launch preparation failed.' }); parentPort!.postMessage({ id, error: error instanceof Error ? error.message : 'Launch preparation failed.' });
} }
+40 -23
View File
@@ -1,3 +1,4 @@
import { putFileBlob, getFileBlob, blobSize } from '../db/file-blobs.js';
import { concurrencyBlocker, preparingJobs } from './concurrency.js'; import { concurrencyBlocker, preparingJobs } from './concurrency.js';
import { isIP } from 'node:net'; import { isIP } from 'node:net';
import { setImmediate as yieldToController } from 'node:timers/promises'; import { setImmediate as yieldToController } from 'node:timers/promises';
@@ -36,8 +37,8 @@ import { safetyOverride, safetyOverrideCapability } from './safety-override.js';
export type NativeSelection = import('../../hosts.js').NativePlacement; export type NativeSelection = import('../../hosts.js').NativePlacement;
type NativeRecipe = LaunchRecipePlan & { native?: NativeSelection }; type NativeRecipe = LaunchRecipePlan & { native?: NativeSelection };
interface Job { selection?: string | null; id: string; server: string; action: ProvisioningAction; state: string; submitted_at: string | null; stage: string; interface Job { selection?: string | null; id: string; server: string; action: ProvisioningAction; state: string; submitted_at: string | null; stage: string;
instance_id: string; generation: number; stage_operation_id: string | null; operation_id: string | null; files_json: string | null; applied_at: string | null } instance_id: string; generation: number; stage_operation_id: string | null; operation_id: string | null; applied_at: string | null }
interface FilePlan { id: number; path: string; contentBase64: string | null; sha256: string | null; checkBefore: boolean; expectedBeforeSha: string | null } interface FilePlan { id: number; path: string; sha256: string | null; checkBefore: boolean; expectedBeforeSha: string | null }
/** Validate a launch request without selecting a host or consuming capacity. */ /** Validate a launch request without selecting a host or consuming capacity. */
export function nativeLaunchSelection(input: Record<string, unknown>, game: string): NativeSelection { export function nativeLaunchSelection(input: Record<string, unknown>, game: string): NativeSelection {
@@ -216,39 +217,50 @@ export function queueNativeRebuild(name: string, triggeredBy: string): CreateSer
} }
function filesFor(job: Job): FilePlan[] { function filesFor(job: Job): FilePlan[] {
const rows = db().prepare(`SELECT f.* FROM staged_file_changes f JOIN provisioning_sources s const rows = db().prepare(`SELECT f.id,f.kind,f.rel_path,f.content_hash,f.check_before,f.expected_before_sha
FROM staged_file_changes f JOIN provisioning_sources s
ON s.kind=f.source_kind AND s.source_id=f.source_id AND s.server=f.server ON s.kind=f.source_kind AND s.source_id=f.source_id AND s.server=f.server
WHERE s.operation_id=? AND f.server=? AND f.state='pending' ORDER BY f.id`).all(job.id, job.server) as StagedFileChange[]; WHERE s.operation_id=? AND f.server=? AND f.state='pending' ORDER BY f.id`).all(job.id, job.server) as Array<
Pick<StagedFileChange, 'id' | 'kind' | 'rel_path' | 'check_before' | 'expected_before_sha'> & { content_hash: string | null }>;
return rows.map(row => ({ id: row.id, path: posix.join(relDestForKind(row.kind, job.server.startsWith('t6_') ? 't6' : 't5'), row.rel_path), return rows.map(row => ({ id: row.id, path: posix.join(relDestForKind(row.kind, job.server.startsWith('t6_') ? 't6' : 't5'), row.rel_path),
contentBase64: row.content?.toString('base64') ?? null, sha256: row.content ? sha256Buffer(row.content) : null, sha256: row.content_hash, checkBefore: !!row.check_before, expectedBeforeSha: row.expected_before_sha }));
checkBefore: !!row.check_before, expectedBeforeSha: row.expected_before_sha })); }
function fileMetadata({ contentBase64, ...file }: FilePlan & { contentBase64?: string | null }): FilePlan { return file; }
function storedFiles(job: Job): FilePlan[] {
const row = db().prepare('SELECT files_json FROM native_provisioning WHERE deployment_id=?').get(job.id) as { files_json: string | null } | undefined;
return (JSON.parse(row?.files_json ?? '[]') as FilePlan[]).map(fileMetadata);
} }
/** Agent receipts are the authoritative before images; do not read a controller mount. */ /** Agent receipts are the authoritative before images; do not read a controller mount. */
function applyReceipt(job: Job, result: Record<string, unknown>) { function applyReceipt(job: Job, result: Record<string, unknown>) {
if (job.applied_at || !job.files_json) return; if (job.applied_at) return;
const plans = JSON.parse(job.files_json) as FilePlan[]; const plans = storedFiles(job);
const before = result.before as { id?: number; path: string; contentBase64: string | null }[] | undefined; if (!plans.length) return;
const before = result.before as { id?: number; path: string; contentBase64: string | null; contentSha256?: string }[] | undefined;
db().transaction(() => { db().transaction(() => {
for (const plan of plans) { for (const plan of plans) {
const snapshot = before?.find(item => item.id === plan.id || item.path === plan.path); const snapshot = before?.find(item => item.id === plan.id || item.path === plan.path);
if (!snapshot) throw new Error('Agent activation receipt is missing a before image.'); if (!snapshot) throw new Error('Agent activation receipt is missing a before image.');
const row = db().prepare('SELECT * FROM staged_file_changes WHERE id=?').get(plan.id) as StagedFileChange; const row = db().prepare(`SELECT *, ${blobSize('content_hash')} AS size FROM staged_file_changes WHERE id=?`).get(plan.id) as
Omit<StagedFileChange, 'content'> & { content_hash: string | null; size: number };
if (!row || row.state === 'applied') continue; if (!row || row.state === 'applied') continue;
const bytes = snapshot.contentBase64 === null ? null : Buffer.from(snapshot.contentBase64, 'base64'); const bytes = snapshot.contentSha256 ? getFileBlob(snapshot.contentSha256)
: snapshot.contentBase64 === null ? null : Buffer.from(snapshot.contentBase64, 'base64');
if (row.source_kind === 'beta') { if (row.source_kind === 'beta') {
captureBetaBeforeImage(row, bytes); captureBetaBeforeImage(row, bytes);
db().prepare('UPDATE beta_servers SET deployed_at=?,updated_at=? WHERE beta_id=? AND server=?').run(new Date().toISOString(), new Date().toISOString(), row.source_id, job.server); db().prepare('UPDATE beta_servers SET deployed_at=?,updated_at=? WHERE beta_id=? AND server=?').run(new Date().toISOString(), new Date().toISOString(), row.source_id, job.server);
} }
if (row.deployment_file_id && !row.preserve_before_image) { if (row.deployment_file_id && !row.preserve_before_image) {
db().prepare('UPDATE deployment_files SET verb=?,content_before=?,sha256_before=?,snapshot_pending=0 WHERE id=?') db().prepare('UPDATE deployment_files SET verb=?,content_before_hash=?,sha256_before=?,snapshot_pending=0 WHERE id=?')
.run(row.content === null ? 'removed' : bytes === null ? 'added' : 'updated', bytes, bytes === null ? null : sha256Buffer(bytes), row.deployment_file_id); .run(row.content_hash === null ? 'removed' : bytes === null ? 'added' : 'updated', putFileBlob(bytes), bytes === null ? null : sha256Buffer(bytes), row.deployment_file_id);
if (row.source_kind === 'campaign') db().prepare('INSERT INTO campaign_events (deployment_id, message) VALUES (?, ?)') if (row.source_kind === 'campaign') db().prepare('INSERT INTO campaign_events (deployment_id, message) VALUES (?, ?)')
.run(row.source_id, `${job.server}: captured rollback snapshot for ${row.rel_path}`); .run(row.source_id, `${job.server}: captured rollback snapshot for ${row.rel_path}`);
} }
if (row.content === null) removeScript(job.server, row.rel_path); if (row.content_hash === null) removeScript(job.server, row.rel_path);
else recordScript(job.server, row.rel_path, row.script_version_id && db().prepare('SELECT 1 FROM script_versions WHERE id=?').get(row.script_version_id) ? row.script_version_id : null, else recordScript(job.server, row.rel_path, row.script_version_id && db().prepare('SELECT 1 FROM script_versions WHERE id=?').get(row.script_version_id) ? row.script_version_id : null,
sha256Buffer(row.content), row.content.length, row.inventory_source); row.content_hash, row.size, row.inventory_source);
db().prepare("UPDATE staged_file_changes SET state='applied',applied_at=? WHERE id=?").run(new Date().toISOString(), row.id); db().prepare("UPDATE staged_file_changes SET state='applied',applied_at=? WHERE id=?").run(new Date().toISOString(), row.id);
} }
db().prepare('UPDATE native_provisioning SET applied_at=? WHERE deployment_id=?').run(new Date().toISOString(), job.id); db().prepare('UPDATE native_provisioning SET applied_at=? WHERE deployment_id=?').run(new Date().toISOString(), job.id);
@@ -257,7 +269,7 @@ function applyReceipt(job: Job, result: Record<string, unknown>) {
/** Retain confirmed file snapshots and instance identity even after observation is cancelled. */ /** Retain confirmed file snapshots and instance identity even after observation is cancelled. */
export function reconcileNativeProvisioningReceipt(id: string): void { export function reconcileNativeProvisioningReceipt(id: string): void {
const job = db().prepare(`SELECT d.*,n.instance_id,n.generation,n.operation_id,n.files_json,n.applied_at const job = db().prepare(`SELECT d.*,n.instance_id,n.generation,n.operation_id,n.applied_at
FROM server_deployments d JOIN native_provisioning n ON n.deployment_id=d.id FROM server_deployments d JOIN native_provisioning n ON n.deployment_id=d.id
WHERE d.id=? AND d.server_deleted_at IS NULL`).get(id) as Job | undefined; WHERE d.id=? AND d.server_deleted_at IS NULL`).get(id) as Job | undefined;
if (!job) return; if (!job) return;
@@ -280,7 +292,7 @@ export function reconcileNativeProvisioningReceipt(id: string): void {
if (!job.operation_id) return; if (!job.operation_id) return;
const operation = getHostOperationStatus(job.operation_id); const operation = getHostOperationStatus(job.operation_id);
if (operation?.state === 'failed') { if (operation?.state === 'failed') {
for (const file of JSON.parse(job.files_json ?? '[]') as FilePlan[]) { for (const file of storedFiles(job)) {
db().prepare("UPDATE staged_file_changes SET state=? WHERE id=? AND state='applying'") db().prepare("UPDATE staged_file_changes SET state=? WHERE id=? AND state='applying'")
.run(job.state === 'cancelled' ? 'cancelled' : 'pending', file.id); .run(job.state === 'cancelled' ? 'cancelled' : 'pending', file.id);
} }
@@ -348,7 +360,7 @@ export async function processNativeProvisioning(eligibility: (operation: { id: s
} }
} }
reconcileHostDrains(); reconcileHostDrains();
const jobs = db().prepare(`SELECT d.*,n.instance_id,n.generation,n.stage_operation_id,n.operation_id,n.files_json,n.applied_at,q.selection const jobs = db().prepare(`SELECT d.*,n.instance_id,n.generation,n.stage_operation_id,n.operation_id,n.applied_at,q.selection
FROM server_deployments d LEFT JOIN native_provisioning n ON n.deployment_id=d.id FROM server_deployments d LEFT JOIN native_provisioning n ON n.deployment_id=d.id
LEFT JOIN native_creation_intents q ON q.deployment_id=d.id LEFT JOIN native_creation_intents q ON q.deployment_id=d.id
WHERE (n.deployment_id IS NOT NULL OR q.deployment_id IS NOT NULL) AND d.server_deleted_at IS NULL AND d.state IN ('queued','waiting','running','awaiting-health','interrupted') ORDER BY d.started_at,d.rowid`).all() as Job[]; WHERE (n.deployment_id IS NOT NULL OR q.deployment_id IS NOT NULL) AND d.server_deleted_at IS NULL AND d.state IN ('queued','waiting','running','awaiting-health','interrupted') ORDER BY d.started_at,d.rowid`).all() as Job[];
@@ -494,7 +506,7 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
// post-mutation failures remain unknown and retain their capacity. // post-mutation failures remain unknown and retain their capacity.
db().transaction(() => { db().transaction(() => {
db().prepare('DELETE FROM restart_rollout WHERE operation_id=?').run(job.id); db().prepare('DELETE FROM restart_rollout WHERE operation_id=?').run(job.id);
const files = JSON.parse(job.files_json ?? '[]') as FilePlan[]; const files = storedFiles(job);
for (const file of files) db().prepare("UPDATE staged_file_changes SET state='pending' WHERE id=? AND state='applying'").run(file.id); for (const file of files) db().prepare("UPDATE staged_file_changes SET state='pending' WHERE id=? AND state='applying'").run(file.id);
finishDeployment(job.id, { ok: false, message: operation.error ?? 'Agent operation failed before mutation.' }); finishDeployment(job.id, { ok: false, message: operation.error ?? 'Agent operation failed before mutation.' });
})(); })();
@@ -585,10 +597,14 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
if (job.action === 'destroy' || job.action === 'rebuild') { if (job.action === 'destroy' || job.action === 'rebuild') {
const beta = activeBetaRemovalBlocker(job.server); if (beta) { hold(job, beta); return; } const beta = activeBetaRemovalBlocker(job.server); if (beta) { hold(job, beta); return; }
} }
const files = job.files_json ? JSON.parse(job.files_json) as FilePlan[] : filesFor(job); const files = job.stage_operation_id ? storedFiles(job) : filesFor(job);
if (files.length && !job.stage_operation_id) { if (files.length && !job.stage_operation_id) {
const stage = queueHostOperation(instance.host_id, 'files.stage', { stageId: job.id, files }, { instanceId: instance.id, generation: instance.generation, idempotencyKey: `${job.id}:stage` }); db().transaction(() => {
db().prepare('UPDATE native_provisioning SET stage_operation_id=?,files_json=? WHERE deployment_id=?').run(stage.id, JSON.stringify(files), job.id); const stage = queueHostOperationSerialized(instance.host_id, 'files.stage', Buffer.from(JSON.stringify({ stageId: job.id, files: files.map(file => ({ ...file, ...(file.sha256 ? { contentSha256: file.sha256 } : { contentBase64: null }) })) })),
{ instanceId: instance.id, generation: instance.generation, idempotencyKey: `${job.id}:stage` }, { stageId: job.id });
db().prepare('UPDATE native_provisioning SET stage_operation_id=?,files_json=? WHERE deployment_id=?')
.run(stage.id, JSON.stringify(files.map(fileMetadata)), job.id);
})();
hold(job, 'Staging and verifying files on the host.'); return; hold(job, 'Staging and verifying files on the host.'); return;
} }
if (job.stage_operation_id) { if (job.stage_operation_id) {
@@ -671,7 +687,7 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
const beta = activeBetaRemovalBlocker(job.server); const beta = activeBetaRemovalBlocker(job.server);
if (beta) { hold(job, beta); return; } if (beta) { hold(job, beta); return; }
} }
if (JSON.stringify(filesFor(job)) !== JSON.stringify(files)) { if (JSON.stringify(filesFor(job).map(fileMetadata)) !== JSON.stringify(files.map(fileMetadata))) {
finishDeployment(job.id, { ok: false, message: 'File requests changed during preflight. Review and submit a fresh activation.' }); return; finishDeployment(job.id, { ok: false, message: 'File requests changed during preflight. Review and submit a fresh activation.' }); return;
} }
const capacityBlocker = concurrencyBlocker(job.id, job.action, instance!.host_id); const capacityBlocker = concurrencyBlocker(job.id, job.action, instance!.host_id);
@@ -701,6 +717,7 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
if (prepared) { if (prepared) {
deploymentStage(job.id, 'Preparing launch files for the host agent', { phase: 'prepare', recipeStatus: 'attempted', deploymentStage(job.id, 'Preparing launch files for the host agent', { phase: 'prepare', recipeStatus: 'attempted',
checkpoint: { key: 'startup-queue', label: 'Startup capacity available' } }); checkpoint: { key: 'startup-queue', label: 'Startup capacity available' } });
for (const blob of prepared.blobs ?? []) putFileBlob(blob.content);
const recipe = prepared.recipe; const recipe = prepared.recipe;
db().prepare('UPDATE server_launch_recipes SET recipe=CAST(? AS TEXT),recipe_summary=? WHERE deployment_id=?') db().prepare('UPDATE server_launch_recipes SET recipe=CAST(? AS TEXT),recipe_summary=? WHERE deployment_id=?')
.run(Buffer.from(prepared.recipeJson.buffer, prepared.recipeJson.byteOffset, prepared.recipeJson.byteLength), prepared.recipeSummary, job.id); .run(Buffer.from(prepared.recipeJson.buffer, prepared.recipeJson.byteOffset, prepared.recipeJson.byteLength), prepared.recipeSummary, job.id);
+2
View File
@@ -1,5 +1,7 @@
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import { workerData } from 'node:worker_threads'; import { workerData } from 'node:worker_threads';
import { tuneConnection } from '../db/connection.js';
const handle = new Database(workerData.dbPath, { readonly: true, fileMustExist: true }); const handle = new Database(workerData.dbPath, { readonly: true, fileMustExist: true });
tuneConnection(handle, true);
export function db() { return handle; } export function db() { return handle; }
+20 -4
View File
@@ -3,12 +3,28 @@ import type Database from 'better-sqlite3';
export function installReportRevisions(handle: Database.Database) { export function installReportRevisions(handle: Database.Database) {
handle.exec(`CREATE TABLE IF NOT EXISTS report_revisions (topic TEXT PRIMARY KEY, revision INTEGER NOT NULL DEFAULT 0); handle.exec(`CREATE TABLE IF NOT EXISTS report_revisions (topic TEXT PRIMARY KEY, revision INTEGER NOT NULL DEFAULT 0);
INSERT OR IGNORE INTO report_revisions(topic) VALUES ('metrics'), ('leaderboard');`); INSERT OR IGNORE INTO report_revisions(topic) VALUES ('metrics'), ('leaderboard');`);
for (const [topic, tables] of Object.entries({ metrics: ['metric_samples', 'servers'], for (const table of ['metric_samples', 'servers']) for (const action of ['INSERT', 'UPDATE', 'DELETE']) {
leaderboard: ['leaderboard_matches', 'leaderboard_match_players', 'leaderboard_players'] })) {
for (const table of tables) for (const action of ['INSERT', 'UPDATE', 'DELETE']) {
const condition = table === 'servers' && action === 'UPDATE' ? 'WHEN OLD.destroyed_at IS NOT NEW.destroyed_at OR OLD.name IS NOT NEW.name' : ''; const condition = table === 'servers' && action === 'UPDATE' ? 'WHEN OLD.destroyed_at IS NOT NEW.destroyed_at OR OLD.name IS NOT NEW.name' : '';
handle.exec(`CREATE TRIGGER IF NOT EXISTS report_${table}_${action} AFTER ${action} ON ${table} ${condition} handle.exec(`CREATE TRIGGER IF NOT EXISTS report_${table}_${action} AFTER ${action} ON ${table} ${condition}
BEGIN UPDATE report_revisions SET revision=revision+1 WHERE topic='${topic}'; END;`); BEGIN UPDATE report_revisions SET revision=revision+1 WHERE topic='metrics'; END;`);
}
if (handle.prepare("SELECT 1 FROM settings WHERE key='completed_report_revisions_v1'").get()) return;
handle.transaction(() => {
for (const table of ['leaderboard_matches', 'leaderboard_match_players', 'leaderboard_players']) {
for (const action of ['INSERT', 'UPDATE', 'DELETE']) {
const name = `report_${table}_${action}`;
const versions = action === 'INSERT' ? ['NEW'] : action === 'DELETE' ? ['OLD'] : ['OLD', 'NEW'];
const condition = table === 'leaderboard_matches'
? `WHEN ${versions.map(row => `${row}.status='completed'`).join(' OR ')}`
: table === 'leaderboard_match_players'
? `WHEN ${versions.map(row => `(SELECT status FROM leaderboard_matches WHERE id=${row}.match_id)='completed'`).join(' OR ')}`
: action === 'UPDATE' ? 'WHEN OLD.name IS NOT NEW.name' : '';
const event = table === 'leaderboard_players' && action === 'UPDATE' ? 'UPDATE OF name' : action;
handle.exec(`DROP TRIGGER IF EXISTS ${name};
CREATE TRIGGER ${name} AFTER ${event} ON ${table} ${condition}
BEGIN UPDATE report_revisions SET revision=revision+1 WHERE topic='leaderboard'; END;`);
} }
} }
handle.exec("INSERT INTO settings(key,value) VALUES('completed_report_revisions_v1','1')");
})();
} }
+62 -15
View File
@@ -1,3 +1,4 @@
import { blobSize, pruneFileBlobs, referenceBlobs } from './db/file-blobs.js';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { db } from './db/index.js'; import { db } from './db/index.js';
import { fleetSettings } from './settings.js'; import { fleetSettings } from './settings.js';
@@ -42,7 +43,7 @@ function policies(): Policy[] {
return [ return [
{ category: 'campaign', label: 'Campaigns', table: 'deployments', id: 'id', clock: 'started_at', days: 'deployKeepDays', { category: 'campaign', label: 'Campaigns', table: 'deployments', id: 'id', clock: 'started_at', days: 'deployKeepDays',
eligible: `${campaignSafe} AND NOT EXISTS (SELECT 1 FROM retention_controls c WHERE c.category='file-content' AND c.record_id=o.id AND c.protected=1)`, failed: failedCampaign, eligible: `${campaignSafe} AND NOT EXISTS (SELECT 1 FROM retention_controls c WHERE c.category='file-content' AND c.record_id=o.id AND c.protected=1)`, failed: failedCampaign,
bytes: `(SELECT COALESCE(SUM(COALESCE(length(content_before),0)+COALESCE(length(content_after),0)),0) FROM deployment_files WHERE deployment_id=o.id)` }, bytes: `(SELECT COALESCE(SUM(COALESCE(${blobSize('content_before_hash')},0)+COALESCE(${blobSize('content_after_hash')},0)),0) FROM deployment_files WHERE deployment_id=o.id)` },
{ category: 'provisioning', label: 'Provisioning workflows', table: 'server_deployments', id: 'id', clock: 'finished_at', days: 'provisioningKeepDays', failed: "o.state='failed'", eligible: `o.state IN ${terminal} AND NOT (${unsettledDeployment.replaceAll('d.', 'o.')}) { category: 'provisioning', label: 'Provisioning workflows', table: 'server_deployments', id: 'id', clock: 'finished_at', days: 'provisioningKeepDays', failed: "o.state='failed'", eligible: `o.state IN ${terminal} AND NOT (${unsettledDeployment.replaceAll('d.', 'o.')})
AND NOT EXISTS (SELECT 1 FROM restart_rollout r WHERE r.operation_id=o.id AND r.healthy_at IS NULL) AND NOT EXISTS (SELECT 1 FROM restart_rollout r WHERE r.operation_id=o.id AND r.healthy_at IS NULL)
AND NOT EXISTS (SELECT 1 FROM native_provisioning n JOIN ec_host_operations a ON a.id IN (n.operation_id,n.stage_operation_id) WHERE n.deployment_id=o.id AND a.state NOT IN ('completed','failed')) AND NOT EXISTS (SELECT 1 FROM native_provisioning n JOIN ec_host_operations a ON a.id IN (n.operation_id,n.stage_operation_id) WHERE n.deployment_id=o.id AND a.state NOT IN ('completed','failed'))
@@ -70,22 +71,37 @@ function policies(): Policy[] {
{ category: 'beta', label: 'Completed betas', table: 'betas', id: 'id', clock: 'closed_at', days: 'betaKeepDays', { category: 'beta', label: 'Completed betas', table: 'betas', id: 'id', clock: 'closed_at', days: 'betaKeepDays',
eligible: `o.state IN ('promoted','rolled-back') AND NOT EXISTS (SELECT 1 FROM retention_controls c WHERE c.category='beta-file-content' AND c.record_id=CAST(o.id AS TEXT) AND c.protected=1) AND NOT EXISTS (SELECT 1 FROM deployments d WHERE d.beta_id=o.id) eligible: `o.state IN ('promoted','rolled-back') AND NOT EXISTS (SELECT 1 FROM retention_controls c WHERE c.category='beta-file-content' AND c.record_id=CAST(o.id AS TEXT) AND c.protected=1) AND NOT EXISTS (SELECT 1 FROM deployments d WHERE d.beta_id=o.id)
AND NOT EXISTS (SELECT 1 FROM provisioning_sources s JOIN server_deployments d ON d.id=s.operation_id WHERE s.kind='beta' AND s.source_id=CAST(o.id AS TEXT) AND (${unsettledDeployment}))`, AND NOT EXISTS (SELECT 1 FROM provisioning_sources s JOIN server_deployments d ON d.id=s.operation_id WHERE s.kind='beta' AND s.source_id=CAST(o.id AS TEXT) AND (${unsettledDeployment}))`,
bytes: '(SELECT COALESCE(SUM(length(content)),0) FROM beta_revision_files WHERE beta_id=o.id)' }, bytes: `(SELECT COALESCE(SUM(${blobSize('content_hash')}),0) FROM beta_revision_files WHERE beta_id=o.id)` },
{ category: 'file-content', label: 'Campaign file bodies', table: 'deployments', id: 'id', clock: 'finished_at', days: 'fileContentKeepDays', { category: 'file-content', label: 'Campaign file bodies', table: 'deployments', id: 'id', clock: 'finished_at', days: 'fileContentKeepDays',
eligible: `${campaignSafe} AND NOT (${failedCampaign}) eligible: `${campaignSafe} AND NOT (${failedCampaign})
AND NOT EXISTS (SELECT 1 FROM retention_controls c WHERE c.category='campaign' AND c.record_id=o.id AND c.protected=1) AND NOT EXISTS (SELECT 1 FROM retention_controls c WHERE c.category='campaign' AND c.record_id=o.id AND c.protected=1)
AND NOT EXISTS (SELECT 1 FROM retention_expirations e WHERE e.category='file-content' AND e.record_id=o.id)`, AND NOT EXISTS (SELECT 1 FROM retention_expirations e WHERE e.category='file-content' AND e.record_id=o.id)`,
bytes: '(SELECT COALESCE(SUM(COALESCE(length(content_before),0)+COALESCE(length(content_after),0)),0) FROM deployment_files WHERE deployment_id=o.id)' }, bytes: `(SELECT COALESCE(SUM(COALESCE(${blobSize('content_before_hash')},0)+COALESCE(${blobSize('content_after_hash')},0)),0) FROM deployment_files WHERE deployment_id=o.id)` },
{ category: 'beta-file-content', label: 'Closed beta file snapshots', table: 'betas', id: 'id', clock: 'closed_at', days: 'fileContentKeepDays', { category: 'beta-file-content', label: 'Closed beta file snapshots', table: 'betas', id: 'id', clock: 'closed_at', days: 'fileContentKeepDays',
eligible: `o.state IN ('promoted','rolled-back') AND NOT EXISTS (SELECT 1 FROM deployments d WHERE d.beta_id=o.id) eligible: `o.state IN ('promoted','rolled-back')
AND NOT EXISTS (SELECT 1 FROM retention_controls c WHERE c.category='beta' AND c.record_id=CAST(o.id AS TEXT) AND c.protected=1) AND NOT EXISTS (SELECT 1 FROM retention_controls c WHERE c.category='beta' AND c.record_id=CAST(o.id AS TEXT) AND c.protected=1)
AND NOT EXISTS (SELECT 1 FROM retention_expirations e WHERE e.category='beta-file-content' AND e.record_id=CAST(o.id AS TEXT)) AND NOT EXISTS (SELECT 1 FROM retention_expirations e WHERE e.category='beta-file-content' AND e.record_id=CAST(o.id AS TEXT))
AND NOT EXISTS (SELECT 1 FROM provisioning_sources s JOIN server_deployments d ON d.id=s.operation_id WHERE s.kind='beta' AND s.source_id=CAST(o.id AS TEXT) AND (${unsettledDeployment}))`, AND NOT EXISTS (SELECT 1 FROM provisioning_sources s JOIN server_deployments d ON d.id=s.operation_id WHERE s.kind='beta' AND s.source_id=CAST(o.id AS TEXT) AND (${unsettledDeployment}))`,
bytes: '(SELECT COALESCE(SUM(length(content)),0) FROM beta_revision_files WHERE beta_id=o.id)' }, bytes: `(SELECT COALESCE(SUM(${blobSize('content_hash')}),0) FROM beta_revision_files WHERE beta_id=o.id)` },
{ category: 'workflow-payload', label: 'Completed launch file bodies', table: 'server_deployments', id: 'id', clock: 'finished_at', days: 'payloadKeepDays',
failed: "o.state='failed'",
eligible: `o.state IN ${terminal} AND NOT (${unsettledDeployment.replaceAll('d.', 'o.')})
AND NOT EXISTS (SELECT 1 FROM retention_controls c WHERE c.category='provisioning' AND c.record_id=o.id AND c.protected=1)
AND NOT EXISTS (SELECT 1 FROM retention_expirations e WHERE e.category='workflow-payload' AND e.record_id=o.id)`,
bytes: "(SELECT COALESCE(SUM(b.size),0) FROM file_blob_references r JOIN file_blobs b ON b.sha256=r.sha256 WHERE r.owner_type='recipe' AND r.owner_id=o.id)" },
{ category: 'operation-payload', label: 'Completed command file bodies', table: 'ec_host_operations', id: 'id', clock: 'updated_at', days: 'payloadKeepDays',
failed: "o.state='failed'",
eligible: `o.state IN ('completed','failed') AND o.kind IN ('instance.create','instance.rebuild','instance.restart','instance.start','files.stage','files.read')
AND NOT EXISTS (SELECT 1 FROM retention_controls c WHERE c.category='operation' AND c.record_id=o.id AND c.protected=1)
AND NOT EXISTS (SELECT 1 FROM native_provisioning n JOIN server_deployments d ON d.id=n.deployment_id
WHERE o.id IN (n.operation_id,n.stage_operation_id) AND ((${unsettledDeployment}) OR EXISTS (
SELECT 1 FROM retention_controls c WHERE c.category IN ('provisioning','workflow-payload') AND c.record_id=d.id AND c.protected=1)))
AND NOT EXISTS (SELECT 1 FROM retention_expirations e WHERE e.category='operation-payload' AND e.record_id=o.id)`,
bytes: "(SELECT COALESCE(SUM(b.size),0) FROM file_blob_references r JOIN file_blobs b ON b.sha256=r.sha256 WHERE r.owner_type IN ('operation','operation-result') AND r.owner_id=o.id)" },
{ category: 'version', label: 'Unused artifact versions', table: 'script_versions', id: 'id', clock: 'uploaded_at', days: 'versionKeepDays', minimum: false, { category: 'version', label: 'Unused artifact versions', table: 'script_versions', id: 'id', clock: 'uploaded_at', days: 'versionKeepDays', minimum: false,
eligible: `NOT EXISTS (SELECT 1 FROM scripts s WHERE s.current_version_id=o.id) eligible: `NOT EXISTS (SELECT 1 FROM scripts s WHERE s.current_version_id=o.id)
AND NOT EXISTS (SELECT 1 FROM staged_file_changes s WHERE s.script_version_id=o.id) AND NOT EXISTS (SELECT 1 FROM staged_file_changes s WHERE s.script_version_id=o.id)
AND ${versionReferences()}`, bytes: 'length(o.content)' }, AND ${versionReferences()}`, bytes: `${blobSize('o.content_hash')}` },
{ category: 'audit', label: 'Authentication audit events', table: 'auth_audit', id: 'id', clock: 'created_at', days: 'auditKeepDays', eligible: '1', bytes: 'length(o.details)' } { category: 'audit', label: 'Authentication audit events', table: 'auth_audit', id: 'id', clock: 'created_at', days: 'auditKeepDays', eligible: '1', bytes: 'length(o.details)' }
]; ];
} }
@@ -104,11 +120,37 @@ function versionReferences(): string {
return clauses.join(' AND ') || '1'; return clauses.join(' AND ') || '1';
} }
function initialize() { ensureHostSchema(db()); ensureAuthSchema(db()); } function initialize() { ensureHostSchema(db()); ensureAuthSchema(db()); }
function withoutFileBodies(value: unknown): unknown {
if (Array.isArray(value)) return value.map(withoutFileBodies);
if (!value || typeof value !== 'object') return value;
return Object.fromEntries(Object.entries(value).filter(([key]) => !['contentHash','contentSha256','contentBase64'].includes(key))
.map(([key, item]) => [key, withoutFileBodies(item)]));
}
function expirePayload(category: string, id: string): void {
if (category === 'workflow-payload') {
const row = db().prepare('SELECT recipe FROM server_launch_recipes WHERE deployment_id=?').get(id) as { recipe: string } | undefined;
if (row) db().prepare('UPDATE server_launch_recipes SET recipe=? WHERE deployment_id=?').run(JSON.stringify(withoutFileBodies(JSON.parse(row.recipe))), id);
db().prepare('UPDATE native_provisioning SET files_json=NULL WHERE deployment_id=?').run(id);
referenceBlobs('recipe', id, []);
return;
}
const row = db().prepare(`SELECT COALESCE(p.payload,o.payload) payload,o.result FROM ec_host_operations o
LEFT JOIN ec_host_operation_payloads p ON p.operation_id=o.id WHERE o.id=?`).get(id) as { payload: string; result: string | null };
const payload = JSON.stringify(withoutFileBodies(JSON.parse(row.payload)));
db().prepare(`UPDATE ec_host_operations SET payload=?,result=?,request_hash=COALESCE(request_hash,?) WHERE id=?`)
.run(payload, row.result === null ? null : JSON.stringify(withoutFileBodies(JSON.parse(row.result))),
createHash('sha256').update(row.payload).digest('hex'), id);
db().prepare('DELETE FROM ec_host_operation_payloads WHERE operation_id=?').run(id);
referenceBlobs('operation', id, []);
referenceBlobs('operation-result', id, []);
}
function selection(policy: Policy, settings = fleetSettings()) { function selection(policy: Policy, settings = fleetSettings()) {
const days = settings[policy.days]; const days = settings[policy.days];
const failureDays = settings.failureKeepDays; const failureDays = settings.failureKeepDays;
const cut = (n: number) => new Date(Date.now() - n * 86400_000).toISOString(); const cut = (n: number) => new Date(Date.now() - n * 86400_000).toISOString();
const age = ['campaign','observation','operation'].includes(policy.category) ? `o.${policy.clock} < @cutoff` : policy.category === 'audit' ? "o.created_at < @numericCutoff" : `julianday(o.${policy.clock}) < julianday(@cutoff)`; const age = policy.category === 'audit' ? 'o.created_at < @numericCutoff' : `o.${policy.clock} < @cutoff`;
const failed = policy.failed ?? '0'; const failed = policy.failed ?? '0';
const enabled = days > 0 || policy.category === 'version' && settings.versionKeepCount > 0; const enabled = days > 0 || policy.category === 'version' && settings.versionKeepCount > 0;
// Keep age outside the correlated failure checks so SQLite can seek the // Keep age outside the correlated failure checks so SQLite can seek the
@@ -119,8 +161,8 @@ function selection(policy: Policy, settings = fleetSettings()) {
WHERE ${policy.category === 'version' && !days ? '1' : age} AND NOT (${failed})`); WHERE ${policy.category === 'version' && !days ? '1' : age} AND NOT (${failed})`);
if (failureDays > 0 && policy.failed) candidates.push(`SELECT o.rowid FROM retention_controls c if (failureDays > 0 && policy.failed) candidates.push(`SELECT o.rowid FROM retention_controls c
JOIN ${policy.table} o ON o.${policy.id}=c.record_id JOIN ${policy.table} o ON o.${policy.id}=c.record_id
WHERE c.category=@category AND julianday(c.resolved_at)<julianday(@failureCutoff) WHERE c.category=@category AND c.resolved_at<@failureCutoff
AND julianday(c.resolved_at)>=julianday(o.${policy.clock}) AND (${failed})`); AND c.resolved_at>=o.${policy.clock} AND (${failed})`);
const minimum = policy.minimum === false ? 0 : settings.historyKeepCount; const minimum = policy.minimum === false ? 0 : settings.historyKeepCount;
const minimumScope = policy.category === 'observation' ? "WHERE observation_success=1 AND state='completed'" const minimumScope = policy.category === 'observation' ? "WHERE observation_success=1 AND state='completed'"
: policy.category === 'operation' ? "WHERE history_expired_at IS NULL AND (routine=0 OR observation_success=0)" : ''; : policy.category === 'operation' ? "WHERE history_expired_at IS NULL AND (routine=0 OR observation_success=0)" : '';
@@ -181,18 +223,21 @@ export function pruneHistory(only?: string): number {
db().prepare('DELETE FROM ec_host_operation_events WHERE operation_id=?').run(row.id); db().prepare('DELETE FROM ec_host_operation_events WHERE operation_id=?').run(row.id);
if (policy.category === 'operation') { if (policy.category === 'operation') {
const payload = db().prepare(`SELECT COALESCE(p.payload,o.payload) AS payload FROM ec_host_operations o LEFT JOIN ec_host_operation_payloads p ON p.operation_id=o.id WHERE o.id=?`).get(row.id) as { payload: string }; const payload = db().prepare(`SELECT COALESCE(p.payload,o.payload) AS payload FROM ec_host_operations o LEFT JOIN ec_host_operation_payloads p ON p.operation_id=o.id WHERE o.id=?`).get(row.id) as { payload: string };
db().prepare(`UPDATE ec_host_operations SET request_hash=?,payload='{}',result='{"historyExpired":true}',error=NULL,history_expired_at=? WHERE id=?`) db().prepare(`UPDATE ec_host_operations SET request_hash=COALESCE(request_hash,?),payload='{}',result='{"historyExpired":true}',error=NULL,history_expired_at=? WHERE id=?`)
.run(createHash('sha256').update(payload.payload).digest('hex'),new Date().toISOString(),row.id); .run(createHash('sha256').update(payload.payload).digest('hex'),new Date().toISOString(),row.id);
} }
db().prepare('DELETE FROM ec_host_operation_payloads WHERE operation_id=?').run(row.id); db().prepare('DELETE FROM ec_host_operation_payloads WHERE operation_id=?').run(row.id);
} }
if (policy.category === 'beta-file-content') { if (policy.category === 'workflow-payload' || policy.category === 'operation-payload') {
db().prepare("UPDATE beta_revision_files SET content=X'' WHERE beta_id=?").run(row.id); expirePayload(policy.category, row.id);
db().prepare('DELETE FROM beta_file_snapshots WHERE beta_id=?').run(row.id); db().prepare('INSERT OR IGNORE INTO retention_expirations(category,record_id,expired_at) VALUES(?,?,?)').run(policy.category,row.id,new Date().toISOString());
} else if (policy.category === 'beta-file-content') {
db().prepare("UPDATE beta_revision_files SET content_hash=NULL WHERE beta_id=?").run(row.id);
db().prepare('UPDATE beta_file_snapshots SET prior_content_hash=NULL WHERE beta_id=?').run(row.id);
db().prepare('INSERT OR IGNORE INTO retention_expirations(category,record_id,expired_at) VALUES(?,?,?)').run(policy.category,row.id,new Date().toISOString()); db().prepare('INSERT OR IGNORE INTO retention_expirations(category,record_id,expired_at) VALUES(?,?,?)').run(policy.category,row.id,new Date().toISOString());
} else if (policy.category === 'file-content') { } else if (policy.category === 'file-content') {
db().prepare('UPDATE deployment_files SET content_before=NULL,content_after=NULL WHERE deployment_id=?').run(row.id); db().prepare('UPDATE deployment_files SET content_before_hash=NULL,content_after_hash=NULL WHERE deployment_id=?').run(row.id);
db().prepare("UPDATE staged_file_changes SET content=NULL WHERE source_kind='campaign' AND source_id=? AND state IN ('applied','cancelled')").run(row.id); db().prepare("UPDATE staged_file_changes SET content_hash=NULL WHERE source_kind='campaign' AND source_id=? AND state IN ('applied','cancelled')").run(row.id);
db().prepare('INSERT OR IGNORE INTO retention_expirations(category,record_id,expired_at) VALUES(?,?,?)').run(policy.category,row.id,new Date().toISOString()); db().prepare('INSERT OR IGNORE INTO retention_expirations(category,record_id,expired_at) VALUES(?,?,?)').run(policy.category,row.id,new Date().toISOString());
} else if (policy.category !== 'operation') { } else if (policy.category !== 'operation') {
db().prepare(`DELETE FROM ${policy.table} WHERE rowid=?`).run(row.rid); db().prepare(`DELETE FROM ${policy.table} WHERE rowid=?`).run(row.rid);
@@ -202,6 +247,8 @@ export function pruneHistory(only?: string): number {
return rows.length; return rows.length;
})(); })();
} }
const blobs = pruneFileBlobs();
if ((removed || blobs) && Number(db().pragma('freelist_count', { simple: true })) >= 256) db().pragma('incremental_vacuum(256)');
return removed; return removed;
} }
+30
View File
@@ -0,0 +1,30 @@
/** Coalesce pushes, serialize refreshes, and recover missed events after reconnect or tab activation. */
export function betaUpdates(load: () => Promise<unknown>, betaId?: () => number): () => void {
let timer: ReturnType<typeof setTimeout> | undefined;
let stopped = false, running = false, pending = false;
const schedule = () => {
pending = true;
if (stopped || running || timer || document.hidden) return;
timer = setTimeout(async () => {
timer = undefined;
if (stopped || document.hidden) return;
running = true; pending = false;
try { await load(); }
catch { /* The page owns its error state; later events can retry. */ }
finally { running = false; if (pending) schedule(); }
}, 200);
};
const changed = (event: Event) => { if (!betaId || (event as CustomEvent).detail?.betaId === betaId()) schedule(); };
const visible = () => { if (!document.hidden) schedule(); };
window.addEventListener('ec:beta-changed', changed);
window.addEventListener('ec:pipeline-connected', schedule);
window.addEventListener('ec:fleet-changed', schedule);
document.addEventListener('visibilitychange', visible);
return () => {
stopped = true; clearTimeout(timer);
window.removeEventListener('ec:beta-changed', changed);
window.removeEventListener('ec:pipeline-connected', schedule);
window.removeEventListener('ec:fleet-changed', schedule);
document.removeEventListener('visibilitychange', visible);
};
}
+5
View File
@@ -26,6 +26,11 @@ function updateDeployment(id: string, mutate: (d: PipelineDeployment) => void):
export function startPipelineStream(): (() => void) | undefined { export function startPipelineStream(): (() => void) | undefined {
if (!browser || source) return; if (!browser || source) return;
source = new EventSource('/api/events/pipeline'); source = new EventSource('/api/events/pipeline');
source.addEventListener('open', () => window.dispatchEvent(new Event('ec:pipeline-connected')));
source.addEventListener('beta-changed', event => {
try { window.dispatchEvent(new CustomEvent('ec:beta-changed', { detail: JSON.parse((event as MessageEvent).data) })); }
catch { /* Reconnect refreshes authoritative state if an event is malformed. */ }
});
for (const event of ['server-changed', 'server-state', 'deployment-opened', 'fleet-invalidated']) { for (const event of ['server-changed', 'server-state', 'deployment-opened', 'fleet-invalidated']) {
source.addEventListener(event, () => window.dispatchEvent(new Event('ec:fleet-changed'))); source.addEventListener(event, () => window.dispatchEvent(new Event('ec:fleet-changed')));
} }
@@ -0,0 +1,17 @@
import { error } from '@sveltejs/kit';
import { authenticateAgent } from '$lib/server/hosts/http.js';
import { db } from '$lib/server/db/index.js';
export function GET({ request, params }: { request: Request; params: { sha256: string } }) {
const host = authenticateAgent(request);
if (!/^[a-f0-9]{64}$/.test(params.sha256)) error(404, 'File not found.');
const row = db().prepare(`SELECT b.content,b.size FROM file_blobs b WHERE b.sha256=? AND EXISTS (
SELECT 1 FROM file_blob_references r JOIN ec_host_operations o ON o.id=r.owner_id
WHERE r.sha256=b.sha256 AND r.owner_type='operation' AND o.host_id=?)`).get(params.sha256, host.id) as
{ content: Buffer; size: number } | undefined;
if (!row) error(404, 'File not found.');
return new Response(new Uint8Array(row.content), { headers: {
'Content-Type': 'application/octet-stream', 'Content-Length': String(row.size),
'Cache-Control': 'private, no-store', ETag: `"${params.sha256}"`
} });
}
@@ -1,10 +1,8 @@
import { pendingHostOperationRows, waitForHostWork } from '$lib/server/hosts/index.js'; import { pendingHostOperationRows, waitForHostWork } from '$lib/server/hosts/index.js';
import { authenticateAgent } from '$lib/server/hosts/http.js'; import { authenticateAgent } from '$lib/server/hosts/http.js';
import { reconcileHostPreparation } from '$lib/server/hosts/preparation.js';
import { hostOperationsResponse } from '$lib/server/hosts/delivery.js'; import { hostOperationsResponse } from '$lib/server/hosts/delivery.js';
export async function GET({ request }: { request: Request }) { export async function GET({ request }: { request: Request }) {
const host = authenticateAgent(request); const host = authenticateAgent(request);
reconcileHostPreparation(host.id);
const params = new URL(request.url).searchParams; const params = new URL(request.url).searchParams;
const lane = params.get('lane') === 'health' ? 'health' : params.get('lane') === 'read' ? 'read' : params.get('lane') === 'write' ? 'write' : 'all'; const lane = params.get('lane') === 'health' ? 'health' : params.get('lane') === 'read' ? 'read' : params.get('lane') === 'write' ? 'write' : 'all';
let operations = pendingHostOperationRows(host.id, lane, false); let operations = pendingHostOperationRows(host.id, lane, false);
+21 -30
View File
@@ -1,50 +1,41 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { import {
listBetas,
listBetaServers,
listBetaScripts,
openBeta, openBeta,
BetaError BetaError
} from '$lib/server/fleet/beta.js'; } from '$lib/server/fleet/beta.js';
import { getScript, getVersion } from '$lib/server/fleet/scripts.js'; import { betaScriptMetadata } from '$lib/server/fleet/library-metadata.js';
import { db } from '$lib/server/db/index.js';
import type { BetaServerRow } from '$lib/server/fleet/beta.js';
import type { StoredPlan } from '$lib/server/fleet/beta-tests.js';
import { kick } from '$lib/server/pipeline/worker.js'; import { kick } from '$lib/server/pipeline/worker.js';
import { parseBetaPlan, type BetaPlan } from '$lib/beta-plan.js'; import { parseBetaPlan, type BetaPlan } from '$lib/beta-plan.js';
import { storedPlan } from '$lib/server/fleet/beta-tests.js'; import { betaListPage } from '$lib/server/fleet/beta-pages.js';
import { currentBetaRevision } from '$lib/server/fleet/beta-revisions.js';
export async function GET({ url }: { url: URL }) { export async function GET({ url }: { url: URL }) {
const stateFilter = url.searchParams.get('state') as const state = url.searchParams.get('state');
| 'running' if (state && !['running','ready-promote','promoted','rolled-back'].includes(state)) return json({ error: 'Invalid beta state.' }, { status: 400 });
| 'ready-promote' const { betas, ...pagination } = betaListPage(url.searchParams);
| 'promoted' const ids = JSON.stringify(betas.map(beta => beta.id));
| 'rolled-back' const bundles = betaScriptMetadata(betas.map(beta => beta.id));
| null; const plans = new Map((db().prepare(`SELECT p.*, COALESCE((SELECT started_at FROM beta_revisions r
const rows = listBetas(stateFilter ?? undefined).map((b) => { WHERE r.beta_id=p.beta_id ORDER BY revision DESC LIMIT 1), b.opened_at) started_at
const scripts = listBetaScripts(b.id).map((bs) => { FROM beta_test_plans p JOIN betas b ON b.id=p.beta_id
const script = getScript(bs.script_id); WHERE p.beta_id IN (SELECT value FROM json_each(?))`).all(ids) as Array<StoredPlan & { started_at: string }>).map(plan => [plan.beta_id, plan]));
const version = getVersion(bs.script_version_id); const members = db().prepare('SELECT * FROM beta_servers WHERE beta_id IN (SELECT value FROM json_each(?)) ORDER BY server').all(ids) as BetaServerRow[];
return { const rows = betas.map(b => {
id: bs.script_id, const scripts = bundles.get(b.id) ?? [];
name: script?.name ?? '(deleted)', const test = plans.get(b.id);
target_rel_path: script?.target_rel_path ?? '',
kind: script?.kind ?? bs.kind ?? 'script',
version: version
? { id: version.id, version: version.version, release_version: version.release_version, sha256: version.sha256 }
: null
};
});
const test = storedPlan(b.id);
const plan = test ? parseBetaPlan(JSON.parse(test.plan)) : null; const plan = test ? parseBetaPlan(JSON.parse(test.plan)) : null;
return { return {
...b, ...b,
testOutcome: test?.outcome ?? null, testOutcome: test?.outcome ?? null,
testReason: test?.reason ?? null, testReason: test?.reason ?? null,
deadlineAt: plan ? new Date(Date.parse(currentBetaRevision(b.id).started_at) + plan.timeoutHours * 3600000).toISOString() : null, deadlineAt: plan ? new Date(Date.parse(test!.started_at) + plan.timeoutHours * 3600000).toISOString() : null,
scripts, scripts,
servers: listBetaServers(b.id) servers: members.filter(member => member.beta_id === b.id)
}; };
}); });
return json({ betas: rows }); return json({ betas: rows, ...pagination });
} }
// POST body accepts either: // POST body accepts either:
+6 -17
View File
@@ -1,9 +1,10 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
import { getBeta, listBetaServers, listBetaScripts } from '$lib/server/fleet/beta.js'; import { getBeta, listBetaServers } from '$lib/server/fleet/beta.js';
import { getScript, getVersion } from '$lib/server/fleet/scripts.js'; import { betaScriptMetadata } from '$lib/server/fleet/library-metadata.js';
import { testView } from '$lib/server/fleet/beta-tests.js'; import { testView } from '$lib/server/fleet/beta-tests.js';
import { betaRevisionHistory, currentBetaRevision } from '$lib/server/fleet/beta-revisions.js'; import { betaRevisionHistory, currentBetaRevision } from '$lib/server/fleet/beta-revisions.js';
import { betaActivityPage } from '$lib/server/fleet/beta-pages.js';
function parseId(raw: string): number | null { function parseId(raw: string): number | null {
const n = Number.parseInt(raw, 10); const n = Number.parseInt(raw, 10);
@@ -16,28 +17,16 @@ export async function GET({ params }: { params: { id: string } }) {
const beta = getBeta(id); const beta = getBeta(id);
if (!beta) return json({ ok: false, error: 'not found' }, { status: 404 }); if (!beta) return json({ ok: false, error: 'not found' }, { status: 404 });
const scripts = listBetaScripts(id).map((bs) => { const scripts = betaScriptMetadata([id]).get(id) ?? [];
const script = getScript(bs.script_id);
const version = getVersion(bs.script_version_id);
return {
id: bs.script_id,
name: script?.name ?? '(deleted)',
target_rel_path: script?.target_rel_path ?? '',
kind: script?.kind ?? bs.kind ?? 'script',
version: version
? { id: version.id, version: version.version, release_version: version.release_version, sha256: version.sha256 }
: null
};
});
const servers = listBetaServers(id); const servers = listBetaServers(id);
const events = db().prepare('SELECT id, created_at, message FROM beta_events WHERE beta_id = ? ORDER BY id').all(id); const { events, nextBefore: eventsNextBefore } = betaActivityPage(id);
const campaign = db().prepare("SELECT id FROM deployments WHERE beta_id = ? AND action = 'promote' ORDER BY started_at DESC LIMIT 1").get(id) as { id: string } | undefined; const campaign = db().prepare("SELECT id FROM deployments WHERE beta_id = ? AND action = 'promote' ORDER BY started_at DESC LIMIT 1").get(id) as { id: string } | undefined;
const promotion = campaign ? { ...campaign, const promotion = campaign ? { ...campaign,
pending: (db().prepare("SELECT COUNT(*) n FROM deployment_servers WHERE deployment_id = ? AND server_deleted_at IS NULL AND restart_state NOT IN ('done','failed','cancelled','skipped')").get(campaign.id) as { n: number }).n, pending: (db().prepare("SELECT COUNT(*) n FROM deployment_servers WHERE deployment_id = ? AND server_deleted_at IS NULL AND restart_state NOT IN ('done','failed','cancelled','skipped')").get(campaign.id) as { n: number }).n,
failed: (db().prepare("SELECT COUNT(*) n FROM deployment_servers WHERE deployment_id = ? AND server_deleted_at IS NULL AND restart_state = 'failed'").get(campaign.id) as { n: number }).n } : null; failed: (db().prepare("SELECT COUNT(*) n FROM deployment_servers WHERE deployment_id = ? AND server_deleted_at IS NULL AND restart_state = 'failed'").get(campaign.id) as { n: number }).n } : null;
const rollback = db().prepare("SELECT id FROM deployments WHERE beta_id = ? AND action = 'rollback' ORDER BY started_at DESC LIMIT 1") const rollback = db().prepare("SELECT id FROM deployments WHERE beta_id = ? AND action = 'rollback' ORDER BY started_at DESC LIMIT 1")
.get(id) as { id: string } | undefined; .get(id) as { id: string } | undefined;
return json({ beta, scripts, servers, events, test: testView(id), promotion, rollback: rollback ?? null, return json({ beta, scripts, servers, events, eventsNextBefore, test: testView(id), promotion, rollback: rollback ?? null,
revision: currentBetaRevision(id), revisions: betaRevisionHistory(id) }); revision: currentBetaRevision(id), revisions: betaRevisionHistory(id) });
} }
@@ -0,0 +1,10 @@
import { json } from '@sveltejs/kit';
import { getBeta } from '$lib/server/fleet/beta.js';
import { betaActivityPage } from '$lib/server/fleet/beta-pages.js';
export function GET({ params, url }: { params: { id: string }; url: URL }) {
const id = Number(params.id);
if (!Number.isSafeInteger(id) || id < 1) return json({ error: 'Invalid beta ID.' }, { status: 400 });
if (!getBeta(id)) return json({ error: 'Beta not found.' }, { status: 404 });
return json(betaActivityPage(id, url.searchParams));
}
@@ -1,3 +1,4 @@
import { blobContent } from '$lib/server/db/file-blobs.js';
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
/** Current files can be reviewed before the first revision is archived. */ /** Current files can be reviewed before the first revision is archived. */
@@ -5,12 +6,12 @@ export async function GET({ params }: { params: { id: string; revision: string;
const values = [params.id, params.revision, params.scriptId].map(Number); const values = [params.id, params.revision, params.scriptId].map(Number);
if (values.some(value => !Number.isSafeInteger(value) || value < 1)) return new Response('Invalid file', { status: 400 }); if (values.some(value => !Number.isSafeInteger(value) || value < 1)) return new Response('Invalid file', { status: 400 });
const [id, revision, scriptId] = values; const [id, revision, scriptId] = values;
const file = db().prepare('SELECT content FROM beta_revision_files WHERE beta_id = ? AND revision = ? AND script_id = ?') const file = db().prepare(`SELECT ${blobContent('content_hash')} AS content FROM beta_revision_files WHERE beta_id = ? AND revision = ? AND script_id = ?`)
.get(id, revision, scriptId) as { content: Buffer } | undefined .get(id, revision, scriptId) as { content: Buffer } | undefined
?? (revision === 1 ? db().prepare(`SELECT v.content FROM beta_scripts bs JOIN script_versions v ON v.id = bs.script_version_id ?? (revision === 1 ? db().prepare(`SELECT ${blobContent('v.content_hash')} AS content FROM beta_scripts bs JOIN script_versions v ON v.id = bs.script_version_id
WHERE bs.beta_id = ? AND bs.script_id = ? AND NOT EXISTS (SELECT 1 FROM beta_revisions WHERE beta_id = ?)`) WHERE bs.beta_id = ? AND bs.script_id = ? AND NOT EXISTS (SELECT 1 FROM beta_revisions WHERE beta_id = ?)`)
.get(id, scriptId, id) as { content: Buffer } | undefined : undefined); .get(id, scriptId, id) as { content: Buffer } | undefined : undefined);
if (!file) return new Response('File not found', { status: 404 }); if (!file?.content) return new Response('File not found', { status: 404 });
return new Response(new Uint8Array(file.content), { headers: { 'Content-Type': 'application/octet-stream', return new Response(new Uint8Array(file.content), { headers: { 'Content-Type': 'application/octet-stream',
'Content-Disposition': 'attachment', 'Cache-Control': 'no-store' } }); 'Content-Disposition': 'attachment', 'Cache-Control': 'no-store' } });
} }
+3 -2
View File
@@ -1,3 +1,4 @@
import { blobSize } from '$lib/server/db/file-blobs.js';
import { json, error } from '@sveltejs/kit'; import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
@@ -16,8 +17,8 @@ export async function GET({ params }: { params: { id: string } }) {
const files = db() const files = db()
.prepare( .prepare(
`SELECT id, server, verb, rel_path, sha256_before, sha256_after, snapshot_pending, `SELECT id, server, verb, rel_path, sha256_before, sha256_after, snapshot_pending,
LENGTH(content_before) AS size_before, ${blobSize('content_before_hash')} AS size_before,
LENGTH(content_after) AS size_after, ${blobSize('content_after_hash')} AS size_after,
COALESCE((SELECT state FROM staged_file_changes WHERE deployment_file_id = deployment_files.id), 'applied') AS apply_state COALESCE((SELECT state FROM staged_file_changes WHERE deployment_file_id = deployment_files.id), 'applied') AS apply_state
FROM deployment_files FROM deployment_files
WHERE deployment_id = ? WHERE deployment_id = ?
@@ -1,3 +1,4 @@
import { blobContent } from '$lib/server/db/file-blobs.js';
import { json, error } from '@sveltejs/kit'; import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
@@ -10,7 +11,7 @@ export async function GET({ params }: { params: { id: string; fileId: string } }
} }
const row = db() const row = db()
.prepare( .prepare(
`SELECT verb, rel_path, sha256_before, sha256_after, content_before, content_after, snapshot_pending `SELECT verb, rel_path, sha256_before, sha256_after, ${blobContent('content_before_hash')} AS content_before, ${blobContent('content_after_hash')} AS content_after, snapshot_pending
FROM deployment_files FROM deployment_files
WHERE deployment_id = ? AND id = ?` WHERE deployment_id = ? AND id = ?`
) )
+2 -1
View File
@@ -1,3 +1,4 @@
import { getVersionContent } from '$lib/server/fleet/scripts.js';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { getScript, getVersion, getLatestVersion } from '$lib/server/fleet/scripts.js'; import { getScript, getVersion, getLatestVersion } from '$lib/server/fleet/scripts.js';
@@ -9,5 +10,5 @@ export async function GET({ params, url }: { params: { id: string }; url: URL })
const requested = url.searchParams.get('version'); const requested = url.searchParams.get('version');
const version = requested === null ? getLatestVersion(id) : getVersion(Number(requested)); const version = requested === null ? getLatestVersion(id) : getVersion(Number(requested));
if (!version || version.script_id !== id) return json({ error: 'Config version not found.' }, { status: 404 }); if (!version || version.script_id !== id) return json({ error: 'Config version not found.' }, { status: 404 });
return json({ text: version.content.toString('utf8'), versionId: version.id, version: version.version }, { headers: { 'Cache-Control': 'no-store' } }); return json({ text: getVersionContent(version.id).toString('utf8'), versionId: version.id, version: version.version }, { headers: { 'Cache-Control': 'no-store' } });
} }
+2 -1
View File
@@ -1,3 +1,4 @@
import { getVersionContent } from '$lib/server/fleet/scripts.js';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { getServer, listServers } from '$lib/server/fleet/servers.js'; import { getServer, listServers } from '$lib/server/fleet/servers.js';
import { getBaselineForScope, getScript, getVersion, listPromotedOverlaysForScope, type ScriptRow } from '$lib/server/fleet/scripts.js'; import { getBaselineForScope, getScript, getVersion, listPromotedOverlaysForScope, type ScriptRow } from '$lib/server/fleet/scripts.js';
@@ -20,7 +21,7 @@ function inspect(serverName: string, draft?: { artifactId: number; text: string
if (!isDraft && !version) return null; if (!isDraft && !version) return null;
return { return {
source: { id: row.id, name: row.name, version: isDraft ? null : version!.version, kind: row.kind as 'baseline' | 'config', draft: isDraft }, source: { id: row.id, name: row.name, version: isDraft ? null : version!.version, kind: row.kind as 'baseline' | 'config', draft: isDraft },
text: isDraft ? draft!.text : version!.content.toString('utf8') text: isDraft ? draft!.text : getVersionContent(version!.id).toString('utf8')
}; };
} }
const base = baseline ? layer(baseline) : null; const base = baseline ? layer(baseline) : null;
+3 -2
View File
@@ -1,3 +1,4 @@
import { blobSize } from '$lib/server/db/file-blobs.js';
import { json, error } from '@sveltejs/kit'; import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
import { getDeployment } from '$lib/server/provisioning/index.js'; import { getDeployment } from '$lib/server/provisioning/index.js';
@@ -19,8 +20,8 @@ export async function GET({ params, url }: { params: { id: string }; url?: URL }
const files = db() const files = db()
.prepare( .prepare(
`SELECT id, server, verb, rel_path, sha256_before, sha256_after, `SELECT id, server, verb, rel_path, sha256_before, sha256_after,
LENGTH(content_before) AS size_before, ${blobSize('content_before_hash')} AS size_before,
LENGTH(content_after) AS size_after ${blobSize('content_after_hash')} AS size_after
FROM deployment_files FROM deployment_files
WHERE deployment_id = ? WHERE deployment_id = ?
ORDER BY server, rel_path` ORDER BY server, rel_path`
@@ -1,3 +1,4 @@
import { blobContent } from '$lib/server/db/file-blobs.js';
import { json, error } from '@sveltejs/kit'; import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
@@ -7,7 +8,7 @@ import { db } from '$lib/server/db/index.js';
export async function GET({ params }: { params: { id: string; fileId: string } }) { export async function GET({ params }: { params: { id: string; fileId: string } }) {
const row = db() const row = db()
.prepare( .prepare(
`SELECT verb, rel_path, sha256_before, sha256_after, content_before, content_after `SELECT verb, rel_path, sha256_before, sha256_after, ${blobContent('content_before_hash')} AS content_before, ${blobContent('content_after_hash')} AS content_after
FROM deployment_files FROM deployment_files
WHERE deployment_id = ? AND id = ?` WHERE deployment_id = ? AND id = ?`
) )
+1 -1
View File
@@ -21,7 +21,7 @@ export async function GET() {
event.type === 'server-state' || event.type === 'server-state' ||
event.type === 'deployment-opened' || event.type === 'deployment-opened' ||
event.type === 'server-changed' || event.type === 'server-changed' ||
event.type === 'sweep-tick' || event.type === 'fleet-invalidated' event.type === 'sweep-tick' || event.type === 'fleet-invalidated' || event.type === 'beta-changed'
) { ) {
send(controller, event); send(controller, event);
} }
+2 -34
View File
@@ -2,14 +2,11 @@ import { json } from '@sveltejs/kit';
import { import {
createScript, createScript,
isArtifactKind, isArtifactKind,
listScripts,
listVersions,
serversMatchingScope,
ScriptError, ScriptError,
type ArtifactKind, type ArtifactKind,
type ScopeType type ScopeType
} from '$lib/server/fleet/scripts.js'; } from '$lib/server/fleet/scripts.js';
import { findRunningBetaForScript } from '$lib/server/fleet/beta.js'; import { scriptCatalog } from '$lib/server/fleet/library-metadata.js';
function scopeTypeOrNull(v: FormDataEntryValue | null): ScopeType | null { function scopeTypeOrNull(v: FormDataEntryValue | null): ScopeType | null {
if (v === null) return null; if (v === null) return null;
@@ -29,36 +26,7 @@ function kindOrDefault(v: FormDataEntryValue | string | null, fallback: Artifact
export async function GET({ url }: { url: URL }) { export async function GET({ url }: { url: URL }) {
const kindParam = url.searchParams.get('kind'); const kindParam = url.searchParams.get('kind');
const kindFilter = kindParam ? (isArtifactKind(kindParam) ? kindParam : null) : null; const kindFilter = kindParam ? (isArtifactKind(kindParam) ? kindParam : null) : null;
const rows = kindFilter ? listScripts(kindFilter) : listScripts(); const scripts = scriptCatalog(kindFilter ?? undefined);
const scripts = rows.map((s) => {
const versions = listVersions(s.id);
const matched = s.scope_type && s.scope_value
? serversMatchingScope(s.scope_type as ScopeType, s.scope_value)
: [];
const runningBeta = findRunningBetaForScript(s.id);
const current = versions.find(v => v.id === s.current_version_id);
return {
...s,
currentVersion: current ? {
id: current.id, version: current.version, sha256: current.sha256,
size: current.size, state: current.state, uploaded_at: current.uploaded_at
} : null,
latestVersion: versions[0]
? {
id: versions[0].id,
version: versions[0].version,
release_version: versions[0].release_version,
sha256: versions[0].sha256,
size: versions[0].size,
state: versions[0].state,
uploaded_at: versions[0].uploaded_at
}
: null,
versionCount: versions.length,
servers: matched,
runningBetaId: runningBeta?.id ?? null
};
});
return json({ scripts }); return json({ scripts });
} }
+33 -17
View File
@@ -4,14 +4,13 @@
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { pollVisible } from '$ui/poll.js'; import { betaUpdates } from '$ui/beta-updates.js';
import { artifactVersionLabel } from '$lib/artifact-version.js'; import { artifactVersionLabel } from '$lib/artifact-version.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte'; import Button from '$ui/components/primitives/Button.svelte';
import Tabs from '$ui/components/primitives/Tabs.svelte'; import Tabs from '$ui/components/primitives/Tabs.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte'; import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte'; import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
import { statusPresentation } from '$lib/ui/status.js'; import { statusPresentation } from '$lib/ui/status.js';
import { serverTitle } from '$ui/server-labels.js'; import { serverTitle } from '$ui/server-labels.js';
type ArtifactKind = 'script' | 'config' | 'plugin' | 'baseline'; type ArtifactKind = 'script' | 'config' | 'plugin' | 'baseline';
@@ -38,11 +37,13 @@
let error = $state(''); let error = $state('');
let tab = $state('active'); let tab = $state('active');
let search = $state(''); let search = $state('');
let limit = $state(25); let pageNumber = $state(1), totalPages = $state(1), total = $state(0);
let counts = $state({ active: 0, history: 0 });
let request = 0;
const active = (beta: BetaListRow) => beta.state === 'running' || beta.state === 'ready-promote'; const active = (beta: BetaListRow) => beta.state === 'running' || beta.state === 'ready-promote';
const tabs = $derived([{id:'active',label:'Active',badge:betas.filter(active).length},{id:'history',label:'History',badge:betas.filter(b => !active(b)).length}]); const tabs = $derived([{id:'active',label:'Active',badge:counts.active},{id:'history',label:'History',badge:counts.history}]);
let now = $state(Date.now()); let now = $state(Date.now());
const filtered = $derived(betas.filter(b => active(b) === (tab === 'active') && ('#' + b.id + ' ' + (b.notes ?? '') + ' ' + b.scripts.map(s => s.name).join(' ')).toLowerCase().includes(search.toLowerCase())));
function age(date:string) { const minutes=Math.max(0,Math.floor((now-Date.parse(date))/60000));return minutes<60?`${minutes}m ago`:minutes<1440?`${Math.floor(minutes/60)}h ago`:`${Math.floor(minutes/1440)}d ago`; } function age(date:string) { const minutes=Math.max(0,Math.floor((now-Date.parse(date))/60000));return minutes<60?`${minutes}m ago`:minutes<1440?`${Math.floor(minutes/60)}h ago`:`${Math.floor(minutes/1440)}d ago`; }
function blocker(beta:BetaListRow) { function blocker(beta:BetaListRow) {
if(beta.state==='promoted')return 'Version promoted; check its campaign for remaining activation.'; if(beta.state==='promoted')return 'Version promoted; check its campaign for remaining activation.';
@@ -58,23 +59,38 @@
const params = $page.url.searchParams; const params = $page.url.searchParams;
tab = params.get('view') === 'history' ? 'history' : 'active'; tab = params.get('view') === 'history' ? 'history' : 'active';
search = params.get('q') ?? ''; search = params.get('q') ?? '';
limit = Math.min(10000, Math.max(25, Number(params.get('limit')) || 25)); pageNumber = Math.max(1, Number(params.get('page')) || 1);
void load(params);
}); });
function saveContext() { function saveContext() {
const url = new URL($page.url); const url = new URL($page.url);
if (tab === 'history') url.searchParams.set('view', tab); else url.searchParams.delete('view'); if (tab === 'history') url.searchParams.set('view', tab); else url.searchParams.delete('view');
if (search) url.searchParams.set('q', search); else url.searchParams.delete('q'); if (search) url.searchParams.set('q', search); else url.searchParams.delete('q');
if (limit > 25) url.searchParams.set('limit', String(limit)); else url.searchParams.delete('limit'); url.searchParams.delete('limit');
if (pageNumber > 1) url.searchParams.set('page', String(pageNumber)); else url.searchParams.delete('page');
void goto(url, { replaceState: true, noScroll: true, keepFocus: true }); void goto(url, { replaceState: true, noScroll: true, keepFocus: true });
} }
async function load() { async function load(params = new URLSearchParams(window.location.search)) {
try { const res = await fetch('/api/betas'); if (!res.ok) throw new Error('Could not refresh betas.'); betas = (await res.json()).betas; now=Date.now(); error = ''; } const current = ++request;
catch (e) { error = (e as Error).message; } finally { loading = false; } params = new URLSearchParams(params);
params.set('view', params.get('view') === 'history' ? 'history' : 'active');
params.set('limit', '25');
try {
const res = await fetch('/api/betas?' + params);
if (!res.ok) throw new Error('Could not refresh betas.');
const body = await res.json();
if (current !== request) return;
betas = body.betas; counts = body.counts; total = body.total; totalPages = body.totalPages; pageNumber = body.page;
now = Date.now(); error = '';
} catch (e) { if (current === request) error = (e as Error).message; }
finally { if (current === request) loading = false; }
} }
onMount(() => { onMount(() => {
const query = new URLSearchParams(window.location.search); const query = new URLSearchParams(window.location.search);
if (query.has('artifact') || query.has('pluginId')) { void goto('/betas/new' + window.location.search, {replaceState:true}); return; } if (query.has('artifact') || query.has('pluginId')) { void goto('/betas/new' + window.location.search, {replaceState:true}); return; }
return pollVisible(load, 5000); const stop = betaUpdates(() => load());
const clock = setInterval(() => { now = Date.now(); }, 30000);
return () => { stop(); clearInterval(clock); request++; };
}); });
</script> </script>
<svelte:head><title>Betas · Electric Cherry</title></svelte:head> <svelte:head><title>Betas · Electric Cherry</title></svelte:head>
@@ -82,18 +98,18 @@
<SectionHeader title="Betas" subtitle="Test selected versions before promoting them to the fleet."> <SectionHeader title="Betas" subtitle="Test selected versions before promoting them to the fleet.">
{#snippet actions()}<Button variant="primary" href="/betas/new">New beta</Button>{/snippet} {#snippet actions()}<Button variant="primary" href="/betas/new">New beta</Button>{/snippet}
</SectionHeader> </SectionHeader>
<Tabs {tabs} bind:value={tab} onchange={() => { limit = 25; saveContext(); }} idPrefix="beta-tabs" panelId="beta-list" ariaLabel="Beta views" /> <Tabs {tabs} bind:value={tab} onchange={() => { pageNumber = 1; saveContext(); }} idPrefix="beta-tabs" panelId="beta-list" ariaLabel="Beta views" />
<div class="ui-toolbar"><input type="search" bind:value={search} oninput={event => { search = event.currentTarget.value; limit = 25; saveContext(); }} aria-label="Search betas" placeholder="Search betas or files…" /></div> <div class="ui-toolbar"><input type="search" bind:value={search} oninput={event => { search = event.currentTarget.value; pageNumber = 1; saveContext(); }} aria-label="Search betas" placeholder="Search betas or files…" /></div>
{#if error}<div class="ui-error" role="alert">{error} {betas.length ? 'Showing the last loaded results.' : ''}<Button size="sm" onclick={load}>Retry</Button></div>{/if} {#if error}<div class="ui-error" role="alert">{error} {betas.length ? 'Showing the last loaded results.' : ''}<Button size="sm" onclick={() => load()}>Retry</Button></div>{/if}
<div id="beta-list" role="tabpanel" aria-labelledby={'beta-tabs-' + tab}> <div id="beta-list" role="tabpanel" aria-labelledby={'beta-tabs-' + tab}>
{#if loading}<EmptyState title="Loading betas…" /> {#if loading}<EmptyState title="Loading betas…" />
{:else if !filtered.length && !error}<EmptyState title={search ? 'No matching betas' : tab === 'active' ? 'No active betas' : 'No beta history'} description={tab === 'active' && !search ? 'Start a beta when you have a version ready to test.' : undefined} /> {:else if !betas.length && !error}<EmptyState title={search ? 'No matching betas' : tab === 'active' ? 'No active betas' : 'No beta history'} description={tab === 'active' && !search ? 'Start a beta when you have a version ready to test.' : undefined} />
{:else}<div class="rows">{#each filtered.slice(0,limit) as beta (beta.id)} {:else}<div class="rows">{#each betas as beta (beta.id)}
<a href={contextHref('/betas/' + beta.id, $page.url)} class="row"> <a href={contextHref('/betas/' + beta.id, $page.url)} class="row">
<div class="identity"><strong>{beta.notes || `Beta #${beta.id}`}</strong>{#if beta.notes}<span class="beta-id"> · #{beta.id}</span>{/if}<span class="files">{beta.scripts.map(s => s.name + ' · ' + artifactVersionLabel(s.version)).join(', ')}</span><span class="blocker">{blocker(beta)}</span><span class="timing"><time datetime={beta.opened_at} title={$timeFormat.dateTime(beta.opened_at)}>Opened {age(beta.opened_at)}</time>{#if beta.deadlineAt && active(beta)} · Ends {$timeFormat.dateTime(beta.deadlineAt)}{:else if beta.closed_at} · Finished {$timeFormat.dateTime(beta.closed_at)}{/if}</span></div> <div class="identity"><strong>{beta.notes || `Beta #${beta.id}`}</strong>{#if beta.notes}<span class="beta-id"> · #{beta.id}</span>{/if}<span class="files">{beta.scripts.map(s => s.name + ' · ' + artifactVersionLabel(s.version)).join(', ')}</span><span class="blocker">{blocker(beta)}</span><span class="timing"><time datetime={beta.opened_at} title={$timeFormat.dateTime(beta.opened_at)}>Opened {age(beta.opened_at)}</time>{#if beta.deadlineAt && active(beta)} · Ends {$timeFormat.dateTime(beta.deadlineAt)}{:else if beta.closed_at} · Finished {$timeFormat.dateTime(beta.closed_at)}{/if}</span></div>
<div class="meta"><StatusBadge state={beta.state} />{#if beta.testOutcome && beta.testOutcome !== 'active'}<StatusBadge state={beta.testOutcome} />{/if}<span>{beta.servers.filter(server=>server.stage==='ready').length} / {beta.servers.length} servers passed</span></div> <div class="meta"><StatusBadge state={beta.state} />{#if beta.testOutcome && beta.testOutcome !== 'active'}<StatusBadge state={beta.testOutcome} />{/if}<span>{beta.servers.filter(server=>server.stage==='ready').length} / {beta.servers.length} servers passed</span></div>
</a> </a>
{/each}</div><ListFooter total={filtered.length} bind:limit onchange={saveContext} label="betas" />{/if} {/each}</div><div class="ui-toolbar" aria-label="Beta pagination"><span>{total} betas · Page {pageNumber} of {totalPages}</span><Button size="sm" disabled={pageNumber <= 1} onclick={() => { pageNumber--; saveContext(); }}>Previous</Button><Button size="sm" disabled={pageNumber >= totalPages} onclick={() => { pageNumber++; saveContext(); }}>Next</Button></div>{/if}
</div> </div>
</div> </div>
<style> <style>
+25 -4
View File
@@ -9,7 +9,7 @@
import type { BetaPromotionPreview } from '$lib/beta-promotion.js'; import type { BetaPromotionPreview } from '$lib/beta-promotion.js';
import { coverageSummary, requirementSummary } from '$lib/beta-plan-summary.js'; import { coverageSummary, requirementSummary } from '$lib/beta-plan-summary.js';
import type { ActivityEvent } from '$lib/operation-flow.js'; import type { ActivityEvent } from '$lib/operation-flow.js';
import { pollVisible } from '$ui/poll.js'; import { betaUpdates } from '$ui/beta-updates.js';
import { confirmAction } from '$ui/confirm.js'; import { confirmAction } from '$ui/confirm.js';
import type { BetaRevision } from '$lib/beta-revision.js'; import type { BetaRevision } from '$lib/beta-revision.js';
import BetaRevisionDialog from '$ui/components/BetaRevisionDialog.svelte'; import BetaRevisionDialog from '$ui/components/BetaRevisionDialog.svelte';
@@ -34,6 +34,8 @@
let scripts = $state<BetaFile[]>([]); let scripts = $state<BetaFile[]>([]);
let servers = $state<BetaServer[]>([]); let servers = $state<BetaServer[]>([]);
let events = $state<ActivityEvent[]>([]); let events = $state<ActivityEvent[]>([]);
let activityBefore = $state<number|null>(null), nextBefore = $state<number|null>(null), activityLoading = $state(false);
let activityRequest = 0;
let promotion = $state<Promotion|null>(null); let promotion = $state<Promotion|null>(null);
let rollbackCampaign = $state<{ id: string }|null>(null); let rollbackCampaign = $state<{ id: string }|null>(null);
let loading = $state(true), loadError = $state(''), flash = $state(''), success = $state(''); let loading = $state(true), loadError = $state(''), flash = $state(''), success = $state('');
@@ -67,15 +69,33 @@
if (!res.ok) throw new Error(res.status === 404 ? 'Beta not found.' : 'Could not refresh beta. Showing the last loaded results.'); if (!res.ok) throw new Error(res.status === 404 ? 'Beta not found.' : 'Could not refresh beta. Showing the last loaded results.');
const body = await res.json(); const body = await res.json();
if (current !== request || requestedId !== id) return; if (current !== request || requestedId !== id) return;
test = body.test; beta = body.beta; scripts = body.scripts; servers = body.servers; events = body.events; promotion = body.promotion ?? null; test = body.test; beta = body.beta; scripts = body.scripts; servers = body.servers;
if (activityBefore === null) { events = body.events; nextBefore = body.eventsNextBefore ?? null; }
promotion = body.promotion ?? null;
rollbackCampaign = body.rollback ?? null; rollbackCampaign = body.rollback ?? null;
revision = body.revision; revisions = body.revisions ?? []; revision = body.revision; revisions = body.revisions ?? [];
now = Date.now(); loadError = ''; now = Date.now(); loadError = '';
} catch (error) { if (current === request && requestedId === id) loadError = (error as Error).message; } } catch (error) { if (current === request && requestedId === id) loadError = (error as Error).message; }
finally { if (current === request && requestedId === id) loading = false; } finally { if (current === request && requestedId === id) loading = false; }
} }
$effect(() => { id; beta = null; loading = true; loadError = ''; flash = ''; success = ''; reviewOpen = false; updateOpen = false; void load(); }); $effect(() => { id; activityRequest++; activityBefore = null; activityLoading = false; beta = null; loading = true; loadError = ''; flash = ''; success = ''; reviewOpen = false; updateOpen = false; void load(); });
onMount(() => pollVisible(load, 5000, false)); onMount(() => {
const stop = betaUpdates(load, () => id);
const clock = setInterval(() => { now = Date.now(); }, 30000);
return () => { stop(); clearInterval(clock); request++; activityRequest++; };
});
async function loadActivity(before: number|null) {
const current = ++activityRequest, requestedId = id;
activityLoading = true;
try {
const res = await fetch(`/api/betas/${id}/events` + (before === null ? '' : `?before=${before}`));
if (!res.ok) throw new Error('Could not load beta activity.');
const body = await res.json();
if (current !== activityRequest || requestedId !== id) return;
activityBefore = before; events = body.events; nextBefore = body.nextBefore; loadError = '';
} catch (error) { if (current === activityRequest) loadError = (error as Error).message; }
finally { if (current === activityRequest) activityLoading = false; }
}
async function reviewPromotion() { async function reviewPromotion() {
reviewOpen = true; preview = null; reviewError = ''; acting = 'preview'; reviewOpen = true; preview = null; reviewError = ''; acting = 'preview';
try { try {
@@ -181,6 +201,7 @@
</div></details> </div></details>
{/if} {/if}
<OperationHistory {events} active={!closed} showOverview={false}/> <OperationHistory {events} active={!closed} showOverview={false}/>
<div class="ui-toolbar" aria-label="Activity pagination">{#if activityBefore !== null}<Button size="sm" disabled={activityLoading} onclick={() => loadActivity(null)}>Latest activity</Button>{/if}{#if nextBefore !== null}<Button size="sm" disabled={activityLoading} onclick={() => loadActivity(nextBefore)}>Older activity</Button>{/if}</div>
</div> </div>
{/if} {/if}
+15 -3
View File
@@ -33,7 +33,7 @@ import urllib.parse
import urllib.request import urllib.request
import uuid import uuid
VERSION = "1.5.3" VERSION = "1.6.0"
LABEL = "io.electric-cherry" LABEL = "io.electric-cherry"
MAX_FILE = 32 * 1024 * 1024 MAX_FILE = 32 * 1024 * 1024
MANIFEST_MAX_ENTRIES = 10000 MANIFEST_MAX_ENTRIES = 10000
@@ -1356,7 +1356,7 @@ class Agent:
if line.startswith("PRETTY_NAME="): if line.startswith("PRETTY_NAME="):
os_name = line.split("=", 1)[1].strip('"') os_name = line.split("=", 1)[1].strip('"')
update = self.update_inventory() update = self.update_inventory()
capabilities = ["library.remove", "instance.cancel-create", "instance.safety-override", "operations.read-lane", "operations.health-lane", "rcon.status-batch", "instance.heartbeat-state", "files.manifest"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else []) capabilities = ["library.remove", "instance.cancel-create", "instance.safety-override", "operations.read-lane", "operations.health-lane", "rcon.status-batch", "instance.heartbeat-state", "files.manifest", "files.blobs"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else [])
return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": capabilities, "agentUpdate": update, "agentLanes": self.lane_inventory(), "os": os_name, "osVersion": platform.release(), "architecture": platform.machine(), "cpu": platform.processor() or platform.machine(), "cpuCount": os.cpu_count() or 0, **resources, **({"cpuUsagePercent": cpu_usage} if cpu_usage is not None else {}), "memoryTotal": memory.get("MemTotal", 0), "memoryAvailable": memory.get("MemAvailable", 0), "storageTotal": usage.total, "storageAvailable": usage.free, "dockerVersion": docker_version, "dockerRunning": docker_running, "directoriesWritable": os.access(self.root, os.W_OK), "runtimeImages": sorted(set(images)), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})} return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": capabilities, "agentUpdate": update, "agentLanes": self.lane_inventory(), "os": os_name, "osVersion": platform.release(), "architecture": platform.machine(), "cpu": platform.processor() or platform.machine(), "cpuCount": os.cpu_count() or 0, **resources, **({"cpuUsagePercent": cpu_usage} if cpu_usage is not None else {}), "memoryTotal": memory.get("MemTotal", 0), "memoryAvailable": memory.get("MemAvailable", 0), "storageTotal": usage.total, "storageAvailable": usage.free, "dockerVersion": docker_version, "dockerRunning": docker_running, "directoriesWritable": os.access(self.root, os.W_OK), "runtimeImages": sorted(set(images)), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})}
def safe_to_disrupt(self, instance_id, generation): def safe_to_disrupt(self, instance_id, generation):
@@ -1559,6 +1559,18 @@ class Agent:
managed_write(root, item["path"], content, int(self.config.get("runtimeUid", 1000)), int(self.config.get("runtimeGid", 1000))) managed_write(root, item["path"], content, int(self.config.get("runtimeUid", 1000)), int(self.config.get("runtimeGid", 1000)))
def decode_file(self, item): def decode_file(self, item):
if "contentSha256" in item:
sha = item["contentSha256"]
if not isinstance(sha, str) or not re.fullmatch(r"[a-f0-9]{64}", sha):
raise AgentError("Invalid file content hash")
url = self.config["controller"].rstrip("/") + "/api/agent/blobs/" + sha
with self.artifact_open("GET", url) as response:
content = response.read(MAX_FILE + 1)
if len(content) > MAX_FILE:
raise AgentError("File exceeds staging size limit")
if digest(content) != sha or item.get("sha256", sha) != sha:
raise AgentError("Staged file checksum mismatch")
return content
try: try:
content = base64.b64decode(item["contentBase64"], validate=True) content = base64.b64decode(item["contentBase64"], validate=True)
except (ValueError, KeyError, TypeError): except (ValueError, KeyError, TypeError):
@@ -1677,7 +1689,7 @@ class Agent:
if item["path"] in paths: if item["path"] in paths:
raise AgentError("Duplicate staged file path") raise AgentError("Duplicate staged file path")
paths.add(item["path"]) paths.add(item["path"])
deleting = item.get("delete") is True or item.get("contentBase64") is None deleting = item.get("delete") is True or (item.get("contentBase64") is None and "contentSha256" not in item)
content = None if deleting else self.decode_file(item) content = None if deleting else self.decode_file(item)
total_size += len(content) if content is not None else 0 total_size += len(content) if content is not None else 0
if total_size > 64 * 1024 * 1024: if total_size > 64 * 1024 * 1024:
+2 -2
View File
@@ -1,8 +1,8 @@
{ {
"version": "1.5.3", "version": "1.6.0",
"protocolVersion": 1, "protocolVersion": 1,
"platform": "debian-amd64", "platform": "debian-amd64",
"sha256": "ebd6f67a44fdb4bd0683597d4abd54999311473561204de368864b04a5436256", "sha256": "bcc0a207125f2a1ad754c294decf2238064995ba93183b845a589a41a41925f6",
"updaterProtocolVersion": 1, "updaterProtocolVersion": 1,
"updaterSha256": "066bb896726600ece9ce72697ce523c9a707cc9a621505931ba0892dec59d3c8" "updaterSha256": "066bb896726600ece9ce72697ce523c9a707cc9a621505931ba0892dec59d3c8"
} }
+48
View File
@@ -0,0 +1,48 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
afterEach(async () => { await fixture.cleanup(); });
it('bounds beta pages, searches across retained history, and seeks activity without duplicates', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const { betaListPage, betaActivityPage } = await import('../../src/lib/server/fleet/beta-pages.js');
db().exec(`INSERT INTO scripts(id,name,target_rel_path) VALUES(1,'Plugin','plugin.dll');
INSERT INTO script_versions(id,script_id,version,sha256,size) VALUES(1,1,1,'hash',0)`);
db().transaction(() => {
for (let id = 1; id <= 125; id++) {
db().prepare('INSERT INTO betas(id,script_id,script_version_id,trigger_kind,state,notes) VALUES(?,1,1,?,?,?)')
.run(id, 'ui', id <= 5 ? 'running' : 'promoted', `Test ${id}`);
db().prepare('INSERT INTO beta_scripts(beta_id,script_id,script_version_id) VALUES(?,1,1)').run(id);
}
for (let i = 0; i < 150; i++) db().prepare('INSERT INTO beta_events(beta_id,message) VALUES(1,?)').run(`Activity ${i}`);
})();
const page = betaListPage(new URLSearchParams());
expect(page.betas).toHaveLength(25);
expect(page).toMatchObject({ total: 125, page: 1, totalPages: 5, counts: { active: 5, history: 120 } });
expect(betaListPage(new URLSearchParams('view=active')).betas.map(beta => beta.id)).toEqual([5,4,3,2,1]);
expect(betaListPage(new URLSearchParams('view=history&q=Test%20101')).betas.map(beta => beta.id)).toEqual([101]);
expect(betaListPage(new URLSearchParams('q=Plugin&limit=100000')).betas).toHaveLength(100);
const second = betaListPage(new URLSearchParams('page=2'));
expect(second.betas.some(beta => page.betas.some(other => other.id === beta.id))).toBe(false);
const latest = betaActivityPage(1);
expect(latest.events).toHaveLength(50);
db().prepare("INSERT INTO beta_events(beta_id,message) VALUES(1,'New event')").run();
const previous = betaActivityPage(1, new URLSearchParams({ before: String(latest.nextBefore) }));
expect(previous.events).toHaveLength(50);
expect(previous.events.at(-1)!.id).toBeLessThan(latest.events[0].id);
const { GET } = await import('../../src/routes/api/betas/[id]/+server.js');
expect((await (await GET({ params: { id: '1' } })).json()).events).toHaveLength(50);
});
it('coalesces beta progress notifications after synchronous database work', async () => {
const { invalidateBeta, subscribe } = await import('../../src/lib/server/pipeline/bus.js');
const events: unknown[] = [];
const stop = subscribe(event => events.push(event));
try {
invalidateBeta(1); invalidateBeta(1); invalidateBeta(2);
expect(events).toEqual([]);
await Promise.resolve();
expect(events).toEqual([{ type: 'beta-changed', betaId: 1 }, { type: 'beta-changed', betaId: 2 }]);
} finally { stop(); }
});
+5 -4
View File
@@ -1,3 +1,4 @@
import { putFileBlob } from '../../src/lib/server/db/file-blobs.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
@@ -35,13 +36,13 @@ describe('reviewed beta promotion', () => {
it('upgrades existing campaign history without marking known snapshots pending', async () => { it('upgrades existing campaign history without marking known snapshots pending', async () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js'); const { db, closeDb } = await import('../../src/lib/server/db/index.js');
db().prepare("INSERT INTO deployments (id, action, started_at) VALUES ('legacy', 'promote', ?)").run(new Date().toISOString()); db().prepare("INSERT INTO deployments (id, action, started_at) VALUES ('legacy', 'promote', ?)").run(new Date().toISOString());
db().prepare("INSERT INTO deployment_files (deployment_id, server, verb, rel_path, content_before) VALUES ('legacy', ?, 'updated', 'old.gsc', ?)") db().prepare("INSERT INTO deployment_files (deployment_id, server, verb, rel_path, content_before_hash) VALUES ('legacy', ?, 'updated', 'old.gsc', ?)")
.run(servers[0], Buffer.from('original')); .run(servers[0], putFileBlob(Buffer.from('original'), db()));
closeDb(); closeDb();
const legacy = new Database(fixture.dbPath); const legacy = new Database(fixture.dbPath);
try { legacy.exec('ALTER TABLE deployment_files DROP COLUMN snapshot_pending'); } try { legacy.exec('ALTER TABLE deployment_files DROP COLUMN snapshot_pending'); }
finally { legacy.close(); } finally { legacy.close(); }
expect(db().prepare('SELECT snapshot_pending, content_before FROM deployment_files').get()) expect(db().prepare('SELECT snapshot_pending, (SELECT content FROM file_blobs WHERE sha256=content_before_hash) AS content_before FROM deployment_files').get())
.toEqual({ snapshot_pending: 0, content_before: Buffer.from('original') }); .toEqual({ snapshot_pending: 0, content_before: Buffer.from('original') });
}); });
it.each(['script', 'baseline'] as const)('queues %s promotion without contacting additional host agents', async kind => { it.each(['script', 'baseline'] as const)('queues %s promotion without contacting additional host agents', async kind => {
@@ -82,7 +83,7 @@ describe('reviewed beta promotion', () => {
await writeFile(path, before); await writeFile(path, before);
} }
await activateFiles('campaign', result.deploymentId); await activateFiles('campaign', result.deploymentId);
expect(db().prepare('SELECT snapshot_pending, verb, content_before FROM deployment_files WHERE id = ?').get(file.id)) expect(db().prepare('SELECT snapshot_pending, verb, (SELECT content FROM file_blobs WHERE sha256=content_before_hash) AS content_before FROM deployment_files WHERE id = ?').get(file.id))
.toEqual({ snapshot_pending: 0, verb: before === null ? 'added' : 'updated', content_before: before === null ? null : Buffer.from(before) }); .toEqual({ snapshot_pending: 0, verb: before === null ? 'added' : 'updated', content_before: before === null ? null : Buffer.from(before) });
expect(db().prepare('SELECT message FROM campaign_events WHERE deployment_id = ?').all(result.deploymentId)) expect(db().prepare('SELECT message FROM campaign_events WHERE deployment_id = ?').all(result.deploymentId))
.toContainEqual({ message: `${servers[1]}: captured rollback snapshot for ${kind === 'script' ? 'review.gsc' : configFileOf(servers[1])}` }); .toContainEqual({ message: `${servers[1]}: captured rollback snapshot for ${kind === 'script' ? 'review.gsc' : configFileOf(servers[1])}` });
+2 -2
View File
@@ -61,7 +61,7 @@ describe('beta revisions', () => {
expect(history[0]).toMatchObject({ revision: 2, notes: 'Small fix', results: null }); expect(history[0]).toMatchObject({ revision: 2, notes: 'Small fix', results: null });
expect(history[1].results?.test?.servers[0].passed).toBe(true); expect(history[1].results?.test?.servers[0].passed).toBe(true);
expect(history[1].results?.test?.evidence).toHaveLength(1); expect(history[1].results?.test?.evidence).toHaveLength(1);
expect(x.db.prepare('SELECT content FROM beta_revision_files WHERE beta_id = ? AND revision = 1').get(x.betaId)) expect(x.db.prepare('SELECT (SELECT content FROM file_blobs WHERE sha256=content_hash) AS content FROM beta_revision_files WHERE beta_id = ? AND revision = 1').get(x.betaId))
.toEqual({ content: Buffer.from('revision one') }); .toEqual({ content: Buffer.from('revision one') });
expect(await readFile(x.path, 'utf8')).toBe('revision one'); expect(await readFile(x.path, 'utf8')).toBe('revision one');
await expect(x.promoteBeta(x.betaId)).rejects.toThrow('has not passed'); await expect(x.promoteBeta(x.betaId)).rejects.toThrow('has not passed');
@@ -154,7 +154,7 @@ describe('beta revisions', () => {
it.each(['stale', 'identical', 'empty', 'unbundled', 'duplicate', 'closed', 'deleted', 'interrupted', 'activating'])('rejects %s updates without saving a revision', async reason => { it.each(['stale', 'identical', 'empty', 'unbundled', 'duplicate', 'closed', 'deleted', 'interrupted', 'activating'])('rejects %s updates without saving a revision', async reason => {
const x = await setup(); const x = await setup();
const upload = { scriptId: x.script.id, content: Buffer.from('changed') }; const upload = { scriptId: x.script.id, content: Buffer.from('changed') };
if (reason === 'identical') upload.content = Buffer.from(x.version.content); if (reason === 'identical') upload.content = Buffer.from((await import('../../src/lib/server/fleet/scripts.js')).getVersionContent(x.version.id));
if (reason === 'empty') upload.content = Buffer.alloc(0); if (reason === 'empty') upload.content = Buffer.alloc(0);
if (reason === 'unbundled') upload.scriptId = 999; if (reason === 'unbundled') upload.scriptId = 999;
if (reason === 'closed') x.db.prepare("UPDATE betas SET state = 'promoted' WHERE id = ?").run(x.betaId); if (reason === 'closed') x.db.prepare("UPDATE betas SET state = 'promoted' WHERE id = ?").run(x.betaId);
+2 -2
View File
@@ -193,7 +193,7 @@ describe('staged beta files', () => {
const { destOfKind, configFileOf } = await import('../../src/lib/server/config.js'); const { destOfKind, configFileOf } = await import('../../src/lib/server/config.js');
const relPath = kind === 'script' ? script.target_rel_path : configFileOf(servers[1]); const relPath = kind === 'script' ? script.target_rel_path : configFileOf(servers[1]);
expect(await readFile(join(destOfKind(servers[1], kind), relPath), 'utf8')).toContain(newestBytes.trim()); expect(await readFile(join(destOfKind(servers[1], kind), relPath), 'utf8')).toContain(newestBytes.trim());
expect(db().prepare('SELECT content_before FROM deployment_files WHERE deployment_id = ? AND server = ?') expect(db().prepare('SELECT (SELECT content FROM file_blobs WHERE sha256=content_before_hash) AS content_before FROM deployment_files WHERE deployment_id = ? AND server = ?')
.get(latest.deploymentId, servers[1])).toEqual({ content_before: null }); .get(latest.deploymentId, servers[1])).toEqual({ content_before: null });
}); });
@@ -219,7 +219,7 @@ describe('staged beta files', () => {
await activateFiles('campaign', result.deploymentId); await activateFiles('campaign', result.deploymentId);
expect(await readFile(path, 'utf8')).toContain('set sv_voice "0"'); expect(await readFile(path, 'utf8')).toContain('set sv_voice "0"');
} }
const history = db().prepare('SELECT content_before FROM deployment_files WHERE deployment_id = ? AND server = ?') const history = db().prepare('SELECT (SELECT content FROM file_blobs WHERE sha256=content_before_hash) AS content_before FROM deployment_files WHERE deployment_id = ? AND server = ?')
.get(result.deploymentId, servers[0]) as { content_before: Buffer }; .get(result.deploymentId, servers[0]) as { content_before: Buffer };
expect(history.content_before.toString()).toBe('original configuration'); expect(history.content_before.toString()).toBe('original configuration');
const { rollbackPromoteDeployment } = await import('../../src/lib/server/fleet/rollback.js'); const { rollbackPromoteDeployment } = await import('../../src/lib/server/fleet/rollback.js');
+25
View File
@@ -0,0 +1,25 @@
import { afterEach, expect, it, vi } from 'vitest';
import { betaUpdates } from '../../src/lib/ui/beta-updates.js';
afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); });
it('refreshes relevant pushes and reconnects without polling, pauses hidden tabs, and cleans up', async () => {
vi.useFakeTimers();
const window = new EventTarget(), document = Object.assign(new EventTarget(), { hidden: false });
vi.stubGlobal('window', window); vi.stubGlobal('document', document);
let finish!: () => void;
const load = vi.fn(() => new Promise<void>(resolve => { finish = resolve; }));
const stop = betaUpdates(load, () => 7);
const change = (id: number) => window.dispatchEvent(Object.assign(new Event('ec:beta-changed'), { detail: { betaId: id } }));
try {
change(2); await vi.advanceTimersByTimeAsync(1000); expect(load).not.toHaveBeenCalled();
change(7); change(7); await vi.advanceTimersByTimeAsync(200); expect(load).toHaveBeenCalledTimes(1);
change(7); await vi.advanceTimersByTimeAsync(200); expect(load).toHaveBeenCalledTimes(1);
finish(); await vi.advanceTimersByTimeAsync(200); expect(load).toHaveBeenCalledTimes(2);
finish(); await vi.advanceTimersByTimeAsync(30000); expect(load).toHaveBeenCalledTimes(2);
document.hidden = true; change(7); await vi.advanceTimersByTimeAsync(1000); expect(load).toHaveBeenCalledTimes(2);
document.hidden = false; document.dispatchEvent(new Event('visibilitychange'));
await vi.advanceTimersByTimeAsync(200); expect(load).toHaveBeenCalledTimes(3); finish();
window.dispatchEvent(new Event('ec:pipeline-connected'));
await vi.advanceTimersByTimeAsync(200); expect(load).toHaveBeenCalledTimes(4); finish();
stop(); change(7); await vi.advanceTimersByTimeAsync(1000); expect(load).toHaveBeenCalledTimes(4);
} finally { stop(); }
});
+1 -1
View File
@@ -50,7 +50,7 @@ describe('campaign resume API', () => {
expect(db().prepare('SELECT restart_state, error, provisioning_id FROM deployment_servers WHERE deployment_id = ?').get(campaign)) expect(db().prepare('SELECT restart_state, error, provisioning_id FROM deployment_servers WHERE deployment_id = ?').get(campaign))
.toEqual({ restart_state: 'pending', error: null, provisioning_id: null }); .toEqual({ restart_state: 'pending', error: null, provisioning_id: null });
expect(db().prepare('SELECT finished_at FROM deployments WHERE id = ?').get(campaign)).toEqual({ finished_at: null }); expect(db().prepare('SELECT finished_at FROM deployments WHERE id = ?').get(campaign)).toEqual({ finished_at: null });
expect(db().prepare('SELECT state, content FROM staged_file_changes WHERE source_id = ?').get(campaign)) expect(db().prepare('SELECT state, (SELECT content FROM file_blobs WHERE sha256=content_hash) AS content FROM staged_file_changes WHERE source_id = ?').get(campaign))
.toEqual({ state: 'pending', content: restored }); .toEqual({ state: 'pending', content: restored });
expect(await readFile(destination())).toEqual(before); expect(await readFile(destination())).toEqual(before);
expect(kick).toHaveBeenCalledTimes(1); expect(kick).toHaveBeenCalledTimes(1);
+44
View File
@@ -0,0 +1,44 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
afterEach(async () => { await fixture.cleanup(); });
it('indexes campaign cascades and beta evidence on fresh and reopened databases', async () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
for (let pass = 0; pass < 2; pass++) {
const handle = db();
for (const [query, index] of [
['SELECT id FROM staged_file_changes WHERE deployment_file_id = 1', 'idx_staged_files_deployment_file'],
['SELECT id FROM beta_test_runs WHERE beta_id = 1 AND revision = 2', 'idx_beta_test_runs_beta_revision']
]) {
const plan = handle.prepare(`EXPLAIN QUERY PLAN ${query}`).all() as { detail: string }[];
expect(plan.some(row => row.detail.includes(index))).toBe(true);
}
expect(handle.pragma('foreign_key_check')).toEqual([]);
closeDb();
}
});
it('covers every foreign key child prefix, including lazy host and library schemas', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const { ensureHostSchema } = await import('../../src/lib/server/hosts/schema.js');
const { ensureGameFileSchema } = await import('../../src/lib/server/game-files/schema.js');
const handle = db();
ensureHostSchema(handle); ensureGameFileSchema(handle);
const tables = handle.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as { name: string }[];
const missing: string[] = [];
for (const { name } of tables) {
const foreignKeys = handle.pragma(`foreign_key_list(${name})`) as { from: string; seq: number }[];
const indexes = handle.pragma(`index_list(${name})`) as { name: string; partial: number }[];
const prefixes = indexes.filter(index => !index.partial).map(index =>
(handle.pragma(`index_info(${index.name})`) as { name: string }[])[0]?.name);
const columns = handle.pragma(`table_info(${name})`) as { name: string; type: string; pk: number }[];
if (columns.filter(column => column.pk).length === 1) prefixes.push(columns.find(column => column.pk)!.name);
for (const key of foreignKeys.filter(key => key.seq === 0)) {
if (!prefixes.includes(key.from)) missing.push(`${name}.${key.from}`);
}
}
expect(missing).toEqual([]);
});
+17
View File
@@ -9,6 +9,23 @@ beforeEach(async () => {
afterEach(async () => { await fixture.cleanup(); }); afterEach(async () => { await fixture.cleanup(); });
describe('prepared statement caching', () => { describe('prepared statement caching', () => {
it('tunes the controller connection and stops periodic optimization when closed', async () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] });
try {
const handle = db();
expect(handle.pragma('cache_size', { simple: true })).toBe(-131072);
expect(handle.pragma('temp_store', { simple: true })).toBe(2);
expect(handle.pragma('journal_size_limit', { simple: true })).toBe(67108864);
const pragma = vi.spyOn(handle, 'pragma');
vi.advanceTimersByTime(60 * 60_000);
expect(pragma).toHaveBeenCalledWith('optimize');
pragma.mockClear();
closeDb();
vi.advanceTimersByTime(60 * 60_000);
expect(pragma).not.toHaveBeenCalled();
} finally { vi.useRealTimers(); }
});
it('reuses statements with fresh parameters and remains usable after rollback', async () => { it('reuses statements with fresh parameters and remains usable after rollback', async () => {
const { db } = await import('../../src/lib/server/db/index.js'); const { db } = await import('../../src/lib/server/db/index.js');
const insertSql = 'INSERT INTO settings (key, value) VALUES (?, ?)'; const insertSql = 'INSERT INTO settings (key, value) VALUES (?, ?)';
+5 -4
View File
@@ -1,3 +1,4 @@
import { putFileBlob } from '../../src/lib/server/db/file-blobs.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
@@ -143,7 +144,7 @@ describe('reviewed drift plans', () => {
const path = join(fixture.t5, 't5_zombie_theater_1', 'configs', 'dedicated_sp.cfg'); const path = join(fixture.t5, 't5_zombie_theater_1', 'configs', 'dedicated_sp.cfg');
await writeFile(path, 'existing settings'); await writeFile(path, 'existing settings');
const plan = await previewDrift(['t5_zombie_theater_1']); const plan = await previewDrift(['t5_zombie_theater_1']);
db().prepare('UPDATE script_versions SET content = ? WHERE id = ?').run(Buffer.from('set sv_voice "0"\n'), version.id); db().prepare('UPDATE script_versions SET content_hash = ? WHERE id = ?').run(putFileBlob(Buffer.from('set sv_voice "0"\n'), db()), version.id);
await expect(applyDriftDeployment({ servers: plan.servers, expectedFingerprint: plan.fingerprint })).rejects.toBeInstanceOf(DriftPlanChanged); await expect(applyDriftDeployment({ servers: plan.servers, expectedFingerprint: plan.fingerprint })).rejects.toBeInstanceOf(DriftPlanChanged);
expect(await readFile(path, 'utf8')).toBe('existing settings'); expect(await readFile(path, 'utf8')).toBe('existing settings');
}); });
@@ -316,7 +317,7 @@ describe('drift scanning', () => {
expect(result).toMatchObject({ totalRemoved: 0, totalRestored: 1 }); expect(result).toMatchObject({ totalRemoved: 0, totalRestored: 1 });
expect(await readFile(target)).toEqual(content); expect(await readFile(target)).toEqual(content);
expect(getServerScript(server, 'zm_spawn_fix.gsc')?.script_version_id).toBe(version.id); expect(getServerScript(server, 'zm_spawn_fix.gsc')?.script_version_id).toBe(version.id);
expect(db().prepare('SELECT verb, content_before, content_after FROM deployment_files WHERE deployment_id = ?').all(result.deploymentId)) expect(db().prepare('SELECT verb, (SELECT content FROM file_blobs WHERE sha256=content_before_hash) AS content_before, (SELECT content FROM file_blobs WHERE sha256=content_after_hash) AS content_after FROM deployment_files WHERE deployment_id = ?').all(result.deploymentId))
.toEqual([{ verb: 'updated', content_before: before, content_after: content }]); .toEqual([{ verb: 'updated', content_before: before, content_after: content }]);
expect(await scanServerDrift(server)).toEqual([]); expect(await scanServerDrift(server)).toEqual([]);
expect((await applyDriftDeployment({ servers: [server] })).deploymentId).toBeNull(); expect((await applyDriftDeployment({ servers: [server] })).deploymentId).toBeNull();
@@ -376,9 +377,9 @@ describe('drift rebuild campaigns', () => {
expect(campaignId).toBeTruthy(); expect(campaignId).toBeTruthy();
expect(await readFile(target, 'utf8')).toBe('old content'); expect(await readFile(target, 'utf8')).toBe('old content');
expect(await readFile(stray, 'utf8')).toBe('untracked'); expect(await readFile(stray, 'utf8')).toBe('untracked');
expect(db().prepare('SELECT rel_path, verb, content_before, content_after FROM deployment_files WHERE deployment_id = ? ORDER BY rel_path').all(campaignId)) expect(db().prepare('SELECT rel_path, verb, (SELECT content FROM file_blobs WHERE sha256=content_before_hash) AS content_before, (SELECT content FROM file_blobs WHERE sha256=content_after_hash) AS content_after FROM deployment_files WHERE deployment_id = ? ORDER BY rel_path').all(campaignId))
.toEqual([ .toEqual([
{ rel_path: 'managed.gsc', verb: 'updated', content_before: Buffer.from('old content'), content_after: version.content }, { rel_path: 'managed.gsc', verb: 'updated', content_before: Buffer.from('old content'), content_after: (await import('../../src/lib/server/fleet/scripts.js')).getVersionContent(version.id) },
{ rel_path: 'stray.cfg', verb: 'removed', content_before: Buffer.from('untracked'), content_after: null } { rel_path: 'stray.cfg', verb: 'removed', content_before: Buffer.from('untracked'), content_after: null }
]); ]);
await sweep(); await sweep();
+85
View File
@@ -0,0 +1,85 @@
import Database from 'better-sqlite3';
import { createHash } from 'node:crypto';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import schema from '../../src/lib/server/db/schema.sql?raw';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
afterEach(async () => { vi.restoreAllMocks(); await fixture.cleanup(); });
it('shares bytes across versions, staged copies and commands, and scopes downloads to the assigned host', async () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
const { queueFileChange } = await import('../../src/lib/server/fleet/staged-files.js');
const hosts = await import('../../src/lib/server/hosts/index.js');
const { pruneFileBlobs } = await import('../../src/lib/server/db/file-blobs.js');
const content = Buffer.alloc(1024 * 1024, 71);
const hash = createHash('sha256').update(content).digest('hex');
for (let i = 0; i < 20; i++) {
createScript({ name: `plugin-${i}`, kind: 'plugin', targetRelPath: 'plugin.dll', content });
queueFileChange({ sourceKind: 'campaign', sourceId: 'campaign', server: `t5_server_${i}`, kind: 'plugin', relPath: 'plugin.dll', content, inventorySource: 'test' });
}
const host = hosts.createHost({ name: 'Owner' });
const credential = hosts.enrollHost(hosts.createEnrollment(host.id).token, { capabilities: ['files.blobs'] }).credential;
const other = hosts.createHost({ name: 'Other' });
const otherCredential = hosts.enrollHost(hosts.createEnrollment(other.id).token, {}).credential;
const payload = { stageId: 'one', files: [{ path: 'plugins/plugin.dll', contentBase64: content.toString('base64'), sha256: hash }] };
const op = hosts.queueHostOperation(host.id, 'files.stage', payload, { idempotencyKey: 'one' });
expect(hosts.queueHostOperation(host.id, 'files.stage', payload, { idempotencyKey: 'one' }).id).toBe(op.id);
expect(() => hosts.queueHostOperation(host.id, 'files.stage', { ...payload, stageId: 'two' }, { idempotencyKey: 'one' })).toThrow('different request');
expect(db().prepare('SELECT COUNT(*) n,SUM(size) bytes FROM file_blobs').get()).toEqual({ n: 1, bytes: content.length });
const packed = JSON.parse(hosts.getHostOperationPayload(host.id, op.id).toString());
expect(packed.files[0]).toEqual({ path: 'plugins/plugin.dll', contentSha256: hash, sha256: hash });
expect(pruneFileBlobs()).toBe(0);
const { GET } = await import('../../src/routes/api/agent/blobs/[sha256]/+server.js');
const request = (token: string) => new Request(`https://ec.test/api/agent/blobs/${hash}`, { headers: { authorization: `Bearer ${token}` } });
expect(Buffer.from(await GET({ request: request(credential), params: { sha256: hash } }).arrayBuffer())).toEqual(content);
expect(() => GET({ request: request(otherCredential), params: { sha256: hash } })).toThrow();
closeDb();
expect(hosts.getHostOperation(op.id)?.payload).toEqual(payload);
// Previous releases receive their original inline protocol during a rolling upgrade.
hosts.heartbeat(host.id, {});
expect(JSON.parse(hosts.getHostOperationPayload(host.id, op.id).toString())).toEqual(payload);
});
it('backfills legacy bytes once while preserving foreign keys, IDs and pending work', async () => {
const legacy = new Database(fixture.dbPath);
const content = Buffer.from([0, 255, 1, 88]);
const hash = createHash('sha256').update(content).digest('hex');
try {
legacy.exec(schema.replace(/\b(content|content_before|content_after|prior_content)_hash(\s+)TEXT REFERENCES file_blobs\(sha256\)/g, '$1$2BLOB'));
legacy.exec("INSERT INTO scripts(id,name,target_rel_path) VALUES(7,'legacy','plugin.dll')");
legacy.prepare('INSERT INTO script_versions(id,script_id,version,sha256,size,content) VALUES(9,7,1,?,?,?)').run(hash, content.length, content);
legacy.prepare("INSERT INTO staged_file_changes(source_kind,source_id,server,kind,rel_path,content,inventory_source) VALUES('campaign','old','t5_one','plugin','plugin.dll',?,'test')").run(content);
legacy.exec("INSERT INTO deployments(id,action,started_at) VALUES('old','promote','2026-10-01T00:00:00.000Z')");
legacy.prepare("INSERT INTO deployment_files(deployment_id,server,verb,rel_path,content_before,content_after) VALUES('old','t5_one','updated','plugin.dll',?,?)").run(content, content);
} finally { legacy.close(); }
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const { getVersionContent } = await import('../../src/lib/server/fleet/scripts.js');
for (let pass = 0; pass < 2; pass++) {
expect(getVersionContent(9)).toEqual(content);
expect(db().prepare('SELECT content_hash,state FROM staged_file_changes').get()).toEqual({ content_hash: hash, state: 'pending' });
expect(db().prepare('SELECT COUNT(*) n FROM file_blobs').get()).toEqual({ n: 1 });
expect(db().pragma('foreign_key_check')).toEqual([]);
expect((db().pragma('table_info(script_versions)') as { name: string }[]).some(column => column.name === 'content')).toBe(false);
closeDb();
}
});
it('migrates legacy command and receipt bytes without changing replay identity', async () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const hosts = await import('../../src/lib/server/hosts/index.js');
const host = hosts.createHost({ name: 'Legacy' });
hosts.enrollHost(hosts.createEnrollment(host.id).token, {});
const payload = { stageId: 'legacy', files: [{ path: 'plugin.dll', contentBase64: 'AAEC' }] };
const result = { before: [{ path: 'plugin.dll', contentBase64: 'AAEC' }] };
db().prepare(`INSERT INTO ec_host_operations(id,host_id,kind,idempotency_key,payload,state,result,created_at,updated_at)
VALUES('legacy',?,'files.stage','legacy',?,'running',?, ?,?)`).run(host.id, JSON.stringify(payload), JSON.stringify(result), new Date().toISOString(), new Date().toISOString());
db().prepare("DELETE FROM ec_host_schema_migrations WHERE name IN ('staged-operation-payloads','operation-file-blobs')").run();
closeDb();
expect(hosts.getHostOperation('legacy')).toMatchObject({ payload, result, state: 'running' });
expect(hosts.queueHostOperation(host.id, 'files.stage', payload, { idempotencyKey: 'legacy' }).id).toBe('legacy');
expect(db().prepare('SELECT COUNT(*) n FROM file_blobs').get()).toEqual({ n: 1 });
expect(hosts.getHostOperationStatus('legacy')?.result).toEqual({ before: [{ path: 'plugin.dll', contentSha256: createHash('sha256').update(Buffer.from('AAEC', 'base64')).digest('hex') }] });
});
+1 -1
View File
@@ -113,7 +113,7 @@ describe('persisted Fleet operations', () => {
const { fleetOperations, serverOperations } = await import('../../src/lib/server/fleet/overview.js'); const { fleetOperations, serverOperations } = await import('../../src/lib/server/fleet/overview.js');
expect(fleetOperations(now)[0]).toMatchObject({ kind: 'recovery', state: 'attention', detail: 'RCON not answering' }); expect(fleetOperations(now)[0]).toMatchObject({ kind: 'recovery', state: 'attention', detail: 'RCON not answering' });
handle.prepare("INSERT INTO scripts (id, name, target_rel_path) VALUES (1, 'test', 'test.gsc')").run(); handle.prepare("INSERT INTO scripts (id, name, target_rel_path) VALUES (1, 'test', 'test.gsc')").run();
handle.prepare("INSERT INTO script_versions (id, script_id, version, sha256, size, content) VALUES (1, 1, 1, 'hash', 0, X'')").run(); handle.prepare("INSERT INTO script_versions (id, script_id, version, sha256, size, content_hash) VALUES (1, 1, 1, 'hash', 0, NULL)").run();
handle.prepare("INSERT INTO betas (id, script_id, script_version_id, trigger_kind, state) VALUES (1, 1, 1, 'ui', 'running')").run(); handle.prepare("INSERT INTO betas (id, script_id, script_version_id, trigger_kind, state) VALUES (1, 1, 1, 'ui', 'running')").run();
handle.prepare("INSERT INTO beta_servers (beta_id, server, had_original, stage) VALUES (1, 'server', 0, 'awaiting-match')").run(); handle.prepare("INSERT INTO beta_servers (beta_id, server, had_original, stage) VALUES (1, 'server', 0, 'awaiting-match')").run();
expect(fleetOperations(now)).toContainEqual(expect.objectContaining({ kind: 'beta', label: 'Observing a match', href: '/betas/1' })); expect(fleetOperations(now)).toContainEqual(expect.objectContaining({ kind: 'beta', label: 'Observing a match', href: '/betas/1' }));
+2
View File
@@ -121,6 +121,8 @@ it('applies saved retention to cleanup while protecting unfinished campaigns and
expect(db().prepare('SELECT id FROM deployments').all()).toHaveLength(4); expect(db().prepare('SELECT id FROM deployments').all()).toHaveLength(4);
saveFleetSettings({ ...fleetSettings(), deployKeepDays: 7 }); saveFleetSettings({ ...fleetSettings(), deployKeepDays: 7 });
await sweep(); await sweep();
const { pruneHistory } = await import('../../src/lib/server/retention.js');
pruneHistory();
expect(db().prepare('SELECT id FROM deployments ORDER BY id').all()).toEqual([{ id: 'failed' }, { id: 'held' }, { id: 'pending' }]); expect(db().prepare('SELECT id FROM deployments ORDER BY id').all()).toEqual([{ id: 'failed' }, { id: 'held' }, { id: 'pending' }]);
expect(db().prepare("SELECT server FROM deployment_servers WHERE deployment_id = 'completed'").all()).toEqual([]); expect(db().prepare("SELECT server FROM deployment_servers WHERE deployment_id = 'completed'").all()).toEqual([]);
expect(db().prepare('SELECT id FROM server_deployments').all()).toEqual([{ id: 'history' }]); expect(db().prepare('SELECT id FROM server_deployments').all()).toEqual([{ id: 'history' }]);
+2 -1
View File
@@ -72,7 +72,8 @@ describe('GitHub DLL imports', () => {
expect(imported.sha256).toBe(sha256); expect(imported.sha256).toBe(sha256);
expect(imported.betaUrl).toBe(`/betas?pluginId=${imported.pluginId}&versionId=${imported.versionId}`); expect(imported.betaUrl).toBe(`/betas?pluginId=${imported.pluginId}&versionId=${imported.versionId}`);
expect(getScript(imported.pluginId)).toMatchObject({ kind: 'plugin', target_rel_path: 't6-gsc-utils.dll', scope_value: 't6', current_version_id: null }); expect(getScript(imported.pluginId)).toMatchObject({ kind: 'plugin', target_rel_path: 't6-gsc-utils.dll', scope_value: 't6', current_version_id: null });
expect(getVersion(imported.versionId)).toMatchObject({ release_version: 'v3.1.0', content, notes: metadata().body, state: 'untested' }); expect(getVersion(imported.versionId)).toMatchObject({ release_version: 'v3.1.0', notes: metadata().body, state: 'untested' });
expect((await import('../../src/lib/server/fleet/scripts.js')).getVersionContent(imported.versionId)).toEqual(content);
expect(pluginSource(imported.pluginId)).toMatchObject({ repository: 'alicealys/t6-gsc-utils', release_id: 100, asset_id: 200, release_url: url }); expect(pluginSource(imported.pluginId)).toMatchObject({ repository: 'alicealys/t6-gsc-utils', release_id: 100, asset_id: 200, release_url: url });
const duplicates = await Promise.all([1, 2].map(() => importPluginRelease({ url, assetId: 200, pluginId: imported.pluginId }))); const duplicates = await Promise.all([1, 2].map(() => importPluginRelease({ url, assetId: 200, pluginId: imported.pluginId })));
expect(duplicates.every(r => r.alreadyImported && r.versionId === imported.versionId)).toBe(true); expect(duplicates.every(r => r.alreadyImported && r.versionId === imported.versionId)).toBe(true);
@@ -179,6 +179,9 @@ describe('routine host observation retention', () => {
retained.push(malformed); retained.push(malformed);
api.handle.exec('DROP INDEX ec_host_operations_observation_retention'); api.handle.exec('DROP INDEX ec_host_operations_observation_retention');
api.handle.exec('DROP INDEX ec_host_operations_important_history'); api.handle.exec('DROP INDEX ec_host_operations_important_history');
api.handle.exec(`DROP TRIGGER operation_history_count_insert; DROP TRIGGER operation_history_count_delete;
DROP TRIGGER operation_history_count_update; DROP TABLE ec_host_operation_history_counts;
DELETE FROM ec_host_schema_migrations WHERE name='history-counts-v1'`);
api.handle.exec('ALTER TABLE ec_host_operations DROP COLUMN observation_success'); api.handle.exec('ALTER TABLE ec_host_operations DROP COLUMN observation_success');
api.closeDb(); api.closeDb();
+39
View File
@@ -43,6 +43,40 @@ async function operationHistory() {
} }
describe('host operation history', () => { describe('host operation history', () => {
it('maintains exact scoped totals through receipts, expiration, rollback, deletion and reopening without recounting history', async () => {
const api = await operationHistory();
const { importantOperationSql } = await import('../../src/lib/server/hosts/observations.js');
const { closeDb, db } = await import('../../src/lib/server/db/index.js');
const ids = Array.from({ length: 35 }, (_, i) => api.add(i % 2 ? 'instance.inspect' : 'instance.restart', { state: 'pending' }));
const verify = () => {
for (const host of [undefined, api.first.id, api.second.id]) for (const server of [undefined, 'server-one']) {
for (const filter of ['all', 'routine', 'important'] as const) {
const where = ['o.history_expired_at IS NULL']; const args: string[] = [];
if (host) { where.push('o.host_id=?'); args.push(host); }
if (server) { where.push('o.server_name=?'); args.push(server); }
if (filter !== 'all') where.push(filter === 'routine' ? 'o.routine=1' : importantOperationSql);
const expected = db().prepare(`SELECT COUNT(*) total FROM ec_host_operations o WHERE ${where.join(' AND ')}`).get(...args) as { total: number };
expect(api.listHostOperationHistory(1, filter, { host, server }).total).toBe(expected.total);
}
}
};
verify();
for (const [index, id] of ids.entries()) api.reportHostOperationStatus(api.first.id, id,
index % 3 ? { state: 'completed', result: { healthy: index % 5 !== 0 } } : { state: 'failed', error: 'failure' });
api.handle.prepare("UPDATE ec_host_operations SET server_name='server-one',host_id=? WHERE rowid%2=0").run(api.second.id);
api.handle.prepare('UPDATE ec_host_operations SET history_expired_at=? WHERE rowid%4=0').run(new Date().toISOString());
api.handle.exec('DELETE FROM ec_host_operation_events WHERE operation_id IN (SELECT id FROM ec_host_operations WHERE rowid%7=0); DELETE FROM ec_host_operations WHERE rowid%7=0');
expect(() => api.handle.transaction(() => { api.handle.exec('DELETE FROM ec_host_operation_events; DELETE FROM ec_host_operations'); throw new Error('rollback'); })()).toThrow('rollback');
verify(); closeDb(); verify();
const prepare = vi.spyOn(db(), 'prepare');
api.listHostOperationHistory(1); api.listHostOperationHistory(2);
expect(prepare.mock.calls.some(([sql]) => /COUNT\(/i.test(sql))).toBe(false);
const count = prepare.mock.calls.find(([sql]) => sql.includes('FROM ec_host_operation_history_counts'))![0];
expect(db().prepare('EXPLAIN QUERY PLAN ' + count).all('', '')).toEqual(expect.arrayContaining([
expect.objectContaining({ detail: expect.stringContaining('USING PRIMARY KEY') })
]));
});
it('scopes counts and pages to one host or server and preserves deleted-host history', async () => { it('scopes counts and pages to one host or server and preserves deleted-host history', async () => {
const api = await seedHistory(90); const api = await seedHistory(90);
api.handle.prepare("UPDATE ec_host_operations SET server_name='server-one' WHERE host_id=?").run(api.first.id); api.handle.prepare("UPDATE ec_host_operations SET server_name='server-one' WHERE host_id=?").run(api.first.id);
@@ -223,6 +257,11 @@ describe('host operation history', () => {
const manual = api.add('rcon.execute', { payload: { command: 'say command-secret' } }); const manual = api.add('rcon.execute', { payload: { command: 'say command-secret' } });
const unsafe = api.add('files.read', { payload: { path: '../private-secret' } }); const unsafe = api.add('files.read', { payload: { path: '../private-secret' } });
api.handle.exec(`DROP INDEX ec_host_operations_routine_history; api.handle.exec(`DROP INDEX ec_host_operations_routine_history;
DROP TRIGGER operation_history_count_insert;
DROP TRIGGER operation_history_count_delete;
DROP TRIGGER operation_history_count_update;
DROP TABLE ec_host_operation_history_counts;
DELETE FROM ec_host_schema_migrations WHERE name='history-counts-v1';
DROP INDEX ec_host_operations_important_history; DROP INDEX ec_host_operations_important_history;
DROP INDEX ec_host_operations_server_history; DROP INDEX ec_host_operations_server_history;
ALTER TABLE ec_host_operations DROP COLUMN routine; ALTER TABLE ec_host_operations DROP COLUMN routine;
+8
View File
@@ -183,6 +183,14 @@ describe('automatic host preparation', () => {
}); });
expect(operations(host.id)).toEqual([]); expect(operations(host.id)).toEqual([]);
// Poll lanes only deliver queued work. One inventory heartbeat drives setup.
const reconcile = vi.spyOn(preparation, 'reconcileHostPreparation');
expect((await (await pollOperations({ request: request('operations') })).json()).operations).toEqual([]);
expect(reconcile).not.toHaveBeenCalled();
expect((await reportHeartbeat({ request: request('heartbeat', { inventory }) })).status).toBe(200);
expect(reconcile).toHaveBeenCalledOnce();
reconcile.mockRestore();
const firstPoll = await (await pollOperations({ request: request('operations') })).json(); const firstPoll = await (await pollOperations({ request: request('operations') })).json();
expect(firstPoll.operations).toHaveLength(1); expect(firstPoll.operations).toHaveLength(1);
const runtimeOperation = firstPoll.operations[0]; const runtimeOperation = firstPoll.operations[0];
+18
View File
@@ -45,6 +45,24 @@ async function setup(count = 5, hostCount = 1) {
} }
describe('host status observations', () => { describe('host status observations', () => {
it('bounds monitoring reads below five per server across thirty instances', async () => {
const api = await setup(10, 3);
const hosts = await import('../../src/lib/server/hosts/index.js');
const wait = vi.spyOn(hosts, 'waitHostOperationStatus').mockImplementation(async id => {
const operation = hosts.getHostOperation(id)!;
return { ...operation, state: 'completed', result: { results: (operation.payload.instances as { instanceId: string; generation: number }[])
.map(target => ({ ...target, reply: { ok: true, text: 'map: mp_kino\nnum score ping guid name\n--- ----- ---- ---- ----\n' } })) } };
});
const prepare = vi.spyOn(api.db, 'prepare');
try {
await api.fanoutStatus();
const reads = prepare.mock.calls.filter(([sql]) => /^\s*(SELECT|WITH)\b/i.test(sql));
expect(reads.length).toBeLessThan(30 * 5);
expect(reads.filter(([sql]) => sql.includes('instance_json')).length).toBeLessThanOrEqual(30 * 3 + 5);
expect(api.getServer('server-2-9')?.last_players).toBe(0);
} finally { prepare.mockRestore(); wait.mockRestore(); }
});
it('uses one batch per host for a fifteen-server pass and shares overlapping scans', async () => { it('uses one batch per host for a fifteen-server pass and shares overlapping scans', async () => {
const api = await setup(5, 3); const api = await setup(5, 3);
const hosts = await import('../../src/lib/server/hosts/index.js'); const hosts = await import('../../src/lib/server/hosts/index.js');
@@ -41,6 +41,8 @@ describe('persisted launch recipe summaries', () => {
expect(recipeSummaryForDeployment('missing')).toBeNull(); expect(recipeSummaryForDeployment('missing')).toBeNull();
} finally { parsing.mockRestore(); } } finally { parsing.mockRestore(); }
expect(recipeForDeployment('legacy')).toEqual({ recipe, blueprintName: 'Old collection' }); expect(recipeForDeployment('legacy')).toEqual({ recipe, blueprintName: 'Old collection' });
expect(db().prepare('SELECT recipe FROM server_launch_recipes').get()).toEqual({ recipe: encoded }); const stored = db().prepare('SELECT recipe FROM server_launch_recipes').get() as { recipe: string };
expect(stored.recipe.length).toBeLessThan(1024);
expect(JSON.parse(stored.recipe).files[0]).not.toHaveProperty('content');
}); });
}); });
+1 -1
View File
@@ -10,7 +10,7 @@ vi.mock('../../src/lib/server/integrations/rcon.js', () => ({
rconStatusResult: vi.fn(async () => ({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: '', rconStatusResult: vi.fn(async () => ({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: '',
status: { players: 0, map: 'zombie_theater', gametype: null, roster: [] } })) status: { players: 0, map: 'zombie_theater', gametype: null, roster: [] } }))
})); }));
vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}) })); vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}), kickProvisioning: vi.fn(async () => {}) }));
let fixture: Fixture; let fixture: Fixture;
const selection = { game: 't5' as const, mapSlug: 'theater' }; const selection = { game: 't5' as const, mapSlug: 'theater' };
+25
View File
@@ -34,6 +34,31 @@ async function matches() {
}>; }>;
} }
it('keeps report revisions stable during a match and invalidates completed data and renamed players', async () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const revision = () => (db().prepare("SELECT revision FROM report_revisions WHERE topic='leaderboard'").get() as { revision: number }).revision;
await report('game_start');
const started = revision();
for (let round = 1; round <= 30; round++) {
await report('round_change', round, [player(undefined, { kills: round * 10 })]);
await report('heartbeat', round);
}
expect(revision()).toBe(started);
await report('game_end', 30);
expect(revision()).toBeGreaterThan(started);
const completed = revision();
db().prepare('UPDATE leaderboard_players SET last_seen_at=?').run('2026-10-06T12:00:00.000Z');
expect(revision()).toBe(completed);
db().prepare("UPDATE leaderboard_players SET name='Renamed'").run();
expect(revision()).toBe(completed + 1);
db().prepare('UPDATE leaderboard_match_players SET kills=kills+1').run();
expect(revision()).toBe(completed + 2);
closeDb();
const reopened = revision();
db().prepare('DELETE FROM leaderboard_matches').run();
expect(revision()).toBeGreaterThan(reopened);
});
it('publishes round 2 or higher games only after game end, preserving early stats and excluding round 1 departures', async () => { it('publishes round 2 or higher games only after game end, preserving early stats and excluding round 1 departures', async () => {
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js'); const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js'); const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
+53
View File
@@ -0,0 +1,53 @@
import Database from 'better-sqlite3';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import { migrateVersionLayout } from '../../src/lib/server/db/version-layout.js';
let fixture: Fixture;
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
afterEach(async () => { vi.restoreAllMocks(); await fixture.cleanup(); });
it('returns metadata without plugin bytes and lists many artifacts in two queries', async () => {
const { createScript, getVersion, getVersionContent, getLatestVersion, listVersions } = await import('../../src/lib/server/fleet/scripts.js');
const { scriptCatalog, betaScriptMetadata } = await import('../../src/lib/server/fleet/library-metadata.js');
const { db } = await import('../../src/lib/server/db/index.js');
const content = Buffer.alloc(1024 * 1024, 77);
let id = 0;
for (let i = 0; i < 20; i++) id = createScript({ name: `plugin-${i}`, kind: 'plugin', targetRelPath: 'plugin.dll', content }).version.id;
expect(getVersionContent(id)).toEqual(content);
expect(getVersion(id)).not.toHaveProperty('content');
expect(getLatestVersion(getVersion(id)!.script_id)).not.toHaveProperty('content');
expect(listVersions(getVersion(id)!.script_id)[0]).not.toHaveProperty('content');
const prepare = vi.spyOn(db(), 'prepare');
expect(scriptCatalog('plugin')).toHaveLength(20);
expect(prepare).toHaveBeenCalledTimes(2);
betaScriptMetadata([]);
expect(prepare.mock.calls.every(([sql]) => !/\bcontent\b/.test(sql))).toBe(true);
});
it('moves legacy content last without losing bytes, IDs, indexes, triggers or references', () => {
const handle = new Database(':memory:');
try {
handle.exec(`PRAGMA foreign_keys=ON;
CREATE TABLE script_versions(id INTEGER PRIMARY KEY AUTOINCREMENT, script_id INTEGER,
version INTEGER, content BLOB NOT NULL, state TEXT, release_version TEXT, UNIQUE(script_id,version));
CREATE INDEX version_state ON script_versions(state);
CREATE TABLE child(version INTEGER REFERENCES script_versions(id));
CREATE TABLE updates(n INTEGER); INSERT INTO updates VALUES(0);
CREATE TRIGGER version_update AFTER UPDATE ON script_versions BEGIN UPDATE updates SET n=n+1; END;
INSERT INTO script_versions VALUES(7,1,1,x'00ff','tested','v1');
INSERT INTO child VALUES(7);
INSERT INTO script_versions VALUES(100,1,2,x'aa','untested',NULL);
DELETE FROM script_versions WHERE id=100;`);
const before = handle.prepare('SELECT * FROM script_versions').all();
migrateVersionLayout(handle);
migrateVersionLayout(handle);
expect(handle.prepare('SELECT * FROM script_versions').all()).toEqual(before);
expect((handle.pragma('table_info(script_versions)') as { name: string }[]).at(-1)?.name).toBe('content');
expect(handle.pragma('foreign_key_check')).toEqual([]);
expect(handle.prepare("INSERT INTO script_versions(script_id,version,content) VALUES(1,3,x'01')").run().lastInsertRowid).toBe(101);
handle.prepare("UPDATE script_versions SET state='tested' WHERE id=101").run();
expect(handle.prepare('SELECT n FROM updates').get()).toEqual({ n: 1 });
expect((handle.pragma('index_list(script_versions)') as { name: string }[]).some(index => index.name === 'version_state')).toBe(true);
} finally { handle.close(); }
});
+1 -1
View File
@@ -40,7 +40,7 @@ async function setup(capable = true) {
const baseline = promoted('baseline', 'Baseline', Buffer.from('set sv_voice "1"\n')); const baseline = promoted('baseline', 'Baseline', Buffer.from('set sv_voice "1"\n'));
for (const [root, artifact] of [[scriptRoot, script], [pluginRoot, plugin]] as const) { for (const [root, artifact] of [[scriptRoot, script], [pluginRoot, plugin]] as const) {
const { version } = artifact; const { version } = artifact;
contents.set(`${root}/${artifact.script.target_rel_path}`, version.content); contents.set(`${root}/${artifact.script.target_rel_path}`, (await import('../../src/lib/server/fleet/scripts.js')).getVersionContent(version.id));
recordScript(server, artifact.script.target_rel_path, version.id, version.sha256, version.size, 'test'); recordScript(server, artifact.script.target_rel_path, version.id, version.sha256, version.size, 'test');
} }
const { getPromotedServerConfig, getServerMapConfig, MAP_CONFIG_FILE } = await import('../../src/lib/server/fleet/server-config.js'); const { getPromotedServerConfig, getServerMapConfig, MAP_CONFIG_FILE } = await import('../../src/lib/server/fleet/server-config.js');
+6 -3
View File
@@ -64,7 +64,10 @@ describe('native host enrollment and scoped authentication', () => {
const api = await enrolled(); const api = await enrolled();
const { db } = await import('../../src/lib/server/db/index.js'); const { db } = await import('../../src/lib/server/db/index.js');
const payload = { files: [{ path: 'plugins/fixture.dll', contentBase64: Buffer.alloc(8 * 1024 ** 2, 42).toString('base64') }] }; const payload = { files: [{ path: 'plugins/fixture.dll', contentBase64: Buffer.alloc(8 * 1024 ** 2, 42).toString('base64') }] };
const bytes = Buffer.from(JSON.stringify(payload)); const { putFileBlob } = await import('../../src/lib/server/db/file-blobs.js');
const hash = putFileBlob(Buffer.from(payload.files[0].contentBase64, 'base64'));
// Launch preparation transfers bytes separately and serializes only references.
const bytes = Buffer.from(JSON.stringify({ files: [{ path: payload.files[0].path, contentSha256: hash }] }));
const options = { instanceId: 'immutable-instance', generation: 1, idempotencyKey: 'large-launch' }; const options = { instanceId: 'immutable-instance', generation: 1, idempotencyKey: 'large-launch' };
const parse = JSON.parse; const parse = JSON.parse;
const parsing = vi.spyOn(JSON, 'parse').mockImplementation((value, reviver) => { const parsing = vi.spyOn(JSON, 'parse').mockImplementation((value, reviver) => {
@@ -115,8 +118,8 @@ describe('native host enrollment and scoped authentication', () => {
{ payload: string; last_delivered_at: string | null }; { payload: string; last_delivered_at: string | null };
expect(stored.payload).toBe(JSON.stringify(routing)); expect(stored.payload).toBe(JSON.stringify(routing));
expect(stored.last_delivered_at).not.toBeNull(); expect(stored.last_delivered_at).not.toBeNull();
expect(db().prepare('SELECT length(CAST(payload AS BLOB)) AS bytes FROM ec_host_operation_payloads WHERE operation_id=?').get(launch.id)) const saved = db().prepare('SELECT length(CAST(payload AS BLOB)) AS bytes FROM ec_host_operation_payloads WHERE operation_id=?').get(launch.id) as { bytes: number };
.toEqual({ bytes: bytes.length }); expect(saved.bytes).toBeLessThan(1024);
expect(api.getHostOperationPayload(api.host.id, launch.id)).toEqual(bytes); expect(api.getHostOperationPayload(api.host.id, launch.id)).toEqual(bytes);
expect(api.getHostOperationPayload(api.host.id, inline.id).toString('utf8')).toBe(JSON.stringify(inline.payload)); expect(api.getHostOperationPayload(api.host.id, inline.id).toString('utf8')).toBe(JSON.stringify(inline.payload));
expect(db().prepare('SELECT 1 FROM ec_host_operation_payloads WHERE operation_id=?').get(inline.id)).toBeUndefined(); expect(db().prepare('SELECT 1 FROM ec_host_operation_payloads WHERE operation_id=?').get(inline.id)).toBeUndefined();
+20 -6
View File
@@ -1,3 +1,4 @@
import { putFileBlob } from '../../src/lib/server/db/file-blobs.js';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { join } from 'node:path'; import { join } from 'node:path';
@@ -697,7 +698,7 @@ describe('native instance provisioning', () => {
const before = Buffer.from('original bytes on the host'); const after = Buffer.from('new campaign bytes'); const before = Buffer.from('original bytes on the host'); const after = Buffer.from('new campaign bytes');
db().prepare("INSERT INTO deployments(id,action,started_at) VALUES('campaign','promote',?)").run(new Date().toISOString()); db().prepare("INSERT INTO deployments(id,action,started_at) VALUES('campaign','promote',?)").run(new Date().toISOString());
db().prepare("INSERT INTO deployment_servers(deployment_id,server,files_changed,restart_state) VALUES('campaign',?,1,'pending')").run(job.name); db().prepare("INSERT INTO deployment_servers(deployment_id,server,files_changed,restart_state) VALUES('campaign',?,1,'pending')").run(job.name);
const fileId = Number(db().prepare("INSERT INTO deployment_files(deployment_id,server,verb,rel_path,content_after,snapshot_pending) VALUES('campaign',?,'updated','hud.gsc',?,1)").run(job.name, after).lastInsertRowid); const fileId = Number(db().prepare("INSERT INTO deployment_files(deployment_id,server,verb,rel_path,content_after_hash,snapshot_pending) VALUES('campaign',?,'updated','hud.gsc',?,1)").run(job.name, putFileBlob(after, db())).lastInsertRowid);
const { queueFileChange, stagedFileChanges } = await import('../../src/lib/server/fleet/staged-files.js'); const { queueFileChange, stagedFileChanges } = await import('../../src/lib/server/fleet/staged-files.js');
queueFileChange({ sourceKind: 'campaign', sourceId: 'campaign', server: job.name, kind: 'script', relPath: 'hud.gsc', content: after, inventorySource: 'test', deploymentFileId: fileId }); queueFileChange({ sourceKind: 'campaign', sourceId: 'campaign', server: job.name, kind: 'script', relPath: 'hud.gsc', content: after, inventorySource: 'test', deploymentFileId: fileId });
const { requestRestart } = await import('../../src/lib/server/provisioning/requests.js'); const { requestRestart } = await import('../../src/lib/server/provisioning/requests.js');
@@ -708,19 +709,32 @@ describe('native instance provisioning', () => {
const { getHostOperation, reportHostOperation } = await import('../../src/lib/server/hosts/index.js'); const { getHostOperation, reportHostOperation } = await import('../../src/lib/server/hosts/index.js');
const stage = getHostOperation(staged.stage_operation_id)!; const stage = getHostOperation(staged.stage_operation_id)!;
expect(stage.kind).toBe('files.stage'); expect(await operationFor(restart.id)).toBeNull(); expect(stage.kind).toBe('files.stage'); expect(await operationFor(restart.id)).toBeNull();
const routing = db().prepare('SELECT payload FROM ec_host_operations WHERE id=?').get(stage.id) as { payload: string };
expect(JSON.parse(routing.payload)).toEqual({ stageId: restart.id });
const plan = db().prepare('SELECT files_json FROM native_provisioning WHERE deployment_id=?').get(restart.id) as { files_json: string };
expect(JSON.parse(plan.files_json)[0]).not.toHaveProperty('contentBase64');
const storedPayload = db().prepare('SELECT payload FROM ec_host_operation_payloads WHERE operation_id=?').get(stage.id) as { payload: string };
expect(JSON.parse(storedPayload.payload).files[0]).toMatchObject({ contentSha256: createHash('sha256').update(after).digest('hex') });
expect(JSON.parse(storedPayload.payload).files[0]).not.toHaveProperty('contentBase64');
const { closeDb } = await import('../../src/lib/server/db/index.js');
closeDb();
const { pendingHostOperations } = await import('../../src/lib/server/hosts/index.js');
expect(pendingHostOperations(hostId).find(operation => operation.id === stage.id)?.payload).toEqual(stage.payload);
await advance();
expect((db().prepare('SELECT stage_operation_id FROM native_provisioning WHERE deployment_id=?').get(restart.id) as { stage_operation_id: string }).stage_operation_id).toBe(stage.id);
expect(stagedFileChanges('campaign', 'campaign')[0].state).toBe('pending'); expect(stagedFileChanges('campaign', 'campaign')[0].state).toBe('pending');
reportHostOperation(hostId, stage.id, { state: 'completed', result: { state: 'staged' } }); reportHostOperation(hostId, stage.id, { state: 'completed', result: { state: 'staged' } });
await advance(); expect(await operationFor(restart.id)).toBeNull(); await advance(); expect(await operationFor(restart.id)).toBeNull();
remote.players = 0; await advance(); remote.players = 0; await advance();
const activation = (await operationFor(restart.id))!; const activation = (await operationFor(restart.id))!;
expect(activation).toMatchObject({ kind: 'instance.restart', generation: 1, payload: { stageId: restart.id } }); expect(activation).toMatchObject({ kind: 'instance.restart', generation: 1, payload: { stageId: restart.id } });
expect(db().prepare('SELECT content_before,snapshot_pending FROM deployment_files WHERE id=?').get(fileId)).toEqual({ content_before: null, snapshot_pending: 1 }); expect(db().prepare('SELECT (SELECT content FROM file_blobs WHERE sha256=content_before_hash) AS content_before,snapshot_pending FROM deployment_files WHERE id=?').get(fileId)).toEqual({ content_before: null, snapshot_pending: 1 });
const files = stage.payload.files as Array<{ id: number; path: string }>; const files = stage.payload.files as Array<{ id: number; path: string }>;
reportHostOperation(hostId, activation.id, { state: 'completed', result: { before: [{ id: files[0].id, path: files[0].path, contentBase64: before.toString('base64') }] } }); reportHostOperation(hostId, activation.id, { state: 'completed', result: { before: [{ id: files[0].id, path: files[0].path, contentBase64: before.toString('base64') }] } });
await advance(); await advance();
expect((await detail(restart.id)).state).toBe('done'); expect((await detail(restart.id)).state).toBe('done');
expect(stagedFileChanges('campaign', 'campaign')[0].state).toBe('applied'); expect(stagedFileChanges('campaign', 'campaign')[0].state).toBe('applied');
expect(db().prepare('SELECT content_before,sha256_before,snapshot_pending FROM deployment_files WHERE id=?').get(fileId)).toEqual({ content_before: before, sha256_before: createHash('sha256').update(before).digest('hex'), snapshot_pending: 0 }); expect(db().prepare('SELECT (SELECT content FROM file_blobs WHERE sha256=content_before_hash) AS content_before,sha256_before,snapshot_pending FROM deployment_files WHERE id=?').get(fileId)).toEqual({ content_before: before, sha256_before: createHash('sha256').update(before).digest('hex'), snapshot_pending: 0 });
const { getServerScript } = await import('../../src/lib/server/fleet/inventory.js'); const { getServerScript } = await import('../../src/lib/server/fleet/inventory.js');
expect(getServerScript(job.name, 'hud.gsc')?.sha256).toBe(createHash('sha256').update(after).digest('hex')); expect(getServerScript(job.name, 'hud.gsc')?.sha256).toBe(createHash('sha256').update(after).digest('hex'));
}); });
@@ -815,7 +829,7 @@ describe('native instance provisioning', () => {
expect((await detail(restart.id)).state).toBe('failed'); expect((await detail(restart.id)).state).toBe('failed');
expect(stagedFileChanges('campaign', 'preflight-failed')[0].state).toBe('pending'); expect(stagedFileChanges('campaign', 'preflight-failed')[0].state).toBe('pending');
expect(db().prepare('SELECT COUNT(*) AS count FROM restart_rollout WHERE healthy_at IS NULL').get()).toEqual({ count: 0 }); expect(db().prepare('SELECT COUNT(*) AS count FROM restart_rollout WHERE healthy_at IS NULL').get()).toEqual({ count: 0 });
expect(db().prepare('SELECT content_before,sha256_before FROM deployment_files WHERE id=?').get(fileId)).toEqual({ content_before: null, sha256_before: null }); expect(db().prepare('SELECT (SELECT content FROM file_blobs WHERE sha256=content_before_hash) AS content_before,sha256_before FROM deployment_files WHERE id=?').get(fileId)).toEqual({ content_before: null, sha256_before: null });
const { getServerScript } = await import('../../src/lib/server/fleet/inventory.js'); const { getServerScript } = await import('../../src/lib/server/fleet/inventory.js');
expect(getServerScript(job.name, 'hud.gsc')).toBeUndefined(); expect(getServerScript(job.name, 'hud.gsc')).toBeUndefined();
}); });
@@ -1265,7 +1279,7 @@ describe('native provisioning cancellation and rollback', () => {
expect((await detail(restart.id)).state).toBe('cancelled'); expect((await detail(restart.id)).state).toBe('cancelled');
const replacement = db().prepare('SELECT provisioning_id FROM deployment_servers WHERE deployment_id=?').get(rollback.deploymentId) as {provisioning_id:string}; const replacement = db().prepare('SELECT provisioning_id FROM deployment_servers WHERE deployment_id=?').get(rollback.deploymentId) as {provisioning_id:string};
expect(replacement.provisioning_id).not.toBe(restart.id); expect(replacement.provisioning_id).not.toBe(restart.id);
expect(db().prepare("SELECT content FROM staged_file_changes WHERE source_kind='campaign' AND source_id=?").get(rollback.deploymentId)) expect(db().prepare("SELECT (SELECT content FROM file_blobs WHERE sha256=content_hash) AS content FROM staged_file_changes WHERE source_kind='campaign' AND source_id=?").get(rollback.deploymentId))
.toEqual({content:Buffer.from('working')}); .toEqual({content:Buffer.from('working')});
await advance(); await advance();
const stagedRollback = db().prepare('SELECT stage_operation_id FROM native_provisioning WHERE deployment_id=?').get(replacement.provisioning_id) as {stage_operation_id:string}; const stagedRollback = db().prepare('SELECT stage_operation_id FROM native_provisioning WHERE deployment_id=?').get(replacement.provisioning_id) as {stage_operation_id:string};
@@ -1307,7 +1321,7 @@ describe('cancelled native mutation bookkeeping', () => {
await reviseBeta(betaId, 1, [{ scriptId: script.id, content: Buffer.from('revision two') }]); await reviseBeta(betaId, 1, [{ scriptId: script.id, content: Buffer.from('revision two') }]);
await activate('revision one'); await activate('revision one');
const rollback = await rollbackBeta(betaId); const rollback = await rollbackBeta(betaId);
expect(db().prepare("SELECT content FROM staged_file_changes WHERE source_kind = 'campaign' AND source_id = ?").get(rollback.deploymentId)) expect(db().prepare("SELECT (SELECT content FROM file_blobs WHERE sha256=content_hash) AS content FROM staged_file_changes WHERE source_kind = 'campaign' AND source_id = ?").get(rollback.deploymentId))
.toEqual({ content: original === null ? null : Buffer.from(original) }); .toEqual({ content: original === null ? null : Buffer.from(original) });
}); });
+1 -1
View File
@@ -12,7 +12,7 @@ describe('persistent activity', () => {
const handle = db(); const handle = db();
handle.prepare("INSERT INTO servers (name) VALUES ('server')").run(); handle.prepare("INSERT INTO servers (name) VALUES ('server')").run();
handle.prepare("INSERT INTO scripts (id, name, target_rel_path) VALUES (1, 'test', 'test.gsc')").run(); handle.prepare("INSERT INTO scripts (id, name, target_rel_path) VALUES (1, 'test', 'test.gsc')").run();
handle.prepare("INSERT INTO script_versions (id, script_id, version, sha256, size, content) VALUES (1, 1, 1, 'hash', 0, X'')").run(); handle.prepare("INSERT INTO script_versions (id, script_id, version, sha256, size, content_hash) VALUES (1, 1, 1, 'hash', 0, NULL)").run();
handle.prepare("INSERT INTO betas (id, script_id, script_version_id, trigger_kind, state) VALUES (1, 1, 1, 'ui', 'running')").run(); handle.prepare("INSERT INTO betas (id, script_id, script_version_id, trigger_kind, state) VALUES (1, 1, 1, 'ui', 'running')").run();
handle.prepare("INSERT INTO beta_servers (beta_id, server, had_original, stage) VALUES (1, 'server', 0, 'deploying')").run(); handle.prepare("INSERT INTO beta_servers (beta_id, server, had_original, stage) VALUES (1, 'server', 0, 'deploying')").run();
handle.prepare("UPDATE beta_servers SET stage = 'failed', error = 'write failed' WHERE beta_id = 1").run(); handle.prepare("UPDATE beta_servers SET stage = 'failed', error = 'write failed' WHERE beta_id = 1").run();
+3 -2
View File
@@ -26,7 +26,7 @@ describe('plugin release versions', () => {
[3, 'v3.0.4'], [2, 'v3.0.4'], [1, 'v3.1.0'] [3, 'v3.0.4'], [2, 'v3.0.4'], [1, 'v3.1.0']
]); ]);
expect(older.sha256).not.toBe(rebuild.sha256); expect(older.sha256).not.toBe(rebuild.sha256);
expect(getVersion(version.id)!.content).toEqual(plugin.content); expect((await import('../../src/lib/server/fleet/scripts.js')).getVersionContent(version.id)).toEqual(plugin.content);
}); });
it('allows unlabeled uploads and non-semver release tags', async () => { it('allows unlabeled uploads and non-semver release tags', async () => {
@@ -53,8 +53,9 @@ describe('plugin release versions', () => {
db().exec('ALTER TABLE script_versions DROP COLUMN release_version'); db().exec('ALTER TABLE script_versions DROP COLUMN release_version');
closeDb(); closeDb();
expect(getVersion(version.id)).toMatchObject({ expect(getVersion(version.id)).toMatchObject({
id: version.id, version: 1, release_version: null, sha256: version.sha256, content: plugin.content id: version.id, version: 1, release_version: null, sha256: version.sha256
}); });
expect((await import('../../src/lib/server/fleet/scripts.js')).getVersionContent(version.id)).toEqual(plugin.content);
closeDb(); closeDb();
expect(getVersion(version.id)!.release_version).toBeNull(); expect(getVersion(version.id)!.release_version).toBeNull();
}); });
+81
View File
@@ -0,0 +1,81 @@
import Database from 'better-sqlite3';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import { enableIncrementalVacuum, retentionClocks } from '../../src/lib/server/db/retention-clocks.js';
let fixture: Fixture;
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
afterEach(async () => { await fixture.cleanup(); });
const old = new Date(Date.now() - 90 * 86400000).toISOString();
it('expires completed file payloads while keeping history, replay identity, holds and active work', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const hosts = await import('../../src/lib/server/hosts/index.js');
const { pruneHistory, setRetentionControl } = await import('../../src/lib/server/retention.js');
const { putFileBlob } = await import('../../src/lib/server/db/file-blobs.js');
const host = hosts.createHost({ name: 'Files' });
const payload = { files: [{ path: 'plugin.dll', contentBase64: Buffer.from('file').toString('base64') }] };
const completed = hosts.queueHostOperation(host.id, 'files.stage', payload, { idempotencyKey: 'finished' });
hosts.reportHostOperation(host.id, completed.id, { state: 'completed', result: { before: [{ path: 'plugin.dll', contentBase64: 'YmVmb3Jl' }] } });
const active = hosts.queueHostOperation(host.id, 'files.stage', payload);
const held = hosts.queueHostOperation(host.id, 'files.stage', payload);
hosts.reportHostOperation(host.id, held.id, { state: 'completed', result: {} });
setRetentionControl({ category: 'operation', id: held.id, protected: true });
const failed = hosts.queueHostOperation(host.id, 'files.stage', payload);
hosts.reportHostOperation(host.id, failed.id, { state: 'failed', error: 'unfinished repair' });
db().prepare('UPDATE ec_host_operations SET updated_at=?').run(old);
db().exec("INSERT INTO servers(name) VALUES('s')");
const hash = putFileBlob(Buffer.from('recipe-only'));
db().prepare(`INSERT INTO server_deployments(id,server,action,state,stage,started_at,finished_at,triggered_by)
VALUES('launch','s','create','done','done',?,?,'test')`).run(old, old);
db().prepare('INSERT INTO server_launch_recipes(deployment_id,recipe) VALUES(?,?)')
.run('launch', JSON.stringify({ files: [{ path: 'launch.dll', sha256: hash, contentHash: hash }] }));
expect(pruneHistory('workflow-payload')).toBe(1);
expect(db().prepare("SELECT state FROM server_deployments WHERE id='launch'").get()).toEqual({ state: 'done' });
expect(JSON.parse((db().prepare('SELECT recipe FROM server_launch_recipes').get() as { recipe: string }).recipe))
.toEqual({ files: [{ path: 'launch.dll', sha256: hash }] });
expect(db().prepare('SELECT 1 FROM file_blobs WHERE sha256=?').get(hash)).toBeUndefined();
expect(pruneHistory('operation-payload')).toBe(1);
expect(hosts.getHostOperation(completed.id)).toMatchObject({ state: 'completed', payload: { files: [{ path: 'plugin.dll' }] }, result: { before: [{ path: 'plugin.dll' }] } });
expect(hosts.getHostOperation(active.id)?.payload).toEqual(payload);
expect(hosts.getHostOperation(held.id)?.payload).toEqual(payload);
expect(hosts.getHostOperation(failed.id)?.payload).toEqual(payload);
expect(hosts.queueHostOperation(host.id, 'files.stage', payload, { idempotencyKey: 'finished' }).id).toBe(completed.id);
expect(() => hosts.queueHostOperation(host.id, 'files.stage', { files: [] }, { idempotencyKey: 'finished' })).toThrow('different request');
expect(pruneHistory('operation-payload')).toBe(0);
});
it('normalizes legacy retention clocks once and reuses clock indexes', () => {
const handle = new Database(':memory:');
try {
handle.exec(`CREATE TABLE history(id INTEGER PRIMARY KEY, at TEXT); CREATE INDEX history_age ON history(at);
INSERT INTO history(at) VALUES('2026-01-01 12:00:00'),('2026-01-01T08:00:00-04:00'),(NULL)`);
retentionClocks(handle, { history: ['at'] });
expect(handle.prepare('SELECT at FROM history ORDER BY id').all()).toEqual([
{ at: '2026-01-01T12:00:00.000Z' }, { at: '2026-01-01T12:00:00.000Z' }, { at: null }
]);
expect(handle.pragma('index_list(history)')).toHaveLength(1);
const prepare = vi.spyOn(handle, 'prepare');
retentionClocks(handle, { history: ['at'] });
expect(prepare.mock.calls.some(([sql]) => sql.startsWith('UPDATE'))).toBe(false);
} finally { handle.close(); }
});
it('enables incremental vacuum once and preserves host enable command boundaries', () => {
const handle = new Database(fixture.dbPath);
try {
handle.exec(`CREATE TABLE ec_host_operations(id TEXT, host_id TEXT);
CREATE TABLE ec_host_enable_requests(host_id TEXT, operation_floor INTEGER);
INSERT INTO ec_host_operations(rowid,id,host_id) VALUES(10,'before','h'),(30,'after','h');
INSERT INTO ec_host_enable_requests VALUES('h',20)`);
enableIncrementalVacuum(handle);
expect(handle.pragma('auto_vacuum', { simple: true })).toBe(2);
const eligible = () => handle.prepare(`SELECT id FROM ec_host_operations o JOIN ec_host_enable_requests e ON e.host_id=o.host_id
WHERE o.rowid>e.operation_floor`).all();
expect(eligible()).toEqual([{ id: 'after' }]);
const exec = vi.spyOn(handle, 'exec');
enableIncrementalVacuum(handle);
expect(exec.mock.calls).toHaveLength(0);
expect(eligible()).toEqual([{ id: 'after' }]);
} finally { handle.close(); }
});
+7 -6
View File
@@ -1,3 +1,4 @@
import { putFileBlob } from '../../src/lib/server/db/file-blobs.js';
import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js'; import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture; let fixture: Fixture;
@@ -72,11 +73,11 @@ it('selects resolved failures separately while preserving age, protection and ba
it('expires file bodies without silently turning rollback into file deletion', async () => { it('expires file bodies without silently turning rollback into file deletion', async () => {
const a = await api(); const a = await api();
a.db.prepare("INSERT INTO deployments(id,action,started_at,finished_at) VALUES('p','promote',?,?)").run(old,old); a.db.prepare("INSERT INTO deployments(id,action,started_at,finished_at) VALUES('p','promote',?,?)").run(old,old);
a.db.prepare("INSERT INTO deployment_files(deployment_id,server,verb,rel_path,content_before,content_after) VALUES('p','s','updated','test.cfg',?,?)").run(Buffer.from('before'),Buffer.from('after')); a.db.prepare("INSERT INTO deployment_files(deployment_id,server,verb,rel_path,content_before_hash,content_after_hash) VALUES('p','s','updated','test.cfg',?,?)").run(putFileBlob(Buffer.from('before'), a.db),putFileBlob(Buffer.from('after'), a.db));
a.saveFleetSettings({ ...a.fleetSettings(),deployKeepDays: 0,fileContentKeepDays: 1 }); a.saveFleetSettings({ ...a.fleetSettings(),deployKeepDays: 0,fileContentKeepDays: 1 });
expect(a.retentionPreview().find(p=>p.category==='file-content')?.payloadBytes).toBe(11); expect(a.retentionPreview().find(p=>p.category==='file-content')?.payloadBytes).toBe(11);
expect(a.pruneHistory('file-content')).toBe(1); expect(a.pruneHistory('file-content')).toBe(1);
expect(a.db.prepare("SELECT content_before,content_after FROM deployment_files").get()).toEqual({ content_before:null,content_after:null }); expect(a.db.prepare("SELECT (SELECT content FROM file_blobs WHERE sha256=content_before_hash) AS content_before,(SELECT content FROM file_blobs WHERE sha256=content_after_hash) AS content_after FROM deployment_files").get()).toEqual({ content_before:null,content_after:null });
const { rollbackPromoteDeployment } = await import('../../src/lib/server/fleet/rollback.js'); const { rollbackPromoteDeployment } = await import('../../src/lib/server/fleet/rollback.js');
await expect(rollbackPromoteDeployment('p')).rejects.toThrow('expired'); await expect(rollbackPromoteDeployment('p')).rejects.toThrow('expired');
expect(a.pruneHistory('file-content')).toBe(0); expect(a.pruneHistory('file-content')).toBe(0);
@@ -119,7 +120,7 @@ it('deletes old maintenance jobs while preserving update suppression evidence an
it('keeps current and referenced artifact versions and honors the minimum version count', async () => { it('keeps current and referenced artifact versions and honors the minimum version count', async () => {
const a = await api(); const a = await api();
a.db.prepare("INSERT INTO scripts(id,name,target_rel_path,current_version_id) VALUES(1,'test','test.gsc',1)").run(); a.db.prepare("INSERT INTO scripts(id,name,target_rel_path,current_version_id) VALUES(1,'test','test.gsc',1)").run();
for(let n=1;n<=4;n++) a.db.prepare("INSERT INTO script_versions(id,script_id,version,sha256,size,content,uploaded_at) VALUES(?,1,?,'sha',1,X'01',?)").run(n,n,old); for(let n=1;n<=4;n++) a.db.prepare("INSERT INTO script_versions(id,script_id,version,sha256,size,content_hash,uploaded_at) VALUES(?,1,?,'sha',1,?,?)").run(n,n,putFileBlob(Buffer.from([1]), a.db),old);
a.saveFleetSettings({...a.fleetSettings(),versionKeepCount:2}); a.saveFleetSettings({...a.fleetSettings(),versionKeepCount:2});
expect(a.pruneHistory('version')).toBe(1); expect(a.pruneHistory('version')).toBe(1);
expect(a.db.prepare('SELECT id FROM script_versions ORDER BY id').all()).toEqual([{id:1},{id:3},{id:4}]); expect(a.db.prepare('SELECT id FROM script_versions ORDER BY id').all()).toEqual([{id:1},{id:3},{id:4}]);
@@ -153,15 +154,15 @@ it('prunes closed matches and audit events without deleting active matches or pr
it('keeps active beta evidence while expiring closed snapshots and then closed history', async () => { it('keeps active beta evidence while expiring closed snapshots and then closed history', async () => {
const a = await api(); const a = await api();
a.db.prepare("INSERT INTO scripts(id,name,target_rel_path) VALUES(1,'s','s.gsc')").run(); a.db.prepare("INSERT INTO scripts(id,name,target_rel_path) VALUES(1,'s','s.gsc')").run();
a.db.prepare("INSERT INTO script_versions(id,script_id,version,sha256,size,content) VALUES(1,1,1,'sha',3,X'010203')").run(); a.db.prepare("INSERT INTO script_versions(id,script_id,version,sha256,size,content_hash) VALUES(1,1,1,'sha',3,?)").run(putFileBlob(Buffer.from([1,2,3]), a.db));
for (const [id,state] of [[1,'running'],[2,'rolled-back']] as const) { for (const [id,state] of [[1,'running'],[2,'rolled-back']] as const) {
a.db.prepare("INSERT INTO betas(id,script_id,script_version_id,trigger_kind,state,opened_at,closed_at) VALUES(?,1,1,'test',?,?,?)").run(id,state,old,old); a.db.prepare("INSERT INTO betas(id,script_id,script_version_id,trigger_kind,state,opened_at,closed_at) VALUES(?,1,1,'test',?,?,?)").run(id,state,old,old);
a.db.prepare("INSERT INTO beta_revisions(beta_id,revision,started_at) VALUES(?,1,?)").run(id,old); a.db.prepare("INSERT INTO beta_revisions(beta_id,revision,started_at) VALUES(?,1,?)").run(id,old);
a.db.prepare("INSERT INTO beta_revision_files(beta_id,revision,script_id,name,kind,path,version_id,version,sha256,content) VALUES(?,1,1,'s','script','s.gsc',1,1,'sha',X'010203')").run(id); a.db.prepare("INSERT INTO beta_revision_files(beta_id,revision,script_id,name,kind,path,version_id,version,sha256,content_hash) VALUES(?,1,1,'s','script','s.gsc',1,1,'sha',?)").run(id,putFileBlob(Buffer.from([1,2,3]), a.db));
} }
a.saveFleetSettings({...a.fleetSettings(),fileContentKeepDays:1}); a.saveFleetSettings({...a.fleetSettings(),fileContentKeepDays:1});
expect(a.pruneHistory('beta-file-content')).toBe(1); expect(a.pruneHistory('beta-file-content')).toBe(1);
expect(a.db.prepare('SELECT beta_id,length(content) AS bytes FROM beta_revision_files ORDER BY beta_id').all()).toEqual([{beta_id:1,bytes:3},{beta_id:2,bytes:0}]); expect(a.db.prepare('SELECT beta_id,COALESCE((SELECT size FROM file_blobs WHERE sha256=content_hash),0) AS bytes FROM beta_revision_files ORDER BY beta_id').all()).toEqual([{beta_id:1,bytes:3},{beta_id:2,bytes:0}]);
a.saveFleetSettings({...a.fleetSettings(),betaKeepDays:1}); a.saveFleetSettings({...a.fleetSettings(),betaKeepDays:1});
expect(a.pruneHistory('beta')).toBe(1); expect(a.pruneHistory('beta')).toBe(1);
expect(a.db.prepare('SELECT id FROM betas').all()).toEqual([{id:1}]); expect(a.db.prepare('SELECT id FROM betas').all()).toEqual([{id:1}]);
+5 -4
View File
@@ -1,3 +1,4 @@
import { putFileBlob } from '../../src/lib/server/db/file-blobs.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { mkdir, readFile, writeFile } from 'node:fs/promises';
@@ -31,8 +32,8 @@ async function promotion(kind: 'script' | 'plugin' | 'baseline', staged: boolean
db().prepare("INSERT INTO deployment_servers (deployment_id, server, files_changed, restart_state) VALUES ('promote', ?, 1, 'pending')").run(server); db().prepare("INSERT INTO deployment_servers (deployment_id, server, files_changed, restart_state) VALUES ('promote', ?, 1, 'pending')").run(server);
db().prepare("INSERT INTO deployment_scripts (deployment_id, script_id, script_version_id, prior_version_id, kind) VALUES ('promote', ?, ?, ?, ?)") db().prepare("INSERT INTO deployment_scripts (deployment_id, script_id, script_version_id, prior_version_id, kind) VALUES ('promote', ?, ?, ?, ?)")
.run(script.id, version.id, prior.id, kind); .run(script.id, version.id, prior.id, kind);
const file = db().prepare(`INSERT INTO deployment_files (deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before, content_after) const file = db().prepare(`INSERT INTO deployment_files (deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before_hash, content_after_hash)
VALUES ('promote', ?, 'updated', ?, ?, ?, ?, ?)`).run(server, relPath, sha256Buffer(before), sha256Buffer(after), before, after); VALUES ('promote', ?, 'updated', ?, ?, ?, ?, ?)`).run(server, relPath, sha256Buffer(before), sha256Buffer(after), putFileBlob(before, db()), putFileBlob(after, db()));
const path = join(destOfKind(server, kind), relPath); const path = join(destOfKind(server, kind), relPath);
await mkdir(dirname(path), { recursive: true }); await mkdir(dirname(path), { recursive: true });
await writeFile(path, staged ? before : after); await writeFile(path, staged ? before : after);
@@ -86,8 +87,8 @@ describe('deferred promotion rollback', () => {
const { db } = await import('../../src/lib/server/db/index.js'); const { db } = await import('../../src/lib/server/db/index.js');
const { queueFileChange } = await import('../../src/lib/server/fleet/staged-files.js'); const { queueFileChange } = await import('../../src/lib/server/fleet/staged-files.js');
const { rollbackPromoteDeployment } = await import('../../src/lib/server/fleet/rollback.js'); const { rollbackPromoteDeployment } = await import('../../src/lib/server/fleet/rollback.js');
const pending = db().prepare(`INSERT INTO deployment_files (deployment_id, server, verb, rel_path, content_after) const pending = db().prepare(`INSERT INTO deployment_files (deployment_id, server, verb, rel_path, content_after_hash)
VALUES ('promote', ?, 'added', 'never.gsc', ?)`).run(server, Buffer.from('queued')); VALUES ('promote', ?, 'added', 'never.gsc', ?)`).run(server, putFileBlob(Buffer.from('queued'), db()));
queueFileChange({ sourceKind: 'campaign', sourceId: 'promote', server, kind: 'script', relPath: 'never.gsc', queueFileChange({ sourceKind: 'campaign', sourceId: 'promote', server, kind: 'script', relPath: 'never.gsc',
content: Buffer.from('queued'), inventorySource: 'promote:promote', deploymentFileId: Number(pending.lastInsertRowid) }); content: Buffer.from('queued'), inventorySource: 'promote:promote', deploymentFileId: Number(pending.lastInsertRowid) });
const result = await rollbackPromoteDeployment('promote'); const result = await rollbackPromoteDeployment('promote');
+4 -3
View File
@@ -21,8 +21,9 @@ function database(legacy = false) {
function seedHistory(db: Database.Database) { function seedHistory(db: Database.Database) {
db.exec(`INSERT INTO servers (name, game_port) VALUES ('server', 28960); db.exec(`INSERT INTO servers (name, game_port) VALUES ('server', 28960);
INSERT INTO file_blobs(sha256,size,content) VALUES('hash',4,X'74657374');
INSERT INTO scripts (id, name, target_rel_path) VALUES (1, 'script', 'test.gsc'); INSERT INTO scripts (id, name, target_rel_path) VALUES (1, 'script', 'test.gsc');
INSERT INTO script_versions (id, script_id, version, sha256, size, content) VALUES (1, 1, 1, 'hash', 4, X'74657374'); INSERT INTO script_versions (id, script_id, version, sha256, size, content_hash) VALUES (1, 1, 1, 'hash', 4, 'hash');
INSERT INTO betas (id, script_id, script_version_id, trigger_kind, state) VALUES (1, 1, 1, 'ui', 'promoted'); INSERT INTO betas (id, script_id, script_version_id, trigger_kind, state) VALUES (1, 1, 1, 'ui', 'promoted');
INSERT INTO beta_servers (beta_id, server, had_original, stage, healthy_at) VALUES (1, 'server', 0, 'ready', 'healthy'); INSERT INTO beta_servers (beta_id, server, had_original, stage, healthy_at) VALUES (1, 'server', 0, 'ready', 'healthy');
INSERT INTO beta_server_scripts (beta_id, server, script_id, had_original) VALUES (1, 'server', 1, 0); INSERT INTO beta_server_scripts (beta_id, server, script_id, had_original) VALUES (1, 'server', 1, 0);
@@ -32,7 +33,7 @@ function seedHistory(db: Database.Database) {
INSERT INTO beta_file_changes (beta_id, server, rel_path) VALUES (1, 'server', 'test.gsc'); INSERT INTO beta_file_changes (beta_id, server, rel_path) VALUES (1, 'server', 'test.gsc');
INSERT INTO deployments (id, action, started_at, finished_at) VALUES ('campaign', 'promote', 'before', 'after'); INSERT INTO deployments (id, action, started_at, finished_at) VALUES ('campaign', 'promote', 'before', 'after');
INSERT INTO deployment_servers (deployment_id, server, files_changed, restart_state, restarted_at) VALUES ('campaign', 'server', 1, 'done', 'restarted'); INSERT INTO deployment_servers (deployment_id, server, files_changed, restart_state, restarted_at) VALUES ('campaign', 'server', 1, 'done', 'restarted');
INSERT INTO deployment_files (deployment_id, server, verb, rel_path, content_after) VALUES ('campaign', 'server', 'updated', 'test.gsc', X'74657374'); INSERT INTO deployment_files (deployment_id, server, verb, rel_path, content_after_hash) VALUES ('campaign', 'server', 'updated', 'test.gsc', 'hash');
INSERT INTO server_deployments (id, server, action, state, stage, started_at, finished_at, triggered_by) VALUES ('created', 'server', 'create', 'done', 'Ready', 'before', 'after', 'ui'); INSERT INTO server_deployments (id, server, action, state, stage, started_at, finished_at, triggered_by) VALUES ('created', 'server', 'create', 'done', 'Ready', 'before', 'after', 'ui');
INSERT INTO maintenance_jobs (server, state, requested_at, finished_at, detail) VALUES ('server', 'done', 'before', 'after', 'Updated'); INSERT INTO maintenance_jobs (server, state, requested_at, finished_at, detail) VALUES ('server', 'done', 'before', 'after', 'Updated');
INSERT INTO watchdog_incidents (id, server, kind, state, summary, first_seen_at, last_seen_at) VALUES ('incident', 'server', 'health', 'attention', 'Unhealthy', 'before', 'after'); INSERT INTO watchdog_incidents (id, server, kind, state, summary, first_seen_at, last_seen_at) VALUES ('incident', 'server', 'health', 'attention', 'Unhealthy', 'before', 'after');
@@ -76,7 +77,7 @@ describe('durable server history', () => {
expect(JSON.parse(retained.retained_server_deletions)).toEqual({ server: expect.any(String) }); expect(JSON.parse(retained.retained_server_deletions)).toEqual({ server: expect.any(String) });
expect(db.prepare('SELECT stage, healthy_at FROM beta_servers').get()).toEqual({ stage: 'ready', healthy_at: 'healthy' }); expect(db.prepare('SELECT stage, healthy_at FROM beta_servers').get()).toEqual({ stage: 'ready', healthy_at: 'healthy' });
expect(db.prepare('SELECT restart_state, restarted_at FROM deployment_servers').get()).toEqual({ restart_state: 'done', restarted_at: 'restarted' }); expect(db.prepare('SELECT restart_state, restarted_at FROM deployment_servers').get()).toEqual({ restart_state: 'done', restarted_at: 'restarted' });
expect(db.prepare('SELECT content_after FROM deployment_files').get()).toEqual({ content_after: Buffer.from('test') }); expect(db.prepare('SELECT (SELECT content FROM file_blobs WHERE sha256=content_after_hash) AS content_after FROM deployment_files').get()).toEqual({ content_after: Buffer.from('test') });
expect(db.prepare('SELECT status, highest_round, ended_at FROM leaderboard_matches').get()).toEqual({ status: 'interrupted', highest_round: 25, ended_at: expect.any(String) }); expect(db.prepare('SELECT status, highest_round, ended_at FROM leaderboard_matches').get()).toEqual({ status: 'interrupted', highest_round: 25, ended_at: expect.any(String) });
db.exec(`INSERT INTO servers (name, game_port) VALUES ('server', 28960); db.exec(`INSERT INTO servers (name, game_port) VALUES ('server', 28960);
INSERT INTO maintenance_jobs (server, state, requested_at) VALUES ('server', 'queued', 'new'); INSERT INTO maintenance_jobs (server, state, requested_at) VALUES ('server', 'queued', 'new');
+3 -2
View File
@@ -1,3 +1,4 @@
import { putFileBlob } from '../../src/lib/server/db/file-blobs.js';
import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js'; import { newFixture, type Fixture } from '../helpers/fixture.js';
@@ -20,8 +21,8 @@ it('exposes retained campaign and provisioning history without rebinding links t
finishDeployment(operation, { ok: true }); finishDeployment(operation, { ok: true });
db().prepare("INSERT INTO deployments (id, action, started_at, finished_at) VALUES ('history-campaign', 'promote', ?, ?)").run(at, at); db().prepare("INSERT INTO deployments (id, action, started_at, finished_at) VALUES ('history-campaign', 'promote', ?, ?)").run(at, at);
db().prepare("INSERT INTO deployment_servers (deployment_id, server, files_changed, restart_state) VALUES ('history-campaign', ?, 1, 'done')").run(server); db().prepare("INSERT INTO deployment_servers (deployment_id, server, files_changed, restart_state) VALUES ('history-campaign', ?, 1, 'done')").run(server);
db().prepare("INSERT INTO deployment_files (deployment_id, server, verb, rel_path, content_before, content_after) VALUES ('history-campaign', ?, 'updated', 'script.gsc', ?, ?)") db().prepare("INSERT INTO deployment_files (deployment_id, server, verb, rel_path, content_before_hash, content_after_hash) VALUES ('history-campaign', ?, 'updated', 'script.gsc', ?, ?)")
.run(server, Buffer.from('before'), Buffer.from('after')); .run(server, putFileBlob(Buffer.from('before'), db()), putFileBlob(Buffer.from('after'), db()));
db().prepare('DELETE FROM servers WHERE name = ?').run(server); db().prepare('DELETE FROM servers WHERE name = ?').run(server);
await seedServerRegistry(); await seedServerRegistry();
closeDb(); closeDb();
+32
View File
@@ -0,0 +1,32 @@
import Database from 'better-sqlite3';
import { afterEach, expect, it, vi } from 'vitest';
import { timeStatement } from '../../src/lib/server/db/slow-statements.js';
afterEach(() => vi.restoreAllMocks());
it('preserves results and errors, logs slow query shapes without values', () => {
const db = new Database(':memory:');
const log = vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(performance, 'now').mockReturnValueOnce(0).mockReturnValueOnce(21)
.mockReturnValueOnce(30).mockReturnValueOnce(31).mockReturnValueOnce(40).mockReturnValueOnce(62);
try {
const query = timeStatement(db.prepare("SELECT ? AS value, 'private''literal' AS literal"), "SELECT ? AS value, 'private''literal' AS literal", 20);
expect(query.get('secret')).toEqual({ value: 'secret', literal: "private'literal" });
expect(query.all('other')).toHaveLength(1);
expect(() => query.get()).toThrow();
expect(log).toHaveBeenCalledTimes(2);
expect(log.mock.calls[0]).toEqual(['[sql]', { ms: 21, method: 'get', sql: 'SELECT ? AS value, ? AS literal' }]);
expect(JSON.stringify(log.mock.calls)).not.toMatch(/secret|private|other/);
} finally { db.close(); }
});
it('does not wrap statements when disabled or invalid', () => {
const db = new Database(':memory:');
try {
const statement = db.prepare('SELECT 1');
const get = statement.get;
for (const threshold of [0, -1, NaN, Infinity]) {
expect(timeStatement(statement, 'SELECT 1', threshold).get).toBe(get);
}
} finally { db.close(); }
});
+3 -2
View File
@@ -1,3 +1,4 @@
import { putFileBlob } from '../../src/lib/server/db/file-blobs.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { chmod, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { chmod, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { existsSync, writeFileSync } from 'node:fs'; import { existsSync, writeFileSync } from 'node:fs';
@@ -104,8 +105,8 @@ describe.each<Source>(['campaign', 'beta'])('%s staged file activation', (source
it('applies the exact queued bytes even if artifact storage changes while waiting', async () => { it('applies the exact queued bytes even if artifact storage changes while waiting', async () => {
const { replacement } = await queueChange(source); const { replacement } = await queueChange(source);
const { db } = await import('../../src/lib/server/db/index.js'); const { db } = await import('../../src/lib/server/db/index.js');
db().prepare('UPDATE script_versions SET content = ? WHERE id = ?') db().prepare('UPDATE script_versions SET content_hash = ? WHERE id = ?')
.run(Buffer.from('changed after review'), replacement.id); .run(putFileBlob(Buffer.from('changed after review'), db()), replacement.id);
await tick(source); await tick(source);
expect(await readFile(destination())).toEqual(after); expect(await readFile(destination())).toEqual(after);
}); });