- 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>
226 lines
6.8 KiB
TypeScript
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
|
|
}
|
|
}
|