mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 10:21:03 +00:00
2d56ea958a
* 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.
367 lines
16 KiB
TypeScript
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);
|
|
}
|
|
}
|