321 lines
13 KiB
TypeScript
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) }
|
|
}
|
|
}
|