Skip to content

Commit 913a10d

Browse files
committed
chore: add reconcicler
Signed-off-by: William Phetsinorath <william.phetsinorath-open@interieur.gouv.fr>
1 parent 4bbd6a1 commit 913a10d

6 files changed

Lines changed: 162 additions & 6 deletions

File tree

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { setTimeout } from 'node:timers/promises'
2+
import { Logger } from '@nestjs/common'
3+
4+
export interface RequeueResult {
5+
requeueAfterMs?: number
6+
reason?: string
7+
}
8+
9+
export type ReconcileResult = undefined | RequeueResult
10+
11+
export function requeue(options: RequeueResult = {}): RequeueResult {
12+
return options
13+
}
14+
15+
export interface ReconcileOptions {
16+
maxRetries?: number
17+
initialDelayMs?: number
18+
maxDelayMs?: number
19+
factor?: number
20+
jitter?: number
21+
shouldRetry?: (error: unknown) => boolean
22+
onError?: (error: unknown) => void
23+
}
24+
25+
export async function reconcile<T>(handler: () => Promise<T> | T, options: ReconcileOptions = {}): Promise<T> {
26+
const {
27+
maxRetries = 5,
28+
initialDelayMs = 1000,
29+
maxDelayMs = 60_000,
30+
factor = 2,
31+
jitter = 0.2,
32+
shouldRetry,
33+
onError,
34+
} = options
35+
36+
const run = async (attempt: number): Promise<T> => {
37+
try {
38+
const result = await handler()
39+
const requeueResult = toRequeueResult(result)
40+
41+
if (requeueResult) {
42+
if (attempt >= maxRetries) return result
43+
const delayMs = Math.max(0, requeueResult.requeueAfterMs ?? computeBackoffDelayMs({
44+
attempt,
45+
initialDelayMs,
46+
maxDelayMs,
47+
factor,
48+
jitter,
49+
}))
50+
await setTimeout(delayMs)
51+
return await run(attempt + 1)
52+
}
53+
54+
return result
55+
} catch (error) {
56+
onError?.(error)
57+
const canRetry = attempt < maxRetries && (shouldRetry?.(error) ?? true)
58+
if (!canRetry) throw error
59+
60+
const delayMs = computeBackoffDelayMs({
61+
attempt,
62+
initialDelayMs,
63+
maxDelayMs,
64+
factor,
65+
jitter,
66+
})
67+
await setTimeout(delayMs)
68+
return await run(attempt + 1)
69+
}
70+
}
71+
72+
return await run(0)
73+
}
74+
75+
export type TypedMethodDecorator = <T extends (this: any, ...args: any[]) => any>(
76+
target: object,
77+
propertyKey: string | symbol,
78+
descriptor: TypedPropertyDescriptor<T>,
79+
) => void
80+
81+
export function Reconcile(options: ReconcileOptions = {}): TypedMethodDecorator {
82+
return <T extends (this: any, ...args: any[]) => any>(
83+
_target: object,
84+
propertyKey: string | symbol,
85+
descriptor: TypedPropertyDescriptor<T>,
86+
): void => {
87+
const original = descriptor.value
88+
if (!original) return
89+
90+
descriptor.value = (async function (this: ThisParameterType<T>, ...args: Parameters<T>): Promise<Awaited<ReturnType<T>>> {
91+
const logger: Logger = this?.logger instanceof Logger
92+
? this.logger
93+
: new Logger(this?.constructor?.name ?? 'Reconcile')
94+
95+
try {
96+
return await reconcile(
97+
() => original.apply(this, args),
98+
options,
99+
) as Awaited<ReturnType<T>>
100+
} catch (error) {
101+
logger.error(
102+
`Handler ${String(propertyKey)} failed permanently`,
103+
error instanceof Error ? error.stack : undefined,
104+
)
105+
throw error
106+
}
107+
}) as T
108+
}
109+
}
110+
111+
function toRequeueResult(value: unknown): RequeueResult | undefined {
112+
if (!value || typeof value !== 'object') return undefined
113+
const keys = Object.keys(value)
114+
if (keys.length === 0) return undefined
115+
116+
for (const key of keys) {
117+
if (key !== 'requeueAfterMs' && key !== 'reason') return undefined
118+
}
119+
120+
const requeueAfterMs = (value as any).requeueAfterMs
121+
const reason = (value as any).reason
122+
123+
if (requeueAfterMs !== undefined && typeof requeueAfterMs !== 'number') return undefined
124+
if (reason !== undefined && typeof reason !== 'string') return undefined
125+
126+
return { requeueAfterMs, reason }
127+
}
128+
129+
function computeBackoffDelayMs(options: {
130+
attempt: number
131+
initialDelayMs: number
132+
maxDelayMs: number
133+
factor: number
134+
jitter: number
135+
}): number {
136+
const base = Math.min(options.maxDelayMs, options.initialDelayMs * (options.factor ** options.attempt))
137+
const jitter = options.jitter <= 0 ? 0 : (Math.random() * 2 - 1) * options.jitter
138+
return Math.max(0, Math.round(base * (1 + jitter)))
139+
}

