Skip to content

Commit 7e8b126

Browse files
committed
feat: enhance cron task management with console handler integration
- Enabled the lifecycle-execute-check-identities cron task and updated its schedule. - Introduced CronConsoleHandler decorator for agents and backends commands, improving command registration. - Added new endpoints in CronController for listing console handlers and updating cron tasks. - Enhanced cron service to validate and update task options, ensuring better error handling. - Updated frontend to support editing cron tasks with dynamic argument handling for console commands.
1 parent 8350a90 commit 7e8b126

18 files changed

Lines changed: 946 additions & 36 deletions
Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
tasks:
2-
- name: "lifecycle-execute-check-identities"
3-
description: "Execute lifecycle trigger for check identities"
2+
- name: lifecycle-execute-check-identities
3+
description: Execute lifecycle trigger for check identities
44
enabled: false
5-
schedule: "0 2 15 6 *" # Le 15 juin de chaque année à 02:00
6-
handler: "lifecycle-execute"
5+
schedule: 0 2 30 6 *
6+
handler: lifecycle-execute
77
options:
8+
source: 01-etd
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { SetMetadata } from '@nestjs/common'
2+
3+
export const CRON_CONSOLE_HANDLER_METADATA = 'sesame:cron-console-handler'
4+
5+
export type CronConsoleHandlerArgumentType = 'string' | 'number' | 'boolean'
6+
7+
export interface CronConsoleHandlerArgument {
8+
/** Nom de l'argument (`limit` → `--limit`, ou argument positionnel si `positional: true`). */
9+
name: string
10+
label?: string
11+
description?: string
12+
type?: CronConsoleHandlerArgumentType
13+
default?: string | number | boolean
14+
required?: boolean
15+
/** Passe la valeur comme argument positionnel après la commande console. */
16+
positional?: boolean
17+
}
18+
19+
export interface CronConsoleHandlerDescriptor {
20+
handler: string
21+
command: string
22+
label: string
23+
arguments: CronConsoleHandlerArgument[]
24+
}
25+
26+
export interface CronConsoleHandlerOptions {
27+
/** Identifiant handler (ex. `lifecycle-execute`). */
28+
handler: string
29+
30+
/** Commande console complète (ex. `lifecycle execute`). */
31+
command: string
32+
33+
/** Libellé affiché dans l'interface d'administration. */
34+
label?: string
35+
36+
/** Arguments CLI suggérés pour la configuration cron. */
37+
arguments?: CronConsoleHandlerArgument[]
38+
}
39+
40+
const cronConsoleHandlerRegistry: CronConsoleHandlerDescriptor[] = []
41+
42+
export function getCronConsoleHandlers(): CronConsoleHandlerDescriptor[] {
43+
return [...cronConsoleHandlerRegistry].sort((left, right) => left.handler.localeCompare(right.handler))
44+
}
45+
46+
export const CronConsoleHandler = (options: CronConsoleHandlerOptions) => {
47+
cronConsoleHandlerRegistry.push({
48+
handler: options.handler,
49+
command: options.command,
50+
label: options.label || options.handler,
51+
arguments: options.arguments || [],
52+
})
53+
54+
return SetMetadata(CRON_CONSOLE_HANDLER_METADATA, options)
55+
}

apps/api/src/core/agents/agents.command.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Logger } from '@nestjs/common'
22
import { ModuleRef } from '@nestjs/core'
33
import { Types } from 'mongoose'
44
import { Command, CommandRunner, InquirerService, Question, QuestionSet, SubCommand } from 'nest-commander'
5+
import { CronConsoleHandler } from '~/_common/decorators/cron-console-handler.decorator'
56
import { AgentsCreateDto, AgentsUpdateDto } from '~/core/agents/_dto/agents.dto'
67
import { AgentsService } from '~/core/agents/agents.service'
78
import { Agents } from './_schemas/agents.schema'
@@ -221,6 +222,11 @@ export class AgentsClearMfaCommand extends CommandRunner {
221222
}
222223
}
223224

