Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 0 additions & 8 deletions apps/server-nestjs/.env-example
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,6 @@ DB_URL=postgresql://admin:admin@localhost:5432/dso-console-db?schema=public
# Adresse e-mail de contact affichée dans l'interface
CONTACT_EMAIL=cloudpinative-relations@interieur.gouv.fr

# --- Configuration OpenCDS ---
# URL de l'API OpenCDS (laisser vide pour désactiver)
OPENCDS_URL=
# Token d'authentification pour l'API OpenCDS
OPENCDS_API_TOKEN=token
# Vérification du certificat TLS de l'API OpenCDS (true | false)
OPENCDS_API_TLS_REJECT_UNAUTHORIZED=true

# --- Activation des plugins externes ---
# Mettez "true" et renseignez les variables ci-dessus pour activer chaque plugin.
USE_ARGOCD=false
Expand Down
3 changes: 0 additions & 3 deletions apps/server-nestjs/.env.docker-example
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,6 @@ DB_URL=postgresql://admin:admin@postgres:5432/dso-console-db?schema=public
CONTACT_EMAIL=cloudpinative-relations@interieur.gouv.fr

# --- Configuration OpenCDS ---
# URL de l'API OpenCDS (laisser vide pour désactiver)
# Par défaut configuré sur le conteneur Mockoon (à changer sur envs non-PAX)
OPENCDS_URL=
# Token d'authentification pour l'API OpenCDS
OPENCDS_API_TOKEN=token
# Vérification du certificat TLS de l'API OpenCDS (true | false)
Expand Down
1 change: 1 addition & 0 deletions apps/server-nestjs/.env.integ-example
Original file line number Diff line number Diff line change
Expand Up @@ -111,5 +111,6 @@ DSO_OBSERVABILITY_CHART_VERSION=dso-observability-0.1.7

# Configuration OpenCDS
OPENCDS_URL=
OPENCDS_INTERNAL_URL=
OPENCDS_API_TOKEN=token
OPENCDS_API_TLS_REJECT_UNAUTHORIZED=false
52 changes: 52 additions & 0 deletions apps/server-nestjs/src/config/argocd.config.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { argocdConfigFactory } from './argocd.config'
import { resetEnvs } from './config-testing.utils'

describe('argocdConfig', () => {
beforeEach(() => { resetEnvs(['ARGO_NAMESPACE', 'ARGOCD_URL', 'ARGOCD_INTERNAL_URL', 'DSO_ENV_CHART_VERSION', 'DSO_NS_CHART_VERSION', 'VAULT__DEPLOY_VAULT_CONNECTION_IN_NS']) })
afterEach(() => { vi.unstubAllEnvs() })

it('parses a full config', () => {
vi.stubEnv('ARGOCD_URL', 'https://argocd.internal')
vi.stubEnv('ARGOCD_INTERNAL_URL', 'https://argocd.internal:8080')
vi.stubEnv('ARGOCD_EXTRA_REPOSITORIES', 'repo1')
vi.stubEnv('VAULT__DEPLOY_VAULT_CONNECTION_IN_NS', 'true')
vi.stubEnv('PROJECTS_ROOT_DIR', 'forge-test/projects')
expect(argocdConfigFactory()).toMatchObject({
namespace: 'argocd',
url: 'https://argocd.internal',
internalUrl: 'https://argocd.internal:8080',
extraRepositories: ['repo1'],
dsoEnvChartVersion: 'dso-env-1.6.0',
dsoNsChartVersion: 'dso-ns-1.1.5',
vaultDeployVaultConnectionInNs: true,
})
})

it('falls back to public url when internal url is absent', () => {
vi.stubEnv('ARGOCD_URL', 'https://argocd.internal')
vi.stubEnv('ARGOCD_EXTRA_REPOSITORIES', 'repo1')
vi.stubEnv('VAULT__DEPLOY_VAULT_CONNECTION_IN_NS', 'true')
vi.stubEnv('PROJECTS_ROOT_DIR', 'forge-test/projects')
const cfg = argocdConfigFactory()
expect(cfg.internalUrl).toBeUndefined()
expect(cfg.url).toBe('https://argocd.internal')
})

it('applies flag defaults', () => {
vi.stubEnv('ARGOCD_URL', 'https://argocd.internal')
vi.stubEnv('ARGOCD_EXTRA_REPOSITORIES', 'repo1')
vi.stubEnv('PROJECTS_ROOT_DIR', 'forge-test/projects')
expect(argocdConfigFactory().vaultDeployVaultConnectionInNs).toBe(false)
})

it('defaults extraRepositories to an empty array', () => {
vi.stubEnv('ARGOCD_URL', 'https://argocd.internal')
vi.stubEnv('PROJECTS_ROOT_DIR', 'forge-test/projects')
expect(argocdConfigFactory().extraRepositories).toEqual([])
})

it('throws when a required var is missing', () => {
expect(() => argocdConfigFactory()).toThrow()
})
})
25 changes: 25 additions & 0 deletions apps/server-nestjs/src/config/argocd.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { registerAs } from '@nestjs/config'
import z from 'zod'
import { csv, truthySchema } from './config.utils'