apps/server-nestjs/src/modules/argocd/argocd-controller.service.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ import { trace } from '@opentelemetry/api'
99
import { stringify } from 'yaml'
1010

1111
import { ConfigurationService } from '../../cpin-module/infrastructure/configuration/configuration.service'
12+
import { Reconcile } from '../../cpin-module/infrastructure/reconcile/reconcile.decorator'
1213
import { StartActiveSpan } from '../../cpin-module/infrastructure/telemetry/telemetry.decorator'
14+
import { GitlabService } from '../gitlab/gitlab.service'
15+
import { VaultService } from '../vault/vault.service'
16+
import { ArgoCDDatastoreService } from './argocd-datastore.service'
1317

1418
@Injectable()
1519
export class ArgoCDControllerService {
@@ -25,6 +29,7 @@ export class ArgoCDControllerService {
2529
}
2630

2731
@OnEvent('project.upsert')
32+
@Reconcile()
2833
@StartActiveSpan()
2934
async handleUpsert(project: ProjectWithDetails) {
3035
const span = trace.getActiveSpan()
@@ -34,6 +39,7 @@ export class ArgoCDControllerService {
3439
}
3540

3641
@OnEvent('project.delete')
42+
@Reconcile()
3743
@StartActiveSpan()
3844
async handleDelete(project: ProjectWithDetails) {
3945
const span = trace.getActiveSpan()

apps/server-nestjs/src/modules/gitlab/gitlab-controller.service.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,6 @@ import { INFRA_APPS_REPO_NAME, TOPIC_PLUGIN_MANAGED } from './gitlab.constants'
1717
import { DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX } from './gitlab.constants.js'
1818
import { GitlabService } from './gitlab.service'
1919

20-
import { ConfigurationService } from '../../cpin-module/infrastructure/configuration/configuration.service'
21-
import { StartActiveSpan } from '../../cpin-module/infrastructure/telemetry/telemetry.decorator'
22-
import { getAll } from '../../utils/iterable'
2320
const ownedUserRegex = /group_\d+_bot/u
2421

2522
@Injectable()
@@ -36,6 +33,7 @@ export class GitlabControllerService {
3633
}
3734

3835
@OnEvent('project.upsert')
36+
@Reconcile()
3937
@StartActiveSpan()
4038
async handleUpsert(project: ProjectWithDetails) {
4139
const span = trace.getActiveSpan()
@@ -45,6 +43,7 @@ export class GitlabControllerService {
4543
}
4644

4745
@OnEvent('project.delete')
46+
@Reconcile()
4847
@StartActiveSpan()
4948
async handleDelete(project: ProjectWithDetails) {
5049
const span = trace.getActiveSpan()

apps/server-nestjs/src/modules/keycloak/keycloak-controller.service.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { OnEvent } from '@nestjs/event-emitter'
77
import { Cron, CronExpression } from '@nestjs/schedule'
88
import { trace } from '@opentelemetry/api'
99
import z from 'zod'
10+
import { Reconcile } from '../../cpin-module/infrastructure/reconcile/reconcile.decorator'
1011
import { StartActiveSpan } from '../../cpin-module/infrastructure/telemetry/telemetry.decorator'
1112
import { KeycloakClientService } from './keycloak-client.service'
1213
import { KeycloakDatastoreService } from './keycloak-datastore.service'
@@ -24,6 +25,7 @@ export class KeycloakControllerService {
2425
}
2526

2627
@OnEvent('project.upsert')
28+
@Reconcile()
2729
@StartActiveSpan()
2830
async handleUpsert(project: ProjectWithDetails) {
2931
const span = trace.getActiveSpan()
@@ -33,6 +35,7 @@ export class KeycloakControllerService {
3335
}
3436

3537
@OnEvent('project.delete')
38+
@Reconcile()
3639
@StartActiveSpan()
3740
async handleDelete(project: ProjectWithDetails) {
3841
const span = trace.getActiveSpan()

apps/server-nestjs/src/modules/nexus/nexus-controller.service.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
import { ConfigurationService } from '../../cpin-module/infrastructure/configuration/configuration.service'
1+
import type { ProjectWithDetails } from './nexus-datastore.service'
2+
import { specificallyEnabled } from '@cpn-console/hooks'
23
import { Inject, Injectable, Logger } from '@nestjs/common'
34
import { OnEvent } from '@nestjs/event-emitter'
45
import { Cron, CronExpression } from '@nestjs/schedule'
56
import { trace } from '@opentelemetry/api'
7+
68
import { ConfigurationService } from '../../cpin-module/infrastructure/configuration/configuration.service'
79
import { Reconcile } from '../../cpin-module/infrastructure/reconcile/reconcile.decorator'
810
import { StartActiveSpan } from '../../cpin-module/infrastructure/telemetry/telemetry.decorator'
@@ -12,7 +14,6 @@ import { NexusClientService } from './nexus-client.service'
1214
import { NexusDatastoreService } from './nexus-datastore.service'
1315
import { NEXUS_CONFIG_KEYS } from './nexus.constants'
1416
import { generateMavenGroupPrivilegeName, generateMavenGroupRepoName, generateMavenHostedPrivilegeName, generateMavenHostedRepoName, generateNpmGroupPrivilegeName, generateNpmGroupRepoName, generateNpmHostedPrivilegeName, generateNpmHostedRepoName, generateRandomPassword, getPluginConfig, getProjectVaultPath } from './nexus.utils'
15-
import { StartActiveSpan } from '../../cpin-module/infrastructure/telemetry/telemetry.decorator'
1617

1718
@Injectable()
1819
export class NexusControllerService {
@@ -28,6 +29,7 @@ export class NexusControllerService {
2829
}
2930

3031
@OnEvent('project.upsert')
32+
@Reconcile()
3133
@StartActiveSpan()
3234
async handleUpsert(project: ProjectWithDetails) {
3335
const span = trace.getActiveSpan()
@@ -37,6 +39,7 @@ export class NexusControllerService {
3739
}
3840

3941
@OnEvent('project.delete')
42+
@Reconcile()
4043
@StartActiveSpan()
4144
async handleDelete(project: ProjectWithDetails) {
4245
const span = trace.getActiveSpan()

apps/server-nestjs/src/modules/vault/vault-controller.service.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ import { Inject, Injectable, Logger } from '@nestjs/common'
33
import { OnEvent } from '@nestjs/event-emitter'
44
import { Cron, CronExpression } from '@nestjs/schedule'
55
import { trace } from '@opentelemetry/api'
6+
import { ConfigurationService } from '../../cpin-module/infrastructure/configuration/configuration.service'
7+
import { Reconcile } from '../../cpin-module/infrastructure/reconcile/reconcile.decorator'
68
import { StartActiveSpan } from '../../cpin-module/infrastructure/telemetry/telemetry.decorator'
79
import { VaultClientService, VaultError } from './vault-client.service'
8-
import { ConfigurationService } from '../../cpin-module/infrastructure/configuration/configuration.service'
10+
import { VaultDatastoreService } from './vault-datastore.service'
911
import { generateProjectPath } from './vault.utils'
1012

1113
@Injectable()
@@ -21,6 +23,7 @@ export class VaultControllerService {
2123
}
2224

2325
@OnEvent('project.upsert')
26+
@Reconcile()
2427
@StartActiveSpan()
2528
async handleUpsert(project: ProjectWithDetails) {
2629
const span = trace.getActiveSpan()
@@ -30,6 +33,7 @@ export class VaultControllerService {
3033
}
3134

3235
@OnEvent('project.delete')
36+
@Reconcile()
3337
@StartActiveSpan()
3438
async handleDelete(project: ProjectWithDetails) {
3539
const span = trace.getActiveSpan()
@@ -42,6 +46,7 @@ export class VaultControllerService {
4246
}
4347

4448
@OnEvent('zone.upsert')
49+
@Reconcile()
4550
@StartActiveSpan()
4651
async handleUpsertZone(zone: ZoneWithDetails) {
4752
const span = trace.getActiveSpan()
@@ -51,6 +56,7 @@ export class VaultControllerService {
5156
}
5257

5358
@OnEvent('zone.delete')
59+
@Reconcile()
5460
@StartActiveSpan()
5561
async handleDeleteZone(zone: ZoneWithDetails) {
5662
const span = trace.getActiveSpan()

0 commit comments

Comments
 (0)