Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f8897b86b | ||
|
|
703d414c7a | ||
|
|
b17673720b | ||
|
|
d99e5979fa | ||
|
|
daefb22be6 | ||
|
|
562b770725 | ||
|
|
62df14b180 | ||
|
|
18283c63a7 | ||
|
|
283a553cc2 | ||
|
|
fbf0629486 | ||
|
|
ddb3db9537 | ||
|
|
27be1abf41 | ||
|
|
8e948c07d9 |
@@ -32,3 +32,7 @@ PLUTAINER_IMAGE=ghcr.io/ayymoss/plutainer:latest
|
||||
|
||||
# Optional request/report timings and event-loop delay diagnostics.
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
`dedicated_zm.cfg`. Map selection generates `electric_cherry_map.cfg`, loaded
|
||||
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>`
|
||||
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
|
||||
metadata defaults to `./data/cherry.db`; use a separate test database when
|
||||
exercising enrollment. Production agent transport requires HTTPS.
|
||||
|
||||
+15
-3
@@ -33,7 +33,7 @@ import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
VERSION = "1.5.3"
|
||||
VERSION = "1.6.0"
|
||||
LABEL = "io.electric-cherry"
|
||||
MAX_FILE = 32 * 1024 * 1024
|
||||
MANIFEST_MAX_ENTRIES = 10000
|
||||
@@ -1356,7 +1356,7 @@ class Agent:
|
||||
if line.startswith("PRETTY_NAME="):
|
||||
os_name = line.split("=", 1)[1].strip('"')
|
||||
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})}
|
||||
|
||||
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)))
|
||||
|
||||
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:
|
||||
content = base64.b64decode(item["contentBase64"], validate=True)
|
||||
except (ValueError, KeyError, TypeError):
|
||||
@@ -1677,7 +1689,7 @@ class Agent:
|
||||
if item["path"] in paths:
|
||||
raise AgentError("Duplicate staged file 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)
|
||||
total_size += len(content) if content is not None else 0
|
||||
if total_size > 64 * 1024 * 1024:
|
||||
|
||||
@@ -832,6 +832,26 @@ class AgentTests(unittest.TestCase):
|
||||
self.assertEqual(result["state"], "completed", result)
|
||||
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):
|
||||
self.create()
|
||||
operation = self.operation("instance.restart")
|
||||
|
||||
@@ -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
|
||||
enrollment; inspect `systemctl status docker` and `journalctl -u docker`.
|
||||
|
||||
The controller reconciles automatic setup on agent heartbeats and operation
|
||||
polls. It first pulls `PLUTAINER_IMAGE` (default
|
||||
The controller reconciles automatic setup on agent heartbeats. Command polls only
|
||||
deliver work already queued. Setup first pulls `PLUTAINER_IMAGE` (default
|
||||
`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
|
||||
or enrolling another host needs no manual propagation. Setup waits for an active, connected,
|
||||
|
||||
+14
-1
@@ -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 |
|
||||
| Completed/interrupted matches | 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 game files | No grace period; keep latest 1 version per game |
|
||||
| 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
|
||||
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.
|
||||
Live, stopped, rebuilding and provisioning instances pin their files; unresolved
|
||||
operations and offline host copies also prevent controller deletion. Controller
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"benchmark:leaderboard": "node scripts/benchmark-leaderboard.mjs",
|
||||
"diagnostics:database": "node scripts/database-diagnostics.mjs",
|
||||
"seed:fixture": "tsx scripts/seed-fixture.ts",
|
||||
"agent:package": "python3 agent/package_release.py",
|
||||
"prebuild": "npm run agent:package",
|
||||
|
||||
@@ -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(); }
|
||||
@@ -13,6 +13,7 @@ export interface FleetSettings {
|
||||
matchKeepDays: number;
|
||||
betaKeepDays: number;
|
||||
fileContentKeepDays: number;
|
||||
payloadKeepDays: number;
|
||||
versionKeepDays: number;
|
||||
versionKeepCount: number;
|
||||
gameFileGraceDays: number;
|
||||
@@ -49,7 +50,8 @@ export const defaultFleetSettings: FleetSettings = {
|
||||
telemetryMaxTotal: 10000,
|
||||
matchKeepDays: 0,
|
||||
betaKeepDays: 0,
|
||||
fileContentKeepDays: 0,
|
||||
fileContentKeepDays: 30,
|
||||
payloadKeepDays: 30,
|
||||
versionKeepDays: 0,
|
||||
versionKeepCount: 0,
|
||||
gameFileGraceDays: 0,
|
||||
@@ -86,6 +88,7 @@ export const fleetSettingLabels: Record<keyof FleetSettings, string> = {
|
||||
matchKeepDays: 'Match history retention',
|
||||
betaKeepDays: 'Completed beta history retention',
|
||||
fileContentKeepDays: 'File diffs and rollback snapshot retention',
|
||||
payloadKeepDays: 'Launch and command file retention',
|
||||
versionKeepDays: 'Unused artifact version retention',
|
||||
versionKeepCount: 'Minimum artifact versions to keep',
|
||||
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 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 {
|
||||
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.',
|
||||
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.',
|
||||
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.',
|
||||
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.',
|
||||
|
||||
@@ -22,6 +22,7 @@ export function ensureAuthSchema(handle: Database.Database): void {
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, actor_id TEXT, action TEXT 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);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,16 @@ export function migrateNativeInstances(handle: Database.Database): void {
|
||||
creation_id TEXT PRIMARY KEY 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. */
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -10,8 +10,15 @@ import { backfillLeaderboard } from '../fleet/leaderboard-ingest.js';
|
||||
import { initializeFleetSettings } from './fleet-settings.js';
|
||||
import { migrateServerHistory } from './server-history.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 optimizeTimer: NodeJS.Timeout | undefined;
|
||||
|
||||
// Desired shape of the `servers` table. Every column here is added to the
|
||||
// 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.
|
||||
try {
|
||||
handle.pragma('journal_mode = WAL');
|
||||
handle.pragma('auto_vacuum = INCREMENTAL');
|
||||
handle.pragma('foreign_keys = ON');
|
||||
tuneConnection(handle);
|
||||
dropLegacyBetaTables(handle);
|
||||
handle.exec(schemaSql);
|
||||
migrateProvisioningActions(handle);
|
||||
@@ -386,6 +395,8 @@ export function db(): Database.Database {
|
||||
backfillLeaderboard(handle);
|
||||
initializeFleetSettings(handle);
|
||||
migrateNativeInstances(handle);
|
||||
migrateVersionLayout(handle);
|
||||
migrateFileBlobs(handle);
|
||||
// Lifecycle history is retained indefinitely. Admission and status reads
|
||||
// must narrow by server or active state instead of scanning that history.
|
||||
// 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;
|
||||
CREATE INDEX IF NOT EXISTS idx_server_deployments_unsettled
|
||||
ON server_deployments(id) WHERE state NOT IN ('done','warning','cancelled');`);
|
||||
installPerformanceIndexes(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) {
|
||||
handle.close();
|
||||
throw err;
|
||||
@@ -414,21 +435,29 @@ export function db(): Database.Database {
|
||||
// Callers share statement options (e.g. raw()); use consistent options for
|
||||
// identical SQL and pass values to run/get/all rather than binding permanently.
|
||||
const statements = new Map<string, Database.Statement>();
|
||||
const slowMs = Number(process.env.EC_SLOW_SQL_MS ?? 0);
|
||||
const prepare = handle.prepare.bind(handle);
|
||||
handle.prepare = ((sql: string) => {
|
||||
let statement = statements.get(sql);
|
||||
if (!statement) {
|
||||
statement = prepare(sql);
|
||||
statement = timeStatement(prepare(sql), sql, slowMs);
|
||||
statements.set(sql, statement);
|
||||
}
|
||||
return statement;
|
||||
}) as typeof handle.prepare;
|
||||
|
||||
_db = handle;
|
||||
optimizeTimer = setInterval(() => {
|
||||
try { handle.pragma('optimize'); }
|
||||
catch (error) { console.error('[database] optimize failed:', error); }
|
||||
}, 60 * 60_000);
|
||||
optimizeTimer.unref();
|
||||
return _db;
|
||||
}
|
||||
|
||||
export function closeDb(): void {
|
||||
clearInterval(optimizeTimer);
|
||||
optimizeTimer = undefined;
|
||||
if (_db) {
|
||||
_db.close();
|
||||
_db = null;
|
||||
|
||||
@@ -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);
|
||||
`);
|
||||
}
|
||||
@@ -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');
|
||||
})();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
-- 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
|
||||
@@ -130,11 +140,11 @@ CREATE TABLE IF NOT EXISTS script_versions (
|
||||
release_version TEXT, -- publisher's version/tag, independent of upload order
|
||||
sha256 TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
content BLOB NOT NULL,
|
||||
notes TEXT,
|
||||
state TEXT NOT NULL DEFAULT 'untested', -- 'untested' | 'tested' | 'test-failed'
|
||||
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')),
|
||||
content_hash TEXT REFERENCES file_blobs(sha256),
|
||||
UNIQUE(script_id, version)
|
||||
);
|
||||
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,
|
||||
release_version TEXT,
|
||||
sha256 TEXT NOT NULL,
|
||||
content BLOB NOT NULL,
|
||||
content_hash TEXT REFERENCES file_blobs(sha256),
|
||||
PRIMARY KEY (beta_id, revision, script_id),
|
||||
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,
|
||||
server TEXT NOT NULL REFERENCES servers(name) ON DELETE CASCADE,
|
||||
rel_path TEXT NOT NULL,
|
||||
prior_content BLOB NOT NULL,
|
||||
prior_content_hash TEXT REFERENCES file_blobs(sha256),
|
||||
prior_sha256 TEXT NOT NULL,
|
||||
prior_size INTEGER NOT NULL,
|
||||
PRIMARY KEY (beta_id, server, rel_path)
|
||||
@@ -364,8 +374,8 @@ CREATE TABLE IF NOT EXISTS deployment_files (
|
||||
rel_path TEXT NOT NULL,
|
||||
sha256_before TEXT,
|
||||
sha256_after TEXT,
|
||||
content_before BLOB,
|
||||
content_after BLOB,
|
||||
content_before_hash TEXT REFERENCES file_blobs(sha256),
|
||||
content_after_hash TEXT REFERENCES file_blobs(sha256),
|
||||
-- An unknown before image is distinct from a confirmed absent file.
|
||||
snapshot_pending INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
@@ -382,7 +392,7 @@ CREATE TABLE IF NOT EXISTS staged_file_changes (
|
||||
server TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
rel_path TEXT NOT NULL,
|
||||
content BLOB,
|
||||
content_hash TEXT REFERENCES file_blobs(sha256),
|
||||
script_version_id INTEGER,
|
||||
inventory_source TEXT NOT NULL,
|
||||
deployment_file_id INTEGER REFERENCES deployment_files(id) ON DELETE CASCADE,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'); }
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 (?, ?, ?, ?)')
|
||||
.run(id, revision, startedAt, notes);
|
||||
db().prepare(`INSERT OR IGNORE INTO beta_revision_files
|
||||
(beta_id, revision, script_id, name, kind, path, version_id, version, release_version, sha256, 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
|
||||
(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_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
|
||||
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[] {
|
||||
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 }>;
|
||||
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,
|
||||
results: results_json ? JSON.parse(results_json) : null,
|
||||
files: db().prepare(`SELECT script_id, name, kind, path, version_id, version, release_version, sha256
|
||||
FROM beta_revision_files WHERE beta_id = ? AND revision = ? ORDER BY script_id`).all(id, row.revision) as BetaRevision['files']
|
||||
files: files.filter(file => file.revision === row.revision).map(({ revision, ...file }) => file)
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { GameTelemetry } from '../../telemetry.js';
|
||||
import { applySample, applyGameEvent, emptyMetrics, evaluateRequirements, type TestMetrics } from './beta-evaluation.js';
|
||||
import { ONLINE_MAX_MS } from '../../server-freshness.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 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);
|
||||
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 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 {
|
||||
const previous = storedPlan(id);
|
||||
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}` : ''}`);
|
||||
}
|
||||
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 {
|
||||
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;
|
||||
|
||||
@@ -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 { storedPlan, testView, activity, setOutcome } from './beta-tests.js';
|
||||
import { archiveBetaRevision, currentBetaRevision } from './beta-revisions.js';
|
||||
@@ -89,7 +91,7 @@ function composeCfgForServer(
|
||||
if (inputs.inBetaBaseline) {
|
||||
const s = getScript(inputs.inBetaBaseline.script_id);
|
||||
if (s && scopeAppliesToServer(s, { game: serverGame, map: serverMap })) {
|
||||
baselineText = inputs.inBetaBaseline.content.toString('utf8');
|
||||
baselineText = getVersionContent(inputs.inBetaBaseline.id).toString('utf8');
|
||||
baselineScriptId = s.id;
|
||||
}
|
||||
}
|
||||
@@ -99,7 +101,7 @@ function composeCfgForServer(
|
||||
if (promoted?.current_version_id) {
|
||||
const ver = getVersion(promoted.current_version_id);
|
||||
if (ver) {
|
||||
baselineText = ver.content.toString('utf8');
|
||||
baselineText = getVersionContent(ver.id).toString('utf8');
|
||||
baselineScriptId = promoted.id;
|
||||
}
|
||||
}
|
||||
@@ -122,7 +124,7 @@ function composeCfgForServer(
|
||||
if (!s.current_version_id) continue;
|
||||
const ver = getVersion(s.current_version_id);
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -132,7 +134,7 @@ function composeCfgForServer(
|
||||
// In-beta overlays scoped to this server.
|
||||
const inBetaScopedOverlays = inputs.inBetaOverlays
|
||||
.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);
|
||||
return { text: composed, baselineScriptId };
|
||||
@@ -604,7 +606,7 @@ function revisionFileChanges(betaId: number, name: string): Parameters<typeof qu
|
||||
const server = getServer(name)!;
|
||||
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,
|
||||
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))) {
|
||||
const composed = composeCfgForServer(name, server.game, server.map, {
|
||||
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 });
|
||||
for (const r of nonCollapsing) {
|
||||
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
|
||||
@@ -650,7 +652,7 @@ async function deployBetaServersInternal(betaId: number, names: string[]): Promi
|
||||
try {
|
||||
const changes: Parameters<typeof queueFileChange>[0][] = nonCollapsing.map(({ script, version }) => ({
|
||||
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}`
|
||||
}));
|
||||
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 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 = ?`
|
||||
);
|
||||
|
||||
@@ -796,10 +798,10 @@ async function rollbackBetaInternal(betaId: number, options: { restart?: 'manual
|
||||
}
|
||||
for (const change of restorations) {
|
||||
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,
|
||||
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,
|
||||
kind: change.kind, relPath: change.relPath, content: change.content,
|
||||
scriptVersionId: change.versionId, inventorySource: `rollback:${betaId}`,
|
||||
@@ -968,7 +970,7 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
|
||||
const plans: Plan[] = [];
|
||||
|
||||
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 = ?`
|
||||
);
|
||||
|
||||
@@ -990,7 +992,7 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
|
||||
priorSha = snap.prior_sha256;
|
||||
} else if (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 = {
|
||||
script, version, server: name,
|
||||
@@ -1095,7 +1097,7 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
|
||||
);
|
||||
const insertDepFile = db().prepare(
|
||||
`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 (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
|
||||
@@ -1120,13 +1122,13 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
|
||||
p.script.target_rel_path,
|
||||
p.priorSha,
|
||||
p.version.sha256,
|
||||
p.priorContent,
|
||||
p.version.content,
|
||||
putFileBlob(p.priorContent),
|
||||
putFileBlob(getVersionContent(p.version.id)),
|
||||
betaServerSet.has(p.server) ? 0 : 1
|
||||
);
|
||||
if (plansToActivate.has(p)) {
|
||||
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}`,
|
||||
deploymentFileId: Number(file.lastInsertRowid), preserveBeforeImage: betaServerSet.has(p.server) });
|
||||
}
|
||||
@@ -1140,8 +1142,8 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
|
||||
configFileOf(cp.server),
|
||||
cp.priorSha,
|
||||
cp.composedSha,
|
||||
cp.priorContent,
|
||||
cp.composedContent,
|
||||
putFileBlob(cp.priorContent),
|
||||
putFileBlob(cp.composedContent),
|
||||
betaServerSet.has(cp.server) ? 0 : 1
|
||||
);
|
||||
if (cp.requiresActivation) {
|
||||
|
||||
@@ -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 { randomUUID } from 'node:crypto';
|
||||
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;
|
||||
const ver = getVersion(entry.scriptVersionId);
|
||||
return ver?.content ?? null;
|
||||
return ver ? getVersionContent(ver.id) : null;
|
||||
}
|
||||
|
||||
function composeExpectedCfg(
|
||||
@@ -400,11 +402,11 @@ export function createDriftRebuildCampaign(
|
||||
db().prepare("INSERT INTO deployments (id, action, started_at, triggered_by) VALUES (?, 'drift', ?, 'watchdog')")
|
||||
.run(id, new Date().toISOString());
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?)`);
|
||||
for (const { file, before, after } of prepared.files) {
|
||||
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)
|
||||
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(
|
||||
`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 (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
|
||||
@@ -512,7 +514,7 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise<Run
|
||||
const content = await readFile(abs);
|
||||
if (hasActiveBeta(s.server)) throw new Error('active beta owns this server; reconciliation deferred');
|
||||
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,
|
||||
kind: entry.kind, relPath: entry.relPath, content: null,
|
||||
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');
|
||||
db().transaction(() => {
|
||||
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,
|
||||
kind: entry.kind, relPath: entry.relPath, content: written,
|
||||
scriptVersionId: entry.kind === 'config' ? null : entry.scriptVersionId ?? null,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { rconStatusResult, agentRconStatusResult, type AgentRconReply } from '..
|
||||
import { withOperationSource } from '../hosts/operation-context.js';
|
||||
import { getServer } from './servers.js';
|
||||
import { latestGameSubmission } from './lifecycle-evidence.js';
|
||||
import type { MonitoringSnapshot } from './monitoring-snapshot.js';
|
||||
import { ONLINE_MAX_MS } from '../../server-freshness.js';
|
||||
|
||||
const MAX_AGE_MS = 30_000;
|
||||
@@ -25,19 +26,20 @@ interface Entry<T> {
|
||||
export class MonitoringChangedError extends Error {}
|
||||
export class MonitoringSkippedError extends Error {}
|
||||
|
||||
function identity(server: string, probe: Probe): Identity {
|
||||
const row = getServer(server);
|
||||
function identity(server: string, probe: Probe, snapshot?: MonitoringSnapshot): Identity {
|
||||
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.');
|
||||
const native = row.native_instance_id ? nativeInstance(server) : null;
|
||||
if (row.native_instance_id && (!native || getHost(native.host_id)?.connectionState !== 'online')) {
|
||||
const native = snapshot ? snapshot.get(server)?.native : row.native_instance_id ? nativeInstance(server) : null;
|
||||
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.');
|
||||
}
|
||||
// Stop requests also invalidate observations, even though they do not
|
||||
// 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;
|
||||
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,
|
||||
probe === 'rcon' ? [row.rcon_host, config.rcon] : 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,
|
||||
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;
|
||||
try { current = identity(server, probe); }
|
||||
try { current = identity(server, probe, snapshot); }
|
||||
catch (error) { entries.delete(server); throw error; }
|
||||
const saved = entries.get(server);
|
||||
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);
|
||||
while (entries.size > MAX_SERVERS) entries.delete(entries.keys().next().value!);
|
||||
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)) {
|
||||
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.
|
||||
const statuses = new Map<string, { snapshot?: MonitoringStatus }>();
|
||||
|
||||
export function monitoringIdentity(server: string): string {
|
||||
const container = identity(server, 'inspect');
|
||||
const game = identity(server, 'rcon');
|
||||
export function monitoringIdentity(server: string, snapshot?: MonitoringSnapshot): string {
|
||||
const container = identity(server, 'inspect', snapshot);
|
||||
const game = identity(server, 'rcon', snapshot);
|
||||
// Identity includes connection configuration; persist only its fingerprint.
|
||||
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. */
|
||||
export function monitoringStatusWriter(server: string) {
|
||||
export function monitoringStatusWriter(server: string, snapshot?: MonitoringSnapshot) {
|
||||
let before: string;
|
||||
try { before = identity(server, 'inspect').key; }
|
||||
try { before = identity(server, 'inspect', snapshot).key; }
|
||||
catch { return () => {}; }
|
||||
const slot = { snapshot: statuses.get(server)?.snapshot };
|
||||
statuses.delete(server);
|
||||
@@ -126,16 +129,16 @@ export function monitoringStatusWriter(server: string) {
|
||||
// network requests are still in flight.
|
||||
if (statuses.get(server) !== slot) return;
|
||||
try {
|
||||
if (identity(server, 'inspect').key !== before) return;
|
||||
if (identity(server, 'inspect', snapshot).key !== before) return;
|
||||
const current = slot.snapshot;
|
||||
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); }
|
||||
};
|
||||
}
|
||||
|
||||
/** 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;
|
||||
if (!saved) return null;
|
||||
const fresh = (at: string) => {
|
||||
@@ -143,28 +146,28 @@ export function monitoringStatus(server: string): MonitoringStatus | null {
|
||||
return age >= 0 && age <= ONLINE_MAX_MS;
|
||||
};
|
||||
try {
|
||||
if (saved.identity !== monitoringIdentity(server)
|
||||
if (saved.identity !== monitoringIdentity(server, snapshot)
|
||||
|| !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)
|
||||
&& !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 };
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** Keep the actual sample time: a later heartbeat must not renew old evidence. */
|
||||
export function heartbeatInspection(server: string): Observation<Awaited<ReturnType<typeof nativeHealth>>> | null {
|
||||
const row = getServer(server);
|
||||
export function heartbeatInspection(server: string, snapshot?: MonitoringSnapshot): Observation<Awaited<ReturnType<typeof nativeHealth>>> | null {
|
||||
const row = snapshot ? snapshot.get(server)?.server : getServer(server);
|
||||
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;
|
||||
const state = host.inventory.instanceStates?.find(state => state.instanceId === row.native_instance_id && state.generation === row.instance_generation);
|
||||
if (!state) return null;
|
||||
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 };
|
||||
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
|
||||
? { 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>>>;
|
||||
|
||||
/** 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 batches = new Map<string, { instances: NativeInstance[]; result: Promise<{ results: Array<{ instanceId: string; generation: number; reply: AgentRconReply }> }> }>();
|
||||
for (const server of servers) {
|
||||
const instance = nativeInstance(server);
|
||||
const instance = snapshot ? snapshot.get(server)?.native : nativeInstance(server);
|
||||
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;
|
||||
const container = heartbeatInspection(server);
|
||||
const container = heartbeatInspection(server, snapshot);
|
||||
if (container && !container.result.ok && container.result.state !== 'unknown') continue;
|
||||
const before = identity(server, 'rcon');
|
||||
const before = identity(server, 'rcon', snapshot);
|
||||
const request = async () => {
|
||||
let batch = batches.get(instance.host_id);
|
||||
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.
|
||||
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.');
|
||||
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
|
||||
// queue slot. Consume failures too, so a failed batch is never retried
|
||||
// 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 () => {
|
||||
const result = await pending;
|
||||
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))) {
|
||||
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. */
|
||||
export function inspectForMonitoring(server: string, fresh = false) {
|
||||
export function inspectForMonitoring(server: string, fresh = false, snapshot?: MonitoringSnapshot) {
|
||||
if (fresh) statuses.delete(server);
|
||||
const heartbeat = !fresh && heartbeatInspection(server);
|
||||
const heartbeat = !fresh && heartbeatInspection(server, snapshot);
|
||||
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);
|
||||
return observe(server, fresh, 'rcon', rcon, rconStatusResult);
|
||||
return observe(server, fresh, 'rcon', rcon, rconStatusResult, snapshot);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { packFileJson, unpackFileJson } from '../db/file-blobs.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { BlueprintSummary, LaunchRecipeSummary, LaunchFileSummary } from '$lib/deployment-plan.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. */
|
||||
export function recipeStorage(recipe: LaunchRecipePlan): { recipe: string; recipeSummary: string } {
|
||||
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 {
|
||||
@@ -137,7 +138,7 @@ export function recipeSummaryForDeployment(id: string): { recipe: LaunchRecipeSu
|
||||
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 = ?')
|
||||
.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 } {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
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. */
|
||||
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. */
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { putFileBlob, blobContent } from '../db/file-blobs.js';
|
||||
import type { ArtifactKind } from '../config.js';
|
||||
import { db } from '../db/index.js';
|
||||
import { emit } from '../pipeline/bus.js';
|
||||
@@ -101,7 +102,7 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
|
||||
|
||||
const fileRows = db()
|
||||
.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
|
||||
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`
|
||||
@@ -184,7 +185,7 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
|
||||
);
|
||||
const insertDepFile = 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 (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
|
||||
@@ -226,8 +227,8 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
|
||||
f.rel_path,
|
||||
f.sha256_after,
|
||||
f.sha256_before,
|
||||
f.content_after,
|
||||
f.content_before
|
||||
putFileBlob(f.content_after),
|
||||
putFileBlob(f.content_before)
|
||||
);
|
||||
const owningScript = scriptByTarget.get(f.rel_path);
|
||||
const priorVersion = owningScript?.prior_version_id ? getVersion(owningScript.prior_version_id) : null;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { putFileBlob, blobContent } from '../db/file-blobs.js';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { db } from '../db/index.js';
|
||||
import { configFileForGame, type ArtifactKind } from '../config.js';
|
||||
@@ -32,7 +33,6 @@ export interface ScriptVersionRow {
|
||||
release_version: string | null;
|
||||
sha256: string;
|
||||
size: number;
|
||||
content: Buffer;
|
||||
notes: string | null;
|
||||
state: ScriptVersionState;
|
||||
kind: ArtifactKind;
|
||||
@@ -137,14 +137,22 @@ export function listPromotedOverlaysForScope(
|
||||
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[] {
|
||||
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[];
|
||||
}
|
||||
|
||||
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
|
||||
| undefined;
|
||||
}
|
||||
@@ -162,7 +170,7 @@ export function getVersionMetadata(id: number): ScriptVersionMetadata | undefine
|
||||
// upload, whether or not it has been promoted (current_version_id).
|
||||
export function getLatestVersion(scriptId: number): ScriptVersionRow | undefined {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -234,7 +242,7 @@ export function createScript(input: CreateScriptInput): { script: ScriptRow; ver
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
);
|
||||
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, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
|
||||
@@ -243,7 +251,7 @@ export function createScript(input: CreateScriptInput): { script: ScriptRow; ver
|
||||
db().transaction(() => {
|
||||
const s = insertScript.run(input.name, targetRelPath, input.scopeType ?? null, input.scopeValue ?? null, kind);
|
||||
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);
|
||||
})();
|
||||
|
||||
@@ -274,10 +282,10 @@ export function uploadNewVersion(scriptId: number, content: Buffer, notes?: stri
|
||||
|
||||
const res = 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 (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.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))!;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getVersionContent } from './scripts.js';
|
||||
import { composeServerCfg } from './config-compose.js';
|
||||
import { getBaselineForScope, getVersion, listPromotedOverlaysForScope } from './scripts.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) : [])
|
||||
].flatMap((overlay) => {
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { putFileBlob, blobContent } from '../db/file-blobs.js';
|
||||
import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, relative, resolve, isAbsolute } from 'node:path';
|
||||
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. */
|
||||
export function queueFileChange(change: FileChange): void {
|
||||
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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
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,
|
||||
preserve_before_image = excluded.preserve_before_image, check_before = excluded.check_before,
|
||||
expected_before_sha = excluded.expected_before_sha
|
||||
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.preserveBeforeImage ? 1 : 0, change.expectedBeforeSha !== undefined ? 1 : 0, change.expectedBeforeSha ?? null);
|
||||
}
|
||||
|
||||
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`)
|
||||
.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. */
|
||||
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 = ?')
|
||||
.get(row.source_id, row.server, row.rel_path)) return;
|
||||
const prior = getServerScript(row.server, row.rel_path);
|
||||
if (before !== null) db().prepare(`INSERT OR IGNORE INTO beta_file_snapshots
|
||||
(beta_id, server, rel_path, prior_content, prior_sha256, prior_size) VALUES (?, ?, ?, ?, ?, ?)`)
|
||||
.run(row.source_id, row.server, row.rel_path, before, sha256Buffer(before), before.length);
|
||||
(beta_id, server, rel_path, prior_content_hash, prior_sha256, prior_size) VALUES (?, ?, ?, ?, ?, ?)`)
|
||||
.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 = ?
|
||||
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);
|
||||
@@ -100,7 +101,7 @@ export function captureBetaBeforeImage(row: StagedFileChange, before: Buffer | n
|
||||
* from interleaving between the final gates and dispatching the restart.
|
||||
*/
|
||||
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
|
||||
WHERE s.operation_id = ? AND f.server = ? AND f.state IN ('pending','applying') ORDER BY f.id`)
|
||||
.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) {
|
||||
db().prepare(`UPDATE deployment_files SET
|
||||
verb = ?, content_before = ?, sha256_before = ?, snapshot_pending = 0 WHERE id = ?`)
|
||||
.run(row.content === null ? 'removed' : before === null ? 'added' : 'updated', before,
|
||||
verb = ?, content_before_hash = ?, sha256_before = ?, snapshot_pending = 0 WHERE id = ?`)
|
||||
.run(row.content === null ? 'removed' : before === null ? 'added' : 'updated', putFileBlob(before),
|
||||
before === null ? null : sha256Buffer(before), row.deployment_file_id);
|
||||
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}`);
|
||||
|
||||
@@ -48,6 +48,7 @@ export function ensureGameFileSchema(handle: Database.Database): void {
|
||||
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
|
||||
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 }[];
|
||||
if (!versionColumns.some(column => column.name === 'last_activity_at')) handle.exec('ALTER TABLE game_file_versions ADD COLUMN last_activity_at TEXT');
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { packFileJson, unpackFileJson, referenceBlobs } from '../db/file-blobs.js';
|
||||
import { pruneHistory } from '../retention.js';
|
||||
import { recordAgentReceipt } from '../provisioning/agent-events.js';
|
||||
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 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 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 {
|
||||
const row = database().prepare(`SELECT ${operationStatusColumns},${operationPayloadSql} AS payload FROM ec_host_operations o WHERE o.id=?`)
|
||||
.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); }
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
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 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,
|
||||
@@ -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
|
||||
{ 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) {
|
||||
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.');
|
||||
return getHostOperationStatus(existing.id)!;
|
||||
}
|
||||
}
|
||||
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.');
|
||||
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 serverName = options.instanceId ? (database().prepare('SELECT server FROM native_instances WHERE id=?')
|
||||
.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)
|
||||
VALUES(?,?,?,?,?,?,CAST(? AS TEXT),?,?,?,?)`)
|
||||
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),?,?,?,?,?)`)
|
||||
.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);
|
||||
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);
|
||||
@@ -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=?`)
|
||||
.get(id, hostId) as { payload: Buffer } | undefined;
|
||||
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 {
|
||||
const operation = database().transaction(() => {
|
||||
@@ -575,8 +583,13 @@ export function reportHostOperationStatus(hostId: string, id: string, input: { s
|
||||
const state = input.state as HostOperationState;
|
||||
if (operation.state === 'running' && state === 'accepted') return operation;
|
||||
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) {
|
||||
const packed = packFileJson(input.result);
|
||||
result = JSON.stringify(packed.value);
|
||||
referenceBlobs('operation-result', id, packed.hashes);
|
||||
}
|
||||
const observation = state === 'completed' && !error && successfulObservation(operation.kind,
|
||||
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=?')
|
||||
|
||||
@@ -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 {
|
||||
return db().transaction(() => {
|
||||
const host = getHost(hostId);
|
||||
@@ -203,5 +203,5 @@ export function reconcileHostPreparation(hostId: string): HostPreparation {
|
||||
scheduleAvailabilityRefresh();
|
||||
}
|
||||
return status;
|
||||
}).immediate();
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -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 { successfulObservationSql, routineObservationSql, operationTargetSql, importantOperationSql } from './observations.js';
|
||||
|
||||
@@ -6,6 +10,7 @@ export function ensureHostSchema(handle: Database.Database): void {
|
||||
if (initialized.has(handle)) return;
|
||||
// Background readers open only after controller migrations have completed.
|
||||
if (handle.readonly) { initialized.add(handle); return; }
|
||||
ensureFileBlobSchema(handle);
|
||||
handle.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ec_hosts (
|
||||
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')),
|
||||
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 }[];
|
||||
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)
|
||||
WHERE ${importantOperationSql.replaceAll('o.', '')}`);
|
||||
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')) {
|
||||
handle.transaction(() => {
|
||||
// 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)');
|
||||
})();
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ export type BetaStage =
|
||||
export type BetaState = 'running' | 'ready-promote' | 'promoted' | 'rolled-back';
|
||||
|
||||
export type PipelineEvent =
|
||||
| { type: 'beta-changed'; betaId: number }
|
||||
| { type: 'fleet-invalidated' }
|
||||
| { type: 'server-telemetry'; entries: GameTelemetry[] }
|
||||
| { type: 'deployment-opened'; deploymentId: string; action: string; servers: string[] }
|
||||
@@ -79,6 +80,15 @@ class Bus extends EventEmitter {
|
||||
}
|
||||
|
||||
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;
|
||||
export function invalidateFleet(): void {
|
||||
if (invalidationPending) return;
|
||||
@@ -90,6 +100,7 @@ export function invalidateFleet(): void {
|
||||
export function emit(event: PipelineEvent): void {
|
||||
bus.emit('event', event);
|
||||
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 {
|
||||
|
||||
@@ -6,18 +6,17 @@ import { betaTick } from './beta-runner.js';
|
||||
import { emit } from './bus.js';
|
||||
import type { ServerStatusEntry } from './bus.js';
|
||||
import type { RconFailure } from '../integrations/rcon.js';
|
||||
import { getServer, listServers, setServerFields, type ServerRow } from '../fleet/servers.js';
|
||||
import { inspectForMonitoring, rconForMonitoring, prepareMonitoringRcon, monitoringStatus, monitoringStatusWriter, MonitoringChangedError, MonitoringSkippedError } from '../fleet/health-observations.js';
|
||||
import { restartSettling } from '../provisioning/index.js';
|
||||
import { serverObservation, observationReader } from '../fleet/status.js';
|
||||
import { telemetryEvidence, latestGameSubmission } from '../fleet/status-evidence.js';
|
||||
import { isProvisioning } from '../fleet/provisioning.js';
|
||||
import { setServerFields, type ServerRow } from '../fleet/servers.js';
|
||||
import { inspectForMonitoring, rconForMonitoring, prepareMonitoringRcon, monitoringStatus, monitoringStatusWriter, monitoringIdentity, MonitoringChangedError, MonitoringSkippedError } from '../fleet/health-observations.js';
|
||||
import { serverObservation } from '../fleet/status.js';
|
||||
import { STATUS_REFRESH_MS } from '../../server-freshness.js';
|
||||
import { recordMetrics } from '../fleet/metrics.js';
|
||||
import { selectedZombieMap } from '../../map-aliases.js';
|
||||
import { runMaintenance } from '../maintenance/index.js';
|
||||
import { cleanupControllerLibraries } from '../game-files/index.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';
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 pending = pendingStatus.get(server);
|
||||
if (pending) return pending;
|
||||
@@ -118,10 +117,10 @@ function queueStatus(srv: ServerRow, probe?: () => ReturnType<typeof rconForMoni
|
||||
queue.queued.push(async () => {
|
||||
let capacity: number | null = null;
|
||||
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.
|
||||
const current = getServer(server);
|
||||
if (current) publishStatus(statusEntry(current));
|
||||
const current = snapshot.get(server)?.server;
|
||||
if (current) publishStatus(statusEntry(current, snapshot.observe));
|
||||
} catch (err) { console.error(`[worker] status error for ${server}:`, err); }
|
||||
finally { pendingStatus.delete(server); finish(capacity); }
|
||||
});
|
||||
@@ -129,19 +128,24 @@ function queueStatus(srv: ServerRow, probe?: () => ReturnType<typeof rconForMoni
|
||||
return work;
|
||||
}
|
||||
|
||||
export async function fanoutStatus(): Promise<void> {
|
||||
const servers = listServers();
|
||||
export function fanoutStatus(): Promise<void> {
|
||||
return withHostRequestContext(statusPass);
|
||||
}
|
||||
|
||||
async function statusPass(): Promise<void> {
|
||||
const snapshot = new MonitoringSnapshot();
|
||||
const servers = snapshot.servers();
|
||||
if (!servers.length) return;
|
||||
const probes = prepareMonitoringRcon(servers.filter(server => !pendingStatus.has(server.name)
|
||||
&& !isProvisioning(server.name) && !restartSettling(server.name) && !telemetryEvidence(server.name)).map(server => server.name));
|
||||
const results = await Promise.all(servers.map(server => queueStatus(server, probes.get(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, snapshot, probes.get(server.name))));
|
||||
const capacities = new Map(servers.map((server, i) => [server.name, results[i]]));
|
||||
try {
|
||||
// Metrics remain complete fleet collections, using freshness at capture
|
||||
// time rather than the status saved at the beginning of a long scan.
|
||||
const observe = observationReader();
|
||||
recordMetrics(listServers().map(srv => {
|
||||
const current = observe(srv);
|
||||
snapshot.refresh();
|
||||
recordMetrics(snapshot.servers().map(srv => {
|
||||
const current = snapshot.observe(srv);
|
||||
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',
|
||||
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); }
|
||||
}
|
||||
|
||||
async function refreshServerStatus(server: string, probeStatus?: () => ReturnType<typeof rconForMonitoring>): Promise<number | null> {
|
||||
const srv = getServer(server);
|
||||
async function refreshServerStatus(server: string, snapshot: MonitoringSnapshot, probeStatus?: () => ReturnType<typeof rconForMonitoring>): Promise<number | null> {
|
||||
snapshot.refresh(server);
|
||||
const srv = snapshot.get(server)?.server;
|
||||
if (!srv) return null;
|
||||
let capacity: number | null = null;
|
||||
let updates: Partial<Record<keyof ServerRow, string | number | null>> = {};
|
||||
const flush = () => { setServerFields(srv.name, updates); updates = {}; };
|
||||
if (!isProvisioning(srv.name)) {
|
||||
const previousGame = monitoringStatus(srv.name)?.game;
|
||||
const publishObservation = monitoringStatusWriter(srv.name);
|
||||
const flush = () => { setServerFields(srv.name, updates); snapshot.patch(srv.name, updates as Partial<ServerRow>); updates = {}; };
|
||||
if (!snapshot.provisioning(srv.name)) {
|
||||
const previousGame = monitoringStatus(srv.name, snapshot)?.game;
|
||||
const publishObservation = monitoringStatusWriter(srv.name, snapshot);
|
||||
let gameObservation: Awaited<ReturnType<typeof rconForMonitoring>> | undefined;
|
||||
const settling = restartSettling(srv.name);
|
||||
const epoch = latestGameSubmission(srv.name);
|
||||
const settling = snapshot.settling(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 current = getServer(srv.name);
|
||||
return epoch === latestGameSubmission(srv.name) && !isProvisioning(srv.name)
|
||||
const current = snapshot.get(srv.name)?.server;
|
||||
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.native_instance_id === srv.native_instance_id && current.instance_generation === srv.instance_generation;
|
||||
};
|
||||
const inspectionStartedAt = new Date().toISOString();
|
||||
const inspected = settling
|
||||
? { 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 });
|
||||
snapshot.refresh(srv.name);
|
||||
if (!inspected) return null;
|
||||
const container = inspected.result;
|
||||
// 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_checked_at = inspected.observedAt;
|
||||
// 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.rcon_failures = 0;
|
||||
updates.last_reply_snippet = null;
|
||||
} else if (unavailable || !telemetryEvidence(srv.name)) {
|
||||
} else if (unavailable || !snapshot.telemetryEvidence(srv.name)) {
|
||||
flush();
|
||||
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) {
|
||||
// The container was checked, but game evidence retains its original age.
|
||||
if (unchanged()) publishObservation(inspected, previousGame);
|
||||
@@ -199,9 +209,10 @@ async function refreshServerStatus(server: string, probeStatus?: () => ReturnTyp
|
||||
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 };
|
||||
});
|
||||
snapshot.refresh(srv.name);
|
||||
if (!probe) return null;
|
||||
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) {
|
||||
gameObservation = probe;
|
||||
const now = probe.observedAt;
|
||||
@@ -234,9 +245,8 @@ async function refreshServerStatus(server: string, probeStatus?: () => ReturnTyp
|
||||
updates.rcon_failures = 0;
|
||||
updates.last_reply_snippet = null;
|
||||
}
|
||||
flush();
|
||||
const current = getServer(srv.name) ?? srv;
|
||||
const status = serverObservation(current);
|
||||
const current = { ...(snapshot.get(srv.name)?.server ?? srv), ...updates } as ServerRow;
|
||||
const status = snapshot.observe(current);
|
||||
// Occupancy timers and match clearing follow the same source as the UI.
|
||||
if (status.health === 'online' && status.last_players !== null) {
|
||||
if (status.last_players > 0) {
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface NativeLaunchInput {
|
||||
export interface PreparedNativeLaunch {
|
||||
recipe: LaunchRecipePlan;
|
||||
recipeSummary: string;
|
||||
blobs?: Array<{ sha256: string; content: Uint8Array }>;
|
||||
recipeJson: Uint8Array;
|
||||
payloadJson: Uint8Array;
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
PLUTO_SERVER_KEY: input.serverKey },
|
||||
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 } : {}) } : {})
|
||||
};
|
||||
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 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.
|
||||
parentPort!.postMessage({ id, value: { recipe: plan, recipeSummary, recipeJson, payloadJson } },
|
||||
[recipeJson.buffer, payloadJson.buffer]);
|
||||
parentPort!.postMessage({ id, value: { recipe: plan, recipeSummary, recipeJson, payloadJson, blobs } },
|
||||
[recipeJson.buffer, payloadJson.buffer, ...blobs.map(blob => blob.content.buffer)]);
|
||||
} catch (error) {
|
||||
parentPort!.postMessage({ id, error: error instanceof Error ? error.message : 'Launch preparation failed.' });
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { putFileBlob, getFileBlob, blobSize } from '../db/file-blobs.js';
|
||||
import { concurrencyBlocker, preparingJobs } from './concurrency.js';
|
||||
import { isIP } from 'node:net';
|
||||
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;
|
||||
type NativeRecipe = LaunchRecipePlan & { native?: NativeSelection };
|
||||
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 }
|
||||
interface FilePlan { id: number; path: string; contentBase64: string | null; sha256: string | null; checkBefore: boolean; expectedBeforeSha: 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; sha256: string | null; checkBefore: boolean; expectedBeforeSha: string | null }
|
||||
|
||||
/** Validate a launch request without selecting a host or consuming capacity. */
|
||||
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[] {
|
||||
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
|
||||
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),
|
||||
contentBase64: row.content?.toString('base64') ?? null, sha256: row.content ? sha256Buffer(row.content) : null,
|
||||
checkBefore: !!row.check_before, expectedBeforeSha: row.expected_before_sha }));
|
||||
sha256: row.content_hash, 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. */
|
||||
function applyReceipt(job: Job, result: Record<string, unknown>) {
|
||||
if (job.applied_at || !job.files_json) return;
|
||||
const plans = JSON.parse(job.files_json) as FilePlan[];
|
||||
const before = result.before as { id?: number; path: string; contentBase64: string | null }[] | undefined;
|
||||
if (job.applied_at) return;
|
||||
const plans = storedFiles(job);
|
||||
if (!plans.length) return;
|
||||
const before = result.before as { id?: number; path: string; contentBase64: string | null; contentSha256?: string }[] | undefined;
|
||||
db().transaction(() => {
|
||||
for (const plan of plans) {
|
||||
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.');
|
||||
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;
|
||||
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') {
|
||||
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);
|
||||
}
|
||||
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=?')
|
||||
.run(row.content === null ? 'removed' : bytes === null ? 'added' : 'updated', bytes, bytes === null ? null : sha256Buffer(bytes), row.deployment_file_id);
|
||||
db().prepare('UPDATE deployment_files SET verb=?,content_before_hash=?,sha256_before=?,snapshot_pending=0 WHERE 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 (?, ?)')
|
||||
.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,
|
||||
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 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. */
|
||||
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
|
||||
WHERE d.id=? AND d.server_deleted_at IS NULL`).get(id) as Job | undefined;
|
||||
if (!job) return;
|
||||
@@ -280,7 +292,7 @@ export function reconcileNativeProvisioningReceipt(id: string): void {
|
||||
if (!job.operation_id) return;
|
||||
const operation = getHostOperationStatus(job.operation_id);
|
||||
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'")
|
||||
.run(job.state === 'cancelled' ? 'cancelled' : 'pending', file.id);
|
||||
}
|
||||
@@ -348,7 +360,7 @@ export async function processNativeProvisioning(eligibility: (operation: { id: s
|
||||
}
|
||||
}
|
||||
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
|
||||
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[];
|
||||
@@ -494,7 +506,7 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
|
||||
// post-mutation failures remain unknown and retain their capacity.
|
||||
db().transaction(() => {
|
||||
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);
|
||||
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') {
|
||||
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) {
|
||||
const stage = queueHostOperation(instance.host_id, 'files.stage', { stageId: job.id, files }, { instanceId: instance.id, generation: instance.generation, idempotencyKey: `${job.id}:stage` });
|
||||
db().prepare('UPDATE native_provisioning SET stage_operation_id=?,files_json=? WHERE deployment_id=?').run(stage.id, JSON.stringify(files), job.id);
|
||||
db().transaction(() => {
|
||||
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;
|
||||
}
|
||||
if (job.stage_operation_id) {
|
||||
@@ -671,7 +687,7 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
|
||||
const beta = activeBetaRemovalBlocker(job.server);
|
||||
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;
|
||||
}
|
||||
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) {
|
||||
deploymentStage(job.id, 'Preparing launch files for the host agent', { phase: 'prepare', recipeStatus: 'attempted',
|
||||
checkpoint: { key: 'startup-queue', label: 'Startup capacity available' } });
|
||||
for (const blob of prepared.blobs ?? []) putFileBlob(blob.content);
|
||||
const recipe = prepared.recipe;
|
||||
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);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { workerData } from 'node:worker_threads';
|
||||
import { tuneConnection } from '../db/connection.js';
|
||||
|
||||
const handle = new Database(workerData.dbPath, { readonly: true, fileMustExist: true });
|
||||
tuneConnection(handle, true);
|
||||
export function db() { return handle; }
|
||||
|
||||
@@ -3,12 +3,28 @@ import type Database from 'better-sqlite3';
|
||||
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);
|
||||
INSERT OR IGNORE INTO report_revisions(topic) VALUES ('metrics'), ('leaderboard');`);
|
||||
for (const [topic, tables] of Object.entries({ metrics: ['metric_samples', 'servers'],
|
||||
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' : '';
|
||||
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;`);
|
||||
}
|
||||
for (const table of ['metric_samples', 'servers']) 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' : '';
|
||||
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='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
@@ -1,3 +1,4 @@
|
||||
import { blobSize, pruneFileBlobs, referenceBlobs } from './db/file-blobs.js';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { db } from './db/index.js';
|
||||
import { fleetSettings } from './settings.js';
|
||||
@@ -42,7 +43,7 @@ function policies(): Policy[] {
|
||||
return [
|
||||
{ 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,
|
||||
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.')})
|
||||
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'))
|
||||
@@ -70,22 +71,37 @@ function policies(): Policy[] {
|
||||
{ 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)
|
||||
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',
|
||||
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_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',
|
||||
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_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}))`,
|
||||
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,
|
||||
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 ${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)' }
|
||||
];
|
||||
}
|
||||
@@ -104,11 +120,37 @@ function versionReferences(): string {
|
||||
return clauses.join(' AND ') || '1';
|
||||
}
|
||||
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()) {
|
||||
const days = settings[policy.days];
|
||||
const failureDays = settings.failureKeepDays;
|
||||
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 enabled = days > 0 || policy.category === 'version' && settings.versionKeepCount > 0;
|
||||
// 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})`);
|
||||
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
|
||||
WHERE c.category=@category AND julianday(c.resolved_at)<julianday(@failureCutoff)
|
||||
AND julianday(c.resolved_at)>=julianday(o.${policy.clock}) AND (${failed})`);
|
||||
WHERE c.category=@category AND c.resolved_at<@failureCutoff
|
||||
AND c.resolved_at>=o.${policy.clock} AND (${failed})`);
|
||||
const minimum = policy.minimum === false ? 0 : settings.historyKeepCount;
|
||||
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)" : '';
|
||||
@@ -181,18 +223,21 @@ export function pruneHistory(only?: string): number {
|
||||
db().prepare('DELETE FROM ec_host_operation_events WHERE operation_id=?').run(row.id);
|
||||
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 };
|
||||
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);
|
||||
}
|
||||
db().prepare('DELETE FROM ec_host_operation_payloads WHERE operation_id=?').run(row.id);
|
||||
}
|
||||
if (policy.category === 'beta-file-content') {
|
||||
db().prepare("UPDATE beta_revision_files SET content=X'' WHERE beta_id=?").run(row.id);
|
||||
db().prepare('DELETE FROM beta_file_snapshots WHERE beta_id=?').run(row.id);
|
||||
if (policy.category === 'workflow-payload' || policy.category === 'operation-payload') {
|
||||
expirePayload(policy.category, 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());
|
||||
} 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 staged_file_changes SET content=NULL WHERE source_kind='campaign' AND source_id=? AND state IN ('applied','cancelled')").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_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());
|
||||
} else if (policy.category !== 'operation') {
|
||||
db().prepare(`DELETE FROM ${policy.table} WHERE rowid=?`).run(row.rid);
|
||||
@@ -202,6 +247,8 @@ export function pruneHistory(only?: string): number {
|
||||
return rows.length;
|
||||
})();
|
||||
}
|
||||
const blobs = pruneFileBlobs();
|
||||
if ((removed || blobs) && Number(db().pragma('freelist_count', { simple: true })) >= 256) db().pragma('incremental_vacuum(256)');
|
||||
return removed;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -26,6 +26,11 @@ function updateDeployment(id: string, mutate: (d: PipelineDeployment) => void):
|
||||
export function startPipelineStream(): (() => void) | undefined {
|
||||
if (!browser || source) return;
|
||||
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']) {
|
||||
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 { authenticateAgent } from '$lib/server/hosts/http.js';
|
||||
import { reconcileHostPreparation } from '$lib/server/hosts/preparation.js';
|
||||
import { hostOperationsResponse } from '$lib/server/hosts/delivery.js';
|
||||
export async function GET({ request }: { request: Request }) {
|
||||
const host = authenticateAgent(request);
|
||||
reconcileHostPreparation(host.id);
|
||||
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';
|
||||
let operations = pendingHostOperationRows(host.id, lane, false);
|
||||
|
||||
@@ -1,50 +1,41 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import {
|
||||
listBetas,
|
||||
listBetaServers,
|
||||
listBetaScripts,
|
||||
openBeta,
|
||||
BetaError
|
||||
} 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 { parseBetaPlan, type BetaPlan } from '$lib/beta-plan.js';
|
||||
import { storedPlan } from '$lib/server/fleet/beta-tests.js';
|
||||
import { currentBetaRevision } from '$lib/server/fleet/beta-revisions.js';
|
||||
import { betaListPage } from '$lib/server/fleet/beta-pages.js';
|
||||
|
||||
export async function GET({ url }: { url: URL }) {
|
||||
const stateFilter = url.searchParams.get('state') as
|
||||
| 'running'
|
||||
| 'ready-promote'
|
||||
| 'promoted'
|
||||
| 'rolled-back'
|
||||
| null;
|
||||
const rows = listBetas(stateFilter ?? undefined).map((b) => {
|
||||
const scripts = listBetaScripts(b.id).map((bs) => {
|
||||
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 test = storedPlan(b.id);
|
||||
const state = url.searchParams.get('state');
|
||||
if (state && !['running','ready-promote','promoted','rolled-back'].includes(state)) return json({ error: 'Invalid beta state.' }, { status: 400 });
|
||||
const { betas, ...pagination } = betaListPage(url.searchParams);
|
||||
const ids = JSON.stringify(betas.map(beta => beta.id));
|
||||
const bundles = betaScriptMetadata(betas.map(beta => beta.id));
|
||||
const plans = new Map((db().prepare(`SELECT p.*, COALESCE((SELECT started_at FROM beta_revisions r
|
||||
WHERE r.beta_id=p.beta_id ORDER BY revision DESC LIMIT 1), b.opened_at) started_at
|
||||
FROM beta_test_plans p JOIN betas b ON b.id=p.beta_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 members = db().prepare('SELECT * FROM beta_servers WHERE beta_id IN (SELECT value FROM json_each(?)) ORDER BY server').all(ids) as BetaServerRow[];
|
||||
const rows = betas.map(b => {
|
||||
const scripts = bundles.get(b.id) ?? [];
|
||||
const test = plans.get(b.id);
|
||||
const plan = test ? parseBetaPlan(JSON.parse(test.plan)) : null;
|
||||
return {
|
||||
...b,
|
||||
testOutcome: test?.outcome ?? 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,
|
||||
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:
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { getBeta, listBetaServers, listBetaScripts } from '$lib/server/fleet/beta.js';
|
||||
import { getScript, getVersion } from '$lib/server/fleet/scripts.js';
|
||||
import { getBeta, listBetaServers } from '$lib/server/fleet/beta.js';
|
||||
import { betaScriptMetadata } from '$lib/server/fleet/library-metadata.js';
|
||||
import { testView } from '$lib/server/fleet/beta-tests.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 {
|
||||
const n = Number.parseInt(raw, 10);
|
||||
@@ -16,28 +17,16 @@ export async function GET({ params }: { params: { id: string } }) {
|
||||
const beta = getBeta(id);
|
||||
if (!beta) return json({ ok: false, error: 'not found' }, { status: 404 });
|
||||
|
||||
const scripts = listBetaScripts(id).map((bs) => {
|
||||
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 scripts = betaScriptMetadata([id]).get(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 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,
|
||||
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")
|
||||
.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) });
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
/** 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);
|
||||
if (values.some(value => !Number.isSafeInteger(value) || value < 1)) return new Response('Invalid file', { status: 400 });
|
||||
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
|
||||
?? (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 = ?)`)
|
||||
.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',
|
||||
'Content-Disposition': 'attachment', 'Cache-Control': 'no-store' } });
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { blobSize } from '$lib/server/db/file-blobs.js';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
|
||||
@@ -16,8 +17,8 @@ export async function GET({ params }: { params: { id: string } }) {
|
||||
const files = db()
|
||||
.prepare(
|
||||
`SELECT id, server, verb, rel_path, sha256_before, sha256_after, snapshot_pending,
|
||||
LENGTH(content_before) AS size_before,
|
||||
LENGTH(content_after) AS size_after,
|
||||
${blobSize('content_before_hash')} AS size_before,
|
||||
${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
|
||||
FROM deployment_files
|
||||
WHERE deployment_id = ?
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { blobContent } from '$lib/server/db/file-blobs.js';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
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()
|
||||
.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
|
||||
WHERE deployment_id = ? AND id = ?`
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getVersionContent } from '$lib/server/fleet/scripts.js';
|
||||
import { json } from '@sveltejs/kit';
|
||||
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 version = requested === null ? getLatestVersion(id) : getVersion(Number(requested));
|
||||
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' } });
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getVersionContent } from '$lib/server/fleet/scripts.js';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getServer, listServers } from '$lib/server/fleet/servers.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;
|
||||
return {
|
||||
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;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { blobSize } from '$lib/server/db/file-blobs.js';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db/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()
|
||||
.prepare(
|
||||
`SELECT id, server, verb, rel_path, sha256_before, sha256_after,
|
||||
LENGTH(content_before) AS size_before,
|
||||
LENGTH(content_after) AS size_after
|
||||
${blobSize('content_before_hash')} AS size_before,
|
||||
${blobSize('content_after_hash')} AS size_after
|
||||
FROM deployment_files
|
||||
WHERE deployment_id = ?
|
||||
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 { 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 } }) {
|
||||
const row = db()
|
||||
.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
|
||||
WHERE deployment_id = ? AND id = ?`
|
||||
)
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function GET() {
|
||||
event.type === 'server-state' ||
|
||||
event.type === 'deployment-opened' ||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -2,14 +2,11 @@ import { json } from '@sveltejs/kit';
|
||||
import {
|
||||
createScript,
|
||||
isArtifactKind,
|
||||
listScripts,
|
||||
listVersions,
|
||||
serversMatchingScope,
|
||||
ScriptError,
|
||||
type ArtifactKind,
|
||||
type ScopeType
|
||||
} 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 {
|
||||
if (v === null) return null;
|
||||
@@ -29,36 +26,7 @@ function kindOrDefault(v: FormDataEntryValue | string | null, fallback: Artifact
|
||||
export async function GET({ url }: { url: URL }) {
|
||||
const kindParam = url.searchParams.get('kind');
|
||||
const kindFilter = kindParam ? (isArtifactKind(kindParam) ? kindParam : null) : null;
|
||||
const rows = kindFilter ? listScripts(kindFilter) : listScripts();
|
||||
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
|
||||
};
|
||||
});
|
||||
const scripts = scriptCatalog(kindFilter ?? undefined);
|
||||
return json({ scripts });
|
||||
}
|
||||
|
||||
|
||||
@@ -4,14 +4,13 @@
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { onMount } from 'svelte';
|
||||
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 SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import Tabs from '$ui/components/primitives/Tabs.svelte';
|
||||
import StatusBadge from '$ui/components/primitives/StatusBadge.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 { serverTitle } from '$ui/server-labels.js';
|
||||
type ArtifactKind = 'script' | 'config' | 'plugin' | 'baseline';
|
||||
@@ -38,11 +37,13 @@
|
||||
let error = $state('');
|
||||
let tab = $state('active');
|
||||
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 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());
|
||||
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 blocker(beta:BetaListRow) {
|
||||
if(beta.state==='promoted')return 'Version promoted; check its campaign for remaining activation.';
|
||||
@@ -58,23 +59,38 @@
|
||||
const params = $page.url.searchParams;
|
||||
tab = params.get('view') === 'history' ? 'history' : 'active';
|
||||
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() {
|
||||
const url = new URL($page.url);
|
||||
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 (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 });
|
||||
}
|
||||
async function load() {
|
||||
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 = ''; }
|
||||
catch (e) { error = (e as Error).message; } finally { loading = false; }
|
||||
async function load(params = new URLSearchParams(window.location.search)) {
|
||||
const current = ++request;
|
||||
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(() => {
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
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>
|
||||
<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.">
|
||||
{#snippet actions()}<Button variant="primary" href="/betas/new">New beta</Button>{/snippet}
|
||||
</SectionHeader>
|
||||
<Tabs {tabs} bind:value={tab} onchange={() => { limit = 25; 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>
|
||||
{#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}
|
||||
<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; 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}
|
||||
<div id="beta-list" role="tabpanel" aria-labelledby={'beta-tabs-' + tab}>
|
||||
{#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}<div class="rows">{#each filtered.slice(0,limit) as beta (beta.id)}
|
||||
{: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 betas as beta (beta.id)}
|
||||
<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="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>
|
||||
{/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>
|
||||
<style>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import type { BetaPromotionPreview } from '$lib/beta-promotion.js';
|
||||
import { coverageSummary, requirementSummary } from '$lib/beta-plan-summary.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 type { BetaRevision } from '$lib/beta-revision.js';
|
||||
import BetaRevisionDialog from '$ui/components/BetaRevisionDialog.svelte';
|
||||
@@ -34,6 +34,8 @@
|
||||
let scripts = $state<BetaFile[]>([]);
|
||||
let servers = $state<BetaServer[]>([]);
|
||||
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 rollbackCampaign = $state<{ id: string }|null>(null);
|
||||
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.');
|
||||
const body = await res.json();
|
||||
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;
|
||||
revision = body.revision; revisions = body.revisions ?? [];
|
||||
now = Date.now(); loadError = '';
|
||||
} catch (error) { if (current === request && requestedId === id) loadError = (error as Error).message; }
|
||||
finally { if (current === request && requestedId === id) loading = false; }
|
||||
}
|
||||
$effect(() => { id; beta = null; loading = true; loadError = ''; flash = ''; success = ''; reviewOpen = false; updateOpen = false; void load(); });
|
||||
onMount(() => pollVisible(load, 5000, false));
|
||||
$effect(() => { id; activityRequest++; activityBefore = null; activityLoading = false; beta = null; loading = true; loadError = ''; flash = ''; success = ''; reviewOpen = false; updateOpen = false; void load(); });
|
||||
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() {
|
||||
reviewOpen = true; preview = null; reviewError = ''; acting = 'preview';
|
||||
try {
|
||||
@@ -181,6 +201,7 @@
|
||||
</div></details>
|
||||
{/if}
|
||||
<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>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
VERSION = "1.5.3"
|
||||
VERSION = "1.6.0"
|
||||
LABEL = "io.electric-cherry"
|
||||
MAX_FILE = 32 * 1024 * 1024
|
||||
MANIFEST_MAX_ENTRIES = 10000
|
||||
@@ -1356,7 +1356,7 @@ class Agent:
|
||||
if line.startswith("PRETTY_NAME="):
|
||||
os_name = line.split("=", 1)[1].strip('"')
|
||||
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})}
|
||||
|
||||
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)))
|
||||
|
||||
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:
|
||||
content = base64.b64decode(item["contentBase64"], validate=True)
|
||||
except (ValueError, KeyError, TypeError):
|
||||
@@ -1677,7 +1689,7 @@ class Agent:
|
||||
if item["path"] in paths:
|
||||
raise AgentError("Duplicate staged file 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)
|
||||
total_size += len(content) if content is not None else 0
|
||||
if total_size > 64 * 1024 * 1024:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"version": "1.5.3",
|
||||
"version": "1.6.0",
|
||||
"protocolVersion": 1,
|
||||
"platform": "debian-amd64",
|
||||
"sha256": "ebd6f67a44fdb4bd0683597d4abd54999311473561204de368864b04a5436256",
|
||||
"sha256": "bcc0a207125f2a1ad754c294decf2238064995ba93183b845a589a41a41925f6",
|
||||
"updaterProtocolVersion": 1,
|
||||
"updaterSha256": "066bb896726600ece9ce72697ce523c9a707cc9a621505931ba0892dec59d3c8"
|
||||
}
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { putFileBlob } from '../../src/lib/server/db/file-blobs.js';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { existsSync } from 'node:fs';
|
||||
@@ -35,13 +36,13 @@ describe('reviewed beta promotion', () => {
|
||||
it('upgrades existing campaign history without marking known snapshots pending', async () => {
|
||||
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 deployment_files (deployment_id, server, verb, rel_path, content_before) VALUES ('legacy', ?, 'updated', 'old.gsc', ?)")
|
||||
.run(servers[0], Buffer.from('original'));
|
||||
db().prepare("INSERT INTO deployment_files (deployment_id, server, verb, rel_path, content_before_hash) VALUES ('legacy', ?, 'updated', 'old.gsc', ?)")
|
||||
.run(servers[0], putFileBlob(Buffer.from('original'), db()));
|
||||
closeDb();
|
||||
const legacy = new Database(fixture.dbPath);
|
||||
try { legacy.exec('ALTER TABLE deployment_files DROP COLUMN snapshot_pending'); }
|
||||
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') });
|
||||
});
|
||||
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 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) });
|
||||
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])}` });
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('beta revisions', () => {
|
||||
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?.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') });
|
||||
expect(await readFile(x.path, 'utf8')).toBe('revision one');
|
||||
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 => {
|
||||
const x = await setup();
|
||||
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 === 'unbundled') upload.scriptId = 999;
|
||||
if (reason === 'closed') x.db.prepare("UPDATE betas SET state = 'promoted' WHERE id = ?").run(x.betaId);
|
||||
|
||||
@@ -193,7 +193,7 @@ describe('staged beta files', () => {
|
||||
const { destOfKind, configFileOf } = await import('../../src/lib/server/config.js');
|
||||
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(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 });
|
||||
});
|
||||
|
||||
@@ -219,7 +219,7 @@ describe('staged beta files', () => {
|
||||
await activateFiles('campaign', result.deploymentId);
|
||||
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 };
|
||||
expect(history.content_before.toString()).toBe('original configuration');
|
||||
const { rollbackPromoteDeployment } = await import('../../src/lib/server/fleet/rollback.js');
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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))
|
||||
.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 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 });
|
||||
expect(await readFile(destination())).toEqual(before);
|
||||
expect(kick).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -9,6 +9,23 @@ beforeEach(async () => {
|
||||
afterEach(async () => { await fixture.cleanup(); });
|
||||
|
||||
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 () => {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const insertSql = 'INSERT INTO settings (key, value) VALUES (?, ?)';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { putFileBlob } from '../../src/lib/server/db/file-blobs.js';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
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');
|
||||
await writeFile(path, 'existing settings');
|
||||
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);
|
||||
expect(await readFile(path, 'utf8')).toBe('existing settings');
|
||||
});
|
||||
@@ -316,7 +317,7 @@ describe('drift scanning', () => {
|
||||
expect(result).toMatchObject({ totalRemoved: 0, totalRestored: 1 });
|
||||
expect(await readFile(target)).toEqual(content);
|
||||
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 }]);
|
||||
expect(await scanServerDrift(server)).toEqual([]);
|
||||
expect((await applyDriftDeployment({ servers: [server] })).deploymentId).toBeNull();
|
||||
@@ -376,9 +377,9 @@ describe('drift rebuild campaigns', () => {
|
||||
expect(campaignId).toBeTruthy();
|
||||
expect(await readFile(target, 'utf8')).toBe('old content');
|
||||
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([
|
||||
{ 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 }
|
||||
]);
|
||||
await sweep();
|
||||
|
||||
@@ -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') }] });
|
||||
});
|
||||
@@ -113,7 +113,7 @@ describe('persisted Fleet operations', () => {
|
||||
const { fleetOperations, serverOperations } = await import('../../src/lib/server/fleet/overview.js');
|
||||
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 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 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' }));
|
||||
|
||||
@@ -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);
|
||||
saveFleetSettings({ ...fleetSettings(), deployKeepDays: 7 });
|
||||
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 server FROM deployment_servers WHERE deployment_id = 'completed'").all()).toEqual([]);
|
||||
expect(db().prepare('SELECT id FROM server_deployments').all()).toEqual([{ id: 'history' }]);
|
||||
|
||||
@@ -72,7 +72,8 @@ describe('GitHub DLL imports', () => {
|
||||
expect(imported.sha256).toBe(sha256);
|
||||
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(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 });
|
||||
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);
|
||||
|
||||
@@ -179,6 +179,9 @@ describe('routine host observation retention', () => {
|
||||
retained.push(malformed);
|
||||
api.handle.exec('DROP INDEX ec_host_operations_observation_retention');
|
||||
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.closeDb();
|
||||
|
||||
|
||||
@@ -43,6 +43,40 @@ async function operationHistory() {
|
||||
}
|
||||
|
||||
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 () => {
|
||||
const api = await seedHistory(90);
|
||||
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 unsafe = api.add('files.read', { payload: { path: '../private-secret' } });
|
||||
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_server_history;
|
||||
ALTER TABLE ec_host_operations DROP COLUMN routine;
|
||||
|
||||
@@ -183,6 +183,14 @@ describe('automatic host preparation', () => {
|
||||
});
|
||||
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();
|
||||
expect(firstPoll.operations).toHaveLength(1);
|
||||
const runtimeOperation = firstPoll.operations[0];
|
||||
|
||||
@@ -45,6 +45,24 @@ async function setup(count = 5, hostCount = 1) {
|
||||
}
|
||||
|
||||
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 () => {
|
||||
const api = await setup(5, 3);
|
||||
const hosts = await import('../../src/lib/server/hosts/index.js');
|
||||
|
||||
@@ -41,6 +41,8 @@ describe('persisted launch recipe summaries', () => {
|
||||
expect(recipeSummaryForDeployment('missing')).toBeNull();
|
||||
} finally { parsing.mockRestore(); }
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ vi.mock('../../src/lib/server/integrations/rcon.js', () => ({
|
||||
rconStatusResult: vi.fn(async () => ({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: '',
|
||||
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;
|
||||
const selection = { game: 't5' as const, mapSlug: 'theater' };
|
||||
|
||||
@@ -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 () => {
|
||||
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -40,7 +40,7 @@ async function setup(capable = true) {
|
||||
const baseline = promoted('baseline', 'Baseline', Buffer.from('set sv_voice "1"\n'));
|
||||
for (const [root, artifact] of [[scriptRoot, script], [pluginRoot, plugin]] as const) {
|
||||
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');
|
||||
}
|
||||
const { getPromotedServerConfig, getServerMapConfig, MAP_CONFIG_FILE } = await import('../../src/lib/server/fleet/server-config.js');
|
||||
|
||||
@@ -64,7 +64,10 @@ describe('native host enrollment and scoped authentication', () => {
|
||||
const api = await enrolled();
|
||||
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 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 parse = JSON.parse;
|
||||
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 };
|
||||
expect(stored.payload).toBe(JSON.stringify(routing));
|
||||
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))
|
||||
.toEqual({ bytes: bytes.length });
|
||||
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 };
|
||||
expect(saved.bytes).toBeLessThan(1024);
|
||||
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(db().prepare('SELECT 1 FROM ec_host_operation_payloads WHERE operation_id=?').get(inline.id)).toBeUndefined();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { putFileBlob } from '../../src/lib/server/db/file-blobs.js';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
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');
|
||||
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);
|
||||
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');
|
||||
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');
|
||||
@@ -708,19 +709,32 @@ describe('native instance provisioning', () => {
|
||||
const { getHostOperation, reportHostOperation } = await import('../../src/lib/server/hosts/index.js');
|
||||
const stage = getHostOperation(staged.stage_operation_id)!;
|
||||
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');
|
||||
reportHostOperation(hostId, stage.id, { state: 'completed', result: { state: 'staged' } });
|
||||
await advance(); expect(await operationFor(restart.id)).toBeNull();
|
||||
remote.players = 0; await advance();
|
||||
const activation = (await operationFor(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 }>;
|
||||
reportHostOperation(hostId, activation.id, { state: 'completed', result: { before: [{ id: files[0].id, path: files[0].path, contentBase64: before.toString('base64') }] } });
|
||||
await advance();
|
||||
expect((await detail(restart.id)).state).toBe('done');
|
||||
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');
|
||||
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(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 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');
|
||||
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');
|
||||
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(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')});
|
||||
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};
|
||||
@@ -1307,7 +1321,7 @@ describe('cancelled native mutation bookkeeping', () => {
|
||||
await reviseBeta(betaId, 1, [{ scriptId: script.id, content: Buffer.from('revision two') }]);
|
||||
await activate('revision one');
|
||||
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) });
|
||||
});
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ describe('persistent activity', () => {
|
||||
const handle = db();
|
||||
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 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 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();
|
||||
|
||||
@@ -26,7 +26,7 @@ describe('plugin release versions', () => {
|
||||
[3, 'v3.0.4'], [2, 'v3.0.4'], [1, 'v3.1.0']
|
||||
]);
|
||||
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 () => {
|
||||
@@ -53,8 +53,9 @@ describe('plugin release versions', () => {
|
||||
db().exec('ALTER TABLE script_versions DROP COLUMN release_version');
|
||||
closeDb();
|
||||
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();
|
||||
expect(getVersion(version.id)!.release_version).toBeNull();
|
||||
});
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { putFileBlob } from '../../src/lib/server/db/file-blobs.js';
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
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 () => {
|
||||
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 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 });
|
||||
expect(a.retentionPreview().find(p=>p.category==='file-content')?.payloadBytes).toBe(11);
|
||||
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');
|
||||
await expect(rollbackPromoteDeployment('p')).rejects.toThrow('expired');
|
||||
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 () => {
|
||||
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();
|
||||
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});
|
||||
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}]);
|
||||
@@ -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 () => {
|
||||
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 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) {
|
||||
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_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});
|
||||
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});
|
||||
expect(a.pruneHistory('beta')).toBe(1);
|
||||
expect(a.db.prepare('SELECT id FROM betas').all()).toEqual([{id:1}]);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { putFileBlob } from '../../src/lib/server/db/file-blobs.js';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { existsSync } from 'node:fs';
|
||||
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_scripts (deployment_id, script_id, script_version_id, prior_version_id, kind) VALUES ('promote', ?, ?, ?, ?)")
|
||||
.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)
|
||||
VALUES ('promote', ?, 'updated', ?, ?, ?, ?, ?)`).run(server, relPath, sha256Buffer(before), sha256Buffer(after), before, 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), putFileBlob(before, db()), putFileBlob(after, db()));
|
||||
const path = join(destOfKind(server, kind), relPath);
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
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 { queueFileChange } = await import('../../src/lib/server/fleet/staged-files.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)
|
||||
VALUES ('promote', ?, 'added', 'never.gsc', ?)`).run(server, Buffer.from('queued'));
|
||||
const pending = db().prepare(`INSERT INTO deployment_files (deployment_id, server, verb, rel_path, content_after_hash)
|
||||
VALUES ('promote', ?, 'added', 'never.gsc', ?)`).run(server, putFileBlob(Buffer.from('queued'), db()));
|
||||
queueFileChange({ sourceKind: 'campaign', sourceId: 'promote', server, kind: 'script', relPath: 'never.gsc',
|
||||
content: Buffer.from('queued'), inventorySource: 'promote:promote', deploymentFileId: Number(pending.lastInsertRowid) });
|
||||
const result = await rollbackPromoteDeployment('promote');
|
||||
|
||||
@@ -21,8 +21,9 @@ function database(legacy = false) {
|
||||
|
||||
function seedHistory(db: Database.Database) {
|
||||
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 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 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);
|
||||
@@ -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 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_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 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');
|
||||
@@ -76,7 +77,7 @@ describe('durable server history', () => {
|
||||
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 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) });
|
||||
db.exec(`INSERT INTO servers (name, game_port) VALUES ('server', 28960);
|
||||
INSERT INTO maintenance_jobs (server, state, requested_at) VALUES ('server', 'queued', 'new');
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { putFileBlob } from '../../src/lib/server/db/file-blobs.js';
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
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 });
|
||||
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_files (deployment_id, server, verb, rel_path, content_before, content_after) VALUES ('history-campaign', ?, 'updated', 'script.gsc', ?, ?)")
|
||||
.run(server, Buffer.from('before'), Buffer.from('after'));
|
||||
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, putFileBlob(Buffer.from('before'), db()), putFileBlob(Buffer.from('after'), db()));
|
||||
db().prepare('DELETE FROM servers WHERE name = ?').run(server);
|
||||
await seedServerRegistry();
|
||||
closeDb();
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { putFileBlob } from '../../src/lib/server/db/file-blobs.js';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { chmod, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||
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 () => {
|
||||
const { replacement } = await queueChange(source);
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
db().prepare('UPDATE script_versions SET content = ? WHERE id = ?')
|
||||
.run(Buffer.from('changed after review'), replacement.id);
|
||||
db().prepare('UPDATE script_versions SET content_hash = ? WHERE id = ?')
|
||||
.run(putFileBlob(Buffer.from('changed after review'), db()), replacement.id);
|
||||
await tick(source);
|
||||
expect(await readFile(destination())).toEqual(after);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user