Files
adam-acs/app/controllers/configurations_v1_controller.ts
T
Kevin L'HereecandCopilot aff8e14a63 feat: compliance report + auto-versioning for configurations
- Add ComplianceStatus.PENDING and configVersion to DeviceComplianceInfo
- Add ComplianceReport + ComplianceDeviceResult types to ConfigurationEngine
- Expose evaluateSpecific() as public (used by compliance + dry-run)
- Add complianceReport() method: filter matching devices, evaluate each
- Fix matchesCriteria: skip parameterCriteria entries with null/empty name
- Fix getDeviceParameterValue: fallback to plain object arrays (DeviceRecord)
- Fix findDevice: support lookup by both identifier and deviceId.id
- ConfigurationService.update: version is now auto-managed (no user input)
  - behavioral changes (criteria/params/policy/priority/…) → patch bump
  - bumpType field allows 'patch' | 'minor' | 'major'
  - cosmetic changes (name/tags) → no bump
- Add sanitizeCriteria: filter null/empty parameterCriteria names silently
- Add isBehavioralChange helper
- Add DeviceModel.resetComplianceForConfiguration call on behavioral update
- Add ConfigurationModel.distinctTags() + controller endpoint GET /tags
- Add controller endpoint GET /configurations/:id/compliance
- Add DeviceModel.getDistinctValues for inventory fields
- Fix unit tests: mock DeviceModel, remove obsolete version-validation test
- Add 14 new unit tests (engine: 5, service: 9)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-01 11:32:17 +02:00

312 lines
11 KiB
TypeScript

