mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 03:59:41 +00:00
fix: reconcile sticky update indicators with Anatomy preview (#1698)
* fix: reconcile sticky update indicators with Anatomy preview Sidebar, Updates filter, and Fleet treated retained partial/failed scanner has_update as confirmed. Keep raw state for retention/notifications, project confirmed-only to APIs, show distinct incomplete indicators, and clear sticky rows only after an authoritative-negative preview. Closes #1685 * test: align sidebar truncate E2E with failed-over-retained precedence Purple update indicators are confirmed-only; hasUpdate with a failed check correctly shows the failed trailing icon. * fix: clear confirmed update rows on authoritative-negative preview Address audit SF-1/SF-2/SF-3: observation-watermark clears for older ok+has_update rows (DB + memory gens), Fleet checkability parity with backend not_checkable, and Updates chip confirmed-only regressions. * fix: tombstone equal-generation writers on preview clear Advance the per-stack write generation when clearing at the observation watermark so a scanner reserved before preview cannot recreate the row after an authoritative-negative reconcile. * fix: clear sticky updates with digest and tag preview parity Share detection across scanner and preview, keep GET read-only with POST reconcile, gate Apply to digest and rebuild updates, and invalidate the hub fleet cache on clear. * test: set digestUpdate on auto-update checkImage mocks Scheduler and execute routes now gate Compose on digest drift; fixtures that expect an apply need digestUpdate so they exercise the update path. * fix: clear unused lint errors on sticky update branch Drop unused partial helper and fleet invalidate import; keep the CacheService inflight self-ref as let with an eslint exception so tsc stays green. * fix: use inflight holder for CacheService prefer-const Keep generation-aware ownership without a let self-reference that fights ESLint and tsc.
This commit is contained in:
@@ -5,6 +5,12 @@ import DockerController from '../services/DockerController';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
import { CacheService } from '../services/CacheService';
|
||||
import { FLEET_UPDATE_CACHE_KEY } from '../helpers/fleetUpdateCache';
|
||||
import {
|
||||
createAutoUpdateDigestGateState,
|
||||
messageWhenNoDigestUpdate,
|
||||
recordAutoUpdateImageCheck,
|
||||
} from '../helpers/autoUpdateDigestGate';
|
||||
import { ImageUpdateService } from '../services/ImageUpdateService';
|
||||
import { FileSystemService } from '../services/FileSystemService';
|
||||
import { StackUpdateOrchestrator } from '../services/StackUpdateOrchestrator';
|
||||
@@ -22,7 +28,6 @@ import { logDebugTiming } from '../utils/requestTiming';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
|
||||
// Fleet aggregation cache: 2-minute TTL, shared across dashboard tabs.
|
||||
const FLEET_UPDATE_CACHE_KEY = 'fleet-updates';
|
||||
const FLEET_CACHE_TTL = 120_000;
|
||||
const REMOTE_NODE_FETCH_TIMEOUT_MS = 5000;
|
||||
|
||||
@@ -30,7 +35,9 @@ export const imageUpdatesRouter = Router();
|
||||
|
||||
imageUpdatesRouter.get('/', authMiddleware, (req: Request, res: Response): void => {
|
||||
try {
|
||||
const updates = DatabaseService.getInstance().getStackUpdateStatus(req.nodeId);
|
||||
// Confirmed-only: partial/failed retained has_update rows stay out of the
|
||||
// boolean map so Fleet and node cards do not treat uncertainty as pending.
|
||||
const updates = DatabaseService.getInstance().getConfirmedStackUpdateStatus(req.nodeId);
|
||||
res.json(updates);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch image update status:', error);
|
||||
@@ -184,10 +191,10 @@ imageUpdatesRouter.get('/fleet', authMiddleware, async (req: Request, res: Respo
|
||||
const nr = NodeRegistry.getInstance();
|
||||
const data: Record<number, Record<string, boolean>> = {};
|
||||
|
||||
// Local nodes: synchronous DB reads.
|
||||
// Local nodes: synchronous DB reads (confirmed-only projection).
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'local') {
|
||||
data[node.id] = db.getStackUpdateStatus(node.id);
|
||||
data[node.id] = db.getConfirmedStackUpdateStatus(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,36 +372,25 @@ autoUpdateRouter.post('/execute', authMiddleware, async (req: Request, res: Resp
|
||||
continue;
|
||||
}
|
||||
|
||||
let hasUpdate = false;
|
||||
const updatedImages: string[] = [];
|
||||
const checkErrors: string[] = [];
|
||||
const gate = createAutoUpdateDigestGateState();
|
||||
for (const imageRef of imageRefs) {
|
||||
try {
|
||||
const result = await imageUpdateService.checkImage(docker, imageRef);
|
||||
if (result.error) {
|
||||
checkErrors.push(result.error);
|
||||
} else if (result.hasUpdate) {
|
||||
hasUpdate = true;
|
||||
updatedImages.push(imageRef);
|
||||
}
|
||||
recordAutoUpdateImageCheck(gate, imageRef, result);
|
||||
} catch (e) {
|
||||
const errMsg = getErrorMessage(e, String(e));
|
||||
checkErrors.push(errMsg);
|
||||
gate.checkErrors.push(errMsg);
|
||||
console.warn('[AutoUpdate] Failed to check image %s:', sanitizeForLog(imageRef), sanitizeForLog((e as Error)?.message ?? String(e)));
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasUpdate) {
|
||||
if (checkErrors.length > 0 && checkErrors.length === imageRefs.length) {
|
||||
results.push(`Stack "${stackName}": WARNING - all image checks failed (${checkErrors.join('; ')}). Unable to determine update status.`);
|
||||
} else if (checkErrors.length > 0) {
|
||||
results.push(`Stack "${stackName}": all reachable images up to date (${checkErrors.length} check(s) failed).`);
|
||||
} else {
|
||||
results.push(`Stack "${stackName}": all images up to date.`);
|
||||
}
|
||||
if (!gate.hasDigestUpdate) {
|
||||
results.push(messageWhenNoDigestUpdate(stackName, gate, imageRefs.length));
|
||||
continue;
|
||||
}
|
||||
|
||||
const { updatedImages } = gate;
|
||||
|
||||
// Auto-update runs from the scheduler: a policy bypass is never
|
||||
// appropriate. If updated images fail the gate, skip the stack and
|
||||
// raise a notification so an operator can review before a manual retry.
|
||||
|
||||
@@ -17,7 +17,9 @@ import { StackUpdateOrchestrator, shortImageId, type OrchestratorResult } from '
|
||||
import DockerController, { type BulkStackInfo } from '../services/DockerController';
|
||||
import { DatabaseService, type StackDossierFields } from '../services/DatabaseService';
|
||||
import { CacheService, type CacheFetchOutcome } from '../services/CacheService';
|
||||
import { UpdatePreviewService } from '../services/UpdatePreviewService';
|
||||
import { UpdatePreviewService, isAuthoritativeNegativePreview } from '../services/UpdatePreviewService';
|
||||
import { ImageUpdateService } from '../services/ImageUpdateService';
|
||||
import { invalidateFleetUpdateCache } from '../helpers/fleetUpdateCache';
|
||||
import { GitSourceService, GitSourceError, repoHost as gitRepoHost } from '../services/GitSourceService';
|
||||
import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement';
|
||||
import { buildStackDriftReport, type DriftFindingKind, type StackDriftReport } from '../services/DriftDetectionService';
|
||||
@@ -2243,6 +2245,8 @@ stacksRouter.post('/:stackName/services/:serviceName/restore', async (req: Reque
|
||||
stacksRouter.get('/:stackName/update-preview', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
try {
|
||||
// Read-only: sticky reconciliation lives on POST so UpdateGuard and other
|
||||
// GET consumers never mutate persisted scanner state.
|
||||
const preview = await UpdatePreviewService.getInstance().getPreview(req.nodeId, stackName);
|
||||
res.json(preview);
|
||||
} catch (error) {
|
||||
@@ -2251,6 +2255,45 @@ stacksRouter.get('/:stackName/update-preview', async (req: Request, res: Respons
|
||||
}
|
||||
});
|
||||
|
||||
stacksRouter.post('/:stackName/update-preview', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
try {
|
||||
// Snapshot write-generation watermarks before the read-only preview so a
|
||||
// later clear can erase older confirmed/sticky rows without racing a
|
||||
// scanner that reserved or rewrote the row after this observation.
|
||||
const imageUpdates = ImageUpdateService.getInstance();
|
||||
const db = DatabaseService.getInstance();
|
||||
const observedMemoryGeneration = imageUpdates.peekStackWriteGeneration(req.nodeId, stackName);
|
||||
const observedRowGeneration = db.getStackUpdateWriteGeneration(req.nodeId, stackName);
|
||||
const preview = await UpdatePreviewService.getInstance().getPreview(req.nodeId, stackName);
|
||||
let reconciled = false;
|
||||
if (isAuthoritativeNegativePreview(preview)) {
|
||||
const clearResult = await imageUpdates.commitPreviewClear(
|
||||
req.nodeId,
|
||||
stackName,
|
||||
observedMemoryGeneration,
|
||||
observedRowGeneration,
|
||||
);
|
||||
if (clearResult === 'cleared') {
|
||||
reconciled = true;
|
||||
invalidateFleetUpdateCache();
|
||||
NotificationService.getInstance().broadcastEvent({
|
||||
type: 'state-invalidate',
|
||||
scope: 'image-updates',
|
||||
nodeId: req.nodeId,
|
||||
stackName,
|
||||
action: 'update-status-reconciled',
|
||||
ts: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
res.json({ ...preview, reconciled });
|
||||
} catch (error) {
|
||||
console.error('[Stacks] Update preview reconcile failed: %s', sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(error, 'unknown')));
|
||||
res.status(500).json({ error: 'Failed to compute update preview' });
|
||||
}
|
||||
});
|
||||
|
||||
stacksRouter.post('/:stackName/update', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
|
||||
@@ -2271,6 +2314,7 @@ stacksRouter.post('/:stackName/update', async (req: Request, res: Response) => {
|
||||
{ atomic, terminalWs: getTerminalWs(req.get(DEPLOY_SESSION_HEADER)) },
|
||||
);
|
||||
DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName);
|
||||
invalidateFleetUpdateCache();
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
NotificationService.getInstance().broadcastEvent({
|
||||
type: 'state-invalidate',
|
||||
|
||||
Reference in New Issue
Block a user