- TR-069 CWMP state machine hardening (14 phases): shared event utility, multi-event routing, GPN circuit breaker, IdleState GPN guard, expanded GPN fault codes, CriticalReadsState, ConfigEval GPN guard, task queue race condition fix, explicit error handling, terminal states (OperationalState/PartialOperationalState), consolidated reclassify, crash pattern detection, removed enqueueImmediate, state flow wiring - Fix SOAP Fault not processed by state machine (soap-env:Fault detection) - Fix GPN circuit breaker not persisting after session drops - Fix CriticalReadsState GPV response parsing (xsi:type normalization) - Fix CriticalReadsState not processing GPV response values - Skip SPV when device is already compliant (ProvisioningCode check) - Add Device.DeviceInfo.ModelName to critical reads GPV - Fix TypeError on DeviceDetail parameters view (null guards) - Log empty 204 responses in exchange trace UI - TR-181 catalog: type corrections and new entries (+6640 lines) - SequenceViewer: replace session-nav buttons with compact dropdown select - SequenceViewer: fix CPE/ACS lifeline headers wrapping to 2 lines - Add 20 unit tests for state machine fixes (SOAP Fault, GPN circuit breaker, CriticalReads, stale session, ConfigEval/ComplianceUpdate skips) Generated with [Continue](https://continue.dev) Co-Authored-By: Continue <noreply@continue.dev>
299 lines
12 KiB
TypeScript
299 lines
12 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
|
|
|
|
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> {
|
|
// 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,
|
|
async (result) => {
|
|
if (result.status === 'error') {
|
|
// GPN failed during inventory — record failure
|
|
context.device.recordGpnFailure()
|
|
}
|
|
// Collect leaf names into pendingValuesQueue for phase 2
|
|
const infoList = result.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)
|
|
}
|
|
return ''
|
|
}
|
|
)
|
|
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('')
|
|
)
|
|
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) }
|
|
}
|
|
}
|