const argocdFeatureSchema = z.object({
ARGO_NAMESPACE: z.string().default('argocd'),
ARGOCD_URL: z.string().url(),
ARGOCD_INTERNAL_URL: z.string().url().optional(),
ARGOCD_EXTRA_REPOSITORIES: csv(z.string()),
DSO_ENV_CHART_VERSION: z.string().default('dso-env-1.6.0'),
DSO_NS_CHART_VERSION: z.string().default('dso-ns-1.1.5'),
VAULT__DEPLOY_VAULT_CONNECTION_IN_NS: truthySchema.default('false').transform(v => v === 'true' || v === '1'),
}).transform(raw => ({
namespace: raw.ARGO_NAMESPACE,
url: raw.ARGOCD_URL,
internalUrl: raw.ARGOCD_INTERNAL_URL,
extraRepositories: raw.ARGOCD_EXTRA_REPOSITORIES,
dsoEnvChartVersion: raw.DSO_ENV_CHART_VERSION,
dsoNsChartVersion: raw.DSO_NS_CHART_VERSION,
vaultDeployVaultConnectionInNs: raw.VAULT__DEPLOY_VAULT_CONNECTION_IN_NS,
}))

export type ArgocdConfig = z.infer<typeof argocdFeatureSchema>

export const argocdConfigFactory = registerAs('argocd', () => argocdFeatureSchema.parse(process.env))
63 changes: 63 additions & 0 deletions apps/server-nestjs/src/config/base.config.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { baseConfigFactory } from './base.config'
import { resetEnvs } from './config-testing.utils'

describe('baseConfig', () => {
beforeEach(() => { resetEnvs(['NODE_ENV', 'CI', 'SERVER_HOST', 'SERVER_PORT', 'APP_VERSION', 'DB_URL', 'PROJECTS_ROOT_DIR']) })
afterEach(() => { vi.unstubAllEnvs() })

it('parses a full config and derives env flags', () => {
vi.stubEnv('NODE_ENV', 'development')
vi.stubEnv('CI', 'true')
vi.stubEnv('SERVER_HOST', 'host')
vi.stubEnv('SERVER_PORT', '4000')
vi.stubEnv('APP_VERSION', '1.2.3')
vi.stubEnv('PROJECTS_ROOT_DIR', 'forge-test/projects')
vi.stubEnv('DB_URL', 'postgres://db')
expect(baseConfigFactory()).toEqual({
isTest: false,
isDev: true,
isCI: true,
isProd: false,
serverHost: 'host',
serverPort: 4000,
appVersion: 'dev',
dbUrl: 'postgres://db',
projectsRootDir: 'forge-test/projects',
})
})

it('uses APP_VERSION in production, "dev" otherwise', () => {
vi.stubEnv('NODE_ENV', 'production')
vi.stubEnv('CI', 'true')
vi.stubEnv('SERVER_HOST', 'host')
vi.stubEnv('SERVER_PORT', '4000')
vi.stubEnv('APP_VERSION', '1.2.3')
vi.stubEnv('PROJECTS_ROOT_DIR', 'forge-test/projects')
vi.stubEnv('DB_URL', 'postgres://db')
expect(baseConfigFactory().appVersion).toBe('1.2.3')
vi.stubEnv('NODE_ENV', 'test')
vi.stubEnv('CI', 'true')
vi.stubEnv('SERVER_HOST', 'host')
vi.stubEnv('SERVER_PORT', '4000')
vi.stubEnv('APP_VERSION', '1.2.3')
vi.stubEnv('PROJECTS_ROOT_DIR', 'forge-test/projects')
vi.stubEnv('DB_URL', 'postgres://db')
expect(baseConfigFactory().appVersion).toBe('dev')
})

it('applies defaults when optional vars are absent', () => {
vi.stubEnv('DB_URL', 'postgres://default')
vi.stubEnv('PROJECTS_ROOT_DIR', 'r')
vi.stubEnv('NODE_ENV', 'production')
expect(baseConfigFactory()).toMatchObject({
isDev: false,
isProd: true,
isCI: false,
serverHost: 'localhost',
serverPort: 0,
appVersion: 'unknown',
dbUrl: 'postgres://default',
})
})
})
29 changes: 29 additions & 0 deletions apps/server-nestjs/src/config/base.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { registerAs } from '@nestjs/config'
import z from 'zod'
import { flag, truthySchema } from './config.utils'

const baseFeatureSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('production'),
CI: flag(truthySchema.default('false')),
SERVER_HOST: z.string().default('localhost'),
SERVER_PORT: z.string().transform(Number).default('0'),
APP_VERSION: z.string().default('unknown'),
DB_URL: z.string().url(),
PROJECTS_ROOT_DIR: z.string().default(''),
}).transform((raw) => {
return {
isTest: raw.NODE_ENV === 'test',
isDev: raw.NODE_ENV === 'development',
isCI: raw.CI,
isProd: raw.NODE_ENV === 'production',
serverHost: raw.SERVER_HOST,
serverPort: raw.SERVER_PORT,
appVersion: raw.NODE_ENV === 'production' ? raw.APP_VERSION : 'dev',
dbUrl: raw.DB_URL,
projectsRootDir: raw.PROJECTS_ROOT_DIR,
}
})

export type BaseConfig = z.infer<typeof baseFeatureSchema>

export const baseConfigFactory = registerAs('base', () => baseFeatureSchema.parse(process.env))
8 changes: 8 additions & 0 deletions apps/server-nestjs/src/config/config-testing.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { vi } from 'vitest'

// Stub every env key a config reads as undefined (removed) so the test
// runner's real environment cannot leak into a config parse. Tests then
// stub only the keys they exercise. Pair with vi.unstubAllEnvs() in afterEach.
export function resetEnvs(keys: readonly string[]): void {
for (const key of keys) vi.stubEnv(key, undefined)
}
38 changes: 38 additions & 0 deletions apps/server-nestjs/src/config/config.utils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import z from 'zod'
import { csv, flag, truthySchema } from './config.utils'

describe('config.utils', () => {
describe('flag', () => {
it('coerces true/1 (case-insensitive) to true', () => {
const schema = flag(truthySchema.default('false'))
expect(schema.parse('true')).toBe(true)
expect(schema.parse('1')).toBe(true)
expect(schema.parse('TRUE')).toBe(true)
})

it('coerces false/0 to false', () => {
const schema = flag(truthySchema.default('true'))
expect(schema.parse('false')).toBe(false)
expect(schema.parse('0')).toBe(false)
})

it('falls back to the schema default when missing', () => {
expect(flag(truthySchema.default('false')).parse(undefined)).toBe(false)
expect(flag(truthySchema.default('true')).parse(undefined)).toBe(true)
})
})

describe('csv', () => {
const schema = csv(z.string())

it('splits, trims and drops empty parts', () => {
expect(schema.parse('a, b ,,c')).toEqual(['a', 'b', 'c'])
})

it('maps missing/empty to an empty array', () => {
expect(schema.parse(undefined)).toEqual([])
expect(schema.parse('')).toEqual([])
})
})
})
94 changes: 94 additions & 0 deletions apps/server-nestjs/src/config/config.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import z from 'zod'

// Harbor retention triggers use 6-field (seconds) Quartz cron expressions.
// Stdlib-only validation, expressed as a Zod schema (no external dependency).
// Months (field 4) and days-of-week (field 5) accept Quartz name tokens too.
const MONTH_NAMES = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'] as const
const DOW_NAMES = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'] as const

const CRON_FIELD_BOUNDS: ReadonlyArray<readonly [number, number]> = [
[0, 59], // seconds
[0, 59], // minutes
[0, 23], // hours
[1, 31], // day of month
[1, 12], // month
[0, 7], // day of week (0 and 7 = Sunday)
]

function isNameToken(token: string, names: ReadonlyArray<string>): boolean {
return names.includes(token.toUpperCase())
}

function isNameItem(item: string, field: number): boolean {
if (field !== 4 && field !== 5) return false
const names = field === 4 ? MONTH_NAMES : DOW_NAMES
if (isNameToken(item, names)) return true
if (item.includes('-')) {
const [lo, hi] = item.split('-')
return isNameToken(lo, names) && isNameToken(hi, names)
}
return false
}

function isRangeOrNumber(token: string, min: number, max: number): boolean {
if (token.includes('-')) {
const [lo, hi] = token.split('-').map(Number)
return lo >= min && hi <= max && lo <= hi
}
const n = Number(token)
return Number.isInteger(n) && n >= min && n <= max
}

function isCronItem(item: string, field: number): boolean {
if (item === '*' || item === '?') return true
if (isNameItem(item, field)) return true
const [min, max] = CRON_FIELD_BOUNDS[field]
const step = /^(.+)\/(\d+)$/.exec(item)
if (step) {
if (Number(step[2]) < 1) return false
const base = step[1]
if (base === '*' || base === '?') return true
if (isNameItem(base, field)) return true
return isRangeOrNumber(base, min, max)
}
return isRangeOrNumber(item, min, max)
}

export const cronSchema = z
.string()
.superRefine((value, ctx) => {
const fields = value.trim().split(/\s+/)
if (fields.length !== 6) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'cron must have 6 fields (seconds required)' })
return
}
if (!fields.every((field, i) => field.split(',').every(part => isCronItem(part, i)))) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'invalid cron expression' })
}
})

// Shared truthy enum for flag(): 'true'/'false'/'1'/'0'.
export const truthySchema = z.enum(['true', 'false', '1', '0'])

// Boolean flag. Strictly takes a z string schema (e.g. truthySchema.default('true')).
// Lowercases, then coerces 'true'/'1' -> true, 'false'/'0' -> false.
// Missing value falls back to the schema's default.
export function flag(schema: z.ZodType<string, z.ZodTypeDef, unknown>) {
return z
.preprocess(
val => (typeof val === 'string' ? val.toLowerCase() : val),
schema,
)
.transform(val => val === 'true' || val === '1')
}

// Comma-separated string -> array of schema-validated, trimmed non-empty parts.
// Empty/missing -> []. Strictly takes a z string schema (applied per element).
export function csv<T extends z.ZodType<string, z.ZodTypeDef, unknown>>(schema: T) {
return z
.preprocess(
val => (typeof val === 'string' ? val : val ?? ''),
z.string().transform(value => value.split(',').map(part => part.trim()).filter(Boolean)),
)
.pipe(z.array(schema))
}
39 changes: 39 additions & 0 deletions apps/server-nestjs/src/config/gitlab.config.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { resetEnvs } from './config-testing.utils'
import { gitlabConfigFactory } from './gitlab.config'

describe('gitlabConfig', () => {
beforeEach(() => { resetEnvs(['GITLAB_TOKEN', 'GITLAB_URL', 'GITLAB_INTERNAL_URL', 'GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS', 'GITLAB_MIRROR_TOKEN_ROTATION_THRESHOLD_DAYS', 'GITLAB__SECRET_EXPOSE_INTERNAL_URL', 'PROJECTS_ROOT_DIR']) })
afterEach(() => { vi.unstubAllEnvs() })

it('parses a full config', () => {
vi.stubEnv('GITLAB_TOKEN', 'token')
vi.stubEnv('GITLAB_URL', 'https://gitlab.internal')
vi.stubEnv('GITLAB_INTERNAL_URL', 'https://gitlab.internal:8080')
vi.stubEnv('PROJECTS_ROOT_DIR', 'forge-test/projects')
vi.stubEnv('GITLAB__SECRET_EXPOSE_INTERNAL_URL', '1')
expect(gitlabConfigFactory()).toMatchObject({
token: 'token',
url: 'https://gitlab.internal',
internalUrl: 'https://gitlab.internal:8080',
secretExposeInternalUrl: true,
mirrorTokenExpirationDays: 180,
mirrorTokenRotationThresholdDays: 90,
projectRootDir: 'forge-test/projects',
})
})

it('falls back to public url when internal url is absent', () => {
vi.stubEnv('GITLAB_TOKEN', 'token')
vi.stubEnv('GITLAB_URL', 'https://gitlab.internal')
vi.stubEnv('PROJECTS_ROOT_DIR', 'forge-test/projects')
vi.stubEnv('GITLAB__SECRET_EXPOSE_INTERNAL_URL', '1')
const cfg = gitlabConfigFactory()
expect(cfg.internalUrl).toBeUndefined()
expect(cfg.url).toBe('https://gitlab.internal')
})

it('throws when a required var is missing', () => {
expect(() => gitlabConfigFactory()).toThrow()
})
})
Loading
Loading