• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

inclusion-numerique / coop-mediation-numerique / 47b77ff3-bc71-4025-a464-77250a49db92

29 Jan 2026 09:49AM UTC coverage: 10.721% (+3.3%) from 7.44%
47b77ff3-bc71-4025-a464-77250a49db92

push

circleci

web-flow
Merge pull request #398 from inclusion-numerique/dev

release

628 of 9623 branches covered (6.53%)

Branch coverage included in aggregate %.

73 of 118 new or added lines in 12 files covered. (61.86%)

786 existing lines in 84 files now uncovered.

1991 of 14805 relevant lines covered (13.45%)

1.86 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/packages/cdk/src/ProjectStack.ts
1
import { environmentVariablesFromList } from '@app/cdk/environmentVariable'
2
import { ProjectCdkOutput } from '@app/cdk/getCdkOutput'
3
import { MaildevInstance } from '@app/cdk/MaildevInstance'
4
import { createOutput } from '@app/cdk/output'
5
import { terraformBackend } from '@app/cdk/terraformBackend'
6
import {
7
  chromaticAppId,
8
  cockpitGrafanaEditors,
9
  cockpitGrafanaViewers,
10
  containerNamespaceName,
11
  databaseInstanceName,
12
  mainDomain,
13
  mainRootDomain,
14
  mainSubdomain,
15
  nextTelemetryDisabled,
16
  previewDomain,
17
  previewRootDomain,
18
  previewSubdomain,
19
  region,
20
  sentryOrg,
21
  sentryProject,
22
  sentryUrl,
23
  smtpPort,
24
} from '@app/config/config'
25
import { Cockpit } from '@app/scaleway/cockpit'
26
import { CockpitGrafanaUser } from '@app/scaleway/cockpit-grafana-user'
27
import { CockpitToken } from '@app/scaleway/cockpit-token'
28
import { ContainerNamespace } from '@app/scaleway/container-namespace'
29
import { DataScalewayDomainZone } from '@app/scaleway/data-scaleway-domain-zone'
30
import { DomainRecord } from '@app/scaleway/domain-record'
31
import { ObjectBucket } from '@app/scaleway/object-bucket'
32
import { ScalewayProvider } from '@app/scaleway/provider'
33
import { RdbInstance } from '@app/scaleway/rdb-instance'
34
import { RegistryNamespace } from '@app/scaleway/registry-namespace'
35
import { Secret } from '@app/scaleway/secret'
36
import { SecretVersion } from '@app/scaleway/secret-version'
37
import { TemDomain } from '@app/scaleway/tem-domain'
38
import { TerraformStack } from 'cdktf'
39
import { Construct } from 'constructs'
40

41
export const projectStackVariables = [
×
42
  'SCW_DEFAULT_ORGANIZATION_ID',
43
  'SCW_PROJECT_ID',
44
  'EMAIL_FROM_DOMAIN',
45
  'UPLOADS_BUCKET',
46
  'BACKUPS_BUCKET',
47
  'WEB_APP_DOCKER_REGISTRY_NAME',
48
  'S3_HOST',
49
] as const
50

51
export const projectStackSensitiveVariables = [
×
52
  'NEXTAUTH_SECRET',
53
  'SCW_ACCESS_KEY',
54
  'SCW_SECRET_KEY',
55
  'SENTRY_AUTH_TOKEN',
56
  'SMTP_MAILDEV_USERNAME',
57
  'SMTP_MAILDEV_PASSWORD',
58
] as const
59

60
/**
61
 * This stack represents the resources shared by other project stacks
62
 * It aims to be deployed only once, and used by other stacks
63
 */
