Files
sencho/backend/src/services/BlueprintReconciler.ts
T
Anso 2d56ea958a fix(stack-activity): per-stack history integrity, attribution, sanitization (#1228)
* fix(stack-activity): per-stack history integrity, attribution, sanitization

Address the Stack Activity audit findings (PR 1 of 2):

- Per-stack history integrity: drop the per-insert 100-row prune in
  addNotificationHistory that evicted quieter stacks' history whenever
  another stack got chatty. Periodic cleanupOldNotifications now caps
  per (node, stack) at 500 rows and per-node unattached system events
  at 1000 rows, on top of the existing 30-day retention. Signature
  takes an options bag and returns a per-stage summary so MonitorService
  can log what actually ran each cycle.

- Actor attribution: thread req.user?.username through every
  notifyActionFailure call site and add synthetic actors at service
  emit sites (system:autoheal, system:scheduler, system:image-update,
  system:docker-events, system:blueprint, system:monitor, system:policy).
  The timeline renders system actors as "via <Label>" so an autoheal
  redeploy is no longer indistinguishable from a user redeploy.

- Message sanitization: new sanitizeNotificationMessage at
  NotificationService.dispatchAlert strips KEY=VALUE pairs whose key
  ends in TOKEN/KEY/PASSWORD/SECRET/CREDENTIALS/AUTH, scrubs HTTP basic
  auth in URLs and Bearer tokens, collapses COMPOSE_DIR paths, and
  truncates to 1000 chars. Applied to the stored history and to every
  downstream Discord/Slack/webhook channel. The ImageUpdateService
  recovery-path direct DB write also runs through the sanitizer.

- Composite pagination cursor: getStackActivity now accepts a
  (timestamp, id) cursor (?before=&beforeId=). The legacy timestamp-only
  form silently dropped events when a single compose up emitted many
  events sharing one millisecond. Route rejects beforeId without before.

- Frontend hardening: distinct error state with retry button (initial
  fetch failure no longer renders as the genuine empty state), strict
  positive-integer parsing on cursor params, overrequest-by-1 pagination
  so the last page does not leave a dead "Load more" click, runtime
  guard on liveEvents merge that validates the level union, per-minute
  day-bucket recompute so an open panel does not stay on "Today" past
  midnight.

No tier, role, or capability gate touched. Route permission gate
remains stack:read on the named stack.

* fix(stack-activity): sanitizer covers lowercase env vars and per-node compose dir

External review surfaced two leak paths in the message sanitizer:

- The sensitive-key regex was uppercase-only. Compose env names are
  conventionally uppercase but lowercase forms (db_password, jwt_secret,
  github_token) are valid and do leak through the same Docker and
  compose-parse error paths. Make the regex case-insensitive and tighten
  it to also catch bare TOKEN= / KEY= / PASSWORD= without a prefix word,
  while still leaving BYPASS, COMPASS, and similar non-secret keys alone.

- The compose-dir path collapse only read process.env.COMPOSE_DIR, but
  the real resolution chain is node.compose_dir (per-node DB override)
  -> process.env.COMPOSE_DIR -> /app/compose. A node with a custom
  compose_dir could still leak absolute paths into stored history and
  downstream channels. Route both the dispatchAlert call and the
  ImageUpdateService recovery-path direct write through
  NodeRegistry.getInstance().getComposeDir(localNodeId) so the
  collapse covers every resolution outcome.

Tests now assert lowercase keys are redacted and that BYPASS-style
non-secrets stay intact in both cases. notification-routing mock
extended to stub the new getComposeDir call.

* chore(stack-activity): a11y roles, visibility-aware tick, live-disconnect signal

Close three small follow-ups on the per-stack activity timeline:

- A11y: each day-group gets role="list" and each event row gets
  role="listitem" so screen readers traverse the timeline as a list
  instead of a wall of text. The day-group container also carries an
  aria-label naming the bucket.

- Visibility-aware day-bucket tick: the 60s setInterval that re-derives
  Today/Yesterday/Earlier now short-circuits when document.hidden, so a
  backgrounded panel does not re-render every minute for no visible
  effect.

- Live-disconnect signal: useNotifications dispatches a
  sencho:notifications-connection custom event on WebSocket open and
  close. The timeline listens and, when explicitly disconnected, shows
  a one-line "Live updates offline; reconnecting…" hint above the list.
  The sidebar ticker already surfaces fleet-wide connection state; this
  adds an in-context cue for users who are focused on a single stack.

Stack-name case normalization was considered and rejected: stack names
are case-permissive per the isValidStackName validator, and lowercasing
on read or write would silently rename or hide a user's "MyApp" stack.

* ci(stack-activity): drop unnecessary escape in URL_BASIC_AUTH regex

ESLint no-useless-escape errored on \- inside the character class
[a-zA-Z0-9+.\-] at notificationMessage.ts:14. Move the dash to the
end of the class so it's an unambiguous literal and the escape is no
longer required. Behavior is identical; sanitizer tests still pass.

* revert(stack-activity): drop unvalidated E2E spec from this PR

The spec was committed without ever running against a real Docker
daemon, then failed in CI when it ran for the first time: deploy
returned 200 but no notification appeared on the activity endpoint
within the polling window, suggesting either a deploy-notification
race or a node-id resolution mismatch in the CI environment.

Backend unit tests (route + composite cursor + sanitizer) and
frontend component tests cover the same logic. The E2E spec will
land in a dedicated follow-up once it has been authored against a
working CI environment.
2026-05-25 21:09:00 -04:00

367 lines
16 KiB
TypeScript

import {
DatabaseService,
type Blueprint,
type BlueprintDeployment,
type Node,
} from './DatabaseService';
import { BlueprintService } from './BlueprintService';
import { BlueprintAnalyzer } from './BlueprintAnalyzer';
import { NodeLabelService } from './NodeLabelService';
import { NotificationService } from './NotificationService';
import { sanitizeForLog } from '../utils/safeLog';
const RECONCILER_INTERVAL_MS = 60_000;
const RECONCILER_INITIAL_DELAY_MS = 5_000;
function isDeveloperModeEnabled(): boolean {
try {
return DatabaseService.getInstance().getGlobalSettings().developer_mode === '1';
} catch {
return false;
}
}
function diagnosticLog(message: string, fields: Record<string, string | number | boolean | null | undefined>): void {
if (!isDeveloperModeEnabled()) return;
const safeFields = Object.fromEntries(
Object.entries(fields).map(([key, value]) => [key, typeof value === 'string' ? sanitizeForLog(value) : value]),
);
console.info(`[BlueprintReconciler:diag] ${message}`, safeFields);
}
export interface ReconcileDecision {
deploy: Node[];
withdraw: Node[];
check: Node[];
stateReview: Node[];
evictBlocked: Node[];
}
/**
* BlueprintReconciler is the desired-state loop. Every tick it reads each
* enabled blueprint, resolves its selector, and reconciles the per-node
* state against the desired set. It honors the state-aware guards
* (stateful blueprints get pending_state_review on first deploy and
* evict_blocked on un-target) and the three-mode drift policy.
*/
export class BlueprintReconciler {
private static instance: BlueprintReconciler | null = null;
private intervalHandle: ReturnType<typeof setInterval> | null = null;
private initialTimer: ReturnType<typeof setTimeout> | null = null;
private running = false;
private stopped = false;
static getInstance(): BlueprintReconciler {
if (!BlueprintReconciler.instance) {
BlueprintReconciler.instance = new BlueprintReconciler();
}
return BlueprintReconciler.instance;
}
private constructor() { /* singleton */ }
start(): void {
if (this.intervalHandle || this.initialTimer) return;
this.stopped = false;
this.initialTimer = setTimeout(() => {
this.initialTimer = null;
// Guard against a stop() that fired during the initial delay.
if (this.stopped) return;
void this.evaluate();
this.intervalHandle = setInterval(() => void this.evaluate(), RECONCILER_INTERVAL_MS);
}, RECONCILER_INITIAL_DELAY_MS);
}
stop(): void {
this.stopped = true;
if (this.initialTimer) { clearTimeout(this.initialTimer); this.initialTimer = null; }
if (this.intervalHandle) { clearInterval(this.intervalHandle); this.intervalHandle = null; }
}
/**
* Force one tick. Useful for the /apply endpoint and tests.
*/
async tick(): Promise<void> {
await this.evaluate();
}
/**
* Force reconciliation for a single blueprint. Invoked by the /apply
* endpoint so users get immediate action without waiting for the
* interval.
*/
async reconcileOne(blueprintId: number): Promise<void> {
const blueprint = DatabaseService.getInstance().getBlueprint(blueprintId);
if (!blueprint || !blueprint.enabled) return;
const nodes = DatabaseService.getInstance().getNodes();
diagnosticLog('manual reconcile requested', { blueprintId, nodeCount: nodes.length });
await this.reconcileBlueprint(blueprint, nodes);
}
private async evaluate(): Promise<void> {
if (this.running) return; // prevent overlap on slow ticks
this.running = true;
const started = Date.now();
try {
const db = DatabaseService.getInstance();
const blueprints = db.listEnabledBlueprints();
if (blueprints.length === 0) return;
const nodes = db.getNodes();
console.info('[BlueprintReconciler] tick start blueprints=%s nodes=%s', blueprints.length, nodes.length);
diagnosticLog('tick inputs', { blueprintCount: blueprints.length, nodeCount: nodes.length });
for (const blueprint of blueprints) {
try {
await this.reconcileBlueprint(blueprint, nodes);
} catch (err) {
console.error(`[BlueprintReconciler] failed for blueprint "${blueprint.name}":`, err);
}
}
console.info('[BlueprintReconciler] tick complete blueprints=%s durationMs=%s', blueprints.length, Date.now() - started);
} finally {
this.running = false;
}
}
private async reconcileBlueprint(blueprint: Blueprint, allNodes: Node[]): Promise<void> {
const decision = this.computeDecision(blueprint, allNodes);
diagnosticLog('decision computed', {
blueprintId: blueprint.id,
blueprintName: blueprint.name,
revision: blueprint.revision,
deploy: decision.deploy.length,
withdraw: decision.withdraw.length,
check: decision.check.length,
stateReview: decision.stateReview.length,
evictBlocked: decision.evictBlocked.length,
});
// 1. State-review guard for stateful blueprints reaching new nodes.
for (const node of decision.stateReview) {
const existing = DatabaseService.getInstance().getDeployment(blueprint.id, node.id);
DatabaseService.getInstance().upsertDeployment({
blueprint_id: blueprint.id,
node_id: node.id,
status: 'pending_state_review',
last_checked_at: Date.now(),
drift_summary: existing
? 'Stateful blueprint revision change awaits operator confirmation'
: 'Stateful blueprint awaiting operator confirmation before first deploy',
});
}
// 2. Eviction guard for stateful blueprints leaving the selector.
for (const node of decision.evictBlocked) {
DatabaseService.getInstance().upsertDeployment({
blueprint_id: blueprint.id,
node_id: node.id,
status: 'evict_blocked',
last_checked_at: Date.now(),
drift_summary: 'Stateful blueprint eviction requires operator confirmation',
});
}
// 3. Deploy missing/stale entries (stateless or pre-accepted stateful).
const svc = BlueprintService.getInstance();
for (const node of decision.deploy) {
await svc.deployToNode(blueprint, node);
}
// 4. Withdraw stateless deployments leaving the selector.
for (const node of decision.withdraw) {
await svc.withdrawFromNode(blueprint, node);
}
// 5. Drift check for active deployments.
for (const node of decision.check) {
const driftResult = await svc.checkForDrift(blueprint, node);
if (!driftResult.drifted) continue;
const reason = driftResult.reason ?? 'unknown drift';
DatabaseService.getInstance().upsertDeployment({
blueprint_id: blueprint.id,
node_id: node.id,
status: 'drifted',
last_checked_at: Date.now(),
last_drift_at: Date.now(),
drift_summary: reason,
});
await this.handleDrift(blueprint, node, reason);
}
}
private computeDecision(blueprint: Blueprint, allNodes: Node[]): ReconcileDecision {
// Pin override: a pinned blueprint deploys only on its pinned node,
// regardless of the selector. The pinned node also wins over a
// cordon flag (pin is an explicit operator decision; cordon governs
// automatic placement only).
let desiredNodes: Node[];
if (blueprint.pinned_node_id !== null) {
const pinned = allNodes.find(n => n.id === blueprint.pinned_node_id);
if (!pinned) {
console.warn(
`[BlueprintReconciler] blueprint "${blueprint.name}" pinned to node ${blueprint.pinned_node_id} which no longer exists; treating desired set as empty`,
);
desiredNodes = [];
} else {
desiredNodes = [pinned];
}
} else {
const labelSvc = NodeLabelService.getInstance();
desiredNodes = labelSvc.matchSelector(blueprint.selector, allNodes);
}
const desiredIds = new Set(desiredNodes.map(n => n.id));
const existingDeployments = DatabaseService.getInstance().listDeployments(blueprint.id);
const deploymentByNode = new Map<number, BlueprintDeployment>();
for (const dep of existingDeployments) deploymentByNode.set(dep.node_id, dep);
const decision: ReconcileDecision = {
deploy: [],
withdraw: [],
check: [],
stateReview: [],
evictBlocked: [],
};
// Desired but not active or stale
for (const node of desiredNodes) {
const dep = deploymentByNode.get(node.id);
if (!dep) {
// Cordon filter: skip new placements onto cordoned nodes.
// Pin always wins, so the pinned node is exempt. Existing
// deployments below are untouched: cordon does not evict.
if (node.cordoned && blueprint.pinned_node_id !== node.id) {
continue;
}
if (blueprint.classification === 'stateful' || blueprint.classification === 'unknown') {
decision.stateReview.push(node);
} else {
decision.deploy.push(node);
}
continue;
}
// Operator-blocking states must not be auto-acted on
if (dep.status === 'pending_state_review' || dep.status === 'evict_blocked' || dep.status === 'name_conflict') {
continue;
}
if (dep.status === 'active' && dep.applied_revision === blueprint.revision) {
decision.check.push(node);
continue;
}
if (dep.applied_revision !== blueprint.revision) {
if (blueprint.classification === 'stateful' || blueprint.classification === 'unknown') {
decision.stateReview.push(node);
} else {
decision.deploy.push(node);
}
continue;
}
if (dep.status === 'failed' || dep.status === 'pending') {
decision.deploy.push(node);
continue;
}
}
// Active on a node that is no longer desired
for (const dep of existingDeployments) {
if (desiredIds.has(dep.node_id)) continue;
if (dep.status === 'withdrawn') continue;
const node = allNodes.find(n => n.id === dep.node_id);
if (!node) continue;
if (blueprint.classification === 'stateful' || blueprint.classification === 'unknown') {
if (dep.status !== 'evict_blocked') decision.evictBlocked.push(node);
} else {
decision.withdraw.push(node);
}
}
return decision;
}
private async handleDrift(blueprint: Blueprint, node: Node, reason: string): Promise<void> {
const notifications = NotificationService.getInstance();
switch (blueprint.drift_mode) {
case 'observe':
return; // detection only; UI surfaces the drift
case 'suggest':
notifications.dispatchAlert(
'warning',
'blueprint_drift_detected',
`Blueprint "${blueprint.name}" drifted on node "${node.name}": ${reason}`,
{ stackName: blueprint.name, actor: 'system:blueprint' },
);
return;
case 'enforce': {
// Stateful safeguard: if the upcoming redeploy would destroy named volumes,
// downgrade to suggest semantics for this drift event.
if (blueprint.classification === 'stateful') {
const marker = await BlueprintService.getInstance().readMarker(blueprint.name, node);
if (!marker) {
notifications.dispatchAlert(
'warning',
'blueprint_drift_detected',
`Blueprint "${blueprint.name}" lost its marker on node "${node.name}"; auto-fix declined to avoid stomping unowned data. Reason: ${reason}`,
{ stackName: blueprint.name, actor: 'system:blueprint' },
);
return;
}
}
DatabaseService.getInstance().upsertDeployment({
blueprint_id: blueprint.id,
node_id: node.id,
status: 'correcting',
last_checked_at: Date.now(),
});
const result = await BlueprintService.getInstance().deployToNode(blueprint, node);
if (result.status !== 'active') {
notifications.dispatchAlert(
'error',
'blueprint_drift_correction_failed',
`Auto-fix for "${blueprint.name}" on node "${node.name}" failed: ${result.error ?? 'unknown error'}`,
{ stackName: blueprint.name, actor: 'system:blueprint' },
);
}
return;
}
}
}
/**
* Used by an operator-confirmed redeploy of a deployment in a guard
* state. Re-reads the deployment row and refuses unless it is in a
* transition-eligible state, so a TOCTOU window between the route
* handler's check and the actual deploy can't smuggle a name_conflict
* row through.
*/
async forceDeploy(blueprintId: number, nodeId: number): Promise<void> {
const blueprint = DatabaseService.getInstance().getBlueprint(blueprintId);
if (!blueprint) return;
const node = DatabaseService.getInstance().getNode(nodeId);
if (!node) return;
const dep = DatabaseService.getInstance().getDeployment(blueprintId, nodeId);
// Allow forceDeploy when:
// - dep is missing (operator-driven first deploy outside selector)
// - dep.status is pending_state_review (operator accepted)
// - dep.status is failed (manual retry)
// Refuse when dep is name_conflict (must be cleared explicitly) or evict_blocked
// (operator must use the withdraw flow first).
if (dep && (dep.status === 'name_conflict' || dep.status === 'evict_blocked')) {
console.warn(`[BlueprintReconciler] forceDeploy refused for blueprint ${blueprintId} on node ${nodeId}: status=${dep.status}`);
return;
}
await BlueprintService.getInstance().deployToNode(blueprint, node);
}
/**
* Used to react to a compose change that introduces volume-destroying
* differences. Returns true when the change would destroy data on the
* given deployment. Reconciler uses this to refuse Enforce on a
* stateful drift that would wipe volumes.
*/
static wouldDestroyVolumes(blueprint: Blueprint, priorCompose: string): boolean {
if (blueprint.classification !== 'stateful') return false;
return BlueprintAnalyzer.wouldDestroyVolumes(priorCompose, blueprint.compose_content);
}
}