import ConfigurationEngine from '../configuration_engine.js' import type { EngineResult } from '../configuration_engine.js' import type { DeviceStateResult, DeviceContext } from './main_state_machine.js' import { PartialOperationalState } from './main_state_machine.js' import DeviceModel from '#models/device' import logger from '@adonisjs/core/services/logger' import { ComplianceStatus } from '../../domain/configuration/compliance.js' import { buildProvisioningCode } from '../../utils/provisioning_code.js' /** * ConfigurationEvaluationState * * Stateful state that drives the full configuration evaluation flow: * 1. On first handle(): calls ConfigurationEngine.evaluate(), sets compliance to "in_progress" * 2. Processes inventory reads one path at a time (GetParameterNames RPC) * 3. Processes all parameter diffs in one SetParameterValues RPC * 4. If firmware needed, transitions to FirmwareUpgradeState * 5. Otherwise, transitions to ComplianceUpdateState */ export class ConfigurationEvaluationState { name = 'configuration-evaluation' private engine: ConfigurationEngine private engineResult: EngineResult | null = null private inventoryQueue: string[] = [] private pendingValuesQueue: string[] = [] private paramDiffs: Array<{ name: string; value: string | number | boolean; type?: string }> = [] private initialized = false private awaitingPhase: 'gpn_response' | 'gpv_response' | null = null constructor() { this.engine = new ConfigurationEngine() } async handle(context: DeviceContext): Promise { try { if (!this.initialized) { this.initialized = true this.engineResult = await this.engine.evaluate(context.device) logger.info( { identifier: context.device.identifier, configId: this.engineResult.configuration?.identifier || null, inventoryPaths: this.engineResult.inventoryPaths.length, paramDiffs: this.engineResult.parameterDiffs.length, firmwareSteps: this.engineResult.firmwareMigrationPath.length, isAlreadyCompliant: this.engineResult.isAlreadyCompliant, }, '[ConfigEval] Evaluation complete' ) await DeviceModel.updateOne( { identifier: context.device.identifier }, { 'compliance.status': ComplianceStatus.IN_PROGRESS, 'compliance.lastEvaluatedAt': new Date(), 'compliance.configurationIdentifier': this.engineResult.configuration?.identifier || null, } ) // Skip inventory when already compliant — no need to re-read parameters if (!this.engineResult.isAlreadyCompliant) { this.inventoryQueue = [...this.engineResult.inventoryPaths] this.paramDiffs = this.engineResult.parameterDiffs.map((d) => ({ name: d.name, value: d.targetValue, type: d.type, })) } } return this.processNextAction(context) } catch (error: any) { logger.error({ identifier: context.device.identifier, error: error.message }, '[ConfigEval] Evaluation failed') await DeviceModel.updateOne( { identifier: context.device.identifier }, { 'compliance.status': ComplianceStatus.FAILED, 'compliance.lastError': error.message, 'compliance.lastEvaluatedAt': new Date(), } ).catch(() => {}) return { nextState: new PartialOperationalState(context.device, { reason: 'configuration_evaluation_failed', blockingState: 'configuration-evaluation', error: error.message, }) } } } private async processNextAction(context: DeviceContext): Promise { // --- Process pending response from previous RPC (inline, not via task callback) --- if (this.awaitingPhase === 'gpn_response') { this.awaitingPhase = null if (context.message?.success && context.message.data) { const infoList = context.message.data?.ParameterList?.ParameterInfoStruct || [] const leafNames: string[] = infoList .map((info: any) => { const n = info?.Name return typeof n === 'string' ? n : (n && typeof n._ === 'string' ? n._ : null) }) .filter((name: any): name is string => !!name && !name.endsWith('.')) if (leafNames.length > 0) { this.pendingValuesQueue.push(...leafNames) } logger.debug({ identifier: context.device.identifier, leafCount: leafNames.length }, '[ConfigEval] Inventory GPN response processed inline') } else if (context.message && !context.message.success) { // GPN failed during inventory — record failure context.device.recordGpnFailure() logger.debug({ identifier: context.device.identifier }, '[ConfigEval] Inventory GPN failed — skipping remaining inventory') this.inventoryQueue = [] } } else if (this.awaitingPhase === 'gpv_response') { this.awaitingPhase = null if (context.message?.success && context.message.data) { const list = context.message.data?.ParameterList?.ParameterValueStruct if (Array.isArray(list) && list.length > 0) { const normalized = list.map((pv: any) => ({ ...pv, Name: typeof pv?.Name === 'string' ? pv.Name : pv?.Name?._ || pv?.Name, })) context.device.updateFromValues(normalized) logger.debug({ identifier: context.device.identifier, count: normalized.length }, '[ConfigEval] Inventory GPV values saved to device') } } } // 1. Inventory phase 1: GetParameterNames per subtree path // Skip if device does not support GPN or circuit breaker is active if (this.inventoryQueue.length > 0 && context.device.gpnStatus !== 'unsupported' && context.device.isGpnAllowed()) { const path = this.inventoryQueue.shift()! logger.debug({ identifier: context.device.identifier, path }, '[ConfigEval] Inventory GetParameterNames') const response = await context.services.deviceAction.getParameterNames( context.device, path, () => Promise.resolve('') ) this.awaitingPhase = 'gpn_response' return { response } } else if (this.inventoryQueue.length > 0) { // GPN unsupported or circuit breaker active — skip inventory logger.debug({ identifier: context.device.identifier, skipped: this.inventoryQueue.length }, '[ConfigEval] Skipping inventory (GPN unsupported or circuit breaker active)') this.inventoryQueue = [] } // 2. Inventory phase 2: GetParameterValues for discovered leaf names if (this.pendingValuesQueue.length > 0) { const leaves = this.pendingValuesQueue.splice(0) logger.debug({ identifier: context.device.identifier, count: leaves.length }, '[ConfigEval] Inventory GetParameterValues') const response = await context.services.deviceAction.getParameterValues( context.device, leaves, () => Promise.resolve('') ) this.awaitingPhase = 'gpv_response' return { response } } // 3. All parameter diffs in one SetParameterValues RPC if (this.paramDiffs.length > 0) { const diffs = this.paramDiffs.splice(0) logger.debug({ identifier: context.device.identifier, count: diffs.length }, '[ConfigEval] Applying parameter diffs') const params = diffs.map((d) => ({ parameterName: d.name, value: d.value, type: d.type })) const response = await context.services.deviceAction.applySetParameterValues( context.device, params, () => Promise.resolve('') ) return { response } } // 4. Firmware upgrade (if needed and within schedule) if (this.engineResult!.firmwareMigrationPath.length > 0 && !this.engineResult!.skipFirmware) { logger.debug( { identifier: context.device.identifier, steps: this.engineResult!.firmwareMigrationPath.length }, '[ConfigEval] Transitioning to firmware upgrade' ) return { nextState: new FirmwareUpgradeState(this.engineResult!.firmwareMigrationPath, 0, this.engineResult!) } } // 5. All done return { nextState: new ComplianceUpdateState(this.engineResult!) } } } /** * FirmwareUpgradeState * * Sends a Download RPC for the current migration step. * After the RPC, the session ends (device reboots and re-Informs for next step). */ export class FirmwareUpgradeState { name = 'firmware-upgrade' private migrationSteps: any[] private currentStepIndex: number private engineResult: EngineResult constructor(migrationSteps: any[], currentStepIndex: number = 0, engineResult: EngineResult) { this.migrationSteps = migrationSteps this.currentStepIndex = currentStepIndex this.engineResult = engineResult } async handle(context: DeviceContext): Promise { const step = this.migrationSteps[this.currentStepIndex] if (!step) { return { nextState: new ComplianceUpdateState(this.engineResult) } } logger.debug( { identifier: context.device.identifier, step: this.currentStepIndex + 1, total: this.migrationSteps.length }, '[FirmwareUpgrade] Sending Download RPC' ) try { const response = await context.services.deviceAction.applyDownload( context.device, { commandKey: `fw-upgrade-step${this.currentStepIndex}-${Date.now()}`, fileType: '1 Firmware Upgrade Image', url: step.downloadUrl, fileSize: step.fileSize || 0, targetFileName: '', }, async () => '' ) return { response } } catch (error: any) { logger.error({ identifier: context.device.identifier, error: error.message }, '[FirmwareUpgrade] Download RPC failed') await DeviceModel.updateOne( { identifier: context.device.identifier }, { 'compliance.status': ComplianceStatus.FAILED, 'compliance.lastError': `Firmware upgrade failed: ${error.message}`, 'compliance.lastEvaluatedAt': new Date(), } ).catch(() => {}) return { isComplete: true } } } } /** * ComplianceUpdateState * * Finalizes device compliance: * - Updates compliance status to "conforme" in DB * - For policy "once": writes ProvisioningCode to device * - Transitions to ExecutingCommandsState to process pending user commands */ export class ComplianceUpdateState { name = 'compliance-update' private engineResult: EngineResult private started = false constructor(engineResult: EngineResult) { this.engineResult = engineResult } async handle(context: DeviceContext): Promise { const { ExecutingCommandsState } = await import('./main_state_machine.js') if (!this.started) { this.started = true const updateData: Record = { 'compliance.status': ComplianceStatus.COMPLIANT, 'compliance.lastEvaluatedAt': new Date(), 'compliance.lastError': null, } if (this.engineResult.configuration) { updateData['compliance.configurationIdentifier'] = this.engineResult.configuration.identifier } // Always build and write provisioning code to device (functional for "once", informative for "maintain") if (this.engineResult.configuration) { const provCode = buildProvisioningCode(this.engineResult.configuration) // Check if device already has the correct provisioning code — skip SPV if so const allParams = [...context.device.getParameters(), ...context.device.getProperties()] const currentPC = allParams.find((p: any) => /(^|\.)ProvisioningCode$/.test(p?.name)) const currentPCValue = currentPC?.value ?? '' if (currentPCValue === provCode) { logger.debug({ identifier: context.device.identifier, provCode }, '[ComplianceUpdate] Provisioning code already matches — skipping SPV') updateData.provisioningCode = provCode await DeviceModel.updateOne({ identifier: context.device.identifier }, updateData).catch(() => {}) return { nextState: new ExecutingCommandsState(true) } } updateData.provisioningCode = provCode await DeviceModel.updateOne({ identifier: context.device.identifier }, updateData).catch(() => {}) context.device.tmpPC = provCode logger.debug({ identifier: context.device.identifier, provCode, policy: this.engineResult.configuration.policy }, '[ComplianceUpdate] Writing provisioning code') const response = await context.services.deviceAction.insertProvisioningCode( context.device, () => Promise.resolve('') ) return { response } } await DeviceModel.updateOne({ identifier: context.device.identifier }, updateData).catch(() => {}) logger.debug({ identifier: context.device.identifier }, '[ComplianceUpdate] Compliance finalized (no configuration)') return { nextState: new ExecutingCommandsState(true) } } // Second call (after provCode write response): execute user commands return { nextState: new ExecutingCommandsState(true) } } }