Files
adam-acs/app/services/firmware_catalog_service.ts
T
Kevin L'HereecandCopilot d1c65dbf1e chore: initial commit — AdonisJS v7 TR-069 ACS
- Docker infrastructure (Dockerfile, docker-compose.yml, genieacs-sim)
- AdonisJS v7 app with Inertia.js + Vue 3
- MongoDB integration
- CWMP/TR-069 state machine engine
- Configuration engine with compliance tracking
- Branding: ADAM ACS (favicon, navbar, locales)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-29 16:46:37 +02:00

226 lines
6.8 KiB
TypeScript

import FirmwareEntryModel from '#models/firmware_entry'
import MigrationRouteModel from '#models/migration_route'
import { FirmwareEntry, MigrationRoute } from '../domain/firmware/firmware_catalog.js'
import { MigrationStep } from '../domain/firmware/migration_step.js'
import { matchGlob } from '../utils/glob_match.js'
import { BadRequestException } from '../exceptions/index.js'
export interface CreateFirmwareEntryDto {
identifier: string
productClass: string
version: string
downloadUrl: string
checksum: string
checksumType: 'md5' | 'sha256'
fileSize: number
}
export interface CreateMigrationRouteDto {
identifier: string
productClass: string
sourceVersionPattern: string
targetVersion: string
downloadUrl: string
priority: number
}
export default class FirmwareCatalogService {
/**
* Validate that a string is a valid URL.
*/
private validateUrl(url: string, fieldName: string): void {
try {
new URL(url)
} catch {
throw new BadRequestException(`${fieldName} must be a valid URL`)
}
}
/**
* Validate that a number is a positive integer (> 0).
*/
private validatePositiveInteger(value: number, fieldName: string): void {
if (!Number.isInteger(value) || value <= 0) {
throw new BadRequestException(`${fieldName} must be a positive integer`)
}
}
/**
* Validate that a string is non-empty.
*/
private validateNonEmptyString(value: string, fieldName: string): void {
if (!value || value.trim().length === 0) {
throw new BadRequestException(`${fieldName} must be a non-empty string`)
}
}
/**
* Validate checksumType is 'md5' or 'sha256'.
*/
private validateChecksumType(value: string, fieldName: string): void {
if (value !== 'md5' && value !== 'sha256') {
throw new BadRequestException(`${fieldName} must be 'md5' or 'sha256'`)
}
}
/**
* Create a new firmware entry after validation.
*/
async createEntry(data: CreateFirmwareEntryDto): Promise<FirmwareEntry> {
this.validateNonEmptyString(data.identifier, 'identifier')
this.validateUrl(data.downloadUrl, 'downloadUrl')
this.validatePositiveInteger(data.fileSize, 'fileSize')
this.validateChecksumType(data.checksumType, 'checksumType')
return FirmwareEntryModel.createIfNotExists({
identifier: data.identifier,
productClass: data.productClass,
version: data.version,
downloadUrl: data.downloadUrl,
checksum: data.checksum,
checksumType: data.checksumType,
fileSize: data.fileSize,
})
}
/**
* Find firmware entries, optionally filtered by productClass.
*/
async findEntries(productClass?: string): Promise<FirmwareEntry[]> {
if (productClass) {
return FirmwareEntryModel.findByProductClass(productClass)
}
return FirmwareEntryModel.find({}) as unknown as Promise<FirmwareEntry[]>
}
/**
* Delete a firmware entry by identifier.
*/
async deleteEntry(identifier: string): Promise<void> {
const deleted = await FirmwareEntryModel.deleteByIdentifier(identifier)
if (!deleted) {
throw new BadRequestException(`Firmware entry '${identifier}' not found`)
}
}
/**
* Create a new migration route after validation.
*/
async createMigrationRoute(data: CreateMigrationRouteDto): Promise<MigrationRoute> {
this.validateNonEmptyString(data.identifier, 'identifier')
this.validateUrl(data.downloadUrl, 'downloadUrl')
this.validatePositiveInteger(data.priority, 'priority')
return MigrationRouteModel.createIfNotExists({
identifier: data.identifier,
productClass: data.productClass,
sourceVersionPattern: data.sourceVersionPattern,
targetVersion: data.targetVersion,
downloadUrl: data.downloadUrl,
priority: data.priority,
})
}
async findAllRoutes(): Promise<MigrationRoute[]> {
return MigrationRouteModel.find({}) as unknown as Promise<MigrationRoute[]>
}
async updateEntry(identifier: string, data: Partial<CreateFirmwareEntryDto>): Promise<FirmwareEntry> {
const collection = FirmwareEntryModel.getCollection()
const result = await collection.findOneAndUpdate(
{ identifier },
{ $set: data },
{ returnDocument: 'after' }
)
if (!result) throw new BadRequestException(`Firmware entry '${identifier}' not found`)
return result as unknown as FirmwareEntry
}
/**
* Find migration routes for a productClass, sorted by priority descending.
*/
async findMigrationRoutes(productClass: string): Promise<MigrationRoute[]> {
return MigrationRouteModel.findByProductClass(productClass, { priority: -1 })
}
/**
* Delete a migration route by identifier.
*/
async deleteMigrationRoute(identifier: string): Promise<void> {
const deleted = await MigrationRouteModel.deleteByIdentifier(identifier)
if (!deleted) {
throw new BadRequestException(`Migration route '${identifier}' not found`)
}
}
/**
* Resolve the firmware upgrade path from currentVersion to targetVersion.
* Uses a greedy best-first algorithm based on route priority with cycle detection.
*
* @returns MigrationStep[] ordered from first to last, or [] if no path exists
*/
async resolveUpgradePath(
productClass: string,
currentVersion: string,
targetVersion: string
): Promise<MigrationStep[]> {
// Trivial case: already at target version
if (currentVersion === targetVersion) return []
// Load all routes for this productClass
const routes = await MigrationRouteModel.findByProductClass(productClass)
const path: MigrationStep[] = []
const visited = new Set<string>()
let version = currentVersion
visited.add(version)
while (version !== targetVersion) {
// Find all applicable routes from the current version
const applicableRoutes = routes
.filter((r) => matchGlob(r.sourceVersionPattern, version))
.sort((a, b) => b.priority - a.priority) // Highest priority first
// Take the best route (highest priority)
const bestRoute = applicableRoutes[0]
if (!bestRoute) {
// No route found → no path exists
return []
}
// Cycle detection
if (visited.has(bestRoute.targetVersion)) {
return []
}
// Add step to path
path.push({
fromVersion: version,
toVersion: bestRoute.targetVersion,
downloadUrl: bestRoute.downloadUrl,
fileSize: 0,
checksum: '',
})
// Advance
visited.add(bestRoute.targetVersion)
version = bestRoute.targetVersion
}
// Enrich each step with fileSize and checksum from firmware_entries
for (const step of path) {
const entry = await FirmwareEntryModel.findByProductClassAndVersion(
productClass,
step.toVersion
)
if (entry) {
step.fileSize = entry.fileSize
step.checksum = entry.checksum
}
}
return path
}
}