Initial
This commit is contained in:
73
server/services/promotionWorker.js
Normal file
73
server/services/promotionWorker.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { applyPromotion, listPromotionCandidates } from '../models/psadtModel.js';
|
||||
import { getGraphConnectionSystem } from '../models/graphModel.js';
|
||||
import { setRingIntent } from './intunePromotionService.js';
|
||||
import { buildAssignmentsPayload } from './intunePublishService.js';
|
||||
import { assignWin32App } from './graphService.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
// Scheduled auto-promotion: after a deployment's soak window elapses, advance
|
||||
// its configured rollout ring's intent. When the linked Graph connection is
|
||||
// write-enabled and not approval-gated, the new assignments are also pushed to
|
||||
// the tenant; otherwise the plan is promoted and an operator pushes it.
|
||||
|
||||
// Pure eligibility decision so the timing logic is unit-tested.
|
||||
export function evaluatePromotion(deployment, nowMs = Date.now()) {
|
||||
const hours = Number(deployment.autoPromoteAfterHours) || 0;
|
||||
if (hours <= 0) return { due: false, reason: 'auto-promote disabled' };
|
||||
if (!deployment.autoPromoteRing) return { due: false, reason: 'no target ring' };
|
||||
if (deployment.promotedAt) return { due: false, reason: 'already promoted' };
|
||||
if (!deployment.soakStartedAt) return { due: false, reason: 'soak not started' };
|
||||
const soakStart = Date.parse(deployment.soakStartedAt);
|
||||
if (Number.isNaN(soakStart)) return { due: false, reason: 'invalid soak timestamp' };
|
||||
const elapsedHours = (nowMs - soakStart) / 3_600_000;
|
||||
if (elapsedHours < hours) return { due: false, reason: `soaking (${elapsedHours.toFixed(1)}/${hours}h)` };
|
||||
return { due: true, reason: 'soak window elapsed' };
|
||||
}
|
||||
|
||||
export async function runAutoPromotions({ nowMs = Date.now(), pusher = assignWin32App } = {}) {
|
||||
const summary = { evaluated: 0, promoted: 0, pushed: 0, errors: 0 };
|
||||
for (const deployment of listPromotionCandidates()) {
|
||||
summary.evaluated += 1;
|
||||
const decision = evaluatePromotion(deployment, nowMs);
|
||||
if (!decision.due) continue;
|
||||
try {
|
||||
const { assignments, changed } = setRingIntent(deployment.assignments, deployment.autoPromoteRing, deployment.autoPromoteIntent);
|
||||
applyPromotion(deployment.id, changed ? assignments : deployment.assignments);
|
||||
summary.promoted += 1;
|
||||
|
||||
// Optionally push to the tenant when it is safe to do so automatically.
|
||||
if (deployment.graphAppId && deployment.graphConnectionId) {
|
||||
const connection = getGraphConnectionSystem(deployment.graphConnectionId);
|
||||
if (connection?.allow_write && !connection?.require_approval) {
|
||||
const built = buildAssignmentsPayload({ ...deployment, assignments });
|
||||
await pusher(connection, deployment.graphAppId, built.assignments);
|
||||
summary.pushed += 1;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
summary.errors += 1;
|
||||
logger.error('auto-promotion failed', { deploymentId: deployment.id, error: error.message });
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
let timer = null;
|
||||
|
||||
export function startPromotionWorker() {
|
||||
const minutes = Number(process.env.AUTO_PROMOTE_INTERVAL_MINUTES || 0);
|
||||
if (!Number.isFinite(minutes) || minutes <= 0) return null;
|
||||
timer = setInterval(() => {
|
||||
runAutoPromotions()
|
||||
.then((summary) => logger.info('auto-promotion run complete', summary))
|
||||
.catch((error) => logger.error('auto-promotion run failed', { error: error.message }));
|
||||
}, minutes * 60 * 1000);
|
||||
timer.unref?.();
|
||||
logger.info(`auto-promotion scheduled every ${minutes} minute(s)`);
|
||||
return timer;
|
||||
}
|
||||
|
||||
export function stopPromotionWorker() {
|
||||
if (timer) clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
Reference in New Issue
Block a user