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; }