import type { HttpContext } from '@adonisjs/core/http'
import ConfigurationService, {
type ConfigurationFilters,
type CreateConfigurationDto,
type UpdateConfigurationDto,
} from '#services/configuration_service'
import ConfigurationEngine from '#services/configuration_engine'
import ConfigurationModel from '#models/configuration'
import logger from '@adonisjs/core/services/logger'
/**
* Configurations API Controller
*
* Handles REST endpoints for Configuration management:
* - GET /api/v1/configurations (list with pagination and filters)
* - GET /api/v1/configurations/:id (get single with compliance stats)
* - POST /api/v1/configurations (create with validation and overlap detection)
* - PUT /api/v1/configurations/:id (update with validation and overlap detection)
* - DELETE /api/v1/configurations/:id (delete)
* - PATCH /api/v1/configurations/:id/toggle (toggle enabled status)
* - PATCH /api/v1/configurations/:id/tags (add/remove tags)
* - GET /api/v1/configurations/:id/history (version history)
* - POST /api/v1/configurations/:id/dry-run (dry-run evaluation)
*/
export default class ConfigurationsController {
private configService: ConfigurationService
private engine: ConfigurationEngine
constructor() {
this.configService = new ConfigurationService()
this.engine = new ConfigurationEngine()
}
/**
* GET /api/v1/configurations
* List configurations with pagination and filters
*/
async index({ request, response }: HttpContext) {
try {
const filters: ConfigurationFilters = {
page: Number(request.input('page', 1)),
perPage: Number(request.input('perPage', 20)),
tag: request.input('tag'),
name: request.input('name') || undefined,
enabled: request.input('enabled') !== undefined ? request.input('enabled') === 'true' : undefined,
productClass: request.input('productClass'),
}
const result = await this.configService.findAll(filters)
return response.ok(result)
} catch (error: any) {
logger.error({ error: error.message }, '[ConfigAPI] List configurations failed')
return response.status(500).json({ message: 'Failed to list configurations' })
}
}
/**
* GET /api/v1/configurations/tags
* Return all distinct tags across configurations
*/
async listTags({ response }: HttpContext) {
try {
const tags = await ConfigurationModel.distinctTags()
return response.ok(tags)
} catch (error: any) {
return response.status(500).json({ message: 'Failed to list tags' })
}
}
/**
* GET /api/v1/configurations/:id
* Get single configuration with compliance statistics
*/
async show({ params, response }: HttpContext) {
try {
const config = await this.configService.findById(params.id)
if (!config) {
return response.status(404).json({ message: 'Configuration not found' })
}
// TODO: Add compliance statistics from device compliance info
return response.ok(config)
} catch (error: any) {
logger.error({ error: error.message, id: params.id }, '[ConfigAPI] Get configuration failed')
return response.status(500).json({ message: 'Failed to retrieve configuration' })
}
}
/**
* POST /api/v1/configurations
* Create new configuration with validation and overlap detection
*/
async store({ request, response }: HttpContext) {
try {
const data = request.only([
'identifier',
'name',
'version',
'priority',
'policy',
'enabled',
'tags',
'criteria',
'inventory',
'desiredParameters',
'firmwareTarget',
'schedule',
]) as CreateConfigurationDto
// Check for overlaps
const overlapResult = await this.configService.detectOverlap(data.criteria || {}, data.priority)
if (overlapResult && overlapResult.overlappingConfigurations && overlapResult.overlappingConfigurations.length > 0 && !request.input('ignoreOverlap')) {
return response.status(409).json({
message: 'Configuration overlaps with existing ones',
overlaps: overlapResult.overlappingConfigurations,
})
}
// Create configuration
const config = await this.configService.create(data)
return response.status(201).json(config)
} catch (error: any) {
logger.error({ error: error.message }, '[ConfigAPI] Create configuration failed')
if (error.field) {
return response.status(422).json({ message: error.message, field: error.field })
}
return response.status(500).json({ message: 'Failed to create configuration' })
}
}
/**
* PUT /api/v1/configurations/:id
* Update configuration with validation and overlap detection
*/
async update({ params, request, response }: HttpContext) {
try {
const data = request.only([
'name',
'priority',
'policy',
'enabled',
'tags',
'criteria',
'inventory',
'desiredParameters',
'firmwareTarget',
'schedule',
'bumpType',
]) as UpdateConfigurationDto
// Check for overlaps if priority changed
if (data.priority) {
const overlapResult = await this.configService.detectOverlap(
data.criteria || {},
data.priority,
params.id
)
if (overlapResult && overlapResult.overlappingConfigurations && overlapResult.overlappingConfigurations.length > 0 && !request.input('ignoreOverlap')) {
return response.status(409).json({
message: 'Configuration overlaps with existing ones',
overlaps: overlapResult.overlappingConfigurations,
})
}
}
// Update configuration
const config = await this.configService.update(params.id, data)
if (!config) {
return response.status(404).json({ message: 'Configuration not found' })
}
return response.ok(config)
} catch (error: any) {
logger.error({ error: error.message, id: params.id }, '[ConfigAPI] Update configuration failed')
if (error.field) {
return response.status(422).json({ message: error.message, field: error.field })
}
return response.status(500).json({ message: 'Failed to update configuration' })
}
}
/**
* DELETE /api/v1/configurations/:id
* Delete configuration
*/
async destroy({ params, response }: HttpContext) {
try {
await this.configService.delete(params.id)
return response.status(204).send(null)
} catch (error: any) {
logger.error({ error: error.message, id: params.id }, '[ConfigAPI] Delete configuration failed')
return response.status(500).json({ message: 'Failed to delete configuration' })
}
}
/**
* PATCH /api/v1/configurations/:id/toggle
* Toggle enabled status
*/
async toggleEnabled({ params, request, response }: HttpContext) {
try {
const enabled = request.input('enabled') as boolean
const config = await this.configService.toggleEnabled(params.id, enabled)
if (!config) {
return response.status(404).json({ message: 'Configuration not found' })
}
return response.ok(config)
} catch (error: any) {
logger.error({ error: error.message, id: params.id }, '[ConfigAPI] Toggle configuration failed')
return response.status(500).json({ message: 'Failed to toggle configuration' })
}
}
/**
* PATCH /api/v1/configurations/:id/tags
* Add or remove tags
*/
async manageTags({ params, request, response }: HttpContext) {
try {
const action = request.input('action') as 'add' | 'remove'
const tag = request.input('tag') as string
if (!action || !['add', 'remove'].includes(action)) {
return response.status(422).json({ message: 'Invalid action: must be "add" or "remove"' })
}
if (!tag) {
return response.status(422).json({ message: 'Tag is required' })
}
const config = await this.configService.findById(params.id)
if (!config) {
return response.status(404).json({ message: 'Configuration not found' })
}
if (action === 'add') {
if (!config.tags.includes(tag)) {
config.tags.push(tag)
}
} else {
const index = config.tags.indexOf(tag)
if (index > -1) {
config.tags.splice(index, 1)
}
}
const updated = await this.configService.update(params.id, { tags: config.tags })
return response.ok(updated)
} catch (error: any) {
logger.error({ error: error.message, id: params.id }, '[ConfigAPI] Manage tags failed')
return response.status(500).json({ message: 'Failed to manage tags' })
}
}
/**
* GET /api/v1/configurations/:id/history
* Get configuration version history
*/
async history({ params, request, response }: HttpContext) {
try {
const page = Number(request.input('page', 1))
const limit = Number(request.input('limit', 10))
const result = await this.configService.getHistory(params.id, page, limit)
return response.ok(result)
} catch (error: any) {
logger.error({ error: error.message, id: params.id }, '[ConfigAPI] Get history failed')
return response.status(500).json({ message: 'Failed to retrieve configuration history' })
}
}
/**
* GET /api/v1/configurations/:id/compliance
* Compliance report: all devices matching criteria + their compliance status
*/
async compliance({ params, response }: HttpContext) {
try {
const report = await this.engine.complianceReport(params.id)
return response.ok(report)
} catch (error: any) {
logger.error({ error: error.message, id: params.id }, '[ConfigAPI] Compliance report failed')
if (error.message.includes('not found')) {
return response.status(404).json({ message: error.message })
}
return response.status(500).json({ message: 'Compliance report failed' })
}
}
/**
* POST /api/v1/configurations/:id/dry-run
* Perform dry-run evaluation for a device
*/
async dryRun({ params, request, response }: HttpContext) {
try {
const deviceIdentifier = request.input('deviceIdentifier') as string
if (!deviceIdentifier) {
return response.status(422).json({ message: 'deviceIdentifier is required' })
}
const result = await this.engine.dryRun(params.id, deviceIdentifier)
return response.ok(result)
} catch (error: any) {
logger.error({ error: error.message, id: params.id }, '[ConfigAPI] Dry-run failed')
if (error.message.includes('not found')) {
return response.status(404).json({ message: error.message })
}
return response.status(500).json({ message: 'Dry-run failed' })
}
}
}