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
4 changes: 4 additions & 0 deletions apps/server-nestjs/.env.integ-example
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ HARBOR_ADMIN_PASSWORD=
HARBOR_URL=
# URL interne de l'API Harbor
HARBOR_INTERNAL_URL=
# Durée de validité (en jours) des robots Harbor
HARBOR_ROBOT_EXPIRATION_DAYS=90
# Seuil (en jours) de rotation préventive des secrets, strictement inférieur à HARBOR_ROBOT_EXPIRATION_DAYS
HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS=60

# --- Keycloak (admin) ---
# Utilisateur admin Keycloak pour la gestion des royaumes/clients
Expand Down
27 changes: 25 additions & 2 deletions apps/server-nestjs/src/config/harbor.config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { resetEnvs } from './config-testing.utils'
import { harborConfigFactory } from './harbor.config'

describe('harborConfig', () => {
beforeEach(() => { resetEnvs(['HARBOR_URL', 'HARBOR_INTERNAL_URL', 'HARBOR_ADMIN', 'HARBOR_ADMIN_PASSWORD', 'HARBOR_RULE_TEMPLATE', 'HARBOR_RULE_COUNT', 'HARBOR_RETENTION_CRON', 'HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS', 'HARBOR_PROJECT_SLUG_CACHE_TTL_MS']) })
beforeEach(() => { resetEnvs(['HARBOR_URL', 'HARBOR_INTERNAL_URL', 'HARBOR_ADMIN', 'HARBOR_ADMIN_PASSWORD', 'HARBOR_RULE_TEMPLATE', 'HARBOR_RULE_COUNT', 'HARBOR_RETENTION_CRON', 'HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS', 'HARBOR_ROBOT_EXPIRATION_DAYS', 'HARBOR_PROJECT_SLUG_CACHE_TTL_MS']) })
afterEach(() => { vi.unstubAllEnvs() })

it('parses a full config', () => {
Expand All @@ -13,6 +13,8 @@ describe('harborConfig', () => {
vi.stubEnv('HARBOR_ADMIN_PASSWORD', 'pw')
vi.stubEnv('HARBOR_RULE_TEMPLATE', 'always')
vi.stubEnv('HARBOR_RULE_COUNT', '3')
vi.stubEnv('HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS', '30')
vi.stubEnv('HARBOR_ROBOT_EXPIRATION_DAYS', '60')
expect(harborConfigFactory()).toMatchObject({
url: 'https://harbor.internal',
internalUrl: 'https://harbor.internal:8080',
Expand All @@ -21,7 +23,8 @@ describe('harborConfig', () => {
ruleTemplate: 'always',
ruleCount: 3,
retentionCron: '0 22 2 * * *',
robotRotationThresholdDays: 90,
robotRotationThresholdDays: 30,
robotExpirationDays: 60,
projectSlugCacheTtlMs: 300_000,
})
})
Expand All @@ -45,6 +48,26 @@ describe('harborConfig', () => {
expect(cfg.ruleTemplate).toBeUndefined()
expect(cfg.ruleCount).toBeUndefined()
expect(cfg.retentionCron).toBe('0 22 2 * * *')
expect(cfg.robotRotationThresholdDays).toBe(60)
expect(cfg.robotExpirationDays).toBe(90)
})

it('throws when rotation threshold is not strictly lower than expiration', () => {
vi.stubEnv('HARBOR_URL', 'https://harbor.internal')
vi.stubEnv('HARBOR_ADMIN', 'admin')
vi.stubEnv('HARBOR_ADMIN_PASSWORD', 'pw')
vi.stubEnv('HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS', '180')
vi.stubEnv('HARBOR_ROBOT_EXPIRATION_DAYS', '180')
expect(() => harborConfigFactory()).toThrow(/must be strictly lower than HARBOR_ROBOT_EXPIRATION_DAYS/)
})

it('throws when rotation threshold exceeds expiration', () => {
vi.stubEnv('HARBOR_URL', 'https://harbor.internal')
vi.stubEnv('HARBOR_ADMIN', 'admin')
vi.stubEnv('HARBOR_ADMIN_PASSWORD', 'pw')
vi.stubEnv('HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS', '365')
vi.stubEnv('HARBOR_ROBOT_EXPIRATION_DAYS', '90')
expect(() => harborConfigFactory()).toThrow()
})

it('throws when a required var is missing', () => {
Expand Down
13 changes: 12 additions & 1 deletion apps/server-nestjs/src/config/harbor.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,18 @@ const harborFeatureSchema = z.object({
HARBOR_RULE_TEMPLATE: ruleTemplateSchema.optional(),
HARBOR_RULE_COUNT: z.coerce.number().int().positive().optional(),
HARBOR_RETENTION_CRON: cronSchema.default('0 22 2 * * *'),
HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS: z.coerce.number().int().positive().default(90),
HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS: z.coerce.number().int().positive().default(60),
HARBOR_ROBOT_EXPIRATION_DAYS: z.coerce.number().int().positive().default(90),
HARBOR_PROJECT_SLUG_CACHE_TTL_MS: z.coerce.number().int().positive().default(300_000),
}).superRefine((raw, ctx) => {
// Rotation must trigger strictly before expiration, otherwise robots die
// before any secret regeneration had a chance to replace them.
if (raw.HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS >= raw.HARBOR_ROBOT_EXPIRATION_DAYS) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS (${raw.HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS}) must be strictly lower than HARBOR_ROBOT_EXPIRATION_DAYS (${raw.HARBOR_ROBOT_EXPIRATION_DAYS})`,
})
}
}).transform(raw => ({
url: raw.HARBOR_URL,
internalUrl: raw.HARBOR_INTERNAL_URL,
Expand All @@ -31,6 +41,7 @@ const harborFeatureSchema = z.object({
ruleCount: raw.HARBOR_RULE_COUNT,
retentionCron: raw.HARBOR_RETENTION_CRON,
robotRotationThresholdDays: raw.HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS,
robotExpirationDays: raw.HARBOR_ROBOT_EXPIRATION_DAYS,
projectSlugCacheTtlMs: raw.HARBOR_PROJECT_SLUG_CACHE_TTL_MS,
}))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ describe('registryService', () => {
ruleCount: 10,
retentionCron: '0 22 2 * * *',
robotRotationThresholdDays: 90,
robotExpirationDays: 90,
})
baseConfig = mockDeep<ConfigType<typeof baseConfigFactory>>({
projectsRootDir: 'forge',
Expand Down Expand Up @@ -274,6 +275,35 @@ describe('registryService', () => {
}), `forge/${project.slug}/REGISTRY/ro-robot`)
})

it('creates robots with the configured expiration duration', async () => {
const project = makeProjectWithDetails()
vault.read.mockImplementation(async () => makeVaultSecret({
data: {
HOST: 'other.example',
DOCKER_CONFIG: '{}',
USERNAME: `robot$${project.slug}+robot`,
TOKEN: 'old',
},
}))

client.getProjectRobots.mockImplementation(async function* () {
yield { id: 11, name: `robot$${project.slug}+ro-robot` }
})
client.deleteRobot.mockResolvedValue(makeNoContent())
client.ensureRobot.mockResolvedValue({ id: 22, name: `robot$${project.slug}+ro-robot`, secret: 'newsecret' })

await service.handleUpsert(project)

expect(client.ensureRobot).toHaveBeenCalledWith(expect.objectContaining({
name: 'ro-robot',
duration: 90,
}))
expect(client.ensureRobot).toHaveBeenCalledWith(expect.objectContaining({
name: 'rw-robot',
duration: 90,
}))
})

it('parses plugin config and enables project robot publishing', async () => {
const project = makeProjectWithDetails({
plugins: [
Expand Down
6 changes: 3 additions & 3 deletions apps/server-nestjs/src/modules/registry/registry.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export class RegistryService {

private async ensureProjectRobot(project: ProjectWithDetails, robotName: string, access: HarborAccess[]) {
const created = await this.client.ensureRobot(
generateRobotPermissions(project, robotName, access),
generateRobotPermissions(project, robotName, access, this.harborConfig.robotExpirationDays),
)
if (!created) {
throw new Error(`Harbor robot already exists (${robotName})`)
Expand Down Expand Up @@ -435,10 +435,10 @@ function generateRobotFullName(project: ProjectWithDetails, robotName: string) {
return `robot$${project.slug}+${robotName}`
}

function generateRobotPermissions(project: ProjectWithDetails, robotName: string, access: HarborAccess[]): HarborRobotCreateRequest {
function generateRobotPermissions(project: ProjectWithDetails, robotName: string, access: HarborAccess[], durationDays: number): HarborRobotCreateRequest {
return {
name: robotName,
duration: -1,
duration: durationDays,
description: 'robot for ci builds',
disable: false,
level: 'project',
Expand Down