225+
@CronConsoleHandler({
226+
handler: 'agents-list',
227+
command: 'agents list',
228+
label: 'Liste des agents',
229+
})
224230
@SubCommand({ name: 'list' })
225231
export class AgentsListCommand extends CommandRunner {
226232
private readonly logger = new Logger(AgentsListCommand.name)

apps/api/src/core/backends/backends.command.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1-
import { ModuleRef } from '@nestjs/core';
2-
import { Command, CommandRunner, SubCommand } from 'nest-commander';
3-
import { BackendsService } from '~/core/backends/backends.service';
1+
import { ModuleRef } from '@nestjs/core'
2+
import { Command, CommandRunner, SubCommand } from 'nest-commander'
3+
import { CronConsoleHandler } from '~/_common/decorators/cron-console-handler.decorator'
4+
import { BackendsService } from '~/core/backends/backends.service'
45

6+
@CronConsoleHandler({
7+
handler: 'backends-syncall',
8+
command: 'backends syncall',
9+
label: 'Synchronisation de toutes les identités vers les backends',
10+
})
511
@SubCommand({ name: 'syncall' })
612
export class BackendsSyncallCommand extends CommandRunner {
713
public constructor(

apps/api/src/core/cron/_dto/config-task.dto.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -107,10 +107,10 @@ export class CronTaskDTO {
107107
{ type: 'array', items: { type: 'string' } },
108108
{ type: 'object', additionalProperties: true },
109109
],
110-
description: 'Options spécifiques de la tâche : tableau de strings ou objet clé/valeur',
110+
description: 'Options CLI transmises au handler (arguments déclarés via @CronConsoleHandler)',
111111
examples: [
112-
['arg1', 'arg2'],
113-
{ key1: 'value1', key2: 'value2', retentionPeriodDays: 30 },
112+
{ limit: 500 },
113+
{ source: '01-etd' },
114114
],
115115
})
116116
options?: string[] | Record<string, any>
@@ -131,8 +131,8 @@ export class ConfigTaskDTO {
131131
* description: 'Cleans up old task lifecycle entries from the database.',
132132
* enabled: true,
133133
* schedule: 'CRON_PATTERN',
134-
* handler: 'agents-create',
135-
* options: { retentionPeriodDays: 30 }
134+
* handler: 'identities-pwned-recheck',
135+
* options: { limit: 500 }
136136
* }
137137
* ]
138138
*/

apps/api/src/core/cron/_dto/cron.dto.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,8 @@ export class CronCreateDto {
6363
@IsOptional()
6464
@ApiProperty({
6565
type: 'object',
66-
description: 'Options spécifiques de la tâche (clé/valeur)',
67-
example: { retentionPeriodDays: 30 },
66+
description: 'Arguments CLI du handler (schéma défini par @CronConsoleHandler)',
67+
example: { limit: 500 },
6868
additionalProperties: true,
6969
})
7070
options?: Record<string, any>
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { BadRequestException } from '@nestjs/common'
2+
import {
3+
CronConsoleHandlerArgument,
4+
getCronConsoleHandlers,
5+
} from '~/_common/decorators/cron-console-handler.decorator'
6+
7+
function getHandlerDescriptor(handler: string) {
8+
return getCronConsoleHandlers().find((entry) => entry.handler === handler)
9+
}
10+
11+
function assertArgumentType(argument: CronConsoleHandlerArgument, value: unknown, handler: string): void {
12+
if (value === undefined || value === null || value === '') {
13+
return
14+
}
15+
16+
if (argument.type === 'number' && !Number.isFinite(Number(value))) {
17+
throw new BadRequestException(`L'argument "${argument.name}" doit être numérique pour le handler "${handler}".`)
18+
}
19+
20+
if (argument.type === 'boolean' && typeof value !== 'boolean') {
21+
throw new BadRequestException(`L'argument "${argument.name}" doit être un booléen pour le handler "${handler}".`)
22+
}
23+
}
24+
25+
export function validateCronTaskOptions(
26+
handler: string,
27+
options?: string[] | Record<string, unknown>,
28+
): void {
29+
if (options === undefined || options === null) {
30+
return
31+
}
32+
33+
const descriptor = getHandlerDescriptor(handler)
34+
const schema = descriptor?.arguments || []
35+
36+
if (Array.isArray(options)) {
37+
if (schema.length > 0) {
38+
throw new BadRequestException(
39+
`Le handler "${handler}" attend un objet d'arguments (${schema.map((argument) => argument.name).join(', ')}).`,
40+
)
41+
}
42+
return
43+
}
44+
45+
if (typeof options !== 'object') {
46+
throw new BadRequestException(`Les options du handler "${handler}" doivent être un objet clé-valeur.`)
47+
}
48+
49+
if (!schema.length) {
50+
if (Object.keys(options).length > 0) {
51+
throw new BadRequestException(`Le handler "${handler}" n'accepte pas d'arguments configurables.`)
52+
}
53+
return
54+
}
55+
56+
const allowed = new Map(schema.map((argument) => [argument.name, argument]))
57+
58+
for (const key of Object.keys(options)) {
59+
if (!allowed.has(key)) {
60+
throw new BadRequestException(`Argument "${key}" invalide pour le handler "${handler}".`)
61+
}
62+
}
63+
64+
for (const argument of schema) {
65+
const value = options[argument.name]
66+
if (argument.required && (value === undefined || value === null || value === '')) {
67+
throw new BadRequestException(`L'argument "${argument.name}" est requis pour le handler "${handler}".`)
68+
}
69+
assertArgumentType(argument, value, handler)
70+
}
71+
}
72+
73+
export function buildCronCommandArgs(
74+
handler: string,
75+
options?: string[] | Record<string, unknown> | null,
76+
): { positionalArgs: string[]; flagArgs: string[] } {
77+
const descriptor = getHandlerDescriptor(handler)
78+
const schema = descriptor?.arguments || []
79+
const positionalArgs: string[] = []
80+
const flagArgs: string[] = []
81+
82+
if (!options) {
83+
return { positionalArgs, flagArgs }
84+
}
85+
86+
if (Array.isArray(options)) {
87+
positionalArgs.push(...options.map(String).filter((value) => value.length > 0))
88+
return { positionalArgs, flagArgs }
89+
}
90+
91+
if (typeof options !== 'object') {
92+
return { positionalArgs, flagArgs }
93+
}
94+
95+
const positionalSchema = schema.filter((argument) => argument.positional)
96+
const flagSchema = schema.filter((argument) => !argument.positional)
97+
98+
for (const argument of positionalSchema) {
99+
const value = options[argument.name]
100+
if (value !== undefined && value !== null && `${value}`.length > 0) {
101+
positionalArgs.push(String(value))
102+
}
103+
}
104+
105+
const flagEntries = flagSchema.length
106+
? flagSchema.map((argument) => [argument.name, options[argument.name]] as const)
107+
: Object.entries(options).filter(([key]) => !positionalSchema.some((argument) => argument.name === key))
108+
109+
for (const [key, value] of flagEntries) {
110+
if (typeof value === 'boolean') {
111+
if (value) {
112+
flagArgs.push(`--${key}`)
113+
}
114+
continue
115+
}
116+
117+
if (value === null || value === undefined || `${value}`.length === 0) {
118+
continue
119+
}
120+
121+
if (typeof value === 'object') {
122+
flagArgs.push(`--${key}='${JSON.stringify(value)}'`)
123+
continue
124+
}
125+
126+
flagArgs.push(`--${key}='${String(value)}'`)
127+
}
128+
129+
return { positionalArgs, flagArgs }
130+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import '~/core/agents/agents.command'
2+
import '~/core/backends/backends.command'
3+
import '~/management/identities/identities.command'
4+
import '~/management/lifecycle/lifecycle.command'

apps/api/src/core/cron/cron-hooks.service.ts

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { loadcronTasks } from './_functions/load-cron-tasks.function'
99
import { ConfigService } from '@nestjs/config'
1010
import { createHandlerLogger } from '~/_common/functions/handler-logger'
1111
import { resolveConfigVariables } from '~/_common/functions/resolve-config-variables.function'
12+
import { buildCronCommandArgs } from './_functions/cron-command-options.function'
1213

1314
@Injectable()
1415
export class CronHooksService {
@@ -293,25 +294,11 @@ export class CronHooksService {
293294
}
294295

295296
private async executeHandlerCommand(name: string, handler: string, options?: Record<string, any>): Promise<void> {
296-
const args: string[] = []
297297
const resolvedOptions = await resolveConfigVariables(options)
298-
299-
if (resolvedOptions && typeof resolvedOptions === 'object') {
300-
for (const [k, v] of Object.entries(resolvedOptions)) {
301-
if (typeof v === 'boolean') {
302-
if (v) args.push(`--${k}`)
303-
} else if (v === null || v === undefined) {
304-
// skip
305-
} else if (typeof v === 'object') {
306-
args.push(`--${k}='${JSON.stringify(v)}'`)
307-
} else {
308-
args.push(`--${k}='${String(v)}'`)
309-
}
310-
}
311-
}
298+
const { positionalArgs, flagArgs } = buildCronCommandArgs(handler, resolvedOptions)
312299

313300
const cmd = 'yarn'
314-
const cmdArgs = ['run', 'console', handler.split('-').join(' '), ...args]
301+
const cmdArgs = ['run', 'console', ...handler.split('-'), ...positionalArgs, ...flagArgs]
315302

316303
const handlerLogger = createHandlerLogger(this.configService, name)
317304
this.logger.log(`Spawning command: ${cmd} ${cmdArgs.join(' ')}`)

apps/api/src/core/cron/cron.controller.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { UseRoles } from '~/_common/decorators/use-roles.decorator'
77
import { AC_ACTIONS, AC_DEFAULT_POSSESSION } from '~/_common/types/ac-types'
88
import { ApiPaginatedDecorator } from '~/_common/decorators/api-paginated.decorator'
99
import { PickProjectionHelper } from '~/_common/helpers/pick-projection.helper'
10-
import { CronDto } from './_dto/cron.dto'
10+
import { CronDto, CronUpdateDto } from './_dto/cron.dto'
1111
import { PartialProjectionType } from '~/_common/types/partial-projection.type'
1212
import { ApiReadResponseDecorator } from '~/_common/decorators/api-read-response.decorator'
1313
import { IsBoolean } from 'class-validator'
@@ -67,6 +67,19 @@ export class CronController {
6767
})
6868
}
6969

70+
@Get('handlers')
71+
@UseRoles({
72+
resource: '/core/cron',
73+
action: AC_ACTIONS.READ,
74+
possession: AC_DEFAULT_POSSESSION,
75+
})
76+
public async listHandlers(@Res() res: Response): Promise<Response> {
77+
return res.json({
78+
statusCode: HttpStatus.OK,
79+
data: this.cronService.getConsoleHandlers(),
80+
})
81+
}
82+
7083
@Get(':name')
7184
@UseRoles({
7285
resource: '/core/cron',
@@ -130,6 +143,29 @@ export class CronController {
130143
})
131144
}
132145

146+
@Patch(':name')
147+
@UseRoles({
148+
resource: '/core/cron',
149+
action: AC_ACTIONS.UPDATE,
150+
possession: AC_DEFAULT_POSSESSION,
151+
})
152+
@ApiReadResponseDecorator(CronDto)
153+
public async update(
154+
@Param('name') name: string,
155+
@Body() body: CronUpdateDto,
156+
@Res() res: Response,
157+
): Promise<Response> {
158+
const data = await this.cronService.update(name, body)
159+
if (!data) {
160+
throw new NotFoundException(`Cron task <${name}> not found`)
161+
}
162+
163+
return res.json({
164+
statusCode: HttpStatus.OK,
165+
data,
166+
})
167+
}
168+
133169
@Post(':name/run-immediately')
134170
@UseRoles({
135171
resource: '/core/cron',

0 commit comments

Comments
 (0)