Files
adam-acs/app/services/cwmp/configuration_evaluation_state.ts
T

321 lines
13 KiB
TypeScript

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<DeviceStateResult> {
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<DeviceStateResult> {
// --- 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<DeviceStateResult> {
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<DeviceStateResult> {
const { ExecutingCommandsState } = await import('./main_state_machine.js')
if (!this.started) {
this.started = true
const updateData: Record<string, any> = {
'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) }
}
}