Files
adam-acs/app/services/cwmp/configuration_evaluation_state.ts
T
2026-06-01 15:49:09 +02:00

275 lines
10 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 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.debug(
{
identifier: context.device.identifier,
configId: this.engineResult.configuration?.identifier,
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,
}
)
// Always populate inventory queue (discovery happens regardless of compliance)
this.inventoryQueue = [...this.engineResult.inventoryPaths]
if (!this.engineResult.isAlreadyCompliant) {
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 { isComplete: true }
}
}
private async processNextAction(context: DeviceContext): Promise<DeviceStateResult> {
// 1. Inventory phase 1: GetParameterNames per subtree path
// Skip if device does not support GPN (avoids useless round-trips that will fail)
if (this.inventoryQueue.length > 0 && context.device.gpnStatus !== 'unsupported') {
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) => {
// Collect leaf names into pendingValuesQueue for phase 2
const infoList = result.data?.ParameterList?.ParameterInfoStruct || []
const leafNames: string[] = infoList
.map((info: any) => info?.Name)
.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 — clear inventory queue and skip to next phase
logger.debug({ identifier: context.device.identifier, skipped: this.inventoryQueue.length }, '[ConfigEval] Skipping inventory (GPN unsupported)')
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)
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) }
}
}