64
export class ProjectStack extends TerraformStack {
65
  constructor(scope: Construct) {
66
    super(scope, 'project')
×
67

68
    // ⚠️ When calling this function, do not forget to update typings in src/getCdkOutput.ts
69
    const output = createOutput<ProjectCdkOutput>(this)
×
70

71
    const environmentVariables = environmentVariablesFromList(
×
72
      this,
73
      projectStackVariables,
74
      { sensitive: false },
75
    )
76

77
    const sensitiveEnvironmentVariables = environmentVariablesFromList(
×
78
      this,
79
      projectStackSensitiveVariables,
80
      { sensitive: true },
81
    )
82

83
    // Configuring provider that will be used for the rest of the stack
84
    new ScalewayProvider(this, 'provider', {
×
85
      region,
86
      accessKey: sensitiveEnvironmentVariables.SCW_ACCESS_KEY.value,
87
      secretKey: sensitiveEnvironmentVariables.SCW_SECRET_KEY.value,
88
      organizationId: environmentVariables.SCW_DEFAULT_ORGANIZATION_ID.value,
89
      projectId: environmentVariables.SCW_PROJECT_ID.value,
90
    })
91

92
    terraformBackend(this, 'project')
×
93

94
    const mainDomainZone = new DataScalewayDomainZone(this, 'mainDomainZone', {
×
95
      domain: mainRootDomain,
96
      subdomain: mainSubdomain,
97
    })
98

99
    const previewDomainZone =
100
      mainDomain === previewDomain
×
101
        ? mainDomainZone
102
        : new DataScalewayDomainZone(this, 'previewDomainZone', {
103
            domain: previewRootDomain,
104
            subdomain: previewSubdomain,
105
          })
106

107
    // If email domain differ, create different zone
108
    const emailDomainZone = mainDomainZone
×
109

110
    const transactionalEmailDomain = new TemDomain(
×
111
      this,
112
      'transactionalEmailDomain',
113
      {
114
        acceptTos: true,
115
        name: environmentVariables.EMAIL_FROM_DOMAIN.value,
116
      },
117
    )
118

119
    // Uploads bucket for usage in integration testing and dev environments
120
    new ObjectBucket(this, 'devUploads', {
×
121
      name: environmentVariables.UPLOADS_BUCKET.value,
122
      corsRule: [
123
        {
124
          allowedHeaders: ['*'],
125
          allowedMethods: ['GET', 'HEAD', 'POST', 'PUT', 'DELETE'],
126
          maxAgeSeconds: 3000,
127
          exposeHeaders: ['Etag'],
128
          allowedOrigins: ['http://localhost:3000', 'http://localhost'],
129
        },
130
      ],
131
    })
132

133
    // Backups bucket for database dumps or other important backups
134
    new ObjectBucket(this, 'backupsBucket', {
×
135
      name: environmentVariables.BACKUPS_BUCKET.value,
136
      corsRule: [
137
        {
138
          allowedHeaders: ['*'],
139
          allowedMethods: ['GET', 'HEAD', 'POST', 'PUT', 'DELETE'],
140
          maxAgeSeconds: 3000,
141
          exposeHeaders: ['Etag'],
142
          allowedOrigins: ['http://localhost:3000', 'http://localhost'],
143
        },
144
      ],
145
    })
146

147
    // https://registry.terraform.io/providers/scaleway/scaleway/latest/docs/resources/rdb_instance
148
    const database = new RdbInstance(this, 'database', {
×
149
      name: databaseInstanceName,
150
      engine: 'PostgreSQL-14',
151
      isHaCluster: true,
152
      // nodeType: 'db-pro2-xs', // todo: go back to xs when september performance issues are fixed
153
      nodeType: 'db-pro2-s',
154
      disableBackup: false,
155
      backupSameRegion: false,
156
      backupScheduleFrequency: 24,
157
      backupScheduleRetention: 14,
158
      volumeType: 'sbs_15k',
159
      volumeSizeInGb: 60,
160
      encryptionAtRest: true,
161
      settings: {
162
        // Custom max connections
163
        max_connections: '500',
164

165
        // Default values (if we define settings the are deleted)
166
        effective_cache_size: '2700',
167
        maintenance_work_mem: '300',
168
        max_parallel_workers: '1',
169
        max_parallel_workers_per_gather: '0',
170
        work_mem: '8',
171
      },
172
    })
173

174
    const cockpit = new Cockpit(this, 'cockpit', {})
×
175
    const cockpitEndpoints = cockpit.endpoints.get(0)
×
176

177
    // https://registry.terraform.io/providers/scaleway/scaleway/latest/docs/resources/cockpit_grafana_user
178

179
    for (const [index, login] of cockpitGrafanaEditors.entries()) {
×
180
      const user = new CockpitGrafanaUser(this, `grafanaEditor${index}`, {
×
181
        role: 'editor',
182
        login,
183
      })
184

185
      output(
×
186
        `grafanaEditorPassword_${index}` as keyof ProjectCdkOutput,
187
        user.password,
188
        'sensitive',
189
      )
190
    }
191

192
    for (const [index, login] of cockpitGrafanaViewers.entries()) {
×
193
      const user = new CockpitGrafanaUser(this, `grafanaViewer${index}`, {
×
194
        role: 'viewer',
195
        login,
196
      })
197
      output(
×
198
        `grafanaViewerPassword_${index}` as keyof ProjectCdkOutput,
199
        user.password,
200
        'sensitive',
201
      )
202
    }
203

204
    // Create cockpit token for web app containers
205
    // https://registry.terraform.io/providers/scaleway/scaleway/latest/docs/resources/cockpit_token
206
    const cockpitToken = new CockpitToken(this, 'cockpitWebToken', {
×
207
      name: 'web-app',
208
      scopes: {
209
        queryLogs: true,
210
        queryMetrics: true,
211
        setupAlerts: false,
212
        setupLogsRules: false,
213
        setupMetricsRules: false,
214
        writeLogs: true,
215
        writeMetrics: true,
216
      },
217
    })
218

219
    // Database secrets
220
    const databaseInstanceIdSecret = new Secret(this, 'databaseInstanceId', {
×
221
      name: 'DATABASE_INSTANCE_ID',
222
      description:
223
        'Instance id of managed database instance. Used for api interactions.',
224
    })
225

226
    new SecretVersion(this, 'databaseInstanceIdVersion', {
×
227
      secretId: databaseInstanceIdSecret.id,
228
      data: database.id,
229
    })
230

231
    const maildev = new MaildevInstance(this, 'maildev', {
×
232
      username: sensitiveEnvironmentVariables.SMTP_MAILDEV_USERNAME.value,
233
      password: sensitiveEnvironmentVariables.SMTP_MAILDEV_PASSWORD.value,
234
    })
235
    const maildevSmtp = maildev.getMaildevSmtp()
×
236
    const publicIpAddress = maildev.getPublicIpAddress()
×
237

238
    const webContainers = new ContainerNamespace(this, 'webContainers', {
×
239
      name: containerNamespaceName,
240
      description: 'Web application containers',
241
      environmentVariables: {
242
        CHROMATIC_APP_ID: chromaticAppId,
243
        NEXT_TELEMETRY_DISABLED: nextTelemetryDisabled,
244
        SENTRY_ORG: sentryOrg,
245
        SENTRY_PROJECT: sentryProject,
246
        SENTRY_URL: sentryUrl,
247
        COCKPIT_METRICS_URL: cockpitEndpoints.metricsUrl,
248
        COCKPIT_LOGS_URL: cockpitEndpoints.logsUrl,
249
        COCKPIT_ALERT_MANAGER_URL: cockpitEndpoints.alertmanagerUrl,
250
        COCKPIT_GRAFANA_URL: cockpitEndpoints.grafanaUrl,
251
        SMTP_PORT: smtpPort,
252
        SCW_DEFAULT_REGION: region,
253
        AWS_DEFAULT_REGION: region,
254
        S3_HOST: environmentVariables.S3_HOST.value,
255
        NODE_ENV: 'production',
256
        TZ: 'utc',
257
        DATABASE_INSTANCE_ID: database.id,
258
      },
259
      secretEnvironmentVariables: {
260
        COCKPIT_TOKEN: cockpitToken.secretKey,
261
        NEXTAUTH_SECRET: sensitiveEnvironmentVariables.NEXTAUTH_SECRET.value,
262
        SCW_ACCESS_KEY: sensitiveEnvironmentVariables.SCW_ACCESS_KEY.value,
263
        SCW_SECRET_KEY: sensitiveEnvironmentVariables.SCW_SECRET_KEY.value,
264
        AWS_ACCESS_KEY_ID: sensitiveEnvironmentVariables.SCW_ACCESS_KEY.value,
265
        AWS_SECRET_ACCESS_KEY:
266
          sensitiveEnvironmentVariables.SCW_SECRET_KEY.value,
267
        SENTRY_AUTH_TOKEN:
268
          sensitiveEnvironmentVariables.SENTRY_AUTH_TOKEN.value,
269
      },
270
    })
271

272
    new RegistryNamespace(this, 'webApp', {
×
273
      name: environmentVariables.WEB_APP_DOCKER_REGISTRY_NAME.value,
274
      description: 'Built Web App docker images, ready to use in containers',
275
    })
276

277
    // Main domain DNS Records
278
    new DomainRecord(this, 'main_ns0', {
×
279
      dnsZone: mainDomainZone.id,
280
      type: 'NS',
281
      name: '',
282
      data: 'ns0.dom.scw.cloud.',
283
      ttl: 1800,
284
    })
285

286
    new DomainRecord(this, 'main_ns1', {
×
287
      dnsZone: mainDomainZone.id,
288
      type: 'NS',
289
      name: '',
290
      data: 'ns1.dom.scw.cloud.',
291
      ttl: 1800,
292
    })
293

294
    // Preview domain DNS Records
295
    if (previewDomain !== mainDomain) {
×
296
      new DomainRecord(this, 'preview_ns0', {
×
297
        dnsZone: previewDomainZone.id,
298
        type: 'NS',
299
        name: '',
300
        data: 'ns0.dom.scw.cloud.',
301
        ttl: 1800,
302
      })
303
      new DomainRecord(this, 'preview_ns1', {
×
304
        dnsZone: previewDomainZone.id,
305
        type: 'NS',
306
        name: '',
307
        data: 'ns1.dom.scw.cloud.',
308
        ttl: 1800,
309
      })
310
    }
311

312
    // Email domain DNS Records
313
    new DomainRecord(this, 'spf', {
×
314
      dnsZone: emailDomainZone.id,
315
      type: 'TXT',
316
      name: '',
317
      data: `v=spf1 include:_spf.ox.numerique.gouv.fr ${transactionalEmailDomain.spfConfig} -all`,
318
      ttl: 3600,
319
    })
320

321
    // MX record for email reception
322
    new DomainRecord(this, 'mx', {
×
323
      dnsZone: emailDomainZone.id,
324
      type: 'MX',
325
      name: '',
326
      data: '1 mx.ox.numerique.gouv.fr.',
327
      ttl: 3600,
328
    })
329

330
    // Transactional email DKIM
331
    new DomainRecord(this, 'dkim', {
×
332
      dnsZone: emailDomainZone.id,
333
      type: 'TXT',
334
      name: `${transactionalEmailDomain.projectId}._domainkey`,
335
      data: transactionalEmailDomain.dkimConfig,
336
      ttl: 3600,
337
    })
338

339
    // OX email DKIM
340
    new DomainRecord(this, 'ox_dkim', {
×
341
      dnsZone: emailDomainZone.id,
342
      type: 'TXT',
343
      name: 'DIMAIL._DOMAINKEY',
344
      data: 'v=DKIM1; h=sha256; k=rsa; p=MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAyuql/9+4fmFtkKbJTA7vBLGoMroWmvsj2eZvzvcKP+msDyRaSW0wRXb5SJVh+LL6N6NrjdemCt6KXZaU2UO0nMyZRepPQBkbzu5bYDUtf0FTRpab5Ii8nygrYw1PjqHqvcQOWBLHCzKTmB7PsCNnqmxOTeNziKBQp7FW4y/XavdOwDi3WB3Fz7PRXmrHAqEQPlt/W9TYIlNp4+rvRxzSErMkt4Z525r5aNCo8OfsET3avIG5E4rTWiS1Pat0ceOzEkOBROYxE1w9voSs5tVTanUq6TEbnB1SlY2hoO8BknSiSISn0vfNSUUoSv84nqb6gaCL9ByqZKxgvjWEf7TCnizDI7hZs0PgUBJnJaFSD79Bl4iv7x5292U0hnvtFXUuFwd0/PllHc3/40DWBL9cYN98VbkfhghO76NUN5LogPCnWyiPKnjYzBeV0AMvv45kYn/2yvUhlZNI4r7lETO+Q3+dVKdIdoHjSLDK0u9gmV9XNG/MIvi5dLMgqBvVH1OGDLcNJZv/+f4SBCgFk2Vc4BvaoUfz24Ii/HwkYNEwmo86rFNrHFCT+mWR7YpIe1uNpMiOJ8vrASrxAs5Y6Cp7SDw4SK6zPxY+A6ePpQDbXnAsGpFZKDAGPuD5HJWLZYALImt8+JLzmx5FCX/1YvolaywXqKhlN1MfL6DjAFyrMSUCAwEAAQ==',
345
      ttl: 3600,
346
    })
347

348
    // OX email CNAME records
349
    new DomainRecord(this, 'ox_imap', {
×
350
      dnsZone: emailDomainZone.id,
351
      type: 'CNAME',
352
      name: 'imap',
353
      data: 'imap.ox.numerique.gouv.fr.',
354
      ttl: 3600,
355
    })
356

357
    new DomainRecord(this, 'ox_smtp', {
×
358
      dnsZone: emailDomainZone.id,
359
      type: 'CNAME',
360
      name: 'smtp',
361
      data: 'smtp.ox.numerique.gouv.fr.',
362
      ttl: 3600,
363
    })
364

365
    new DomainRecord(this, 'ox_webmail', {
×
366
      dnsZone: emailDomainZone.id,
367
      type: 'CNAME',
368
      name: 'webmail',
369
      data: 'webmail.ox.numerique.gouv.fr.',
370
      ttl: 3600,
371
    })
372

373
    // Brevo records
374
    new DomainRecord(this, 'brevo_code', {
×
375
      dnsZone: emailDomainZone.id,
376
      type: 'TXT',
377
      name: '',
378
      data: 'brevo-code:8ac99f620a9cf5718a8f484756b0d148',
379
      ttl: 3600,
380
    })
381

382
    new DomainRecord(this, 'brevo_dkim1', {
×
383
      dnsZone: emailDomainZone.id,
384
      type: 'CNAME',
385
      name: 'brevo1._domainkey',
386
      data: 'b1.coop-numerique-anct-gouv-fr.dkim.brevo.com.',
387
      ttl: 3600,
388
    })
389

390
    new DomainRecord(this, 'brevo_dkim2', {
×
391
      dnsZone: emailDomainZone.id,
392
      type: 'CNAME',
393
      name: 'brevo2._domainkey',
394
      data: 'b2.coop-numerique-anct-gouv-fr.dkim.brevo.com.',
395
      ttl: 3600,
396
    })
397

398
    new DomainRecord(this, 'brevo_dmarc', {
×
399
      dnsZone: emailDomainZone.id,
400
      type: 'TXT',
401
      name: '_dmarc',
402
      data: 'v=DMARC1; p=none; rua=mailto:rua@dmarc.brevo.com',
403
      ttl: 3600,
404
    })
405

406
    new DomainRecord(this, 'maildevDns', {
×
407
      dnsZone: mainDomainZone.id,
408
      type: 'A',
409
      name: 'maildev',
410
      data: publicIpAddress,
411
      ttl: 3600,
412
    })
413

414
    // Mattermost subdomain
NEW
415
    new DomainRecord(this, 'mattermostCname', {
×
416
      dnsZone: mainDomainZone.id,
417
      type: 'CNAME',
418
      name: 'discussion',
419
      data: 'domain.par.clever-cloud.com.',
420
      ttl: 3600,
421
    })
422

423
    output('cockpitId', cockpit.id)
×
424
    output('mainDomainZoneId', mainDomainZone.id)
×
425
    output('transactionalEmailDomainStatus', transactionalEmailDomain.status)
×
426
    output('webContainersId', webContainers.id)
×
427
    output('databaseInstanceId', database.id)
×
428
    output('databaseEndpointIp', database.endpointIp)
×
429
    output('databaseEndpointPort', database.endpointPort)
×
430
    output('maildevSmtp', maildevSmtp)
×
431
  }
432
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc